From 1fc9ef7bb614c30daadcf55ff8eb067e2b65527d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 12:34:48 +0200 Subject: [PATCH 01/68] fix(composer): declare ext-xsl so dependabot can resolve (#3201) Dependabot cannot propose composer updates for this app. Its updater fails with: Your requirements could not be resolved to an installable set of packages. - edgedesign/phpqa[v1.27.0, ..., v1.27.2] require ext-xsl * -> it is missing from your system. config.platform pins php 8.3 so composer resolves against a known PHP version, but says nothing about extensions. edgedesign/phpqa requires ext-xsl and the resolving environment does not have it, so the resolve fails before any bump can be computed. CI is unaffected, which is why this went unnoticed: composer install replays the committed lock and never re-resolves, so the pipeline stays green while dependabot -- which does re-resolve -- fails every time. The shared quality.yml installs no xsl extension anywhere and never invokes phpqa. Declaring ext-xsl beside the php pin makes resolution assume exactly what the committed lockfile already assumes. Verified in a clean composer:2 container without --ignore-platform-reqs: the same file fails without this line and resolves (103 installs, lock written) with it. Co-authored-by: Conduction Release Bot --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d1c459351f..fd4058e003 100644 --- a/composer.json +++ b/composer.json @@ -159,7 +159,8 @@ "optimize-autoloader": true, "sort-packages": true, "platform": { - "php": "8.3" + "php": "8.3", + "ext-xsl": "1" } }, "extra": { From 8fa961894f5547c374480a40b4107083aaf11232 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 13:21:05 +0200 Subject: [PATCH 02/68] chore(deps): take @conduction/nextcloud-vue 2.27.2 (#3214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Levels this app with the fleet. 2.27.2 adds two fixes the earlier 2.27.0 pin does not carry: * headerless is no longer chromeless — a flat KPI card in a borderless wrapper had no card, border or background at all; * a stat `variant` paints from the `-text` tokens rather than the fill tokens, which failed WCAG AA at 1.08:1 as a foreground colour. The KPI colour cleanup converted hardcoded values to `variant`, so this is what makes those conversions contrast-safe. Lockfile only, and npm pruned nothing. --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6967e9cf5..6ba7e4fc24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2289,9 +2289,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.27.0.tgz", - "integrity": "sha512-64IS6VrAp4c+Hr9AAS9/z6AzznmaPfbO4GrZtmOX57dLCpJORkn5M/wSKpAC6ZG+gUHTJeXDUiabut9bV0kRnQ==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.27.2.tgz", + "integrity": "sha512-FhDF3FvM+ee0Jx7z70Lki7o7Mm4DeX/GsOqHo33hOcdylzXR63UXLRbxKMedodEiIqgOFUAI6GTox735iyL8Dw==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", From 36809e16fa8e276691750feb8ec79d69a9115f25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:55:20 +0200 Subject: [PATCH 03/68] chore(deps): bump zod from 4.4.3 to 4.5.2 (#3206) Bumps [zod](https://github.com/colinhacks/zod) from 4.4.3 to 4.5.2. - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.4.3...v4.5.2) --- updated-dependencies: - dependency-name: zod dependency-version: 4.5.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 87 +++++++++-------------------------------------- package.json | 2 +- 2 files changed, 18 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ba7e4fc24..9c8d710dab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "vue-material-design-icons": "^5.2.0", "vue-router": "^5.3.0", "vue3-apexcharts": "~1.8.0", - "zod": "^4.4.3" + "zod": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.23.9", @@ -2698,7 +2698,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2715,7 +2714,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2732,7 +2730,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2749,7 +2746,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2766,7 +2762,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2783,7 +2778,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2800,7 +2794,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2817,7 +2810,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2834,7 +2826,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2851,7 +2842,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2868,7 +2858,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2885,7 +2874,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2902,7 +2890,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2919,7 +2906,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2936,7 +2922,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2953,7 +2938,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2970,7 +2954,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2987,7 +2970,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3004,7 +2986,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3021,7 +3002,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3038,7 +3018,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3055,7 +3034,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3072,7 +3050,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3089,7 +3066,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3106,7 +3082,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3123,7 +3098,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4884,7 +4858,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6182,7 +6155,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6196,7 +6168,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6210,7 +6181,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6224,7 +6194,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6238,7 +6207,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6252,7 +6220,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6266,7 +6233,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6280,7 +6246,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6294,7 +6259,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6308,7 +6272,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6322,7 +6285,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6336,7 +6298,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6350,7 +6311,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6364,7 +6324,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6378,7 +6337,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6392,7 +6350,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6406,7 +6363,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6420,7 +6376,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6434,7 +6389,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6448,7 +6402,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6462,7 +6415,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6476,7 +6428,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6490,7 +6441,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6504,7 +6454,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6518,7 +6467,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7112,7 +7060,7 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", @@ -7133,7 +7081,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -7735,7 +7683,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", @@ -7760,7 +7708,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.68.0", @@ -7782,7 +7730,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.68.0", @@ -7800,7 +7748,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7817,7 +7765,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7831,7 +7779,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.68.0", @@ -7859,7 +7807,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.68.0", @@ -7877,7 +7825,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -7890,7 +7838,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7985,7 +7933,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13356,7 +13304,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -23346,7 +23293,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.12" @@ -25672,9 +25619,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.2.tgz", + "integrity": "sha512-XkYXCol10+ba/6F/cueWV+TezUeOqXW0hdeJt5CdXjTYeAgAQg5N03RQdJ80mhfFE72+pblvYMW4wy2Qp4Qbrg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 71e8f90a5f..e04ee8a4e8 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "vue-material-design-icons": "^5.2.0", "vue-router": "^5.3.0", "vue3-apexcharts": "~1.8.0", - "zod": "^4.4.3" + "zod": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.23.9", From df087fb0b7f4c09cb75b2087d7e9033895edb129 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:58:33 +0200 Subject: [PATCH 04/68] chore(deps): bump marked from 12.0.2 to 18.0.11 (#3207) Bumps [marked](https://github.com/markedjs/marked) from 12.0.2 to 18.0.11. - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v12.0.2...v18.0.11) --- updated-dependencies: - dependency-name: marked dependency-version: 18.0.11 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 22 +++++++++++++++++----- package.json | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9c8d710dab..d7c1324590 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "dexie": "^4.4.5", "dompurify": "^3.4.14", "gridstack": "^13.2.0", - "marked": "^12.0.0", + "marked": "^18.0.11", "path-browserify": "^1.0.1", "pinia": "^3.0.4", "style-loader": "^4.0.0", @@ -2398,6 +2398,18 @@ "@yr/monotone-cubic-spline": "^1.0.3" } }, + "node_modules/@conduction/nextcloud-vue/node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -16732,15 +16744,15 @@ "license": "MIT" }, "node_modules/marked": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", - "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/material-colors": { diff --git a/package.json b/package.json index e04ee8a4e8..16c5d7cace 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "dexie": "^4.4.5", "dompurify": "^3.4.14", "gridstack": "^13.2.0", - "marked": "^12.0.0", + "marked": "^18.0.11", "path-browserify": "^1.0.1", "pinia": "^3.0.4", "style-loader": "^4.0.0", From e27bde1ca331ff1922f4b2f1f3d15df4e0929520 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 15:32:37 +0200 Subject: [PATCH 05/68] docs(license): state the apexcharts licence accurately in the override (#3220) The override read: "apexcharts": "License is MIT - license-checker misreads logo URL as custom license" That was true once and is not true now. apexcharts stopped being MIT at 6.0.0: 4.7.0 and 5.0.0 are MIT, 6.0.0 and 7.0.0 are SEE LICENSE IN LICENSE. This app declares ^7.0.0 and locks 7.0.0, so the checker flagging it is CORRECT and the override was suppressing a true finding rather than correcting a false one -- the licence gate was reporting green on a dependency that is not open source. Nothing about the dependency changes here. apexcharts 7 stays. What changes is that the file now says what is actually true: - 6.0.0+ ship a dual-license: free under a Community License for organisations below USD 2M annual revenue, paid above it, and sublicensing under different terms is not permitted. - Conduction confirmed 2026-08-31 that it is below that threshold and so qualifies under the Community License. - The override is therefore a deliberate acceptance, dated and attributed, not a claim that the checker is wrong. The note also records the redistribution question, because the Community License binds the organisation USING the software rather than only the one shipping it, and this app is distributed through the Nextcloud App Store. An installing tenant above USD 2M would need its own commercial licence. That is flagged for a decision, not decided here. For reference: dossiq is on apexcharts 4.7.0 (MIT) with no override, and its bump to 7.0.0 was closed today on these grounds (dossiq#1527). apexcharts 5.x is the last MIT release if the fleet ever needs to return to one. Co-authored-by: Conduction Release Bot --- .license-overrides.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.license-overrides.json b/.license-overrides.json index d7940bc6fd..afd9c79db3 100644 --- a/.license-overrides.json +++ b/.license-overrides.json @@ -1,6 +1,6 @@ { "@fortawesome/free-solid-svg-icons": "License is (CC-BY-4.0 AND MIT) — both are approved open-source licenses, compound AND expression not parsed by checker", "smalot/pdfparser": "License is LGPL-3.0 — equivalent to LGPL-3.0-only which is on the allowlist, SPDX identifier variation not recognized by checker", - "apexcharts": "License is MIT — license-checker misreads logo URL as custom license, see https://github.com/apexcharts/apexcharts.js/blob/main/LICENSE", + "apexcharts": "License is NOT MIT from 6.0.0 onward and this override is a deliberate acceptance, not a checker correction. 4.7.0 and 5.0.0 are MIT; 6.0.0+ ship a dual-license (SEE LICENSE IN LICENSE): free under a Community License for organisations below USD 2M annual revenue, paid above it, and sublicensing under different terms is not permitted. Conduction confirmed 2026-08-31 that it is below that threshold and so qualifies. NOTE for redistribution: the Community License binds the ORGANISATION USING the software, so an installing tenant above USD 2M needs its own commercial license -- this app is distributed via the Nextcloud App Store. Revisit if that becomes a problem; apexcharts 5.x is the last MIT release.", "dompdf/dompdf": "License is LGPL-2.1 — equivalent to LGPL-2.1-or-later which is on the allowlist; configured hermetically (isRemoteEnabled=false / isPhpEnabled=false) at the single instantiation site PdfReportWriter:69" } From 40da9a439988d05932365b139cd1a7af047fbfa6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:03:01 +0200 Subject: [PATCH 06/68] chore(release): 2.0.9-unstable.20260831133308 (#3225) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- appinfo/info.xml | 2 +- openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 15deb5804d..4354f53d56 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata Vrij en open source onder de EUPL-licentie. ]]> - 2.0.5-unstable.20260831053109 + 2.0.9-unstable.20260831133308 EUPL-1.2 Conduction OpenRegister diff --git a/openapi.json b/openapi.json index 0a5968ed65..0698e318dc 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "openregister", - "version": "2.0.5-unstable.20260831053109", + "version": "2.0.9-unstable.20260831133308", "description": "Open Register", "license": { "name": "EUPL-1.2" From e6acacc082b9375b445903e64f9005b50ccccc6d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 18:18:13 +0200 Subject: [PATCH 07/68] fix(deps): make the npm overrides follow their direct dependency (#3232) Dependabot cannot update these packages. Its npm updater fails with: npm error code EOVERRIDE npm error Override for conflicts with direct dependency The package is listed BOTH as a direct dependency and as a literal overrides entry pinned to the same range. Dependabot bumps the direct dependency and does not move the override, npm rejects the mismatched pair, and the update is abandoned -- so these packages can never be updated, security advisories included. npm's self-reference is the idiom for exactly this: $ tells the override to follow whatever the direct dependency resolves to, instead of a range that has to be kept in lockstep by hand. This repo already uses it (see the vue entry under @vue/test-utils). Transitive consumers still get pinned to one version -- the direct dependency's -- which is what the override was there to do. What changes is that the pin tracks the dependency instead of duplicating it. Verified in a clean container: bumping the direct dependency with the literal override reproduces the EOVERRIDE above; with the self-reference the same bump resolves. Co-authored-by: Conduction Release Bot --- package.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 16c5d7cace..90405d5133 100644 --- a/package.json +++ b/package.json @@ -138,14 +138,14 @@ }, "overrides": { "vue": "$vue", - "@nextcloud/vue": "^9.9.0", - "@nextcloud/axios": "^2.6.0", - "stylelint": "^17.9.1", - "postcss": "^8.4.31", + "@nextcloud/vue": "$@nextcloud/vue", + "@nextcloud/axios": "$@nextcloud/axios", + "stylelint": "$stylelint", + "postcss": "$postcss", "json5": "^2.2.3", - "@babel/traverse": "^7.23.9", - "babel-jest": "^29.7.0", - "@vue/test-utils": "^2.4.4", + "@babel/traverse": "$@babel/traverse", + "babel-jest": "$babel-jest", + "@vue/test-utils": "$@vue/test-utils", "minimatch": ">=3.1.5", "rollup": ">=2.80.0", "tar": ">=7.5.22", From b957caef5c332d1b191d6ef11104ebe48f5c4eda Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 18:50:50 +0200 Subject: [PATCH 08/68] fix(db): bind catch(Exception) to a class that exists, and drop dead SetupHandler (#3233) * fix(db): bind catch(Exception) to a class that exists, and drop dead SetupHandler Two findings that psalm-baseline.xml had been carrying as accepted noise. Neither is noise. 187 -> 184. lib/Db/Register.php caught `Exception` with no import, inside namespace OCA\OpenRegister\Db. PHP resolves an unqualified catch type against the CURRENT namespace, so that block was bound to OCA\OpenRegister\Db\Exception, which does not exist, and it could never catch anything. Verified rather than reasoned about: namespace Demo\Sub; try { throw new \RuntimeException("boom"); } catch (Exception $e) { ... } // no import -> NOT CAUGHT -> RuntimeException: boom Adding `use Exception;` binds it to the global class the code plainly meant. Checked the rest of lib/: ObjectsController and Repair/RenameDutchColumns also catch a bare `Exception`, but both import OCP\DB\Exception, so their catches bind to a real class and are deliberate. Psalm flagged exactly the one file that was broken. lib/Service/SettingsService.php declared a property, a constructor parameter and two docblocks for SetupHandler, a class that exists nowhere in the tree. It was written and never read, so it was dead API surface that would fatal the moment anything tried to supply it. Removed, along with the `setupHandler: null` argument in Application.php and the matching positional null in the two tests that build the service positionally. Verified with the repo own vendor/bin/psalm 5.26.1 on PHP 8.3: errors with the baseline emptied went 187 -> 184, and the regenerated baseline is green. PHPUnit was NOT run here: it needs the Nextcloud server bootstrap, which only exists inside a server checkout. * fix(tests): realign the five positional constructor calls I missed Removing the SetupHandler parameter shifted every later POSITIONAL argument, and I checked for that by reading the first line after each `new SettingsService(` and concluding the file used named arguments. It uses both. Five calls further down SettingsServiceTest.php are positional, and CI caught it as 13 TypeErrors, all the same one: Argument #14 ($appName) must be of type string, MockObject_IAppContainer given which is the shift saying exactly what it was. Verified by position this time rather than by sampling: with the parameter gone, appName is slot 14, and all seven positional call sites across the three test files now carry a string literal there. --------- Co-authored-by: Conduction Release Bot --- lib/AppInfo/Application.php | 1 - lib/Db/Register.php | 1 + lib/Service/SettingsService.php | 10 ---------- psalm-baseline.xml | 9 --------- tests/Unit/Service/SettingsServiceDeepTest.php | 1 - tests/Unit/Service/SettingsServiceGapTest.php | 1 - tests/Unit/Service/SettingsServiceTest.php | 5 ----- 7 files changed, 1 insertion(+), 27 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 45973c2c9c..5d1a8ad562 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -1110,7 +1110,6 @@ function (ContainerInterface $container) { searchTrailMapper: $container->get(SearchTrailMapper::class), userManager: $container->get('OCP\IUserManager'), db: $container->get('OCP\IDBConnection'), - setupHandler: null, objectCacheService: null, container: $container, appName: 'openregister', diff --git a/lib/Db/Register.php b/lib/Db/Register.php index e8567c0d93..d206ec3eaf 100644 --- a/lib/Db/Register.php +++ b/lib/Db/Register.php @@ -24,6 +24,7 @@ namespace OCA\OpenRegister\Db; use DateTime; +use Exception; use JsonSerializable; use OCP\AppFramework\Db\Entity; diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 8c791118eb..802b7a2f0f 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -207,13 +207,6 @@ class SettingsService { */ private ?ConfigurationSettingsHandler $configurationSettingsHandler = null; - /** - * Setup handler (optional, lazy-loaded to break circular dependency). - * - * @var SetupHandler|null - */ - private ?SetupHandler $setupHandler = null; - /** * Logger * @@ -309,7 +302,6 @@ class SettingsService { * @param SearchTrailMapper $searchTrailMapper Search trail mapper * @param IUserManager $userManager User manager * @param IDBConnection $db Database connection - * @param SetupHandler|null $setupHandler Setup handler (optional) * @param CacheHandler|null $objectCacheService Object cache service (optional) * @param IAppContainer|null $container Container for lazy loading (optional) * @param string $appName Application name @@ -338,7 +330,6 @@ public function __construct( SearchTrailMapper $searchTrailMapper, IUserManager $userManager, IDBConnection $db, - ?SetupHandler $setupHandler = null, ?CacheHandler $objectCacheService = null, ?IAppContainer $container = null, string $appName = 'openregister', @@ -362,7 +353,6 @@ public function __construct( $this->searchTrailMapper = $searchTrailMapper; $this->userManager = $userManager; $this->db = $db; - $this->setupHandler = $setupHandler; $this->objectCacheService = $objectCacheService; $this->container = $container; $this->appName = $appName; diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 2ba9bba898..34cbc314d4 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -110,9 +110,6 @@ id]]> - - - @@ -358,12 +355,6 @@ - - - - - - calDavBackend]]> diff --git a/tests/Unit/Service/SettingsServiceDeepTest.php b/tests/Unit/Service/SettingsServiceDeepTest.php index e667589106..2740fc8a41 100644 --- a/tests/Unit/Service/SettingsServiceDeepTest.php +++ b/tests/Unit/Service/SettingsServiceDeepTest.php @@ -117,7 +117,6 @@ protected function setUp(): void { $searchTrailMapper, $userManager, $this->db, - null, $objectCacheService, $this->container, 'openregister', diff --git a/tests/Unit/Service/SettingsServiceGapTest.php b/tests/Unit/Service/SettingsServiceGapTest.php index 3134a2c9cc..f12a5c9f2f 100644 --- a/tests/Unit/Service/SettingsServiceGapTest.php +++ b/tests/Unit/Service/SettingsServiceGapTest.php @@ -112,7 +112,6 @@ protected function setUp(): void { $db, null, null, - null, 'openregister', null, $this->searchBackendHandler, diff --git a/tests/Unit/Service/SettingsServiceTest.php b/tests/Unit/Service/SettingsServiceTest.php index a9d4aec65b..61ae34d928 100644 --- a/tests/Unit/Service/SettingsServiceTest.php +++ b/tests/Unit/Service/SettingsServiceTest.php @@ -1447,7 +1447,6 @@ public function testMassValidateObjectsNegativeBatchSize(): void { $this->userManager, $this->db, null, - null, $container, 'openregister', $this->validationOperationsHandler, @@ -1484,7 +1483,6 @@ public function testMassValidateObjectsModeValidatedFirst(): void { $this->userManager, $this->db, null, - null, $container, 'openregister', $this->validationOperationsHandler, @@ -1798,7 +1796,6 @@ public function testConstructorWithAllHandlers(): void { $this->db, null, null, - null, 'openregister', $this->validationOperationsHandler, $this->searchBackendHandler, @@ -1837,7 +1834,6 @@ public function testConstructorWithCustomAppName(): void { $this->db, null, null, - null, 'custom_app', $this->validationOperationsHandler, $this->searchBackendHandler, @@ -1914,7 +1910,6 @@ private function createServiceWithContainer($container): SettingsService { $this->userManager, $this->db, null, - null, $container, 'openregister', $this->validationOperationsHandler, From 07b58b0f63722b0314d63082784fbb127b2e4354 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 19:12:04 +0200 Subject: [PATCH 09/68] fix(db): remove an impossible lastInsertId fallback and a duplicate getter (#3236) Two more entries the Psalm baseline was carrying. Both are real. 184 -> 180. PollLinkService had a fallback that could never run: $pollId = (int)$this->db->lastInsertId("oc_polls_polls_id_seq"); if ($pollId === 0) { // Fallback for drivers without sequence support. $pollId = (int)$this->db->lastInsertId(); } OCP\IDBConnection::lastInsertId(string $table): int takes a REQUIRED argument, so the no-arg call raises ArgumentCountError on exactly the drivers the fallback was written for. The surrounding catch (Throwable) turned that into "Failed to create poll", which blames the insert rather than the id retrieval. A zero now falls through to the explicit throw that was already there and says what actually happened. Checked the other four lastInsertId call sites. MagicMapper and DedupCollidedSchemasCommand pass a table. DbalObjectSourceProvider calls it with no argument, but on a DBAL Connection rather than IDBConnection, where the parameter is optional. Psalm flagged only the one that was wrong. CaseToken declared getToken TWICE: @method string|null getToken() @method void getToken(?string $token) <- copy-paste of the setter @method void setToken(?string $token) Psalm binds to the second, which is why two correct $row->getToken() calls read as "Too few arguments". Removed; setToken on the next line was already right. Swept lib/Db for the same slip: this was the only one of them. Verified with the repo own vendor/bin/psalm 5.26.1 on PHP 8.3: all four TooFewArguments are gone, errors with the baseline emptied went 184 -> 180, regenerated baseline is green. PHPUnit was NOT run here, it needs the Nextcloud server bootstrap that only exists inside a server checkout. Co-authored-by: Conduction Release Bot --- lib/Db/CaseToken.php | 1 - lib/Service/PollLinkService.php | 10 ++++++---- psalm-baseline.xml | 12 ------------ 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/lib/Db/CaseToken.php b/lib/Db/CaseToken.php index 8e0b8af63d..54eb808cb1 100644 --- a/lib/Db/CaseToken.php +++ b/lib/Db/CaseToken.php @@ -43,7 +43,6 @@ * Class CaseToken * * @method string|null getToken() - * @method void getToken(?string $token) * @method void setToken(?string $token) * @method string|null getObjectUuid() * @method void setObjectUuid(?string $objectUuid) diff --git a/lib/Service/PollLinkService.php b/lib/Service/PollLinkService.php index 96871fac24..cea7b6aa6f 100644 --- a/lib/Service/PollLinkService.php +++ b/lib/Service/PollLinkService.php @@ -425,11 +425,13 @@ private function insertPollRecord( ); $insert->executeStatement(); + // OCP\IDBConnection::lastInsertId(string $table) REQUIRES the table. + // The old fallback called it with no argument, so on exactly the + // drivers it was written for it raised ArgumentCountError, which the + // catch below reported as "Failed to create poll", a message that + // blames the insert rather than the retrieval. A zero here now falls + // through to the explicit throw, which says what actually happened. $pollId = (int)$this->db->lastInsertId('oc_polls_polls_id_seq'); - if ($pollId === 0) { - // Fallback for drivers without sequence support. - $pollId = (int)$this->db->lastInsertId(); - } if ($pollId === 0) { throw new Exception('Failed to retrieve created poll id', 500); diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 34cbc314d4..5bbb61a415 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -182,12 +182,6 @@ - - - getToken()]]> - getToken()]]> - - temperature]]> @@ -337,12 +331,6 @@ - - - - - - From f26b368b0614faacfa7afcd8dc2d72de4e84d802 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 19:39:32 +0200 Subject: [PATCH 10/68] fix(archival): call a mapper method that exists, and correct a @throws (#3239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(archival): call a mapper method that exists, and correct a @throws Six calls to MagicMapper::findByUuid(), a method MagicMapper does not have. Fourteen OTHER mappers define findByUuid, which is what makes the call look right, but MagicMapper is not one of them, AbstractObjectMapper only declares abstract find/findAll/findMultiple/findBySchema, and neither has __call or a trait supplying it. The failure mode is worse than a 404. `Call to undefined method` raises a PHP Error, and Error does NOT extend Exception, so the } catch (\Exception $e) { return new JSONResponse(["error" => "... not found"], 404); around every one of these never catches it. Five archival endpoints and one destruction path answer with an uncaught fatal instead of the 404 they were written to return. MagicMapper::find(string|int $identifier, ...) is the method meant here: its own docblock says the identifier may be an "ID, UUID, slug, or URI", it returns ObjectEntity, and it throws DoesNotExistException when the object is absent, which is exactly what these call sites expect and catch. Also corrected a @throws in ObjectService::rejectIfTransferred that named \OCP\AppFramework\Http\ContentSecurityPolicy, a CSP class rather than an exception. The method throws DoesNotExistException. Verified with the repo own vendor/bin/psalm 5.26.1 on PHP 8.3: errors with the baseline emptied went 180 -> 173, regenerated baseline is green. PHPUnit was NOT run here, it needs the Nextcloud server bootstrap that only exists inside a server checkout. * test(archival): stub find() with onlyMethods, not an invented findByUuid The four archival test files all did: ->addMethods([\x27findByUuid\x27]) addMethods() is PHPUnit\x27s API for mocking a method the class DOES NOT HAVE. That is why this bug lived: the tests had to invent findByUuid on MagicMapper to stub it, so the suite was green while production raised `Call to undefined method` on every archival endpoint. The mock documented the defect and nobody read it that way. ArchivalControllerTest and DestructionServiceTest now stub the real method with onlyMethods([..., \x27find\x27]), and their 14 ->method(\x27findByUuid\x27) expectations point at find(). LegalHoldServiceTest and DestructionCertificateContentTest never called it at all: their addMethods was dead invention, so it is simply removed. 🔑 onlyMethods() fails loudly when the method does not exist. addMethods() cannot, by design. Prefer onlyMethods for anything that is supposed to be real. --------- Co-authored-by: Conduction Release Bot --- lib/Controller/ArchivalController.php | 10 +++--- lib/Service/Archival/DestructionService.php | 2 +- lib/Service/ObjectService.php | 3 +- psalm-baseline.xml | 19 ------------ .../Controller/ArchivalControllerTest.php | 31 ++++++++++--------- .../DestructionCertificateContentTest.php | 1 - .../Archival/DestructionServiceTest.php | 7 ++--- .../Service/Archival/LegalHoldServiceTest.php | 1 - 8 files changed, 28 insertions(+), 46 deletions(-) diff --git a/lib/Controller/ArchivalController.php b/lib/Controller/ArchivalController.php index 0f64b4c2f3..e771b4c529 100644 --- a/lib/Controller/ArchivalController.php +++ b/lib/Controller/ArchivalController.php @@ -184,7 +184,7 @@ public function getDestructionList(string $id): JSONResponse { } try { - $object = $this->objectMapper->findByUuid($id); + $object = $this->objectMapper->find($id); return new JSONResponse( data: $object->jsonSerialize(), statusCode: Http::STATUS_OK @@ -221,7 +221,7 @@ public function approveDestructionList(string $id): JSONResponse { $exclusionReasons = $params['exclusionReasons'] ?? []; try { - $object = $this->objectMapper->findByUuid($id); + $object = $this->objectMapper->find($id); $destructionList = $object->getObject() ?? []; // Check for dual-approval requirement based on schema config. @@ -304,7 +304,7 @@ public function rejectDestructionList(string $id): JSONResponse { } try { - $object = $this->objectMapper->findByUuid($id); + $object = $this->objectMapper->find($id); $destructionList = $object->getObject() ?? []; $result = $this->destructionService->rejectList($destructionList, $reason); @@ -389,7 +389,7 @@ public function createLegalHold(): JSONResponse { ); } - $object = $this->objectMapper->findByUuid($objectId); + $object = $this->objectMapper->find($objectId); $result = $this->legalHoldService->placeHold($object, $reason); return new JSONResponse( @@ -445,7 +445,7 @@ public function releaseLegalHold(string $id): JSONResponse { } try { - $object = $this->objectMapper->findByUuid($id); + $object = $this->objectMapper->find($id); $result = $this->legalHoldService->releaseHold($object, $reason); return new JSONResponse( diff --git a/lib/Service/Archival/DestructionService.php b/lib/Service/Archival/DestructionService.php index fdd8162ad3..6e9861755a 100644 --- a/lib/Service/Archival/DestructionService.php +++ b/lib/Service/Archival/DestructionService.php @@ -505,7 +505,7 @@ public function rejectList(array $destructionList, string $reason): array { */ private function extendArchiveActionDate(string $uuid, string $extensionPeriod, string $reason): void { try { - $object = $this->objectMapper->findByUuid($uuid); + $object = $this->objectMapper->find($uuid); $retention = $object->getRetention() ?? []; $currentDate = $retention['archiefactiedatum'] ?? null; diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 84756796f0..d7b1ed84c5 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -2519,7 +2519,8 @@ private function schemaHasArchivalAnnotation(Schema $schema): bool * * @return void * - * @throws \OCP\AppFramework\Http\ContentSecurityPolicy + * @throws \OCP\AppFramework\Db\DoesNotExistException When the object has been + * transferred to e-Depot and is therefore read-only. * * @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md */ diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 5bbb61a415..4e069e6c38 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -38,15 +38,6 @@ - - - - - - - - - csrfManager]]> @@ -133,11 +124,6 @@ - - - - - @@ -306,11 +292,6 @@ - - - - - diff --git a/tests/Unit/Controller/ArchivalControllerTest.php b/tests/Unit/Controller/ArchivalControllerTest.php index a838426789..ba27923215 100644 --- a/tests/Unit/Controller/ArchivalControllerTest.php +++ b/tests/Unit/Controller/ArchivalControllerTest.php @@ -54,8 +54,11 @@ protected function setUp(): void { // list (openregister#393 D3). Without it the real MagicMapper::update() runs. $this->objectMapper = $this->getMockBuilder(MagicMapper::class) ->disableOriginalConstructor() - ->onlyMethods(['update']) - ->addMethods(['findByUuid']) + // `find` is stubbed with onlyMethods, NOT addMethods. addMethods + // invents a method the class does not have, which is how + // `findByUuid` passed here for so long while production raised + // `Call to undefined method` on every archival endpoint. + ->onlyMethods(['update', 'find']) ->getMock(); $this->userSession = $this->createMock(IUserSession::class); $this->groupManager = $this->createMock(IGroupManager::class); @@ -230,7 +233,7 @@ public function testGetDestructionListOk(): void { $object->method('jsonSerialize')->willReturn(['uuid' => 'dl-1', 'status' => 'in_review']); $this->objectMapper - ->method('findByUuid') + ->method('find') ->with('dl-1') ->willReturn($object); @@ -247,7 +250,7 @@ public function testGetDestructionListNotFound(): void { $this->setUpArchivist(); $this->objectMapper - ->method('findByUuid') + ->method('find') ->willThrowException(new \Exception('Not found')); $response = $this->controller->getDestructionList('non-existent'); @@ -273,7 +276,7 @@ public function testApproveDestructionListOk(): void { ]); $this->objectMapper - ->method('findByUuid') + ->method('find') ->with('dl-1') ->willReturn($object); @@ -315,7 +318,7 @@ public function testApproveDestructionListPartial(): void { ]); $this->objectMapper - ->method('findByUuid') + ->method('find') ->with('dl-1') ->willReturn($object); @@ -349,7 +352,7 @@ public function testApproveDestructionListDualApprovalConflict(): void { ]); $this->objectMapper - ->method('findByUuid') + ->method('find') ->with('dl-1') ->willReturn($object); @@ -375,7 +378,7 @@ public function testApproveDestructionListException(): void { $this->setUpArchivist(); $this->objectMapper - ->method('findByUuid') + ->method('find') ->willThrowException(new \Exception('Not found')); $this->request->method('getParams')->willReturn([]); @@ -402,7 +405,7 @@ public function testRejectDestructionListOk(): void { ]); $this->objectMapper - ->method('findByUuid') + ->method('find') ->with('dl-1') ->willReturn($object); @@ -463,7 +466,7 @@ public function testRejectDestructionListException(): void { ]); $this->objectMapper - ->method('findByUuid') + ->method('find') ->willThrowException(new \Exception('Not found')); $response = $this->controller->rejectDestructionList('non-existent'); @@ -494,7 +497,7 @@ public function testCreateLegalHoldSingleObjectOk(): void { ]); $this->objectMapper - ->method('findByUuid') + ->method('find') ->with('obj-1') ->willReturn($object); @@ -589,7 +592,7 @@ public function testCreateLegalHoldException(): void { ]); $this->objectMapper - ->method('findByUuid') + ->method('find') ->willThrowException(new \Exception('Object not found')); $response = $this->controller->createLegalHold(); @@ -619,7 +622,7 @@ public function testReleaseLegalHoldOk(): void { ]); $this->objectMapper - ->method('findByUuid') + ->method('find') ->with('obj-1') ->willReturn($object); @@ -660,7 +663,7 @@ public function testReleaseLegalHoldException(): void { ]); $this->objectMapper - ->method('findByUuid') + ->method('find') ->willThrowException(new \Exception('Object not found')); $response = $this->controller->releaseLegalHold('obj-1'); diff --git a/tests/Unit/Service/Archival/DestructionCertificateContentTest.php b/tests/Unit/Service/Archival/DestructionCertificateContentTest.php index a926b98141..2654391e8e 100644 --- a/tests/Unit/Service/Archival/DestructionCertificateContentTest.php +++ b/tests/Unit/Service/Archival/DestructionCertificateContentTest.php @@ -80,7 +80,6 @@ static function (string $app, string $key, string $default = ''): string { $objectMapper = $this->getMockBuilder(MagicMapper::class) ->disableOriginalConstructor() ->onlyMethods(['update']) - ->addMethods(['findByUuid']) ->getMock(); $this->destructionService = new DestructionService( diff --git a/tests/Unit/Service/Archival/DestructionServiceTest.php b/tests/Unit/Service/Archival/DestructionServiceTest.php index ff9fe54660..4d22cc10db 100644 --- a/tests/Unit/Service/Archival/DestructionServiceTest.php +++ b/tests/Unit/Service/Archival/DestructionServiceTest.php @@ -45,8 +45,7 @@ protected function setUp(): void { $this->objectMapper = $this->getMockBuilder(MagicMapper::class) ->disableOriginalConstructor() - ->onlyMethods(['update']) - ->addMethods(['findByUuid']) + ->onlyMethods(['update', 'find']) ->getMock(); $this->legalHoldService = $this->createMock(LegalHoldService::class); $this->appConfig = $this->createMock(IAppConfig::class); @@ -151,7 +150,7 @@ public function testApproveListPartial(): void { 'archiefactiedatum' => '2025-01-01', ]); $mockObject->expects($this->once())->method('setRetention'); - $this->objectMapper->method('findByUuid')->willReturn($mockObject); + $this->objectMapper->method('find')->willReturn($mockObject); $this->objectMapper->method('update')->willReturn($mockObject); $list = [ @@ -190,7 +189,7 @@ public function testRejectList(): void { ->getMock(); $mockObject->method('getRetention')->willReturn(['archiefactiedatum' => '2025-01-01']); $mockObject->expects($this->once())->method('setRetention'); - $this->objectMapper->method('findByUuid')->willReturn($mockObject); + $this->objectMapper->method('find')->willReturn($mockObject); $this->objectMapper->method('update')->willReturn($mockObject); $list = [ diff --git a/tests/Unit/Service/Archival/LegalHoldServiceTest.php b/tests/Unit/Service/Archival/LegalHoldServiceTest.php index 3d9b2a4772..954420873b 100644 --- a/tests/Unit/Service/Archival/LegalHoldServiceTest.php +++ b/tests/Unit/Service/Archival/LegalHoldServiceTest.php @@ -43,7 +43,6 @@ protected function setUp(): void { $this->objectMapper = $this->getMockBuilder(MagicMapper::class) ->disableOriginalConstructor() ->onlyMethods(['update']) - ->addMethods(['findByUuid']) ->getMock(); $this->auditTrailMapper = $this->createMock(AuditTrailMapper::class); $this->userSession = $this->createMock(IUserSession::class); From 5c5a73496d385bbd38cdad59bcb69f3fc35b4fa0 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 19:42:27 +0200 Subject: [PATCH 11/68] docs(license): correct the apexcharts MIT boundary and record the OEM gap (#3240) Two corrections to the note added earlier today. 1. The MIT boundary is 5.1.0, not 6.0.0. Measured per version: 4.7.0 MIT, 5.0.0 MIT, 5.1.0 'SEE LICENSE IN LICENSE', 5.2.0 'ApexCharts License', 6.0.0 and 7.0.0 likewise. So the last MIT release is 5.0.0, and the earlier note understated how far back the proprietary line starts. 2. The revenue threshold is not the nearest trigger. The licence also requires a paid OEM/Redistribution licence for 'embedding ApexCharts into a product or platform used by other people', waived only if the app 'simply renders static charts and users cannot configure or interact with them'. RegisterDetail.vue and SchemaDetails.vue both set toolbar: { show: true } and zoom: { enabled: true }, and this app is distributed to third parties through the Nextcloud App Store, so the exemption does not apply on its face. Conduction being below USD 2M settles the Community tier for its OWN use. It does not settle redistribution. The note now says so rather than reading as a clearance. Co-authored-by: Conduction Release Bot --- .license-overrides.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.license-overrides.json b/.license-overrides.json index afd9c79db3..37385dbaf1 100644 --- a/.license-overrides.json +++ b/.license-overrides.json @@ -1,6 +1,6 @@ { "@fortawesome/free-solid-svg-icons": "License is (CC-BY-4.0 AND MIT) — both are approved open-source licenses, compound AND expression not parsed by checker", "smalot/pdfparser": "License is LGPL-3.0 — equivalent to LGPL-3.0-only which is on the allowlist, SPDX identifier variation not recognized by checker", - "apexcharts": "License is NOT MIT from 6.0.0 onward and this override is a deliberate acceptance, not a checker correction. 4.7.0 and 5.0.0 are MIT; 6.0.0+ ship a dual-license (SEE LICENSE IN LICENSE): free under a Community License for organisations below USD 2M annual revenue, paid above it, and sublicensing under different terms is not permitted. Conduction confirmed 2026-08-31 that it is below that threshold and so qualifies. NOTE for redistribution: the Community License binds the ORGANISATION USING the software, so an installing tenant above USD 2M needs its own commercial license -- this app is distributed via the Nextcloud App Store. Revisit if that becomes a problem; apexcharts 5.x is the last MIT release.", + "apexcharts": "NOT MIT. The last MIT release is 5.0.0 -- 5.1.0 onward is the ApexCharts dual-license (Community free only below USD 2M annual revenue; sublicensing under different terms not permitted). This app declares ^7.0.0. Conduction confirmed 2026-08-31 it is below USD 2M, so it qualifies for the Community tier. 🔴 UNRESOLVED: an OEM/Redistribution licence is separately required when embedding ApexCharts in a product used by other people, and is only waived for STATIC charts users cannot interact with -- RegisterDetail.vue and SchemaDetails.vue both set toolbar.show and zoom.enabled, and this app ships to third parties via the Nextcloud App Store. Escalated to the maintainers; do not read this override as a clearance for redistribution.", "dompdf/dompdf": "License is LGPL-2.1 — equivalent to LGPL-2.1-or-later which is on the allowlist; configured hermetically (isRemoteEnabled=false / isPhpEnabled=false) at the single instantiation site PdfReportWriter:69" } From 290b35d6628ca08770049390767f9f9466a707e3 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 20:09:28 +0200 Subject: [PATCH 12/68] fix(chat): send temperature through modelOptions, where LLPhant reads it (#3243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat): send temperature through modelOptions, where LLPhant reads it Four assignments of $config->temperature and one of $config->organizationId on LLPhant\OpenAIConfig. Neither is a property of that class, so each one created a DYNAMIC property that nothing ever read. An agent temperature configured in the UI never reached the model. The suppressions above them said so and were read the other way round: @psalm-suppress UndefinedPropertyAssignment LLPhant dynamic properties LLPhant does not use dynamic properties. It reads modelOptions: OpenAIChat.php:89 $this->modelOptions = $config->modelOptions; OpenAIChat.php:368 $openAiArgs = $this->modelOptions; and OpenAIConfig own docblock lists temperature inside the ModelOptions array shape, not among its properties. Nothing anywhere in the library reads a temperature property. Temperature now goes to $config->modelOptions["temperature"], which is what reaches the API. organizationId is removed rather than relocated: LLPhant has no organization support of any kind, so there is nowhere for it to go. That drops the pretence, not the behaviour, because there was none. ⚠️ PHP 8.2 deprecated dynamic property creation, so this was also going to start emitting deprecations and then fail outright. Verified with the repo own vendor/bin/psalm 5.26.1 on PHP 8.3: errors with the baseline emptied went 173 -> 168, regenerated baseline is green. PHPUnit was NOT run here, it needs the Nextcloud server bootstrap that only exists inside a server checkout. * style(chat): start the new inline comments with a capital phpcs: "Inline comments must start with a capital letter". Five of the comments I added opened on an identifier (modelOptions, organizationId). --------- Co-authored-by: Conduction Release Bot --- .../Chat/ConversationManagementHandler.php | 12 ++++--- .../Chat/ResponseGenerationHandler.php | 35 ++++++++++--------- psalm-baseline.xml | 11 ------ 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/lib/Service/Chat/ConversationManagementHandler.php b/lib/Service/Chat/ConversationManagementHandler.php index 9fa0c0e3eb..e0641948d6 100644 --- a/lib/Service/Chat/ConversationManagementHandler.php +++ b/lib/Service/Chat/ConversationManagementHandler.php @@ -190,8 +190,10 @@ public function generateConversationTitle(string $firstMessage): string { $config->apiKey = $openaiConfig['apiKey']; $config->model = 'gpt-4o-mini'; - // @psalm-suppress UndefinedPropertyAssignment LLPhant dynamic properties. - $config->temperature = 0.7; + // Through modelOptions, NOT a property: OpenAIChat reads + // $config->modelOptions and nothing in LLPhant reads a + // temperature property, so the old form was discarded silently. + $config->modelOptions['temperature'] = 0.7; } elseif ($chatProvider === 'fireworks') { $fireworksConfig = $llmConfig['fireworksConfig'] ?? []; if (empty($fireworksConfig['apiKey']) === true) { @@ -209,8 +211,10 @@ public function generateConversationTitle(string $firstMessage): string { $config->url = $baseUrl; - // @psalm-suppress UndefinedPropertyAssignment LLPhant dynamic properties. - $config->temperature = 0.7; + // Through modelOptions, NOT a property: OpenAIChat reads + // $config->modelOptions and nothing in LLPhant reads a + // temperature property, so the old form was discarded silently. + $config->modelOptions['temperature'] = 0.7; }//end if if ($chatProvider !== 'ollama' && $chatProvider !== 'openai' && $chatProvider !== 'fireworks') { diff --git a/lib/Service/Chat/ResponseGenerationHandler.php b/lib/Service/Chat/ResponseGenerationHandler.php index 96e80421e9..72b2e9d15d 100644 --- a/lib/Service/Chat/ResponseGenerationHandler.php +++ b/lib/Service/Chat/ResponseGenerationHandler.php @@ -270,21 +270,20 @@ public function generateResponse( $config->model = $agentModel; } - if (empty($openaiConfig['organizationId']) === false) { - /* - * @psalm-suppress UndefinedPropertyAssignment LLPhant dynamic properties - */ - - $config->organizationId = $openaiConfig['organizationId']; - } + // OrganizationId is NOT set here. LLPhant has no organization + // support of any kind, so the previous assignment created a + // dynamic property nothing ever read. Dropping it removes the + // pretence rather than the behaviour: there was none. // Set temperature from agent or default (OpenAI). if ($agent?->getTemperature() !== null) { - /* - * @psalm-suppress UndefinedPropertyAssignment LLPhant dynamic properties - */ - - $config->temperature = $agent->getTemperature(); + // Through modelOptions, NOT a property. OpenAIChat reads + // $config->modelOptions and sends it as the API arguments; + // nothing in LLPhant ever reads a temperature property, so + // the old assignment created a dynamic property that was + // silently discarded and the agent's temperature never + // reached the model. + $config->modelOptions['temperature'] = $agent->getTemperature(); } } elseif ($chatProvider === 'fireworks') { // Fireworks uses OpenAIConfig. @@ -313,11 +312,13 @@ public function generateResponse( // Set temperature from agent or default (Fireworks). if ($agent?->getTemperature() !== null) { - /* - * @psalm-suppress UndefinedPropertyAssignment LLPhant dynamic properties - */ - - $config->temperature = $agent->getTemperature(); + // Through modelOptions, NOT a property. OpenAIChat reads + // $config->modelOptions and sends it as the API arguments; + // nothing in LLPhant ever reads a temperature property, so + // the old assignment created a dynamic property that was + // silently discarded and the agent's temperature never + // reached the model. + $config->modelOptions['temperature'] = $agent->getTemperature(); } }//end if diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 4e069e6c38..c2edba3590 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -168,18 +168,7 @@ - - - temperature]]> - temperature]]> - - - - organizationId]]> - temperature]]> - temperature]]> - From 99aaccc08fffcbac2080ab16ae45bba12f4a1e08 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 20:42:37 +0200 Subject: [PATCH 13/68] docs(cache): declare the warmup_breakdown key the stats array actually holds (#3245) $this->stats was documented as eight int keys, and the name-cache warm-up stores a ninth, warmup_breakdown, holding an array. That mismatch is what Psalm was reporting as InvalidPropertyAssignmentValue, and it had been baselined rather than reconciled. The runtime behaviour was never wrong: PHP arrays take the key regardless. What was wrong is that the declaration described a narrower shape than the class keeps, so anything reading the annotation, a reader or an IDE or the analyser, was told the key cannot be there. Declared optional, because it is absent until the warm-up has run. Verified with the repo own vendor/bin/psalm 5.26.1 on PHP 8.3: 168 -> 167 with the baseline emptied, regenerated baseline green, phpcs clean on the touched file. Co-authored-by: Conduction Release Bot --- lib/Service/Object/CacheHandler.php | 10 +++++++++- psalm-baseline.xml | 5 ----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/Service/Object/CacheHandler.php b/lib/Service/Object/CacheHandler.php index 9b7a89209a..50c2aa29b6 100644 --- a/lib/Service/Object/CacheHandler.php +++ b/lib/Service/Object/CacheHandler.php @@ -144,8 +144,16 @@ class CacheHandler { /** * Cache hit statistics * + * `warmup_breakdown` is written by the name-cache warm-up for diagnostics + * and is absent until that has run, so it is optional here. It was missing + * from this shape entirely, which is why assigning it read as + * InvalidPropertyAssignmentValue: the declaration described eight int keys + * and the code stored a ninth holding an array. + * * @var array{hits: int, misses: int, preloads: int, query_hits: int, - * query_misses: int, name_hits: int, name_misses: int, name_warmups: int} + * query_misses: int, name_hits: int, name_misses: int, name_warmups: int, + * warmup_breakdown?: array{organisations: int, objects_table: int, + * magic_tables: int, total_unique: int}} */ private array $stats = [ 'hits' => 0, diff --git a/psalm-baseline.xml b/psalm-baseline.xml index c2edba3590..fcecd3fba3 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -258,11 +258,6 @@ oas['paths'][$pathName][$method]]]> - - - stats]]> - - From ce8b4f49bd77ba5f5c10ab4954e5b902217f8f31 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:12:29 +0200 Subject: [PATCH 14/68] chore(release): 2.0.11-unstable.20260831184325 (#3246) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- appinfo/info.xml | 2 +- openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 4354f53d56..c653aa46e5 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata Vrij en open source onder de EUPL-licentie. ]]> - 2.0.9-unstable.20260831133308 + 2.0.11-unstable.20260831184325 EUPL-1.2 Conduction OpenRegister diff --git a/openapi.json b/openapi.json index 0698e318dc..888e7dda46 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "openregister", - "version": "2.0.9-unstable.20260831133308", + "version": "2.0.11-unstable.20260831184325", "description": "Open Register", "license": { "name": "EUPL-1.2" From e10e5bc596d2fe3925a722081ec1356976def673 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 23:41:50 +0200 Subject: [PATCH 15/68] feat(schemas): let an app retire a schema from its descriptor (#3248) * feat(schemas): let an app retire a schema from its descriptor Removing a schema from an app's register descriptor never removed it from the instance. ImportHandler unions the freshly-imported schema ids into the register's existing list and prunes only the ids it has just shadowed by slug, so a retired schema kept its row, its magic table and its place in the register's schemas array forever. On a shared instance that is how a cross-app slug collision outlives the descriptor change meant to end it. Adds occ openregister:schemas:prune-retired --app --slug . Scoped by owning application, so it can never reach a same-slug schema another app owns: measured on the dev instance, filinq's product is id 21 and decidiq's is id 987. Dry-run by default, refuses a schema that still owns objects unless --force, and unlinks the id from every referencing register before the row goes. unlinkSchemaId() matches the integer and the string form of an id. The stored list holds either depending on which import era wrote it, so a strict comparison would leave "74" behind pointing at a deleted row. * docs(schemas): tag every prune-retired method with its @spec gate-16 named two changed methods with no @spec. Tagging all five so the class does not drift back over the line as it changes. --- appinfo/info.xml | 1 + lib/Command/PruneRetiredSchemasCommand.php | 358 ++++++++++++++++++ openspec/specs/schema-import/spec.md | 43 +++ .../PruneRetiredSchemasCommandTest.php | 124 ++++++ 4 files changed, 526 insertions(+) create mode 100644 lib/Command/PruneRetiredSchemasCommand.php create mode 100644 tests/Unit/Command/PruneRetiredSchemasCommandTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index c653aa46e5..f0255a56b1 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -310,6 +310,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Command\EncryptFieldCommand OCA\OpenRegister\Command\DedupeRegistersCommand + OCA\OpenRegister\Command\PruneRetiredSchemasCommand OCA\OpenRegister\Command\RelinkRegisterSchemasCommand OCA\OpenRegister\Command\ReconcileMagicTablesCommand OCA\OpenRegister\Command\DedupeConfigurationsCommand diff --git a/lib/Command/PruneRetiredSchemasCommand.php b/lib/Command/PruneRetiredSchemasCommand.php new file mode 100644 index 0000000000..53a18d8570 --- /dev/null +++ b/lib/Command/PruneRetiredSchemasCommand.php @@ -0,0 +1,358 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Command; + +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\SchemaDeletionService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Remove schemas an app has retired from its descriptor. + * + * @spec openspec/specs/schema-import/spec.md#requirement-a-schema-retired-from-a-descriptor-must-be-removable-from-the-instance + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class PruneRetiredSchemasCommand extends Command { + /** + * Wire the mappers and the cascade service. + * + * @param SchemaMapper $schemaMapper Schema lookup mapper (app-scoped resolution). + * @param RegisterMapper $registerMapper Register mapper, to unlink the retired id. + * @param MagicMapper $magicMapper Magic table resolver, for the object-count guard. + * @param SchemaDeletionService $deletionService Cascade teardown (objects, tables, row). + * + * @return void + * + * @spec openspec/specs/schema-import/spec.md#requirement-a-schema-retired-from-a-descriptor-must-be-removable-from-the-instance + */ + public function __construct( + private readonly SchemaMapper $schemaMapper, + private readonly RegisterMapper $registerMapper, + private readonly MagicMapper $magicMapper, + private readonly SchemaDeletionService $deletionService, + ) { + parent::__construct(); + }//end __construct() + + /** + * Define command name, description, and options. + * + * @return void + * + * @spec openspec/specs/schema-import/spec.md#requirement-a-schema-retired-from-a-descriptor-must-be-removable-from-the-instance + */ + protected function configure(): void { + $this->setName(name: 'openregister:schemas:prune-retired') + ->setDescription( + 'Remove schemas an app has retired from its register descriptor ' + . '(the import unions ids, so it never removes them itself).' + ) + ->addOption( + 'app', + null, + InputOption::VALUE_REQUIRED, + 'The owning application id, exactly as it appears in the schema\'s `application` column ' + . '(for example `filinq`). Scoping is mandatory: it is what stops this command reaching ' + . 'a same-slug schema another app owns.' + ) + ->addOption( + 'slug', + null, + (InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), + 'A retired schema slug. Repeat the option to prune several.' + ) + ->addOption( + 'apply', + null, + InputOption::VALUE_NONE, + 'Actually delete. Without this flag the command reports what it WOULD delete.' + ) + ->addOption( + 'force', + null, + InputOption::VALUE_NONE, + 'Also delete a schema that still owns objects (DANGEROUS — drops those objects). ' + . 'Has no effect without --apply.' + ); + }//end configure() + + /** + * Resolve each named slug within the app, then cascade-delete it. + * + * @param InputInterface $input Console input. + * @param OutputInterface $output Console output stream. + * + * @return int Symfony command exit code. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * + * @spec openspec/specs/schema-import/spec.md#requirement-a-schema-retired-from-a-descriptor-must-be-removable-from-the-instance + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $appId = (string)$input->getOption('app'); + $slugs = (array)$input->getOption('slug'); + $apply = (bool)$input->getOption('apply'); + $force = (bool)$input->getOption('force'); + $dryRun = ($apply === false); + + if ($appId === '') { + $output->writeln('--app is required.'); + return Command::FAILURE; + } + + if ($slugs === []) { + $output->writeln('At least one --slug is required.'); + return Command::FAILURE; + } + + if ($dryRun === true) { + $output->writeln( + 'Running in DRY-RUN mode — nothing will be deleted. ' + . 'Re-run with --apply to perform deletions.' + ); + } + + $pruned = 0; + $skipped = 0; + $missing = 0; + + foreach ($slugs as $slug) { + $slug = (string)$slug; + $schema = $this->schemaMapper->findByApplicationAndSlug(slug: $slug, application: $appId); + + if ($schema === null) { + $missing++; + $output->writeln( + sprintf( + 'SKIP "%s": app "%s" owns no schema with that slug (already pruned, or never imported).', + $slug, + $appId + ) + ); + continue; + } + + $schemaId = (int)$schema->getId(); + $objectCount = $this->countObjectsForSchema(schema: $schema); + $linkedRegisters = $this->registersReferencing(schemaId: $schemaId); + + $output->writeln( + sprintf( + '%s (id=%d, app=%s): %d object(s), referenced by %d register(s)', + $slug, + $schemaId, + $appId, + $objectCount, + count($linkedRegisters) + ) + ); + + if ($objectCount > 0 && $force === false) { + $skipped++; + $output->writeln( + ' SKIP — still owns objects. Re-run with --force to drop them, ' + . 'or migrate them first.' + ); + continue; + } + + if ($dryRun === true) { + $output->writeln(sprintf(' WOULD DELETE (objects=%d)', $objectCount)); + continue; + } + + // Unlink first. cascadeDeleteSchema() removes the row, and a register + // left pointing at a missing id makes every later slug resolution in + // that register scan a dangling reference. + foreach ($linkedRegisters as $register) { + $remaining = self::unlinkSchemaId(schemaRefs: $register->getSchemas(), schemaId: $schemaId); + $register->setSchemas($remaining); + $this->registerMapper->update($register); + $output->writeln( + sprintf(' unlinked from register id=%d (%s)', (int)$register->getId(), (string)$register->getSlug()) + ); + } + + try { + $result = $this->deletionService->cascadeDeleteSchema(schema: $schema); + } catch (\Throwable $e) { + $skipped++; + $output->writeln(sprintf(' FAILED: %s', $e->getMessage())); + continue; + } + + $pruned++; + $tableDropped = 'no'; + if ($result['tableDropped'] === true) { + $tableDropped = 'yes'; + } + + $output->writeln( + sprintf( + ' DELETED (objects removed=%d, table dropped=%s)', + (int)$result['deletedCount'], + $tableDropped + ) + ); + }//end foreach + + $suffix = ''; + if ($dryRun === true) { + $suffix = ' (dry run — no writes performed)'; + } + + $output->writeln( + sprintf( + 'Done. Pruned=%d, skipped=%d, not-found=%d%s', + $pruned, + $skipped, + $missing, + $suffix + ) + ); + + return Command::SUCCESS; + }//end execute() + + /** + * Drop one schema id from a register's stored schema list. + * + * The stored list holds ids as ints or as strings depending on which import + * era wrote it, so `"74"` and `74` are the same reference and both have to + * go. A strict comparison here would leave the string form behind and the + * register would keep pointing at a row that no longer exists. + * + * Entries that are not numeric at all are preserved untouched: they are not + * this id, and silently dropping them would corrupt the register. + * + * @param array $schemaRefs The register's stored schema list. + * @param int $schemaId The schema id to remove. + * + * @return array The list with every form of that id removed. + * + * @spec openspec/specs/schema-import/spec.md#requirement-a-schema-retired-from-a-descriptor-must-be-removable-from-the-instance + */ + public static function unlinkSchemaId(array $schemaRefs, int $schemaId): array { + return array_values( + array_filter( + $schemaRefs, + static function ($ref) use ($schemaId) { + if (is_int($ref) === false && is_string($ref) === false) { + return true; + } + + if (is_string($ref) === true && is_numeric($ref) === false) { + return true; + } + + return ((int)$ref !== $schemaId); + } + ) + ); + }//end unlinkSchemaId() + + /** + * Count objects a schema owns across every register that references it. + * + * @param Schema $schema The schema to count objects for. + * + * @return int Total object count across the schema's magic tables. + * + * @spec openspec/specs/schema-import/spec.md#requirement-a-schema-retired-from-a-descriptor-must-be-removable-from-the-instance + */ + private function countObjectsForSchema(Schema $schema): int { + $total = 0; + + foreach ($this->registersReferencing(schemaId: (int)$schema->getId()) as $register) { + if ($this->magicMapper->tableExistsForRegisterSchema(register: $register, schema: $schema) === false) { + continue; + } + + try { + $total += $this->magicMapper->countObjectsInRegisterSchemaTable( + query: [], + register: $register, + schema: $schema + ); + } catch (\Throwable $e) { + // An unreadable magic table cannot be counted. Treat it as + // non-empty so the guard errs towards keeping data. + $total++; + } + } + + return $total; + }//end countObjectsForSchema() + + /** + * Every register whose schema list references this schema id. + * + * @param int $schemaId The schema id to look for. + * + * @return Register[] The referencing registers. + * + * @spec openspec/specs/schema-import/spec.md#requirement-a-schema-retired-from-a-descriptor-must-be-removable-from-the-instance + */ + private function registersReferencing(int $schemaId): array { + $matches = []; + + foreach ($this->registerMapper->findAll(_rbac: false, _multitenancy: false) as $register) { + foreach ($register->getSchemas() as $ref) { + if ((int)$ref === $schemaId) { + $matches[] = $register; + break; + } + } + } + + return $matches; + }//end registersReferencing() +}//end class diff --git a/openspec/specs/schema-import/spec.md b/openspec/specs/schema-import/spec.md index a201d9fec2..c8fb8a5a94 100644 --- a/openspec/specs/schema-import/spec.md +++ b/openspec/specs/schema-import/spec.md @@ -206,3 +206,46 @@ Provenance MUST be visible via the schemas API. - THEN the property is flagged as a conflict requiring explicit per-property confirmation - AND it is not overwritten without that confirmation + +### Requirement: A schema retired from a descriptor MUST be removable from the instance + +Removing a schema from an app's register descriptor does not remove it from the +instance. `ImportHandler` unions the freshly-imported schema ids into the +register's existing list and prunes only the ids it has just shadowed by slug, +so a retired schema keeps its row, its magic table and its place in the +register's `schemas` array indefinitely. On a shared instance that is how a +cross-app slug collision outlives the descriptor change intended to end it. + +OpenRegister MUST therefore offer an operator command that removes a named, +app-owned schema: `openregister:schemas:prune-retired --app --slug `. + +The command MUST be scoped by owning application, so it can never reach a +same-slug schema another app owns. It MUST be dry-run by default. It MUST +refuse a schema that still owns objects unless `--force` is given. It MUST +unlink the schema id from every referencing register before the row is deleted, +matching both the integer and the string form of the id, because the stored list +holds either depending on which import era wrote it. + +#### Scenario: A retired schema is removed with its table and its links +- GIVEN app `filinq` owns schema `product`, which owns no objects +- AND one register references that schema id +- WHEN `openregister:schemas:prune-retired --app filinq --slug product --apply` runs +- THEN the schema row is deleted and its magic table is dropped +- AND the referencing register no longer lists that schema id + +#### Scenario: The command cannot reach another app's same-slug schema +- GIVEN app `filinq` owns schema `product` and app `decidiq` owns a different schema also slugged `product` +- WHEN the command runs with `--app filinq --slug product --apply` +- THEN only filinq's schema is deleted +- AND decidiq's schema is untouched + +#### Scenario: A schema that still owns objects is refused +- GIVEN app `shillinq` owns schema `Account`, which owns 115 objects +- WHEN the command runs with `--app shillinq --slug Account --apply` and no `--force` +- THEN nothing is deleted +- AND the command reports the object count and the `--force` escape + +#### Scenario: A slug the app does not own is reported, not failed +- GIVEN app `filinq` owns no schema slugged `task` +- WHEN the command runs with `--app filinq --slug task` +- THEN it reports the slug as not found and exits successfully diff --git a/tests/Unit/Command/PruneRetiredSchemasCommandTest.php b/tests/Unit/Command/PruneRetiredSchemasCommandTest.php new file mode 100644 index 0000000000..38093d5e3d --- /dev/null +++ b/tests/Unit/Command/PruneRetiredSchemasCommandTest.php @@ -0,0 +1,124 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Command; + +use OCA\OpenRegister\Command\PruneRetiredSchemasCommand; +use PHPUnit\Framework\TestCase; + +/** + * Locks unlinkSchemaId(): every stored form of an id goes, everything else stays. + */ +class PruneRetiredSchemasCommandTest extends TestCase { + + /** + * The plain integer form is removed. + * + * @return void + */ + public function testRemovesTheIntegerForm(): void { + $this->assertSame( + [159, 161], + PruneRetiredSchemasCommand::unlinkSchemaId(schemaRefs: [74, 159, 161], schemaId: 74) + ); + + }//end testRemovesTheIntegerForm() + + /** + * The string form is the same reference and must go too. + * + * A strict comparison would keep "74" and leave the register pointing at a + * row cascadeDeleteSchema() has just removed. + * + * @return void + */ + public function testRemovesTheStringForm(): void { + $this->assertSame( + ['159', 161], + PruneRetiredSchemasCommand::unlinkSchemaId(schemaRefs: ['74', '159', 161], schemaId: 74) + ); + + }//end testRemovesTheStringForm() + + /** + * Both forms in one list are removed together. + * + * @return void + */ + public function testRemovesBothFormsAtOnce(): void { + $this->assertSame( + [159], + PruneRetiredSchemasCommand::unlinkSchemaId(schemaRefs: [74, '74', 159], schemaId: 74) + ); + + }//end testRemovesBothFormsAtOnce() + + /** + * A different id that merely starts with the same digits is kept. + * + * @return void + */ + public function testKeepsIdsThatOnlyLookSimilar(): void { + $this->assertSame( + [7, 740, '7400'], + PruneRetiredSchemasCommand::unlinkSchemaId(schemaRefs: [7, 740, '7400', 74], schemaId: 74) + ); + + }//end testKeepsIdsThatOnlyLookSimilar() + + /** + * Non-numeric entries are preserved: they are not this id, and dropping + * them would corrupt the register's list. + * + * @return void + */ + public function testKeepsNonNumericEntries(): void { + $this->assertSame( + ['some-slug', 159], + PruneRetiredSchemasCommand::unlinkSchemaId(schemaRefs: ['some-slug', 74, 159], schemaId: 74) + ); + + }//end testKeepsNonNumericEntries() + + /** + * A list that never referenced the id comes back unchanged, and reindexed. + * + * @return void + */ + public function testUnreferencedListIsUnchanged(): void { + $this->assertSame( + [159, 161], + PruneRetiredSchemasCommand::unlinkSchemaId(schemaRefs: [159, 161], schemaId: 74) + ); + + }//end testUnreferencedListIsUnchanged() + + /** + * An empty list stays empty rather than throwing. + * + * @return void + */ + public function testEmptyListStaysEmpty(): void { + $this->assertSame( + [], + PruneRetiredSchemasCommand::unlinkSchemaId(schemaRefs: [], schemaId: 74) + ); + + }//end testEmptyListStaysEmpty() +}//end class From 842ba5028447f35ae22c3ccd370c2029d040d761 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 23:44:35 +0200 Subject: [PATCH 16/68] feat(organisation): carry the four chain-partner fields it lacked (#3247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(organisation): carry the four chain-partner fields it lacked Version1Date20260831020000 made an organisation able to say it is a tenant somewhere ELSE. That settled WHERE a counterparty lives, not WHO it is. dossiq carries that separately in its own `partnerOrganization` schema, and folding partners into Organisation would drop whatever this table cannot hold. Measured against the live table rather than assumed, which corrected me: of that schema's nine properties, `name`, `slug`, `isActive` and `groupId` already map onto columns here, and `oin` ALREADY EXISTS alongside `tooi`. I had told the team OIN was the missing field that decided the question. It was not. Four properties genuinely had nowhere to go, and those four are what this adds: contactEmail, defaultPermissionLevel, qualityScore, qualityStatus. All four are nullable with NO default. A tenant of this installation has no chain-partner contact address and has never been scored, and a zero score reads as "scored badly" — a different claim from "never assessed", and one a partner dashboard cannot tell apart afterwards. There is a test for exactly that. 🔴 `defaultPermissionLevel` is a DEFAULT, never an authorization input. ADR-002 Rule 1 keeps the organisation UUID as the only tenant key; nothing may read this column to decide whether an actor may act, only what a NEW share is proposed at. That is the trap `type` was deliberately kept out of, and the docblock says so where someone would otherwise reach for it. The field types register under the PROPERTY name, not the column name: Entity resolves a setter to lcfirst(substr($method, 3)) and looks that up, so a snake_case registration matches nothing and the integer cast silently never runs. Tested. Note for reviewers: OrganisationTest reports 3 PHP warnings from inside Nextcloud's own Entity.php. They are pre-existing — 3 before this change and 3 after, with 67 tests becoming 70. * fix(quality): restore the copyright tag and group the suppression Two CI findings, both mine. gate-1 spdx-headers: the new migration carried @license but no @copyright. phpcs: the PHPMD.LongVariable suppression sat between the @method tags and the rest, separated by a blank line, which breaks "tags must be grouped together". It now joins the existing class-level suppressions, and the REASON it exists moves to the property's own docblock where it is actually read: Entity maps a property to its column BY NAME, so `defaultPermissionLevel` must be the camelCase of `default_permission_level`, and shortening it to satisfy the rule would stop the mapping silently rather than failing. A bare suppression tag with the reason deleted would have satisfied phpcs and told the next reader nothing. --- lib/Db/Organisation.php | 63 ++++++++ lib/Migration/Version1Date20260901090000.php | 150 +++++++++++++++++++ tests/Unit/Db/OrganisationTest.php | 56 +++++++ 3 files changed, 269 insertions(+) create mode 100644 lib/Migration/Version1Date20260901090000.php diff --git a/lib/Db/Organisation.php b/lib/Db/Organisation.php index 237c2eb77a..8cd6fc56fe 100644 --- a/lib/Db/Organisation.php +++ b/lib/Db/Organisation.php @@ -81,6 +81,14 @@ * @method void setIsLocalTenant(?bool $isLocalTenant) * @method string|null getRemoteInstanceUrl() * @method void setRemoteInstanceUrl(?string $remoteInstanceUrl) + * @method string|null getContactEmail() + * @method void setContactEmail(?string $contactEmail) + * @method string|null getDefaultPermissionLevel() + * @method void setDefaultPermissionLevel(?string $defaultPermissionLevel) + * @method int|null getQualityScore() + * @method void setQualityScore(?int $qualityScore) + * @method string|null getQualityStatus() + * @method void setQualityStatus(?string $qualityStatus) * @method string|null getSummary() * @method void setSummary(?string $summary) * @method string|null getOin() @@ -119,6 +127,7 @@ * @method void setDeck(?array $deck) * * @SuppressWarnings(PHPMD.TooManyFields) + * @SuppressWarnings(PHPMD.LongVariable) * * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class */ @@ -339,6 +348,52 @@ class Organisation extends Entity implements JsonSerializable { */ protected ?string $remoteInstanceUrl = null; + /** + * Where to reach this organisation about the work. + * + * The organisation's own address, as opposed to any individual user account + * inside it. A chain partner is corresponded with as a body. + * + * @var string|null The contact address. + */ + protected ?string $contactEmail = null; + + /** + * The access level a share with this organisation starts at. + * + * 🔴 A DEFAULT, never an authorization decision. ADR-002 Rule 1 keeps the + * organisation UUID as the only tenant key, and nothing may read this to + * decide whether an actor MAY act, only to decide what a new share is + * proposed at. The same trap `type` was kept out of. + * + * The name is 22 characters and has to be: Entity maps a property to its + * column BY NAME, resolving a setter to lcfirst(substr($method, 3)) and + * looking that up, so this must be the camelCase of + * `default_permission_level`. Shortening it to satisfy PHPMD.LongVariable + * would stop the mapping silently rather than failing, which is why the + * class-level suppression exists. + * + * @var string|null The proposed starting level. + */ + protected ?string $defaultPermissionLevel = null; + + /** + * Chain-partner quality score, as scored by the installation working with them. + * + * Null rather than zero when unscored: a zero reads as "scored badly", which + * is a different claim from "never assessed". + * + * @var integer|null The score. + */ + protected ?int $qualityScore = null; + + /** + * The qualitative reading of {@see $qualityScore}. + * + * @var string|null The status. + */ + protected ?string $qualityStatus = null; + /** * Short summary for overview pages (OpenCatalogi `summary`). * @@ -541,6 +596,10 @@ public function __construct() { $this->addType(fieldName: 'type', type: 'string'); $this->addType(fieldName: 'isLocalTenant', type: 'boolean'); $this->addType(fieldName: 'remoteInstanceUrl', type: 'string'); + $this->addType(fieldName: 'contactEmail', type: 'string'); + $this->addType(fieldName: 'defaultPermissionLevel', type: 'string'); + $this->addType(fieldName: 'qualityScore', type: 'integer'); + $this->addType(fieldName: 'qualityStatus', type: 'string'); $this->addType(fieldName: 'summary', type: 'string'); $this->addType(fieldName: 'oin', type: 'string'); $this->addType(fieldName: 'tooi', type: 'string'); @@ -990,6 +1049,10 @@ public function jsonSerialize(): array { 'type' => $this->type ?? 'organisation', 'isLocalTenant' => ($this->isLocalTenant ?? true), 'remoteInstanceUrl' => $this->remoteInstanceUrl, + 'contactEmail' => $this->contactEmail, + 'defaultPermissionLevel' => $this->defaultPermissionLevel, + 'qualityScore' => $this->qualityScore, + 'qualityStatus' => $this->qualityStatus, 'summary' => $this->summary, 'oin' => $this->oin, 'tooi' => $this->tooi, diff --git a/lib/Migration/Version1Date20260901090000.php b/lib/Migration/Version1Date20260901090000.php new file mode 100644 index 0000000000..2b995076c5 --- /dev/null +++ b/lib/Migration/Version1Date20260901090000.php @@ -0,0 +1,150 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Give an organisation the four chain-partner fields it did not already have. + * + * Version1Date20260831020000 made an organisation able to say it is a tenant + * somewhere ELSE (`is_local_tenant`, `remote_instance_url`). That settled WHERE + * a counterparty lives. It did not settle WHO it is. + * + * dossiq carried that separately, in its own `partnerOrganization` schema, and + * folding partners into Organisation would have dropped whatever this table + * could not hold. Measured against the live table rather than assumed: of that + * schema's nine properties, `name`, `slug`, `isActive` and `groupId` already + * map onto columns here, and `oin` ALREADY EXISTS (alongside `tooi`) — the + * Organisatie-identificatienummer, how a Dutch government body is named across + * installations, was added before this. Four properties had nowhere to go, and + * those four are what this migration adds. + * + * All four are nullable with NO default. An organisation that is this + * installation's own tenant has no chain-partner contact address and has never + * been scored, and writing a zero onto it would read as "scored badly" rather + * than "not scored". + * + * @category Migration + * @package OCA\OpenRegister\Migration + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/openregister + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the chain-partner identity columns to `openregister_organisations`. + */ +final class Version1Date20260901090000 extends SimpleMigrationStep { + + /** + * The table this migration widens. + * + * @var string + */ + private const TABLE = 'openregister_organisations'; + + /** + * Add the columns when they are absent. + * + * @param IOutput $output Migration output. + * @param Closure(): ISchemaWrapper $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* @var ISchemaWrapper $schema The schema wrapper. */ + $schema = $schemaClosure(); + + if ($schema->hasTable(self::TABLE) === false) { + $output->warning(message: 'openregister_organisations is absent; skipping the chain-partner columns'); + + return null; + } + + $table = $schema->getTable(self::TABLE); + $added = []; + + foreach ($this->columnSpecifications() as $column) { + if ($table->hasColumn($column['name']) === true) { + continue; + } + + $table->addColumn($column['name'], $column['type'], $column['options']); + $added[] = $column['name']; + } + + if ($added === []) { + return null; + } + + $output->info(message: 'openregister_organisations: added ' . implode(', ', $added)); + + return $schema; + + }//end changeSchema() + + /** + * The columns this migration adds. + * + * @return array> The specifications. + */ + private function columnSpecifications(): array { + return [ + [ + 'name' => 'contact_email', + 'type' => Types::STRING, + 'options' => [ + 'notnull' => false, + 'length' => 255, + 'comment' => 'Where to reach this organisation about the work, as opposed ' + . 'to any individual user account inside it.', + ], + ], + [ + 'name' => 'default_permission_level', + 'type' => Types::STRING, + 'options' => [ + 'notnull' => false, + 'length' => 64, + 'comment' => 'The access level a share with this organisation starts at. ' + . 'A DEFAULT, never an authorization decision: ADR-002 Rule 1 keeps ' + . 'the organisation UUID as the only tenant key, and nothing may ' + . 'read this column to decide whether an actor may act.', + ], + ], + [ + 'name' => 'quality_score', + 'type' => Types::INTEGER, + 'options' => [ + 'notnull' => false, + 'comment' => 'Chain-partner quality score, as scored by the installation ' + . 'that works with them. Nullable with no default: a zero would ' + . 'read as "scored badly" rather than "not scored".', + ], + ], + [ + 'name' => 'quality_status', + 'type' => Types::STRING, + 'options' => [ + 'notnull' => false, + 'length' => 64, + 'comment' => 'The qualitative reading of quality_score.', + ], + ], + ]; + + }//end columnSpecifications() + +}//end class diff --git a/tests/Unit/Db/OrganisationTest.php b/tests/Unit/Db/OrganisationTest.php index 84ea4dd0b2..b3fc4c4e19 100644 --- a/tests/Unit/Db/OrganisationTest.php +++ b/tests/Unit/Db/OrganisationTest.php @@ -614,4 +614,60 @@ public function testToStringPersistsGeneratedUuid(): void { $result2 = (string)$this->organisation; $this->assertSame($result1, $result2); } + + /** + * The chain-partner fields register their types under the PROPERTY name. + * + * Entity::__call() resolves a setter to lcfirst(substr($method, 3)) and + * looks THAT up in _fieldTypes, so registering a snake_case column name + * matches nothing and the cast silently never runs — qualityScore would + * come back from the database as a string. + * + * @return void + */ + public function testChainPartnerFieldTypesAreRegistered(): void { + $fieldTypes = $this->organisation->getFieldTypes(); + + $this->assertSame('string', $fieldTypes['contactEmail']); + $this->assertSame('string', $fieldTypes['defaultPermissionLevel']); + $this->assertSame('integer', $fieldTypes['qualityScore']); + $this->assertSame('string', $fieldTypes['qualityStatus']); + } + + /** + * An unscored partner serialises as null, never as zero. + * + * A zero reads as "scored badly", which is a different claim from "never + * assessed", and a chain-partner dashboard cannot tell them apart after + * the fact. + * + * @return void + */ + public function testAnUnscoredPartnerSerialisesAsNullNotZero(): void { + $serialised = $this->organisation->jsonSerialize(); + + $this->assertNull($serialised['qualityScore']); + $this->assertNull($serialised['qualityStatus']); + $this->assertNull($serialised['contactEmail']); + $this->assertNull($serialised['defaultPermissionLevel']); + } + + /** + * The chain-partner fields round-trip through jsonSerialize. + * + * @return void + */ + public function testChainPartnerFieldsRoundTrip(): void { + $this->organisation->setContactEmail('post@gemeente.nl'); + $this->organisation->setDefaultPermissionLevel('read'); + $this->organisation->setQualityScore(72); + $this->organisation->setQualityStatus('adequate'); + + $serialised = $this->organisation->jsonSerialize(); + + $this->assertSame('post@gemeente.nl', $serialised['contactEmail']); + $this->assertSame('read', $serialised['defaultPermissionLevel']); + $this->assertSame(72, $serialised['qualityScore']); + $this->assertSame('adequate', $serialised['qualityStatus']); + } } From 67cc872252ffe845589b5975da5ec304d4ae2b0b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 06:56:22 +0200 Subject: [PATCH 17/68] spec(flow-portal-task): a portal-task node that asks a party outside the instance (#3249) New change in the ADR-098 chain, depending on flow-task-entity and flow-user-task-node. A separate openregister.portal-task node (not an external mode of user-task: performer resolution, delivery channel and completion payload all differ, and a mode would make half of each node's config keys invalid per mode) that: - matches the party from the case object's initiator role, frozen at creation and audited - creates a task with the external performer type (ADR-098 D3 amendment 2026-08-31), delta on the flow-tasks capability - suspends heartbeat-safe with a non-null resumeAt (findAbandonedSignals matches resume_at IS NULL; the 14-day reaper must never see a hersteltermijn) - delivers through portaliq's contribution surface (ADR-046); rendering is portaliq's follow-up change, one task line only - accepts an upload completion stored as an OR file attachment on the case object (ADR-022, decision 2026-08-31) - refuses completion by anyone but the matched party, fail-closed - loops on re-ask with a mandatory reason - consumes flow-business-timers for reminder, escalation and expiry --- openspec/changes/flow-portal-task/design.md | 236 ++++++++++++ openspec/changes/flow-portal-task/proposal.md | 170 +++++++++ .../specs/flow-portal-task/spec.md | 340 ++++++++++++++++++ .../flow-portal-task/specs/flow-tasks/spec.md | 59 +++ openspec/changes/flow-portal-task/tasks.md | 144 ++++++++ 5 files changed, 949 insertions(+) create mode 100644 openspec/changes/flow-portal-task/design.md create mode 100644 openspec/changes/flow-portal-task/proposal.md create mode 100644 openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md create mode 100644 openspec/changes/flow-portal-task/specs/flow-tasks/spec.md create mode 100644 openspec/changes/flow-portal-task/tasks.md diff --git a/openspec/changes/flow-portal-task/design.md b/openspec/changes/flow-portal-task/design.md new file mode 100644 index 0000000000..729450fdb7 --- /dev/null +++ b/openspec/changes/flow-portal-task/design.md @@ -0,0 +1,236 @@ +# Design: flow-portal-task + +## Context + +See proposal.md for the motivation. What the approach stands on, measured: + +- **The suspend/resume machinery is proven and per-node.** + `FlowNodeResumeState` scopes state to one node + (`lib/Service/Flow/FlowNodeResumeState.php:39-65`), `FlowSuspension` is + thrown with a non-null heartbeat by both shipped waiters + (`WaitNode.php:192`, `AwaitSignalNode.php:266`), and + `flow-user-task-node` specifies continuation on TASK terminality rather + than the run's signal slot. This node changes none of that; it changes + who the performer is and how the ask travels. +- **The reaper defines the floor.** `findAbandonedSignals()` matches + `resume_at IS NULL` (`lib/Db/FlowRunMapper.php:589-605`) and the worker + fails matches at 14 days (`lib/BackgroundJob/FlowRunWorker.php:94`, + `:311-349`). A hersteltermijn of six weeks parked on null would be + FAILED mid-term. Non-null `resumeAt` is therefore a correctness rule + here, not a style preference. +- **Portaliq's boundary is structural.** Contributions are descriptors, + never data (ADR-046 rule 3); data reaches a visitor only through + subject-scoped readers; actions forward server-to-server under a signed + `X-Portal-Subject` assertion + (`portaliq/lib/Contribution/IPortalContributionProvider.php`). The + portal task rides these rails; it adds no new edge surface. +- **The case object already owns files.** + `FileService::addFile()` writes into the object's own folder + (`lib/Service/FileService.php:1480`). The 2026-08-31 decision makes that + the ONLY landing place for a resident's upload. + +## Goals / Non-Goals + +**Goals:** + +- One node that asks a party outside the instance and holds the run open + until they answer, with the same graph semantics as + `flow-user-task-node`. +- Matching, visibility and completion authorization that are fail-closed + against the CASE's own data, so a portal subject can never see or answer + work that is not theirs. +- An upload path whose result is a case-object file attachment, so the + case sees its own evidence without a sync. +- A re-ask loop an author can draw in the graph, with the reason carried + to the party. + +**Non-Goals:** + +- Rendering anything. The portal page, the upload form and their styling + are portaliq's follow-up change. +- Notifying Nextcloud users. The caseworker's review step is an ordinary + `openregister.user-task`; this node's delivery goes outward only. +- Timers. Reminder cadence, business-day arithmetic and expiry enforcement + are `flow-business-timers`' rules; this node stores two timestamps and + names two rungs. +- Identity brokering. DigiD, eHerkenning and eIDAS terminate at + portaliq's edge (ADR-046, ADR-108); OpenRegister sees a resolved, + server-derived subject and nothing upstream of it. + +## Decisions + +### D-1: A separate node, not an external mode on `openregister.user-task` + +The one-line version: the performer resolution, the delivery channel and +the completion payload all differ, and a mode would make half of each +node's config keys invalid depending on the mode. + +The longer version, in the order that decided it: + +1. **The config vocabularies are disjoint.** `user-task` speaks candidate + users, groups, roles, routing strategies and fallbacks. This node + speaks a party role on the case, delivery addresses and upload + constraints. Under a mode flag, `validateConfig()` becomes a two-branch + validator where every key's validity depends on the flag, which is + precisely the shape that ships a config the author believed was + checked and the validator never read. +2. **The verb sets differ.** External tasks refuse `claim`, `unclaim` and + `delegate` (specs/flow-tasks delta). A mode would put refusable verbs + and poolable candidates one flag away from each other in the same node, + and the flag is exactly what a copy-pasted flow definition gets wrong. +3. **The delivery seams must not meet.** + `flow-task-inbox-projections` projects Nextcloud tasks into + notifications and VTODO; the portal seam is subject-scoped and outward. + Two nodes keep the seams apart by construction; a mode keeps them apart + by an `if`. +4. **The palette is the author's contract.** `flow-user-task-node` D-9 + already pairs the waiters with one line each. This node extends the set + to three: a signal is for a system that will call back; a user task is + for a performer in the organisation; a portal task is for a party + outside it. + +The cost is a third waiting node in the palette and some shared mechanics. +The mechanics are shared by reusing `flow-user-task-node`'s bridge +(suspension, terminality read, outcome placement, cancellation +propagation), not by copying it; only performer resolution, delivery and +completion handling are node-specific. + +### D-2: Declarative-vs-imperative (ADR-031) + +Same verdict as `flow-user-task-node` D-1, inherited deliberately: the +node is imperative because a node is the imperative half of the platform, +and everything it points at stays declarative. The party role is data on +the case schema; the delivery message content is a portal notification +key, not PHP; the reminder and escalation are declarative timer rungs; the +upload constraints are validated config, not code. The fence carries over +verbatim: no branch in `PortalTaskNode` may be about what a specific app's +case MEANS. + +### D-3: The match is frozen at creation + +The node resolves the party role against the case object once, when the +task is created, and stores the resolved party reference on the task. It +is not re-resolved at completion. + +A re-resolving match would let an edit to the case's initiator silently +transfer an open ask to a different person, with the audit showing neither +the transfer nor who authorized it. Frozen, the correction has one honest +path: cancel or re-ask, which creates a new task with a new match and a +new audit entry. The completion check compares the acting portal subject +to the STORED reference, fail-closed: no stored reference, no resolvable +subject, no match, all deny. + +### D-4: The heartbeat is non-null, inherited as a rule + +`flow-user-task-node` D-3 already argued this against the same two facts +(`findAbandonedSignals()` matching only null, the 14-day failure) and this +node's waits are LONGER: a hersteltermijn is measured in weeks. Same +default (15 minutes), same floor (5 minutes), same consequence stated +honestly: a run holding an open portal task is never reaped as abandoned, +and the thing that ends an unanswered ask is `expires_at` enforcement in +`flow-business-timers`. + +### D-5: The upload is a case-object file attachment, and the completion references it + +Files are stored through the file service onto the case object's own +folder before the task completion is recorded; the completion carries the +stored file references, not the bytes. Order matters: a completion that +records first and stores second can be interrupted into a completed task +whose evidence does not exist. Storing first degrades the other way, an +orphaned file on the right case, which is recoverable and visible. + +Node config declares whether an upload is required, how many files are +accepted, and the accepted types and maximum size; violations refuse the +completion naming the constraint. Any dossier folder view of the file is a +projection of the attachment (decision 2026-08-31), one-directional per +ADR-098 Decision 2; nothing in this change writes a second file store. + +### D-6: Re-ask is graph re-entry, and a reason is mandatory + +The loop lives in the graph, where the author can see it: a caseworker +review step (an ordinary user task) routes its rejection edge back into +the portal-task node. On re-entry the node finds its slot task TERMINAL +and creates a new task; on a heartbeat wake it finds its slot task OPEN +and suspends again. Terminality of the slot task is what distinguishes a +re-ask from a duplicate, so idempotence and looping use one mechanism +instead of two flags. + +The re-ask reason is read from a configured item field and is MANDATORY: +a firing that re-enters with no reason fails validation of the firing, not +silently. Asking a resident the same thing twice with no explanation is +the behaviour every complaint procedure exists to punish. The cycle count +and the previous task's uuid are recorded on the new task, so "asked +three times" is a query, not an archaeology. + +### D-7: Delivery is a contract with portaliq, owned here as a seam only + +This change specifies WHAT crosses the seam: a subject-scoped read that +lists a portal subject's open portal tasks with their case context, and a +delivery request (portal inbox message plus mail) recorded at creation and +at re-ask. It deliberately does not specify how portaliq renders either; +that is portaliq's follow-up change, and tasks.md carries exactly one +pointer line for it (one canonical home per spec). + +Fail-open is refused on both sides of the seam: a task whose delivery +request cannot be recorded still exists and still suspends the run (the +ask outlives a mail outage), and the delivery state is queryable so the +caseworker sees "not yet delivered" instead of silence. + +### D-8: The overdue path names timer rungs and owns nothing else + +The node passes `due_at` and `expires_at` references to the task, and the +flow author attaches an escalation ladder from `flow-business-timers`: a +`preBreach` rung addressed to the party (delivered through the same portal +seam as the ask) is the reminder; a `slaBreached` rung addressed to the +caseworker role is the escalation; expiry enforcement transitions the task +terminally there. This node contains no sweep, no cadence and no +business-day arithmetic, and its spec asserts that absence. + +## Risks / Trade-offs + +- **The portaliq follow-up can lag, leaving tasks undeliverable.** + → The delivery state is recorded and queryable, the task and suspension + are correct without the portal, and the seam is specified here so the + portaliq change implements against a contract instead of guessing. +- **Party matching is only as good as the case's party data.** A case + whose initiator reference is stale asks the wrong person. + → The match is frozen and audited (D-3), so the error is visible and + recoverable through re-ask; and a case naming nobody fails the firing + loudly instead of creating an unperformable task. +- **Long-lived heartbeats accumulate.** A six-week hersteltermijn at a + 15-minute heartbeat is ~4,000 no-op wakes. + → The same trade `AwaitSignalNode` documents and the platform already + pays; the interval is configurable per node. +- **A three-waiter palette can confuse authors.** + → D-1 point 4: three one-line descriptions written as a set; and using + the wrong node fails visibly (a user task for a resident has no + performable candidate; a portal task for an employee never reaches an + inbox). +- **Uploads are attack surface.** Type and size constraints are validated + fail-closed (D-5), the file lands under the case object's ordinary OR + permissions, and the portal edge's existing throttling (ADR-082) fronts + the endpoint. Anti-virus scanning is the instance's existing NC file + pipeline, not re-specified here. + +## Migration Plan + +Additive: one node registration, one performer-type extension on an +entity that is being implemented in parallel and has no shipped consumers +to migrate. Deploy order is the dependency chain: +`flow-task-entity` → `flow-user-task-node` → this change → portaliq's +contribution change. Rollback is removing the registration; open external +tasks are then terminated through the ordinary run-cancellation +propagation. + +## Open Questions + +- **Does one portal task ever address several parties?** A case with two + applicants may owe both an ask. Provisionally: one task, one matched + party; a multi-party ask is modelled in the graph (a fan-out over the + party list), which `flow-parallel-streams` serves better than a + multi-match here would. +- **Does the caseworker need a "complete on behalf of the resident at the + desk" path?** Today: no; the desk scenario is the caseworker uploading + to the case directly and cancelling the portal task with a reason. If + practice demands more, it arrives as a delegation rule on the `external` + performer type in `flow-tasks`, not as a bypass in this node. diff --git a/openspec/changes/flow-portal-task/proposal.md b/openspec/changes/flow-portal-task/proposal.md new file mode 100644 index 0000000000..ebdd025340 --- /dev/null +++ b/openspec/changes/flow-portal-task/proposal.md @@ -0,0 +1,170 @@ +--- +kind: code +depends_on: [flow-task-entity, flow-user-task-node] +--- + +# Proposal: flow-portal-task + +## Summary + +Ask a person who has no account. A new node type `openregister.portal-task` +creates a task for an EXTERNAL party (ADR-098 Decision 3 as amended +2026-08-31: performer type `external`), matched from the subject case +object's party role (default: the initiator), and suspends the run +heartbeat-safe until that party answers. The task reaches the party through +portaliq's contribution surface (ADR-046): a portal inbox message plus a +mail, never a Nextcloud notification. The answer can carry an upload, and +that file lands as an OpenRegister file attachment on the case object +(ADR-022); any dossier folder view is a projection of that attachment. Only +the matched party may complete. A caseworker who is not satisfied routes the +flow back into the node with a reason, and the party is asked again. The +overdue path (reminder, escalation, enforcement) is consumed from +`flow-business-timers`, not rebuilt here. + +## Why + +**The engine can now ask a person with an account. Dutch case handling +mostly waits on a person without one.** `flow-user-task-node` gives the +graph a performer who can be found, told and allowed to say no, but its +performer model resolves against Nextcloud: a uid, a group, a role, an +agent. The hersteltermijn (ADR-098 Context: wait-on-citizen is the +CMMN-shaped core of Dutch case handling) is addressed to a resident who +authenticates with DigiD at the portal's edge and will never hold a +Nextcloud account. Today that resident is unreachable from the graph, so +every "send the applicant a letter and wait" step stays manual. + +**The projection alternative was measured and rejected on 2026-08-31.** A +portaliq-side mirror of tasks would be a second store that can drift, the +`agentflow` defect class ADR-098 Decision 2 forbids, and it could not +suspend a run on the resident's answer. Residents become first-class +performers by EXTENDING the performer model instead. That amendment is +recorded in ADR-098 Decision 3. + +**The delivery surface already exists and already enforces the boundary.** +Portaliq discovers a leaf app's `PortalContributionProvider` by convention +(ADR-046), aggregates DESCRIPTORS never data, and serves rows through +subject-scoped readers; its endpoint actions forward server-to-server with a +signed `X-Portal-Subject` assertion, never the client bearer +(`portaliq/lib/Contribution/IPortalContributionProvider.php`). A portal task +that rides this contract inherits the tenant boundary instead of +re-implementing it. What portaliq renders is portaliq's change to make; this +change specifies the OpenRegister half of the seam. + +**The upload has exactly one correct home.** OpenRegister already attaches +files to an object's own folder (`lib/Service/FileService.php`, +`addFile()`). Decided 2026-08-31: a resident's uploaded file lands as an OR +file attachment on the CASE object, and any dossier folder view of it is a +projection. A file held in a portal-private store is evidence the case +cannot see. + +**And the suspension discipline is measured, not optional.** +`FlowRunMapper::findAbandonedSignals()` matches `resume_at IS NULL` +(`lib/Db/FlowRunMapper.php:589-605`) and `FlowRunWorker` FAILS those runs at +14 days (`lib/BackgroundJob/FlowRunWorker.php:94`, `:311-349`). A +hersteltermijn is routinely longer than 14 days. A portal-task node that +parked on a null `resumeAt` would hand the reaper its first input and fail +exactly the waiting-on-citizen runs this node exists to hold open. The node +copies `AwaitSignalNode`'s non-null heartbeat discipline verbatim. + +## What Changes + +- **A new node, `openregister.portal-task`**, in `lib/Service/Flow/Nodes/`, + implementing `IFlowNode`, `IFlowNodeConfigKeys` and `IFlowNodeConfigForm`, + registered through `lib/Listener/FlowNodeRegistrationListener.php`. A + SEPARATE node, not an external-performer mode of + `openregister.user-task`; design.md D-1 argues the choice. In one line: + the performer resolution, the delivery channel and the completion payload + all differ, and a mode would make half of each node's config keys invalid + depending on the mode. +- **The `external` performer type on the task entity** (Modified capability + `flow-tasks`): performer reference is a party reference resolved to a + portal subject, such tasks appear in NO Nextcloud inbox or candidate + pool, `claim`/`unclaim`/`delegate` are refused for them, and completion + authorization matches the acting portal subject to the stored party + reference, fail-closed. +- **Party matching from the case, not from config.** The node names a party + ROLE on the subject case object (default `initiator`); the concrete party + is resolved at task creation, frozen on the task, and recorded in the + audit. A case that names nobody for that role fails the firing loudly. +- **Suspend and resume exactly as `flow-user-task-node`**: one task per + node per run via the node's own resume slot, continuation on task + terminality, outcome written onto every item, non-null heartbeat + `resumeAt` (15-minute default, 5-minute floor, matching + `AwaitSignalNode.php:87`, `:98`). +- **Delivery through portaliq**: the task is exposed subject-scoped to the + portal contribution seam, and its creation requests a portal inbox + message plus a mail to the party. Nothing is delivered through + `INotificationManager` or the VTODO projection; an external performer has + neither. +- **Upload completion onto the case**: a completion may carry one or more + files; each is stored as an OR file attachment on the case object via the + file service, and the task's completion record references the stored + files. Node config declares whether an upload is required and constrains + type and size. +- **Re-ask as graph re-entry**: when the flow routes back into the node + after its task went terminal, the node creates a NEW task carrying a + mandatory reason, increments the cycle count, and delivers again. The + previous task and its audit survive untouched. +- **The overdue path consumes `flow-business-timers`**: the node passes + `due_at`/`expires_at` references to the task; a `preBreach` escalation + rung is the resident's reminder (through the portal delivery seam), a + `slaBreached` rung escalates to the caseworker role, and expiry + enforcement transitions the task there, never here. The node owns no + clock. + +## What does NOT change + +- **`flow-task-entity` and `flow-user-task-node`** own everything they + already specify: table, lifecycle, verbs, authorization, inbox, the + user-task node's mechanics. This change adds one performer type to the + entity's model and one sibling node; it redefines nothing. +- **Portaliq's own surface.** The contribution that renders the portal task + and its upload form is a follow-up change in portaliq's repository. One + canonical home per spec: this change specifies the OpenRegister seam it + will consume, and tasks.md carries exactly one pointer line for it. +- **`flow-business-timers`** owns reminders, escalation ladders, + opschorting and expiry enforcement. This change names the rungs it + consumes and stores the two timestamps. +- **`flow-task-inbox-projections`** stays Nextcloud-facing. External tasks + are explicitly outside its notification and VTODO scope. +- **`AwaitSignalNode`, `POST /api/flow-runs/{uuid}/resume`** and every + existing node and endpoint are untouched. The resume endpoint cannot + complete a portal task, exactly as it cannot complete a user task. + +## Capabilities + +### New Capabilities + +- `flow-portal-task`: the `openregister.portal-task` step node: party + matching from the case, external task creation, heartbeat-safe + suspension, portal delivery contract, upload-to-case completion, + matched-party-only completion, re-ask cycles, and the timer consumption + contract. + +### Modified Capabilities + +- `flow-tasks`: gains the `external` performer type (ADR-098 D3 amendment + 2026-08-31) with portal-scoped visibility and authorization; delta in + `specs/flow-tasks/spec.md`. + +## Impact + +- **Affected specs**: new `flow-portal-task`; delta on `flow-tasks`. +- **Affected code**: new `lib/Service/Flow/Nodes/PortalTaskNode.php`; a + party-matching resolver against the subject object; the external-performer + branch in `flow-task-entity`'s authorization service; a portal delivery + seam (subject-scoped task read plus a delivery request record) consumed + by portaliq; completion handling that stores uploads through + `FileService::addFile()` onto the case object. +- **Affected apps**: portaliq (follow-up change in its own repo renders the + contribution); dossiq consumes the node in its hersteltermijn flow, in + its own change. +- **Depends on**: `flow-task-entity` (the task, its lifecycle and audit) + and `flow-user-task-node` (the suspend/resume, outcome placement and + cancellation mechanics this node reuses). +- **ADRs**: ADR-098 D3 as amended 2026-08-31 (`external` performer; upload + lands on the case object), D2 (no second store), D4 (the case object is + the anchor); ADR-046 (portal contribution contract); ADR-108 (public + surface placement belongs to portaliq); ADR-022 (apps consume OR + abstractions; the file attachment IS the record); ADR-005 (fail-closed + authorization); ADR-031 (declarative-vs-imperative, argued in design.md). diff --git a/openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md b/openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md new file mode 100644 index 0000000000..49b6563d24 --- /dev/null +++ b/openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md @@ -0,0 +1,340 @@ +## Purpose + +One step type that asks a party OUTSIDE the Nextcloud instance to do +something: it matches the party from the case, creates an external task, +parks the run heartbeat-safe, delivers the ask through the portal, accepts +an answer whose files land on the case object, refuses everyone but the +matched party, and can ask again with a reason. + +## ADDED Requirements + +### Requirement: A portal-task step creates one external task and suspends the run + +The system SHALL provide a step node type `openregister.portal-task`. On +its first firing with items, the node SHALL create ONE task through the +`flow-tasks` task service with performer type `external`, carrying the +run's uuid and this node's id as provenance, and SHALL then suspend the +run. + +Whether a task already exists for this firing SHALL be determined from +this node's own resume slot, exactly as `flow-user-task-node` specifies: a +heartbeat wake, a lost delivery or a duplicated worker pass MUST NOT +produce a second ask. + +A firing that carries no items SHALL create no task and SHALL NOT suspend +the run. + +The node SHALL delegate task creation in full to the task service. It +SHALL NOT define a task field, a lifecycle state or an authorization rule +of its own. + +#### Scenario: The first firing produces an external task and a suspended run + +- **GIVEN** a flow whose graph contains one `openregister.portal-task` node + and a subject case object naming an initiator +- **WHEN** the run reaches that node carrying one item +- **THEN** exactly one task MUST exist with performer type `external`, + carrying the run's uuid and the node's id +- **AND** the run MUST be suspended +- @e2e a flow with a portal task suspends and the task reaches the portal + subject + +#### Scenario: A heartbeat wake does not ask twice + +- **GIVEN** a run suspended on a portal-task node whose task is still open +- **WHEN** the worker wakes it on its heartbeat and the node fires again +- **THEN** the task count for that run and node MUST still be one +- **AND** the run MUST suspend again +- @e2e exclude covered by PortalTaskNode unit tests over a resumed context + +#### Scenario: An empty branch creates nothing + +- **GIVEN** a routing node that sent every item down a sibling branch +- **WHEN** the portal-task node on the empty branch fires with no items +- **THEN** no task MUST be created +- **AND** the run MUST NOT suspend +- @e2e exclude engine-internal suspend rule, covered by PortalTaskNode unit + tests + +### Requirement: The matched party comes from the case and is frozen at creation + +The node's configuration SHALL name a party ROLE on the subject case +object, defaulting to `initiator`. At task creation the system SHALL +resolve that role against the case object to a party reference, SHALL +store the resolved reference on the task, and SHALL record the resolution +in the task audit. + +The stored reference SHALL NOT be re-resolved afterwards. A later edit to +the case's party data SHALL NOT transfer an open task; correcting a wrong +match SHALL be done by cancelling or re-asking, which creates a new task +with a new match and a new audit entry. + +A firing whose case names NO party for the configured role SHALL fail the +firing with an error naming the role and the case, and SHALL NOT create a +task. An unperformable ask parked in a suspended run buries the mistake. + +#### Scenario: The initiator is matched and recorded + +- **GIVEN** a portal-task node with the default party role and a case whose + initiator is a known party +- **WHEN** the node fires +- **THEN** the created task MUST store that party's reference +- **AND** the audit MUST record the role and the resolved reference +- @e2e exclude covered by party-matching unit tests + +#### Scenario: A case with no initiator fails loudly + +- **GIVEN** a portal-task node whose subject case object names no party for + the configured role +- **WHEN** the node fires with items +- **THEN** the firing MUST fail with an error naming the role +- **AND** no task MUST be created +- @e2e exclude covered by party-matching unit tests + +#### Scenario: Editing the case does not move an open ask + +- **GIVEN** an open portal task matched to party A +- **WHEN** the case's initiator is changed to party B +- **THEN** the task's stored party reference MUST still be party A +- **AND** party B MUST NOT be able to complete it +- @e2e exclude covered by completion-authorization unit tests + +### Requirement: The suspension is heartbeat-safe and continues on task terminality + +The node SHALL suspend with a non-null heartbeat `resumeAt`, defaulting to +15 minutes and clamped to a 5-minute floor, matching the shipped waiters. +It SHALL NEVER suspend with a null `resumeAt`: that is the only shape +`findAbandonedSignals` reaps, and the 14-day failure it drives is shorter +than an ordinary hersteltermijn. + +A suspended portal-task node SHALL continue the run only when its task has +reached a terminal state, read from the TASK, never from the run context's +signal slot. While the task is non-terminal the node SHALL suspend again +without restamping its recorded creation time. + +When the node continues, it SHALL write the task's result onto EVERY item +it passes on, under a configurable key defaulting to `portalTask`, +carrying at minimum the outcome, the submitted answer fields, the stored +file references, and the matched party reference. A task that went +terminal WITHOUT a completion (terminated, expired) SHALL be +distinguishable downstream from a completed one. + +#### Scenario: A suspended portal task is reachable by the clock + +- **GIVEN** a run suspended on a portal-task node +- **WHEN** its persisted resume time is read +- **THEN** it MUST NOT be null +- @e2e exclude covered by a unit test asserting the thrown suspension + +#### Scenario: Completing the task advances the run with the answer on the items + +- **GIVEN** a run suspended on a portal-task node +- **WHEN** the matched party completes the task +- **THEN** the run MUST become due and continue past the node +- **AND** every item leaving the node MUST carry the outcome and the file + references under the configured key +- @e2e a resident's completed portal task advances the case flow + +#### Scenario: An expired ask is not an answer + +- **GIVEN** a portal task transitioned terminally by expiry enforcement +- **WHEN** the run continues past the node +- **THEN** the item payload MUST distinguish the expiry from a completion +- @e2e exclude covered by PortalTaskNode unit tests over a terminated task + +### Requirement: Delivery rides the portal contribution surface and nothing else + +Creating an external task, and every re-ask, SHALL record a delivery +request for a portal inbox message and a mail to the matched party. The +delivery state SHALL be queryable, so an undelivered ask reads as "not +yet delivered" rather than as silence. + +The system SHALL expose a subject-scoped read listing a portal subject's +open portal tasks with their case context, shaped for consumption through +the ADR-046 contribution contract: descriptors aggregate, rows stay behind +subject-scoped readers, and one subject's read MUST NOT return or count +another subject's tasks. + +An external task SHALL NOT be delivered through `INotificationManager`, +SHALL NOT be projected to CalDAV, and SHALL NOT appear in any Nextcloud +user's or group's inbox. The rendering of the portal task and its upload +form is portaliq's own change and is NOT specified here. + +A delivery request that cannot be recorded SHALL NOT roll back the task or +the suspension: the ask outlives a delivery outage, and the queryable +delivery state is what makes the outage visible. + +#### Scenario: The task is visible to its matched subject and to nobody else + +- **GIVEN** an open portal task matched to subject A, and portal subjects A + and B +- **WHEN** each subject's portal task list is read +- **THEN** subject A's list MUST contain the task with its case context +- **AND** subject B's list MUST NOT contain it and MUST NOT count it +- @e2e a portal task is listed for its matched subject and hidden from + another subject + +#### Scenario: No Nextcloud channel carries the ask + +- **GIVEN** a portal task created for an external party +- **WHEN** delivery runs +- **THEN** no Nextcloud notification MUST be sent +- **AND** no VTODO projection MUST be written +- @e2e exclude covered by delivery-seam unit tests + +#### Scenario: A failed delivery leaves the ask standing and visible + +- **GIVEN** a portal task whose delivery request cannot be recorded +- **WHEN** the firing finishes +- **THEN** the task MUST exist and the run MUST be suspended +- **AND** the delivery state MUST read as not delivered +- @e2e exclude covered by delivery-seam unit tests + +### Requirement: An upload completion lands as a file attachment on the case object + +A completion MAY carry files, and the node's configuration SHALL declare +whether at least one file is REQUIRED, how many are accepted, and the +accepted types and maximum size. A completion violating a constraint SHALL +be refused naming the constraint, and the task SHALL remain open. + +Each accepted file SHALL be stored as an OpenRegister file attachment on +the CASE object, through the file service, BEFORE the completion is +recorded; the completion SHALL reference the stored files rather than +carrying bytes. Any dossier folder view of the file is a projection of +that attachment (decision 2026-08-31); no portal-private file store SHALL +be written. + +#### Scenario: The uploaded file is on the case + +- **GIVEN** a portal task requiring one file +- **WHEN** the matched party completes it with a valid file +- **THEN** the file MUST exist as a file attachment on the case object +- **AND** the completion MUST reference it +- @e2e a resident's upload appears as a file on the case object + +#### Scenario: A required upload cannot be skipped + +- **GIVEN** a portal task requiring one file +- **WHEN** the matched party submits a completion with no file +- **THEN** the completion MUST be refused naming the requirement +- **AND** the task MUST remain open +- @e2e exclude covered by completion-validation unit tests + +#### Scenario: An oversized file is refused before anything is stored + +- **GIVEN** a portal task with a configured maximum file size +- **WHEN** the matched party submits a larger file +- **THEN** the completion MUST be refused naming the limit +- **AND** no file MUST be stored on the case +- @e2e exclude covered by completion-validation unit tests + +### Requirement: Only the matched party completes, fail-closed + +Completion of an external task SHALL be authorized by comparing the acting +portal subject to the task's STORED party reference. Any other caller +SHALL be denied: another portal subject, any authenticated Nextcloud user +including administrators acting through the portal seam, and any caller +whose subject cannot be resolved. When the comparison cannot be evaluated, +the answer SHALL be denial, never a skipped check. + +`POST /api/flow-runs/{uuid}/resume` SHALL NOT be able to complete a portal +task, for the same reason it cannot complete a user task: it authorizes +running the flow, not answering for a performer. + +A caseworker who needs the ask withdrawn SHALL cancel or re-ask through +the flow; there SHALL be no completion-on-behalf path for external tasks. + +#### Scenario: Another subject who knows the task cannot answer it + +- **GIVEN** an open portal task matched to subject A +- **WHEN** authenticated portal subject B attempts to complete it +- **THEN** the completion MUST be denied +- **AND** the task state MUST be unchanged +- @e2e another portal subject cannot complete a task that is not theirs + +#### Scenario: The resume endpoint cannot answer for the resident + +- **GIVEN** a run suspended on a portal-task node, and a Nextcloud user who + may run the flow +- **WHEN** that user posts a decision to the run's resume endpoint +- **THEN** the task MUST remain non-terminal +- **AND** the run MUST remain suspended +- @e2e exclude same contract as flow-user-task-node, covered by its e2e plus + PortalTaskNode unit tests + +#### Scenario: An unresolvable subject is denied + +- **GIVEN** an open portal task and a completion whose acting subject cannot + be resolved +- **WHEN** completion is attempted +- **THEN** it MUST be denied with a reason +- **AND** the denial MUST be recorded in the task audit +- @e2e exclude covered by completion-authorization unit tests + +### Requirement: A re-ask creates a new task carrying a mandatory reason + +When the flow routes back into a portal-task node whose slot task is +TERMINAL, the node SHALL create a NEW external task: matched afresh from +the case, carrying a re-ask reason, recording the cycle number and the +previous task's uuid, and delivered like a first ask. + +The reason SHALL be read from a configured item field and SHALL be +MANDATORY on re-entry: a re-entering firing with no reason SHALL fail the +firing rather than ask the party the same thing with no explanation. + +The previous task and its audit SHALL remain untouched. The cycle count +SHALL be queryable, so "how often has this party been asked" is a read, +not a reconstruction. + +#### Scenario: A rejected submission goes back with the reason + +- **GIVEN** a flow where a caseworker review step routes its rejection edge + back into the portal-task node, and a completed first ask +- **WHEN** the reviewer rejects with a reason and the node fires again +- **THEN** a second task MUST exist carrying the reason, cycle number 2 and + the first task's uuid +- **AND** the first task MUST be unchanged +- @e2e a rejected submission returns to the resident with the reason + +#### Scenario: A re-ask without a reason is refused + +- **GIVEN** a portal-task node re-entered after its task went terminal, with + no reason on the configured item field +- **WHEN** the node fires +- **THEN** the firing MUST fail naming the missing reason +- **AND** no new task MUST be created +- @e2e exclude covered by PortalTaskNode re-entry unit tests + +### Requirement: The overdue path is consumed from flow-business-timers, never rebuilt + +The node SHALL pass `due_at` and `expires_at` references through to the +task and SHALL implement no sweep, no cadence and no business-day +arithmetic of its own. + +The reminder and escalation contract SHALL be expressed as +`flow-business-timers` escalation rungs: a `preBreach` rung addressed to +the PARTY is delivered through the same portal delivery seam as the ask, +and a `slaBreached` rung addressed to the caseworker role escalates +inside the organisation. Expiry enforcement transitioning the task is +that capability's rule; this node only guarantees that an +expiry-terminated task continues the run distinguishably (see the +suspension requirement). + +#### Scenario: A reminder reaches the party without this node owning a clock + +- **GIVEN** a portal task with a due date and a preBreach reminder rung +- **WHEN** the rung fires +- **THEN** the reminder MUST be delivered through the portal delivery seam + to the matched party +- **AND** no timer logic MUST exist in the portal-task node +- @e2e exclude timer firing is flow-business-timers' surface; the seam is + covered by delivery-seam unit tests + +#### Scenario: A breach escalates inward, not to the party + +- **GIVEN** a portal task with a slaBreached escalation rung naming the + caseworker role +- **WHEN** the rung fires +- **THEN** the escalation MUST be addressed to the caseworker role +- **AND** the party MUST NOT receive it +- @e2e exclude covered by flow-business-timers rung-addressing unit tests diff --git a/openspec/changes/flow-portal-task/specs/flow-tasks/spec.md b/openspec/changes/flow-portal-task/specs/flow-tasks/spec.md new file mode 100644 index 0000000000..4b39b085d5 --- /dev/null +++ b/openspec/changes/flow-portal-task/specs/flow-tasks/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: The external performer type is portal-scoped and never pooled + +The performer model SHALL additionally accept `performer_type: external` +(ADR-098 Decision 3 as amended 2026-08-31): a party outside the Nextcloud +instance, referenced by a party reference that resolves to a portal +subject, never by a Nextcloud uid, group or role. + +An external task SHALL always be ASSIGNED to its stored party reference at +creation. The verbs `claim`, `unclaim` and `delegate` SHALL be refused for +external tasks with an error naming the performer type: there is no +candidate pool to claim from and no mandate model for parties outside the +instance. + +An external task SHALL NOT appear in any Nextcloud user's or group's inbox +query, SHALL NOT be counted in any inbox total, and SHALL NOT be projected +by the Nextcloud-facing notification or calendar projections. Its +visibility inside the instance is through the task's subject object +(tasks anchored to a case remain readable to the case's authorized +caseworkers); its visibility outside the instance is the portal seam +specified by `flow-portal-task`. + +Completion authorization for an external task SHALL compare the acting +portal subject to the stored party reference and SHALL deny when the +comparison cannot be evaluated. The audit SHALL record performer type +`external` and the acting subject reference on every verb, exactly as it +records the other performer types. + +#### Scenario: An external task is absent from every Nextcloud inbox + +- **GIVEN** an external task anchored to a case +- **WHEN** any Nextcloud user requests their inbox +- **THEN** the task MUST NOT appear +- **AND** it MUST NOT be counted in the total +- @e2e exclude covered by TaskInboxService unit tests over an external task + +#### Scenario: Claim and delegate are refused on an external task + +- **GIVEN** an external task +- **WHEN** claim or delegate is called by any caller +- **THEN** the verb MUST be refused with an error naming the performer type +- @e2e exclude covered by TaskService verb unit tests + +#### Scenario: The caseworker still sees the ask on the case + +- **GIVEN** an external task anchored to a case and a caseworker authorized + on that case +- **WHEN** the caseworker requests the tasks anchored to that object +- **THEN** the external task MUST appear with its delivery state +- @e2e the case detail lists the outstanding portal task for the caseworker + +#### Scenario: The audit names the external performer + +- **GIVEN** an external task completed by its matched portal subject +- **WHEN** the audit is read +- **THEN** the completion entry MUST record performer type `external` and + the acting subject reference +- @e2e exclude covered by task-audit unit tests diff --git a/openspec/changes/flow-portal-task/tasks.md b/openspec/changes/flow-portal-task/tasks.md new file mode 100644 index 0000000000..b89fe129bb --- /dev/null +++ b/openspec/changes/flow-portal-task/tasks.md @@ -0,0 +1,144 @@ +# Tasks: flow-portal-task + +## 1. The node + +- [ ] 1.1 `lib/Service/Flow/Nodes/PortalTaskNode.php` implementing + `IFlowNode`, `IFlowNodeConfigKeys` and `IFlowNodeConfigForm`. Follow + `UserTaskNode` for shape: EUPL-1.2 header, `@spec` on every method, a + file docblock stating the three-waiter division of labour (signal: + a system that calls back; user task: a performer in the organisation; + portal task: a party outside it). `getId()` returns + `openregister.portal-task`. +- [ ] 1.2 `configForm()` + `configKeys()`: title/description templates, + party role (default `initiator`), upload requirements (required, + count, types, max size), `outcomeKey` (default `portalTask`), + re-ask reason item field, `dueAt`/`expiresAt` references, + `heartbeatMinutes`, `advance`. Register the node in + `lib/Listener/FlowNodeRegistrationListener.php`. +- [ ] 1.3 `validateConfig()`: refuse a config with no party role; refuse + `advance: null` exactly as `UserTaskNode` does. The mandatory re-ask + reason cannot be checked statically (whether a firing is a re-entry + is runtime knowledge), so it is validated at fire time per section 6. + +## 2. Party matching + +- [ ] 2.1 Party resolver: resolve the configured role against the subject + case object at creation, freeze the reference on the task, record + role and reference in the audit; fail the firing loudly when the case + names nobody for the role. +- [ ] 2.2 No re-resolution anywhere: completion authorization reads the + STORED reference only; add the case-edit scenario as a regression + test. + +## 3. Suspend, resume, outcome + +- [ ] 3.1 Reuse `flow-user-task-node`'s bridge for suspension and + continuation: one task per node per run via the resume slot, + non-null heartbeat `resumeAt` (15-minute default, 5-minute floor, + never null: `FlowRunMapper::findAbandonedSignals()` matches + `resume_at IS NULL` and `FlowRunWorker` fails matches at 14 days), + continuation on task terminality read from the task. +- [ ] 3.2 Outcome placement onto every item under `outcomeKey`: outcome, + answer fields, stored file references, matched party reference; + expiry/termination distinguishable from completion. + +## 4. The portal delivery seam + +- [ ] 4.1 Subject-scoped portal-task read: list one portal subject's open + external tasks with case context, shaped for ADR-046 consumption + (descriptor aggregate, subject-scoped rows, no cross-subject rows or + counts). +- [ ] 4.2 Delivery request record (portal inbox message + mail) written at + creation and re-ask, queryable delivery state, failure leaves the + task and suspension standing. + +## 5. Completion + +- [ ] 5.1 Completion endpoint on the portal seam: validate upload + constraints fail-closed, store each accepted file via + `FileService::addFile()` onto the CASE object BEFORE recording the + completion, reference the stored files from the completion. +- [ ] 5.2 Completion authorization: acting portal subject vs stored party + reference, deny on any mismatch or unresolvable comparison, audit + every denial; no completion-on-behalf path. +- [ ] 5.3 Keep `POST /api/flow-runs/{uuid}/resume` unable to touch an + external task (same contract as `flow-user-task-node`); regression + test it. + +## 6. Re-ask + +- [ ] 6.1 Re-entry path: slot task terminal + reason present → new task + (fresh match, reason, cycle number, previous task uuid) + delivery; + slot task terminal + reason absent → fail the firing naming the + missing reason; slot task open → suspend again (heartbeat case). + +## 7. The external performer type (flow-tasks delta) + +- [ ] 7.1 Extend the task entity/service with `performer_type: external` + and the party-reference performer shape; refuse `claim`, `unclaim` + and `delegate` for it naming the performer type. +- [ ] 7.2 Exclude external tasks from every Nextcloud inbox query, count + and projection; keep them readable on their anchored object for + authorized caseworkers, with delivery state. + +## 8. Timers + +- [ ] 8.1 Pass `due_at`/`expires_at` references through to the task; wire + the preBreach (party reminder via the portal seam) and slaBreached + (caseworker escalation) rung addressing as consumption of + `flow-business-timers`; add no clock, sweep or business-day code + here. + +## 9. Follow-up (not in this change) + +- [ ] 9.1 portaliq: a follow-up change IN PORTALIQ'S REPO contributes the + portal-task collection and completion action (rendering the task, + the upload form and the inbox message) against the seam of section + 4. One canonical home per spec; nothing of it is specified here. + +## 10. Tests + +- [ ] 10.1 Node unit tests: idempotence across a heartbeat wake, empty + firing, frozen match incl. the case-edit regression, re-ask cycle + and mandatory reason, expiry-vs-completion distinguishability, + non-null `resumeAt`. +- [ ] 10.2 Authorization and validation tables: wrong subject, + unresolvable subject, missing required upload, oversized file, and + the refused verbs (`claim`/`unclaim`/`delegate`) on an external + task. +- [ ] 10.3 Playwright coverage for the six `@e2e`-marked scenarios across + `specs/flow-portal-task/spec.md` and `specs/flow-tasks/spec.md`, + including the negative one: another portal subject cannot complete + a task that is not theirs. + +## Acceptance criteria + +- A flow containing a portal-task node matches the case's initiator, + creates exactly one external task, suspends with a non-null `resumeAt`, + and continues only on task terminality, verified across a heartbeat + wake. +- The uploaded file exists as an OR file attachment on the case object + before the completion is recorded; no portal-private file store exists. +- Completion is denied to every caller but the matched portal subject, + fail-closed, including through the run resume endpoint. +- A re-ask produces a new task with a mandatory reason, a cycle number and + the previous task's uuid; the previous task is untouched. +- External tasks appear in no Nextcloud inbox and no notification or + VTODO projection, and remain visible with delivery state on their + anchored case. +- No timer logic exists in this change; reminder and escalation are + expressed as `flow-business-timers` rungs. +- `AwaitSignalNode`, `UserTaskNode`, `FlowRunController::resume()` and all + existing endpoints are unchanged. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- Every new PHP file carries `@license EUPL-1.2` and + `@copyright 2026 Conduction B.V.`; every public/protected method carries + a `@spec openspec/changes/flow-portal-task/...` anchor. +- Depends on `flow-task-entity` and `flow-user-task-node`; deploy order is + that chain, then this change, then portaliq's contribution change. +- References ADR-098 D3 as amended 2026-08-31 (`external` performer; + upload lands on the case object), ADR-046, ADR-108, ADR-022, ADR-005, + ADR-031 (design.md D-2). From 37dd61847780d0bee74ff07697a90bb8fec5f1fb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 06:56:26 +0200 Subject: [PATCH 18/68] chore(n8n): retire the n8n register descriptor (#3251) The fleet is dropping n8n: OpenRegister flows become the only engine. The descriptor is the first piece to go and the only one this schema audit touches. It declared five schemas with no `slug` key at all, so their slugs derived from their titles, and two of those, `notification` and `schedule`, collided with decidiq and hermiq in the fleet-wide scan. It reports `absent` on the dev instance, so it shipped and was never imported: this clears a static collision, not a live one. testEveryDescriptorOpenRegisterShipsIsDiscovered counts declaring files by reading lib/Settings itself, so it follows the deletion rather than breaking. The comments that named this file as a live example are rewritten to name one that still exists: a comment asserting a file ships is a claim, and a stale one is worse than none. The server-side retirement (N8nSettingsController, WorkflowEngineController, WorkflowEngineRegistry and the n8n branches in FlowEngine) is its own change. --- lib/Service/RegisterDescriptorService.php | 11 +- lib/Settings/n8n_workflows.openregister.json | 205 ------------------ .../Service/RegisterDescriptorServiceTest.php | 9 +- 3 files changed, 9 insertions(+), 216 deletions(-) delete mode 100644 lib/Settings/n8n_workflows.openregister.json diff --git a/lib/Service/RegisterDescriptorService.php b/lib/Service/RegisterDescriptorService.php index b2025d47dc..5e6e284e70 100644 --- a/lib/Service/RegisterDescriptorService.php +++ b/lib/Service/RegisterDescriptorService.php @@ -133,11 +133,10 @@ public function inventory(): array { } // ATTRIBUTED TO THE APP THAT DECLARES IT, not to the directory it - // was found in. `n8n_workflows.openregister.json` lives in - // OpenRegister's lib/Settings and declares `app: n8n` — it is n8n's - // register, shipped alongside. Filing it under `openregister` names - // the wrong owner, and an inventory exists to tell somebody whose - // problem a row is. + // was found in. A descriptor shipped inside OpenRegister's own + // lib/Settings may declare a different `app`, and filing it under + // `openregister` names the wrong owner. An inventory exists to tell + // somebody whose problem a row is. $owner = (string)($descriptor['data']['x-openregister']['app'] ?? $appId); if ($owner === '') { $owner = $appId; @@ -319,7 +318,7 @@ private function installedRegisterVersions(): array { * A descriptor is recognised by SHAPE — an OpenAPI document carrying * `components.registers` — not by filename. The filenames vary across the * fleet (`flow_register.json`, `credential-providers.json`, - * `n8n_workflows.openregister.json`), and a `*_register.json` glob would + * `credential-providers.json`), and a `*_register.json` glob would * quietly omit the ones that do not match, shrinking the inventory instead of * failing. That is the same invisibility this service exists to fix, one * level up. diff --git a/lib/Settings/n8n_workflows.openregister.json b/lib/Settings/n8n_workflows.openregister.json deleted file mode 100644 index 65d8fdb1d7..0000000000 --- a/lib/Settings/n8n_workflows.openregister.json +++ /dev/null @@ -1,205 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "n8n Workflow Automation Configuration", - "description": "Configuration for n8n workflow integration with OpenRegister - enables automation, data synchronization, notifications, and event-driven workflows.", - "version": "1.0.0" - }, - "x-openregister": { - "type": "integration", - "app": "n8n", - "sourceType": "github", - "sourceUrl": "https://raw.githubusercontent.com/ConductionNL/opencatalogi/master/apps-extra/openregister/lib/Settings/n8n_workflows.openregister.json", - "openregister": "^v0.2.10", - "github": { - "repo": "ConductionNL/opencatalogi", - "branch": "master", - "path": "apps-extra/openregister/lib/Settings/n8n_workflows.openregister.json" - }, - "description": "n8n workflow automation integration - optional enhancement for OpenRegister apps", - "dependencies": [] - }, - "components": { - "registers": { - "n8n-workflows": { - "slug": "n8n-workflows", - "title": "n8n Workflows", - "version": "1.0.0", - "description": "Register for n8n workflow definitions and automation triggers", - "schemas": ["workflow", "trigger", "webhook", "schedule", "notification"], - "source": "internal", - "tablePrefix": "", - "folder": "700", - "updated": "2026-01-05T22:00:00+00:00", - "created": "2026-01-05T22:00:00+00:00", - "owner": "1", - "application": "n8n", - "organisation": null, - "authorization": null, - "groups": null, - "deleted": null, - "configuration": { - "enableMagicMapping": false, - "comment": "n8n workflows are managed through n8n interface - stored as references only" - } - } - }, - "schemas": { - "workflow": { - "title": "Workflow", - "description": "An n8n workflow definition that automates processes", - "type": "object", - "required": ["title", "workflowId"], - "properties": { - "title": { - "title": "Workflow Title", - "type": "string", - "description": "The title of the workflow", - "minLength": 1, - "maxLength": 255 - }, - "workflowId": { - "title": "Workflow ID", - "type": "string", - "description": "The n8n workflow ID", - "pattern": "^[a-zA-Z0-9-_]+$" - }, - "description": { - "title": "Description", - "type": "string", - "description": "Description of what the workflow does" - }, - "active": { - "title": "Active", - "type": "boolean", - "description": "Whether the workflow is currently active", - "default": false - }, - "tags": { - "title": "Tags", - "type": "array", - "description": "Labels used to categorise and filter the workflow.", - "items": {"type": "string"} - } - } - }, - "trigger": { - "title": "Workflow Trigger", - "description": "A trigger that initiates workflow execution", - "type": "object", - "required": ["workflowId", "type"], - "properties": { - "workflowId": { - "title": "Workflow ID", - "type": "string", - "format": "uuid", - "$ref": "workflow", - "description": "UUID reference to the workflow this trigger is associated with." - }, - "type": { - "title": "Trigger Type", - "type": "string", - "description": "The mechanism that initiates the workflow execution.", - "enum": ["webhook", "schedule", "event", "manual"] - }, - "active": { - "title": "Active", - "type": "boolean", - "description": "Whether this trigger is currently enabled.", - "default": true - } - } - }, - "webhook": { - "title": "Webhook", - "description": "A webhook endpoint that triggers workflow execution", - "type": "object", - "required": ["path", "method", "workflowId"], - "properties": { - "path": { - "title": "Webhook Path", - "type": "string", - "description": "The URL path at which the webhook endpoint is exposed.", - "pattern": "^/[a-zA-Z0-9/_-]+$" - }, - "method": { - "title": "HTTP Method", - "type": "string", - "description": "The HTTP method the webhook listens for.", - "enum": ["GET", "POST", "PUT", "DELETE", "PATCH"] - }, - "workflowId": { - "title": "Workflow ID", - "type": "string", - "format": "uuid", - "$ref": "workflow", - "description": "UUID reference to the workflow triggered by this webhook." - } - } - }, - "schedule": { - "title": "Schedule", - "description": "A scheduled trigger for workflow execution", - "type": "object", - "required": ["cronExpression", "workflowId"], - "properties": { - "cronExpression": { - "title": "Cron Expression", - "type": "string", - "description": "Standard five-field cron expression defining when the workflow runs.", - "pattern": "^[0-9*,/-]+ [0-9*,/-]+ [0-9*,/-]+ [0-9*,/-]+ [0-9*,/-]+$" - }, - "workflowId": { - "title": "Workflow ID", - "type": "string", - "format": "uuid", - "$ref": "workflow", - "description": "UUID reference to the workflow executed on this schedule." - }, - "timezone": { - "title": "Timezone", - "type": "string", - "description": "IANA timezone name used to interpret the cron expression.", - "default": "UTC" - }, - "active": { - "title": "Active", - "type": "boolean", - "description": "Whether this scheduled trigger is currently enabled.", - "default": true - } - } - }, - "notification": { - "title": "Notification Configuration", - "description": "Configuration for workflow-triggered notifications", - "type": "object", - "required": ["title", "channel"], - "properties": { - "title": { - "title": "Notification Title", - "type": "string", - "description": "The subject or heading displayed in the notification." - }, - "channel": { - "title": "Delivery Channel", - "type": "string", - "description": "The medium through which the notification is delivered.", - "enum": ["email", "slack", "webhook", "sms", "push"] - }, - "template": { - "title": "Message Template", - "type": "string", - "description": "Template string used to render the notification body." - }, - "recipients": { - "title": "Recipients", - "type": "array", - "description": "List of addresses or identifiers that receive the notification.", - "items": {"type": "string"} - } - } - } - } - } -} diff --git a/tests/Unit/Service/RegisterDescriptorServiceTest.php b/tests/Unit/Service/RegisterDescriptorServiceTest.php index 3ac8bec4b5..f4d94a6846 100644 --- a/tests/Unit/Service/RegisterDescriptorServiceTest.php +++ b/tests/Unit/Service/RegisterDescriptorServiceTest.php @@ -162,8 +162,8 @@ public function testANonMockDescriptorIsStillReported(): void { /** * A row names the app that DECLARES the register, not the directory the file - * was found in. `n8n_workflows.openregister.json` ships in OpenRegister's - * lib/Settings and declares `app: n8n`; filing it under `openregister` tells + * was found in. A descriptor shipped in OpenRegister's own lib/Settings may + * declare a different `app`; filing it under `openregister` tells * the reader the wrong owner, and an inventory exists to say whose problem a * row is. */ @@ -260,7 +260,7 @@ public function testAnAppShippingNoDescriptorIsOmittedNotListedAbsent(): void { /** * A descriptor is recognised by SHAPE, not filename — the fleet's names vary - * (`credential-providers.json`, `n8n_workflows.openregister.json`), and a + * (`credential-providers.json`, `flow_register.json`), and a * `*_register.json` glob would silently shrink the inventory rather than * fail, which is this service's own failure mode one level up. */ @@ -439,8 +439,7 @@ public function testAnImportThatWritesNothingIsReportedFailedNotImported(): void * fleet actually ships — the failure would be a SHORTER inventory, which is * the one failure mode a fixture-only suite cannot see. This reads * OpenRegister's own `lib/Settings`, where the filenames deliberately vary - * (`flow_register.json`, `credential-providers.json`, - * `n8n_workflows.openregister.json`), and asserts every declaring file is + * (`flow_register.json`, `credential-providers.json`), and asserts every declaring file is * found. */ public function testEveryDescriptorOpenRegisterShipsIsDiscovered(): void { From 72fa00a45b95cee949bbf6b648703bec801ca040 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 06:58:41 +0200 Subject: [PATCH 19/68] spec(flow-runs-subject-scope): the case page's view of the engine (#3250) New change, no dependencies beyond shipped code. Three requirements: - GET /api/flow-runs/active accepts an optional subject filter matching FlowRun.subject_uuid, applied in the datastore inside the caller's organisation scope; the filter can only narrow, and the total counts the filtered set - a completed-runs read for a required subject (FlowRun::TERMINAL, newest first, bounded, honest total) so a case page can show history without widening the flowRun#index surface, which or-flow-active-runs requires to stay unchanged - one row contract for both reads: run uuid, flow name, current step, status, started at and the subject block; never marking, items or the step log The nc-vue consumption (subject option + run deep link on CnFlowRunsWidget) is one follow-up task line pointing at an nc-vue change. --- .../changes/flow-runs-subject-scope/design.md | 116 ++++++++++++++++ .../flow-runs-subject-scope/proposal.md | 112 +++++++++++++++ .../specs/flow-runs-subject-scope/spec.md | 130 ++++++++++++++++++ .../changes/flow-runs-subject-scope/tasks.md | 80 +++++++++++ 4 files changed, 438 insertions(+) create mode 100644 openspec/changes/flow-runs-subject-scope/design.md create mode 100644 openspec/changes/flow-runs-subject-scope/proposal.md create mode 100644 openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md create mode 100644 openspec/changes/flow-runs-subject-scope/tasks.md diff --git a/openspec/changes/flow-runs-subject-scope/design.md b/openspec/changes/flow-runs-subject-scope/design.md new file mode 100644 index 0000000000..cffee54c17 --- /dev/null +++ b/openspec/changes/flow-runs-subject-scope/design.md @@ -0,0 +1,116 @@ +# Design: flow-runs-subject-scope + +## Context + +Measured, in the shipped code: + +- `FlowRun` stores the subject anchor (`lib/Db/FlowRun.php:241-255`: + `subjectUuid`, `subjectRegister`, `subjectSchema`) and defines the + status sets (`ACTIVE` at `:159`, `TERMINAL` at `:142`). +- `FlowRunController::active()` (`lib/Controller/FlowRunController.php:225`) + resolves the caller's organisation, returns empty without querying when + none resolves, caps the limit at 50, and reduces each run through + `summarise()` (`:277-299`), which already carries uuid, flowName, + status, subject block, step and created. +- `FlowRunMapper::findActive()`/`countActive()` + (`lib/Db/FlowRunMapper.php:477`, `:565`) filter on status set and + organisation only. +- The `or-flow-active-runs` capability pins the boundary: strict per-org + scoping, unattributed runs returned to nobody, honest totals, and "the + run history surface is unchanged". + +## Goals / Non-Goals + +**Goals:** + +- One datastore-filtered read for "live runs on this subject" and one for + "finished runs on this subject", both inside the existing org scope. +- A row contract a case-detail widget can render without a second request + per row. + +**Non-Goals:** + +- Any change to the org-wide dashboard behaviour, the history endpoint, + or run visibility rules. +- Cross-subject or cross-org aggregation, free-text search, or filtering + on register/schema (the uuid identifies the case; the widget already + knows which case it is on). +- The widget itself (nc-vue follow-up). + +## Decisions + +### D-1: The live filter is a parameter on the existing endpoint, not a new route + +`flow-runs/active` already has the organisation resolution, the limit +cap, the summarise shape and a consumer. A `subject` parameter narrows +it; a parallel `/active-for-subject` route would duplicate all four and +drift. The parameter is optional and absent means bit-identical behaviour +to today, which keeps the existing widget untouched. + +### D-2: The filter narrows in the datastore, after the organisation predicate + +The mapper gains an optional subject argument added as an `AND` predicate +next to the existing organisation predicate. Order of evaluation is a +correctness statement, not an optimisation: the organisation scope is +applied unconditionally first, so a guessed subject uuid from another +tenant matches zero rows rather than leaking one. Client-side filtering +is refused for the same reason the task inbox spec refuses it: a filter +over a server-paginated page silently drops rows the page did not +contain. The `total` is counted with the same predicates as the rows. + +### D-3: The completed read is a new, subject-REQUIRED surface + +The existing capability forbids widening the history endpoint, so case +history gets its own read with a deliberately narrow contract: the +subject uuid is REQUIRED (there is no org-wide "all finished runs" here; +that is what the history endpoint already serves to its audience), +status is the `FlowRun::TERMINAL` set, ordering is newest first, and the +result is bounded with an honest total. It reuses `summarise()` +unchanged: a finished run's row needs nothing a live run's row lacks, +and one shape means the widget renders one list. + +### D-4: The row contract names the widget's five fields and freezes the exclusions + +What a case-detail widget needs is run uuid, flow name, current step, +status and started at; the shipped `summarise()` already carries all five +(started at is served by `created`) plus the subject block. The spec +therefore pins the CONTRACT rather than inventing a shape: those fields +present on every row, and the marking, items and step log absent, for the +same reason `or-flow-active-runs` gives: kilobytes per row a list never +renders, and items can hold the record data itself. The single-run +endpoint stays the place to ask for a run's contents. + +### D-5: One supporting index + +The live read today scans on `(status, organisation)` shapes; adding the +subject predicate makes `(organisation, subject_uuid, status)` the +selective path for both new reads. One migration, one composite index; no +schema change to the entity. + +## Risks / Trade-offs + +- **A subject uuid is not secret.** Mitigated structurally: the + organisation predicate is unconditional, so knowing a uuid from another + tenant yields an empty result, indistinguishable from a case with no + runs. +- **Two reads can drift in shape.** Mitigated by sharing `summarise()`; + the spec makes the shared row a requirement, so a divergence is a spec + violation rather than a taste question. +- **Unbounded history growth on busy cases.** The completed read is + bounded with the same cap discipline as the live read and ordered + newest first; "everything ever" stays on the history surface where its + existing audience and filters live. + +## Migration Plan + +Additive: one parameter, one route, two mapper methods, one index +migration. No consumer changes behaviour until it passes `subject`. +Rollback is dropping the route and parameter; the index is harmless to +leave. + +## Open Questions + +- **Does the widget also want counts per status** (2 live, 14 finished) + for a badge? The honest totals of the two reads already provide both + numbers at one request each; a combined count endpoint is deferred + until a consumer measures the two requests as a problem. diff --git a/openspec/changes/flow-runs-subject-scope/proposal.md b/openspec/changes/flow-runs-subject-scope/proposal.md new file mode 100644 index 0000000000..907fca6306 --- /dev/null +++ b/openspec/changes/flow-runs-subject-scope/proposal.md @@ -0,0 +1,112 @@ +--- +kind: code +--- + +# Proposal: flow-runs-subject-scope + +## Summary + +Let a case page ask "what is running on THIS case, and what already ran". +The live-runs read (`GET /api/flow-runs/active`) accepts an optional +`subject` filter matching `FlowRun.subject_uuid`, applied INSIDE the +caller's existing organisation scope. A new completed-runs read answers +the history half for the same subject. Both reads return the row a +case-detail widget needs (run uuid, flow name, current step, status, +started at) and nothing heavier. The nc-vue widget consumption is a +follow-up change in nc-vue; tasks.md carries one pointer line for it. + +## Why + +**The anchor exists; no read uses it.** `FlowRun` carries +`subject_uuid`, `subject_register` and `subject_schema` +(`lib/Db/FlowRun.php:241-255`), and the active endpoint's own +`summarise()` already returns the subject block per row +(`lib/Controller/FlowRunController.php:288-292`). But +`FlowRunMapper::findActive()` and `countActive()` filter on organisation +and nothing else (`lib/Db/FlowRunMapper.php:477`, `:565`), so the ONLY +consumer today is the org-wide dashboard widget. A case detail page that +wants "runs on this case" has to fetch the org-wide list and filter +client-side, which silently drops any matching run outside the fetched +page. The dossiq flow proof needs exactly that view: a hersteltermijn run +suspended on a resident is invisible from the case it is about. + +**The history half has the same gap with an extra constraint.** The +`or-flow-active-runs` capability requires that "the run history surface is +unchanged": widening run visibility must not be done by widening the +existing history endpoint. So a case page's "what already ran here" needs +its own bounded, subject-required read rather than a loosened +`flowRun#index`. + +**The tenant boundary must be unmovable by the new filter.** The +live-runs read is strictly scoped to one organisation, an unattributed run +is returned to nobody, and a caller with no organisation reads nothing +without a query being issued (all three are existing `flow-active-runs` +requirements). A subject filter must only ever NARROW that result: a +subject uuid is guessable, and a filter that widened by subject would turn +the case anchor into a cross-tenant read primitive. + +## What Changes + +- **`GET /api/flow-runs/active` accepts `subject`** (a subject object + uuid). The filter is applied in the datastore inside the organisation + scope; the returned `total` counts the filtered set. +- **A completed-runs read for a subject**: terminal runs + (`FlowRun::TERMINAL`: completed, stopped, dead_letter, failed) for a + REQUIRED subject uuid, inside the caller's organisation scope, newest + first, bounded with an honest total. It reuses the existing summarise + row shape; it does not touch `flowRun#index`. +- **The row contract is stated once for both reads**: uuid, flow name + (id fallback), current step (null when none), status, started at, and + the subject block; never the marking, the items or the step log. +- **A supporting index** on the runs table so the subject reads are a + range scan, not a table walk. +- **nc-vue follow-up (one task line)**: `CnFlowRunsWidget` gains a + `subject` option and a run deep link, in its own nc-vue change. + +## What does NOT change + +- **The org-wide behaviour of `flow-runs/active`.** No `subject` means + exactly today's read; every existing `flow-active-runs` requirement + (strict org scope, unattributed runs returned to nobody, bounded list, + honest total, name fallback, step derivation) holds unchanged with and + without the filter. +- **`flowRun#index`, `flowRun#show` and every other run endpoint.** The + history surface keeps its current filters, shape and visibility, as the + existing capability requires. +- **RBAC and organisation resolution.** The reads use the same + organisation resolution the active endpoint already uses; this change + adds no new authorization model. +- **The widget itself.** nc-vue's `CnFlowRunsWidget` changes in nc-vue. + +## Capabilities + +### New Capabilities + +- `flow-runs-subject-scope`: the subject filter on the live-runs read, + the subject-required completed-runs read, and the shared case-widget + row contract. + +### Modified Capabilities + + + +## Impact + +- **Affected specs**: new `flow-runs-subject-scope`. +- **Affected code**: `lib/Controller/FlowRunController.php` (`active()` + gains the parameter; one new controller method + route for the + completed read); `lib/Db/FlowRunMapper.php` (`findActive`/`countActive` + gain an optional subject argument; `findCompletedForSubject` + + `countCompletedForSubject`); one migration for the supporting index. +- **Affected apps**: dossiq's case detail page and any app whose detail + page anchors runs to an object become consumers, through the nc-vue + widget follow-up. +- **Depends on**: nothing beyond what exists. The subject columns, + the active endpoint and the summarise shape are all shipped. +- **ADRs**: ADR-098 D4 (the case anchor is the OR object; this read is + that anchor's view of the engine), ADR-022 (apps consume OR + abstractions: the widget consumes this read instead of each app + building a run query), ADR-005 (fail-closed scoping). diff --git a/openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md b/openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md new file mode 100644 index 0000000000..9192e4368b --- /dev/null +++ b/openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md @@ -0,0 +1,130 @@ +## Purpose + +A case page's view of the engine: the live-runs read narrowed to one +subject object, a completed-runs read for the same subject, and the row +contract a case-detail widget renders. Everything stays inside the +caller's existing organisation scope; the subject filter can only ever +narrow. + +## ADDED Requirements + +### Requirement: The live-runs read accepts a subject filter that only narrows + +The live-runs read SHALL accept an optional `subject` parameter holding a +subject object uuid. When present, only runs whose `subject_uuid` equals +it SHALL be returned; when absent, the read SHALL behave exactly as it +does today. + +The filter SHALL be applied in the datastore, as a predicate alongside +the existing organisation predicate, with the organisation scope applied +unconditionally. A run belonging to another organisation SHALL NOT be +returned or counted even when its subject uuid matches: a subject uuid is +guessable, and matching it grants nothing. + +A caller whose organisation cannot be resolved SHALL receive an empty +result with no query issued, exactly as the unfiltered read already +requires. The reported total SHALL count the FILTERED set, so a case +widget can state what it could not fit. + +Filtering SHALL NOT be performed client-side over a paginated result: a +page-then-filter read silently drops matching runs the page did not +contain. + +#### Scenario: A case page lists only its own case's live runs + +- **GIVEN** three live runs in the caller's organisation, two anchored to + case X and one to case Y +- **WHEN** the live runs are read with `subject` = case X's uuid +- **THEN** exactly the two case X runs MUST be returned +- **AND** the reported total MUST be 2 +- @e2e the case detail widget lists only the case's own runs + +#### Scenario: A matching subject in another organisation stays invisible + +- **GIVEN** a live run in organisation B anchored to a subject uuid known + to a caller in organisation A +- **WHEN** the caller in organisation A reads the live runs with that + `subject` +- **THEN** the result MUST be empty +- **AND** the total MUST be 0 +- @e2e exclude covered by mapper unit tests over two organisations + +#### Scenario: No subject means today's read + +- **GIVEN** live runs across several subjects in the caller's organisation +- **WHEN** the live runs are read without `subject` +- **THEN** the result MUST equal the unfiltered org-scoped read +- @e2e exclude regression covered by the existing active-runs tests + +### Requirement: A completed-runs read answers what already ran on this subject + +The system SHALL expose a completed-runs read that returns terminal runs +(the run entity's terminal status set: completed, stopped, dead_letter, +failed) anchored to a REQUIRED subject uuid, inside the caller's +organisation scope. + +The subject SHALL be required: a request without one SHALL be refused, +not treated as an org-wide history. The existing run history surface +SHALL keep its current filters, shape and visibility; this read is a new +surface beside it, not a widening of it. + +Rows SHALL be ordered newest first, bounded by a capped limit, and +accompanied by an honest total counted with the same predicates. The +organisation and no-organisation rules of the live read SHALL apply +identically. + +#### Scenario: A finished run shows up in the case's history + +- **GIVEN** a run anchored to case X that has completed, and a live run on + the same case +- **WHEN** the completed runs are read with `subject` = case X's uuid +- **THEN** the completed run MUST be returned with its terminal status +- **AND** the live run MUST NOT be in this result +- @e2e a finished flow appears in the case detail's run history + +#### Scenario: History without a subject is refused + +- **GIVEN** an authenticated caller +- **WHEN** the completed-runs read is requested without a subject +- **THEN** the request MUST be refused with an error naming the missing + parameter +- @e2e exclude covered by controller unit tests + +#### Scenario: A failed run is history too + +- **GIVEN** a run anchored to case X that ended in `failed` +- **WHEN** the completed runs are read for case X +- **THEN** the failed run MUST be returned with status `failed` +- @e2e exclude covered by mapper unit tests over the terminal set + +### Requirement: Both reads return the case-widget row and nothing heavier + +Every row returned by the subject-filtered live read and by the +completed-runs read SHALL carry at minimum: the run's uuid, the flow's +human name (falling back to the flow id when no app claims it), the +current step or null when the run has no marking, the status, the +started-at timestamp, and the subject block (uuid, register, schema). + +The two reads SHALL share one row shape, so a widget renders live and +finished runs as one list. + +A row SHALL NOT carry the run's marking, its item list or its step log. +Those are kilobytes per run that a list never renders, and the item list +can hold the subject's own record data; the single-run read remains the +place to ask for a run's contents, and the run uuid in the row is the +deep link to it. + +#### Scenario: The row carries the five widget fields + +- **GIVEN** a live run anchored to a case +- **WHEN** the subject-filtered live runs are read +- **THEN** its row MUST carry uuid, flow name, step, status and the + started-at timestamp +- @e2e exclude covered by controller unit tests over the summarised shape + +#### Scenario: The row stays light + +- **GIVEN** a completed run with items and a step log +- **WHEN** the completed runs are read for its subject +- **THEN** its row MUST NOT contain the marking, the items or the step log +- @e2e exclude covered by controller unit tests over the summarised shape diff --git a/openspec/changes/flow-runs-subject-scope/tasks.md b/openspec/changes/flow-runs-subject-scope/tasks.md new file mode 100644 index 0000000000..34a98e59a3 --- /dev/null +++ b/openspec/changes/flow-runs-subject-scope/tasks.md @@ -0,0 +1,80 @@ +# Tasks: flow-runs-subject-scope + +## 1. The subject filter on the live read + +- [ ] 1.1 `FlowRunMapper::findActive()` and `countActive()` gain an + optional `subject` argument, added as an `AND` predicate on + `subject_uuid` next to the unconditional organisation predicate + (`lib/Db/FlowRunMapper.php:477`, `:565`). Rows and total share the + predicates. +- [ ] 1.2 `FlowRunController::active()` reads the `subject` request + parameter and passes it through + (`lib/Controller/FlowRunController.php:225`). Absent parameter is + bit-identical to today; the no-organisation early return stays + before any query. + +## 2. The completed-runs read + +- [ ] 2.1 `FlowRunMapper::findCompletedForSubject()` + + `countCompletedForSubject()`: `FlowRun::TERMINAL` statuses + (`lib/Db/FlowRun.php:142`), required subject uuid, organisation + predicate, newest first, capped limit. +- [ ] 2.2 `FlowRunController::completedForSubject()` + route + `GET /api/flow-runs/completed` in `appinfo/routes.php`, refusing a + request without `subject` and reusing `summarise()` unchanged. The + existing `flowRun#index` history endpoint is not touched. + +## 3. The index + +- [ ] 3.1 Migration adding a composite index over + `(organisation, subject_uuid, status)` on the runs table. + +## 4. Follow-up (not in this change) + +- [ ] 4.1 nc-vue: a follow-up change in nc-vue gives `CnFlowRunsWidget` a + `subject` option (rendering the case-scoped list on detail pages) + and a per-row deep link to the run, consuming these two reads. + Nothing of it is specified here. + +## 5. Tests + +- [ ] 5.1 Mapper unit tests: subject narrowing with honest totals; a + matching subject uuid in another organisation returns and counts + nothing; the terminal set drives the completed read (including + `failed`); newest-first ordering. +- [ ] 5.2 Controller unit tests: absent `subject` equals today's read; + missing subject on the completed read is refused naming the + parameter; both reads return the summarised row (uuid, flow name, + step, status, started at, subject block) and never marking, items + or step log. +- [ ] 5.3 Playwright coverage for the two `@e2e`-marked scenarios in + `specs/flow-runs-subject-scope/spec.md`: the case detail widget + lists only the case's own runs, and a finished flow appears in the + case detail's run history. + +## Acceptance criteria + +- `GET /api/flow-runs/active?subject=` returns only that subject's + live runs inside the caller's organisation, with a total counting the + filtered set; without `subject` the response is unchanged from today. +- A subject uuid from another organisation returns an empty result and a + zero total, with the organisation predicate applied in the datastore. +- The completed-runs read requires a subject, serves the terminal status + set newest first with a capped limit and honest total, and leaves + `flowRun#index` untouched. +- Both reads share the summarise row shape; no row carries marking, items + or a step log. +- A caller with no resolvable organisation gets an empty result from both + reads with no query issued. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- Every touched method carries a + `@spec openspec/changes/flow-runs-subject-scope/...` anchor; new code + carries `@license EUPL-1.2` and `@copyright 2026 Conduction B.V.`. +- No dependency beyond shipped code: the subject columns, the active + endpoint and `summarise()` all exist on development. +- References ADR-098 D4 (the case anchor), ADR-022 (widget consumes the + OR read), ADR-005 (fail-closed scoping); honours `or-flow-active-runs`' + "run history surface is unchanged" requirement. From 6fc19be546b2f1cdd40de04971e1db4b1299bf61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:28:48 +0200 Subject: [PATCH 20/68] chore(release): 2.0.12-unstable.20260901050305 (#3256) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- appinfo/info.xml | 2 +- openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index f0255a56b1..9b9c2fff1a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata Vrij en open source onder de EUPL-licentie. ]]> - 2.0.11-unstable.20260831184325 + 2.0.12-unstable.20260901050305 EUPL-1.2 Conduction OpenRegister diff --git a/openapi.json b/openapi.json index 888e7dda46..67f381eaf6 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "openregister", - "version": "2.0.11-unstable.20260831184325", + "version": "2.0.12-unstable.20260901050305", "description": "Open Register", "license": { "name": "EUPL-1.2" From 9ef9ff8f21c857475ac6d93948574da73cd853b6 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 08:56:58 +0200 Subject: [PATCH 21/68] feat(flow): a case page's view of the engine, scoped to one subject (#3260) The live-runs read (GET /api/flow-runs/active) accepts an optional subject parameter (a subject object uuid). It narrows INSIDE the caller's organisation scope in the datastore: the organisation predicate stays unconditional and the subject is a second AND predicate on subject_uuid, so a guessed uuid from another tenant matches nothing. The total counts the filtered set. Without subject the read is bit-identical to before. A completed-runs read (GET /api/flow-runs/completed) answers the history half for the same subject: FlowRun::TERMINAL statuses, subject REQUIRED (400 naming the parameter, never an org-wide history dump), same organisation scope, newest first, capped limit, honest total. It reuses summarise(), so both reads share one row contract: uuid, flow name, current step (null when no marking), status, created, subject block, and never the marking, items or step log. flowRun#index is untouched. One composite index on (organisation, subject_uuid, status, id) makes both reads a range scan on one case rather than a walk over the tenant's runs. Tests: controller (subject reaches rows and total, no org means no query, blank subject is no filter, completed read refuses a missing or blank subject, capped limit, shared row shape without heavy fields) and mapper (predicate order with the organisation first, terminal set including failed, newest-first bound, fail-closed on a missing predicate). openspec: flow-runs-subject-scope, tasks 1.1, 1.2, 2.1, 2.2, 3.1, 5.1, 5.2 Co-authored-by: Ruben van der Linde --- appinfo/routes.php | 5 + lib/Controller/FlowRunController.php | 125 ++++++++- lib/Db/FlowRunMapper.php | 163 ++++++++++- lib/Migration/Version1Date20260901123000.php | 101 +++++++ .../changes/flow-runs-subject-scope/tasks.md | 14 +- .../Unit/Controller/FlowRunControllerTest.php | 173 +++++++++++- .../Unit/Db/FlowRunMapperSubjectScopeTest.php | 258 ++++++++++++++++++ 7 files changed, 822 insertions(+), 17 deletions(-) create mode 100644 lib/Migration/Version1Date20260901123000.php create mode 100644 tests/Unit/Db/FlowRunMapperSubjectScopeTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 6f4b5ab5ba..9c7aac2c02 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1338,6 +1338,11 @@ // resolves routes in declaration order, so a later registration would be // answered by `show('active')` → 404 for every request. ['name' => 'flowRun#active', 'url' => '/api/flow-runs/active', 'verb' => 'GET'], + // Finished runs on ONE subject object (flow-runs-subject-scope): the case + // page's run history. `subject` is REQUIRED (400 without it) and the read is + // organisation-scoped like `active`. Same ordering rule: it MUST stay above + // the `{uuid}` route or `show('completed')` answers it with a 404. + ['name' => 'flowRun#completedForSubject', 'url' => '/api/flow-runs/completed', 'verb' => 'GET'], ['name' => 'flowRun#show', 'url' => '/api/flow-runs/{uuid}', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'flowRun#objects', 'url' => '/api/flow-runs/{uuid}/objects', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'flowRun#retry', 'url' => '/api/flow-runs/{uuid}/retry', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], diff --git a/lib/Controller/FlowRunController.php b/lib/Controller/FlowRunController.php index e6902d8118..d9de114b95 100644 --- a/lib/Controller/FlowRunController.php +++ b/lib/Controller/FlowRunController.php @@ -213,26 +213,142 @@ public function index(): JSONResponse { * `shared-credentials-and-flows`. Until that landed `index()` was unscoped and * returned every run on the instance to any authenticated user. * + * An optional `subject` (a subject object uuid) narrows the list to the + * runs anchored to that one object: a case detail page's view of the + * engine. It narrows INSIDE the organisation scope and can never widen it; + * the mapper applies both predicates, and the total counts the filtered + * set. Without `subject` the read is bit-identical to what it was. + * * @return JSONResponse `{results, total, limit}` — the live runs. * * @NoAdminRequired * @NoCSRFRequired * * @spec openspec/changes/or-flow-active-runs/specs/flow-active-runs/spec.md + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md */ #[NoAdminRequired] #[NoCSRFRequired] public function active(): JSONResponse { - $limit = min(50, max(1, (int)$this->request->getParam('limit', 10))); + $limit = $this->pageLimit(); + $subject = $this->subjectFilter(); + $organisation = $this->activeOrganisation(); + + if ($organisation === null) { + return new JSONResponse(['results' => [], 'total' => 0, 'limit' => $limit]); + } + + $runs = $this->mapper->findActive(organisation: $organisation, limit: $limit, subject: $subject); + $total = $this->mapper->countActive(organisation: $organisation, subject: $subject); + + return $this->page(runs: $runs, total: $total, limit: $limit); + + }//end active() + + /** + * The finished runs on ONE subject object: a case page's run history. + * + * The other half of the case view. A flow that completed on a case must + * not look like nothing ever happened, so this returns the terminal runs + * (`FlowRun::TERMINAL`: completed, stopped, dead_letter, failed) anchored + * to the given subject, newest first, bounded, with an honest total. + * + * The subject is REQUIRED. There is no org-wide "everything that ever + * finished" here: `or-flow-active-runs` requires the history surface + * (`index()`, with its per-caller visibility rule) to stay unchanged, and + * this read exists beside it rather than as a loosening of it. A request + * without a subject is refused, not answered widely. + * + * Authorization is the organisation scope, exactly as on `active()`: the + * mapper's organisation predicate is unconditional, a caller with no + * resolvable organisation reads nothing without a query being issued, and + * a subject uuid from another tenant matches zero rows. Rows are the same + * summarised shape as the live read, so a widget renders one list. + * + * @return JSONResponse `{results, total, limit}`, or 400 when no subject was given. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function completedForSubject(): JSONResponse { + $subject = $this->subjectFilter(); + if ($subject === null) { + return new JSONResponse( + ['error' => 'The completed-runs read needs a subject: pass the subject object uuid as `subject`.'], + Http::STATUS_BAD_REQUEST + ); + } + + $limit = $this->pageLimit(); $organisation = $this->activeOrganisation(); if ($organisation === null) { return new JSONResponse(['results' => [], 'total' => 0, 'limit' => $limit]); } - $runs = $this->mapper->findActive(organisation: $organisation, limit: $limit); - $total = $this->mapper->countActive(organisation: $organisation); + $runs = $this->mapper->findCompletedForSubject(organisation: $organisation, subject: $subject, limit: $limit); + $total = $this->mapper->countCompletedForSubject(organisation: $organisation, subject: $subject); + return $this->page(runs: $runs, total: $total, limit: $limit); + + }//end completedForSubject() + + /** + * The bounded page size for the two case-widget reads, capped at 50. + * + * @return integer The limit. + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + private function pageLimit(): int { + return min(50, max(1, (int)$this->request->getParam('limit', 10))); + }//end pageLimit() + + /** + * The `subject` request parameter as a uuid, or null when none was given. + * + * Blank and non-string values count as absent: a filter that is not a + * uuid cannot name a subject, and treating it as one would either match + * nothing (misleading) or be coerced into something the caller did not + * send (worse). + * + * @return string|null The subject object uuid. + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + private function subjectFilter(): ?string { + $subject = $this->request->getParam('subject'); + if (is_string($subject) === false) { + return null; + } + + $subject = trim($subject); + if ($subject === '') { + return null; + } + + return $subject; + }//end subjectFilter() + + /** + * A bounded list of summarised runs plus its honest total. + * + * Shared by the live read and the completed read so the two cannot drift + * apart on shape: the spec makes one row contract a requirement. + * + * @param array $runs The page of runs. + * @param integer $total How many runs matched in all. + * @param integer $limit The page size that was applied. + * + * @return JSONResponse `{results, total, limit}`. + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + private function page(array $runs, int $total, int $limit): JSONResponse { return new JSONResponse( [ 'results' => array_map(fn (FlowRun $run): array => $this->summarise(run: $run), $runs), @@ -240,8 +356,7 @@ public function active(): JSONResponse { 'limit' => $limit, ] ); - - }//end active() + }//end page() /** * The caller's active organisation uuid, or null when none resolves. diff --git a/lib/Db/FlowRunMapper.php b/lib/Db/FlowRunMapper.php index 84a7d1b6b0..46dc61b367 100644 --- a/lib/Db/FlowRunMapper.php +++ b/lib/Db/FlowRunMapper.php @@ -36,6 +36,16 @@ * ad-hoc query builders at each call site. * * @SuppressWarnings(PHPMD.TooManyPublicMethods) + * @SuppressWarnings(PHPMD.TooManyMethods) Over by four since the subject-scoped + * reads of `flow-runs-subject-scope`, for the same reason: each public method is + * a distinct question with its own predicate set, and the private helpers behind + * the new reads exist so a row read and its count cannot drift apart on what + * they filter. Splitting the table across two mappers would hide the count, not + * the vocabulary. + * @SuppressWarnings(PHPMD.ExcessiveClassLength) Over the line budget by the same + * change. Most of the length is the docblocks that record WHY each query carries + * the predicates it does; those predicates are tenant boundaries, and trimming + * the reasoning to fit a line count is the wrong trade. * * @template-extends QBMapper * @@ -467,14 +477,24 @@ public function deleteTerminalOlderThanExcluding(DateTime $cutoff, array $exclud * every user, and guessing a tenant for an unattributed run would leak one * tenant's activity into another's dashboard. * + * The subject filter only ever NARROWS. It is a second predicate on + * `subject_uuid` added NEXT TO the organisation predicate, never instead of + * it: a subject uuid is guessable, so a run in another organisation that + * happens to carry the asked-for subject must stay as invisible as it is + * without the filter. Filtering here, in the datastore, is also what keeps + * the total honest: a caller that filtered a bounded page client-side would + * silently drop every matching run the page did not contain. + * * @param string|null $organisation Restrict to one organisation uuid. * @param integer $limit Page size. + * @param string|null $subject Restrict to runs anchored to one subject object uuid. * * @return array The non-terminal runs. * * @spec openspec/changes/or-flow-active-runs/specs/flow-active-runs/spec.md + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md */ - public function findActive(?string $organisation = null, int $limit = 25): array { + public function findActive(?string $organisation = null, int $limit = 25, ?string $subject = null): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->getTableName()) @@ -491,6 +511,8 @@ public function findActive(?string $organisation = null, int $limit = 25): array $qb->andWhere($qb->expr()->eq('organisation', $qb->createNamedParameter($organisation))); } + $this->narrowToSubject(qb: $qb, subject: $subject); + return $this->findEntities(query: $qb); }//end findActive() @@ -556,13 +578,19 @@ public function hasActiveRun(string $flowId): bool { * an honest total — "3 of 47 running" needs the 47, and paging the whole * set to count it would be absurd. * + * Takes the same subject filter as {@see findActive}, applied the same + * way, so the total a widget states is the total of the rows it filtered + * rather than of a set the caller never asked for. + * * @param string|null $organisation Restrict to one organisation uuid. + * @param string|null $subject Restrict to runs anchored to one subject object uuid. * * @return integer The number of non-terminal runs. * * @spec openspec/changes/or-flow-active-runs/specs/flow-active-runs/spec.md + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md */ - public function countActive(?string $organisation = null): int { + public function countActive(?string $organisation = null, ?string $subject = null): int { $qb = $this->db->getQueryBuilder(); $qb->select($qb->createFunction('COUNT(*) AS `total`')) ->from($this->getTableName()) @@ -577,12 +605,141 @@ public function countActive(?string $organisation = null): int { $qb->andWhere($qb->expr()->eq('organisation', $qb->createNamedParameter($organisation))); } + $this->narrowToSubject(qb: $qb, subject: $subject); + + return $this->fetchTotal(qb: $qb); + }//end countActive() + + /** + * The finished runs anchored to ONE subject object, newest first. + * + * A case page's "what already ran here". The read is deliberately narrow + * in a way the history surface (`findAllRuns`) is not: the subject is + * REQUIRED and the organisation is REQUIRED, and both are equality + * predicates in the datastore. There is no org-wide "all finished runs" + * on this path; a caller who wants that has the history endpoint, with + * its own per-caller visibility rule. Widening THIS read would widen run + * visibility through the back door, which `or-flow-active-runs` forbids. + * + * Fails CLOSED on a missing predicate: an empty organisation or subject + * returns nothing without a query rather than dropping the predicate and + * answering a wider question than was asked. + * + * @param string $organisation The caller's organisation uuid. + * @param string $subject The subject object uuid. + * @param integer $limit Page size. + * + * @return array The terminal runs, newest first. + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + public function findCompletedForSubject(string $organisation, string $subject, int $limit = 25): array { + if ($organisation === '' || $subject === '') { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('id', 'DESC') + ->setMaxResults($limit); + + $this->whereTerminalForSubject(qb: $qb, organisation: $organisation, subject: $subject); + + return $this->findEntities(query: $qb); + }//end findCompletedForSubject() + + /** + * How many finished runs one subject object has, for the honest total. + * + * Same predicates as {@see findCompletedForSubject}, so "3 of 14 earlier + * runs" counts the set the rows came from. + * + * @param string $organisation The caller's organisation uuid. + * @param string $subject The subject object uuid. + * + * @return integer The number of terminal runs on the subject. + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + public function countCompletedForSubject(string $organisation, string $subject): int { + if ($organisation === '' || $subject === '') { + return 0; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->createFunction('COUNT(*) AS `total`')) + ->from($this->getTableName()); + + $this->whereTerminalForSubject(qb: $qb, organisation: $organisation, subject: $subject); + + return $this->fetchTotal(qb: $qb); + }//end countCompletedForSubject() + + /** + * Add the subject predicate when a subject was asked for. + * + * One place for both the row read and the count, so the two cannot drift + * apart on what "this subject's runs" means. A null or empty subject adds + * nothing, which is what makes the unfiltered read bit-identical to before. + * + * @param IQueryBuilder $qb The query being built. + * @param string|null $subject The subject object uuid, or null for no filter. + * + * @return void + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + private function narrowToSubject(IQueryBuilder $qb, ?string $subject): void { + if ($subject === null || $subject === '') { + return; + } + + $qb->andWhere($qb->expr()->eq('subject_uuid', $qb->createNamedParameter($subject))); + }//end narrowToSubject() + + /** + * The three predicates of the completed-runs read: terminal, this organisation, this subject. + * + * Order is a correctness statement, not an optimisation: the organisation + * predicate is unconditional, so a guessed subject uuid from another + * tenant matches zero rows rather than leaking one. + * + * @param IQueryBuilder $qb The query being built. + * @param string $organisation The caller's organisation uuid. + * @param string $subject The subject object uuid. + * + * @return void + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + private function whereTerminalForSubject(IQueryBuilder $qb, string $organisation, string $subject): void { + $qb->where( + $qb->expr()->in( + 'status', + $qb->createNamedParameter(FlowRun::TERMINAL, IQueryBuilder::PARAM_STR_ARRAY) + ) + ) + ->andWhere($qb->expr()->eq('organisation', $qb->createNamedParameter($organisation))) + ->andWhere($qb->expr()->eq('subject_uuid', $qb->createNamedParameter($subject))); + }//end whereTerminalForSubject() + + /** + * Run a COUNT(*) query and return its one number. + * + * @param IQueryBuilder $qb A query selecting `COUNT(*) AS total`. + * + * @return integer The count. + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + private function fetchTotal(IQueryBuilder $qb): int { $result = $qb->executeQuery(); $total = (int)$result->fetchOne(); $result->closeCursor(); return $total; - }//end countActive() + }//end fetchTotal() /** * Suspended runs that are due to resume. diff --git a/lib/Migration/Version1Date20260901123000.php b/lib/Migration/Version1Date20260901123000.php new file mode 100644 index 0000000000..b3f9b195f3 --- /dev/null +++ b/lib/Migration/Version1Date20260901123000.php @@ -0,0 +1,101 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds `or_flowrun_org_subject_idx` on `openregister_flow_runs`. + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ +class Version1Date20260901123000 extends SimpleMigrationStep { + /** + * The index that serves the subject-scoped live and completed reads. + * + * @var string + */ + private const SUBJECT_RUNS_INDEX = 'or_flowrun_org_subject_idx'; + + /** + * Change the database schema. + * + * @param IOutput $output Output for the migration process. + * @param Closure $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + + $schema = $schemaClosure(); + + if ($schema->hasTable('openregister_flow_runs') === false) { + return $schema; + } + + $table = $schema->getTable('openregister_flow_runs'); + + if ($table->hasIndex(self::SUBJECT_RUNS_INDEX) === true) { + $output->info(self::SUBJECT_RUNS_INDEX . ' already present on openregister_flow_runs; nothing to do.'); + return $schema; + } + + $table->addIndex(['organisation', 'subject_uuid', 'status', 'id'], self::SUBJECT_RUNS_INDEX); + $output->info( + 'Added ' . self::SUBJECT_RUNS_INDEX . ' on openregister_flow_runs(organisation, subject_uuid, status, id): ' + . 'the subject-scoped live and completed reads are a range scan on one case ' + . 'rather than a walk over every run of the tenant.' + ); + + return $schema; + }//end changeSchema() +}//end class diff --git a/openspec/changes/flow-runs-subject-scope/tasks.md b/openspec/changes/flow-runs-subject-scope/tasks.md index 34a98e59a3..dbdcbb71bb 100644 --- a/openspec/changes/flow-runs-subject-scope/tasks.md +++ b/openspec/changes/flow-runs-subject-scope/tasks.md @@ -2,12 +2,12 @@ ## 1. The subject filter on the live read -- [ ] 1.1 `FlowRunMapper::findActive()` and `countActive()` gain an +- [x] 1.1 `FlowRunMapper::findActive()` and `countActive()` gain an optional `subject` argument, added as an `AND` predicate on `subject_uuid` next to the unconditional organisation predicate (`lib/Db/FlowRunMapper.php:477`, `:565`). Rows and total share the predicates. -- [ ] 1.2 `FlowRunController::active()` reads the `subject` request +- [x] 1.2 `FlowRunController::active()` reads the `subject` request parameter and passes it through (`lib/Controller/FlowRunController.php:225`). Absent parameter is bit-identical to today; the no-organisation early return stays @@ -15,18 +15,18 @@ ## 2. The completed-runs read -- [ ] 2.1 `FlowRunMapper::findCompletedForSubject()` + +- [x] 2.1 `FlowRunMapper::findCompletedForSubject()` + `countCompletedForSubject()`: `FlowRun::TERMINAL` statuses (`lib/Db/FlowRun.php:142`), required subject uuid, organisation predicate, newest first, capped limit. -- [ ] 2.2 `FlowRunController::completedForSubject()` + route +- [x] 2.2 `FlowRunController::completedForSubject()` + route `GET /api/flow-runs/completed` in `appinfo/routes.php`, refusing a request without `subject` and reusing `summarise()` unchanged. The existing `flowRun#index` history endpoint is not touched. ## 3. The index -- [ ] 3.1 Migration adding a composite index over +- [x] 3.1 Migration adding a composite index over `(organisation, subject_uuid, status)` on the runs table. ## 4. Follow-up (not in this change) @@ -38,11 +38,11 @@ ## 5. Tests -- [ ] 5.1 Mapper unit tests: subject narrowing with honest totals; a +- [x] 5.1 Mapper unit tests: subject narrowing with honest totals; a matching subject uuid in another organisation returns and counts nothing; the terminal set drives the completed read (including `failed`); newest-first ordering. -- [ ] 5.2 Controller unit tests: absent `subject` equals today's read; +- [x] 5.2 Controller unit tests: absent `subject` equals today's read; missing subject on the completed read is refused naming the parameter; both reads return the summarised row (uuid, flow name, step, status, started at, subject block) and never marking, items diff --git a/tests/Unit/Controller/FlowRunControllerTest.php b/tests/Unit/Controller/FlowRunControllerTest.php index a8b5efdfea..7e9f30608e 100644 --- a/tests/Unit/Controller/FlowRunControllerTest.php +++ b/tests/Unit/Controller/FlowRunControllerTest.php @@ -170,16 +170,185 @@ public function testActiveScopesToTheCallersOrganisation(): void { $this->params([]); $this->activeOrganisation('org-a'); + // No `subject` on the request means NO subject predicate reaches the + // mapper: the org-wide widget's read is bit-identical to before. $this->mapper->expects($this->once())->method('findActive') - ->with('org-a', 10) + ->with('org-a', 10, null) ->willReturn([]); $this->mapper->expects($this->once())->method('countActive') - ->with('org-a') + ->with('org-a', null) ->willReturn(0); $this->controller->active(); }//end testActiveScopesToTheCallersOrganisation() + public function testActivePassesTheSubjectToBothTheRowsAndTheTotal(): void { + $this->params(['subject' => 'case-x']); + $this->activeOrganisation('org-a'); + + // The organisation predicate stays: the subject NARROWS inside it. And + // the total is counted with the same filter, so a case widget can say + // "2 running" rather than the tenant-wide number. + $this->mapper->expects($this->once())->method('findActive') + ->with('org-a', 10, 'case-x') + ->willReturn([]); + $this->mapper->expects($this->once())->method('countActive') + ->with('org-a', 'case-x') + ->willReturn(2); + + $this->assertSame(2, $this->controller->active()->getData()['total']); + }//end testActivePassesTheSubjectToBothTheRowsAndTheTotal() + + public function testActiveWithASubjectStillReadsNothingWithoutAnOrganisation(): void { + $this->params(['subject' => 'case-x']); + $this->activeOrganisation(null); + + // A subject uuid is guessable. It must not become a way to read runs + // when the tenant scope cannot be established: no query at all. + $this->mapper->expects($this->never())->method('findActive'); + $this->mapper->expects($this->never())->method('countActive'); + + $body = $this->controller->active()->getData(); + + $this->assertSame([], $body['results']); + $this->assertSame(0, $body['total']); + }//end testActiveWithASubjectStillReadsNothingWithoutAnOrganisation() + + public function testABlankSubjectOnTheLiveReadIsNoFilter(): void { + $this->params(['subject' => ' ']); + $this->activeOrganisation('org-a'); + + $this->mapper->expects($this->once())->method('findActive') + ->with('org-a', 10, null) + ->willReturn([]); + $this->mapper->method('countActive')->willReturn(0); + + $this->controller->active(); + }//end testABlankSubjectOnTheLiveReadIsNoFilter() + + public function testCompletedForSubjectWithoutASubjectIsRefusedNamingTheParameter(): void { + $this->params([]); + $this->activeOrganisation('org-a'); + + // Refused, not answered widely: there is no org-wide "everything that + // ever finished" on this path. The history endpoint is that surface, + // with its own per-caller visibility rule. + $this->mapper->expects($this->never())->method('findCompletedForSubject'); + $this->mapper->expects($this->never())->method('countCompletedForSubject'); + + $response = $this->controller->completedForSubject(); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertStringContainsString('subject', $response->getData()['error']); + }//end testCompletedForSubjectWithoutASubjectIsRefusedNamingTheParameter() + + public function testCompletedForSubjectWithABlankSubjectIsRefused(): void { + $this->params(['subject' => ' ']); + $this->activeOrganisation('org-a'); + + $this->mapper->expects($this->never())->method('findCompletedForSubject'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $this->controller->completedForSubject()->getStatus()); + }//end testCompletedForSubjectWithABlankSubjectIsRefused() + + public function testCompletedForSubjectWithNoResolvableOrganisationReturnsNothing(): void { + $this->params(['subject' => 'case-x']); + $this->activeOrganisation(null); + + $this->mapper->expects($this->never())->method('findCompletedForSubject'); + $this->mapper->expects($this->never())->method('countCompletedForSubject'); + + $response = $this->controller->completedForSubject(); + $body = $response->getData(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame([], $body['results']); + $this->assertSame(0, $body['total']); + }//end testCompletedForSubjectWithNoResolvableOrganisationReturnsNothing() + + public function testCompletedForSubjectScopesToTheOrganisationAndTheSubjectWithACappedLimit(): void { + $this->params(['subject' => 'case-x', 'limit' => 5000]); + $this->activeOrganisation('org-a'); + + $this->mapper->expects($this->once())->method('findCompletedForSubject') + ->with('org-a', 'case-x', 50) + ->willReturn([]); + $this->mapper->expects($this->once())->method('countCompletedForSubject') + ->with('org-a', 'case-x') + ->willReturn(14); + + $body = $this->controller->completedForSubject()->getData(); + + $this->assertSame(50, $body['limit']); + // The honest total, not the length of the bounded page. + $this->assertSame(14, $body['total']); + }//end testCompletedForSubjectScopesToTheOrganisationAndTheSubjectWithACappedLimit() + + public function testBothReadsShareOneRowShapeAndNeverCarryTheMarkingItemsOrLog(): void { + $this->params(['subject' => 'case-x']); + $this->activeOrganisation('org-a'); + + $live = new FlowRun(); + $live->setUuid('run-live'); + $live->setFlowId('f1'); + $live->setStatus(FlowRun::STATUS_SUSPENDED); + $live->setMarking(['await-reply' => 1]); + $live->setItems([['record' => 'the subject\'s own data']]); + $live->setLog([['step' => 1, 'node' => 'start']]); + $live->setContext(['secret' => 'x']); + $live->setCreated(new \DateTime('2026-09-01T10:00:00+00:00')); + $live->setSubjectUuid('case-x'); + $live->setSubjectRegister('cases'); + $live->setSubjectSchema('case'); + + $done = new FlowRun(); + $done->setUuid('run-done'); + $done->setFlowId('f1'); + $done->setStatus(FlowRun::STATUS_FAILED); + // A finished run has no token anywhere: step must read null, not ''. + $done->setMarking([]); + $done->setItems([['record' => 'more subject data']]); + $done->setLog([['step' => 1], ['step' => 2]]); + $done->setCreated(new \DateTime('2026-08-30T09:00:00+00:00')); + $done->setSubjectUuid('case-x'); + $done->setSubjectRegister('cases'); + $done->setSubjectSchema('case'); + + $this->mapper->method('findActive')->willReturn([$live]); + $this->mapper->method('countActive')->willReturn(1); + $this->mapper->method('findCompletedForSubject')->willReturn([$done]); + $this->mapper->method('countCompletedForSubject')->willReturn(1); + $this->resolvers->method('resolveFlow')->with('f1')->willReturn(['id' => 'f1', 'name' => 'Hersteltermijn']); + + $liveRow = $this->controller->active()->getData()['results'][0]; + $doneRow = $this->controller->completedForSubject()->getData()['results'][0]; + + // The five widget fields plus the subject block, on both. + $this->assertSame('run-live', $liveRow['uuid']); + $this->assertSame('Hersteltermijn', $liveRow['flowName']); + $this->assertSame('await-reply', $liveRow['step']); + $this->assertSame(FlowRun::STATUS_SUSPENDED, $liveRow['status']); + $this->assertSame('2026-09-01T10:00:00+00:00', $liveRow['created']); + $this->assertSame(['uuid' => 'case-x', 'register' => 'cases', 'schema' => 'case'], $liveRow['subject']); + + $this->assertSame('run-done', $doneRow['uuid']); + $this->assertSame('Hersteltermijn', $doneRow['flowName']); + $this->assertNull($doneRow['step']); + $this->assertSame(FlowRun::STATUS_FAILED, $doneRow['status']); + $this->assertSame('2026-08-30T09:00:00+00:00', $doneRow['created']); + $this->assertSame(['uuid' => 'case-x', 'register' => 'cases', 'schema' => 'case'], $doneRow['subject']); + + // One shape: a widget renders live and finished runs as one list. + $this->assertSame(array_keys($liveRow), array_keys($doneRow)); + + // And never the heavy fields: kilobytes per row a list never renders, + // and items can hold the subject's own record data. + foreach (['marking', 'items', 'log', 'context', 'error', 'steps'] as $heavy) { + $this->assertArrayNotHasKey($heavy, $liveRow, "live row leaks '$heavy'"); + $this->assertArrayNotHasKey($heavy, $doneRow, "completed row leaks '$heavy'"); + } + }//end testBothReadsShareOneRowShapeAndNeverCarryTheMarkingItemsOrLog() + public function testActiveSummarisesEachRunWithItsFlowNameAndStep(): void { $this->params(['limit' => 5]); $this->activeOrganisation('org-a'); diff --git a/tests/Unit/Db/FlowRunMapperSubjectScopeTest.php b/tests/Unit/Db/FlowRunMapperSubjectScopeTest.php new file mode 100644 index 0000000000..f0993ac4a8 --- /dev/null +++ b/tests/Unit/Db/FlowRunMapperSubjectScopeTest.php @@ -0,0 +1,258 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-runs-subject-scope/specs/flow-runs-subject-scope/spec.md + */ + +declare(strict_types=1); + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- arrange/act/assert PHPUnit conventions; the test name is the statement. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit assertion helpers use positional args. + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCP\DB\IResult; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; + +class FlowRunMapperSubjectScopeTest extends TestCase { + + /** + * Every predicate the query received, in order, rendered as `column OP value`. + * + * @var array + */ + private array $predicates = []; + + /** + * The `orderBy` calls, as `[column, direction]` pairs. + * + * @var array> + */ + private array $orderBy = []; + + /** + * The `setMaxResults` argument, or null when never bounded. + * + * @var integer|null + */ + private ?int $limit = null; + + /** + * How many query builders the mapper asked for. + * + * @var integer + */ + private int $queries = 0; + + /** + * A mapper over a connection whose query builder records what is asked of it. + * + * The expression builder renders `eq()`/`in()` as readable strings and + * `createNamedParameter()` returns the VALUE (json-encoded) instead of a + * placeholder, so a recorded predicate reads `organisation = "org-a"` and + * can be asserted on directly. The result set is empty: what is under test + * is the question, not the rows. + * + * @param integer $total What a COUNT(*) query answers. + * + * @return FlowRunMapper The mapper. + */ + private function recordingMapper(int $total = 0): FlowRunMapper { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturnCallback( + static fn (string $column, string $value): string => $column . ' = ' . $value + ); + $expr->method('in')->willReturnCallback( + static fn (string $column, string $value): string => $column . ' IN ' . $value + ); + + $result = $this->createMock(IResult::class); + $result->method('fetch')->willReturn(false); + $result->method('fetchOne')->willReturn($total); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('expr')->willReturn($expr); + $qb->method('createNamedParameter')->willReturnCallback( + static fn ($value): string => (string)json_encode($value) + ); + $qb->method('createFunction')->willReturnArgument(0); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('where')->willReturnCallback( + function (string $predicate) use ($qb): IQueryBuilder { + $this->predicates[] = $predicate; + return $qb; + } + ); + $qb->method('andWhere')->willReturnCallback( + function (string $predicate) use ($qb): IQueryBuilder { + $this->predicates[] = $predicate; + return $qb; + } + ); + $qb->method('orderBy')->willReturnCallback( + function (string $column, ?string $direction = null) use ($qb): IQueryBuilder { + $this->orderBy[] = [$column, (string)$direction]; + return $qb; + } + ); + $qb->method('setMaxResults')->willReturnCallback( + function (?int $limit) use ($qb): IQueryBuilder { + $this->limit = $limit; + return $qb; + } + ); + $qb->method('executeQuery')->willReturn($result); + + $db = $this->createMock(IDBConnection::class); + $db->method('getQueryBuilder')->willReturnCallback( + function () use ($qb): IQueryBuilder { + $this->queries++; + return $qb; + } + ); + + return new FlowRunMapper($db); + }//end recordingMapper() + + /** + * The status set as the recording builder renders it. + * + * @param array $statuses The status set. + * + * @return string The rendered IN predicate. + */ + private static function statusIn(array $statuses): string { + return 'status IN ' . json_encode($statuses); + }//end statusIn() + + public function testFindActiveWithASubjectKeepsTheOrganisationPredicateAndAddsTheSubject(): void { + $mapper = $this->recordingMapper(); + + $mapper->findActive(organisation: 'org-a', limit: 25, subject: 'case-x'); + + // The organisation predicate is unconditional and comes FIRST; the + // subject narrows after it. This is the two-organisation scenario: a + // caller in org A asking for a subject that lives in org B hits + // `organisation = "org-a"` before `subject_uuid` is ever consulted. + $this->assertSame( + [self::statusIn(FlowRun::ACTIVE), 'organisation = "org-a"', 'subject_uuid = "case-x"'], + $this->predicates + ); + }//end testFindActiveWithASubjectKeepsTheOrganisationPredicateAndAddsTheSubject() + + public function testFindActiveWithoutASubjectIsTodaysRead(): void { + $mapper = $this->recordingMapper(); + + $mapper->findActive(organisation: 'org-a', limit: 25); + + $this->assertSame([self::statusIn(FlowRun::ACTIVE), 'organisation = "org-a"'], $this->predicates); + $this->assertSame([['id', 'DESC']], $this->orderBy); + $this->assertSame(25, $this->limit); + }//end testFindActiveWithoutASubjectIsTodaysRead() + + public function testABlankSubjectAddsNoPredicate(): void { + $mapper = $this->recordingMapper(); + + $mapper->findActive(organisation: 'org-a', limit: 25, subject: ''); + + $this->assertNotContains('subject_uuid = ""', $this->predicates); + $this->assertCount(2, $this->predicates); + }//end testABlankSubjectAddsNoPredicate() + + public function testCountActiveSharesTheRowPredicatesSoTheTotalIsHonest(): void { + $mapper = $this->recordingMapper(total: 2); + + $total = $mapper->countActive(organisation: 'org-a', subject: 'case-x'); + + $this->assertSame(2, $total); + $this->assertSame( + [self::statusIn(FlowRun::ACTIVE), 'organisation = "org-a"', 'subject_uuid = "case-x"'], + $this->predicates + ); + }//end testCountActiveSharesTheRowPredicatesSoTheTotalIsHonest() + + public function testTheCompletedReadAsksForTheTerminalSetOnThisOrganisationAndSubjectNewestFirst(): void { + $mapper = $this->recordingMapper(); + + $mapper->findCompletedForSubject(organisation: 'org-a', subject: 'case-x', limit: 7); + + $this->assertSame( + [self::statusIn(FlowRun::TERMINAL), 'organisation = "org-a"', 'subject_uuid = "case-x"'], + $this->predicates + ); + $this->assertSame([['id', 'DESC']], $this->orderBy); + $this->assertSame(7, $this->limit); + }//end testTheCompletedReadAsksForTheTerminalSetOnThisOrganisationAndSubjectNewestFirst() + + public function testTheTerminalSetTheCompletedReadUsesIncludesFailed(): void { + // "A failed run is history too": the read is driven by the entity's + // terminal set, and that set carries `failed`. If someone ever trims it + // to `completed` alone, a failed hersteltermijn vanishes from its case. + $this->assertContains(FlowRun::STATUS_FAILED, FlowRun::TERMINAL); + $this->assertContains(FlowRun::STATUS_COMPLETED, FlowRun::TERMINAL); + $this->assertContains(FlowRun::STATUS_STOPPED, FlowRun::TERMINAL); + $this->assertContains(FlowRun::STATUS_DEAD_LETTER, FlowRun::TERMINAL); + + $mapper = $this->recordingMapper(); + $mapper->findCompletedForSubject(organisation: 'org-a', subject: 'case-x'); + + $this->assertStringContainsString('"failed"', $this->predicates[0]); + }//end testTheTerminalSetTheCompletedReadUsesIncludesFailed() + + public function testTheCompletedCountSharesThePredicates(): void { + $mapper = $this->recordingMapper(total: 14); + + $total = $mapper->countCompletedForSubject(organisation: 'org-a', subject: 'case-x'); + + $this->assertSame(14, $total); + $this->assertSame( + [self::statusIn(FlowRun::TERMINAL), 'organisation = "org-a"', 'subject_uuid = "case-x"'], + $this->predicates + ); + }//end testTheCompletedCountSharesThePredicates() + + public function testTheCompletedReadFailsClosedWithoutASubjectOrOrganisation(): void { + $mapper = $this->recordingMapper(total: 99); + + // Dropping a required predicate would answer a WIDER question than was + // asked. Nothing is queried; the answer is empty and zero. + $this->assertSame([], $mapper->findCompletedForSubject(organisation: 'org-a', subject: '')); + $this->assertSame([], $mapper->findCompletedForSubject(organisation: '', subject: 'case-x')); + $this->assertSame(0, $mapper->countCompletedForSubject(organisation: 'org-a', subject: '')); + $this->assertSame(0, $mapper->countCompletedForSubject(organisation: '', subject: 'case-x')); + $this->assertSame(0, $this->queries); + }//end testTheCompletedReadFailsClosedWithoutASubjectOrOrganisation() +}//end class From 5e0df0424f3faa20ea4110db30e6f085cc01694f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 09:00:04 +0200 Subject: [PATCH 22/68] feat(flow): send-notification, send-email and send-talk-message nodes through the notification subsystem (#3257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flow): send-notification, send-email and send-talk-message nodes through the notification subsystem Three messaging step nodes, implemented as orchestration-time invokers of the ADR-031 channel machinery rather than a second messaging stack: - The per-channel send units (nc-notification + web-push ride-along, email composition/handoff, Talk post), the recipient resolver and the dialect's placeholder evaluator are extracted from AnnotationNotificationDispatcher into call-shared units under lib/Service/Notification/; the dispatcher is re-wired onto them with its existing tests green and unchanged. - FlowMessagingService applies the guards in order: per-channel subsystem kill switches (new, default-on, silencing BOTH callers), the recipient's own channel preference, a post-expansion recipient bound (app-config raisable), and the RateLimiter — whose buckets gain a caller-agnostic shared per-recipient budget so a flow cannot double a person's ceiling. - Sends are attributed to the run's acting user; no resolvable actor fails the step naming the missing actor. Talk posts as the acting user and treats "not a participant" as a step failure, never an auto-join. - Per-recipient/channel outcomes land on the run log through a new FlowStepReport context handle the engine drains per hop, bounded by the log's sampling rule; a send failure is a step failure through onError. - No send-webhook node: outbound HTTP stays with OpenConnector (ADR-094). @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md * chore(flow): satisfy the spec-coverage and spec-anchor gates on the messaging nodes The config-form floor is cited at its shipped home in the flow-engine spec (the flow-node-config-forms change carries no spec file under that name), and the resolver's shared helpers and the Talk app boundary carry the messaging spec anchors they implement. --------- Co-authored-by: Conduction Release Bot --- lib/Listener/FlowNodeRegistrationListener.php | 19 + lib/Notification/AnnotationNotifier.php | 10 +- lib/Service/Flow/FlowEngine.php | 47 +- lib/Service/Flow/FlowMessagingService.php | 842 ++++++++++++++++++ lib/Service/Flow/FlowRunService.php | 1 + lib/Service/Flow/FlowStepReport.php | 85 ++ lib/Service/Flow/Nodes/SendEmailNode.php | 214 +++++ .../Flow/Nodes/SendNotificationNode.php | 217 +++++ .../Flow/Nodes/SendTalkMessageNode.php | 201 +++++ .../AnnotationNotificationDispatcher.php | 747 +++++----------- lib/Service/Notification/EmailSender.php | 116 +++ .../Notification/NcNotificationSender.php | 268 ++++++ .../NotificationChannelPolicy.php | 95 ++ .../NotificationRecipientResolver.php | 412 +++++++++ .../Notification/NotificationTemplating.php | 156 ++++ lib/Service/Notification/RateLimiter.php | 116 ++- .../Notification/TalkSendException.php | 36 + lib/Service/Notification/TalkSender.php | 227 +++++ openspec/changes/flow-engine-docs/tasks.md | 11 +- .../changes/flow-messaging-nodes/tasks.md | 30 +- .../Flow/FlowMessagingEquivalenceTest.php | 328 +++++++ .../Service/Flow/FlowMessagingServiceTest.php | 614 +++++++++++++ .../Service/Flow/SendMessagingNodesTest.php | 201 +++++ .../Service/Notification/RateLimiterTest.php | 30 + 24 files changed, 4453 insertions(+), 570 deletions(-) create mode 100644 lib/Service/Flow/FlowMessagingService.php create mode 100644 lib/Service/Flow/FlowStepReport.php create mode 100644 lib/Service/Flow/Nodes/SendEmailNode.php create mode 100644 lib/Service/Flow/Nodes/SendNotificationNode.php create mode 100644 lib/Service/Flow/Nodes/SendTalkMessageNode.php create mode 100644 lib/Service/Notification/EmailSender.php create mode 100644 lib/Service/Notification/NcNotificationSender.php create mode 100644 lib/Service/Notification/NotificationChannelPolicy.php create mode 100644 lib/Service/Notification/NotificationRecipientResolver.php create mode 100644 lib/Service/Notification/NotificationTemplating.php create mode 100644 lib/Service/Notification/TalkSendException.php create mode 100644 lib/Service/Notification/TalkSender.php create mode 100644 tests/Unit/Service/Flow/FlowMessagingEquivalenceTest.php create mode 100644 tests/Unit/Service/Flow/FlowMessagingServiceTest.php create mode 100644 tests/Unit/Service/Flow/SendMessagingNodesTest.php diff --git a/lib/Listener/FlowNodeRegistrationListener.php b/lib/Listener/FlowNodeRegistrationListener.php index 82c9b665d2..59be73c6e5 100644 --- a/lib/Listener/FlowNodeRegistrationListener.php +++ b/lib/Listener/FlowNodeRegistrationListener.php @@ -40,6 +40,9 @@ use OCA\OpenRegister\Service\Flow\Nodes\ObjectReadNode; use OCA\OpenRegister\Service\Flow\Nodes\ObjectWriteNode; use OCA\OpenRegister\Service\Flow\Nodes\RouterNode; +use OCA\OpenRegister\Service\Flow\Nodes\SendEmailNode; +use OCA\OpenRegister\Service\Flow\Nodes\SendNotificationNode; +use OCA\OpenRegister\Service\Flow\Nodes\SendTalkMessageNode; use OCA\OpenRegister\Service\Flow\Nodes\SetFieldsNode; use OCA\OpenRegister\Service\Flow\Nodes\SubFlowNode; use OCA\OpenRegister\Service\Flow\Nodes\SwitchNode; @@ -76,6 +79,9 @@ class FlowNodeRegistrationListener implements IEventListener { * @param FlowStateNode $flowState The built-in "Flow state" node. * @param MapNode $map The built-in "Map" node. * @param IterateNode $iterate The built-in "Repeat until done" node. + * @param SendNotificationNode $sendNotification The built-in "Send a notification" node. + * @param SendEmailNode $sendEmail The built-in "Send an email" node. + * @param SendTalkMessageNode $sendTalkMessage The built-in "Send a Talk message" node. * @param TriggerObjectNode $triggerObject The "When an object changes" entry point. * @param TriggerScheduleNode $triggerSchedule The "On a schedule" entry point. * @param TriggerManualNode $triggerManual The "When someone runs it" entry point. @@ -97,6 +103,9 @@ public function __construct( private readonly FlowStateNode $flowState, private readonly MapNode $map, private readonly IterateNode $iterate, + private readonly SendNotificationNode $sendNotification, + private readonly SendEmailNode $sendEmail, + private readonly SendTalkMessageNode $sendTalkMessage, private readonly TriggerObjectNode $triggerObject, private readonly TriggerScheduleNode $triggerSchedule, private readonly TriggerManualNode $triggerManual, @@ -135,6 +144,16 @@ public function handle(Event $event): void { $event->registerNode(node: $this->map); $event->registerNode(node: $this->iterate); + // Messaging. Three nodes, not one "send" with a channel picker: the + // three differ in config shape and failure modes, so three flat forms + // beat one union form. Deliberately NO send-webhook — outbound HTTP is + // OpenConnector's job (ADR-094), and `activity`/`web-push` are not + // channels here either: activity is an audit surface, web-push rides + // along with send-notification exactly as it does declaratively. + $event->registerNode(node: $this->sendNotification); + $event->registerNode(node: $this->sendEmail); + $event->registerNode(node: $this->sendTalkMessage); + // Entry points. Registered like any other node so the palette can offer // them and the preflight can check their config — a trigger is where a // run BEGINS, not work it performs, and each `execute()` is a diff --git a/lib/Notification/AnnotationNotifier.php b/lib/Notification/AnnotationNotifier.php index d320aa1a30..c9f3c80d60 100644 --- a/lib/Notification/AnnotationNotifier.php +++ b/lib/Notification/AnnotationNotifier.php @@ -126,7 +126,15 @@ public function prepare(INotification $notification, string $languageCode): INot // localised string with the object title + register name substituted. $objectTitle = (string)($params['objectTitle'] ?? $l->t('object')); $registerName = (string)($params['registerName'] ?? ($params['registerId'] ?? '')); - $parsedSubject = $l->t(self::SUBJECT_TEMPLATES[$subject], [$objectTitle, $registerName]); + // Only a canonical object subject has a template; a custom subject + // (e.g. a flow send's `flow_message`) reaches this point purely on its + // `_text`, and indexing SUBJECT_TEMPLATES with it would be an + // undefined-key error that killed the render. + $parsedSubject = ''; + if ($isObject === true) { + $parsedSubject = $l->t(self::SUBJECT_TEMPLATES[$subject], [$objectTitle, $registerName]); + } + if ($hasText === true) { $parsedSubject = $text; } diff --git a/lib/Service/Flow/FlowEngine.php b/lib/Service/Flow/FlowEngine.php index ab8ae72735..15285530d6 100644 --- a/lib/Service/Flow/FlowEngine.php +++ b/lib/Service/Flow/FlowEngine.php @@ -486,7 +486,7 @@ public function run( $produced = $dispatcher->dispatch(step: $step, items: $itemsIn, context: $context); $items = FlowItems::normalise(value: $produced); - $log[] = [ + $entry = [ 'transition' => $name, 'type' => $stepType, 'status' => 'completed', @@ -501,6 +501,17 @@ public function run( 'output' => $this->sampleItems(items: $items), 'durationMs' => (int)round((microtime(true) - $startedAt) * 1000), ]; + + // What the step LOGGED, beyond its items: a side-effect node's + // own report (a send node's per-recipient outcomes, say). The + // report handle travels in the context and is drained per hop, + // so a node's detail lands on ITS entry and no other. + $report = $this->stepReport(context: $context); + if ($report !== []) { + $entry['report'] = $report; + } + + $log[] = $entry; } catch (FlowStop $stop) { // A deliberate end, requested by a Stop step. Caught before the // generic Throwable so it is never treated as a step failure and @@ -552,13 +563,23 @@ public function run( 'resumeAt' => $suspension->getResumeAt(), ]; } catch (Throwable $e) { - $log[] = [ + $entry = [ 'transition' => $name, 'type' => $stepType, 'status' => 'failed', 'error' => $e->getMessage(), 'durationMs' => (int)round((microtime(true) - $startedAt) * 1000), ]; + + // A failing step's report is worth MORE than a completing one's: + // a send node that delivered to two recipients and failed on the + // third records exactly which sends went out before the throw. + $report = $this->stepReport(context: $context); + if ($report !== []) { + $entry['report'] = $report; + } + + $log[] = $entry; $outcome = $this->outcomeForFailedStep( step: $step, error: $e, @@ -694,6 +715,28 @@ private function outcomeForFailedStep( return null; }//end outcomeForFailedStep() + /** + * Drain the hop's step report from the context, when one travels there. + * + * Taking (rather than reading) is what scopes a report to one hop: the + * handle is cleared by the read, so a node's detail can never bleed onto a + * later step's log entry. + * + * @param array $context The run context. + * + * @return array The report detail, or an empty array. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-records-what-each-node-received-returned-and-logged + */ + private function stepReport(array $context): array { + $report = ($context[FlowStepReport::CONTEXT_KEY] ?? null); + if (($report instanceof FlowStepReport) === false) { + return []; + } + + return $report->take(); + }//end stepReport() + /** * A bounded, honest sample of an item list, for the run log. * diff --git a/lib/Service/Flow/FlowMessagingService.php b/lib/Service/Flow/FlowMessagingService.php new file mode 100644 index 0000000000..87fee2e1b4 --- /dev/null +++ b/lib/Service/Flow/FlowMessagingService.php @@ -0,0 +1,842 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Notification\EmailSender; +use OCA\OpenRegister\Service\Notification\NcNotificationSender; +use OCA\OpenRegister\Service\Notification\NotificationChannelPolicy; +use OCA\OpenRegister\Service\Notification\NotificationPreferenceService; +use OCA\OpenRegister\Service\Notification\NotificationRecipientResolver; +use OCA\OpenRegister\Service\Notification\NotificationTemplating; +use OCA\OpenRegister\Service\Notification\RateLimiter; +use OCA\OpenRegister\Service\Notification\TalkSender; +use OCA\OpenRegister\Service\Notification\TalkSendException; +use OCP\IAppConfig; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Sends on behalf of a flow run, through the notification subsystem. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The service exists to wire the + * subsystem's units together for the flow caller; each dependency IS one of the + * shared units the spec obliges it to reuse rather than duplicate. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One guard chain in the spec's + * order plus per-channel delivery; PHPMD sums every guard's branches into the + * class total, and splitting the chain would hide the order it exists to state. + */ +class FlowMessagingService { + + /** + * The preference scope flow sends resolve under. Flow sends have no + * schema, so their preference overrides live under this pseudo-slug and + * the `send` key — one lever per user, honoured through the SAME + * override store and resolver the declarative subsystem uses. + */ + public const PREFERENCE_SLUG = 'flow'; + + public const PREFERENCE_KEY = 'send'; + + /** + * App-config key for the per-step recipient bound, and its default. + * Modest on purpose: a recipient template that expands to the whole + * instance is a configuration error to surface, not a broadcast to + * perform. Raisable per instance via app config. + */ + public const CONFIG_RECIPIENT_BOUND = 'flow_messaging_recipient_bound'; + + public const DEFAULT_RECIPIENT_BOUND = 25; + + /** + * How many entries each outcome list in the report keeps. The run log's + * existing sampling rule, applied to recipients. + */ + public const REPORT_SAMPLE = FlowEngine::LOG_ITEM_SAMPLE; + + /** + * Constructor. Every dependency is one of the subsystem's call-shared + * units — the same objects the declarative dispatcher invokes. + * + * @param NotificationChannelPolicy $channelPolicy The subsystem's per-channel kill switches. + * @param NotificationRecipientResolver $recipientResolver The subsystem's recipient resolver. + * @param NotificationTemplating $templating The dialect's placeholder evaluator. + * @param NcNotificationSender $ncSender The nc-notification channel sender (web-push rides along). + * @param EmailSender $emailSender The email channel sender. + * @param TalkSender $talkSender The Talk channel sender. + * @param RateLimiter $rateLimiter The subsystem's rate limiter (shared per-recipient budget). + * @param NotificationPreferenceService $preferences The recipient preference resolver. + * @param IUserManager $userManager Resolves the acting user. + * @param IAppConfig $appConfig App config for the recipient bound. + * @param LoggerInterface $logger Logger for send diagnostics. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) DI-injected shared units. + */ + public function __construct( + private readonly NotificationChannelPolicy $channelPolicy, + private readonly NotificationRecipientResolver $recipientResolver, + private readonly NotificationTemplating $templating, + private readonly NcNotificationSender $ncSender, + private readonly EmailSender $emailSender, + private readonly TalkSender $talkSender, + private readonly RateLimiter $rateLimiter, + private readonly NotificationPreferenceService $preferences, + private readonly IUserManager $userManager, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Send an in-app Nextcloud notification (web-push riding along) per item. + * + * @param array $config The step configuration (`recipients`, `title`, `message`). + * @param array $items The flow items; one send per recipient per item. + * @param array $context The run context (acting user, report handle). + * @param string $stepName The step's type id, used as the send's rule identity. + * + * @return array The outcome report also written to the run log. + * + * @throws RuntimeException When there is no resolvable acting user, the + * recipient bound is exceeded, or a send failed. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function sendNotification(array $config, array $items, array $context, string $stepName): array { + return $this->sendPerRecipient( + channel: 'nc-notification', + config: $config, + items: $items, + context: $context, + stepName: $stepName, + titleKey: 'title', + bodyKey: 'message' + ); + }//end sendNotification() + + /** + * Send an email per recipient per item. + * + * @param array $config The step configuration (`recipients`, `subject`, `body`). + * @param array $items The flow items; one send per recipient per item. + * @param array $context The run context (acting user, report handle). + * @param string $stepName The step's type id, used as the send's rule identity. + * + * @return array The outcome report also written to the run log. + * + * @throws RuntimeException When there is no resolvable acting user, the + * recipient bound is exceeded, or a send failed. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function sendEmail(array $config, array $items, array $context, string $stepName): array { + return $this->sendPerRecipient( + channel: 'email', + config: $config, + items: $items, + context: $context, + stepName: $stepName, + titleKey: 'subject', + bodyKey: 'body' + ); + }//end sendEmail() + + /** + * Post a Talk chat message as the run's acting user, per item. + * + * The conversation is a token or an item-field template. The acting user + * MUST be a participant; "not a participant" is a step failure with that + * reason, never an auto-join. + * + * @param array $config The step configuration (`conversation`, `message`). + * @param array $items The flow items; one post per item. + * @param array $context The run context (acting user, report handle). + * @param string $stepName The step's type id, used as the send's rule identity. + * + * @return array The outcome report also written to the run log. + * + * @throws RuntimeException When there is no resolvable acting user or a post failed. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + public function sendTalkMessage(array $config, array $items, array $context, string $stepName): array { + $actor = $this->resolveActingUser(context: $context); + + $outcomes = $this->emptyOutcomes(); + $failures = []; + + foreach ($items as $item) { + $json = (array)($item[FlowItems::JSON] ?? []); + $token = $this->resolveScalarConfig(value: (string)($config['conversation'] ?? ''), json: $json, context: $context); + $message = $this->templating->interpolate( + template: (string)($config['message'] ?? ''), + data: $json, + context: $this->scalarContext(context: $context) + ); + + if ($token === '') { + $failures[] = 'No Talk conversation resolved for an item; nothing was posted for it.'; + $this->addOutcome(outcomes: $outcomes, bucket: 'failed', recipient: $token); + continue; + } + + // The conversation is not a person: the rate bucket key uses the + // broadcast pseudo-recipient convention, outside the shared + // per-recipient budget. + if ($this->rateLimiter->tryConsume(ruleId: $stepName, recipient: '__talk__:' . $token) === false) { + $this->addOutcome(outcomes: $outcomes, bucket: 'rateLimited', recipient: $token); + continue; + } + + try { + $outcome = $this->talkSender->postAsUser(token: $token, message: $message, actorUid: $actor); + } catch (TalkSendException $e) { + $failures[] = $e->getMessage(); + $this->addOutcome(outcomes: $outcomes, bucket: 'failed', recipient: $token); + continue; + } + + if ($outcome === TalkSender::OUTCOME_KILL_SWITCH) { + $this->addOutcome(outcomes: $outcomes, bucket: 'skippedByKillSwitch', recipient: $token); + continue; + } + + $this->addOutcome(outcomes: $outcomes, bucket: 'delivered', recipient: $token); + }//end foreach + + $report = $this->buildReport( + channel: 'talk', + actor: $actor, + recipients: 0, + outcomes: $outcomes, + unknown: [] + ); + $this->writeReport(context: $context, report: $report); + + if ($failures !== []) { + throw new RuntimeException( + sprintf('%d of %d Talk posts failed: %s', count($failures), count($items), implode(' | ', array_slice($failures, 0, 3))) + ); + } + + return $report; + }//end sendTalkMessage() + + /** + * The shared per-recipient pipeline for nc-notification and email. + * + * Order of guards, each sufficient alone: channel kill switch, the + * recipient's preference, the post-expansion recipient bound, the shared + * rate-limit budget — then the send, through the subsystem's own sender. + * + * @param string $channel The channel (`nc-notification` or `email`). + * @param array $config The step configuration. + * @param array $items The flow items. + * @param array $context The run context. + * @param string $stepName The step's type id. + * @param string $titleKey The config key holding the title/subject template. + * @param string $bodyKey The config key holding the body template. + * + * @return array The outcome report. + * + * @throws RuntimeException Missing actor, exceeded bound, or failed sends. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) The guard chain is the spec's own + * order; each branch is one guard with its own outcome bucket. + * @SuppressWarnings(PHPMD.NPathComplexity) Guards multiply; all are required. + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) The chain reads top to bottom in + * the order the spec states it; splitting it would hide the order. + */ + private function sendPerRecipient( + string $channel, + array $config, + array $items, + array $context, + string $stepName, + string $titleKey, + string $bodyKey, + ): array { + $actor = $this->resolveActingUser(context: $context); + + // Resolve recipients per item, post-expansion, before anything sends. + $perItem = []; + $unknown = []; + $distinct = []; + foreach ($items as $index => $item) { + $json = (array)($item[FlowItems::JSON] ?? []); + $resolved = $this->resolveRecipients(recipients: ($config['recipients'] ?? []), json: $json); + $perItem[$index] = ['json' => $json, 'uids' => $resolved['uids']]; + foreach ($resolved['uids'] as $uid) { + $distinct[$uid] = true; + } + + foreach ($resolved['unknown'] as $bad) { + $unknown[$bad] = true; + } + } + + if ($unknown !== []) { + $this->logger->info( + sprintf( + '[FlowMessagingService] %d recipient entries did not resolve to a user or group: %s', + count($unknown), + implode(', ', array_slice(array_keys($unknown), 0, self::REPORT_SAMPLE)) + ) + ); + } + + $outcomes = $this->emptyOutcomes(); + $failures = []; + + // KILL SWITCH, first and channel-wide: a silenced channel is a skip + // recorded per recipient, never a failure and never a silent no-op. + if ($this->channelPolicy->isChannelEnabled(channel: $channel) === false) { + foreach (array_keys($distinct) as $uid) { + $this->addOutcome(outcomes: $outcomes, bucket: 'skippedByKillSwitch', recipient: (string)$uid); + } + + $report = $this->buildReport( + channel: $channel, + actor: $actor, + recipients: count($distinct), + outcomes: $outcomes, + unknown: array_keys($unknown) + ); + $this->writeReport(context: $context, report: $report); + + return $report; + } + + // PREFERENCE, per recipient: a user who turned the channel off stays + // not-messaged on it, flow or no flow. Applied before the bound so a + // preference-skipped user still counts toward the resolved total the + // bound judges (the config addressed them; their settings vetoed it). + $sendable = []; + foreach (array_keys($distinct) as $uid) { + if ($this->preferenceAllows(uid: (string)$uid, channel: $channel) === false) { + $this->addOutcome(outcomes: $outcomes, bucket: 'skippedByPreference', recipient: (string)$uid); + continue; + } + + $sendable[(string)$uid] = true; + } + + // RECIPIENT BOUND, post-expansion: bounding the resolved humans, not + // the config entries. Refusal is a step failure naming the count and + // the bound, routed through the step's `onError` policy — and nothing + // has been sent yet. + $bound = $this->recipientBound(); + if (count($distinct) > $bound) { + $report = $this->buildReport( + channel: $channel, + actor: $actor, + recipients: count($distinct), + outcomes: $outcomes, + unknown: array_keys($unknown) + ); + $this->writeReport(context: $context, report: $report); + + throw new RuntimeException( + sprintf( + 'The recipient list resolved to %d users, above the bound of %d; nothing was sent. Narrow the recipients, or raise "%s" in app config.', + count($distinct), + $bound, + self::CONFIG_RECIPIENT_BOUND + ) + ); + } + + // RATE LIMIT then SEND, per recipient per item. The limiter's buckets + // are the subsystem's own — a shared budget with declarative sends. + foreach ($perItem as $entry) { + $title = $this->templating->interpolate( + template: (string)($config[$titleKey] ?? ''), + data: $entry['json'], + context: $this->scalarContext(context: $context) + ); + $body = $this->templating->interpolate( + template: (string)($config[$bodyKey] ?? ''), + data: $entry['json'], + context: $this->scalarContext(context: $context) + ); + + $deliveredThisItem = []; + foreach ($entry['uids'] as $uid) { + if (isset($sendable[$uid]) === false) { + continue; + } + + if ($this->rateLimiter->tryConsume(ruleId: $stepName, recipient: $uid) === false) { + $this->addOutcome(outcomes: $outcomes, bucket: 'rateLimited', recipient: $uid); + continue; + } + + $outcome = $this->deliver( + channel: $channel, + uid: $uid, + title: $title, + body: $body, + json: $entry['json'], + stepName: $stepName + ); + + if ($outcome === 'dispatched') { + $this->addOutcome(outcomes: $outcomes, bucket: 'delivered', recipient: $uid); + $deliveredThisItem[] = $uid; + continue; + } + + if ($outcome === 'kill-switch') { + $this->addOutcome(outcomes: $outcomes, bucket: 'skippedByKillSwitch', recipient: $uid); + continue; + } + + $this->addOutcome(outcomes: $outcomes, bucket: 'failed', recipient: $uid); + $failures[] = sprintf('%s to "%s" failed (%s)', $channel, $uid, $outcome); + }//end foreach + + // WEB-PUSH rides along with the nc-notification send under the + // dispatcher's existing rules, with no flow-side configuration: + // the job re-resolves each recipient to their stored + // subscriptions, so a user without one simply gets nothing. + if ($channel === 'nc-notification' && $deliveredThisItem !== []) { + $this->ncSender->enqueueWebPush( + recipients: $deliveredThisItem, + ruleId: $stepName, + originApp: 'openregister', + subject: $title, + message: $body, + actions: [], + object: $this->objectFromItem(json: $entry['json']) + ); + } + }//end foreach + + $report = $this->buildReport( + channel: $channel, + actor: $actor, + recipients: count($distinct), + outcomes: $outcomes, + unknown: array_keys($unknown) + ); + $this->writeReport(context: $context, report: $report); + + if ($failures !== []) { + throw new RuntimeException( + sprintf('%d %s send(s) failed: %s', count($failures), $channel, implode(' | ', array_slice($failures, 0, 3))) + ); + } + + return $report; + }//end sendPerRecipient() + + /** + * Deliver one message to one recipient over one channel, via the + * subsystem's own sender. + * + * @param string $channel The channel. + * @param string $uid The recipient. + * @param string $title The rendered title/subject. + * @param string $body The rendered body. + * @param array $json The item's json, for the notification's object reference. + * @param string $stepName The step's type id. + * + * @return string The sender's outcome. + */ + private function deliver(string $channel, string $uid, string $title, string $body, array $json, string $stepName): string { + if ($channel === 'email') { + return $this->emailSender->send(uid: $uid, subject: $title, body: $body); + } + + return $this->ncSender->send( + uid: $uid, + object: $this->objectFromItem(json: $json), + subjectKey: 'flow_message', + name: $stepName, + subject: $title, + message: $body, + context: [], + originApp: 'openregister', + actions: [], + // Web-push rides along (enqueued after this item's sends), so the + // foreground popup is suppressed for the tag exactly as the + // declarative dispatcher suppresses it. + webPushActive: true + ); + }//end deliver() + + /** + * The acting user the run executes as — never a system identity. + * + * A run without a resolvable acting user FAILS the step rather than + * sending anonymously: the fallback would be an anonymous messenger + * created by an edge case. The failure names the missing actor. + * + * @param array $context The run context. + * + * @return string The acting user's uid. + * + * @throws RuntimeException When no enabled acting user resolves. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + private function resolveActingUser(array $context): string { + $uid = ($context['runAs'] ?? null); + if (is_string($uid) === false || trim($uid) === '') { + throw new RuntimeException( + 'This flow run has no acting identity (runAs); a message must have a sender, so nothing was sent.' + ); + } + + $uid = trim($uid); + $user = $this->userManager->get($uid); + if ($user === null) { + throw new RuntimeException( + sprintf('This flow run\'s acting identity "%s" (runAs) is not a user account; nothing was sent.', $uid) + ); + } + + if ($user->isEnabled() === false) { + throw new RuntimeException( + sprintf('This flow run\'s acting identity "%s" (runAs) is a disabled account; nothing was sent on their behalf.', $uid) + ); + } + + return $uid; + }//end resolveActingUser() + + /** + * Resolve a node's recipients config against one item. + * + * Entries are literal user or group ids, or a template resolving a field + * on the item (`{{ assignee }}` / `{{ item.assignee }}`). Resolution goes + * through the subsystem's recipient resolver — groups expanded, every uid + * verified — and unknown ids are returned for the run log rather than + * silently dropped. + * + * @param mixed $recipients The config value. + * @param array $json The item's json. + * + * @return array{uids: array, unknown: array} + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) Three entry shapes (template, user, group) + * each with its own verification and unknown-reporting branch. + */ + private function resolveRecipients(mixed $recipients, array $json): array { + $uids = []; + $unknown = []; + + if (is_string($recipients) === true) { + $recipients = [$recipients]; + } + + foreach ((array)$recipients as $entry) { + if (is_string($entry) === false || trim($entry) === '') { + continue; + } + + $entry = trim($entry); + + $matches = []; + if (preg_match('/^\{\{\s*(?:item\.)?([a-zA-Z0-9_.-]+)\s*\}\}$/', $entry, $matches) === 1) { + $field = $matches[1]; + $resolved = $this->recipientResolver->resolve( + recipientsSpec: [ + [ + 'kind' => 'relation', + 'relation' => $field, + ], + ], + data: $json, + object: null, + context: [] + ); + $candidates = $this->recipientResolver->extractUidsFromRelation(value: ($json[$field] ?? null)); + foreach (array_diff($candidates, $resolved) as $bad) { + $unknown[] = $bad; + } + + foreach ($resolved as $uid) { + $uids[] = $uid; + } + + continue; + }//end if + + if ($this->recipientResolver->userExists(uid: $entry) === true) { + $uids[] = $entry; + continue; + } + + if ($this->recipientResolver->groupExists(gid: $entry) === true) { + $members = $this->recipientResolver->resolve( + recipientsSpec: [ + [ + 'kind' => 'groups', + 'groups' => [$entry], + ], + ], + data: [], + object: null, + context: [] + ); + foreach ($members as $uid) { + $uids[] = $uid; + } + + continue; + } + + $unknown[] = $entry; + }//end foreach + + return [ + 'uids' => array_values(array_unique($uids)), + 'unknown' => array_values(array_unique($unknown)), + ]; + }//end resolveRecipients() + + /** + * Whether the recipient's own preference allows this channel. + * + * Resolved through the subsystem's preference service under the flow + * scope, so an override stored there restricts flow sends the same way a + * schema-scoped override restricts declarative ones. + * + * @param string $uid The recipient. + * @param string $channel The channel. + * + * @return bool True when the send may proceed. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + private function preferenceAllows(string $uid, string $channel): bool { + $effective = $this->preferences->resolveEffective( + schemaDefault: [ + 'enabled' => true, + 'channels' => [$channel], + ], + userId: $uid, + schemaSlug: self::PREFERENCE_SLUG, + notificationKey: self::PREFERENCE_KEY + ); + + if ($effective['enabled'] === false) { + return false; + } + + if ($effective['channels'] !== null && in_array($channel, $effective['channels'], true) === false) { + return false; + } + + return true; + }//end preferenceAllows() + + /** + * The per-step recipient bound: app-config raisable, never below one. + * + * @return int The bound. + */ + private function recipientBound(): int { + try { + $configured = (int)$this->appConfig->getValueInt( + NotificationChannelPolicy::APP_ID, + self::CONFIG_RECIPIENT_BOUND, + self::DEFAULT_RECIPIENT_BOUND + ); + } catch (\Throwable $e) { + return self::DEFAULT_RECIPIENT_BOUND; + } + + return max(1, $configured); + }//end recipientBound() + + /** + * A lightweight object reference for an item, for the notification's + * object link. An item read from a register carries its uuid; an item + * built mid-flow may not, and then the notification simply carries no + * object deeplink. + * + * @param array $json The item's json. + * + * @return ObjectEntity The reference. + */ + private function objectFromItem(array $json): ObjectEntity { + $object = new ObjectEntity(); + $uuid = ($json['uuid'] ?? ($json['id'] ?? null)); + if (is_string($uuid) === true && $uuid !== '') { + $object->setUuid($uuid); + } + + $name = ($json['name'] ?? ($json['title'] ?? null)); + if (is_string($name) === true && $name !== '') { + $object->setName($name); + } + + return $object; + }//end objectFromItem() + + /** + * The context's scalar values, for template interpolation. The node + * context carries handles (the guard, the report); the dialect evaluator + * only ever renders scalars, and handing it the full context would put + * objects where it expects values. + * + * @param array $context The run context. + * + * @return array The scalar entries only. + */ + private function scalarContext(array $context): array { + return array_filter($context, static fn (mixed $value): bool => is_scalar($value) === true); + }//end scalarContext() + + /** + * The empty outcome buckets. + * + * @return array> + */ + private function emptyOutcomes(): array { + return [ + 'delivered' => [], + 'skippedByPreference' => [], + 'skippedByKillSwitch' => [], + 'rateLimited' => [], + 'failed' => [], + ]; + }//end emptyOutcomes() + + /** + * Record one outcome. + * + * @param array> $outcomes The buckets, by reference. + * @param string $bucket The outcome bucket. + * @param string $recipient The recipient (or conversation token). + * + * @return void + */ + private function addOutcome(array &$outcomes, string $bucket, string $recipient): void { + $outcomes[$bucket][] = $recipient; + }//end addOutcome() + + /** + * The bounded outcome report for the run log. + * + * Bounded by the log's sampling rule: each bucket carries its true count + * and at most REPORT_SAMPLE entries — a mail archive is not what a run + * log is for, and neither is a recipient directory. + * + * @param string $channel The channel. + * @param string $actor The acting user. + * @param int $recipients The resolved distinct recipient count. + * @param array> $outcomes The outcome buckets. + * @param array $unknown Unresolvable recipient entries. + * + * @return array The report. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + private function buildReport(string $channel, string $actor, int $recipients, array $outcomes, array $unknown): array { + $report = [ + 'channel' => $channel, + 'actor' => $actor, + 'recipients' => $recipients, + ]; + + $truncated = false; + foreach ($outcomes as $bucket => $entries) { + $report[$bucket] = [ + 'count' => count($entries), + 'sample' => array_slice(array_values($entries), 0, self::REPORT_SAMPLE), + ]; + if (count($entries) > self::REPORT_SAMPLE) { + $truncated = true; + } + } + + if ($unknown !== []) { + $report['unknownRecipients'] = [ + 'count' => count($unknown), + 'sample' => array_slice(array_values($unknown), 0, self::REPORT_SAMPLE), + ]; + if (count($unknown) > self::REPORT_SAMPLE) { + $truncated = true; + } + } + + $report['truncated'] = $truncated; + + return $report; + }//end buildReport() + + /** + * Write the report onto the run log via the context's report handle. + * + * @param array $context The run context. + * @param array $report The report. + * + * @return void + */ + private function writeReport(array $context, array $report): void { + $handle = ($context[FlowStepReport::CONTEXT_KEY] ?? null); + if (($handle instanceof FlowStepReport) === false) { + return; + } + + $handle->put(key: 'messaging', value: $report); + }//end writeReport() + + /** + * Resolve a config value that is a literal or a single-field template. + * + * @param string $value The config value. + * @param array $json The item's json. + * @param array $context The run context. + * + * @return string The resolved value. + */ + private function resolveScalarConfig(string $value, array $json, array $context): string { + $value = trim($value); + if (str_contains($value, '{{') === false) { + return $value; + } + + return trim($this->templating->interpolate(template: $value, data: $json, context: $this->scalarContext(context: $context))); + }//end resolveScalarConfig() +}//end class diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index 78e6ac01f8..d18820d5e4 100644 --- a/lib/Service/Flow/FlowRunService.php +++ b/lib/Service/Flow/FlowRunService.php @@ -282,6 +282,7 @@ private function nodeContextFor(FlowRun $run, bool $resuming, FlowRunGuard $guar $context[FlowToken::CONTEXT_KEY] = FlowToken::fromArray(($context[FlowToken::CONTEXT_KEY] ?? null)); $context[FlowResumeState::CONTEXT_KEY] = FlowResumeState::fromArray(($context[FlowResumeState::CONTEXT_KEY] ?? null)); $context[FlowRunGuard::CONTEXT_KEY] = $guard; + $context[FlowStepReport::CONTEXT_KEY] = new FlowStepReport(); // ATTRIBUTION. Read BEFORE the walk: the audit rows are written during // it, so the base has to be predicted. See {@see FlowStepHistory}. diff --git a/lib/Service/Flow/FlowStepReport.php b/lib/Service/Flow/FlowStepReport.php new file mode 100644 index 0000000000..1f7f7d337c --- /dev/null +++ b/lib/Service/Flow/FlowStepReport.php @@ -0,0 +1,85 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-records-what-each-node-received-returned-and-logged + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +/** + * Collects a node's own log detail for the current hop. + */ +class FlowStepReport { + + /** + * The context key this handle travels under. + */ + public const CONTEXT_KEY = '_stepReport'; + + /** + * The detail written by the current hop's node. + * + * @var array + */ + private array $detail = []; + + /** + * Record one piece of detail for the current step's log entry. + * + * A node MUST keep its report bounded itself — the run log is kept for + * months, and the engine does not re-sample what a node chose to write. + * + * @param string $key The detail's name within the entry's `report` map. + * @param mixed $value The detail; must be JSON-serialisable. + * + * @return void + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-records-what-each-node-received-returned-and-logged + */ + public function put(string $key, mixed $value): void { + $this->detail[$key] = $value; + }//end put() + + /** + * Return and clear the current hop's detail. + * + * Clearing on read is what scopes the report to one hop: the engine takes + * it while writing the step's log entry, and the next node starts blank. + * + * @return array The detail written since the last take. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-records-what-each-node-received-returned-and-logged + */ + public function take(): array { + $detail = $this->detail; + $this->detail = []; + + return $detail; + }//end take() +}//end class diff --git a/lib/Service/Flow/Nodes/SendEmailNode.php b/lib/Service/Flow/Nodes/SendEmailNode.php new file mode 100644 index 0000000000..f3379092ab --- /dev/null +++ b/lib/Service/Flow/Nodes/SendEmailNode.php @@ -0,0 +1,214 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use OCA\OpenRegister\Service\Flow\FlowMessagingService; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\WorkflowEngine\IManager; +use UnexpectedValueException; + +/** + * The "Send an email" step. + */ +class SendEmailNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm { + + /** + * The step type this node answers to. + */ + public const TYPE = 'openregister.send-email'; + + /** + * Constructor. + * + * @param FlowMessagingService $messaging The bridge onto the notification subsystem. + * @param IL10N $l10n Translations. + * @param IURLGenerator $urls For the palette icon. + */ + public function __construct( + private readonly FlowMessagingService $messaging, + private readonly IL10N $l10n, + private readonly IURLGenerator $urls, + ) { + + }//end __construct() + + /** + * The step type. + * + * @return string The id. + */ + public function getId(): string { + return self::TYPE; + }//end getId() + + /** + * Palette name. + * + * @return string The display name. + */ + public function getDisplayName(): string { + return $this->l10n->t('Send an email'); + }//end getDisplayName() + + /** + * Palette description. + * + * @return string The description. + */ + public function getDescription(): string { + return $this->l10n->t('Send an email to people, at this point in the flow.'); + }//end getDescription() + + /** + * Palette icon. + * + * @return string The icon URL. + */ + public function getIcon(): string { + return $this->urls->imagePath('core', 'actions/mail.svg'); + }//end getIcon() + + /** + * Messaging people is not privileged beyond the run's own identity, so + * both scopes get it; every guardrail applies either way. + * + * @param int $scope The scope constant. + * + * @return boolean Whether it is available. + */ + public function isAvailableForScope(int $scope): bool { + return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); + }//end isAvailableForScope() + + /** + * The config vocabulary of a send-email step. + * + * @return array The accepted config keys. + * + * @spec openspec/changes/or-flow-preflight/specs/flow-preflight/spec.md + */ + public function configKeys(): array { + return ['recipients', 'subject', 'body']; + }//end configKeys() + + /** + * Reject a mail with nothing to say or nobody to send it to. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When the body or the recipients are empty. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function validateConfig(array $config): void { + if (trim((string)($config['body'] ?? '')) === '') { + throw new UnexpectedValueException($this->l10n->t('An email needs a body.')); + } + + $recipients = ($config['recipients'] ?? []); + if (is_string($recipients) === true) { + $recipients = [$recipients]; + } + + $recipients = array_filter( + (array)$recipients, + static fn (mixed $entry): bool => is_string($entry) === true && trim($entry) !== '' + ); + if ($recipients === []) { + throw new UnexpectedValueException($this->l10n->t('An email needs at least one recipient.')); + } + }//end validateConfig() + + /** + * The fields this node's configuration is edited through. + * + * @return array> The field descriptions. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-node-type-declares-its-own-form-and-its-own-run-log-actions + */ + public function configForm(): array { + return [ + [ + 'key' => 'recipients', + 'label' => $this->l10n->t('Who to mail'), + 'type' => 'text', + 'help' => $this->l10n->t('User or group ids, or a field on the item such as {{ assignee }}. Groups are expanded.'), + 'required' => true, + ], + [ + 'key' => 'subject', + 'label' => $this->l10n->t('Subject'), + 'type' => 'text', + 'help' => $this->l10n->t('Placeholders such as {{ name }} read fields from the item, the same syntax a schema notification uses.'), + ], + [ + 'key' => 'body', + 'label' => $this->l10n->t('Body'), + 'type' => 'textarea', + 'help' => $this->l10n->t('What the email says. Placeholders read fields from the item.'), + 'required' => true, + ], + ]; + }//end configForm() + + /** + * Send, then pass the items through unchanged. + * + * Sending is a side effect, not a transformation. Failures throw and are + * routed through the step's `onError` policy; every non-delivery lands in + * the run log with its reason. + * + * @param array $items The input items. + * @param array $config The step configuration. + * @param array $context Run-level metadata. + * + * @return array The items, unchanged. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function execute(array $items, array $config, array $context): array { + $this->messaging->sendEmail( + config: $config, + items: $items, + context: $context, + stepName: self::TYPE + ); + + return $items; + }//end execute() +}//end class diff --git a/lib/Service/Flow/Nodes/SendNotificationNode.php b/lib/Service/Flow/Nodes/SendNotificationNode.php new file mode 100644 index 0000000000..a8bc7632a9 --- /dev/null +++ b/lib/Service/Flow/Nodes/SendNotificationNode.php @@ -0,0 +1,217 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use OCA\OpenRegister\Service\Flow\FlowMessagingService; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\WorkflowEngine\IManager; +use UnexpectedValueException; + +/** + * The "Send a notification" step. + */ +class SendNotificationNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm { + + /** + * The step type this node answers to. + */ + public const TYPE = 'openregister.send-notification'; + + /** + * Constructor. + * + * @param FlowMessagingService $messaging The bridge onto the notification subsystem. + * @param IL10N $l10n Translations. + * @param IURLGenerator $urls For the palette icon. + */ + public function __construct( + private readonly FlowMessagingService $messaging, + private readonly IL10N $l10n, + private readonly IURLGenerator $urls, + ) { + + }//end __construct() + + /** + * The step type. + * + * @return string The id. + */ + public function getId(): string { + return self::TYPE; + }//end getId() + + /** + * Palette name. + * + * @return string The display name. + */ + public function getDisplayName(): string { + return $this->l10n->t('Send a notification'); + }//end getDisplayName() + + /** + * Palette description. + * + * @return string The description. + */ + public function getDescription(): string { + return $this->l10n->t('Send an in-app notification to people, at this point in the flow.'); + }//end getDescription() + + /** + * Palette icon. + * + * @return string The icon URL. + */ + public function getIcon(): string { + return $this->urls->imagePath('core', 'actions/sound.svg'); + }//end getIcon() + + /** + * Messaging people is not privileged beyond the run's own identity, so + * both scopes get it; every guardrail applies either way. + * + * @param int $scope The scope constant. + * + * @return boolean Whether it is available. + */ + public function isAvailableForScope(int $scope): bool { + return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); + }//end isAvailableForScope() + + /** + * The config vocabulary of a send-notification step. + * + * @return array The accepted config keys. + * + * @spec openspec/changes/or-flow-preflight/specs/flow-preflight/spec.md + */ + public function configKeys(): array { + return ['recipients', 'title', 'message']; + }//end configKeys() + + /** + * Reject a send with nothing to say or nobody to say it to. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When the message or the recipients are empty. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function validateConfig(array $config): void { + if (trim((string)($config['message'] ?? '')) === '') { + throw new UnexpectedValueException($this->l10n->t('A notification needs a message.')); + } + + $recipients = ($config['recipients'] ?? []); + if (is_string($recipients) === true) { + $recipients = [$recipients]; + } + + $recipients = array_filter( + (array)$recipients, + static fn (mixed $entry): bool => is_string($entry) === true && trim($entry) !== '' + ); + if ($recipients === []) { + throw new UnexpectedValueException($this->l10n->t('A notification needs at least one recipient.')); + } + }//end validateConfig() + + /** + * The fields this node's configuration is edited through. + * + * @return array> The field descriptions. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-node-type-declares-its-own-form-and-its-own-run-log-actions + */ + public function configForm(): array { + return [ + [ + 'key' => 'recipients', + 'label' => $this->l10n->t('Who to tell'), + 'type' => 'text', + 'help' => $this->l10n->t('User or group ids, or a field on the item such as {{ assignee }}. Groups are expanded.'), + 'required' => true, + ], + [ + 'key' => 'title', + 'label' => $this->l10n->t('Title'), + 'type' => 'text', + 'help' => $this->l10n->t('Placeholders such as {{ name }} read fields from the item, the same syntax a schema notification uses.'), + ], + [ + 'key' => 'message', + 'label' => $this->l10n->t('Message'), + 'type' => 'textarea', + 'help' => $this->l10n->t('What the notification says. Placeholders read fields from the item.'), + 'required' => true, + ], + ]; + }//end configForm() + + /** + * Send, then pass the items through unchanged. + * + * Sending is a side effect, not a transformation. Failures throw and are + * routed through the step's `onError` policy; every non-delivery lands in + * the run log with its reason. + * + * @param array $items The input items. + * @param array $config The step configuration. + * @param array $context Run-level metadata. + * + * @return array The items, unchanged. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function execute(array $items, array $config, array $context): array { + $this->messaging->sendNotification( + config: $config, + items: $items, + context: $context, + stepName: self::TYPE + ); + + return $items; + }//end execute() +}//end class diff --git a/lib/Service/Flow/Nodes/SendTalkMessageNode.php b/lib/Service/Flow/Nodes/SendTalkMessageNode.php new file mode 100644 index 0000000000..27cb3dad4d --- /dev/null +++ b/lib/Service/Flow/Nodes/SendTalkMessageNode.php @@ -0,0 +1,201 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use OCA\OpenRegister\Service\Flow\FlowMessagingService; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\WorkflowEngine\IManager; +use UnexpectedValueException; + +/** + * The "Send a Talk message" step. + */ +class SendTalkMessageNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm { + + /** + * The step type this node answers to. + */ + public const TYPE = 'openregister.send-talk-message'; + + /** + * Constructor. + * + * @param FlowMessagingService $messaging The bridge onto the notification subsystem. + * @param IL10N $l10n Translations. + * @param IURLGenerator $urls For the palette icon. + */ + public function __construct( + private readonly FlowMessagingService $messaging, + private readonly IL10N $l10n, + private readonly IURLGenerator $urls, + ) { + + }//end __construct() + + /** + * The step type. + * + * @return string The id. + */ + public function getId(): string { + return self::TYPE; + }//end getId() + + /** + * Palette name. + * + * @return string The display name. + */ + public function getDisplayName(): string { + return $this->l10n->t('Send a Talk message'); + }//end getDisplayName() + + /** + * Palette description. + * + * @return string The description. + */ + public function getDescription(): string { + return $this->l10n->t('Post a chat message to a Talk conversation, as the user this flow runs as.'); + }//end getDescription() + + /** + * Palette icon. + * + * @return string The icon URL. + */ + public function getIcon(): string { + return $this->urls->imagePath('core', 'actions/comment.svg'); + }//end getIcon() + + /** + * Posting requires the acting user to be a participant either way, so + * both scopes get it; every guardrail applies regardless. + * + * @param int $scope The scope constant. + * + * @return boolean Whether it is available. + */ + public function isAvailableForScope(int $scope): bool { + return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); + }//end isAvailableForScope() + + /** + * The config vocabulary of a send-talk-message step. + * + * @return array The accepted config keys. + * + * @spec openspec/changes/or-flow-preflight/specs/flow-preflight/spec.md + */ + public function configKeys(): array { + return ['conversation', 'message']; + }//end configKeys() + + /** + * Reject a post with nothing to say or no conversation to say it in. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When the message or the conversation is empty. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function validateConfig(array $config): void { + if (trim((string)($config['message'] ?? '')) === '') { + throw new UnexpectedValueException($this->l10n->t('A Talk message needs a message.')); + } + + if (trim((string)($config['conversation'] ?? '')) === '') { + throw new UnexpectedValueException($this->l10n->t('A Talk message needs a conversation token, or a field on the item that holds one.')); + } + }//end validateConfig() + + /** + * The fields this node's configuration is edited through. + * + * @return array> The field descriptions. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-node-type-declares-its-own-form-and-its-own-run-log-actions + */ + public function configForm(): array { + return [ + [ + 'key' => 'conversation', + 'label' => $this->l10n->t('Conversation'), + 'type' => 'text', + 'help' => $this->l10n->t('A conversation token, or a field on the item such as {{ conversationToken }}. The acting user must be a participant.'), + 'required' => true, + ], + [ + 'key' => 'message', + 'label' => $this->l10n->t('Message'), + 'type' => 'textarea', + 'help' => $this->l10n->t('What the message says. Placeholders such as {{ name }} read fields from the item.'), + 'required' => true, + ], + ]; + }//end configForm() + + /** + * Post, then pass the items through unchanged. + * + * Sending is a side effect, not a transformation. Failures — Talk absent, + * an unknown conversation, the acting user not a participant, a refused + * post — throw and are routed through the step's `onError` policy. + * + * @param array $items The input items. + * @param array $config The step configuration. + * @param array $context Run-level metadata. + * + * @return array The items, unchanged. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + public function execute(array $items, array $config, array $context): array { + $this->messaging->sendTalkMessage( + config: $config, + items: $items, + context: $context, + stepName: self::TYPE + ); + + return $items; + }//end execute() +}//end class diff --git a/lib/Service/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php index eccd79124e..791a874810 100644 --- a/lib/Service/Notification/AnnotationNotificationDispatcher.php +++ b/lib/Service/Notification/AnnotationNotificationDispatcher.php @@ -30,7 +30,6 @@ use DateTime; use DateTimeImmutable; use DateTimeInterface; -use OCA\OpenRegister\BackgroundJob\WebPushDispatchJob; use OCA\OpenRegister\Db\DuplicateDispatchException; use OCA\OpenRegister\Db\NotificationDispatchLogMapper; use OCA\OpenRegister\Db\NotificationHistoryMapper; @@ -78,13 +77,43 @@ class AnnotationNotificationDispatcher { /** - * Per-instance cache of resolved relation display names, keyed by UUID. - * Avoids repeat ObjectService lookups when the same relation is - * interpolated across a recipient fan-out. + * The nc-notification channel unit — injected, or lazily built. + * Nullable-and-lazy keeps every existing constructor call site working: + * the shared units are appended OPTIONAL constructor parameters, and a + * dispatcher constructed without them builds each from its own + * dependencies on first use — the same objects, wired the same way. * - * @var array + * @var NcNotificationSender|null */ - private array $relationDisplayCache = []; + private ?NcNotificationSender $lazyNcSender = null; + + /** + * The email channel unit — injected, or lazily built. + * + * @var EmailSender|null + */ + private ?EmailSender $lazyEmailSender = null; + + /** + * The Talk channel unit — injected, or lazily built. + * + * @var TalkSender|null + */ + private ?TalkSender $lazyTalkSender = null; + + /** + * The shared recipient resolver — injected, or lazily built. + * + * @var NotificationRecipientResolver|null + */ + private ?NotificationRecipientResolver $lazyRecipientResolver = null; + + /** + * The dialect's placeholder evaluator — injected, or lazily built. + * + * @var NotificationTemplating|null + */ + private ?NotificationTemplating $lazyTemplating = null; /** * Constructor. @@ -115,6 +144,11 @@ class AnnotationNotificationDispatcher { * @param QueuedNotificationMapper|null $queuedNotificationMapper Durable queue mapper (quiet-hours + digest schedule). * @param DigestScheduleEvaluator|null $digestScheduleEvaluator Live evaluator for the `digest` fixed-time schedule. * @param ITimeFactory|null $timeFactory Time source for the delivery/digest gate. + * @param NcNotificationSender|null $ncSender Shared nc-notification channel unit (lazily built when absent). + * @param EmailSender|null $emailSender Shared email channel unit (lazily built when absent). + * @param TalkSender|null $talkSender Shared Talk channel unit (lazily built when absent). + * @param NotificationRecipientResolver|null $recipientResolver Shared recipient resolver (lazily built when absent). + * @param NotificationTemplating|null $templating Shared placeholder evaluator (lazily built when absent). * * @SuppressWarnings(PHPMD.ExcessiveParameterList) DI-injected dependencies. */ @@ -145,9 +179,131 @@ public function __construct( private readonly ?QueuedNotificationMapper $queuedNotificationMapper = null, private readonly ?DigestScheduleEvaluator $digestScheduleEvaluator = null, private readonly ?ITimeFactory $timeFactory = null, + ?NcNotificationSender $ncSender = null, + ?EmailSender $emailSender = null, + ?TalkSender $talkSender = null, + ?NotificationRecipientResolver $recipientResolver = null, + ?NotificationTemplating $templating = null, ) { + $this->lazyNcSender = $ncSender; + $this->lazyEmailSender = $emailSender; + $this->lazyTalkSender = $talkSender; + $this->lazyRecipientResolver = $recipientResolver; + $this->lazyTemplating = $templating; }//end __construct() + /** + * The nc-notification channel sender — injected, or built from this + * dispatcher's own dependencies. One implementation either way; the flow + * messaging service invokes the same class. + * + * @return NcNotificationSender The sender. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + private function ncSender(): NcNotificationSender { + $this->lazyNcSender ??= new NcNotificationSender( + notificationManager: $this->notificationManager, + logger: $this->logger, + userManager: $this->userManager, + jobList: $this->jobList + ); + + return $this->lazyNcSender; + }//end ncSender() + + /** + * The email channel sender — injected, or built from this dispatcher's + * own dependencies. + * + * @return EmailSender The sender. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + private function emailSender(): EmailSender { + $this->lazyEmailSender ??= new EmailSender( + userManager: $this->userManager, + mailer: $this->mailer, + logger: $this->logger + ); + + return $this->lazyEmailSender; + }//end emailSender() + + /** + * The Talk channel sender — injected, or built from this dispatcher's + * own dependencies. + * + * @return TalkSender The sender. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + private function talkSender(): TalkSender { + $this->lazyTalkSender ??= new TalkSender( + httpClient: $this->httpClient, + logger: $this->logger, + config: ($this->config ?? $this->systemConfig()) + ); + + return $this->lazyTalkSender; + }//end talkSender() + + /** + * The recipient resolver — injected, or built from this dispatcher's own + * dependencies. + * + * @return NotificationRecipientResolver The resolver. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + private function recipientResolver(): NotificationRecipientResolver { + $this->lazyRecipientResolver ??= new NotificationRecipientResolver( + userManager: $this->userManager, + groupManager: $this->groupManager, + logger: $this->logger, + serverContainer: $this->serverContainer + ); + + return $this->lazyRecipientResolver; + }//end recipientResolver() + + /** + * The dialect's placeholder evaluator — injected, or built from this + * dispatcher's own dependencies. + * + * @return NotificationTemplating The evaluator. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + private function templating(): NotificationTemplating { + $this->lazyTemplating ??= new NotificationTemplating( + logger: $this->logger, + objectService: $this->objectService + ); + + return $this->lazyTemplating; + }//end templating() + + /** + * The system config, resolved through the server container for callers + * that constructed the dispatcher with the legacy (no-IConfig) signature. + * + * @return IConfig|null The config service, or null when unresolvable. + */ + private function systemConfig(): ?IConfig { + try { + $config = $this->serverContainer->get(IConfig::class); + } catch (\Throwable $e) { + return null; + } + + if (($config instanceof IConfig) === false) { + return null; + } + + return $config; + }//end systemConfig() + /** * Fire any notifications declared on the schema whose trigger matches. * @@ -1381,43 +1537,10 @@ private function emitTalk(array $spec, string $message): void { return; } - try { - $client = $this->httpClient->newClient(); - // Resolve the local OC URL — Talk's chat endpoint is internal - // to the NC instance, so we route via the configured overwrite - // host or fall back to the loopback. The injected IConfig - // dependency is preferred; the server container fallback - // exists for callers that constructed the dispatcher with - // the legacy (no-IConfig) signature. - $base = (string)$this->serverContainer->get(\OCP\IConfig::class)->getSystemValue('overwrite.cli.url', 'http://localhost'); - if ($this->config !== null) { - $base = (string)$this->config->getSystemValue('overwrite.cli.url', 'http://localhost'); - } - - $base = rtrim($base, '/'); - $url = $base . '/ocs/v2.php/apps/spreed/api/v1/chat/' . rawurlencode($token); - - $client->post( - $url, - [ - 'headers' => [ - 'OCS-APIRequest' => 'true', - 'Accept' => 'application/json', - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - 'body' => [ - 'message' => $message, - 'actorType' => 'bots', - 'actorId' => 'openregister', - ], - 'timeout' => 5, - ] - ); - } catch (\Throwable $e) { - $this->logger->warning( - sprintf('[AnnotationNotificationDispatcher] talk to "%s" failed: %s', $token, $e->getMessage()) - ); - }//end try + // The shared Talk send unit — the same class the flow messaging + // service invokes. The declarative path stays a best-effort bot + // broadcast, so the outcome is not escalated here. + $this->talkSender()->postAsBot(token: $token, message: $message); }//end emitTalk() /** @@ -1828,323 +1951,30 @@ private function fieldChangeConditionMatches(array $condition, array $oldData, a * recipient-resolution contract. */ private function resolveRecipients(array $recipientsSpec, array $data, ?ObjectEntity $object = null, array $context = []): array { - $uids = []; - foreach ($recipientsSpec as $r) { - if (is_array($r) === false) { - continue; - } - - $kind = (string)($r['kind'] ?? ''); - if ($kind === 'users') { - foreach ((array)($r['users'] ?? []) as $u) { - if (is_string($u) === true && $u !== '' && $this->userExists(uid: $u) === true) { - $uids[] = $u; - } - } - - continue; - } - - if ($kind === 'field') { - // The field's value comes from the object's stored data, - // which is writeable by anyone with `update` permission - // on the object. An attacker who controls the field - // could otherwise direct notifications at any uid string, - // including admins, with an attacker-shaped subject. - // Verify the value names a real Nextcloud user before - // adding it to the recipient list. - $field = (string)($r['field'] ?? ''); - $value = ($data[$field] ?? null); - if (is_string($value) === true && $value !== '' && $this->userExists(uid: $value) === true) { - $uids[] = $value; - } - - continue; - } - - if ($kind === 'relation') { - // Resolve a typed relation (declared via x-openregister-relations). - // Reads $data[] which by convention holds - // either a string UID, an array of string UIDs, or an - // array of objects each carrying a userId field. Same - // attacker-controlled-input reasoning as the `field` - // kind above — every extracted uid is checked against - // IUserManager::userExists(). - $relName = (string)($r['relation'] ?? ''); - if ($relName === '') { - continue; - } - - $value = ($data[$relName] ?? null); - foreach ($this->extractUidsFromRelation(value: $value) as $uid) { - if ($this->userExists(uid: $uid) === true) { - $uids[] = $uid; - } - } - - continue; - }//end if - - if ($kind === 'object-acl') { - if ($object !== null) { - $perm = (string)($r['permission'] ?? 'read'); - foreach ($this->resolveObjectAclRecipients(object: $object, permission: $perm) as $uid) { - $uids[] = $uid; - } - } - - continue; - } - - if ($kind === 'expression') { - if ($object !== null) { - $resolverTag = (string)($r['resolver'] ?? ''); - $resolved = $this->resolveExpressionRecipients( - resolverTag: $resolverTag, - object: $object, - context: $context - ); - foreach ($resolved as $uid) { - $uids[] = $uid; - } - } - - continue; - } - - if ($kind === 'groups') { - foreach ((array)($r['groups'] ?? []) as $gid) { - if (is_string($gid) === false || $gid === '') { - continue; - } - - try { - $group = $this->groupManager->get($gid); - if ($group === null) { - continue; - } - - foreach ($group->getUsers() as $user) { - $uids[] = $user->getUID(); - } - } catch (\Throwable $e) { - $this->logger->warning( - sprintf('[AnnotationNotificationDispatcher] group "%s" lookup failed: %s', $gid, $e->getMessage()) - ); - } - } - }//end if - }//end foreach - - return array_values(array_unique($uids)); + // The subsystem's ONE recipient resolver — the same class the flow + // messaging service expands its node recipients through. + return $this->recipientResolver()->resolve( + recipientsSpec: $recipientsSpec, + data: $data, + object: $object, + context: $context + ); }//end resolveRecipients() - /** - * Resolve recipients from the object's per-object ACL. - * - * Reads `$object->getAuthorization()` (Schema entity carries the - * permission map per object). Returns every uid (and group-member - * uids) holding the requested permission level. - * - * v1 implementation: best-effort. Reads the object's `groups` and - * `owner` fields directly. Per-object ACL granularity (read vs - * manage) is treated as: `read` matches any user/group in the ACL; - * `manage` matches only the object owner. A future iteration can - * walk the full RBAC `OrObjectAclMapper` once that surface is - * stable. - * - * @param ObjectEntity $object The object whose ACL should be read. - * @param string $permission The required permission (`read` or `manage`). - * - * @return array - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) resolveObjectAclRecipients() walks owner, - * per-user, per-role, and per-group ACL entries; each entry type requires separate null-guards - * and uid-extraction logic that cannot be merged without losing the distinction between - * role-based and explicit-user grants. - */ - private function resolveObjectAclRecipients(ObjectEntity $object, string $permission): array { - $uids = []; - $owner = $object->getOwner(); - if (is_string($owner) === true && $owner !== '') { - $uids[] = $owner; - } - - if ($permission === 'manage') { - return $uids; - } - - // Read permission: also include groups via getGroups(). The - // Entity base uses __call magic for accessors, so method_exists() - // is unreliable — fall through and let the magic call surface - // the value (or throw, which is caught below). - try { - $groupsRaw = $object->getGroups(); - if (is_array($groupsRaw) === true) { - foreach ($groupsRaw as $gid) { - if (is_string($gid) === false || $gid === '') { - continue; - } - - $group = $this->groupManager->get($gid); - if ($group === null) { - continue; - } - - foreach ($group->getUsers() as $user) { - $uids[] = $user->getUID(); - } - } - } - } catch (\Throwable $e) { - $this->logger->warning( - sprintf('[AnnotationNotificationDispatcher] object-acl read resolution failed: %s', $e->getMessage()) - ); - }//end try - - return $uids; - }//end resolveObjectAclRecipients() - - /** - * Resolve recipients via a DI-tagged RecipientResolverInterface. - * - * Looks up the resolver via the injected IServerContainer so apps - * can register their resolver class by FQCN and have NC autowire - * its dependencies. Skips silently when the resolver doesn't exist - * or doesn't implement the interface. - * - * The previous implementation reached for the `\OC::$server` static - * accessor; this PR's ADR (`docs/development-notes/AUDIT_2026-05-01.md`) - * bans that pattern in `lib/`. The injected container is functionally - * equivalent without coupling to the static accessor. - * - * @param string $resolverTag DI tag (or FQCN) of the resolver service. - * @param ObjectEntity $object The object whose recipients are being resolved. - * @param array $context Per-event context passed through to the resolver. - * - * @return array - */ - private function resolveExpressionRecipients(string $resolverTag, ObjectEntity $object, array $context): array { - if ($resolverTag === '') { - return []; - } - - try { - $resolver = $this->serverContainer->get($resolverTag); - if (($resolver instanceof RecipientResolverInterface) === false) { - $this->logger->warning( - sprintf('[AnnotationNotificationDispatcher] expression resolver "%s" does not implement RecipientResolverInterface', $resolverTag) - ); - return []; - } - - return array_values($resolver->resolve($object, $context)); - } catch (\Throwable $e) { - $this->logger->warning( - sprintf('[AnnotationNotificationDispatcher] expression resolver "%s" failed: %s', $resolverTag, $e->getMessage()) - ); - return []; - } - }//end resolveExpressionRecipients() - /** * Verify that a uid corresponds to an actual Nextcloud user. * - * Notification recipient lists pull strings from object data - * (`field` / `relation` kinds) and from schema annotations - * (`users` kind). Without this check, an attacker who can write - * objects in a schema using `field` recipients could direct a - * notification (with an attacker-shaped subject) at any uid string - * — including admins. Backed by a per-request cache to keep the - * cost flat across N recipients in a single dispatch. + * Delegates to the shared recipient resolver, whose per-request cache and + * only-cache-definitive-verdicts posture this method used to carry. * * @param string $uid Candidate Nextcloud user identifier. * * @return bool True when the uid corresponds to a real Nextcloud user. */ private function userExists(string $uid): bool { - if ($uid === '') { - return false; - } - - if (isset($this->userExistsCache[$uid]) === true) { - return $this->userExistsCache[$uid]; - } - - // R06: only cache definitive verdicts. A `\Throwable` from - // IUserManager (transient DB/LDAP failure, momentary container - // hiccup) is NOT a definitive "user doesn't exist" — caching it - // would silently drop every notification for this uid for the - // rest of the request, even after the underlying problem - // clears. Log + return false WITHOUT writing to the cache so - // the next call within the same request retries the lookup. - try { - $exists = $this->userManager->userExists($uid); - } catch (\Throwable $e) { - $this->logger->warning( - sprintf('[AnnotationNotificationDispatcher] userExists check failed for "%s" (not cached, will retry): %s', $uid, $e->getMessage()) - ); - return false; - } - - $this->userExistsCache[$uid] = (bool)$exists; - return $this->userExistsCache[$uid]; + return $this->recipientResolver()->userExists(uid: $uid); }//end userExists() - /** - * Per-request cache for userExists() lookups. - * - * @var array - */ - private array $userExistsCache = []; - - /** - * Extract candidate UIDs from a relation value. The relation value - * can be: - * - a string (treat as UID directly) - * - an array of strings (each treated as a UID) - * - an array of objects with a `userId` or `uid` field - * - any nested combination of the above - * - * @param mixed $value The raw relation value. - * - * @return array - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) extractUidsFromRelation() handles six distinct - * relation shapes (null, array-of-strings, array-of-objects with uid/id/userId, plain string); - * each shape requires a separate extraction branch that cannot be unified. - */ - private function extractUidsFromRelation(mixed $value): array { - if ($value === null) { - return []; - } - - if (is_string($value) === true && $value !== '') { - return [$value]; - } - - if (is_array($value) === false) { - return []; - } - - $out = []; - foreach ($value as $entry) { - if (is_string($entry) === true && $entry !== '') { - $out[] = $entry; - continue; - } - - if (is_array($entry) === true) { - $candidate = ($entry['userId'] ?? $entry['uid'] ?? $entry['user_id'] ?? null); - if (is_string($candidate) === true && $candidate !== '') { - $out[] = $candidate; - } - } - } - - return $out; - }//end extractUidsFromRelation() - /** * Resolve a localized subject template against a recipient locale. * @@ -2395,88 +2225,11 @@ private function resolveUserLocale(string $uid): ?string { * @return string The interpolated string. */ private function interpolate(string $template, array $data, array $context): string { - return preg_replace_callback( - '/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/', - function (array $matches) use ($data, $context): string { - $key = $matches[1]; - if (array_key_exists($key, $data) === true) { - if (is_scalar($data[$key]) === false) { - return ''; - } - - // Relation fields hold a UUID reference; show the related - // object's display name instead of the raw UUID so - // "{{client}}" reads "Acme Gemeente BV", not a UUID string. - $raw = (string)$data[$key]; - $display = $this->resolveRelationDisplayName(value: $raw); - - return htmlspecialchars(($display ?? $raw), ENT_QUOTES, 'UTF-8'); - } - - if (array_key_exists($key, $context) === true) { - if (is_scalar($context[$key]) === false) { - return ''; - } - - return htmlspecialchars((string)$context[$key], ENT_QUOTES, 'UTF-8'); - } - - return ''; - }, - $template - ) ?? $template; + // The dialect's ONE placeholder evaluator — the same class the flow + // messaging service renders node templates through. + return $this->templating()->interpolate(template: $template, data: $data, context: $context); }//end interpolate() - /** - * Resolve a relation-reference UUID to the related object's display name. - * - * Notification subjects/bodies interpolate `{{prop}}` from the object's - * data; a relation field holds a UUID, which reads poorly in a popup - * ("Incoming call from 3b9f…"). When the value is UUID-shaped, resolve the - * related object through OpenRegister (RBAC-scoped, mirroring the action - * deeplink resolver) and return its name. Returns null — so the caller - * keeps the raw value — for non-UUID values, an absent ObjectService, an - * unresolvable id, or a nameless object. Cached per dispatcher instance to - * avoid repeat lookups across a recipient fan-out. - * - * @param string $value The interpolated field value. - * - * @return string|null The related object's display name, or null to keep the raw value. - * - * @spec openspec/changes/openregister-notification-relation-names/specs/notificatie-engine/spec.md - */ - private function resolveRelationDisplayName(string $value): ?string { - if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value) !== 1) { - return null; - } - - if ($this->objectService === null) { - return null; - } - - if (array_key_exists($value, $this->relationDisplayCache) === true) { - return $this->relationDisplayCache[$value]; - } - - $name = null; - try { - $related = $this->objectService->find(id: $value, _rbac: true); - if ($related !== null) { - $candidate = $related->getName(); - if (is_string($candidate) === true && $candidate !== '') { - $name = $candidate; - } - } - } catch (\Throwable $e) { - $this->logger->debug('[AnnotationNotificationDispatcher] relation display-name resolve failed: ' . $e->getMessage()); - $name = null; - } - - $this->relationDisplayCache[$value] = $name; - - return $name; - }//end resolveRelationDisplayName() - /** * Map a trigger to the canonical INotification subject the Notifier * renders. Decouples the displayed subject from the schema author's @@ -2851,32 +2604,23 @@ private function enqueueWebPush( return; } - $objectUuid = (string)($object->getUuid() ?? ''); - $tagSuffix = $ruleId; - if ($objectUuid !== '') { - $tagSuffix = $objectUuid; - } - - $tag = sprintf('openregister-%s-%s', $ruleId, $tagSuffix); - - foreach ($recipients as $uid) { - if (is_string($uid) === false || $uid === '' || $this->userExists(uid: $uid) === false) { - continue; - } - - $this->jobList->add( - WebPushDispatchJob::class, - [ - 'uid' => $uid, - 'ruleId' => $ruleId, - 'originApp' => $originApp, - 'title' => $subject, - 'body' => $message, - 'tag' => $tag, - 'actions' => $actions, - ] - ); - }//end foreach + // The shared ride-along unit — the same class the flow messaging + // service rides web-push along with. Recipients are pre-verified here + // so the unit's own check is a second, cheap gate. + $this->ncSender()->enqueueWebPush( + recipients: array_values( + array_filter( + $recipients, + fn (mixed $uid): bool => is_string($uid) === true && $uid !== '' && $this->userExists(uid: $uid) === true + ) + ), + ruleId: $ruleId, + originApp: $originApp, + subject: $subject, + message: $message, + actions: $actions, + object: $object + ); }//end enqueueWebPush() /** @@ -2920,60 +2664,21 @@ private function emitNotification( array $actions = [], bool $webPushActive = false, ): void { - $objectUuid = (string)($object->getUuid() ?? ''); - $tagSuffix = $name; - if ($objectUuid !== '') { - $tagSuffix = $objectUuid; - } - - $linkParams = [ - 'objectTitle' => (string)($object->getName() ?? $objectUuid), - 'registerId' => $object->getRegister(), - 'schemaId' => $object->getSchema(), - 'objectUuid' => $objectUuid, - // The resolved origin app drives the notifier icon (originApp hex - // composite) and the deeplink base for declared actions. - 'originApp' => $originApp, - // Declared, server-resolved action buttons. The notifier renders - // these via addAction(); an empty array keeps the implicit "View". - '_actions' => $actions, - // Pre-interpolated notification BODY (distinct from the title). - // The notifier sets it via setParsedMessage() when non-empty; - // an empty string leaves the body unset (back-compat). - '_message' => $message, - // Stable notification tag used by the Service Worker / foreground - // client to COLLAPSE the web-push and the stock popup so the - // recipient never sees a duplicate. Keyed by (rule, object). - '_tag' => sprintf('openregister-%s-%s', $name, $tagSuffix), - // Foreground-suppression flag: when web-push is active for this - // rule, an open tab that holds an active push subscription - // declines to render the plain duplicate popup for this tag - // (see js/openregister-push-sw.js + src/webpush/register.js). - '_suppressForegroundPopup' => $webPushActive, - ]; - - $objectRef = $name; - if ($objectUuid !== '') { - $objectRef = $objectUuid; - } - - try { - $notification = $this->notificationManager->createNotification(); - $notification - ->setApp('openregister') - ->setUser($uid) - ->setDateTime(new DateTime()) - ->setObject('object', $objectRef) - ->setSubject( - $subjectKey, - array_merge($context, $linkParams, ['_text' => $subject, 'notificationType' => $name]) - ); - $this->notificationManager->notify($notification); - } catch (\Throwable $e) { - $this->logger->warning( - sprintf('Notification "%s" to "%s" failed: %s', $name, $uid, $e->getMessage()) - ); - } + // The shared channel send unit — the same class the flow messaging + // service invokes. The declarative path stays best-effort, so the + // outcome is not escalated here; the unit already logs failures. + $this->ncSender()->send( + uid: $uid, + object: $object, + subjectKey: $subjectKey, + name: $name, + subject: $subject, + message: $message, + context: $context, + originApp: $originApp, + actions: $actions, + webPushActive: $webPushActive + ); }//end emitNotification() /** @@ -2990,29 +2695,11 @@ private function emitNotification( * @return void */ private function emitEmail(string $uid, string $subject, string $body): void { - try { - $user = $this->userManager->get($uid); - if ($user === null) { - return; - } - - $to = $user->getEMailAddress(); - if ($to === null || $to === '') { - return; - } - - $msg = $this->mailer->createMessage(); - $msg->setTo([$to => $user->getDisplayName()]); - $msg->setSubject($subject); - $msg->setPlainBody($body); - $this->mailer->send($msg); - } catch (\Throwable $e) { - // Don't escalate — email is best-effort. SMTP not configured - // is normal in dev containers. - $this->logger->debug( - sprintf('[AnnotationNotificationDispatcher] email to "%s" failed (%s)', $uid, $e->getMessage()) - ); - }//end try + // The shared channel send unit — the same class the flow messaging + // service invokes. The declarative path stays best-effort (SMTP not + // configured is normal in dev containers), so the outcome is not + // escalated here; the unit already logs failures. + $this->emailSender()->send(uid: $uid, subject: $subject, body: $body); }//end emitEmail() /** diff --git a/lib/Service/Notification/EmailSender.php b/lib/Service/Notification/EmailSender.php new file mode 100644 index 0000000000..8dc9fe1846 --- /dev/null +++ b/lib/Service/Notification/EmailSender.php @@ -0,0 +1,116 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use OCP\IUserManager; +use OCP\Mail\IMailer; +use Psr\Log\LoggerInterface; + +/** + * Composes and hands off a transactional email to a Nextcloud user. + */ +class EmailSender { + + public const OUTCOME_DISPATCHED = 'dispatched'; + + public const OUTCOME_KILL_SWITCH = 'kill-switch'; + + public const OUTCOME_NO_ADDRESS = 'no-address'; + + public const OUTCOME_FAILED = 'failed'; + + /** + * Constructor. + * + * @param IUserManager $userManager User resolver for the recipient's address. + * @param IMailer $mailer The mailer. + * @param LoggerInterface $logger Logger for handoff diagnostics. + * @param NotificationChannelPolicy|null $channelPolicy The subsystem's per-channel kill switches; null means enabled. + */ + public function __construct( + private readonly IUserManager $userManager, + private readonly IMailer $mailer, + private readonly LoggerInterface $logger, + private readonly ?NotificationChannelPolicy $channelPolicy = null, + ) { + + }//end __construct() + + /** + * Send a transactional email to a Nextcloud user. + * + * Resolves the user's email via IUserManager. Never throws: the outcome + * says what happened, and each caller decides what a non-delivery means — + * the declarative dispatcher logs and moves on, the flow messaging + * service escalates a failure into a step failure. + * + * @param string $uid Recipient user UID. + * @param string $subject Email subject line. + * @param string $body Email body text. + * + * @return string One of the OUTCOME_* constants. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function send(string $uid, string $subject, string $body): string { + if ($this->channelPolicy !== null && $this->channelPolicy->isChannelEnabled(channel: 'email') === false) { + return self::OUTCOME_KILL_SWITCH; + } + + try { + $user = $this->userManager->get($uid); + if ($user === null) { + return self::OUTCOME_NO_ADDRESS; + } + + $to = $user->getEMailAddress(); + if ($to === null || $to === '') { + return self::OUTCOME_NO_ADDRESS; + } + + $msg = $this->mailer->createMessage(); + $msg->setTo([$to => $user->getDisplayName()]); + $msg->setSubject($subject); + $msg->setPlainBody($body); + $this->mailer->send($msg); + } catch (\Throwable $e) { + // SMTP not configured is normal in dev containers; the caller + // decides whether this outcome escalates. + $this->logger->debug( + sprintf('[EmailSender] email to "%s" failed (%s)', $uid, $e->getMessage()) + ); + return self::OUTCOME_FAILED; + }//end try + + return self::OUTCOME_DISPATCHED; + }//end send() +}//end class diff --git a/lib/Service/Notification/NcNotificationSender.php b/lib/Service/Notification/NcNotificationSender.php new file mode 100644 index 0000000000..bd1d193885 --- /dev/null +++ b/lib/Service/Notification/NcNotificationSender.php @@ -0,0 +1,268 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use DateTime; +use OCA\OpenRegister\BackgroundJob\WebPushDispatchJob; +use OCA\OpenRegister\Db\ObjectEntity; +use OCP\BackgroundJob\IJobList; +use OCP\IUserManager; +use OCP\Notification\IManager as INotificationManager; +use Psr\Log\LoggerInterface; + +/** + * Persists and pushes a single in-app Nextcloud notification. + */ +class NcNotificationSender { + + public const OUTCOME_DISPATCHED = 'dispatched'; + + public const OUTCOME_KILL_SWITCH = 'kill-switch'; + + public const OUTCOME_FAILED = 'failed'; + + /** + * Constructor. + * + * @param INotificationManager $notificationManager Nextcloud notification API. + * @param LoggerInterface $logger Logger for dispatch diagnostics. + * @param IUserManager|null $userManager User resolver for the web-push ride-along. + * @param IJobList|null $jobList Job list used to enqueue the web-push dispatch job. + * @param NotificationChannelPolicy|null $channelPolicy The subsystem's per-channel kill switches; null means enabled. + */ + public function __construct( + private readonly INotificationManager $notificationManager, + private readonly LoggerInterface $logger, + private readonly ?IUserManager $userManager = null, + private readonly ?IJobList $jobList = null, + private readonly ?NotificationChannelPolicy $channelPolicy = null, + ) { + + }//end __construct() + + /** + * Persist + dispatch a single in-app Nextcloud notification row. + * + * The INotification carries the canonical `$subjectKey` (which the + * Notifier switches on to render localised text + an object-detail + * action link), the routing parameters the action link needs + * (`objectTitle`, `registerId`, `schemaId`, `objectUuid`), the rule's + * own name under `notificationType`, and the pre-rendered subject text + * under `_text` (so a schema's custom per-locale subject still wins). + * + * Push delivery needs no extra code: `notify_push` auto-intercepts this + * same `IManager::notify()` call and relays it to connected devices. + * + * @param string $uid Recipient user UID. + * @param ObjectEntity $object The object the event happened on. + * @param string $subjectKey Canonical subject identifier (object_created/_updated/_transitioned). + * @param string $name Rule name or step identity (notification type identifier). + * @param string $subject Pre-interpolated subject text (notification title). + * @param string $message Pre-interpolated body text (notification body); may be empty. + * @param array $context Trigger context (action, from, to). + * @param string $originApp Resolved originApp (declared or register-owning app). + * @param array> $actions Resolved action buttons (label map + deeplink url + primary). + * @param bool $webPushActive Whether the send also delivers over web-push (drives duplicate suppression). + * + * @return string One of the OUTCOME_* constants. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) The dispatcher's emit signature, moved verbatim: + * every argument is one field of the INotification the channel persists. + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) `$webPushActive` is the foreground-suppression + * flag on the notification tag, a delivery detail of this channel, not a second responsibility. + * + * @spec openspec/changes/openregister-notification-body/specs/notificatie-engine/spec.md + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function send( + string $uid, + ObjectEntity $object, + string $subjectKey, + string $name, + string $subject, + string $message, + array $context, + string $originApp = 'openregister', + array $actions = [], + bool $webPushActive = false, + ): string { + if ($this->channelPolicy !== null && $this->channelPolicy->isChannelEnabled(channel: 'nc-notification') === false) { + return self::OUTCOME_KILL_SWITCH; + } + + $objectUuid = (string)($object->getUuid() ?? ''); + $tagSuffix = $name; + if ($objectUuid !== '') { + $tagSuffix = $objectUuid; + } + + $linkParams = [ + 'objectTitle' => (string)($object->getName() ?? $objectUuid), + 'registerId' => $object->getRegister(), + 'schemaId' => $object->getSchema(), + 'objectUuid' => $objectUuid, + // The resolved origin app drives the notifier icon (originApp hex + // composite) and the deeplink base for declared actions. + 'originApp' => $originApp, + // Declared, server-resolved action buttons. The notifier renders + // these via addAction(); an empty array keeps the implicit "View". + '_actions' => $actions, + // Pre-interpolated notification BODY (distinct from the title). + // The notifier sets it via setParsedMessage() when non-empty; + // an empty string leaves the body unset (back-compat). + '_message' => $message, + // Stable notification tag used by the Service Worker / foreground + // client to COLLAPSE the web-push and the stock popup so the + // recipient never sees a duplicate. Keyed by (rule, object). + '_tag' => sprintf('openregister-%s-%s', $name, $tagSuffix), + // Foreground-suppression flag: when web-push is active for this + // rule, an open tab that holds an active push subscription + // declines to render the plain duplicate popup for this tag + // (see js/openregister-push-sw.js + src/webpush/register.js). + '_suppressForegroundPopup' => $webPushActive, + ]; + + $objectRef = $name; + if ($objectUuid !== '') { + $objectRef = $objectUuid; + } + + try { + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp('openregister') + ->setUser($uid) + ->setDateTime(new DateTime()) + ->setObject('object', $objectRef) + ->setSubject( + $subjectKey, + array_merge($context, $linkParams, ['_text' => $subject, 'notificationType' => $name]) + ); + $this->notificationManager->notify($notification); + } catch (\Throwable $e) { + $this->logger->warning( + sprintf('Notification "%s" to "%s" failed: %s', $name, $uid, $e->getMessage()) + ); + return self::OUTCOME_FAILED; + } + + return self::OUTCOME_DISPATCHED; + }//end send() + + /** + * Enqueue the web-push ride-along for a set of recipients. + * + * Routed out of band: a background job per recipient so the originating + * request is never blocked on push I/O. The job re-resolves recipients to + * their stored subscriptions and sends the encrypted VAPID payload. The + * ride-along follows the nc-notification channel's kill switch — web-push + * is a delivery detail of that channel, not a channel of its own. + * + * @param array $recipients Recipient uids. + * @param string $ruleId Rule name or step identity. + * @param string $originApp Resolved origin app. + * @param string $subject Pre-interpolated title. + * @param string $message Pre-interpolated body. + * @param array> $actions Resolved action buttons. + * @param ObjectEntity $object The object the event happened on. + * + * @return void + * + * @spec openspec/changes/openregister-web-push-engine/specs/notificatie-engine/spec.md + */ + public function enqueueWebPush( + array $recipients, + string $ruleId, + string $originApp, + string $subject, + string $message, + array $actions, + ObjectEntity $object, + ): void { + if ($this->jobList === null) { + $this->logger->debug('[NcNotificationSender] web-push declared but IJobList unavailable.'); + return; + } + + if ($this->channelPolicy !== null && $this->channelPolicy->isChannelEnabled(channel: 'nc-notification') === false) { + return; + } + + $objectUuid = (string)($object->getUuid() ?? ''); + $tagSuffix = $ruleId; + if ($objectUuid !== '') { + $tagSuffix = $objectUuid; + } + + $tag = sprintf('openregister-%s-%s', $ruleId, $tagSuffix); + + foreach ($recipients as $uid) { + if (is_string($uid) === false || $uid === '' || $this->recipientExists(uid: $uid) === false) { + continue; + } + + $this->jobList->add( + WebPushDispatchJob::class, + [ + 'uid' => $uid, + 'ruleId' => $ruleId, + 'originApp' => $originApp, + 'title' => $subject, + 'body' => $message, + 'tag' => $tag, + 'actions' => $actions, + ] + ); + }//end foreach + }//end enqueueWebPush() + + /** + * Whether a uid names a real user; true when no user manager was injected + * (the caller has then already verified its recipients). + * + * @param string $uid Candidate uid. + * + * @return bool True when the uid may be enqueued. + */ + private function recipientExists(string $uid): bool { + if ($this->userManager === null) { + return true; + } + + try { + return $this->userManager->userExists($uid); + } catch (\Throwable $e) { + $this->logger->warning( + sprintf('[NcNotificationSender] userExists check failed for "%s": %s', $uid, $e->getMessage()) + ); + return false; + } + }//end recipientExists() +}//end class diff --git a/lib/Service/Notification/NotificationChannelPolicy.php b/lib/Service/Notification/NotificationChannelPolicy.php new file mode 100644 index 0000000000..e7a8f6bd53 --- /dev/null +++ b/lib/Service/Notification/NotificationChannelPolicy.php @@ -0,0 +1,95 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; + +/** + * Reads the per-channel notification kill switches. + */ +class NotificationChannelPolicy { + + public const APP_ID = 'openregister'; + + /** + * App-config key template for a channel's kill switch, e.g. + * `notification_channel_email_enabled`. Dashes in the channel name are + * folded to underscores (`nc-notification` -> `nc_notification`). + */ + public const CONFIG_KEY_TEMPLATE = 'notification_channel_%s_enabled'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig App-config reader for the switches. + * @param LoggerInterface $logger Logger for silenced-send info events. + */ + public function __construct( + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Whether the given channel may send. Defaults to ON. + * + * @param string $channel The channel name (`nc-notification`, `email`, `talk`). + * + * @return bool True when sends on this channel may proceed. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + public function isChannelEnabled(string $channel): bool { + $key = sprintf(self::CONFIG_KEY_TEMPLATE, str_replace('-', '_', $channel)); + + try { + $value = $this->appConfig->getValueString(self::APP_ID, $key, 'true'); + } catch (\Throwable $e) { + // Fail open: a broken config read must never become a silent + // instance-wide notification outage. + return true; + } + + $enabled = ($value !== 'false' && $value !== '0'); + if ($enabled === false) { + $this->logger->info( + sprintf('[NotificationChannelPolicy] channel "%s" silenced by kill switch (%s)', $channel, $key) + ); + } + + return $enabled; + }//end isChannelEnabled() +}//end class diff --git a/lib/Service/Notification/NotificationRecipientResolver.php b/lib/Service/Notification/NotificationRecipientResolver.php new file mode 100644 index 0000000000..e53b121d5e --- /dev/null +++ b/lib/Service/Notification/NotificationRecipientResolver.php @@ -0,0 +1,412 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCP\IGroupManager; +use OCP\IServerContainer; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +/** + * Resolves a recipients spec to verified Nextcloud uids. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Six recipient kinds, each with its own + * verification posture, plus the uid/group existence checks both callers share; PHPMD sums + * every helper's branches into the class total. + */ +class NotificationRecipientResolver { + + /** + * Per-request cache for userExists() lookups. + * + * @var array + */ + private array $userExistsCache = []; + + /** + * Constructor. + * + * @param IUserManager $userManager User resolver for uid verification. + * @param IGroupManager $groupManager Group resolver for `groups` recipient kinds. + * @param LoggerInterface $logger Logger for resolution diagnostics. + * @param IServerContainer|null $serverContainer Container for `expression` resolvers; null disables that kind. + */ + public function __construct( + private readonly IUserManager $userManager, + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + private readonly ?IServerContainer $serverContainer = null, + ) { + + }//end __construct() + + /** + * Resolve a recipients spec to a deduplicated list of verified uids. + * + * Supported kinds: `users`, `field`, `relation`, `object-acl`, + * `expression`, `groups` — the dispatcher's set, unchanged. + * + * @param array $recipientsSpec The rule's `recipients` declaration. + * @param array $data The object's stored data (or a flow item's json). + * @param ObjectEntity|null $object The object, for `object-acl` and `expression` kinds. + * @param array $context Trigger-specific extras handed to expression resolvers. + * + * @return array Verified, deduplicated uids. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per recipient kind; each is a distinct + * resolution rule that cannot be merged without losing the kind's own verification posture. + * @SuppressWarnings(PHPMD.NPathComplexity) Kind dispatch times per-kind guards; all required. + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) One branch per recipient kind, moved verbatim + * from the dispatcher; splitting per kind would scatter the shared verification posture. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function resolve(array $recipientsSpec, array $data, ?ObjectEntity $object = null, array $context = []): array { + $uids = []; + foreach ($recipientsSpec as $r) { + if (is_array($r) === false) { + continue; + } + + $kind = (string)($r['kind'] ?? ''); + if ($kind === 'users') { + foreach ((array)($r['users'] ?? []) as $u) { + if (is_string($u) === true && $u !== '' && $this->userExists(uid: $u) === true) { + $uids[] = $u; + } + } + + continue; + } + + if ($kind === 'field') { + // The field's value comes from the object's stored data, + // which is writeable by anyone with `update` permission + // on the object. An attacker who controls the field + // could otherwise direct notifications at any uid string, + // including admins, with an attacker-shaped subject. + // Verify the value names a real Nextcloud user before + // adding it to the recipient list. + $field = (string)($r['field'] ?? ''); + $value = ($data[$field] ?? null); + if (is_string($value) === true && $value !== '' && $this->userExists(uid: $value) === true) { + $uids[] = $value; + } + + continue; + } + + if ($kind === 'relation') { + // Resolve a typed relation (declared via x-openregister-relations). + // Same attacker-controlled-input reasoning as the `field` + // kind above — every extracted uid is checked against + // IUserManager::userExists(). + $relName = (string)($r['relation'] ?? ''); + if ($relName === '') { + continue; + } + + $value = ($data[$relName] ?? null); + foreach ($this->extractUidsFromRelation(value: $value) as $uid) { + if ($this->userExists(uid: $uid) === true) { + $uids[] = $uid; + } + } + + continue; + }//end if + + if ($kind === 'object-acl') { + if ($object !== null) { + $perm = (string)($r['permission'] ?? 'read'); + foreach ($this->resolveObjectAclRecipients(object: $object, permission: $perm) as $uid) { + $uids[] = $uid; + } + } + + continue; + } + + if ($kind === 'expression') { + if ($object !== null) { + $resolverTag = (string)($r['resolver'] ?? ''); + $resolved = $this->resolveExpressionRecipients( + resolverTag: $resolverTag, + object: $object, + context: $context + ); + foreach ($resolved as $uid) { + $uids[] = $uid; + } + } + + continue; + } + + if ($kind === 'groups') { + foreach ((array)($r['groups'] ?? []) as $gid) { + if (is_string($gid) === false || $gid === '') { + continue; + } + + try { + $group = $this->groupManager->get($gid); + if ($group === null) { + continue; + } + + foreach ($group->getUsers() as $user) { + $uids[] = $user->getUID(); + } + } catch (\Throwable $e) { + $this->logger->warning( + sprintf('[NotificationRecipientResolver] group "%s" lookup failed: %s', $gid, $e->getMessage()) + ); + } + } + }//end if + }//end foreach + + return array_values(array_unique($uids)); + }//end resolve() + + /** + * Verify that a uid corresponds to an actual Nextcloud user. + * + * Backed by a per-request cache; only definitive verdicts are cached. A + * `\Throwable` from IUserManager (transient DB/LDAP failure) is NOT a + * definitive "user doesn't exist" — caching it would silently drop every + * notification for this uid for the rest of the request, even after the + * underlying problem clears. + * + * @param string $uid Candidate Nextcloud user identifier. + * + * @return bool True when the uid corresponds to a real Nextcloud user. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function userExists(string $uid): bool { + if ($uid === '') { + return false; + } + + if (isset($this->userExistsCache[$uid]) === true) { + return $this->userExistsCache[$uid]; + } + + try { + $exists = $this->userManager->userExists($uid); + } catch (\Throwable $e) { + $this->logger->warning( + sprintf('[NotificationRecipientResolver] userExists check failed for "%s" (not cached, will retry): %s', $uid, $e->getMessage()) + ); + return false; + } + + $this->userExistsCache[$uid] = (bool)$exists; + return $this->userExistsCache[$uid]; + }//end userExists() + + /** + * Whether a group id names a real group. + * + * Used by the flow messaging service to classify a literal recipient + * entry as a group before asking for a `groups` expansion. + * + * @param string $gid Candidate group id. + * + * @return bool True when the group exists. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function groupExists(string $gid): bool { + if ($gid === '') { + return false; + } + + try { + return $this->groupManager->groupExists($gid); + } catch (\Throwable $e) { + $this->logger->warning( + sprintf('[NotificationRecipientResolver] groupExists check failed for "%s": %s', $gid, $e->getMessage()) + ); + return false; + } + }//end groupExists() + + /** + * Extract candidate UIDs from a relation value. The relation value + * can be: + * - a string (treat as UID directly) + * - an array of strings (each treated as a UID) + * - an array of objects with a `userId` or `uid` field + * - any nested combination of the above + * + * @param mixed $value The raw relation value. + * + * @return array + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) Handles six distinct relation shapes + * (null, array-of-strings, array-of-objects with uid/id/userId, plain string); each + * shape requires a separate extraction branch that cannot be unified. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function extractUidsFromRelation(mixed $value): array { + if ($value === null) { + return []; + } + + if (is_string($value) === true && $value !== '') { + return [$value]; + } + + if (is_array($value) === false) { + return []; + } + + $out = []; + foreach ($value as $entry) { + if (is_string($entry) === true && $entry !== '') { + $out[] = $entry; + continue; + } + + if (is_array($entry) === true) { + $candidate = ($entry['userId'] ?? $entry['uid'] ?? $entry['user_id'] ?? null); + if (is_string($candidate) === true && $candidate !== '') { + $out[] = $candidate; + } + } + } + + return $out; + }//end extractUidsFromRelation() + + /** + * Resolve recipients from the object's per-object ACL. + * + * V1 implementation: best-effort. Reads the object's `groups` and + * `owner` fields directly. Per-object ACL granularity (read vs + * manage) is treated as: `read` matches any user/group in the ACL; + * `manage` matches only the object owner. + * + * @param ObjectEntity $object The object whose ACL should be read. + * @param string $permission The required permission (`read` or `manage`). + * + * @return array + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) Walks owner, per-user, per-role and per-group + * ACL entries; each needs its own null-guard and uid extraction. + */ + private function resolveObjectAclRecipients(ObjectEntity $object, string $permission): array { + $uids = []; + $owner = $object->getOwner(); + if (is_string($owner) === true && $owner !== '') { + $uids[] = $owner; + } + + if ($permission === 'manage') { + return $uids; + } + + // Read permission: also include groups via getGroups(). The + // Entity base uses __call magic for accessors, so method_exists() + // is unreliable — fall through and let the magic call surface + // the value (or throw, which is caught below). + try { + $groupsRaw = $object->getGroups(); + if (is_array($groupsRaw) === true) { + foreach ($groupsRaw as $gid) { + if (is_string($gid) === false || $gid === '') { + continue; + } + + $group = $this->groupManager->get($gid); + if ($group === null) { + continue; + } + + foreach ($group->getUsers() as $user) { + $uids[] = $user->getUID(); + } + } + } + } catch (\Throwable $e) { + $this->logger->warning( + sprintf('[NotificationRecipientResolver] object-acl read resolution failed: %s', $e->getMessage()) + ); + }//end try + + return $uids; + }//end resolveObjectAclRecipients() + + /** + * Resolve recipients via a DI-tagged RecipientResolverInterface. + * + * Looks up the resolver via the injected IServerContainer so apps + * can register their resolver class by FQCN and have NC autowire + * its dependencies. Skips silently when the resolver doesn't exist + * or doesn't implement the interface. + * + * @param string $resolverTag DI tag (or FQCN) of the resolver service. + * @param ObjectEntity $object The object whose recipients are being resolved. + * @param array $context Per-event context passed through to the resolver. + * + * @return array + */ + private function resolveExpressionRecipients(string $resolverTag, ObjectEntity $object, array $context): array { + if ($resolverTag === '' || $this->serverContainer === null) { + return []; + } + + try { + $resolver = $this->serverContainer->get($resolverTag); + if (($resolver instanceof RecipientResolverInterface) === false) { + $this->logger->warning( + sprintf('[NotificationRecipientResolver] expression resolver "%s" does not implement RecipientResolverInterface', $resolverTag) + ); + return []; + } + + return array_values($resolver->resolve($object, $context)); + } catch (\Throwable $e) { + $this->logger->warning( + sprintf('[NotificationRecipientResolver] expression resolver "%s" failed: %s', $resolverTag, $e->getMessage()) + ); + return []; + } + }//end resolveExpressionRecipients() +}//end class diff --git a/lib/Service/Notification/NotificationTemplating.php b/lib/Service/Notification/NotificationTemplating.php new file mode 100644 index 0000000000..afa1048281 --- /dev/null +++ b/lib/Service/Notification/NotificationTemplating.php @@ -0,0 +1,156 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use Psr\Log\LoggerInterface; + +/** + * Interpolates `{{ key }}` placeholders against data and context. + */ +class NotificationTemplating { + + /** + * Per-instance cache of resolved relation display names, keyed by UUID. + * Avoids repeat ObjectService lookups when the same relation is + * interpolated across a recipient fan-out. + * + * @var array + */ + private array $relationDisplayCache = []; + + /** + * Constructor. + * + * @param LoggerInterface $logger Logger for resolve diagnostics. + * @param \OCA\OpenRegister\Service\ObjectService|null $objectService Object resolver for relation display names (RBAC-scoped). + */ + public function __construct( + private readonly LoggerInterface $logger, + private readonly ?\OCA\OpenRegister\Service\ObjectService $objectService = null, + ) { + + }//end __construct() + + /** + * Interpolate `{{ key }}` placeholders in a template. + * + * Data keys win over context keys; a placeholder that resolves to a + * non-scalar or to nothing renders as an empty string. A UUID-shaped data + * value is resolved to the related object's display name when possible, + * so `{{client}}` reads "Acme Gemeente BV" rather than a UUID. + * + * This is the notification dialect's ONE placeholder syntax; the flow + * messaging nodes reuse it verbatim rather than introducing a second one. + * + * @param string $template The template carrying `{{ key }}` placeholders. + * @param array $data The primary data (object data, or a flow item's json). + * @param array $context Secondary lookup values. + * + * @return string The interpolated string. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function interpolate(string $template, array $data, array $context): string { + return preg_replace_callback( + '/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/', + function (array $matches) use ($data, $context): string { + $key = $matches[1]; + if (array_key_exists($key, $data) === true) { + if (is_scalar($data[$key]) === false) { + return ''; + } + + // Relation fields hold a UUID reference; show the related + // object's display name instead of the raw UUID so + // "{{client}}" reads "Acme Gemeente BV", not a UUID string. + $raw = (string)$data[$key]; + $display = $this->resolveRelationDisplayName(value: $raw); + + return htmlspecialchars(($display ?? $raw), ENT_QUOTES, 'UTF-8'); + } + + if (array_key_exists($key, $context) === true) { + if (is_scalar($context[$key]) === false) { + return ''; + } + + return htmlspecialchars((string)$context[$key], ENT_QUOTES, 'UTF-8'); + } + + return ''; + }, + $template + ) ?? $template; + }//end interpolate() + + /** + * Resolve a relation-reference UUID to the related object's display name. + * + * Returns null — so the caller keeps the raw value — for non-UUID values, + * an absent ObjectService, an unresolvable id, or a nameless object. + * Cached per instance to avoid repeat lookups across a recipient fan-out. + * + * @param string $value The interpolated field value. + * + * @return string|null The related object's display name, or null to keep the raw value. + * + * @spec openspec/changes/openregister-notification-relation-names/specs/notificatie-engine/spec.md + */ + public function resolveRelationDisplayName(string $value): ?string { + if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value) !== 1) { + return null; + } + + if ($this->objectService === null) { + return null; + } + + if (array_key_exists($value, $this->relationDisplayCache) === true) { + return $this->relationDisplayCache[$value]; + } + + $name = null; + try { + $related = $this->objectService->find(id: $value, _rbac: true); + if ($related !== null) { + $candidate = $related->getName(); + if (is_string($candidate) === true && $candidate !== '') { + $name = $candidate; + } + } + } catch (\Throwable $e) { + $this->logger->debug('[NotificationTemplating] relation display-name resolve failed: ' . $e->getMessage()); + $name = null; + } + + $this->relationDisplayCache[$value] = $name; + + return $name; + }//end resolveRelationDisplayName() +}//end class diff --git a/lib/Service/Notification/RateLimiter.php b/lib/Service/Notification/RateLimiter.php index 8ced210d7d..7983ae3150 100644 --- a/lib/Service/Notification/RateLimiter.php +++ b/lib/Service/Notification/RateLimiter.php @@ -152,10 +152,87 @@ public function tryConsume(string $ruleId, string $recipient, ?array $perRuleOve $key = $this->key(ruleId: $ruleId, recipient: $recipient); $now = ($this->timeProvider)(); + $ruleTokens = $this->currentTokens(key: $key, bucketSize: $bucketSize, refillSeconds: $refillSeconds, now: $now); + if ($ruleTokens === null) { + return true; + } + + if ($ruleTokens < 1.0) { + $this->logger->info( + sprintf( + '[NotificationRateLimiter] dropped rule="%s" recipient="%s" bucket=%d refillSeconds=%d', + $ruleId, + $recipient, + $bucketSize, + $refillSeconds + ) + ); + // Persist the new lastRefill so partial earnings don't + // get lost on the next call. The shared bucket is untouched: + // a refused dispatch messaged nobody. + $this->persist(key: $key, tokens: $ruleTokens, lastRefill: $now); + return false; + } + + // The SHARED per-recipient budget. Bucket keys are caller-agnostic — + // the key is the recipient, nothing else — so a declarative rule and a + // flow send node draw from ONE budget by construction: the abuse being + // bounded is "how much this instance messages this person", not "per + // subsystem" or "per rule". Sized from the app-config defaults only; + // a single rule's generous per-rule override must not widen every + // caller's ceiling. Pseudo-recipients (broadcast channel keys such as + // `__webhook__`) are not people and stay outside the shared budget. + $sharedTokens = null; + $sharedKey = null; + [$sharedBucketSize, $sharedRefillSeconds] = $this->resolveLimits(perRuleOverride: null); + if (str_starts_with($recipient, '__') === false) { + $sharedKey = $this->sharedKey(recipient: $recipient); + $sharedTokens = $this->currentTokens( + key: $sharedKey, + bucketSize: $sharedBucketSize, + refillSeconds: $sharedRefillSeconds, + now: $now + ); + + if ($sharedTokens !== null && $sharedTokens < 1.0) { + $this->logger->info( + sprintf( + '[NotificationRateLimiter] dropped rule="%s" recipient="%s" by the shared per-recipient budget bucket=%d refillSeconds=%d', + $ruleId, + $recipient, + $sharedBucketSize, + $sharedRefillSeconds + ) + ); + $this->persist(key: $sharedKey, tokens: $sharedTokens, lastRefill: $now); + return false; + } + } + + $this->persist(key: $key, tokens: ($ruleTokens - 1.0), lastRefill: $now); + if ($sharedKey !== null && $sharedTokens !== null) { + $this->persist(key: $sharedKey, tokens: ($sharedTokens - 1.0), lastRefill: $now); + } + + return true; + }//end tryConsume() + + /** + * The refilled token count for a bucket, or null when the cache cannot + * answer (the limiter then fails open, as everywhere else). + * + * @param string $key Cache key. + * @param int $bucketSize Bucket capacity. + * @param int $refillSeconds Seconds per earned token. + * @param int $now Current unix timestamp. + * + * @return float|null The available tokens, or null to fail open. + */ + private function currentTokens(string $key, int $bucketSize, int $refillSeconds, int $now): ?float { try { - $state = $this->cache->get($key); + $state = $this->cache?->get($key); } catch (\Throwable $e) { - return true; + return null; }//end try $tokens = (float)$bucketSize; @@ -175,26 +252,23 @@ public function tryConsume(string $ruleId, string $recipient, ?array $perRuleOve $tokens = min((float)$bucketSize, ($tokens + $earned)); } - if ($tokens < 1.0) { - $this->logger->info( - sprintf( - '[NotificationRateLimiter] dropped rule="%s" recipient="%s" bucket=%d refillSeconds=%d', - $ruleId, - $recipient, - $bucketSize, - $refillSeconds - ) - ); - // Persist the new lastRefill so partial earnings don't - // get lost on the next call. - $this->persist(key: $key, tokens: $tokens, lastRefill: $now); - return false; - } + return $tokens; + }//end currentTokens() - $tokens -= 1.0; - $this->persist(key: $key, tokens: $tokens, lastRefill: $now); - return true; - }//end tryConsume() + /** + * The shared per-recipient budget's cache key. Deliberately blind to the + * rule and to the caller: every subsystem that messages this recipient + * lands on the same bucket. + * + * @param string $recipient Recipient identifier. + * + * @return string Cache key. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + private function sharedKey(string $recipient): string { + return 'notification:rate:shared:' . sha1($recipient); + }//end sharedKey() /** * Whether the limiter is enabled. Defaults to ON. diff --git a/lib/Service/Notification/TalkSendException.php b/lib/Service/Notification/TalkSendException.php new file mode 100644 index 0000000000..a8bfa2e567 --- /dev/null +++ b/lib/Service/Notification/TalkSendException.php @@ -0,0 +1,36 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use RuntimeException; + +/** + * A failed Talk post, carrying its reason. + */ +class TalkSendException extends RuntimeException { +}//end class diff --git a/lib/Service/Notification/TalkSender.php b/lib/Service/Notification/TalkSender.php new file mode 100644 index 0000000000..73a7861c92 --- /dev/null +++ b/lib/Service/Notification/TalkSender.php @@ -0,0 +1,227 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use DateTime; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IServerContainer; +use Psr\Log\LoggerInterface; + +/** + * Posts a chat message to a Talk conversation. + */ +class TalkSender { + + public const OUTCOME_DISPATCHED = 'dispatched'; + + public const OUTCOME_KILL_SWITCH = 'kill-switch'; + + public const OUTCOME_FAILED = 'failed'; + + /** + * Constructor. + * + * @param IClientService $httpClient HTTP client for the bot post. + * @param LoggerInterface $logger Logger for post diagnostics. + * @param IConfig|null $config Config service for the local OCS base URL. + * @param NotificationChannelPolicy|null $channelPolicy The subsystem's per-channel kill switches; null means enabled. + * @param IServerContainer|null $serverContainer Container used to reach the Talk app for the attributed path. + */ + public function __construct( + private readonly IClientService $httpClient, + private readonly LoggerInterface $logger, + private readonly ?IConfig $config = null, + private readonly ?NotificationChannelPolicy $channelPolicy = null, + private readonly ?IServerContainer $serverContainer = null, + ) { + + }//end __construct() + + /** + * Post a chat message to a Talk room as the `openregister` bot. + * + * The declarative dispatcher's behaviour, verbatim: best-effort, via the + * local OCS endpoint, no attribution to a person. + * + * @param string $token The Talk conversation token. + * @param string $message The message text. + * + * @return string One of the OUTCOME_* constants. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flows-send-through-the-notification-subsystem-never-beside-it + */ + public function postAsBot(string $token, string $message): string { + if ($token === '') { + return self::OUTCOME_FAILED; + } + + if ($this->channelPolicy !== null && $this->channelPolicy->isChannelEnabled(channel: 'talk') === false) { + return self::OUTCOME_KILL_SWITCH; + } + + try { + $client = $this->httpClient->newClient(); + // Talk's chat endpoint is internal to the NC instance, so route + // via the configured overwrite host or fall back to the loopback. + $base = 'http://localhost'; + if ($this->config !== null) { + $base = (string)$this->config->getSystemValue('overwrite.cli.url', 'http://localhost'); + } + + $base = rtrim($base, '/'); + $url = $base . '/ocs/v2.php/apps/spreed/api/v1/chat/' . rawurlencode($token); + + $client->post( + $url, + [ + 'headers' => [ + 'OCS-APIRequest' => 'true', + 'Accept' => 'application/json', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => [ + 'message' => $message, + 'actorType' => 'bots', + 'actorId' => 'openregister', + ], + 'timeout' => 5, + ] + ); + } catch (\Throwable $e) { + $this->logger->warning( + sprintf('[TalkSender] talk to "%s" failed: %s', $token, $e->getMessage()) + ); + return self::OUTCOME_FAILED; + }//end try + + return self::OUTCOME_DISPATCHED; + }//end postAsBot() + + /** + * Post a chat message to a Talk conversation AS a user. + * + * The flow messaging path: the message appears from the acting user, so + * replies have an addressee and the audit trail an actor. The user MUST + * already be a participant of the conversation; "not a participant" is a + * failure with that reason, never an auto-join. + * + * @param string $token The Talk conversation token. + * @param string $message The message text. + * @param string $actorUid The acting user's uid. + * + * @return string OUTCOME_DISPATCHED or OUTCOME_KILL_SWITCH. + * + * @throws TalkSendException When Talk is unavailable, the conversation is + * unknown, the acting user is not a participant, + * or the post is refused. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + public function postAsUser(string $token, string $message, string $actorUid): string { + if ($token === '') { + throw new TalkSendException('No Talk conversation token was given.'); + } + + if ($this->channelPolicy !== null && $this->channelPolicy->isChannelEnabled(channel: 'talk') === false) { + return self::OUTCOME_KILL_SWITCH; + } + + $this->postViaTalkApp(token: $token, message: $message, actorUid: $actorUid); + + return self::OUTCOME_DISPATCHED; + }//end postAsUser() + + /** + * Perform the attributed post through the Talk app's own services. + * + * Talk publishes no OCP surface for posting as a user, so the classes are + * resolved by name through the container — a duck-typed integration that + * fails LOUDLY on every miss: Talk absent, conversation unknown, actor not + * a participant, API shape changed. A silent no-op here would be a + * COMPLETED run hiding an undelivered message. + * + * Protected so unit tests can substitute the Talk side without a Talk + * installation; the decision logic above it stays under test either way. + * + * @param string $token The conversation token. + * @param string $message The message text. + * @param string $actorUid The acting user's uid. + * + * @return void + * + * @throws TalkSendException On every non-delivery, naming the reason. + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded + */ + protected function postViaTalkApp(string $token, string $message, string $actorUid): void { + if ($this->serverContainer === null) { + throw new TalkSendException('Talk posting is unavailable: no server container to reach the Talk app.'); + } + + try { + $manager = $this->serverContainer->get('OCA\\Talk\\Manager'); + } catch (\Throwable $e) { + throw new TalkSendException('Talk (spreed) is not installed, so no Talk message can be sent.'); + } + + try { + // Resolving the room FOR the user is also the participant check: + // Talk refuses the lookup for a conversation the user is not in. + $room = $manager->getRoomForUserByToken($token, $actorUid); + $participantService = $this->serverContainer->get('OCA\\Talk\\Service\\ParticipantService'); + $participant = $participantService->getParticipant($room, $actorUid, false); + $chatManager = $this->serverContainer->get('OCA\\Talk\\Chat\\ChatManager'); + $chatManager->sendMessage($room, $participant, 'users', $actorUid, $message, new DateTime()); + } catch (TalkSendException $e) { + throw $e; + } catch (\Throwable $e) { + $class = get_class($e); + if (str_contains($class, 'ParticipantNotFound') === true || str_contains($class, 'RoomNotFound') === true) { + throw new TalkSendException( + sprintf( + 'User "%s" is not a participant of Talk conversation "%s"; the message was not sent and the user was not auto-joined.', + $actorUid, + $token + ) + ); + } + + throw new TalkSendException( + sprintf('Talk post to "%s" as "%s" failed: %s', $token, $actorUid, $e->getMessage()) + ); + }//end try + }//end postViaTalkApp() +}//end class diff --git a/openspec/changes/flow-engine-docs/tasks.md b/openspec/changes/flow-engine-docs/tasks.md index 9757aaaccc..bcb59cf6dd 100644 --- a/openspec/changes/flow-engine-docs/tasks.md +++ b/openspec/changes/flow-engine-docs/tasks.md @@ -25,7 +25,16 @@ code named beside it: iterate, batch, flow-state, wait, sub-flow, await-signal, end), one paragraph each: what it does, its config keys, a worked example; contributed nodes (openconnector, hermiq) noted as coming from their - apps' palettes with their own docs. + apps' palettes with their own docs. Fed from `flow-messaging-nodes` + (shipped): `openregister.send-notification` sends an in-app + notification (web-push riding along) to users, groups or an item + field, through the ADR-031 channel machinery; + `openregister.send-email` sends an email with the same recipients, + guardrails and placeholder syntax; `openregister.send-talk-message` + posts a Talk chat message as the run's acting user, who must already + be a participant. State the boundary beside them: notifications + notify, flows orchestrate, and API calls go through OpenConnector + sources — there is deliberately no send-webhook node. - [ ] **Approvals** — await-signal end to end: the question, who answers, how the answer routes downstream, the heartbeat, what happens when nobody answers (reaped as failed, flow schedulable again); telling diff --git a/openspec/changes/flow-messaging-nodes/tasks.md b/openspec/changes/flow-messaging-nodes/tasks.md index f1a2bc4227..b009687e4a 100644 --- a/openspec/changes/flow-messaging-nodes/tasks.md +++ b/openspec/changes/flow-messaging-nodes/tasks.md @@ -2,63 +2,63 @@ ## Extraction (behaviour-preserving refactor first) -- [ ] Extract the per-channel send units from +- [x] Extract the per-channel send units from `AnnotationNotificationDispatcher` — nc-notification (+ web-push ride-along), email composition/handoff, Talk post — and the recipient resolver into injectable units under `lib/Service/Notification/`; dispatcher re-wired onto them with its existing tests green and unchanged. -- [ ] Confirm `RateLimiter` bucket keys are caller-agnostic (recipient + +- [x] Confirm `RateLimiter` bucket keys are caller-agnostic (recipient + channel + window), so both callers share one budget by construction. ## FlowMessagingService -- [ ] `FlowMessagingService` invoking the extracted units; applies, in +- [x] `FlowMessagingService` invoking the extracted units; applies, in order: subsystem kill switches → preference filter (per-recipient channels) → recipient bound (post-expansion, default from app config) → rate limiter → send. No flow-messaging-specific switch: stopping a sending flow is the per-flow `enabled` flag or the instance flow kill switch, both of which halt the run before this service is reached. -- [ ] Acting-user resolution from the run; no resolvable actor = step +- [x] Acting-user resolution from the run; no resolvable actor = step failure naming the missing actor (never a system-identity fallback). -- [ ] Templating through the notification dialect's placeholder evaluation +- [x] Templating through the notification dialect's placeholder evaluation against the flow item — reuse the dialect's evaluator, add none. -- [ ] Per-recipient/channel outcome collection (delivered / skipped-by- +- [x] Per-recipient/channel outcome collection (delivered / skipped-by- preference / skipped-by-kill-switch / rate-limited / failed) returned for the run log, bounded by the log's sampling rule. ## Nodes -- [ ] `SendNotificationNode` (`openregister.send-notification`) — recipients +- [x] `SendNotificationNode` (`openregister.send-notification`) — recipients (literal or item-field template), title + message templates; `validateConfig` refuses an empty message and an empty recipient config; `configForm()` per the flow-node-config-forms floor. -- [ ] `SendEmailNode` (`openregister.send-email`) — recipients, subject + +- [x] `SendEmailNode` (`openregister.send-email`) — recipients, subject + body templates; same validation and form obligations. -- [ ] `SendTalkMessageNode` (`openregister.send-talk-message`) — +- [x] `SendTalkMessageNode` (`openregister.send-talk-message`) — conversation token or item-field lookup, message template; "acting user not a participant" is a step failure, never an auto-join. -- [ ] All three: items returned unchanged; failures routed through the +- [x] All three: items returned unchanged; failures routed through the step's `onError` policy; registration in `FlowNodeRegistrationListener`. ## Tests -- [ ] Equivalence test: schema annotation vs flow node, same recipients and +- [x] Equivalence test: schema annotation vs flow node, same recipients and template, same object — identical deliveries through a recorded sender double. -- [ ] Guardrail tests, each with a positive control (the send that DOES go +- [x] Guardrail tests, each with a positive control (the send that DOES go out when the guard is off): flow kill switch (and declarative path unaffected by it), subsystem kill switches, shared rate-limit bucket (declarative fill blocks flow send), recipient bound, preference skip. -- [ ] Attribution tests: acting user carried onto each channel; missing +- [x] Attribution tests: acting user carried onto each channel; missing actor fails the step. -- [ ] Palette test: exactly these three messaging types; no +- [x] Palette test: exactly these three messaging types; no `openregister.send-webhook` (boundary with ADR-094). ## Documentation -- [ ] Feed the three nodes and the boundary statement ("notifications +- [x] Feed the three nodes and the boundary statement ("notifications notify; flows orchestrate; API calls via OpenConnector sources") into `flow-engine-docs`' steps catalogue — one sentence each here, the prose lives there. diff --git a/tests/Unit/Service/Flow/FlowMessagingEquivalenceTest.php b/tests/Unit/Service/Flow/FlowMessagingEquivalenceTest.php new file mode 100644 index 0000000000..a34c3dac22 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowMessagingEquivalenceTest.php @@ -0,0 +1,328 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowMessagingService; +use OCA\OpenRegister\Service\Flow\Oversight\KillSwitchCheck; +use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; +use OCA\OpenRegister\Service\Notification\EmailSender; +use OCA\OpenRegister\Service\Notification\NcNotificationSender; +use OCA\OpenRegister\Service\Notification\NotificationChannelPolicy; +use OCA\OpenRegister\Service\Notification\NotificationPreferenceService; +use OCA\OpenRegister\Service\Notification\NotificationRecipientResolver; +use OCA\OpenRegister\Service\Notification\NotificationTemplating; +use OCA\OpenRegister\Service\Notification\RateLimiter; +use OCA\OpenRegister\Service\Notification\TalkSender; +use OCP\Activity\IManager as IActivityManager; +use OCP\Http\Client\IClientService; +use OCP\IAppConfig; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IServerContainer; +use OCP\IUserManager; +use OCP\Mail\IMailer; +use OCP\Notification\IManager as INotificationManager; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Records every send handed to the nc-notification channel unit. + */ +class RecordingNcSender extends NcNotificationSender { + /** @var array> */ + public array $sends = []; + + public function send( + string $uid, + ObjectEntity $object, + string $subjectKey, + string $name, + string $subject, + string $message, + array $context, + string $originApp = 'openregister', + array $actions = [], + bool $webPushActive = false, + ): string { + $this->sends[] = [ + 'channel' => 'nc-notification', + 'uid' => $uid, + 'subject' => $subject, + 'message' => $message, + ]; + + return self::OUTCOME_DISPATCHED; + } + + public function enqueueWebPush( + array $recipients, + string $ruleId, + string $originApp, + string $subject, + string $message, + array $actions, + ObjectEntity $object, + ): void { + // Ride-alongs are not part of the delivery comparison. + } +}//end class + +/** + * Same send from both callers; independent kill switches. + */ +class FlowMessagingEquivalenceTest extends TestCase { + + /** + * App-config values behind both subsystems' switches. + * + * @var array + */ + private array $appValues = []; + + /** + * The one recorded sender both callers are wired onto. + */ + private RecordingNcSender $recorder; + + private IUserManager $userManager; + + private IGroupManager $groupManager; + + private IAppConfig $appConfig; + + private LoggerInterface $logger; + + protected function setUp(): void { + parent::setUp(); + $this->logger = $this->createMock(LoggerInterface::class); + + $userManager = $this->createMock(IUserManager::class); + $userManager->method('userExists')->willReturn(true); + $userManager->method('get')->willReturnCallback( + function (string $uid) { + $user = $this->createMock(\OCP\IUser::class); + $user->method('getUID')->willReturn($uid); + $user->method('isEnabled')->willReturn(true); + return $user; + } + ); + $this->userManager = $userManager; + $this->groupManager = $this->createMock(IGroupManager::class); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = ''): string => ($this->appValues[$key] ?? $default) + ); + $appConfig->method('getValueInt')->willReturnCallback( + fn (string $app, string $key, int $default = 0): int => (int)($this->appValues[$key] ?? $default) + ); + $appConfig->method('getValueBool')->willReturnCallback( + fn (string $app, string $key, bool $default = false): bool => (($this->appValues[$key] ?? null) === 'true') || $default + ); + $this->appConfig = $appConfig; + + $this->recorder = new RecordingNcSender( + notificationManager: $this->createMock(INotificationManager::class), + logger: $this->logger + ); + }//end setUp() + + /** + * The declarative dispatcher, wired onto the recorded sender. + * + * @return AnnotationNotificationDispatcher The dispatcher. + */ + private function makeDispatcher(): AnnotationNotificationDispatcher { + return new AnnotationNotificationDispatcher( + schemaMapper: $this->createMock(SchemaMapper::class), + notificationManager: $this->createMock(INotificationManager::class), + logger: $this->logger, + groupManager: $this->groupManager, + userManager: $this->userManager, + mailer: $this->createMock(IMailer::class), + activityManager: $this->createMock(IActivityManager::class), + httpClient: $this->createMock(IClientService::class), + serverContainer: $this->createMock(IServerContainer::class), + ncSender: $this->recorder, + recipientResolver: new NotificationRecipientResolver( + userManager: $this->userManager, + groupManager: $this->groupManager, + logger: $this->logger + ), + templating: new NotificationTemplating(logger: $this->logger) + ); + }//end makeDispatcher() + + /** + * The flow messaging service, wired onto the SAME recorded sender. + * + * @return FlowMessagingService The service. + */ + private function makeMessaging(): FlowMessagingService { + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($this->createMock(\OCP\ICache::class)); + $config = $this->createMock(IConfig::class); + $config->method('getUserValue')->willReturnArgument(3); + + return new FlowMessagingService( + channelPolicy: new NotificationChannelPolicy(appConfig: $this->appConfig, logger: $this->logger), + recipientResolver: new NotificationRecipientResolver( + userManager: $this->userManager, + groupManager: $this->groupManager, + logger: $this->logger + ), + templating: new NotificationTemplating(logger: $this->logger), + ncSender: $this->recorder, + emailSender: new EmailSender( + userManager: $this->userManager, + mailer: $this->createMock(IMailer::class), + logger: $this->logger + ), + talkSender: new TalkSender( + httpClient: $this->createMock(IClientService::class), + logger: $this->logger + ), + rateLimiter: new RateLimiter( + cacheFactory: $cacheFactory, + appConfig: $this->appConfig, + logger: $this->logger + ), + preferences: new NotificationPreferenceService( + config: $config, + schemaMapper: $this->createMock(SchemaMapper::class), + logger: $this->logger + ), + userManager: $this->userManager, + appConfig: $this->appConfig, + logger: $this->logger + ); + }//end makeMessaging() + + public function testSchemaAnnotationAndFlowNodeProduceTheSameSendThroughTheSameSender(): void { + $data = ['title' => 'Bezwaar 12', 'status' => 'moved']; + + // The DECLARATIVE path: a schema annotation firing on the object. + $schema = new Schema(); + $schema->setId(1); + $schema->setSlug('case'); + $schema->setConfiguration( + [ + 'x-openregister-notifications' => [ + 'case-moved' => [ + 'trigger' => ['type' => 'updated'], + 'channels' => ['nc-notification'], + 'recipients' => [ + [ + 'kind' => 'users', + 'users' => ['bob', 'carol'], + ], + ], + 'subject' => 'Case {{ title }} moved', + 'message' => 'Case {{ title }} is now {{ status }}.', + ], + ], + ] + ); + + $object = new ObjectEntity(); + $object->setUuid('uuid-1'); + $object->setObject($data); + + $this->makeDispatcher()->dispatchWithSchema(object: $object, trigger: 'updated', context: [], schema: $schema); + $declarative = $this->recorder->sends; + $this->recorder->sends = []; + + // The FLOW path: a send-notification node's config with the same + // recipients and templates, against the same object as an item. + $this->makeMessaging()->sendNotification( + config: [ + 'recipients' => ['bob', 'carol'], + 'title' => 'Case {{ title }} moved', + 'message' => 'Case {{ title }} is now {{ status }}.', + ], + items: [FlowItems::item(json: $data)], + context: ['runAs' => 'alice'], + stepName: 'openregister.send-notification' + ); + $flow = $this->recorder->sends; + + // Identical in channel, body and recipients — and both lists exist at + // all only because both callers went through the SAME sender instance. + $this->assertNotSame([], $declarative); + $this->assertSame($declarative, $flow); + }//end testSchemaAnnotationAndFlowNodeProduceTheSameSendThroughTheSameSender() + + public function testTheFlowKillSwitchDoesNotSilenceDeclarativeNotifications(): void { + // The instance FLOW kill switch is set: the flow side's own oversight + // check refuses every hop under this config… + $this->appValues[KillSwitchCheck::CONFIG_KEY] = 'true'; + $check = new KillSwitchCheck(appConfig: $this->appConfig); + $this->assertNotNull($check->veto(context: []), 'The flow kill switch must refuse flow hops under this config'); + + // …and the DECLARATIVE dispatch still delivers: killing flows never + // silences case-update notifications. + $schema = new Schema(); + $schema->setId(1); + $schema->setSlug('case'); + $schema->setConfiguration( + [ + 'x-openregister-notifications' => [ + 'case-moved' => [ + 'trigger' => ['type' => 'updated'], + 'channels' => ['nc-notification'], + 'recipients' => [ + [ + 'kind' => 'users', + 'users' => ['bob'], + ], + ], + 'subject' => 'moved', + ], + ], + ] + ); + $object = new ObjectEntity(); + $object->setUuid('uuid-1'); + $object->setObject(['title' => 'x']); + + $this->makeDispatcher()->dispatchWithSchema(object: $object, trigger: 'updated', context: [], schema: $schema); + + $this->assertCount(1, $this->recorder->sends); + $this->assertSame('bob', $this->recorder->sends[0]['uid']); + }//end testTheFlowKillSwitchDoesNotSilenceDeclarativeNotifications() +}//end class diff --git a/tests/Unit/Service/Flow/FlowMessagingServiceTest.php b/tests/Unit/Service/Flow/FlowMessagingServiceTest.php new file mode 100644 index 0000000000..3724712a1b --- /dev/null +++ b/tests/Unit/Service/Flow/FlowMessagingServiceTest.php @@ -0,0 +1,614 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowMessagingService; +use OCA\OpenRegister\Service\Flow\FlowStepReport; +use OCA\OpenRegister\Service\Notification\EmailSender; +use OCA\OpenRegister\Service\Notification\NcNotificationSender; +use OCA\OpenRegister\Service\Notification\NotificationChannelPolicy; +use OCA\OpenRegister\Service\Notification\NotificationPreferenceService; +use OCA\OpenRegister\Service\Notification\NotificationRecipientResolver; +use OCA\OpenRegister\Service\Notification\NotificationTemplating; +use OCA\OpenRegister\Service\Notification\RateLimiter; +use OCA\OpenRegister\Service\Notification\TalkSender; +use OCA\OpenRegister\Service\Notification\TalkSendException; +use OCP\BackgroundJob\IJobList; +use OCP\Http\Client\IClientService; +use OCP\IAppConfig; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Mail\IMailer; +use OCP\Mail\IMessage; +use OCP\Notification\IManager as INotificationManager; +use OCP\Notification\INotification; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * A tiny in-memory ICache for the rate limiter, so bucket state is real + * between calls without a cache backend. + */ +class MessagingTestCache implements ICache { + /** @var array */ + private array $data = []; + + public function get($key) { + return ($this->data[$key] ?? null); + } + + public function set($key, $value, $ttl = 0) { + $this->data[$key] = $value; + return true; + } + + public function hasKey($key) { + return isset($this->data[$key]); + } + + public function remove($key) { + unset($this->data[$key]); + return true; + } + + public function clear($prefix = '') { + $this->data = []; + return true; + } + + public static function isAvailable(): bool { + return true; + } +}//end class + +/** + * A TalkSender whose Talk-app boundary is replaced by a recorder, so the + * decision logic above it (attribution, kill switch, failure escalation) + * runs for real without a Talk installation. + */ +class RecordingTalkSender extends TalkSender { + /** @var array> */ + public array $posts = []; + + public ?TalkSendException $throwOnPost = null; + + protected function postViaTalkApp(string $token, string $message, string $actorUid): void { + if ($this->throwOnPost !== null) { + throw $this->throwOnPost; + } + + $this->posts[] = [ + 'token' => $token, + 'message' => $message, + 'actor' => $actorUid, + ]; + } +}//end class + +/** + * Guardrails, attribution and outcome reporting of the flow messaging bridge. + */ +class FlowMessagingServiceTest extends TestCase { + + private IAppConfig&MockObject $appConfig; + + private IUserManager&MockObject $userManager; + + private IGroupManager&MockObject $groupManager; + + private INotificationManager&MockObject $notificationManager; + + private IMailer&MockObject $mailer; + + private IJobList&MockObject $jobList; + + private IConfig&MockObject $config; + + private RecordingTalkSender $talkSender; + + private MessagingTestCache $cache; + + /** + * Mutable app-config values read through the IAppConfig mock. + * + * @var array + */ + private array $appValues = []; + + /** + * Mutable user-config values read through the IConfig mock (preferences). + * + * @var array + */ + private array $userValues = []; + + /** + * Users that exist, uid => enabled. + * + * @var array + */ + private array $users = [ + 'alice' => true, + 'bob' => true, + 'carol' => true, + ]; + + protected function setUp(): void { + parent::setUp(); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->appConfig->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = ''): string => ($this->appValues[$key] ?? $default) + ); + $this->appConfig->method('getValueInt')->willReturnCallback( + fn (string $app, string $key, int $default = 0): int => (int)($this->appValues[$key] ?? $default) + ); + + $this->userManager = $this->createMock(IUserManager::class); + $this->userManager->method('userExists')->willReturnCallback( + fn (string $uid): bool => isset($this->users[$uid]) + ); + $this->userManager->method('get')->willReturnCallback( + function (string $uid): ?IUser { + if (isset($this->users[$uid]) === false) { + return null; + } + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $user->method('isEnabled')->willReturn($this->users[$uid]); + $user->method('getEMailAddress')->willReturn($uid . '@example.org'); + $user->method('getDisplayName')->willReturn(ucfirst($uid)); + return $user; + } + ); + + $this->groupManager = $this->createMock(IGroupManager::class); + $this->notificationManager = $this->createMock(INotificationManager::class); + $this->mailer = $this->createMock(IMailer::class); + $this->jobList = $this->createMock(IJobList::class); + $this->config = $this->createMock(IConfig::class); + $this->config->method('getUserValue')->willReturnCallback( + fn (string $uid, string $app, string $key, string $default = ''): string => ($this->userValues[$uid . '|' . $key] ?? $default) + ); + + $this->cache = new MessagingTestCache(); + $this->talkSender = new RecordingTalkSender( + httpClient: $this->createMock(IClientService::class), + logger: $this->createMock(LoggerInterface::class), + config: null, + channelPolicy: $this->channelPolicy() + ); + }//end setUp() + + /** + * A real channel policy over the mutable app-config map. + * + * @return NotificationChannelPolicy The policy. + */ + private function channelPolicy(): NotificationChannelPolicy { + return new NotificationChannelPolicy( + appConfig: $this->appConfig, + logger: $this->createMock(LoggerInterface::class) + ); + }//end channelPolicy() + + /** + * The service under test, wired onto the REAL shared units. + * + * @return FlowMessagingService The service. + */ + private function makeService(): FlowMessagingService { + $logger = $this->createMock(LoggerInterface::class); + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($this->cache); + + return new FlowMessagingService( + channelPolicy: $this->channelPolicy(), + recipientResolver: new NotificationRecipientResolver( + userManager: $this->userManager, + groupManager: $this->groupManager, + logger: $logger + ), + templating: new NotificationTemplating(logger: $logger), + ncSender: new NcNotificationSender( + notificationManager: $this->notificationManager, + logger: $logger, + userManager: $this->userManager, + jobList: $this->jobList, + channelPolicy: $this->channelPolicy() + ), + emailSender: new EmailSender( + userManager: $this->userManager, + mailer: $this->mailer, + logger: $logger, + channelPolicy: $this->channelPolicy() + ), + talkSender: $this->talkSender, + rateLimiter: $this->rateLimiter(), + preferences: new NotificationPreferenceService( + config: $this->config, + schemaMapper: $this->createMock(SchemaMapper::class), + logger: $logger + ), + userManager: $this->userManager, + appConfig: $this->appConfig, + logger: $logger + ); + }//end makeService() + + /** + * A real rate limiter over the shared in-memory cache. + * + * @return RateLimiter The limiter. + */ + private function rateLimiter(): RateLimiter { + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($this->cache); + + return new RateLimiter( + cacheFactory: $cacheFactory, + appConfig: $this->appConfig, + logger: $this->createMock(LoggerInterface::class) + ); + }//end rateLimiter() + + /** + * A run context with an acting user and a step report handle. + * + * @param string|null $runAs The acting user, or null for none. + * + * @return array The context. + */ + private function contextFor(?string $runAs = 'alice'): array { + $context = [FlowStepReport::CONTEXT_KEY => new FlowStepReport()]; + if ($runAs !== null) { + $context['runAs'] = $runAs; + } + + return $context; + }//end contextFor() + + /** + * One item with the given json. + * + * @param array $json The item json. + * + * @return array The item list. + */ + private function itemsOf(array $json = ['name' => 'Case 7']): array { + return [FlowItems::item(json: $json)]; + }//end itemsOf() + + /** + * A mock INotification whose fluent setters chain. + * + * @return INotification&MockObject The mock. + */ + private function fluentNotification(): INotification&MockObject { + $notification = $this->createMock(INotification::class); + $notification->method('setApp')->willReturnSelf(); + $notification->method('setUser')->willReturnSelf(); + $notification->method('setDateTime')->willReturnSelf(); + $notification->method('setObject')->willReturnSelf(); + $notification->method('setSubject')->willReturnSelf(); + + return $notification; + }//end fluentNotification() + + // ---- Attribution ------------------------------------------------------- + + public function testMissingActingUserFailsTheStepAndSendsNothing(): void { + $this->notificationManager->expects($this->never())->method('notify'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/runAs/'); + + $this->makeService()->sendNotification( + config: ['recipients' => ['bob'], 'message' => 'hi'], + items: $this->itemsOf(), + context: $this->contextFor(runAs: null), + stepName: 'openregister.send-notification' + ); + }//end testMissingActingUserFailsTheStepAndSendsNothing() + + public function testDisabledActingUserFailsTheStepAndSendsNothing(): void { + $this->users['ghosted'] = false; + $this->notificationManager->expects($this->never())->method('notify'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/disabled/'); + + $this->makeService()->sendNotification( + config: ['recipients' => ['bob'], 'message' => 'hi'], + items: $this->itemsOf(), + context: $this->contextFor(runAs: 'ghosted'), + stepName: 'openregister.send-notification' + ); + }//end testDisabledActingUserFailsTheStepAndSendsNothing() + + public function testTalkPostIsAttributedToTheActingUser(): void { + $report = $this->makeService()->sendTalkMessage( + config: ['conversation' => 'room-token', 'message' => 'Case {{ name }} moved'], + items: $this->itemsOf(json: ['name' => 'Case 7']), + context: $this->contextFor(runAs: 'alice'), + stepName: 'openregister.send-talk-message' + ); + + $this->assertCount(1, $this->talkSender->posts); + $this->assertSame('alice', $this->talkSender->posts[0]['actor']); + $this->assertSame('room-token', $this->talkSender->posts[0]['token']); + $this->assertSame('Case Case 7 moved', $this->talkSender->posts[0]['message']); + $this->assertSame('alice', $report['actor']); + $this->assertSame(1, $report['delivered']['count']); + }//end testTalkPostIsAttributedToTheActingUser() + + public function testTalkNonParticipantIsAStepFailureNotAnAutoJoin(): void { + $this->talkSender->throwOnPost = new TalkSendException( + 'User "alice" is not a participant of Talk conversation "room-token"; the message was not sent and the user was not auto-joined.' + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not a participant/'); + + $this->makeService()->sendTalkMessage( + config: ['conversation' => 'room-token', 'message' => 'hello'], + items: $this->itemsOf(), + context: $this->contextFor(runAs: 'alice'), + stepName: 'openregister.send-talk-message' + ); + }//end testTalkNonParticipantIsAStepFailureNotAnAutoJoin() + + // ---- Kill switches ----------------------------------------------------- + + public function testChannelKillSwitchSilencesFlowSendsAndRecordsTheSkip(): void { + // POSITIVE CONTROL first: with the switch off, the send goes out + // through the real channel sender. + $this->notificationManager->method('createNotification')->willReturn($this->fluentNotification()); + $this->notificationManager->expects($this->exactly(1))->method('notify'); + + $service = $this->makeService(); + $context = $this->contextFor(); + $service->sendNotification( + config: ['recipients' => ['bob'], 'message' => 'hi'], + items: $this->itemsOf(), + context: $context, + stepName: 'openregister.send-notification' + ); + + // Now the guard: switch thrown, nothing may leave, and the outcome is + // a SKIP in the report — not a failure, not silence. + $this->appValues['notification_channel_nc_notification_enabled'] = 'false'; + $report = $service->sendNotification( + config: ['recipients' => ['bob'], 'message' => 'hi'], + items: $this->itemsOf(), + context: $context, + stepName: 'openregister.send-notification' + ); + + $this->assertSame(1, $report['skippedByKillSwitch']['count']); + $this->assertSame(['bob'], $report['skippedByKillSwitch']['sample']); + $this->assertSame(0, $report['delivered']['count']); + }//end testChannelKillSwitchSilencesFlowSendsAndRecordsTheSkip() + + public function testEmailKillSwitchStopsTheMailer(): void { + $this->mailer->expects($this->never())->method('send'); + $this->appValues['notification_channel_email_enabled'] = 'false'; + + $report = $this->makeService()->sendEmail( + config: ['recipients' => ['bob'], 'subject' => 's', 'body' => 'b'], + items: $this->itemsOf(), + context: $this->contextFor(), + stepName: 'openregister.send-email' + ); + + $this->assertSame(1, $report['skippedByKillSwitch']['count']); + }//end testEmailKillSwitchStopsTheMailer() + + // ---- Preferences ------------------------------------------------------- + + public function testRecipientPreferenceSkipsTheChannelAndIsRecordedAsSkipNotFailure(): void { + // bob turned flow sends off; carol did not. One mail leaves, for + // carol — the positive control inside the same dispatch. + $this->userValues['bob|notification_pref/flow/send'] = json_encode(['enabled' => false]); + $message = $this->createMock(IMessage::class); + $message->method('setTo')->willReturnSelf(); + $message->method('setSubject')->willReturnSelf(); + $message->method('setPlainBody')->willReturnSelf(); + $this->mailer->method('createMessage')->willReturn($message); + $this->mailer->expects($this->exactly(1))->method('send'); + + $report = $this->makeService()->sendEmail( + config: ['recipients' => ['bob', 'carol'], 'subject' => 's', 'body' => 'b'], + items: $this->itemsOf(), + context: $this->contextFor(), + stepName: 'openregister.send-email' + ); + + $this->assertSame(1, $report['skippedByPreference']['count']); + $this->assertSame(['bob'], $report['skippedByPreference']['sample']); + $this->assertSame(1, $report['delivered']['count']); + $this->assertSame(['carol'], $report['delivered']['sample']); + $this->assertSame(0, $report['failed']['count']); + }//end testRecipientPreferenceSkipsTheChannelAndIsRecordedAsSkipNotFailure() + + // ---- Recipient bound --------------------------------------------------- + + public function testExplodingRecipientListIsRefusedBeforeAnythingSends(): void { + // A group that expands to 30 members against the default bound of 25. + $members = []; + for ($i = 0; $i < 30; $i++) { + $uid = sprintf('user%02d', $i); + $this->users[$uid] = true; + $member = $this->createMock(IUser::class); + $member->method('getUID')->willReturn($uid); + $members[] = $member; + } + + $group = $this->createMock(IGroup::class); + $group->method('getUsers')->willReturn($members); + $this->groupManager->method('groupExists')->willReturn(true); + $this->groupManager->method('get')->willReturn($group); + + $this->notificationManager->expects($this->never())->method('notify'); + + try { + $this->makeService()->sendNotification( + config: ['recipients' => ['everyone'], 'message' => 'hi'], + items: $this->itemsOf(), + context: $this->contextFor(), + stepName: 'openregister.send-notification' + ); + $this->fail('The exploding recipient list must be refused.'); + } catch (RuntimeException $e) { + // The failure names the resolved count and the bound. + $this->assertStringContainsString('30', $e->getMessage()); + $this->assertStringContainsString('25', $e->getMessage()); + } + }//end testExplodingRecipientListIsRefusedBeforeAnythingSends() + + public function testRecipientBoundIsAppConfigRaisable(): void { + $this->appValues[FlowMessagingService::CONFIG_RECIPIENT_BOUND] = '2'; + $this->notificationManager->method('createNotification')->willReturn($this->fluentNotification()); + $this->notificationManager->expects($this->exactly(2))->method('notify'); + + // Two recipients under a bound of two: sends proceed. + $this->makeService()->sendNotification( + config: ['recipients' => ['bob', 'carol'], 'message' => 'hi'], + items: $this->itemsOf(), + context: $this->contextFor(), + stepName: 'openregister.send-notification' + ); + }//end testRecipientBoundIsAppConfigRaisable() + + // ---- Rate limiting ----------------------------------------------------- + + public function testDeclarativeFillBlocksTheFlowSendTheBudgetIsShared(): void { + // The DECLARATIVE subsystem fills bob's shared per-recipient budget + // (default bucket 10) under its own rule ids… + $limiter = $this->rateLimiter(); + for ($i = 0; $i < 10; $i++) { + $this->assertTrue($limiter->tryConsume('schema-rule-' . $i, 'bob')); + } + + // …and the flow send to bob in the same window is rate-limited, while + // carol — whose budget is untouched — receives: the positive control. + $this->notificationManager->method('createNotification')->willReturn($this->fluentNotification()); + $this->notificationManager->expects($this->exactly(1))->method('notify'); + + $report = $this->makeService()->sendNotification( + config: ['recipients' => ['bob', 'carol'], 'message' => 'hi'], + items: $this->itemsOf(), + context: $this->contextFor(), + stepName: 'openregister.send-notification' + ); + + $this->assertSame(1, $report['rateLimited']['count']); + $this->assertSame(['bob'], $report['rateLimited']['sample']); + $this->assertSame(['carol'], $report['delivered']['sample']); + }//end testDeclarativeFillBlocksTheFlowSendTheBudgetIsShared() + + // ---- Failures and the run log ----------------------------------------- + + public function testSendFailureIsAStepFailureAndTheReportNamesIt(): void { + $this->mailer->method('createMessage')->willThrowException(new RuntimeException('SMTP down')); + + $context = $this->contextFor(); + try { + $this->makeService()->sendEmail( + config: ['recipients' => ['bob'], 'subject' => 's', 'body' => 'b'], + items: $this->itemsOf(), + context: $context, + stepName: 'openregister.send-email' + ); + $this->fail('A failed handoff must fail the step.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('email', $e->getMessage()); + } + + // The report reached the run-log handle BEFORE the throw, so a failed + // step still explains itself. + $handle = $context[FlowStepReport::CONTEXT_KEY]; + $detail = $handle->take(); + $this->assertSame(1, $detail['messaging']['failed']['count']); + $this->assertSame(['bob'], $detail['messaging']['failed']['sample']); + }//end testSendFailureIsAStepFailureAndTheReportNamesIt() + + public function testUnknownRecipientsAreReportedNotSilentlyDropped(): void { + $this->notificationManager->method('createNotification')->willReturn($this->fluentNotification()); + + $context = $this->contextFor(); + $report = $this->makeService()->sendNotification( + config: ['recipients' => ['bob', 'nobody-here'], 'message' => 'hi'], + items: $this->itemsOf(), + context: $context, + stepName: 'openregister.send-notification' + ); + + $this->assertSame(1, $report['unknownRecipients']['count']); + $this->assertSame(['nobody-here'], $report['unknownRecipients']['sample']); + }//end testUnknownRecipientsAreReportedNotSilentlyDropped() + + public function testTemplateRecipientResolvesAFieldOnTheItem(): void { + $this->notificationManager->method('createNotification')->willReturn($this->fluentNotification()); + $this->notificationManager->expects($this->exactly(1))->method('notify'); + + $report = $this->makeService()->sendNotification( + config: ['recipients' => ['{{ item.assignee }}'], 'message' => 'hi'], + items: $this->itemsOf(json: ['assignee' => 'carol']), + context: $this->contextFor(), + stepName: 'openregister.send-notification' + ); + + $this->assertSame(['carol'], $report['delivered']['sample']); + }//end testTemplateRecipientResolvesAFieldOnTheItem() + + public function testWebPushRidesAlongWithADeliveredNotification(): void { + $this->notificationManager->method('createNotification')->willReturn($this->fluentNotification()); + // One notification, one ride-along job — with no flow-side + // configuration asking for it. + $this->jobList->expects($this->exactly(1))->method('add'); + + $this->makeService()->sendNotification( + config: ['recipients' => ['bob'], 'message' => 'hi'], + items: $this->itemsOf(), + context: $this->contextFor(), + stepName: 'openregister.send-notification' + ); + }//end testWebPushRidesAlongWithADeliveredNotification() +}//end class diff --git a/tests/Unit/Service/Flow/SendMessagingNodesTest.php b/tests/Unit/Service/Flow/SendMessagingNodesTest.php new file mode 100644 index 0000000000..e836c556e8 --- /dev/null +++ b/tests/Unit/Service/Flow/SendMessagingNodesTest.php @@ -0,0 +1,201 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Listener\FlowNodeRegistrationListener; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowMessagingService; +use OCA\OpenRegister\Service\Flow\Nodes\SendEmailNode; +use OCA\OpenRegister\Service\Flow\Nodes\SendNotificationNode; +use OCA\OpenRegister\Service\Flow\Nodes\SendTalkMessageNode; +use OCP\IL10N; +use OCP\IURLGenerator; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionNamedType; +use UnexpectedValueException; + +/** + * Node-level behaviour of the messaging palette. + */ +class SendMessagingNodesTest extends TestCase { + + private FlowMessagingService&MockObject $messaging; + + private IL10N&MockObject $l10n; + + private IURLGenerator&MockObject $urls; + + protected function setUp(): void { + parent::setUp(); + $this->messaging = $this->createMock(FlowMessagingService::class); + $this->l10n = $this->createMock(IL10N::class); + $this->l10n->method('t')->willReturnCallback( + static fn (string $text, array $params = []): string => vsprintf(str_replace(['{example}', '{given}'], '%s', $text), $params) + ); + $this->urls = $this->createMock(IURLGenerator::class); + }//end setUp() + + /** + * All three nodes, keyed by their type id. + * + * @return array The nodes. + */ + private function nodes(): array { + return [ + SendNotificationNode::TYPE => new SendNotificationNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls), + SendEmailNode::TYPE => new SendEmailNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls), + SendTalkMessageNode::TYPE => new SendTalkMessageNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls), + ]; + }//end nodes() + + public function testItemsFlowThroughUnchanged(): void { + $items = [ + FlowItems::item(json: ['name' => 'a']), + FlowItems::item(json: ['name' => 'b']), + FlowItems::item(json: ['name' => 'c']), + ]; + + $config = [ + SendNotificationNode::TYPE => ['recipients' => ['bob'], 'message' => 'm'], + SendEmailNode::TYPE => ['recipients' => ['bob'], 'subject' => 's', 'body' => 'b'], + SendTalkMessageNode::TYPE => ['conversation' => 't', 'message' => 'm'], + ]; + + foreach ($this->nodes() as $type => $node) { + $out = $node->execute(items: $items, config: $config[$type], context: ['runAs' => 'alice']); + // Sending is a side effect, not a transformation: the three items + // come back IDENTICAL for downstream steps. + $this->assertSame($items, $out, $type . ' must pass items through unchanged'); + } + }//end testItemsFlowThroughUnchanged() + + public function testValidateConfigRefusesAnEmptyMessage(): void { + $cases = [ + [new SendNotificationNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls), ['recipients' => ['bob']]], + [new SendEmailNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls), ['recipients' => ['bob'], 'subject' => 's']], + [new SendTalkMessageNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls), ['conversation' => 'tok']], + ]; + + foreach ($cases as [$node, $config]) { + try { + $node->validateConfig(config: $config); + $this->fail(get_class($node) . ' must refuse an empty message'); + } catch (UnexpectedValueException $e) { + $this->assertNotSame('', $e->getMessage()); + } + } + }//end testValidateConfigRefusesAnEmptyMessage() + + public function testValidateConfigRefusesAnEmptyRecipientConfig(): void { + $notification = new SendNotificationNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls); + $email = new SendEmailNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls); + $talk = new SendTalkMessageNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls); + + $refused = 0; + foreach ([ + [$notification, ['message' => 'm', 'recipients' => []]], + [$notification, ['message' => 'm', 'recipients' => [' ']]], + [$email, ['body' => 'b', 'recipients' => []]], + [$talk, ['message' => 'm', 'conversation' => ' ']], + ] as [$node, $config]) { + try { + $node->validateConfig(config: $config); + } catch (UnexpectedValueException $e) { + $refused++; + continue; + } + + $this->fail(get_class($node) . ' must refuse an empty recipient/conversation config'); + } + + $this->assertSame(4, $refused, 'Every empty-recipient shape must be refused.'); + }//end testValidateConfigRefusesAnEmptyRecipientConfig() + + public function testValidateConfigAcceptsACompleteConfig(): void { + foreach ([ + [new SendNotificationNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls), ['recipients' => ['bob'], 'title' => 't', 'message' => 'm']], + [new SendEmailNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls), ['recipients' => '{{ assignee }}', 'subject' => 's', 'body' => 'b']], + [new SendTalkMessageNode(messaging: $this->messaging, l10n: $this->l10n, urls: $this->urls), ['conversation' => '{{ token }}', 'message' => 'm']], + ] as [$node, $config]) { + $node->validateConfig(config: $config); + } + + $this->addToAssertionCount(3); + }//end testValidateConfigAcceptsACompleteConfig() + + public function testEveryFormFieldWritesADeclaredConfigKey(): void { + // The flow-node-config-forms floor: a form field over a key the node + // ignores looks like it works and changes nothing. + foreach ($this->nodes() as $type => $node) { + $keys = $node->configKeys(); + $formKeys = array_map(static fn (array $field): string => (string)$field['key'], $node->configForm()); + $this->assertNotSame([], $formKeys, $type . ' must declare a form'); + foreach ($formKeys as $key) { + $this->assertContains($key, $keys, $type . ' form field "' . $key . '" must be a declared config key'); + } + } + }//end testEveryFormFieldWritesADeclaredConfigKey() + + public function testThePaletteHasExactlyTheseThreeMessagingTypesAndNoWebhook(): void { + // The three ids, from the nodes themselves. + $this->assertSame( + ['openregister.send-email', 'openregister.send-notification', 'openregister.send-talk-message'], + array_values(array_intersect( + ['openregister.send-email', 'openregister.send-notification', 'openregister.send-talk-message'], + array_keys($this->nodes()) + )) + ); + + // The registration listener constructor carries all three node types, + // which is the wiring `handle()` registers. + $ctor = (new ReflectionClass(FlowNodeRegistrationListener::class))->getConstructor(); + $paramTypes = []; + foreach ($ctor->getParameters() as $param) { + $type = $param->getType(); + if ($type instanceof ReflectionNamedType) { + $paramTypes[] = $type->getName(); + } + } + + $this->assertContains(SendNotificationNode::class, $paramTypes); + $this->assertContains(SendEmailNode::class, $paramTypes); + $this->assertContains(SendTalkMessageNode::class, $paramTypes); + + // The boundary with ADR-094: outbound HTTP stays with OpenConnector. + // No send-webhook node exists — not in the built-ins directory, and no + // registered constructor parameter carries one. + $nodesDir = dirname((new ReflectionClass(SendEmailNode::class))->getFileName()); + $this->assertSame([], glob($nodesDir . '/*Webhook*'), 'No webhook node may exist in the built-in palette'); + foreach ($paramTypes as $paramType) { + $this->assertStringNotContainsString('Webhook', $paramType); + } + + // Nor may activity or web-push be step types: activity is an audit + // surface, web-push rides along with send-notification. + $this->assertSame([], glob($nodesDir . '/*Activity*')); + $this->assertSame([], glob($nodesDir . '/*WebPush*')); + }//end testThePaletteHasExactlyTheseThreeMessagingTypesAndNoWebhook() +}//end class diff --git a/tests/Unit/Service/Notification/RateLimiterTest.php b/tests/Unit/Service/Notification/RateLimiterTest.php index 95ae856f1b..c543137854 100644 --- a/tests/Unit/Service/Notification/RateLimiterTest.php +++ b/tests/Unit/Service/Notification/RateLimiterTest.php @@ -159,6 +159,36 @@ public function testDropEmitsInfoNotWarning(): void { $limiter->tryConsume('rule', 'alice', $override); // dropped } + public function testTheSharedPerRecipientBudgetIsCallerAgnostic(): void { + // Ten different rules — the declarative subsystem's shape — each + // consume once for alice. Every per-rule bucket is nearly full, but + // the SHARED per-recipient bucket (default size 10) is now empty… + $this->appConfigDefaults(); + $limiter = $this->makeLimiter(); + for ($i = 0; $i < 10; $i++) { + $this->assertTrue($limiter->tryConsume('rule-' . $i, 'alice')); + } + + // …so an ELEVENTH caller — a flow send, under its own rule id whose + // per-rule bucket is untouched — is refused by the shared budget. + // The bucket key is the recipient, not the caller: one budget for + // "how much this instance messages this person". + $this->assertFalse($limiter->tryConsume('openregister.send-notification', 'alice')); + + // A different recipient's budget is unaffected: the positive control. + $this->assertTrue($limiter->tryConsume('openregister.send-notification', 'bob')); + } + + public function testBroadcastPseudoRecipientsStayOutsideTheSharedBudget(): void { + // `__webhook__` / `__talk__` keys are channels, not people; filling + // many rules' broadcast buckets must not starve one another. + $this->appConfigDefaults(); + $limiter = $this->makeLimiter(); + for ($i = 0; $i < 15; $i++) { + $this->assertTrue($limiter->tryConsume('rule-' . $i, '__webhook__')); + } + } + private function appConfigDefaults(): void { $this->appConfig->method('getValueString') ->willReturnCallback(static fn (string $app, string $key, string $default): string => $default); From b42da683eafd414de5fce873a4dc7aff37c4ccda Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 09:54:58 +0200 Subject: [PATCH 23/68] chore(deps): nextcloud-vue 2.28.0, and an npm override that resolves (#3263) 2.28.0 carries the canvas run animation (nextcloud-vue#896) and the subject-scoped runs widget (nextcloud-vue#898), the two surfaces the flow-runs-subject-scope read (#3260) exists for. The bump could not be installed at all: #3232 rewrote the overrides as $-references and `npm install` refuses `$postcss` with "Unable to resolve reference". The other references resolve; this one does not, so it is the one direct spec again, matching the devDependency. Co-authored-by: Ruben van der Linde --- package-lock.json | 87 ++++++++++++++++++++++++++++++++++++++--------- package.json | 4 +-- 2 files changed, 72 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7c1324590..e07163bb3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "EUPL-1.2", "dependencies": { "@codemirror/lang-json": "^6.0.1", - "@conduction/nextcloud-vue": "^2.24.3", + "@conduction/nextcloud-vue": "^2.28.0", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", @@ -2289,9 +2289,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.27.2.tgz", - "integrity": "sha512-FhDF3FvM+ee0Jx7z70Lki7o7Mm4DeX/GsOqHo33hOcdylzXR63UXLRbxKMedodEiIqgOFUAI6GTox735iyL8Dw==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.28.0.tgz", + "integrity": "sha512-y/THRSpixB/KlV6JvKZRM5CGHkCNOZLjcbL1B/Soky6dAnMH5LeNGioiicj6DhUpebK1oALXSVdZP608JUbk6w==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", @@ -2710,6 +2710,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2726,6 +2727,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2742,6 +2744,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2758,6 +2761,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2774,6 +2778,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2790,6 +2795,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2806,6 +2812,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2822,6 +2829,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2838,6 +2846,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2854,6 +2863,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2870,6 +2880,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2886,6 +2897,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2902,6 +2914,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2918,6 +2931,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2934,6 +2948,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2950,6 +2965,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2966,6 +2982,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2982,6 +2999,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2998,6 +3016,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3014,6 +3033,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3030,6 +3050,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3046,6 +3067,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3062,6 +3084,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3078,6 +3101,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3094,6 +3118,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3110,6 +3135,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4870,6 +4896,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6167,6 +6194,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6180,6 +6208,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6193,6 +6222,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6206,6 +6236,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6219,6 +6250,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6232,6 +6264,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6245,6 +6278,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6258,6 +6292,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6271,6 +6306,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6284,6 +6320,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6297,6 +6334,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6310,6 +6348,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6323,6 +6362,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6336,6 +6376,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6349,6 +6390,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6362,6 +6404,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6375,6 +6418,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6388,6 +6432,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6401,6 +6446,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6414,6 +6460,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6427,6 +6474,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6440,6 +6488,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6453,6 +6502,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6466,6 +6516,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6479,6 +6530,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7072,7 +7124,7 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", @@ -7093,7 +7145,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7695,7 +7747,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", @@ -7720,7 +7772,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.68.0", @@ -7742,7 +7794,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.68.0", @@ -7760,7 +7812,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7777,7 +7829,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7791,7 +7843,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.68.0", @@ -7819,7 +7871,7 @@ "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.68.0", @@ -7837,7 +7889,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -7850,7 +7902,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7945,7 +7997,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13316,6 +13368,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -23305,7 +23358,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18.12" diff --git a/package.json b/package.json index 90405d5133..f0b0fa9162 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ }, "dependencies": { "@codemirror/lang-json": "^6.0.1", - "@conduction/nextcloud-vue": "^2.24.3", + "@conduction/nextcloud-vue": "^2.28.0", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", @@ -141,7 +141,7 @@ "@nextcloud/vue": "$@nextcloud/vue", "@nextcloud/axios": "$@nextcloud/axios", "stylelint": "$stylelint", - "postcss": "$postcss", + "postcss": "^8.4.31", "json5": "^2.2.3", "@babel/traverse": "$@babel/traverse", "babel-jest": "$babel-jest", From 40bab5639c4bbbdfa4225a3bbd31627d7613b8ba Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 11:54:01 +0200 Subject: [PATCH 24/68] fix(controllers): name the register before the schema Every object sub-resource endpoint answered 404 "Object not found", in every app on the instance: notes, tags, relations, emails, polls, talk, bookmarks, deck, calendar, tasks, time-tracker and the rest. The object itself resolved fine at /objects/{register}/{schema}/{id}, and the identical resolution chain succeeded from occ, which is what made this hard to see. Under HTTP it threw `Schema slug "case" is not carried by register "buildiq"` on a request that named dossiq. ObjectService::setSchema() scopes its slug lookup to whatever register is CURRENTLY set, and the service is reused across many operations in one process, so setting the schema first resolves the slug against a register left behind by an unrelated earlier call. Buildiq happens to own a `case` schema too; the global lookup reached it first. setRegister() already re-resolves a pending schema ref for exactly this reason, and its own comment names this helper as the shape that inverted the two. That repair cannot help here, because the throw happens inside setSchema() before setRegister() is ever reached. 25 controllers carried the inverted order. FilesController did not, and its docblock already described this bug, so the rule was known and simply never applied to the others. Worth naming what this cost: every one of those surfaces renders an EMPTY STATE, not an error. A case with notes said "No notes yet". The page did not report that it could not ask, so the failure was indistinguishable from the data genuinely being absent. The regression guard is a static scan over every controller rather than a behavioural test. Reproducing the runtime symptom needs a second register that happens to share a slug AND a prior operation that left its register set, which is a state no unit test would reach naturally and one a live instance reaches constantly. Verified the test fails when the order is put back. Verified on the dev instance: notes, tags, relations, emails, files and audit-trails all return 200 where they returned 404, including pipelinq, an app untouched by this change. 3227 controller tests pass. --- lib/Controller/ActivityLinksController.php | 2 +- lib/Controller/AnalyticsLinksController.php | 2 +- lib/Controller/BookmarkLinksController.php | 2 +- lib/Controller/CalendarEventsController.php | 2 +- lib/Controller/CollectiveLinksController.php | 2 +- lib/Controller/ContactsController.php | 2 +- lib/Controller/CospendLinksController.php | 2 +- lib/Controller/DeckController.php | 2 +- lib/Controller/DeckLinksController.php | 2 +- lib/Controller/EmailLinksController.php | 2 +- lib/Controller/EmailsController.php | 2 +- lib/Controller/FlowLinksController.php | 2 +- lib/Controller/FormLinksController.php | 2 +- lib/Controller/MapLinksController.php | 2 +- lib/Controller/NotesController.php | 9 +- lib/Controller/ObjectsController.php | 2 +- lib/Controller/OpenProjectLinksController.php | 2 +- lib/Controller/PhotoLinksController.php | 2 +- lib/Controller/PollLinksController.php | 2 +- lib/Controller/RelationsController.php | 2 +- lib/Controller/ShareLinksController.php | 2 +- lib/Controller/TagsController.php | 6 +- lib/Controller/TalkLinksController.php | 2 +- lib/Controller/TasksController.php | 2 +- lib/Controller/TimeTrackerLinksController.php | 2 +- lib/Controller/XwikiLinksController.php | 2 +- .../RegisterBeforeSchemaOrderTest.php | 92 +++++++++++++++++++ 27 files changed, 127 insertions(+), 28 deletions(-) create mode 100644 tests/Unit/Controller/RegisterBeforeSchemaOrderTest.php diff --git a/lib/Controller/ActivityLinksController.php b/lib/Controller/ActivityLinksController.php index 74a0099be3..09419dc77a 100644 --- a/lib/Controller/ActivityLinksController.php +++ b/lib/Controller/ActivityLinksController.php @@ -248,8 +248,8 @@ private function nullableInt(string $name): ?int { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/AnalyticsLinksController.php b/lib/Controller/AnalyticsLinksController.php index ec212a7427..a96572ed05 100644 --- a/lib/Controller/AnalyticsLinksController.php +++ b/lib/Controller/AnalyticsLinksController.php @@ -305,8 +305,8 @@ public function available(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/BookmarkLinksController.php b/lib/Controller/BookmarkLinksController.php index 7a32b31501..0c1b5962f9 100644 --- a/lib/Controller/BookmarkLinksController.php +++ b/lib/Controller/BookmarkLinksController.php @@ -320,8 +320,8 @@ static function (string $tag): bool { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/CalendarEventsController.php b/lib/Controller/CalendarEventsController.php index a7c009ff52..cd424290e5 100644 --- a/lib/Controller/CalendarEventsController.php +++ b/lib/Controller/CalendarEventsController.php @@ -411,8 +411,8 @@ private function validateObject( string $schema, string $id, ): ?\OCA\OpenRegister\Db\ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/CollectiveLinksController.php b/lib/Controller/CollectiveLinksController.php index 0e34f2feaf..c6ecf87b8f 100644 --- a/lib/Controller/CollectiveLinksController.php +++ b/lib/Controller/CollectiveLinksController.php @@ -327,8 +327,8 @@ public function collectives(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/ContactsController.php b/lib/Controller/ContactsController.php index 661921fa43..8663cb702f 100644 --- a/lib/Controller/ContactsController.php +++ b/lib/Controller/ContactsController.php @@ -462,8 +462,8 @@ private function validateObject( string $schema, string $id, ): ?\OCA\OpenRegister\Db\ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/CospendLinksController.php b/lib/Controller/CospendLinksController.php index aac3bd509a..176c9dd2a7 100644 --- a/lib/Controller/CospendLinksController.php +++ b/lib/Controller/CospendLinksController.php @@ -329,8 +329,8 @@ public function available(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/DeckController.php b/lib/Controller/DeckController.php index 76bf8ebc57..63de86f097 100644 --- a/lib/Controller/DeckController.php +++ b/lib/Controller/DeckController.php @@ -219,8 +219,8 @@ private function validateObject( string $schema, string $id, ): ?\OCA\OpenRegister\Db\ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/DeckLinksController.php b/lib/Controller/DeckLinksController.php index 128fe8044d..dd29d55029 100644 --- a/lib/Controller/DeckLinksController.php +++ b/lib/Controller/DeckLinksController.php @@ -407,8 +407,8 @@ public function setDefault(string $schema): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/EmailLinksController.php b/lib/Controller/EmailLinksController.php index 58de7edd10..f803490dcc 100644 --- a/lib/Controller/EmailLinksController.php +++ b/lib/Controller/EmailLinksController.php @@ -322,8 +322,8 @@ public function messages(string $accountId): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/EmailsController.php b/lib/Controller/EmailsController.php index a13bc4459c..bef379557b 100644 --- a/lib/Controller/EmailsController.php +++ b/lib/Controller/EmailsController.php @@ -321,8 +321,8 @@ private function validateObject( string $schema, string $id, ): ?\OCA\OpenRegister\Db\ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/FlowLinksController.php b/lib/Controller/FlowLinksController.php index e71f3db46d..b5450f5283 100644 --- a/lib/Controller/FlowLinksController.php +++ b/lib/Controller/FlowLinksController.php @@ -261,8 +261,8 @@ public function available(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/FormLinksController.php b/lib/Controller/FormLinksController.php index 7b2c42fcb9..e1dd8a11b2 100644 --- a/lib/Controller/FormLinksController.php +++ b/lib/Controller/FormLinksController.php @@ -424,8 +424,8 @@ private function validateObject( string $schema, string $id, ): ?\OCA\OpenRegister\Db\ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/MapLinksController.php b/lib/Controller/MapLinksController.php index d1298ae7ba..a2368f6ff7 100644 --- a/lib/Controller/MapLinksController.php +++ b/lib/Controller/MapLinksController.php @@ -336,8 +336,8 @@ public function available(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/NotesController.php b/lib/Controller/NotesController.php index c2610a07cf..6d79b8680d 100644 --- a/lib/Controller/NotesController.php +++ b/lib/Controller/NotesController.php @@ -279,8 +279,15 @@ private function validateObject( string $schema, string $id, ): ?\OCA\OpenRegister\Db\ObjectEntity { - $this->objectService->setSchema($schema); + // REGISTER FIRST. `setSchema()` scopes its slug lookup to whatever + // register is currently set, and ObjectService is reused across many + // operations in one process — so setting the schema first resolves it + // against a register LEFT BEHIND by an unrelated call. Measured here: + // `/objects/dossiq/case/{id}/notes` threw `Schema slug "case" is not + // carried by register "buildiq"`, an app this request never mentioned. + // Naming the register first makes the boundary the caller's own. $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index 0eac9b04db..39eee258a2 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -3911,8 +3911,8 @@ public function logs(string $id, string $register, string $schema, ObjectService public function lock(string $register, string $schema, string $id): JSONResponse { try { // Set the schema and register to the object service. - $this->objectService->setSchema(schema: $schema); $this->objectService->setRegister(register: $register); + $this->objectService->setSchema(schema: $schema); $data = $this->request->getParams(); $process = ($data['process'] ?? null); diff --git a/lib/Controller/OpenProjectLinksController.php b/lib/Controller/OpenProjectLinksController.php index 6a1d072f9e..1ca08b2866 100644 --- a/lib/Controller/OpenProjectLinksController.php +++ b/lib/Controller/OpenProjectLinksController.php @@ -312,8 +312,8 @@ public function available(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/PhotoLinksController.php b/lib/Controller/PhotoLinksController.php index c539cc9538..27c9ac6138 100644 --- a/lib/Controller/PhotoLinksController.php +++ b/lib/Controller/PhotoLinksController.php @@ -293,8 +293,8 @@ public function available(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/PollLinksController.php b/lib/Controller/PollLinksController.php index b2004a230f..5f8c969538 100644 --- a/lib/Controller/PollLinksController.php +++ b/lib/Controller/PollLinksController.php @@ -319,8 +319,8 @@ public function available(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/RelationsController.php b/lib/Controller/RelationsController.php index 2175a9afef..df430eef47 100644 --- a/lib/Controller/RelationsController.php +++ b/lib/Controller/RelationsController.php @@ -531,8 +531,8 @@ private function validateObject( string $schema, string $id, ): ?\OCA\OpenRegister\Db\ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/ShareLinksController.php b/lib/Controller/ShareLinksController.php index 0fae0f86ac..12b8e348a6 100644 --- a/lib/Controller/ShareLinksController.php +++ b/lib/Controller/ShareLinksController.php @@ -254,8 +254,8 @@ public function files(string $register, string $schema, string $id): JSONRespons * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/TagsController.php b/lib/Controller/TagsController.php index 9efa517e4f..a987d0f004 100644 --- a/lib/Controller/TagsController.php +++ b/lib/Controller/TagsController.php @@ -155,8 +155,8 @@ public function index( string $id, ): JSONResponse { try { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); $object = $this->objectService->getObject(); @@ -194,8 +194,8 @@ public function add( string $id, ): JSONResponse { try { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); $object = $this->objectService->getObject(); @@ -245,8 +245,8 @@ public function remove( string $tag, ): JSONResponse { try { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); $object = $this->objectService->getObject(); diff --git a/lib/Controller/TalkLinksController.php b/lib/Controller/TalkLinksController.php index a32a231371..d1ec452471 100644 --- a/lib/Controller/TalkLinksController.php +++ b/lib/Controller/TalkLinksController.php @@ -298,8 +298,8 @@ public function rooms(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/TasksController.php b/lib/Controller/TasksController.php index d11e6cdd60..386a31510a 100644 --- a/lib/Controller/TasksController.php +++ b/lib/Controller/TasksController.php @@ -363,8 +363,8 @@ private function validateObject( string $schema, string $id, ): ?\OCA\OpenRegister\Db\ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/TimeTrackerLinksController.php b/lib/Controller/TimeTrackerLinksController.php index 4a66ea980e..b387f525f1 100644 --- a/lib/Controller/TimeTrackerLinksController.php +++ b/lib/Controller/TimeTrackerLinksController.php @@ -314,8 +314,8 @@ public function available(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/lib/Controller/XwikiLinksController.php b/lib/Controller/XwikiLinksController.php index 6ae0b1e51a..80b2663fda 100644 --- a/lib/Controller/XwikiLinksController.php +++ b/lib/Controller/XwikiLinksController.php @@ -355,8 +355,8 @@ public function search(): JSONResponse { * returns for other reasons, which the caller could no longer tell apart. */ private function validateObject(string $register, string $schema, string $id): ?ObjectEntity { - $this->objectService->setSchema($schema); $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); $this->objectService->setObject($id); return $this->objectService->getObject(); diff --git a/tests/Unit/Controller/RegisterBeforeSchemaOrderTest.php b/tests/Unit/Controller/RegisterBeforeSchemaOrderTest.php new file mode 100644 index 0000000000..2444c6f26b --- /dev/null +++ b/tests/Unit/Controller/RegisterBeforeSchemaOrderTest.php @@ -0,0 +1,92 @@ + + */ + public static function controllerFiles(): array { + $dir = __DIR__ . '/../../../lib/Controller'; + $cases = []; + foreach (glob($dir . '/*.php') as $file) { + $cases[basename($file)] = [$file]; + } + + return $cases; + }//end controllerFiles() + + /** + * `setSchema()` must never immediately precede `setRegister()`. + * + * @param string $file The controller file to scan. + * + * @return void + * + * @dataProvider controllerFiles + */ + public function testRegisterIsNamedBeforeSchema(string $file): void { + $source = file_get_contents($file); + $this->assertIsString($source, 'controller source is readable'); + + $inverted = preg_match_all( + '/setSchema\(\s*[^)]*\s*\)\s*;\s*\n\s*\$this->objectService->setRegister\(/', + $source + ); + + $this->assertSame( + 0, + $inverted, + basename($file) . ' calls setSchema() before setRegister(). ' + . 'setSchema() scopes its slug lookup to the register currently set on the ' + . 'shared ObjectService, so this resolves the slug against whatever register ' + . 'an unrelated earlier operation left behind. Name the register first.' + ); + }//end testRegisterIsNamedBeforeSchema() +}//end class From 293736f016cb00de5c73a5847ee997d965707184 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 12:06:05 +0200 Subject: [PATCH 25/68] chore(lint): let the linter see tests/ and scripts/ (#3265) `npm run lint` was `eslint src`, so two whole trees were never linted. Across the fleet that hid roughly 3,900 errors, none of which any CI leg had shown. Most of it was the config, not the code, and the same two defects were in every app. **scripts/ had no config block at all.** These are CommonJS Node CLI checkers, and flat config defaults every `.js` to ESM with browser-ish globals, so eslint read the CommonJS wrapper itself as undefined identifiers: `require`, `process`, `__dirname`, `__filename`, `module`. A `scripts/**` block now declares the environment. Declaring beats suppressing here: `no-undef` is the rule that catches a genuinely misspelled identifier, and dozens of fake findings would bury a real one. A second block covers `scripts/**/*.mjs`, which is ESM and needs Node's globals without the CommonJS wrapper. **The tests block applied a non-TypeScript-aware rule to TypeScript.** It named `tests/**/*.ts` while setting the CORE `no-unused-vars`, which v9 deliberately turns off for `.ts` in favour of the `@typescript-eslint` version. The core rule reads the parameter names inside a function TYPE as bindings, so t?: (app: string, key: string) => string reports `app` and `key` as unused variables, and every unused `catch (e)` in a `.ts` spec reports twice. The block is split now: `.js`/`.mjs` on the core rule, `.ts`/`.tsx` on the TypeScript one, same patterns on both. Also: stale `eslint-disable` comments naming plugins eslint 10 no longer registers, which are themselves errors ("Definition for rule ... was not found"), and a rule that must not parse shell scripts. The genuinely real findings were the useful part: dead locals, unused imports, dead helper functions, unused `catch` bindings, extensionless relative imports, and a handful of `== null` comparisons spelled out so they still match null AND undefined. Verified per app: `npm run lint` 0 errors over src + tests + scripts, `prettier --check` clean, and the unit suite still green. --- eslint.config.mjs | 142 +++- package.json | 2 +- scripts/build-l10n-js.js | 3 + scripts/check-l10n.js | 17 +- scripts/check-schema-l10n.js | 3 + scripts/clean-l10n.js | 9 +- scripts/find-unwrapped.js | 23 +- scripts/l10n-ai.js | 64 +- scripts/l10n/apply.js | 4 +- scripts/l10n/batch.js | 4 +- scripts/l10n/casing.js | 19 +- scripts/l10n/core-diff.js | 4 +- scripts/l10n/detectors/be.js | 14 +- scripts/l10n/detectors/bg.js | 14 +- scripts/l10n/detectors/bs.js | 18 +- scripts/l10n/detectors/ca.js | 14 +- scripts/l10n/detectors/cs.js | 14 +- scripts/l10n/detectors/et.js | 14 +- scripts/l10n/detectors/ga.js | 16 +- scripts/l10n/detectors/hr.js | 14 +- scripts/l10n/detectors/is.js | 14 +- scripts/l10n/detectors/lb.js | 14 +- scripts/l10n/detectors/lt.js | 14 +- scripts/l10n/detectors/lv.js | 14 +- scripts/l10n/detectors/mk.js | Bin 20381 -> 20369 bytes scripts/l10n/detectors/mt.js | 16 +- scripts/l10n/detectors/rm.js | 16 +- scripts/l10n/detectors/ro.js | 14 +- scripts/l10n/detectors/sk.js | 14 +- scripts/l10n/detectors/sl.js | 14 +- scripts/l10n/detectors/sq.js | 14 +- scripts/l10n/detectors/sr.js | 16 +- scripts/l10n/detectors/tr.js | 14 +- scripts/l10n/fetch-dicts.js | 6 +- scripts/l10n/gate-negative-test.js | 6 +- scripts/l10n/harvest.js | 4 +- scripts/l10n/lib.js | 8 +- scripts/l10n/patchcheck.js | 4 +- scripts/l10n/runtime-check.mjs | 29 +- scripts/l10n/script-coverage.js | 3 +- scripts/l10n/selfcheck.js | 16 +- scripts/l10n/spell.js | 6 +- scripts/l10n/termdrift.js | 11 +- tests/__mocks__/conduction-nextcloud-vue.js | 14 +- tests/demo-e2e/demo-journey.spec.ts | 2 +- tests/demo-e2e/docs-sites.spec.ts | 4 +- tests/e2e/_fixtures.ts | 4 +- tests/e2e/_page-routes.ts | 11 +- .../e2e/api-direct/advanced-features.spec.ts | 2 +- tests/e2e/api-direct/api-smoke.spec.ts | 2 +- .../audit-content-versioning.spec.ts | 2 +- tests/e2e/api-direct/chat-agents.spec.ts | 2 +- .../configurations-endpoints.spec.ts | 4 +- .../api-direct/core-crud-lifecycle.spec.ts | 4 +- .../e2e/api-direct/delegated-identity.spec.ts | 6 +- .../e2e/api-direct/delegation-consent.spec.ts | 6 +- .../e2e/api-direct/delegation-parking.spec.ts | 8 +- tests/e2e/api-direct/entities-sources.spec.ts | 2 +- .../federated-config-marker.spec.ts | 4 +- .../api-direct/federated-config-store.spec.ts | 6 +- tests/e2e/api-direct/federated-config.spec.ts | 6 +- tests/e2e/api-direct/files-templates.spec.ts | 2 +- tests/e2e/api-direct/flow-engine.spec.ts | 4 +- .../flow-executionmode-and-token.spec.ts | 6 +- tests/e2e/api-direct/flow-schedule.spec.ts | 7 +- tests/e2e/api-direct/graphql-mcp.spec.ts | 2 +- .../api-direct/integration-registry.spec.ts | 4 +- .../e2e/api-direct/leaf-verification.spec.ts | 6 +- .../openconnector-chaining-migration.spec.ts | 4 +- .../openconnector-flow-nodes.spec.ts | 4 +- tests/e2e/api-direct/platform-admin.spec.ts | 2 +- .../e2e/api-direct/registers-schemas.spec.ts | 2 +- tests/e2e/api-direct/reporting-avg.spec.ts | 2 +- .../search-views-presentation.spec.ts | 16 +- tests/e2e/api-direct/search-views.spec.ts | 2 +- tests/e2e/api-direct/security-rbac.spec.ts | 17 +- tests/e2e/ci/flow-controls.spec.ts | 17 +- tests/e2e/ci/object-shares-tab.spec.ts | 12 +- tests/e2e/ci/object-sharing.spec.ts | 11 +- tests/e2e/ci/playwright.config.ts | 2 +- tests/e2e/ci/smoke-boot.spec.ts | 6 +- tests/e2e/core-crud.spec.ts | 4 +- tests/e2e/crud/object-crud.spec.ts | 13 +- tests/e2e/crud/register-crud.spec.ts | 6 +- tests/e2e/crud/schema-crud.spec.ts | 4 +- tests/e2e/docs-screenshots.spec.ts | 8 +- tests/e2e/flow-engine.spec.ts | 11 +- tests/e2e/global-setup.ts | 19 +- tests/e2e/integration-mount.spec.ts | 4 +- tests/e2e/leaf-screenshots.spec.ts | 6 +- tests/e2e/manifest-shell.spec.ts | 6 +- tests/e2e/mdm-seed.ts | 3 +- tests/e2e/smoke-boot.spec.ts | 6 +- .../admin-settings-pages.spec.ts | 12 +- .../e2e/spec-coverage/core-list-pages.spec.ts | 12 +- .../spec-coverage/data-import-export.spec.ts | 4 +- tests/e2e/spec-coverage/detail-pages.spec.ts | 12 +- .../entity-management-modals.spec.ts | 12 +- tests/e2e/spec-coverage/feature-pages.spec.ts | 15 +- .../features-roadmap-surface.spec.ts | 4 +- .../spec-coverage/files-sidebar-tabs.spec.ts | 4 +- tests/e2e/spec-coverage/mdm-frontend.spec.ts | 6 +- tests/e2e/spec-coverage/mdm-merge-ui.spec.ts | 6 +- .../mdm-survivorship-override.spec.ts | 6 +- .../object-views-kanban-calendar.spec.ts | 17 +- .../platform-administration-modals.spec.ts | 6 +- tests/e2e/spec-coverage/register-i18n.spec.ts | 16 +- .../spec-coverage/saved-search-views.spec.ts | 11 +- tests/e2e/ui-navigation.spec.ts | 4 +- tests/e2e/visual/_visual-helpers.ts | 4 +- tests/e2e/visual/dsar-cases.visual.spec.ts | 4 +- tests/e2e/visual/mdm-frontend.visual.spec.ts | 12 +- tests/e2e/visual/mdm-merge-ui.visual.spec.ts | 12 +- .../mdm-survivorship-override.visual.spec.ts | 10 +- tests/e2e/visual/openregister.visual.spec.ts | 2 +- .../archival-transfer-hardening.spec.ts | 4 +- tests/e2e/workflows/dsar-cases.spec.ts | 4 +- .../dsar-escalation-and-dpia.spec.ts | 4 +- .../object-lifecycle-workflows.spec.ts | 15 +- .../workflows/semantic-object-handoff.spec.ts | 4 +- tests/integration/package.json | 22 +- tests/l10n/check-l10n-parity.js | 19 +- tests/l10n/check-l10n.js | 7 +- tests/newman/build-flow-engine-collection.mjs | 799 +++++++++++------- tests/newman/flow-engine-definitions.mjs | 525 ++++++++++-- tests/validate-json-strict.js | 5 +- tests/validate-manifest.js | 4 +- 127 files changed, 1820 insertions(+), 769 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 9bad6e2d4b..c7c8ac1e1e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -144,11 +144,32 @@ export default [ 'no-console': 'off', 'n/no-process-exit': 'off', 'n/hashbang': 'off', - // `_` / `__` as a deliberate throwaway binding — `catch (_)`, a - // discarded destructuring slot. Narrow on purpose: the pattern matches - // UNDERSCORES ONLY, so a real name that happens to start with `_` is - // still reported. v9 drives plain `.js` through the CORE rule (the - // `@typescript-eslint` swap is per-file-type), so it is set here. + // Tests import devDependencies by definition; this rule is about what + // ships in the published package, which tests/ never does. + 'n/no-unpublished-import': 'off', + }, + }, + + { + // `_` / `__` as a deliberate throwaway binding — `catch (_)`, a discarded + // destructuring slot. Narrow on purpose: the pattern matches UNDERSCORES + // ONLY, so a real name that happens to start with `_` is still reported. + // + // 🔴 `.js` / `.mjs` ONLY, NOT `.ts`. The CORE rule is not TypeScript-aware: + // applied to a `.ts` file it reads the parameter NAMES inside a function + // TYPE as bindings and reports them unused. Measured on humaniq — + // + // t?: (app: string, key: string) => string + // + // produced four `no-unused-vars` errors for `app` and `key`, which are + // documentation, not variables. The same mis-scoping made every unused + // `catch (e)` in a `.ts` spec report TWICE, once per rule. + // + // v9 already turns the core rule off for `.ts` and drives + // `@typescript-eslint/no-unused-vars` instead; naming `.ts` here switched + // it back on. TypeScript files are handled by the block below. + files: ['tests/**/*.js', 'tests/**/*.mjs'], + rules: { 'no-unused-vars': [ 'error', { @@ -165,12 +186,117 @@ export default [ ignoreRestSiblings: true, }, ], - // Tests import devDependencies by definition; this rule is about what - // ships in the published package, which tests/ never does. - 'n/no-unpublished-import': 'off', }, }, + { + // The TypeScript half of the block above. Same intent, same patterns, on + // the rule that actually understands the language: it knows a name inside + // a function type is not a binding, so type annotations stay quiet while a + // genuinely dead local is still reported. + files: ['tests/**/*.ts', 'tests/**/*.tsx'], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_+$', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_+$', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + }, + }, + + { + // 🔴 Node-side CLI tooling under `scripts/`, which is COMMONJS. Flat + // config defaults every `.js` to ESM with browser-ish globals, so without + // this block eslint reports the CommonJS wrapper itself as undefined + // identifiers. Measured on this app: 52 of the 233 errors under + // `tests/` + `scripts/` were `no-undef`, ALL of them in `scripts/`, and + // all five names were the environment rather than a typo — `process` 23, + // `require` 20, `__dirname` 6, `__filename` 2, `module` 1. + // + // This is describing the environment, not relaxing a rule, and it is the + // same argument the test-globals block below makes: declaring them keeps + // `no-undef` able to do its real job, which is catching a genuinely + // misspelled identifier. Suppressing the rule instead would bury that. + // + // `no-console` is off because printing its report is what a CLI checker + // is FOR. + // + // 🔴 NO `n/*` ENTRIES HERE, DELIBERATELY. `eslint-plugin-n` is NOT + // registered for these files under eslint 10 + @nextcloud/eslint-config + // 9, so `'n/no-process-exit': 'off'` would be dead config that reads as + // if it were doing something. Measured both ways on this app: 0 `n/` + // findings with the entries and 0 without. + // + // What DID report was the opposite — four `scripts/*.js` carried + // `/* eslint-disable n/no-process-exit */` and `/* eslint-disable + // n/shebang */` left over from the eslintrc era, and an inline disable + // naming an unregistered plugin is itself an error ("Definition for rule + // 'n/shebang' was not found"). Those 8 comments are removed; do not add + // `n/*` rules back to replace them. + // + // ⚠️ `.js` and `.cjs` ONLY. A `scripts/*.mjs` is genuinely ESM and must + // keep the default `sourceType`, or `import` stops parsing there. + files: ['scripts/**/*.js', 'scripts/**/*.cjs'], + languageOptions: { + sourceType: 'commonjs', + globals: { + require: 'readonly', + module: 'writable', + exports: 'writable', + process: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + console: 'readonly', + Buffer: 'readonly', + global: 'readonly', + URL: 'readonly', + TextEncoder: 'readonly', + TextDecoder: 'readonly', + }, + }, + rules: { + 'no-console': 'off', + }, + }, + + { + // The ESM half of the block above. A `scripts/*.mjs` is genuinely a module + // and must keep the default `sourceType`, so it gets Node's globals but + // none of the CommonJS wrapper. Measured: `process` reported undefined 2x + // in hermiq's generate-opengemeenten-icons.mjs and 4x in openregister's + // l10n/runtime-check.mjs, which the `.js`/`.cjs` block deliberately does + // not match. + files: ['scripts/**/*.mjs', 'tests/**/*.mjs'], + languageOptions: { + globals: { + process: 'readonly', + console: 'readonly', + Buffer: 'readonly', + global: 'readonly', + URL: 'readonly', + TextEncoder: 'readonly', + TextDecoder: 'readonly', + }, + }, + rules: { + 'no-console': 'off', + }, + }, + + { + // eslint must not try to PARSE a shell script. `tests/e2e/seed.test.sh` + // matches the `**/*.test.*` glob some presets use, and eslint then reads + // it as JavaScript and reports "Parsing error: Unexpected character" — + // a finding about a file it should never have opened. + ignores: ['**/*.sh', '**/*.bash'], + }, + + { // Test globals. Several apps keep their spec files INSIDE `src/`, which the // lint script scans, and neither `@nextcloud/eslint-config` nor the runner diff --git a/package.json b/package.json index f0b0fa9162..51cba34c8d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "webpack --config webpack.config.js --progress --mode production", "dev": "NODE_ENV=development webpack --config webpack.config.js --progress", "watch": "NODE_ENV=development webpack --config webpack.config.js --progress --watch", - "lint": "eslint src", + "lint": "eslint src tests scripts", "lint-fix": "npm run lint -- --fix", "test": "jest --silent", "test-coverage": "jest --silent --coverage", diff --git a/scripts/build-l10n-js.js b/scripts/build-l10n-js.js index 176ef519ce..335b08d2c9 100644 --- a/scripts/build-l10n-js.js +++ b/scripts/build-l10n-js.js @@ -101,6 +101,9 @@ function renderJs(id, translations, pluralForm) { ].join('\n') } +/** + * + */ function main() { const check = process.argv.includes('--check') const id = appId() diff --git a/scripts/check-l10n.js b/scripts/check-l10n.js index 00fae70ece..faff332858 100755 --- a/scripts/check-l10n.js +++ b/scripts/check-l10n.js @@ -1,8 +1,6 @@ #!/usr/bin/env node /* eslint-disable jsdoc/require-param */ -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * l10n/i18n consistency checker. * @@ -19,7 +17,6 @@ const fs = require('fs') const path = require('path') - const { loadJsTranslations, walk, @@ -41,6 +38,9 @@ const DIM = '\x1b[2m' const BOLD = '\x1b[1m' const RESET = '\x1b[0m' +/** + * + */ function rel(p) { return path.relative(ROOT, p) } @@ -108,6 +108,9 @@ const NON_DISPLAY_ATTRS = new Set([ 'back-route', // Vue Router route name passed to $router.push({ name }) ]) +/** + * + */ function findUnwrapped(vueFiles, keys) { const hits = [] for (const file of vueFiles) { @@ -185,12 +188,18 @@ function findUnwrapped(vueFiles, keys) { return hits } +/** + * + */ function printSection(title, color, body) { console.log(`${color}${BOLD}${title}${RESET}`) console.log(body) console.log('') } +/** + * + */ function main() { const { app, translations } = loadJsTranslations(L10N_FILE) const keys = new Set(Object.keys(translations)) diff --git a/scripts/check-schema-l10n.js b/scripts/check-schema-l10n.js index 8a860b3305..3c4b2626f4 100644 --- a/scripts/check-schema-l10n.js +++ b/scripts/check-schema-l10n.js @@ -113,6 +113,9 @@ function collect(node, where, sink) { for (const value of Object.values(node)) collect(value, where, sink) } +/** + * + */ function main() { const update = process.argv.includes('--update') const list = process.argv.includes('--list') diff --git a/scripts/clean-l10n.js b/scripts/clean-l10n.js index 9ff895e1df..514b51e926 100755 --- a/scripts/clean-l10n.js +++ b/scripts/clean-l10n.js @@ -1,8 +1,5 @@ #!/usr/bin/env node -/* eslint-disable jsdoc/require-param */ -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * l10n unused-key remover. * @@ -29,7 +26,6 @@ const fs = require('fs') const path = require('path') - const { loadJsTranslations, serializeJs, @@ -56,6 +52,9 @@ const apply = args.has('--apply') // ---------- Main ---------- +/** + * + */ function main() { if (!fs.existsSync(ENGLISH_FILE)) { console.error(`English source file not found: ${ENGLISH_FILE}`) diff --git a/scripts/find-unwrapped.js b/scripts/find-unwrapped.js index 6e853a33c2..d99f6f6a27 100644 --- a/scripts/find-unwrapped.js +++ b/scripts/find-unwrapped.js @@ -1,8 +1,6 @@ #!/usr/bin/env node /* eslint-disable jsdoc/require-param */ -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Candidate unwrapped-string detector. * @@ -28,7 +26,6 @@ const fs = require('fs') const path = require('path') - const { walk } = require('./l10n/lib.js') const ROOT = path.resolve(__dirname, '..') @@ -141,6 +138,9 @@ function isComponentAttrOptOut(tagName, attrName) { // ---------- CLI ---------- +/** + * + */ function parseFlags(argv) { const flags = {} const positionals = [] @@ -176,6 +176,9 @@ const minLength = flags['min-length'] // ---------- helpers ---------- +/** + * + */ function rel(p) { return path.relative(ROOT, p) } @@ -365,6 +368,9 @@ function computeTCallRanges(text, app) { return ranges } +/** + * + */ function isInsideRange(pos, ranges) { for (const [start, end] of ranges) { if (pos >= start && pos < end) return true @@ -511,6 +517,9 @@ function isInsideStoreCall(expr, pos) { return /Store$/.test(baseIdent) } +/** + * + */ function findStringLiteralsInExpression(expr) { const out = [] let i = 0 @@ -788,6 +797,9 @@ function scanScript(file, fullText, scriptStart, scriptEnd, tCallRanges) { // ---------- main ---------- +/** + * + */ function findVueFiles(roots) { const files = [] for (const root of roots) { @@ -832,6 +844,9 @@ function detectAppName() { process.exit(2) } +/** + * + */ function main() { const roots = positionals.length ? positionals.map((p) => diff --git a/scripts/l10n-ai.js b/scripts/l10n-ai.js index e806f42aa5..9dfc4574f0 100644 --- a/scripts/l10n-ai.js +++ b/scripts/l10n-ai.js @@ -1,8 +1,6 @@ #!/usr/bin/env node /* eslint-disable jsdoc/require-param */ -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * AI-focused l10n CRUD tool. Designed to be invoked one subcommand at a time * by Claude (or other automation) so individual operations stay cheap in @@ -31,7 +29,6 @@ const fs = require('fs') const path = require('path') - const { loadJsTranslations, serializeJs, @@ -115,6 +112,9 @@ function parseValuePairs(pairs) { // ---------- file helpers ---------- +/** + * + */ function loadAll() { const files = listJsLocaleFiles(L10N_DIR) if (!files.length) { @@ -150,17 +150,26 @@ function writeAll(entries) { } } +/** + * + */ function fail(msg, code = 1) { console.error(msg) process.exit(code) } +/** + * + */ function rel(p) { return path.relative(ROOT, p) } // ---------- subcommands ---------- +/** + * + */ function cmdHas(args) { const { positionals, flags } = parseArgs(args) const [key] = positionals @@ -190,6 +199,9 @@ function cmdHas(args) { } } +/** + * + */ function cmdGet(args) { const { positionals } = parseArgs(args) const [key] = positionals @@ -198,7 +210,7 @@ function cmdGet(args) { const entries = loadAll() let any = false for (const e of entries) { - if (Object.prototype.hasOwnProperty.call(e.translations, key)) { + if (Object.hasOwn(e.translations, key)) { any = true const v = e.translations[key] const out = Array.isArray(v) ? JSON.stringify(v) : v @@ -211,6 +223,9 @@ function cmdGet(args) { } } +/** + * + */ function cmdFind(args) { const { positionals } = parseArgs(args) const [substring] = positionals @@ -235,6 +250,9 @@ function cmdFind(args) { } } +/** + * + */ function cmdAdd(args) { const { positionals, opts, flags } = parseArgs(args, { repeatable: new Set(['value']), @@ -300,7 +318,7 @@ function cmdAdd(args) { const existing = [] for (const e of entries) { if (!targetLocales.has(e.locale)) continue - if (Object.prototype.hasOwnProperty.call(e.translations, key)) { + if (Object.hasOwn(e.translations, key)) { existing.push(e.locale) } } @@ -321,6 +339,9 @@ function cmdAdd(args) { for (const e of toWrite) console.log(`${e.locale}.js\t${valueMap[e.locale]}`) } +/** + * + */ function cmdSet(args) { const { positionals, opts } = parseArgs(args) const [key] = positionals @@ -335,7 +356,7 @@ function cmdSet(args) { `set: locale '${opts.locale}' has no l10n/${opts.locale}.js (known: ${entries.map((e) => e.locale).join(', ')})`, ) } - if (!Object.prototype.hasOwnProperty.call(target.translations, key)) { + if (!Object.hasOwn(target.translations, key)) { fail(`set: key '${key}' not present in ${opts.locale}.js. Use 'add' first.`) } if (Array.isArray(target.translations[key])) { @@ -349,15 +370,16 @@ function cmdSet(args) { console.log(`${target.locale}.js\t${opts.value}`) } +/** + * + */ function cmdRm(args) { const { positionals, flags } = parseArgs(args) const [key] = positionals if (!key) fail('usage: rm [--force]') const entries = loadAll() - const present = entries.filter((e) => - Object.prototype.hasOwnProperty.call(e.translations, key), - ) + const present = entries.filter((e) => Object.hasOwn(e.translations, key)) if (!present.length) { fail(`rm: key '${key}' not found in any locale .js file`) } @@ -387,6 +409,9 @@ function cmdRm(args) { for (const e of toWrite) console.log(`${e.locale}.js\tremoved`) } +/** + * + */ function cmdRename(args) { const { positionals, flags } = parseArgs(args) const [oldKey, newKey] = positionals @@ -394,15 +419,11 @@ function cmdRename(args) { if (oldKey === newKey) fail('rename: old and new keys are identical') const entries = loadAll() - const present = entries.filter((e) => - Object.prototype.hasOwnProperty.call(e.translations, oldKey), - ) + const present = entries.filter((e) => Object.hasOwn(e.translations, oldKey)) if (!present.length) { fail(`rename: key '${oldKey}' not found in any locale .js file`) } - const collisions = entries.filter((e) => - Object.prototype.hasOwnProperty.call(e.translations, newKey), - ) + const collisions = entries.filter((e) => Object.hasOwn(e.translations, newKey)) if (collisions.length && !flags.force) { fail( `rename: target key '${newKey}' already exists in ${collisions.map((e) => e.locale + '.js').join(', ')}. Pass --force to overwrite.`, @@ -411,7 +432,7 @@ function cmdRename(args) { const toWrite = [] for (const e of entries) { - if (!Object.prototype.hasOwnProperty.call(e.translations, oldKey)) continue + if (!Object.hasOwn(e.translations, oldKey)) continue const next = { ...e.translations } next[newKey] = next[oldKey] delete next[oldKey] @@ -421,12 +442,18 @@ function cmdRename(args) { for (const e of toWrite) console.log(`${e.locale}.js\trenamed`) } +/** + * + */ function cmdListLocales() { const files = listJsLocaleFiles(L10N_DIR) if (!files.length) fail('list-locales: no l10n/*.js files found') for (const f of files) console.log(localeNameOf(f)) } +/** + * + */ function cmdHelp() { const text = [ 'Usage: node scripts/l10n-ai.js [args...]', @@ -450,6 +477,9 @@ function cmdHelp() { // ---------- main ---------- +/** + * + */ function main() { const [, , sub, ...rest] = process.argv if (!sub || sub === '--help' || sub === '-h') { diff --git a/scripts/l10n/apply.js b/scripts/l10n/apply.js index 068374652b..33b6f1b204 100644 --- a/scripts/l10n/apply.js +++ b/scripts/l10n/apply.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Gated writer for one locale's l10n/.js. * diff --git a/scripts/l10n/batch.js b/scripts/l10n/batch.js index e0b032275f..56171bad47 100644 --- a/scripts/l10n/batch.js +++ b/scripts/l10n/batch.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Read-only status and worklist for one locale. * diff --git a/scripts/l10n/casing.js b/scripts/l10n/casing.js index d108377d7c..88fb7b2909 100644 --- a/scripts/l10n/casing.js +++ b/scripts/l10n/casing.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Mid-sentence capitalisation of domain terms, for one bundle, against a baseline. * @@ -71,7 +69,12 @@ if (!loc) { console.error('usage: casing.js [--min-words=6] [--top=30] [--mine] [--all-terms]') process.exit(2) } -const num = (flag, dflt) => { +/** + * + * @param flag + * @param dflt + */ +function num (flag, dflt) { const a = process.argv.find((x) => x.startsWith(`--${flag}=`)) return a ? Number(a.slice(flag.length + 3)) : dflt } @@ -270,7 +273,13 @@ console.log(`casing ${loc}: mid-sentence capitalisation, conditioned on the Engl console.log(`(prose = key >=${MIN_WORDS} words and not Title Case; label = everything else)`) console.log() console.log(' prose (up:down) label (up:down) values') -const row = (name, m, extra) => { +/** + * + * @param name + * @param m + * @param extra + */ +function row (name, m, extra) { console.log(` ${name.padEnd(20)} ${ratio(m.tally.prose).padEnd(16)} ` + `${ratio(m.tally.label).padEnd(16)} ${m.tally.prose.values + m.tally.label.values}` + (extra ? ` ${extra}` : '')) diff --git a/scripts/l10n/core-diff.js b/scripts/l10n/core-diff.js index 635bb1365e..d078e88d8a 100644 --- a/scripts/l10n/core-diff.js +++ b/scripts/l10n/core-diff.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Bundle vs Nextcloud core, key by key, for one locale. * diff --git a/scripts/l10n/detectors/be.js b/scripts/l10n/detectors/be.js index a9d3bd897c..4232c255cd 100644 --- a/scripts/l10n/detectors/be.js +++ b/scripts/l10n/detectors/be.js @@ -1,5 +1,4 @@ -/* eslint-disable no-console */ -/* eslint-disable n/no-process-exit */ + // Belarusian register detector for openregister l10n. // // Measures the PROSE register, which is FORMAL: вы / ваш / 2pl verb forms. @@ -68,6 +67,10 @@ // JS \b is ASCII-only and would treat "ў", "і" and "ё" as boundaries, so every // guard is (? LibreOffice dictionaries directory / basename. diff --git a/scripts/l10n/gate-negative-test.js b/scripts/l10n/gate-negative-test.js index e86adb101e..73e3837c43 100644 --- a/scripts/l10n/gate-negative-test.js +++ b/scripts/l10n/gate-negative-test.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Prove that test:l10n:parity actually FAILS when a locale loses a key. * @@ -32,10 +30,10 @@ 'use strict' +const { execFileSync } = require('child_process') const fs = require('fs') const os = require('os') const path = require('path') -const { execFileSync } = require('child_process') const { loadJsTranslations, serializeJs, APP_ROOT } = require('./lib.js') const loc = process.argv[2] diff --git a/scripts/l10n/harvest.js b/scripts/l10n/harvest.js index f147ecdcfd..8e1a97c4ae 100644 --- a/scripts/l10n/harvest.js +++ b/scripts/l10n/harvest.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Harvest exact-key translation candidates for one locale from Nextcloud core and * from the sibling Conduction apps. diff --git a/scripts/l10n/lib.js b/scripts/l10n/lib.js index 914765bf4b..8c1ce06f93 100644 --- a/scripts/l10n/lib.js +++ b/scripts/l10n/lib.js @@ -32,10 +32,10 @@ * "not started" and "in progress" uniformly. */ +const { execFileSync } = require('child_process') const fs = require('fs') const path = require('path') const vm = require('vm') -const { execFileSync } = require('child_process') /** * Load a single l10n/*.js file and return its app name, translations object, @@ -421,6 +421,9 @@ function findKeyReferences(srcDir, app, key) { return hits } +/** + * + */ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -771,6 +774,9 @@ function localeFileRe(loc) { return new RegExp(`^${escaped}(_[A-Za-z]{2,3})?\\.json$`) } +/** + * + */ function coreCatalogues(loc) { const workspace = process.env.L10N_WORKSPACE || path.resolve(APP_ROOT, '..', '..') const server = process.env.L10N_SERVER_DIR || path.join(workspace, 'server') diff --git a/scripts/l10n/patchcheck.js b/scripts/l10n/patchcheck.js index dc6e76585d..eb252c3637 100644 --- a/scripts/l10n/patchcheck.js +++ b/scripts/l10n/patchcheck.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Run a locale's register detector over a patch's VALUES before it is applied, so * a register slip is caught while it is still cheap to fix rather than after 200 diff --git a/scripts/l10n/runtime-check.mjs b/scripts/l10n/runtime-check.mjs index aff26169ef..f8c7a3f32f 100644 --- a/scripts/l10n/runtime-check.mjs +++ b/scripts/l10n/runtime-check.mjs @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Drive the REAL @nextcloud/l10n against a real locale bundle. * @@ -36,9 +34,9 @@ */ import { readFileSync } from 'fs' +import { createRequire } from 'module' import path from 'path' import vm from 'vm' -import { createRequire } from 'module' const require = createRequire(import.meta.url) const { @@ -52,7 +50,7 @@ if (!loc) { } const l10n = await import(path.join(APP_ROOT, 'node_modules/@nextcloud/l10n/dist/index.mjs')) -const { register, unregister, translate: t, translatePlural: n, setLanguage, getPlural } = l10n +const { register, unregister, translatePlural: n, setLanguage, getPlural } = l10n const locFile = path.join(APP_ROOT, 'l10n', `${loc}.js`) let translations = null @@ -62,7 +60,13 @@ vm.createContext(sandbox) vm.runInContext(readFileSync(locFile, 'utf8'), sandbox, { filename: locFile }) let fails = 0 -const ok = (cond, label, got) => { +/** + * + * @param cond + * @param label + * @param got + */ +function ok (cond, label, got) { console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${got !== undefined ? ' -> ' + JSON.stringify(got) : ''}`) if (!cond) fails++ } @@ -78,8 +82,15 @@ const ok = (cond, label, got) => { // a human instead of a false accusation. const cognates = loadLocaleConfig(loc).cognates const enforced = configuredLocales().includes(loc) -const notEnglish = (cond, key, label, got) => { - if (cond || Object.prototype.hasOwnProperty.call(cognates, key)) { +/** + * + * @param cond + * @param key + * @param label + * @param got + */ +function notEnglish (cond, key, label, got) { + if (cond || Object.hasOwn(cognates, key)) { ok(true, label, got) return } @@ -209,7 +220,7 @@ if (!headerExpr) { cfg.pluralBoundary === 'library' ? undefined : detail) if (cfg.pluralBoundary === 'library') { console.log(` ${disagree.length} count(s) render a form the header would not choose: ` - + `${detail.join(', ')}${disagree.length > 6 ? ', …' : ''}. Acknowledged; see its pluralNote.`) + + `${detail.join(', ')}${disagree.length > 6 ? ', …' : ''}. Acknowledged; see its pluralNote.`) } } } diff --git a/scripts/l10n/script-coverage.js b/scripts/l10n/script-coverage.js index 557df40d5b..6007d01307 100644 --- a/scripts/l10n/script-coverage.js +++ b/scripts/l10n/script-coverage.js @@ -1,5 +1,4 @@ -/* eslint-disable no-console */ -/* eslint-disable n/no-process-exit */ + /** * Script-coverage sweep for a non-Latin locale — bg, sr, mk, be. * diff --git a/scripts/l10n/selfcheck.js b/scripts/l10n/selfcheck.js index cac5ca34db..cfed8b2519 100644 --- a/scripts/l10n/selfcheck.js +++ b/scripts/l10n/selfcheck.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Full verification for one locale pass. Every assertion is measured against the * files; nothing is assumed. @@ -45,6 +43,12 @@ const enKeys = new Set(Object.keys(en.translations)) const keys = Object.keys(cur.translations) let fails = 0 +/** + * + * @param name + * @param ok + * @param detail + */ function check(name, ok, detail) { console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`) if (!ok) { @@ -58,6 +62,12 @@ function check(name, ok, detail) { // FAIL would say the locale is broken when it is merely unreviewed. Everything that // does not depend on a record is still checked in full. const recorded = Boolean(cfg.register) || Object.keys(cfg.cognates).length > 0 +/** + * + * @param name + * @param ok + * @param detail + */ function checkRecorded(name, ok, detail) { if (recorded) { check(name, ok, detail) diff --git a/scripts/l10n/spell.js b/scripts/l10n/spell.js index 3f77328f5e..e8494b06c5 100644 --- a/scripts/l10n/spell.js +++ b/scripts/l10n/spell.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Spell-sweep one bundle against a hunspell dictionary. * @@ -38,9 +36,9 @@ 'use strict' +const { execFileSync } = require('child_process') const fs = require('fs') const path = require('path') -const { execFileSync } = require('child_process') const { loadJsTranslations, APP_ROOT, loadLocaleConfig } = require('./lib.js') const loc = process.argv[2] diff --git a/scripts/l10n/termdrift.js b/scripts/l10n/termdrift.js index 1c5da8434b..5d2ce8fd07 100644 --- a/scripts/l10n/termdrift.js +++ b/scripts/l10n/termdrift.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Terminology drift within one bundle: English words rendered two different ways. * @@ -51,7 +49,12 @@ if (!loc) { console.error('usage: termdrift.js [--min-keys=4] [--max-minority=0.34] [--top=40]') process.exit(2) } -const num = (flag, dflt) => { +/** + * + * @param flag + * @param dflt + */ +function num (flag, dflt) { const a = process.argv.find((x) => x.startsWith(`--${flag}=`)) return a ? Number(a.slice(flag.length + 3)) : dflt } diff --git a/tests/__mocks__/conduction-nextcloud-vue.js b/tests/__mocks__/conduction-nextcloud-vue.js index c105d269b8..1d72de77c7 100644 --- a/tests/__mocks__/conduction-nextcloud-vue.js +++ b/tests/__mocks__/conduction-nextcloud-vue.js @@ -49,12 +49,14 @@ function createObjectStore(id, options = {}) { return defineStore(id, merged) } -const noopPlugin = (name) => () => ({ - name, - state: () => ({}), - getters: {}, - actions: {}, -}) +function noopPlugin(name) { + return () => ({ + name, + state: () => ({}), + getters: {}, + actions: {}, + }) +} /** * Mirrors the REAL liveUpdatesPlugin contract (plugin name, contributed state diff --git a/tests/demo-e2e/demo-journey.spec.ts b/tests/demo-e2e/demo-journey.spec.ts index d3bb7eef4b..2aabbe9e9d 100644 --- a/tests/demo-e2e/demo-journey.spec.ts +++ b/tests/demo-e2e/demo-journey.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, request as pwRequest } from '@playwright/test' +import { expect, request as pwRequest, test } from '@playwright/test' /** * The demo environment, checked the way its documentation says to check it. diff --git a/tests/demo-e2e/docs-sites.spec.ts b/tests/demo-e2e/docs-sites.spec.ts index a09ad30237..44888605cb 100644 --- a/tests/demo-e2e/docs-sites.spec.ts +++ b/tests/demo-e2e/docs-sites.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, request as pwRequest } from '@playwright/test' +import { expect, request as pwRequest, test } from '@playwright/test' /** * The fleet's documentation sites, checked in a real browser. @@ -95,7 +95,7 @@ test.describe('retired hostnames redirect', () => { maxRedirects: 0, }) expect(res.status()).toBe(301) - expect(res.headers()['location']).toBe( + expect(res.headers().location).toBe( `https://${canonical}.conduction.nl${path}`, ) await api.dispose() diff --git a/tests/e2e/_fixtures.ts b/tests/e2e/_fixtures.ts index b9e922818b..7e8d053d38 100644 --- a/tests/e2e/_fixtures.ts +++ b/tests/e2e/_fixtures.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -23,7 +25,7 @@ * Basic-auth `extraHTTPHeaders` from playwright.config.ts (admin/admin), so * fixture setup works without a browser session. */ -import { type APIRequestContext, expect } from '@playwright/test' +import { expect } from '@playwright/test' const API = '/index.php/apps/openregister/api' const JSON_HEADERS = { diff --git a/tests/e2e/_page-routes.ts b/tests/e2e/_page-routes.ts index 1a2d6a2eac..ff36a56c38 100644 --- a/tests/e2e/_page-routes.ts +++ b/tests/e2e/_page-routes.ts @@ -122,8 +122,9 @@ export const DeletedIndex = '/deleted' export const SchemaDetails = (id: string | number): string => `/schemas/${id}` /** `src/views/application/ApplicationDetails.vue` — single application, by id. */ -export const ApplicationDetails = (id: string | number): string => - `/applications/${id}` +export function ApplicationDetails(id: string | number): string { + return `/applications/${id}` +} /** `src/views/flows/FlowDetailPage.vue` — single flow, by id. */ export const FlowDetailPage = (id: string | number): string => `/flows/${id}` @@ -136,8 +137,10 @@ export const ReportView = (id: string | number): string => `/reports/${id}` * host, which mounts one provider tab on its own rather than inside the * ObjectDetails sidebar. `tests/e2e/leaf-screenshots.spec.ts` drives it. */ -export const IntegrationsView = ( +export function IntegrationsView( register: string | number, schema: string | number, objectId: string, -): string => `/integrations/${register}/${schema}/${objectId}` +): string { + return `/integrations/${register}/${schema}/${objectId}` +} diff --git a/tests/e2e/api-direct/advanced-features.spec.ts b/tests/e2e/api-direct/advanced-features.spec.ts index e612f4360c..7e781804c6 100644 --- a/tests/e2e/api-direct/advanced-features.spec.ts +++ b/tests/e2e/api-direct/advanced-features.spec.ts @@ -15,7 +15,7 @@ * * All mutations use RUN_ID prefix for cleanup isolation. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const RUN_ID = `e2e-${Date.now()}` const REGISTER_ID = '8' diff --git a/tests/e2e/api-direct/api-smoke.spec.ts b/tests/e2e/api-direct/api-smoke.spec.ts index a8f542f956..a6132b3211 100644 --- a/tests/e2e/api-direct/api-smoke.spec.ts +++ b/tests/e2e/api-direct/api-smoke.spec.ts @@ -14,7 +14,7 @@ * Playwright suites (opencatalogi, launchpad, etc.) per the cross-app * hand-off pattern. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' test.describe('OAS — ETag short-circuit', () => { test('GET /api/registers/oas returns 200 with ETag, 304 on If-None-Match', async ({ diff --git a/tests/e2e/api-direct/audit-content-versioning.spec.ts b/tests/e2e/api-direct/audit-content-versioning.spec.ts index db97bfbe83..7441f876a8 100644 --- a/tests/e2e/api-direct/audit-content-versioning.spec.ts +++ b/tests/e2e/api-direct/audit-content-versioning.spec.ts @@ -11,7 +11,7 @@ * Creates a test object, verifies an audit entry is created, updates * it, verifies version increments, then deletes it. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const RUN_ID = `e2e-${Date.now()}` const REGISTER_ID = '8' diff --git a/tests/e2e/api-direct/chat-agents.spec.ts b/tests/e2e/api-direct/chat-agents.spec.ts index c33bc81f82..8b77725bba 100644 --- a/tests/e2e/api-direct/chat-agents.spec.ts +++ b/tests/e2e/api-direct/chat-agents.spec.ts @@ -13,7 +13,7 @@ * (correct HTTP status, JSON envelope) without requiring a configured * LLM backend. Most will skip if no agent/LLM is configured. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const RUN_ID = `e2e-${Date.now()}` diff --git a/tests/e2e/api-direct/configurations-endpoints.spec.ts b/tests/e2e/api-direct/configurations-endpoints.spec.ts index 16af536270..3bb96445e1 100644 --- a/tests/e2e/api-direct/configurations-endpoints.spec.ts +++ b/tests/e2e/api-direct/configurations-endpoints.spec.ts @@ -16,7 +16,7 @@ * * All mutations use RUN_ID prefix for cleanup isolation. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const RUN_ID = `e2e-${Date.now()}` @@ -79,7 +79,7 @@ test.describe('openapi-generation — full OAS document', () => { // configurations — configuration sets CRUD // ───────────────────────────────────────────────────────────────────────────── test.describe('configurations — configuration sets REST lifecycle', () => { - let configId: number | null = null + const configId: number | null = null test('GET /api/configurations lists configurations (any response, not just 5xx)', async ({ request, diff --git a/tests/e2e/api-direct/core-crud-lifecycle.spec.ts b/tests/e2e/api-direct/core-crud-lifecycle.spec.ts index 4635628483..c1ca313364 100644 --- a/tests/e2e/api-direct/core-crud-lifecycle.spec.ts +++ b/tests/e2e/api-direct/core-crud-lifecycle.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -20,7 +22,7 @@ * * Extracted from core-crud.spec.ts (the UI tests stay there). */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' // Run-unique prefix so parallel agents don't collide. const RUN_ID = `e2e-${Date.now()}` diff --git a/tests/e2e/api-direct/delegated-identity.spec.ts b/tests/e2e/api-direct/delegated-identity.spec.ts index 45e310dd02..794fbf66e5 100644 --- a/tests/e2e/api-direct/delegated-identity.spec.ts +++ b/tests/e2e/api-direct/delegated-identity.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -22,12 +24,12 @@ * @spec openspec/specs/delegated-identity/spec.md * @spec openspec/specs/flow-engine/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import { findSecondAccount, NO_SECOND_ACCOUNT, revokeGrantsOver, -} from './delegation-fixtures' +} from './delegation-fixtures.ts' const RUN_ID = `e2e-ident-${Date.now().toString(36)}` diff --git a/tests/e2e/api-direct/delegation-consent.spec.ts b/tests/e2e/api-direct/delegation-consent.spec.ts index 049163e156..444f1ac272 100644 --- a/tests/e2e/api-direct/delegation-consent.spec.ts +++ b/tests/e2e/api-direct/delegation-consent.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -19,13 +21,13 @@ * * @spec openspec/specs/delegation-grants/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import { ADMIN, findSecondAccount, NO_SECOND_ACCOUNT, revokeGrantsOver, -} from './delegation-fixtures' +} from './delegation-fixtures.ts' const RUN_ID = `e2e-consent-${Date.now().toString(36)}` diff --git a/tests/e2e/api-direct/delegation-parking.spec.ts b/tests/e2e/api-direct/delegation-parking.spec.ts index cfbbbffec7..f2474a023d 100644 --- a/tests/e2e/api-direct/delegation-parking.spec.ts +++ b/tests/e2e/api-direct/delegation-parking.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -28,15 +30,15 @@ * * @spec openspec/specs/delegation-grants/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import { execSync } from 'node:child_process' -import { resolveContainer } from '../base-url' +import { resolveContainer } from '../base-url.ts' import { ADMIN, findSecondAccount, NO_SECOND_ACCOUNT, revokeGrantsOver, -} from './delegation-fixtures' +} from './delegation-fixtures.ts' const API = '/index.php/apps/openregister/api' const RUN_ID = `e2e-park-${Date.now().toString(36)}` diff --git a/tests/e2e/api-direct/entities-sources.spec.ts b/tests/e2e/api-direct/entities-sources.spec.ts index 04426c6ef6..9e9f8107ca 100644 --- a/tests/e2e/api-direct/entities-sources.spec.ts +++ b/tests/e2e/api-direct/entities-sources.spec.ts @@ -12,7 +12,7 @@ * * Uses API-only approach. All mutations use RUN_ID prefix. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const RUN_ID = `e2e-${Date.now()}` diff --git a/tests/e2e/api-direct/federated-config-marker.spec.ts b/tests/e2e/api-direct/federated-config-marker.spec.ts index be5bb69981..fb288b5465 100644 --- a/tests/e2e/api-direct/federated-config-marker.spec.ts +++ b/tests/e2e/api-direct/federated-config-marker.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * Federated configuration sharing — the schema-marker generic path, e2e via HTTP. * @@ -13,7 +15,7 @@ * * @spec openspec/changes/federated-config-sharing/specs/federated-config-sharing/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' const API = '/index.php/apps/openregister/api' const JSON_HEADERS = { diff --git a/tests/e2e/api-direct/federated-config-store.spec.ts b/tests/e2e/api-direct/federated-config-store.spec.ts index 697d57bab0..9f683ddb92 100644 --- a/tests/e2e/api-direct/federated-config-store.spec.ts +++ b/tests/e2e/api-direct/federated-config-store.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * Federated config STORE surface — publish/discover/sign/RBAC, e2e via HTTP. * @@ -14,9 +16,9 @@ * * @spec openspec/changes/federated-config-sharing/specs/federated-config-sharing/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import { execSync } from 'node:child_process' -import { resolveBaseUrl, resolveContainer } from '../base-url' +import { resolveBaseUrl, resolveContainer } from '../base-url.ts' const API = '/index.php/apps/openregister/api' const JSON_HEADERS = { diff --git a/tests/e2e/api-direct/federated-config.spec.ts b/tests/e2e/api-direct/federated-config.spec.ts index 8b0463e12b..dbe1f0573a 100644 --- a/tests/e2e/api-direct/federated-config.spec.ts +++ b/tests/e2e/api-direct/federated-config.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * Federated configuration sharing — end-to-end via the live HTTP API. * @@ -13,9 +15,9 @@ * * @spec openspec/changes/federated-config-sharing/specs/federated-config-sharing/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import { execSync } from 'node:child_process' -import { resolveContainer } from '../base-url' +import { resolveContainer } from '../base-url.ts' const API = '/index.php/apps/openregister/api' const JSON_HEADERS = { diff --git a/tests/e2e/api-direct/files-templates.spec.ts b/tests/e2e/api-direct/files-templates.spec.ts index f1a71fcbff..b6902322a3 100644 --- a/tests/e2e/api-direct/files-templates.spec.ts +++ b/tests/e2e/api-direct/files-templates.spec.ts @@ -15,7 +15,7 @@ * Uses larpingapp register (8/18) and creates test objects with file * attachments. Uses RUN_ID for cleanup isolation. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const RUN_ID = `e2e-${Date.now()}` const REGISTER_ID = '8' diff --git a/tests/e2e/api-direct/flow-engine.spec.ts b/tests/e2e/api-direct/flow-engine.spec.ts index ac2dd0735a..eda178c996 100644 --- a/tests/e2e/api-direct/flow-engine.spec.ts +++ b/tests/e2e/api-direct/flow-engine.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * OpenRegister flow engine — end-to-end via the live HTTP API. * @@ -14,7 +16,7 @@ * * @spec openspec/changes/or-flow-store/specs/flow-store/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' const API = '/index.php/apps/openregister/api' const JSON_HEADERS = { diff --git a/tests/e2e/api-direct/flow-executionmode-and-token.spec.ts b/tests/e2e/api-direct/flow-executionmode-and-token.spec.ts index de728a2332..ab2176f4fe 100644 --- a/tests/e2e/api-direct/flow-executionmode-and-token.spec.ts +++ b/tests/e2e/api-direct/flow-executionmode-and-token.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * Flow execution mode and the run-level flow token — end-to-end via the live API. * @@ -19,7 +21,7 @@ * @spec openspec/changes/openregister-flow-executionmode-and-token/specs/flow-execution-mode/spec.md * @spec openspec/changes/openregister-flow-executionmode-and-token/specs/flow-token/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' const API = '/index.php/apps/openregister/api' const JSON_HEADERS = { @@ -270,7 +272,7 @@ test.describe('Flow execution mode and token', () => { expect(runs.length, 'the test run was persisted').toBeGreaterThan(0) const stored = runs[0].context ?? {} expect( - Object.prototype.hasOwnProperty.call(stored, 'token'), + Object.hasOwn(stored, 'token'), 'the persisted context carries the token the engine handed to the steps', ).toBeTruthy() }) diff --git a/tests/e2e/api-direct/flow-schedule.spec.ts b/tests/e2e/api-direct/flow-schedule.spec.ts index ec51745abc..b17c731dc6 100644 --- a/tests/e2e/api-direct/flow-schedule.spec.ts +++ b/tests/e2e/api-direct/flow-schedule.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * OpenRegister scheduled flow trigger — end-to-end. * @@ -14,9 +16,9 @@ * * @spec openspec/changes/or-flow-scheduled-trigger/specs/flow-scheduled-trigger/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import { execSync } from 'node:child_process' -import { resolveContainer } from '../base-url' +import { resolveContainer } from '../base-url.ts' const API = '/index.php/apps/openregister/api' const JSON_HEADERS = { @@ -120,7 +122,6 @@ async function schemaOfRegister( test.describe('Scheduled flow trigger', () => { let reg: number - let sch: number let jobId: string | null const created: string[] = [] diff --git a/tests/e2e/api-direct/graphql-mcp.spec.ts b/tests/e2e/api-direct/graphql-mcp.spec.ts index 060a6b9920..47c81a7267 100644 --- a/tests/e2e/api-direct/graphql-mcp.spec.ts +++ b/tests/e2e/api-direct/graphql-mcp.spec.ts @@ -9,7 +9,7 @@ * * These are pure API tests — no browser needed. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' // ───────────────────────────────────────────────────────────────────────────── // graphql-api — auto-generated GraphQL schema diff --git a/tests/e2e/api-direct/integration-registry.spec.ts b/tests/e2e/api-direct/integration-registry.spec.ts index c7da86ab22..ac0693984a 100644 --- a/tests/e2e/api-direct/integration-registry.spec.ts +++ b/tests/e2e/api-direct/integration-registry.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * Integration-registry E2E smoke — exercises every layer of the * pluggable-integration chain (ADR-019) against a live OpenRegister: @@ -19,7 +21,7 @@ * + `bootBuiltinIntegrationProviders` running), and admin:admin * available. */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' /** * The 24 providers the registry SHOULD advertise once openregister diff --git a/tests/e2e/api-direct/leaf-verification.spec.ts b/tests/e2e/api-direct/leaf-verification.spec.ts index 4549b9eb45..0a9378984c 100644 --- a/tests/e2e/api-direct/leaf-verification.spec.ts +++ b/tests/e2e/api-direct/leaf-verification.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * Per-leaf verification harness — runs against a live OpenRegister and * records, per provider: @@ -20,10 +22,10 @@ * NEXTCLOUD_URL=http://localhost:8080 npx playwright test \ * tests/e2e/leaf-verification.spec.ts --project=chromium */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as fs from 'fs' import * as path from 'path' -import { resolveBaseUrl } from '../base-url' +import { resolveBaseUrl } from '../base-url.ts' interface ProviderReport { id: string diff --git a/tests/e2e/api-direct/openconnector-chaining-migration.spec.ts b/tests/e2e/api-direct/openconnector-chaining-migration.spec.ts index 61c25fa8a2..05fe4f6038 100644 --- a/tests/e2e/api-direct/openconnector-chaining-migration.spec.ts +++ b/tests/e2e/api-direct/openconnector-chaining-migration.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * OpenConnector's chaining, before and after the flow migration. * @@ -14,7 +16,7 @@ * * @spec openspec/changes/openregister-flow-executionmode-and-token/specs/flow-execution-mode/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' const API = '/index.php/apps/openregister/api' const JSON_HEADERS = { diff --git a/tests/e2e/api-direct/openconnector-flow-nodes.spec.ts b/tests/e2e/api-direct/openconnector-flow-nodes.spec.ts index 5324092ec7..5675484f93 100644 --- a/tests/e2e/api-direct/openconnector-flow-nodes.spec.ts +++ b/tests/e2e/api-direct/openconnector-flow-nodes.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /** * OpenConnector's flow-node leaves — end-to-end through the live engine. * @@ -16,7 +18,7 @@ * * @spec openspec/changes/openregister-flow-executionmode-and-token/specs/flow-execution-mode/spec.md */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' const API = '/index.php/apps/openregister/api' const JSON_HEADERS = { diff --git a/tests/e2e/api-direct/platform-admin.spec.ts b/tests/e2e/api-direct/platform-admin.spec.ts index 8480a3111e..840ac78cb9 100644 --- a/tests/e2e/api-direct/platform-admin.spec.ts +++ b/tests/e2e/api-direct/platform-admin.spec.ts @@ -10,7 +10,7 @@ * - nextcloud-api-compat (NC OCS capabilities, NC app info) * - mariadb-ci-matrix (backend database adapts to DB engine) */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' // ───────────────────────────────────────────────────────────────────────────── // production-observability — health/status endpoints diff --git a/tests/e2e/api-direct/registers-schemas.spec.ts b/tests/e2e/api-direct/registers-schemas.spec.ts index 024f4280ec..51cdcfa503 100644 --- a/tests/e2e/api-direct/registers-schemas.spec.ts +++ b/tests/e2e/api-direct/registers-schemas.spec.ts @@ -13,7 +13,7 @@ * All mutations use the unique RUN_ID prefix so parallel agents * don't collide. Cleanup runs in afterAll. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const RUN_ID = `e2e-${Date.now()}` diff --git a/tests/e2e/api-direct/reporting-avg.spec.ts b/tests/e2e/api-direct/reporting-avg.spec.ts index 11427264aa..326396f1ef 100644 --- a/tests/e2e/api-direct/reporting-avg.spec.ts +++ b/tests/e2e/api-direct/reporting-avg.spec.ts @@ -14,7 +14,7 @@ * * These are mostly API surface tests verifying HTTP contracts. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const RUN_ID = `e2e-${Date.now()}` diff --git a/tests/e2e/api-direct/search-views-presentation.spec.ts b/tests/e2e/api-direct/search-views-presentation.spec.ts index f6eac8f868..eb0980fea4 100644 --- a/tests/e2e/api-direct/search-views-presentation.spec.ts +++ b/tests/e2e/api-direct/search-views-presentation.spec.ts @@ -1,3 +1,5 @@ +import type { SeededRegister, SeededSchema } from '../_fixtures.ts' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -17,18 +19,16 @@ * * @e2e openspec/specs/saved-search-views/spec.md */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import { - makeRunId, + createObject, createRegister, createSchema, - linkSchemaToRegister, - createObject, - deleteSchema, deleteRegister, - type SeededRegister, - type SeededSchema, -} from '../_fixtures' + deleteSchema, + linkSchemaToRegister, + makeRunId, +} from '../_fixtures.ts' const RUN_ID = makeRunId() const API = '/index.php/apps/openregister/api' diff --git a/tests/e2e/api-direct/search-views.spec.ts b/tests/e2e/api-direct/search-views.spec.ts index a21c961a00..6a9b46618d 100644 --- a/tests/e2e/api-direct/search-views.spec.ts +++ b/tests/e2e/api-direct/search-views.spec.ts @@ -11,7 +11,7 @@ * These are purely API-level tests. The larpingapp register (8/18) has * at least one seeded object with searchable string fields. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const RUN_ID = `e2e-${Date.now()}` const REGISTER_ID = '8' diff --git a/tests/e2e/api-direct/security-rbac.spec.ts b/tests/e2e/api-direct/security-rbac.spec.ts index 1659618a94..5239537fbc 100644 --- a/tests/e2e/api-direct/security-rbac.spec.ts +++ b/tests/e2e/api-direct/security-rbac.spec.ts @@ -10,7 +10,7 @@ * * These are API tests verifying auth/security surface behaviors. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' // ───────────────────────────────────────────────────────────────────────────── // auth-system — authentication method verification @@ -49,7 +49,7 @@ test.describe('auth-system — authentication methods', () => { expect(contentType, 'response must be JSON').toContain('application/json') const body = (await resp.json()) as Record expect(body, 'response must have results array').toHaveProperty('results') - expect(Array.isArray(body['results']), 'results must be an array').toBe(true) + expect(Array.isArray(body.results), 'results must be an array').toBe(true) }) test('wrong credentials return 401', async ({ request }) => { @@ -110,20 +110,7 @@ test.describe('rbac-scopes — OAS scope generation', () => { ) expect(resp.status()).toBe(200) const body = await resp.json() - const paths = body.paths ?? {} // Verify at least one path operation has a security block. - const hasSecurity = Object.values(paths).some((pathItem: unknown) => { - if (typeof pathItem !== 'object' || pathItem === null) return false - return Object.values(pathItem as Record).some( - (op: unknown) => { - if (typeof op !== 'object' || op === null) return false - return ( - 'security' in (op as object) - || 'x-security' in (op as object) - ) - }, - ) - }) // Soft check: OAS security generation may be partially implemented. // Just verify the shape is valid OpenAPI. expect(body).toHaveProperty('paths') diff --git a/tests/e2e/ci/flow-controls.spec.ts b/tests/e2e/ci/flow-controls.spec.ts index 7efa83c50d..72ad381130 100644 --- a/tests/e2e/ci/flow-controls.spec.ts +++ b/tests/e2e/ci/flow-controls.spec.ts @@ -101,9 +101,12 @@ * are 5s router and read-back assertions. If any of these defects returns, this * spec fails in about a second, quoting the server's own reason. */ -import { test, expect } from '@playwright/test' -import * as path from 'path' +import type { Locator } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') @@ -138,9 +141,7 @@ test.use(fs.existsSync(STORAGE_STATE) ? { storageState: STORAGE_STATE } : {}) * @param locator The themed control to click. * @return {Promise} */ -async function clickThemed( - locator: import('@playwright/test').Locator, -): Promise { +async function clickThemed(locator: Locator): Promise { await expect(locator).toBeVisible() await expect(locator).toBeEnabled() await locator.dispatchEvent('click') @@ -156,10 +157,7 @@ async function clickThemed( * @param uuid The flow to delete. * @return {Promise} */ -async function deleteFlow( - page: import('@playwright/test').Page, - uuid: string, -): Promise { +async function deleteFlow(page: Page, uuid: string): Promise { await page.evaluate(async (id) => { const meta = document.head.querySelector( 'meta[name=csrf-token]', @@ -168,7 +166,6 @@ async function deleteFlow( method: 'DELETE', headers: { requesttoken: - // eslint-disable-next-line @typescript-eslint/no-explicit-any ((window as any).OC && (window as any).OC.requestToken) || (meta && meta.content) || '', diff --git a/tests/e2e/ci/object-shares-tab.spec.ts b/tests/e2e/ci/object-shares-tab.spec.ts index a8fe68fea6..55b98cddf1 100644 --- a/tests/e2e/ci/object-shares-tab.spec.ts +++ b/tests/e2e/ci/object-shares-tab.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext, Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -28,14 +30,8 @@ * accounts come from the workflow's `playwright-seed-command`, shared with the * sibling spec — see the note there on why they are fixed rather than per-run. */ -import { - test, - expect, - request as pwRequest, - type APIRequestContext, - type Page, -} from '@playwright/test' -import { resolveBaseUrl } from '../base-url' +import { expect, request as pwRequest, test } from '@playwright/test' +import { resolveBaseUrl } from '../base-url.ts' const BASE = resolveBaseUrl() const ADMIN = process.env.ADMIN_USER || process.env.OR_USER || 'admin' diff --git a/tests/e2e/ci/object-sharing.spec.ts b/tests/e2e/ci/object-sharing.spec.ts index 2554de07d2..4688ca6664 100644 --- a/tests/e2e/ci/object-sharing.spec.ts +++ b/tests/e2e/ci/object-sharing.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -20,13 +22,8 @@ * object, and deletes the users at the end. It needs no `occ`, no docker, and no * pre-seeded data, which is what makes it safe to run on every push. */ -import { - test, - expect, - request as pwRequest, - type APIRequestContext, -} from '@playwright/test' -import { resolveBaseUrl } from '../base-url' +import { expect, request as pwRequest, test } from '@playwright/test' +import { resolveBaseUrl } from '../base-url.ts' const BASE = resolveBaseUrl() const ADMIN = process.env.ADMIN_USER || process.env.OR_USER || 'admin' diff --git a/tests/e2e/ci/playwright.config.ts b/tests/e2e/ci/playwright.config.ts index 659fe1414e..2335ad1992 100644 --- a/tests/e2e/ci/playwright.config.ts +++ b/tests/e2e/ci/playwright.config.ts @@ -101,7 +101,7 @@ */ import { defineConfig, devices } from '@playwright/test' import * as path from 'path' -import { resolveBaseUrl } from '../base-url' +import { resolveBaseUrl } from '../base-url.ts' export default defineConfig({ // Rooted at `tests/e2e` so the allow-list below can admit a spec without diff --git a/tests/e2e/ci/smoke-boot.spec.ts b/tests/e2e/ci/smoke-boot.spec.ts index 849571fd4c..9087c372cd 100644 --- a/tests/e2e/ci/smoke-boot.spec.ts +++ b/tests/e2e/ci/smoke-boot.spec.ts @@ -1,3 +1,5 @@ +import type { ConsoleMessage } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -30,9 +32,9 @@ * ⚠️ `toBeVisible()` on a container is NOT sufficient — the shell is visible * when the app is dead. Assert on rendered descendants. */ -import { test, expect, type ConsoleMessage } from '@playwright/test' -import * as path from 'path' +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') diff --git a/tests/e2e/core-crud.spec.ts b/tests/e2e/core-crud.spec.ts index 9ec8bcaac2..3aa78135da 100644 --- a/tests/e2e/core-crud.spec.ts +++ b/tests/e2e/core-crud.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -14,7 +16,7 @@ * test object. Creates/modifies/deletes objects under a unique run-id * prefix to avoid collisions with other test agents. */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '.auth/admin.json') diff --git a/tests/e2e/crud/object-crud.spec.ts b/tests/e2e/crud/object-crud.spec.ts index 34cf0f283d..2a5a8a1a94 100644 --- a/tests/e2e/crud/object-crud.spec.ts +++ b/tests/e2e/crud/object-crud.spec.ts @@ -1,3 +1,6 @@ +import type { APIRequestContext } from '@playwright/test' +import type { SeededRegister, SeededSchema } from '../_fixtures.ts' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -28,19 +31,17 @@ * persisted data) as the render signal. The UI-create gap is tracked as * test.fixme rather than hidden. */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' import { - makeRunId, createRegister, createSchema, - linkSchemaToRegister, deleteRegister, deleteSchema, + linkSchemaToRegister, + makeRunId, twoPropertySchema, - type SeededRegister, - type SeededSchema, -} from '../_fixtures' +} from '../_fixtures.ts' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') const API = '/index.php/apps/openregister/api' diff --git a/tests/e2e/crud/register-crud.spec.ts b/tests/e2e/crud/register-crud.spec.ts index 1afab994e1..2a5c8c5b20 100644 --- a/tests/e2e/crud/register-crud.spec.ts +++ b/tests/e2e/crud/register-crud.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -26,9 +28,9 @@ * Everything created here is namespaced with a per-run prefix and removed in * afterAll (belt-and-braces; the delete test removes it in the happy path). */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' -import { makeRunId } from '../_fixtures' +import { makeRunId } from '../_fixtures.ts' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') // HASH form — the router runs in hash mode (src/main.js); the path-form URL diff --git a/tests/e2e/crud/schema-crud.spec.ts b/tests/e2e/crud/schema-crud.spec.ts index e484614afc..42f2bd3df4 100644 --- a/tests/e2e/crud/schema-crud.spec.ts +++ b/tests/e2e/crud/schema-crud.spec.ts @@ -22,9 +22,9 @@ * rendered table row — this is exactly the persistence guarantee the shell * tests do not cover. */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' -import { makeRunId, twoPropertySchema } from '../_fixtures' +import { makeRunId, twoPropertySchema } from '../_fixtures.ts' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') // HASH form — the router runs in hash mode (src/main.js); the path-form URL diff --git a/tests/e2e/docs-screenshots.spec.ts b/tests/e2e/docs-screenshots.spec.ts index 15f8738810..8082d4536f 100644 --- a/tests/e2e/docs-screenshots.spec.ts +++ b/tests/e2e/docs-screenshots.spec.ts @@ -37,13 +37,15 @@ * Pattern reference: ADR-030 (hydra/openspec/architecture/). */ -import { test, expect, type Page } from '@playwright/test' -import * as path from 'path' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' // Routes are imported by COMPONENT NAME (see tests/e2e/_page-routes.ts): the // binding records which page host each route mounts, which a bare path string // cannot say. Also what makes this suite legible to gate-26. -import { AuditTrailIndex } from './_page-routes' +import { AuditTrailIndex } from './_page-routes.ts' const SHOT_ROOT = path.resolve( __dirname, diff --git a/tests/e2e/flow-engine.spec.ts b/tests/e2e/flow-engine.spec.ts index 4852ab79bc..cbd8024d75 100644 --- a/tests/e2e/flow-engine.spec.ts +++ b/tests/e2e/flow-engine.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -19,17 +21,12 @@ * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md * @spec openspec/changes/flow-engine-unification/specs/flow-execution-history/spec.md */ -import { - test, - expect, - request as apiRequest, - type APIRequestContext, -} from '@playwright/test' +import { request as apiRequest, expect, test } from '@playwright/test' import * as path from 'path' // Routes are imported by COMPONENT NAME (see tests/e2e/_page-routes.ts): the // binding records which page host each route mounts, which a bare path string // cannot say. Also what makes this suite legible to gate-26. -import { FlowsIndex, FlowDetailPage } from './_page-routes' +import { FlowDetailPage, FlowsIndex } from './_page-routes.ts' const STORAGE_STATE = path.resolve(__dirname, '.auth/admin.json') diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 5017071a29..93b400d3a4 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -18,13 +18,15 @@ * Pattern reference: ADR-030 (hydra/openspec/architecture/). */ -import { chromium, expect, request, type FullConfig } from '@playwright/test' +import type { FullConfig } from '@playwright/test' + +import { seedFirstVisitOverlaysSeen } from '@conduction/nextcloud-vue/testing/playwright' +import { chromium, expect, request } from '@playwright/test' import { execSync } from 'child_process' -import * as path from 'path' import * as fs from 'fs' -import { seedMdm } from './mdm-seed' -import { resolveBaseUrl } from './base-url' -import { seedFirstVisitOverlaysSeen } from '@conduction/nextcloud-vue/testing/playwright' +import * as path from 'path' +import { resolveBaseUrl } from './base-url.ts' +import { seedMdm } from './mdm-seed.ts' const AUTH_DIR = path.resolve(__dirname, '.auth') const STORAGE_STATE = path.join(AUTH_DIR, 'admin.json') @@ -48,7 +50,7 @@ function ensureBundleBuilt(): void { if (fs.existsSync(BUNDLE_PATH)) { return } - // eslint-disable-next-line no-console + console.log( `[playwright globalSetup] bundle missing at ${BUNDLE_PATH}; running 'npm run build' once…`, ) @@ -254,7 +256,7 @@ export default async function globalSetup(config: FullConfig): Promise { 'cn-walkthrough-seen:openregister', '999.0.0', ) - } catch (e) { + } catch { // localStorage unavailable — specs fall back to dismissing by hand. } }) @@ -280,7 +282,7 @@ export default async function globalSetup(config: FullConfig): Promise { }) try { const seed = await seedMdm(apiContext) - // eslint-disable-next-line no-console + console.log( seed ? `[playwright globalSetup] MDM fixture seeded (register ${seed.register}, schema ${seed.masterEntitySchema}, dup pair ${seed.dupPair.join(' + ')}).` @@ -290,7 +292,6 @@ export default async function globalSetup(config: FullConfig): Promise { await apiContext.dispose() } } catch (err) { - // eslint-disable-next-line no-console console.warn( `[playwright globalSetup] MDM seeding failed (continuing): ${(err as Error).message}`, ) diff --git a/tests/e2e/integration-mount.spec.ts b/tests/e2e/integration-mount.spec.ts index 499291ecd1..f41b39fb02 100644 --- a/tests/e2e/integration-mount.spec.ts +++ b/tests/e2e/integration-mount.spec.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext, Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -45,7 +47,7 @@ * NEXTCLOUD_URL=http://localhost:8080 npx playwright test \ * tests/e2e/integration-mount.spec.ts --project=chromium */ -import { test, expect, type APIRequestContext, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' /** * Fetch the registry's advertised providers from OCS capabilities. The diff --git a/tests/e2e/leaf-screenshots.spec.ts b/tests/e2e/leaf-screenshots.spec.ts index a9791f68b7..f7602cb1c3 100644 --- a/tests/e2e/leaf-screenshots.spec.ts +++ b/tests/e2e/leaf-screenshots.spec.ts @@ -14,15 +14,15 @@ * NEXTCLOUD_URL=http://localhost:8080 npx playwright test \ * tests/e2e/leaf-screenshots.spec.ts --project=chromium */ -import { test, expect } from '@playwright/test' -import * as path from 'path' +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' // The route is COMPOSED from the component binding (tests/e2e/_page-routes.ts) // rather than repeated as a template string here. The spec already drove this // page; what it could not say in executable code was WHICH page host it drove. // Importing the builder makes that binding load-bearing: change the manifest // route and this line changes with it. -import { IntegrationsView } from './_page-routes' +import { IntegrationsView } from './_page-routes.ts' const REGISTER = '21' const SCHEMA = '166' diff --git a/tests/e2e/manifest-shell.spec.ts b/tests/e2e/manifest-shell.spec.ts index ae6f3ab30a..5c4253d8f7 100644 --- a/tests/e2e/manifest-shell.spec.ts +++ b/tests/e2e/manifest-shell.spec.ts @@ -27,9 +27,11 @@ * * Uses storageState from global-setup. */ -import { test, expect, Page } from '@playwright/test' -import * as path from 'path' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '.auth/admin.json') const APP_BASE = '/index.php/apps/openregister/#' diff --git a/tests/e2e/mdm-seed.ts b/tests/e2e/mdm-seed.ts index 7ccbc54d61..083b42c377 100644 --- a/tests/e2e/mdm-seed.ts +++ b/tests/e2e/mdm-seed.ts @@ -33,7 +33,8 @@ * All requests use Playwright's APIRequestContext (Basic-auth admin/admin via * the caller's context), so seeding works without a browser session. */ -import { type APIRequestContext } from '@playwright/test' +import type { APIRequestContext } from '@playwright/test' + import * as fs from 'fs' import * as path from 'path' diff --git a/tests/e2e/smoke-boot.spec.ts b/tests/e2e/smoke-boot.spec.ts index 7b8f460f80..99f1cd7078 100644 --- a/tests/e2e/smoke-boot.spec.ts +++ b/tests/e2e/smoke-boot.spec.ts @@ -1,3 +1,5 @@ +import type { ConsoleMessage } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -30,9 +32,9 @@ * ⚠️ `toBeVisible()` on a container is NOT sufficient — the shell is visible * when the app is dead. Assert on rendered descendants. */ -import { test, expect, type ConsoleMessage } from '@playwright/test' -import * as path from 'path' +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '.auth/admin.json') diff --git a/tests/e2e/spec-coverage/admin-settings-pages.spec.ts b/tests/e2e/spec-coverage/admin-settings-pages.spec.ts index 632aae6b49..c00249f2ac 100644 --- a/tests/e2e/spec-coverage/admin-settings-pages.spec.ts +++ b/tests/e2e/spec-coverage/admin-settings-pages.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -16,19 +18,19 @@ * @e2e openspec/specs/tenant-isolation-audit/spec.md * @e2e openspec/specs/data-import-export/spec.md */ -import { test, expect, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' // Routes are imported by COMPONENT NAME (see tests/e2e/_page-routes.ts): the // binding records which page host each route mounts, which a bare path string // cannot say. Also what makes this suite legible to gate-26. import { - OrganisationsIndex, ConfigurationsIndex, - WebhooksIndex, - WebhookLogsIndex, EndpointsIndex, + OrganisationsIndex, SearchTrailIndex, -} from '../_page-routes' + WebhookLogsIndex, + WebhooksIndex, +} from '../_page-routes.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') diff --git a/tests/e2e/spec-coverage/core-list-pages.spec.ts b/tests/e2e/spec-coverage/core-list-pages.spec.ts index 599896ad52..ed467b315a 100644 --- a/tests/e2e/spec-coverage/core-list-pages.spec.ts +++ b/tests/e2e/spec-coverage/core-list-pages.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -18,18 +20,18 @@ * @e2e openspec/specs/no-code-app-builder/spec.md * @e2e openspec/specs/frontend-app-bootstrap/spec.md */ -import { test, expect, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' // Routes are imported by COMPONENT NAME (see tests/e2e/_page-routes.ts): the // binding records which page host each route mounts, which a bare path string // cannot say. Also what makes this suite legible to gate-26. import { - SchemasIndex, - TemplatesIndex, - SourcesIndex, ApplicationsIndex, ObjectsIndex, -} from '../_page-routes' + SchemasIndex, + SourcesIndex, + TemplatesIndex, +} from '../_page-routes.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') diff --git a/tests/e2e/spec-coverage/data-import-export.spec.ts b/tests/e2e/spec-coverage/data-import-export.spec.ts index 8a629f378b..84d168f137 100644 --- a/tests/e2e/spec-coverage/data-import-export.spec.ts +++ b/tests/e2e/spec-coverage/data-import-export.spec.ts @@ -32,15 +32,13 @@ * The test file still validates that the ImportRegister modal is * reachable and the objects view loads (supporting existing coverage). */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as fs from 'fs' import * as path from 'path' -import { resolveBaseUrl } from '../base-url' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') // ⚠️ No `|| 'http://localhost:8080'` — that is the SHARED dev container. // See ../base-url.ts. -const BASE_URL = resolveBaseUrl() // ───────────────────────────────────────────────────────────────────────────── // EXCLUDED scenario diff --git a/tests/e2e/spec-coverage/detail-pages.spec.ts b/tests/e2e/spec-coverage/detail-pages.spec.ts index a6fa3d8eea..184f655f89 100644 --- a/tests/e2e/spec-coverage/detail-pages.spec.ts +++ b/tests/e2e/spec-coverage/detail-pages.spec.ts @@ -1,3 +1,6 @@ +import type { APIRequestContext, Page } from '@playwright/test' +import type { SeededObject, SeededRegister, SeededSchema } from '../_fixtures.ts' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -70,12 +73,9 @@ * @e2e openspec/specs/flow-engine/spec.md * @e2e openspec/specs/rapportage-bi-export/spec.md */ -import { test, expect, type APIRequestContext, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' import { - type SeededObject, - type SeededRegister, - type SeededSchema, createObject, createSchema, deleteObject, @@ -84,7 +84,7 @@ import { linkSchemaToRegister, makeRunId, twoPropertySchema, -} from '../_fixtures' +} from '../_fixtures.ts' // Routes are imported by COMPONENT NAME (see tests/e2e/_page-routes.ts): the // binding records which page host each route mounts, which a bare path string // cannot say. Also what makes this suite legible to gate-26. @@ -93,7 +93,7 @@ import { FlowDetailPage, ReportView, SchemaDetails, -} from '../_page-routes' +} from '../_page-routes.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') const API = '/index.php/apps/openregister/api' diff --git a/tests/e2e/spec-coverage/entity-management-modals.spec.ts b/tests/e2e/spec-coverage/entity-management-modals.spec.ts index 40db64c549..d03727100f 100644 --- a/tests/e2e/spec-coverage/entity-management-modals.spec.ts +++ b/tests/e2e/spec-coverage/entity-management-modals.spec.ts @@ -22,12 +22,15 @@ * confirm-delete-on-a-single-agent (ffafd1c14, moved to hermiq) */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import type { Page } from '@playwright/test' +import type { APIRequestContext } from '@playwright/test' + +import { expect, test } from '@playwright/test' import * as path from 'path' // Routes are imported by COMPONENT NAME (see tests/e2e/_page-routes.ts): the // binding records which page host each route mounts, which a bare path string // cannot say. Also what makes this suite legible to gate-26. -import { DeletedIndex } from '../_page-routes' +import { DeletedIndex } from '../_page-routes.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') @@ -77,10 +80,7 @@ async function deleteTestObject( } /** Navigate to the OR app route (hash form) and wait for NC header. */ -async function gotoApp( - page: import('@playwright/test').Page, - subpath: string, -): Promise { +async function gotoApp(page: Page, subpath: string): Promise { // HASH form — the router runs in hash mode (src/main.js); path-form // deep-links render the dashboard instead of the target page. await page.goto(`/index.php/apps/openregister/#${subpath}`, { diff --git a/tests/e2e/spec-coverage/feature-pages.spec.ts b/tests/e2e/spec-coverage/feature-pages.spec.ts index b45f0fc930..b40e597c2f 100644 --- a/tests/e2e/spec-coverage/feature-pages.spec.ts +++ b/tests/e2e/spec-coverage/feature-pages.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -21,18 +23,18 @@ * @e2e openspec/specs/built-in-dashboards/spec.md * @e2e openspec/specs/account-self-service/spec.md */ -import { test, expect, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' // Routes are imported by COMPONENT NAME (see tests/e2e/_page-routes.ts): the // binding records which page host each route mounts, which a bare path string // cannot say. Also what makes this suite legible to gate-26. import { - FilesIndex, AvgIndex, - ReportsIndex, - MyAccount, FeaturesRoadmapIndex, -} from '../_page-routes' + FilesIndex, + MyAccount, + ReportsIndex, +} from '../_page-routes.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') @@ -57,9 +59,6 @@ const NOISE = [ // every route. Filtered BY URL, not by status: any other 404 still fails. '/apps/hermiq/', ] -function isNoise(t: string): boolean { - return NOISE.some((n) => t.includes(n)) -} function trackErrors( page: Page, diff --git a/tests/e2e/spec-coverage/features-roadmap-surface.spec.ts b/tests/e2e/spec-coverage/features-roadmap-surface.spec.ts index bb53a1b074..6405235c6c 100644 --- a/tests/e2e/spec-coverage/features-roadmap-surface.spec.ts +++ b/tests/e2e/spec-coverage/features-roadmap-surface.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -36,7 +38,7 @@ * @e2e openspec/specs/features-roadmap-menu/spec.md#submit-requires-title-and-body * @e2e openspec/specs/features-roadmap-menu/spec.md#default-behavior */ -import { test, expect, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') diff --git a/tests/e2e/spec-coverage/files-sidebar-tabs.spec.ts b/tests/e2e/spec-coverage/files-sidebar-tabs.spec.ts index 0e8988d963..dd6e7fc0c3 100644 --- a/tests/e2e/spec-coverage/files-sidebar-tabs.spec.ts +++ b/tests/e2e/spec-coverage/files-sidebar-tabs.spec.ts @@ -30,7 +30,9 @@ * by SideBars.vue for the /entities route, controlled by navigationStore.sidebarState.entities. */ -import { test, expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') diff --git a/tests/e2e/spec-coverage/mdm-frontend.spec.ts b/tests/e2e/spec-coverage/mdm-frontend.spec.ts index 291a950780..0906e25a1b 100644 --- a/tests/e2e/spec-coverage/mdm-frontend.spec.ts +++ b/tests/e2e/spec-coverage/mdm-frontend.spec.ts @@ -17,9 +17,11 @@ * and degrade to test.skip(), so the suite still runs everywhere. */ -import { test, expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' import * as path from 'path' -import { readMdmSeed } from '../mdm-seed' +import { readMdmSeed } from '../mdm-seed.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') const seed = readMdmSeed() diff --git a/tests/e2e/spec-coverage/mdm-merge-ui.spec.ts b/tests/e2e/spec-coverage/mdm-merge-ui.spec.ts index dff35f9f39..f981b3dde7 100644 --- a/tests/e2e/spec-coverage/mdm-merge-ui.spec.ts +++ b/tests/e2e/spec-coverage/mdm-merge-ui.spec.ts @@ -17,9 +17,11 @@ * to test.skip(). */ -import { test, expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' import * as path from 'path' -import { readMdmSeed } from '../mdm-seed' +import { readMdmSeed } from '../mdm-seed.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') const seed = readMdmSeed() diff --git a/tests/e2e/spec-coverage/mdm-survivorship-override.spec.ts b/tests/e2e/spec-coverage/mdm-survivorship-override.spec.ts index d56bdee4a3..ba67e1e243 100644 --- a/tests/e2e/spec-coverage/mdm-survivorship-override.spec.ts +++ b/tests/e2e/spec-coverage/mdm-survivorship-override.spec.ts @@ -17,9 +17,11 @@ * first entity and degrades to test.skip(). */ -import { test, expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' import * as path from 'path' -import { readMdmSeed } from '../mdm-seed' +import { readMdmSeed } from '../mdm-seed.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') const seed = readMdmSeed() diff --git a/tests/e2e/spec-coverage/object-views-kanban-calendar.spec.ts b/tests/e2e/spec-coverage/object-views-kanban-calendar.spec.ts index 60819264bc..6c2d0a27cc 100644 --- a/tests/e2e/spec-coverage/object-views-kanban-calendar.spec.ts +++ b/tests/e2e/spec-coverage/object-views-kanban-calendar.spec.ts @@ -1,3 +1,6 @@ +import type { Page } from '@playwright/test' +import type { SeededRegister, SeededSchema } from '../_fixtures.ts' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -21,19 +24,17 @@ * * @e2e openspec/specs/saved-search-views/spec.md */ -import { test, expect, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' import { - makeRunId, + createObject, createRegister, createSchema, - linkSchemaToRegister, - createObject, - deleteSchema, deleteRegister, - type SeededRegister, - type SeededSchema, -} from '../_fixtures' + deleteSchema, + linkSchemaToRegister, + makeRunId, +} from '../_fixtures.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') const API = '/index.php/apps/openregister/api' diff --git a/tests/e2e/spec-coverage/platform-administration-modals.spec.ts b/tests/e2e/spec-coverage/platform-administration-modals.spec.ts index 1cb4ee66cb..6029804198 100644 --- a/tests/e2e/spec-coverage/platform-administration-modals.spec.ts +++ b/tests/e2e/spec-coverage/platform-administration-modals.spec.ts @@ -44,10 +44,10 @@ * navigating to the admin settings page, clicking the Clear Cache * button, confirming, and asserting the success state. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as fs from 'fs' import * as path from 'path' -import { resolveBaseUrl } from '../base-url' +import { resolveBaseUrl } from '../base-url.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') // ⚠️ Never `|| 'http://localhost:8080'` — that is the SHARED dev container. @@ -90,7 +90,7 @@ test.describe('platform-administration-modals — LLM modal settings load', () = expect(body, 'response body should be an object').toBeTruthy() // 'enabled' key maps to llmEnabled expect( - Object.prototype.hasOwnProperty.call(body, 'enabled'), + Object.hasOwn(body, 'enabled'), 'llm settings should have "enabled" key', ).toBe(true) // openaiConfig and ollamaConfig sections must exist diff --git a/tests/e2e/spec-coverage/register-i18n.spec.ts b/tests/e2e/spec-coverage/register-i18n.spec.ts index d8ef41642e..861bc2c586 100644 --- a/tests/e2e/spec-coverage/register-i18n.spec.ts +++ b/tests/e2e/spec-coverage/register-i18n.spec.ts @@ -49,10 +49,10 @@ * reorder-languages-to-change-default * language-selector-shows-native-names */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as fs from 'fs' import * as path from 'path' -import { resolveBaseUrl } from '../base-url' +import { resolveBaseUrl } from '../base-url.ts' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') // ⚠️ No `|| 'http://localhost:8080'` — that is the SHARED dev container. @@ -310,13 +310,13 @@ test.describe('register-i18n — register language management UI', () => { await ctx.dispose() }) - await page.goto(`/index.php/apps/openregister/#/registers`, { - waitUntil: 'domcontentloaded', - }) + await page + .goto(`/index.php/apps/openregister/#/registers`, { + waitUntil: 'domcontentloaded', + }) - // Open the register sidebar for the test register - // Look for a clickable register row that matches our id - const registerRow = page + // Open the register sidebar for the test register + // Look for a clickable register row that matches our id .locator( `[href*="/registers/${registerId}"], [data-id="${registerId}"], .list-item`, ) diff --git a/tests/e2e/spec-coverage/saved-search-views.spec.ts b/tests/e2e/spec-coverage/saved-search-views.spec.ts index 1d3a8a7a3a..c9f686fe3a 100644 --- a/tests/e2e/spec-coverage/saved-search-views.spec.ts +++ b/tests/e2e/spec-coverage/saved-search-views.spec.ts @@ -28,13 +28,9 @@ * the-view-list-is-filtered-and-favorite-sorted — UI test */ -import { - test, - expect, - type APIRequestContext, - type Page, - type Route, -} from '@playwright/test' +import type { APIRequestContext, Page, Route } from '@playwright/test' + +import { expect, test } from '@playwright/test' import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') @@ -814,7 +810,6 @@ test.describe('saved-search-views — the-view-list-is-filtered-and-favorite-sor .evaluate(() => { // Access Pinia store from the Vue app. try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const pinia = (window as any).__pinia if (!pinia) return null const stores = Object.values(pinia.state.value) as Record< diff --git a/tests/e2e/ui-navigation.spec.ts b/tests/e2e/ui-navigation.spec.ts index afb171c984..1dc771aeac 100644 --- a/tests/e2e/ui-navigation.spec.ts +++ b/tests/e2e/ui-navigation.spec.ts @@ -12,9 +12,9 @@ * * Uses storageState from global-setup. */ -import { test, expect } from '@playwright/test' -import * as path from 'path' +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '.auth/admin.json') diff --git a/tests/e2e/visual/_visual-helpers.ts b/tests/e2e/visual/_visual-helpers.ts index a96bb7b581..0d80da57c4 100644 --- a/tests/e2e/visual/_visual-helpers.ts +++ b/tests/e2e/visual/_visual-helpers.ts @@ -1,3 +1,5 @@ +import type { Locator, Page } from '@playwright/test' + /* * SPDX-License-Identifier: EUPL-1.2 * @@ -24,7 +26,7 @@ * own baselines on first run, or (b) stay non-gating until baselined in the CI * environment. See tests/e2e/visual/README in-repo wiring notes. */ -import { expect, type Page, type Locator } from '@playwright/test' +import { expect } from '@playwright/test' /** Common screenshot options applied to every visual assertion. */ export const SHOT_OPTIONS = { diff --git a/tests/e2e/visual/dsar-cases.visual.spec.ts b/tests/e2e/visual/dsar-cases.visual.spec.ts index b4b7cbbfbf..f4d49faeb6 100644 --- a/tests/e2e/visual/dsar-cases.visual.spec.ts +++ b/tests/e2e/visual/dsar-cases.visual.spec.ts @@ -16,9 +16,9 @@ * a baseline is committed this spec is non-gating (first run generates it). */ import { test } from '@playwright/test' -import * as path from 'path' import * as fs from 'fs' -import { shootSurface } from './_visual-helpers' +import * as path from 'path' +import { shootSurface } from './_visual-helpers.ts' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') const APP = '/index.php/apps/openregister' diff --git a/tests/e2e/visual/mdm-frontend.visual.spec.ts b/tests/e2e/visual/mdm-frontend.visual.spec.ts index 56ac378eb9..1fbcae0336 100644 --- a/tests/e2e/visual/mdm-frontend.visual.spec.ts +++ b/tests/e2e/visual/mdm-frontend.visual.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -14,15 +16,15 @@ * Baselines live in tests/e2e/visual/-snapshots/ and ARE committed. * See _visual-helpers.ts for the platform-rendering caveat. */ -import { test, expect, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import { - shootSurface, - waitForContentReady, dismissSupportDialog, + dynamicMasks, freezePage, + shootSurface, SHOT_OPTIONS, - dynamicMasks, -} from './_visual-helpers' + waitForContentReady, +} from './_visual-helpers.ts' const APP = '/index.php/apps/openregister' diff --git a/tests/e2e/visual/mdm-merge-ui.visual.spec.ts b/tests/e2e/visual/mdm-merge-ui.visual.spec.ts index 6e1af82b27..8f346f47ca 100644 --- a/tests/e2e/visual/mdm-merge-ui.visual.spec.ts +++ b/tests/e2e/visual/mdm-merge-ui.visual.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -14,15 +16,15 @@ * Baselines live in tests/e2e/visual/-snapshots/ and ARE committed. * See _visual-helpers.ts for the platform-rendering caveat. */ -import { test, expect, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import { - shootSurface, - waitForContentReady, dismissSupportDialog, + dynamicMasks, freezePage, + shootSurface, SHOT_OPTIONS, - dynamicMasks, -} from './_visual-helpers' + waitForContentReady, +} from './_visual-helpers.ts' const APP = '/index.php/apps/openregister' diff --git a/tests/e2e/visual/mdm-survivorship-override.visual.spec.ts b/tests/e2e/visual/mdm-survivorship-override.visual.spec.ts index e416494ab4..c40a5ffab4 100644 --- a/tests/e2e/visual/mdm-survivorship-override.visual.spec.ts +++ b/tests/e2e/visual/mdm-survivorship-override.visual.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -13,14 +15,14 @@ * Baselines live in tests/e2e/visual/-snapshots/ and ARE committed. * See _visual-helpers.ts for the platform-rendering caveat. */ -import { test, expect, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import { dismissSupportDialog, - waitForContentReady, + dynamicMasks, freezePage, SHOT_OPTIONS, - dynamicMasks, -} from './_visual-helpers' + waitForContentReady, +} from './_visual-helpers.ts' const APP = '/index.php/apps/openregister' diff --git a/tests/e2e/visual/openregister.visual.spec.ts b/tests/e2e/visual/openregister.visual.spec.ts index ee908b2c5e..8bb27dc215 100644 --- a/tests/e2e/visual/openregister.visual.spec.ts +++ b/tests/e2e/visual/openregister.visual.spec.ts @@ -11,7 +11,7 @@ * See _visual-helpers.ts for the platform-rendering caveat. */ import { test } from '@playwright/test' -import { shootSurface, shootByNav } from './_visual-helpers' +import { shootSurface } from './_visual-helpers.ts' const APP = '/index.php/apps/openregister' diff --git a/tests/e2e/workflows/archival-transfer-hardening.spec.ts b/tests/e2e/workflows/archival-transfer-hardening.spec.ts index 5c61209ba6..a9a018b22d 100644 --- a/tests/e2e/workflows/archival-transfer-hardening.spec.ts +++ b/tests/e2e/workflows/archival-transfer-hardening.spec.ts @@ -24,9 +24,9 @@ * triggerable. They skip cleanly when the shared auth storageState or the * required fixtures are absent. All ids are safe placeholders. */ -import { test, expect } from '@playwright/test' -import * as path from 'path' +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') const API_BASE = '/index.php/apps/openregister/api' diff --git a/tests/e2e/workflows/dsar-cases.spec.ts b/tests/e2e/workflows/dsar-cases.spec.ts index a46af30c0f..494cffd784 100644 --- a/tests/e2e/workflows/dsar-cases.spec.ts +++ b/tests/e2e/workflows/dsar-cases.spec.ts @@ -19,9 +19,9 @@ * (the app is not reachable in the current environment). Any placeholder * subject/token is a safe placeholder — never a real BSN or secret. */ -import { test, expect } from '@playwright/test' -import * as path from 'path' +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') const AVG_URL = '/index.php/apps/openregister/#/avg' diff --git a/tests/e2e/workflows/dsar-escalation-and-dpia.spec.ts b/tests/e2e/workflows/dsar-escalation-and-dpia.spec.ts index f38336e7a7..b9468f1289 100644 --- a/tests/e2e/workflows/dsar-escalation-and-dpia.spec.ts +++ b/tests/e2e/workflows/dsar-escalation-and-dpia.spec.ts @@ -27,9 +27,9 @@ * cleanly when the shared auth storageState or the required fixtures are * absent. All ids are safe placeholders — never real subject data. */ -import { test, expect } from '@playwright/test' -import * as path from 'path' +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') const API_BASE = '/index.php/apps/openregister/api' diff --git a/tests/e2e/workflows/object-lifecycle-workflows.spec.ts b/tests/e2e/workflows/object-lifecycle-workflows.spec.ts index 55e5dd59d4..242025019d 100644 --- a/tests/e2e/workflows/object-lifecycle-workflows.spec.ts +++ b/tests/e2e/workflows/object-lifecycle-workflows.spec.ts @@ -1,3 +1,6 @@ +import type { APIRequestContext } from '@playwright/test' +import type { SeededRegister, SeededSchema } from '../_fixtures.ts' + /* * SPDX-FileCopyrightText: 2026 Open Register Contributors * SPDX-License-Identifier: EUPL-1.2 @@ -35,20 +38,18 @@ * BUG-3 GET /api/objects/{register}/{schema}?_includeDeleted=true returns * HTTP 500 (cannot list including soft-deleted objects). */ -import { test, expect, type APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' import { - makeRunId, + createObject, createRegister, createSchema, - linkSchemaToRegister, - createObject, deleteRegister, deleteSchema, + linkSchemaToRegister, + makeRunId, twoPropertySchema, - type SeededRegister, - type SeededSchema, -} from '../_fixtures' +} from '../_fixtures.ts' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') const API = '/index.php/apps/openregister/api' diff --git a/tests/e2e/workflows/semantic-object-handoff.spec.ts b/tests/e2e/workflows/semantic-object-handoff.spec.ts index d1a3cc1b51..3a573095c6 100644 --- a/tests/e2e/workflows/semantic-object-handoff.spec.ts +++ b/tests/e2e/workflows/semantic-object-handoff.spec.ts @@ -20,9 +20,9 @@ * They skip when the shared auth storageState is absent. All ids are safe * placeholders — never real subject data. */ -import { test, expect } from '@playwright/test' -import * as path from 'path' +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') const API_BASE = '/index.php/apps/openregister/api' diff --git a/tests/integration/package.json b/tests/integration/package.json index 49598e5c91..b92bda3980 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -2,16 +2,6 @@ "name": "@openregister/newman-tests", "version": "1.0.0", "description": "Newman integration tests for OpenRegister", - "scripts": { - "test": "newman run openregister-crud.postman_collection.json --env-var 'base_url=http://localhost' --env-var 'admin_user=admin' --env-var 'admin_password=admin' --reporters cli", - "test:clean": "./run-tests.sh --clean", - "test:ci": "./run-tests.sh --mode ci --clean", - "test:verbose": "./run-tests.sh --verbose", - "install-newman": "npm install -g newman" - }, - "devDependencies": { - "newman": "^6.1.0" - }, "keywords": [ "newman", "postman", @@ -19,8 +9,18 @@ "nextcloud", "openregister" ], + "license": "EUPL-1.2", "author": "Conduction", - "license": "EUPL-1.2" + "scripts": { + "install-newman": "npm install -g newman", + "test": "newman run openregister-crud.postman_collection.json --env-var 'base_url=http://localhost' --env-var 'admin_user=admin' --env-var 'admin_password=admin' --reporters cli", + "test:ci": "./run-tests.sh --mode ci --clean", + "test:clean": "./run-tests.sh --clean", + "test:verbose": "./run-tests.sh --verbose" + }, + "devDependencies": { + "newman": "^6.1.0" + } } diff --git a/tests/l10n/check-l10n-parity.js b/tests/l10n/check-l10n-parity.js index 8b314b1f60..91e95c3f6f 100644 --- a/tests/l10n/check-l10n-parity.js +++ b/tests/l10n/check-l10n-parity.js @@ -1,8 +1,5 @@ #!/usr/bin/env node -/* eslint-disable jsdoc/require-param */ -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * l10n translation-PARITY gate. * @@ -172,7 +169,7 @@ function loadJsonSet(file) { /** True when a translation value is empty (string) or has an empty plural. */ function isEmpty(v) { - if (v == null) { + if ((v === null || v === undefined)) { return true } if (Array.isArray(v)) { @@ -222,8 +219,8 @@ for (const set of sets) { failures.push({ set: set.kind, loc, kind: 'UNPARSEABLE', detail: e.message }) continue } - const missing = enKeys.filter((k) => !Object.prototype.hasOwnProperty.call(locObj, k)) - const empty = enKeys.filter((k) => Object.prototype.hasOwnProperty.call(locObj, k) && isEmpty(locObj[k])) + const missing = enKeys.filter((k) => !Object.hasOwn(locObj, k)) + const empty = enKeys.filter((k) => Object.hasOwn(locObj, k) && isEmpty(locObj[k])) // An entry whose value is byte-identical to the English source. Reported // separately from `missing` because the two are OPPOSITES, not degrees of @@ -244,7 +241,7 @@ for (const set of sets) { // // Always measured; whether it is fatal depends on --strict-identical. const identical = enKeys.filter((k) => - Object.prototype.hasOwnProperty.call(locObj, k) + Object.hasOwn(locObj, k) && !isEmpty(locObj[k]) && JSON.stringify(locObj[k]) === JSON.stringify(enObj[k]), ) @@ -300,7 +297,7 @@ for (const set of sets) { const pluralCollision = frontend ? Object.entries(locObj).flatMap(([k, v]) => (Array.isArray(v) ? v : []) .map((form, i) => ({ k, i, form })) - .filter(({ form }) => Object.prototype.hasOwnProperty.call(locObj, form) + .filter(({ form }) => Object.hasOwn(locObj, form) && JSON.stringify(locObj[form]) !== JSON.stringify(form))) : [] @@ -328,7 +325,7 @@ for (const set of sets) { const enforced = set.kind === 'frontend (.js)' && COGNATES_ENFORCED.has(loc) const cognates = enforced ? loadLocaleConfig(loc).cognates : {} const unjustified = enforced - ? identical.filter((k) => !Object.prototype.hasOwnProperty.call(cognates, k)) + ? identical.filter((k) => !Object.hasOwn(cognates, k)) : [] // A reason recorded for a key that is no longer identical is a stale // permission slip: it would silently license the next value written there. @@ -342,7 +339,7 @@ for (const set of sets) { // record simultaneously required and forbidden. See hasIdenticalForm in lib.js. const staleCognates = enforced ? Object.keys(cognates).filter((k) => !identical.includes(k) - && !(Object.prototype.hasOwnProperty.call(locObj, k) + && !(Object.hasOwn(locObj, k) && hasIdenticalForm(k, locObj[k]))) : [] diff --git a/tests/l10n/check-l10n.js b/tests/l10n/check-l10n.js index f9cf1b51ef..598b8730c5 100644 --- a/tests/l10n/check-l10n.js +++ b/tests/l10n/check-l10n.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * l10n extraction / drift check — FRONTEND catalogue. * @@ -75,7 +73,6 @@ const fs = require('fs') const path = require('path') - const { loadJsTranslations, serializeJs, @@ -173,7 +170,7 @@ for (const file of files) { const missing = [] for (const [key, locations] of used) { - if (!Object.prototype.hasOwnProperty.call(translations, key)) { + if (!Object.hasOwn(translations, key)) { missing.push({ key, locations: [...locations] }) } } diff --git a/tests/newman/build-flow-engine-collection.mjs b/tests/newman/build-flow-engine-collection.mjs index dd7a4addfa..869359e43f 100644 --- a/tests/newman/build-flow-engine-collection.mjs +++ b/tests/newman/build-flow-engine-collection.mjs @@ -46,19 +46,41 @@ const HEADERS = [ * * @return The Postman item. */ -const req = (name, method, path, body, test, pre) => ({ - name, - event: [ - ...(pre ? [{ listen: 'prerequest', script: { type: 'text/javascript', exec: pre } }] : []), - ...(test ? [{ listen: 'test', script: { type: 'text/javascript', exec: test } }] : []), - ], - request: { - method, - header: HEADERS, - ...(body === undefined ? {} : { body: { mode: 'raw', raw: JSON.stringify(body, null, 2) } }), - url: { raw: `{{baseUrl}}${path}`, host: ['{{baseUrl}}'], path: path.replace(/^\//, '').split('/') }, - }, -}) +function req(name, method, path, body, test, pre) { + return { + name, + event: [ + ...(pre + ? [ + { + listen: 'prerequest', + script: { type: 'text/javascript', exec: pre }, + }, + ] + : []), + ...(test + ? [ + { + listen: 'test', + script: { type: 'text/javascript', exec: test }, + }, + ] + : []), + ], + request: { + method, + header: HEADERS, + ...(body === undefined + ? {} + : { body: { mode: 'raw', raw: JSON.stringify(body, null, 2) } }), + url: { + raw: `{{baseUrl}}${path}`, + host: ['{{baseUrl}}'], + path: path.replace(/^\//, '').split('/'), + }, + }, + } +} // ---------------------------------------------------------------- setup ---- @@ -76,133 +98,233 @@ const setup = { " pm.expect(pm.response.json().needsDbUpgrade, 'run `occ upgrade` first').to.eql(false)", '})', ]), - req('create register', 'POST', '/apps/openregister/api/registers', { - title: 'flow-engine-{{stamp}}', slug: 'flow-engine-{{stamp}}', description: 'flow engine coverage', - }, [ - "pm.collectionVariables.set('register', pm.response.json().id)", - "pm.test('register created', () => pm.expect(pm.response.json().id).to.be.a('number'))", - ]), - req('create schema', 'POST', '/apps/openregister/api/schemas', { - title: 'flow-engine-item-{{stamp}}', - slug: 'flow-engine-item-{{stamp}}', - properties: { - name: { type: 'string' }, - sourceId: { type: 'string' }, - // The paginated case matches on the SOURCE's own id, which is an - // integer: `{{ item.id }}` is the whole value, so it renders as - // the raw typed value and a string property rejects it. - externalId: { type: 'integer' }, - status: { type: 'string' }, - note: { type: 'string' }, + req( + 'create register', + 'POST', + '/apps/openregister/api/registers', + { + title: 'flow-engine-{{stamp}}', + slug: 'flow-engine-{{stamp}}', + description: 'flow engine coverage', }, - }, [ - "pm.collectionVariables.set('schema', pm.response.json().id)", - "pm.test('schema created', () => pm.expect(pm.response.json().id).to.be.a('number'))", - ]), - req('attach schema to register', 'PUT', '/apps/openregister/api/registers/{{register}}', { - schemas: ['{{schema}}'], - }, ["pm.test('schema attached', () => pm.response.to.have.status(200))"]), + [ + "pm.collectionVariables.set('register', pm.response.json().id)", + "pm.test('register created', () => pm.expect(pm.response.json().id).to.be.a('number'))", + ], + ), + req( + 'create schema', + 'POST', + '/apps/openregister/api/schemas', + { + title: 'flow-engine-item-{{stamp}}', + slug: 'flow-engine-item-{{stamp}}', + properties: { + name: { type: 'string' }, + sourceId: { type: 'string' }, + // The paginated case matches on the SOURCE's own id, which is an + // integer: `{{ item.id }}` is the whole value, so it renders as + // the raw typed value and a string property rejects it. + externalId: { type: 'integer' }, + status: { type: 'string' }, + note: { type: 'string' }, + }, + }, + [ + "pm.collectionVariables.set('schema', pm.response.json().id)", + "pm.test('schema created', () => pm.expect(pm.response.json().id).to.be.a('number'))", + ], + ), + req( + 'attach schema to register', + 'PUT', + '/apps/openregister/api/registers/{{register}}', + { + schemas: ['{{schema}}'], + }, + ["pm.test('schema attached', () => pm.response.to.have.status(200))"], + ), // The mapping the map case reshapes through. `fullName` is composed here // and exists nowhere else, so a row carrying it proves the transform ran. - req('create mapping', 'POST', '/apps/openregister/api/mappings', { - name: 'flow-engine-map-{{stamp}}', - mapping: { fullName: '{{ first }} {{ last }}', kept: '{{ n }}' }, - }, [ - "const b = pm.response.json()", - "pm.collectionVariables.set('mappingId', String(b.id || ''))", - "pm.collectionVariables.set('mappingUuid', String(b.uuid || ''))", - "pm.test('mapping created', () => pm.expect(b.uuid, JSON.stringify(b).slice(0, 200)).to.be.a('string'))", - '// The create/read asymmetry this suite exists to keep closed: a session', - '// with no ACTIVE organisation wrote the row with organisation NULL while', - '// every read welded `1 = 0` onto the query, so POST answered 201 and the', - '// very next GET answered 404 — for the same session, every time.', - ]), - req('read the mapping back', 'GET', '/apps/openregister/api/mappings/{{mappingId}}', undefined, [ - "pm.test('a just-created mapping is readable by its own API', () => pm.response.to.have.status(200))", - ]), - req('create external source', 'POST', '/apps/openregister/api/objects/openconnector/source', { - name: 'flow-engine-ext-{{stamp}}', - location: 'https://jsonplaceholder.typicode.com', - type: 'api', isEnabled: true, version: '1.0.0', - }, [ - "const b = pm.response.json()", - "pm.collectionVariables.set('extSource', (b['@self'] && b['@self'].id) || b.id)", - "pm.test('external source created', () => pm.expect(pm.collectionVariables.get('extSource')).to.be.a('string'))", - ]), + req( + 'create mapping', + 'POST', + '/apps/openregister/api/mappings', + { + name: 'flow-engine-map-{{stamp}}', + mapping: { fullName: '{{ first }} {{ last }}', kept: '{{ n }}' }, + }, + [ + 'const b = pm.response.json()', + "pm.collectionVariables.set('mappingId', String(b.id || ''))", + "pm.collectionVariables.set('mappingUuid', String(b.uuid || ''))", + "pm.test('mapping created', () => pm.expect(b.uuid, JSON.stringify(b).slice(0, 200)).to.be.a('string'))", + '// The create/read asymmetry this suite exists to keep closed: a session', + '// with no ACTIVE organisation wrote the row with organisation NULL while', + '// every read welded `1 = 0` onto the query, so POST answered 201 and the', + '// very next GET answered 404 — for the same session, every time.', + ], + ), + req( + 'read the mapping back', + 'GET', + '/apps/openregister/api/mappings/{{mappingId}}', + undefined, + [ + "pm.test('a just-created mapping is readable by its own API', () => pm.response.to.have.status(200))", + ], + ), + req( + 'create external source', + 'POST', + '/apps/openregister/api/objects/openconnector/source', + { + name: 'flow-engine-ext-{{stamp}}', + location: 'https://jsonplaceholder.typicode.com', + type: 'api', + isEnabled: true, + version: '1.0.0', + }, + [ + 'const b = pm.response.json()', + "pm.collectionVariables.set('extSource', (b['@self'] && b['@self'].id) || b.id)", + "pm.test('external source created', () => pm.expect(pm.collectionVariables.get('extSource')).to.be.a('string'))", + ], + ), // Auth goes in `configuration.headers`, NOT in the source's top-level // `headers` and NOT in `auth`/`username`/`password`. Both of those // persist happily and are then ignored by the call path, so the source // reads as correctly configured while every call it makes comes back // 401 — measured against the notifications endpoint. - req('create nextcloud source', 'POST', '/apps/openregister/api/objects/openconnector/source', { - name: 'flow-engine-nc-{{stamp}}', - location: 'http://localhost', - type: 'api', isEnabled: true, version: '1.0.0', - configuration: { - headers: { - Authorization: 'Basic {{authToken}}', - 'OCS-APIRequest': 'true', + req( + 'create nextcloud source', + 'POST', + '/apps/openregister/api/objects/openconnector/source', + { + name: 'flow-engine-nc-{{stamp}}', + location: 'http://localhost', + type: 'api', + isEnabled: true, + version: '1.0.0', + configuration: { + headers: { + Authorization: 'Basic {{authToken}}', + 'OCS-APIRequest': 'true', + }, }, }, - }, [ - "const b = pm.response.json()", - "pm.collectionVariables.set('ncSource', (b['@self'] && b['@self'].id) || b.id)", - "pm.test('nextcloud source created', () => pm.expect(pm.collectionVariables.get('ncSource')).to.be.a('string'))", - ]), - req('create pager sub-flow', 'POST', '/apps/openregister/api/flows', { - name: 'flow-engine-pager-{{stamp}}', - description: 'Fetches one page and emits an item per record. Empty when the pages run out.', - app: 'openregister', enabled: true, executionMode: 'async', - nodes: [ - { id: 'p1', type: 'openregister.trigger-manual', config: {} }, - { id: 'p2', type: 'openconnector.source-call', config: { method: 'GET', source: '{{extSource}}', endpoint: '/posts?_limit=10&_page={{ iteration.index }}' } }, - { id: 'p3', type: 'openregister.explode', config: { path: 'response.body', as: 'item', keepRecord: false } }, - { id: 'p4', type: 'openregister.end', config: {} }, + [ + 'const b = pm.response.json()', + "pm.collectionVariables.set('ncSource', (b['@self'] && b['@self'].id) || b.id)", + "pm.test('nextcloud source created', () => pm.expect(pm.collectionVariables.get('ncSource')).to.be.a('string'))", ], - edges: [{ id: 'a', from: 'p1', to: 'p2' }, { id: 'b', from: 'p2', to: 'p3' }, { id: 'c', from: 'p3', to: 'p4' }], - }, [ - "pm.collectionVariables.set('pagerFlow', pm.response.json().uuid || '')", - "pm.test('pager sub-flow created', () => pm.expect(pm.collectionVariables.get('pagerFlow')).to.be.a('string'))", - ]), - req('create sync sub-flow', 'POST', '/apps/openregister/api/flows', { - name: 'flow-engine-sync-{{stamp}}', - description: 'Upserts one page of records. Called once per page by the paginated sync.', - app: 'openregister', enabled: true, executionMode: 'async', - nodes: [ - { id: 's1', type: 'openregister.trigger-manual', config: {} }, - { - id: 's2', - type: 'openregister.object-write', - config: { - register: '{{register}}', schema: '{{schema}}', - // `upsert` is its own operation — `update` + onMissing:create - // does not exist, onMissing accepts only omit|fail. - operation: 'upsert', - match: { externalId: '{{ item.id }}' }, - fields: { name: '{{ item.title }}', externalId: '{{ item.id }}', status: 'paged' }, + ), + req( + 'create pager sub-flow', + 'POST', + '/apps/openregister/api/flows', + { + name: 'flow-engine-pager-{{stamp}}', + description: + 'Fetches one page and emits an item per record. Empty when the pages run out.', + app: 'openregister', + enabled: true, + executionMode: 'async', + nodes: [ + { id: 'p1', type: 'openregister.trigger-manual', config: {} }, + { + id: 'p2', + type: 'openconnector.source-call', + config: { + method: 'GET', + source: '{{extSource}}', + endpoint: '/posts?_limit=10&_page={{ iteration.index }}', + }, }, - }, - { id: 's3', type: 'openregister.end', config: {} }, + { + id: 'p3', + type: 'openregister.explode', + config: { + path: 'response.body', + as: 'item', + keepRecord: false, + }, + }, + { id: 'p4', type: 'openregister.end', config: {} }, + ], + edges: [ + { id: 'a', from: 'p1', to: 'p2' }, + { id: 'b', from: 'p2', to: 'p3' }, + { id: 'c', from: 'p3', to: 'p4' }, + ], + }, + [ + "pm.collectionVariables.set('pagerFlow', pm.response.json().uuid || '')", + "pm.test('pager sub-flow created', () => pm.expect(pm.collectionVariables.get('pagerFlow')).to.be.a('string'))", ], - edges: [{ id: 'a', from: 's1', to: 's2' }, { id: 'b', from: 's2', to: 's3' }], - }, [ - "pm.collectionVariables.set('syncFlow', pm.response.json().uuid || '')", - "pm.test('sync sub-flow created', () => pm.expect(pm.collectionVariables.get('syncFlow')).to.be.a('string'))", - ]), - req('create summariser agent', 'POST', '/apps/openregister/api/objects/hermiq/agent', { - name: 'flow-engine-summariser-{{stamp}}', - description: 'Summarises a mailbox for the flow engine coverage', - provider: 'ollama', - model: '{{agentModel}}', - prompt: 'You summarise messages in one short sentence.', - active: true, - }, [ - "const b = pm.response.json()", - "pm.collectionVariables.set('agent', (b['@self'] && b['@self'].id) || b.id || '')", - '// Not a failure when hermiq is absent: only case 3 needs it, and that', - '// case guards on the variable being set.', - "pm.test('agent fixture resolved (or hermiq absent)', () => pm.expect(true).to.be.true)", - ]), + ), + req( + 'create sync sub-flow', + 'POST', + '/apps/openregister/api/flows', + { + name: 'flow-engine-sync-{{stamp}}', + description: + 'Upserts one page of records. Called once per page by the paginated sync.', + app: 'openregister', + enabled: true, + executionMode: 'async', + nodes: [ + { id: 's1', type: 'openregister.trigger-manual', config: {} }, + { + id: 's2', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + // `upsert` is its own operation — `update` + onMissing:create + // does not exist, onMissing accepts only omit|fail. + operation: 'upsert', + match: { externalId: '{{ item.id }}' }, + fields: { + name: '{{ item.title }}', + externalId: '{{ item.id }}', + status: 'paged', + }, + }, + }, + { id: 's3', type: 'openregister.end', config: {} }, + ], + edges: [ + { id: 'a', from: 's1', to: 's2' }, + { id: 'b', from: 's2', to: 's3' }, + ], + }, + [ + "pm.collectionVariables.set('syncFlow', pm.response.json().uuid || '')", + "pm.test('sync sub-flow created', () => pm.expect(pm.collectionVariables.get('syncFlow')).to.be.a('string'))", + ], + ), + req( + 'create summariser agent', + 'POST', + '/apps/openregister/api/objects/hermiq/agent', + { + name: 'flow-engine-summariser-{{stamp}}', + description: 'Summarises a mailbox for the flow engine coverage', + provider: 'ollama', + model: '{{agentModel}}', + prompt: 'You summarise messages in one short sentence.', + active: true, + }, + [ + 'const b = pm.response.json()', + "pm.collectionVariables.set('agent', (b['@self'] && b['@self'].id) || b.id || '')", + '// Not a failure when hermiq is absent: only case 3 needs it, and that', + '// case guards on the variable being set.', + "pm.test('agent fixture resolved (or hermiq absent)', () => pm.expect(true).to.be.true)", + ], + ), ], } @@ -215,60 +337,76 @@ const setup = { * * @return The Postman folder. */ -const caseFolder = (c) => { +function caseFolder(c) { const v = `case_${c.key.replace(/-/g, '_')}` - const guard = c.requires === 'agent' - ? [ - "if (!pm.collectionVariables.get('agent')) {", - ` console.log('SKIP ${c.key}: no agent fixture (hermiq/ollama absent) — the AI case did not run')`, - ` pm.collectionVariables.set('${v}_skipped', '1')`, - '}', - ] - : null + const guard = + c.requires === 'agent' + ? [ + "if (!pm.collectionVariables.get('agent')) {", + ` console.log('SKIP ${c.key}: no agent fixture (hermiq/ollama absent) — the AI case did not run')`, + ` pm.collectionVariables.set('${v}_skipped', '1')`, + '}', + ] + : null return { name: c.title, description: c.description, item: [ - req(`${c.key} — create flow`, 'POST', '/apps/openregister/api/flows', { - name: `flow-engine-${c.key}-{{stamp}}`, - description: c.description, - app: 'openregister', - enabled: true, - executionMode: 'async', - nodes: c.nodes, - edges: c.edges, - }, [ - "const b = pm.response.json()", - `pm.collectionVariables.set('${v}_uuid', b.uuid || '')`, - `pm.collectionVariables.set('flows', (pm.collectionVariables.get('flows') || '') + ' ' + (b.uuid || ''))`, - `pm.test('${c.key}: flow created', () => pm.expect(b.uuid, JSON.stringify(b).slice(0, 200)).to.be.a('string'))`, - ], guard), + req( + `${c.key} — create flow`, + 'POST', + '/apps/openregister/api/flows', + { + name: `flow-engine-${c.key}-{{stamp}}`, + description: c.description, + app: 'openregister', + enabled: true, + executionMode: 'async', + nodes: c.nodes, + edges: c.edges, + }, + [ + 'const b = pm.response.json()', + `pm.collectionVariables.set('${v}_uuid', b.uuid || '')`, + `pm.collectionVariables.set('flows', (pm.collectionVariables.get('flows') || '') + ' ' + (b.uuid || ''))`, + `pm.test('${c.key}: flow created', () => pm.expect(b.uuid, JSON.stringify(b).slice(0, 200)).to.be.a('string'))`, + ], + guard, + ), // `sync: true` executes inline and answers with the FINISHED run. // Asynchronously this needed a background worker alongside Newman // plus a polling loop, and on any instance whose cron is idle the // poll simply exhausted — reporting a perfectly healthy engine as // stuck, which is the most misleading failure a test can produce. - req(`${c.key} — run flow (sync)`, 'POST', `/apps/openregister/api/flows/{{${v}_uuid}}/run`, { sync: true }, [ - "const run = pm.response.json()", - `pm.collectionVariables.set('${v}_run', run.uuid || '')`, - `pm.collectionVariables.set('${v}_status', String(run.status))`, - `pm.collectionVariables.set('${v}_log', JSON.stringify(run.log || []))`, - '', - `pm.test('${c.key}: run executed', () => pm.expect(run.uuid, JSON.stringify(run).slice(0, 200)).to.be.a('string'))`, - `pm.test('${c.key}: run reached a terminal state', () => {`, - ` pm.expect(${JSON.stringify(c.expect.terminal ?? TERMINALS)}, 'a synchronous run answered ' + run.status + ' — it should never come back queued').to.include(String(run.status))`, - '})', - ]), + req( + `${c.key} — run flow (sync)`, + 'POST', + `/apps/openregister/api/flows/{{${v}_uuid}}/run`, + { sync: true }, + [ + 'const run = pm.response.json()', + `pm.collectionVariables.set('${v}_run', run.uuid || '')`, + `pm.collectionVariables.set('${v}_status', String(run.status))`, + `pm.collectionVariables.set('${v}_log', JSON.stringify(run.log || []))`, + '', + `pm.test('${c.key}: run executed', () => pm.expect(run.uuid, JSON.stringify(run).slice(0, 200)).to.be.a('string'))`, + `pm.test('${c.key}: run reached a terminal state', () => {`, + ` pm.expect(${JSON.stringify(c.expect.terminal ?? TERMINALS)}, 'a synchronous run answered ' + run.status + ' — it should never come back queued').to.include(String(run.status))`, + '})', + ], + ), // _limit=1000, and it is load-bearing. At 200 the paginated case's // own 100 records plus the routed branches pushed later cases' rows // past the page boundary, and the assertion read "wrote nothing" // about a flow that had written correctly — a harness limit // reporting itself as a product failure. - req(`${c.key} — assert effect`, 'GET', + req( + `${c.key} — assert effect`, + 'GET', '/apps/openregister/api/objects/{{register}}/{{schema}}?_limit=1000', undefined, [ @@ -283,61 +421,66 @@ const caseFolder = (c) => { '// The claim that matters. A run reports `completed` when every step', '// processed ZERO items, so status alone proves nothing — during', '// development one of these was green with explode in=1 out=0.', - "const body = pm.response.json()", - "const objects = body.results || body || []", + 'const body = pm.response.json()', + 'const objects = body.results || body || []', ...(c.expect.status !== undefined ? [ - `const matching = objects.filter((o) => o.status === ${JSON.stringify(c.expect.status)})`, - ...(c.expect.exactly !== undefined - ? [`pm.test('${c.key}: ${c.expect.status} rows are gone', () => pm.expect(matching.length).to.eql(${c.expect.exactly}))`] - : [`pm.test('${c.key}: wrote at least ${c.expect.atLeast} ${c.expect.status} object(s)', () => pm.expect(matching.length, 'the run was green but wrote nothing').to.be.at.least(${c.expect.atLeast}))`]), - ] + `const matching = objects.filter((o) => o.status === ${JSON.stringify(c.expect.status)})`, + ...(c.expect.exactly !== undefined + ? [ + `pm.test('${c.key}: ${c.expect.status} rows are gone', () => pm.expect(matching.length).to.eql(${c.expect.exactly}))`, + ] + : [ + `pm.test('${c.key}: wrote at least ${c.expect.atLeast} ${c.expect.status} object(s)', () => pm.expect(matching.length, 'the run was green but wrote nothing').to.be.at.least(${c.expect.atLeast}))`, + ]), + ] : []), ...(c.expect.also !== undefined ? [ - `const other = objects.filter((o) => o.status === ${JSON.stringify(c.expect.also.status)})`, - `pm.test('${c.key}: the other branch ran too (${c.expect.also.status})', () => {`, - ` pm.expect(other.length, 'only one branch produced anything — nothing was actually split').to.be.at.least(${c.expect.also.atLeast})`, - '})', - ] + `const other = objects.filter((o) => o.status === ${JSON.stringify(c.expect.also.status)})`, + `pm.test('${c.key}: the other branch ran too (${c.expect.also.status})', () => {`, + ` pm.expect(other.length, 'only one branch produced anything — nothing was actually split').to.be.at.least(${c.expect.also.atLeast})`, + '})', + ] : []), ...(c.expect.field !== undefined ? [ - `const carrying = objects.filter((o) => o.status === ${JSON.stringify(c.expect.status)})`, - `pm.test('${c.key}: ${c.expect.field.name} carries the stored value, not a default', () => {`, - " pm.expect(carrying.length, 'nothing to check the value on').to.be.above(0)", - ` pm.expect(Number(carrying[0][${JSON.stringify(c.expect.field.name)}])).to.eql(${c.expect.field.equals})`, - '})', - ] + `const carrying = objects.filter((o) => o.status === ${JSON.stringify(c.expect.status)})`, + `pm.test('${c.key}: ${c.expect.field.name} carries the stored value, not a default', () => {`, + " pm.expect(carrying.length, 'nothing to check the value on').to.be.above(0)", + ` pm.expect(Number(carrying[0][${JSON.stringify(c.expect.field.name)}])).to.eql(${c.expect.field.equals})`, + '})', + ] : []), ...(c.expect.text !== undefined ? [ - `const bearing = objects.filter((o) => o.status === ${JSON.stringify(c.expect.status)})`, - `pm.test('${c.key}: ${c.expect.text.name} holds the TRANSFORMED value', () => {`, - " pm.expect(bearing.length, 'nothing to check the value on').to.be.above(0)", - ` pm.expect(String(bearing[0][${JSON.stringify(c.expect.text.name)}])).to.eql(${JSON.stringify(c.expect.text.equals)})`, - '})', - ] + `const bearing = objects.filter((o) => o.status === ${JSON.stringify(c.expect.status)})`, + `pm.test('${c.key}: ${c.expect.text.name} holds the TRANSFORMED value', () => {`, + " pm.expect(bearing.length, 'nothing to check the value on').to.be.above(0)", + ` pm.expect(String(bearing[0][${JSON.stringify(c.expect.text.name)}])).to.eql(${JSON.stringify(c.expect.text.equals)})`, + '})', + ] : []), ...(c.expect.step !== undefined ? [ - `const named = steps.filter((s) => s.transition === ${JSON.stringify(c.expect.step.node)})[0]`, - `pm.test('${c.key}: ${c.expect.step.node} reported ${c.expect.step.status}', () => {`, - " pm.expect(named, 'that node produced no step at all — it never ran').to.not.be.undefined", - ` pm.expect(String(named.status)).to.eql(${JSON.stringify(c.expect.step.status)})`, - '})', - ] + `const named = steps.filter((s) => s.transition === ${JSON.stringify(c.expect.step.node)})[0]`, + `pm.test('${c.key}: ${c.expect.step.node} reported ${c.expect.step.status}', () => {`, + " pm.expect(named, 'that node produced no step at all — it never ran').to.not.be.undefined", + ` pm.expect(String(named.status)).to.eql(${JSON.stringify(c.expect.step.status)})`, + '})', + ] : []), ...(c.expect.batched !== undefined ? [ - `const batchStep = steps.filter((s) => s.transition === ${JSON.stringify(c.expect.batched.node)})[0]`, - `pm.test('${c.key}: the batch node actually batched', () => {`, - " pm.expect(batchStep, 'no batch step in the log').to.not.be.undefined", - ` pm.expect(batchStep.itemsOut, 'batching did not reduce the item count').to.be.at.most(${c.expect.batched.maxOut})`, - '})', - ] + `const batchStep = steps.filter((s) => s.transition === ${JSON.stringify(c.expect.batched.node)})[0]`, + `pm.test('${c.key}: the batch node actually batched', () => {`, + " pm.expect(batchStep, 'no batch step in the log').to.not.be.undefined", + ` pm.expect(batchStep.itemsOut, 'batching did not reduce the item count').to.be.at.most(${c.expect.batched.maxOut})`, + '})', + ] : []), - ]), + ], + ), ], } } @@ -363,112 +506,186 @@ const caseFolder = (c) => { const coverage = { name: '98 — coverage', item: [ - req('report which cases ran', 'GET', '/apps/openregister/api/flows?limit=1', undefined, [ - `const cases = ${JSON.stringify(CASES.map((c) => ({ key: c.key, requires: (c.requires ?? null) })))}`, - "const skipped = cases.filter((c) => pm.collectionVariables.get('case_' + c.key.replace(/-/g, '_') + '_skipped') === '1')", - "const ran = cases.length - skipped.length", - "console.log('COVERAGE: ' + ran + ' of ' + cases.length + ' flow-engine cases ran'", - " + (skipped.length ? ' — skipped: ' + skipped.map((c) => c.key + ' (needs ' + c.requires + ')').join(', ') : ''))", - "", - "// A case may only be absent when it DECLARED a prerequisite. One that", - "// skipped without declaring why is a hole in the suite, and the whole", - "// point of this folder is that such a hole cannot pass as green.", - "const unexplained = skipped.filter((c) => !c.requires)", - "pm.test('every skipped case declared a prerequisite', () => {", - " pm.expect(unexplained.map((c) => c.key), 'a case skipped without declaring what it needs').to.eql([])", - '})', - "", - "// Named so the count is in the run summary rather than only in stdout.", - "pm.test('COVERAGE: ' + ran + ' of ' + cases.length + ' flow-engine cases executed', () => {", - " pm.expect(ran, 'no flow-engine case ran at all').to.be.above(0)", - '})', - ]), + req( + 'report which cases ran', + 'GET', + '/apps/openregister/api/flows?limit=1', + undefined, + [ + `const cases = ${JSON.stringify(CASES.map((c) => ({ key: c.key, requires: c.requires ?? null })))}`, + "const skipped = cases.filter((c) => pm.collectionVariables.get('case_' + c.key.replace(/-/g, '_') + '_skipped') === '1')", + 'const ran = cases.length - skipped.length', + "console.log('COVERAGE: ' + ran + ' of ' + cases.length + ' flow-engine cases ran'", + " + (skipped.length ? ' — skipped: ' + skipped.map((c) => c.key + ' (needs ' + c.requires + ')').join(', ') : ''))", + '', + '// A case may only be absent when it DECLARED a prerequisite. One that', + '// skipped without declaring why is a hole in the suite, and the whole', + '// point of this folder is that such a hole cannot pass as green.', + 'const unexplained = skipped.filter((c) => !c.requires)', + "pm.test('every skipped case declared a prerequisite', () => {", + " pm.expect(unexplained.map((c) => c.key), 'a case skipped without declaring what it needs').to.eql([])", + '})', + '', + '// Named so the count is in the run summary rather than only in stdout.', + "pm.test('COVERAGE: ' + ran + ' of ' + cases.length + ' flow-engine cases executed', () => {", + " pm.expect(ran, 'no flow-engine case ran at all').to.be.above(0)", + '})', + ], + ), ], } const teardown = { name: '99 — teardown', item: [ - req('delete flows', 'GET', '/apps/openregister/api/flows?limit=200', undefined, [ - "const body = pm.response.json()", - "const all = body.results || body || []", - "const stamp = pm.collectionVariables.get('stamp')", - "const mine = all.filter((f) => String(f.name || '').indexOf(stamp) !== -1)", - "pm.collectionVariables.set('toDelete', JSON.stringify(mine.map((f) => f.uuid)))", - "pm.test('found this run\\'s flows to delete', () => pm.expect(mine.length, 'nothing matched the run stamp — teardown would silently no-op').to.be.above(0))", - "const next = mine.map((f) => f.uuid)", - "if (next.length) { pm.collectionVariables.set('deleteQueue', JSON.stringify(next)) }", - ]), - req('delete one flow', 'DELETE', '/apps/openregister/api/flows/{{deleteHead}}', undefined, [ - "pm.test('flow deleted', () => pm.expect([200, 404]).to.include(pm.response.code))", - "const queue = JSON.parse(pm.collectionVariables.get('deleteQueue') || '[]')", - "if (queue.length) {", - " pm.collectionVariables.set('deleteHead', queue.shift())", - " pm.collectionVariables.set('deleteQueue', JSON.stringify(queue))", - " postman.setNextRequest('delete one flow')", - '}', - ], [ - "const queue = JSON.parse(pm.collectionVariables.get('deleteQueue') || '[]')", - "if (!pm.collectionVariables.get('deleteHead') && queue.length) {", - " pm.collectionVariables.set('deleteHead', queue.shift())", - " pm.collectionVariables.set('deleteQueue', JSON.stringify(queue))", - '}', - ]), + req( + 'delete flows', + 'GET', + '/apps/openregister/api/flows?limit=200', + undefined, + [ + 'const body = pm.response.json()', + 'const all = body.results || body || []', + "const stamp = pm.collectionVariables.get('stamp')", + "const mine = all.filter((f) => String(f.name || '').indexOf(stamp) !== -1)", + "pm.collectionVariables.set('toDelete', JSON.stringify(mine.map((f) => f.uuid)))", + "pm.test('found this run\\'s flows to delete', () => pm.expect(mine.length, 'nothing matched the run stamp — teardown would silently no-op').to.be.above(0))", + 'const next = mine.map((f) => f.uuid)', + "if (next.length) { pm.collectionVariables.set('deleteQueue', JSON.stringify(next)) }", + ], + ), + req( + 'delete one flow', + 'DELETE', + '/apps/openregister/api/flows/{{deleteHead}}', + undefined, + [ + "pm.test('flow deleted', () => pm.expect([200, 404]).to.include(pm.response.code))", + "const queue = JSON.parse(pm.collectionVariables.get('deleteQueue') || '[]')", + 'if (queue.length) {', + " pm.collectionVariables.set('deleteHead', queue.shift())", + " pm.collectionVariables.set('deleteQueue', JSON.stringify(queue))", + " postman.setNextRequest('delete one flow')", + '}', + ], + [ + "const queue = JSON.parse(pm.collectionVariables.get('deleteQueue') || '[]')", + "if (!pm.collectionVariables.get('deleteHead') && queue.length) {", + " pm.collectionVariables.set('deleteHead', queue.shift())", + " pm.collectionVariables.set('deleteQueue', JSON.stringify(queue))", + '}', + ], + ), // Objects BEFORE schema and register: both refuse to be deleted while // they still hold rows (`register-has-objects` / `schema-has-objects`, // 409), and there is no bulk-delete endpoint — only one object at a // time. Skipping this left the fixture register behind on every run. - req('list objects to purge', 'GET', '/apps/openregister/api/objects/{{register}}/{{schema}}?_limit=500', undefined, [ - "const body = pm.response.json()", - "const objects = body.results || body || []", - "const ids = objects.map((o) => (o['@self'] && o['@self'].id) || o.id).filter(Boolean)", - "pm.collectionVariables.set('objectQueue', JSON.stringify(ids))", - "pm.collectionVariables.set('objectHead', ids.length ? ids[0] : '')", - "console.log('purging ' + ids.length + ' object(s)')", - "if (!ids.length) { postman.setNextRequest('delete schema') }", - ]), - req('delete one object', 'DELETE', '/apps/openregister/api/objects/{{register}}/{{schema}}/{{objectHead}}', undefined, [ - "pm.test('object purged', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", - "const queue = JSON.parse(pm.collectionVariables.get('objectQueue') || '[]')", - "queue.shift()", - "pm.collectionVariables.set('objectQueue', JSON.stringify(queue))", - "if (queue.length) {", - " pm.collectionVariables.set('objectHead', queue[0])", - " postman.setNextRequest('delete one object')", - '}', - ]), - req('delete schema', 'DELETE', '/apps/openregister/api/schemas/{{schema}}', undefined, [ - "pm.test('schema deleted', () => pm.expect([200, 204, 404], JSON.stringify(pm.response.json())).to.include(pm.response.code))", - ]), - req('delete register', 'DELETE', '/apps/openregister/api/registers/{{register}}', undefined, [ - "pm.test('register deleted', () => pm.expect([200, 204, 404], JSON.stringify(pm.response.json())).to.include(pm.response.code))", - ]), - req('delete mapping', 'DELETE', '/apps/openregister/api/mappings/{{mappingId}}', undefined, [ - "pm.test('mapping deleted', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", - ]), - req('delete external source', 'DELETE', '/apps/openregister/api/objects/openconnector/source/{{extSource}}', undefined, [ - "pm.test('external source deleted', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", - ]), - req('delete nextcloud source', 'DELETE', '/apps/openregister/api/objects/openconnector/source/{{ncSource}}', undefined, [ - "pm.test('nextcloud source deleted', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", - ]), - req('delete agent', 'DELETE', '/apps/openregister/api/objects/hermiq/agent/{{agent}}', undefined, [ - "pm.test('agent deleted', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", - ]), - req('no run history survives its flow', 'GET', '/apps/openregister/api/flow-runs?limit=1', undefined, [ - '// Deleting a flow cascades its runs, steps and state. This asserts the', - '// cascade fired rather than trusting it: before that cascade existed the', - '// dev instance had accumulated 493 orphaned runs across 80 dead flows,', - '// and nothing in any test noticed.', - "pm.test('flow-runs endpoint still answers after teardown', () => pm.response.to.have.status(200))", - ]), + req( + 'list objects to purge', + 'GET', + '/apps/openregister/api/objects/{{register}}/{{schema}}?_limit=500', + undefined, + [ + 'const body = pm.response.json()', + 'const objects = body.results || body || []', + "const ids = objects.map((o) => (o['@self'] && o['@self'].id) || o.id).filter(Boolean)", + "pm.collectionVariables.set('objectQueue', JSON.stringify(ids))", + "pm.collectionVariables.set('objectHead', ids.length ? ids[0] : '')", + "console.log('purging ' + ids.length + ' object(s)')", + "if (!ids.length) { postman.setNextRequest('delete schema') }", + ], + ), + req( + 'delete one object', + 'DELETE', + '/apps/openregister/api/objects/{{register}}/{{schema}}/{{objectHead}}', + undefined, + [ + "pm.test('object purged', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", + "const queue = JSON.parse(pm.collectionVariables.get('objectQueue') || '[]')", + 'queue.shift()', + "pm.collectionVariables.set('objectQueue', JSON.stringify(queue))", + 'if (queue.length) {', + " pm.collectionVariables.set('objectHead', queue[0])", + " postman.setNextRequest('delete one object')", + '}', + ], + ), + req( + 'delete schema', + 'DELETE', + '/apps/openregister/api/schemas/{{schema}}', + undefined, + [ + "pm.test('schema deleted', () => pm.expect([200, 204, 404], JSON.stringify(pm.response.json())).to.include(pm.response.code))", + ], + ), + req( + 'delete register', + 'DELETE', + '/apps/openregister/api/registers/{{register}}', + undefined, + [ + "pm.test('register deleted', () => pm.expect([200, 204, 404], JSON.stringify(pm.response.json())).to.include(pm.response.code))", + ], + ), + req( + 'delete mapping', + 'DELETE', + '/apps/openregister/api/mappings/{{mappingId}}', + undefined, + [ + "pm.test('mapping deleted', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", + ], + ), + req( + 'delete external source', + 'DELETE', + '/apps/openregister/api/objects/openconnector/source/{{extSource}}', + undefined, + [ + "pm.test('external source deleted', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", + ], + ), + req( + 'delete nextcloud source', + 'DELETE', + '/apps/openregister/api/objects/openconnector/source/{{ncSource}}', + undefined, + [ + "pm.test('nextcloud source deleted', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", + ], + ), + req( + 'delete agent', + 'DELETE', + '/apps/openregister/api/objects/hermiq/agent/{{agent}}', + undefined, + [ + "pm.test('agent deleted', () => pm.expect([200, 204, 404]).to.include(pm.response.code))", + ], + ), + req( + 'no run history survives its flow', + 'GET', + '/apps/openregister/api/flow-runs?limit=1', + undefined, + [ + '// Deleting a flow cascades its runs, steps and state. This asserts the', + '// cascade fired rather than trusting it: before that cascade existed the', + '// dev instance had accumulated 493 orphaned runs across 80 dead flows,', + '// and nothing in any test noticed.', + "pm.test('flow-runs endpoint still answers after teardown', () => pm.response.to.have.status(200))", + ], + ), ], } const collection = { info: { name: 'OpenRegister — flow engine execution', - description: 'Executes eight real flows end to end and asserts what each one CHANGED, not merely that it reported success. Needs a FlowRunWorker alongside it: use tests/newman/run-flow-engine.sh.', + description: + 'Executes eight real flows end to end and asserts what each one CHANGED, not merely that it reported success. Needs a FlowRunWorker alongside it: use tests/newman/run-flow-engine.sh.', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', }, variable: [ @@ -498,4 +715,6 @@ const collection = { const out = join(here, 'openregister-flow-engine.postman_collection.json') writeFileSync(out, JSON.stringify(collection, null, 2) + '\n') -console.log(`wrote ${out} — ${CASES.length} cases, ${collection.item.length} folders`) +console.log( + `wrote ${out} — ${CASES.length} cases, ${collection.item.length} folders`, +) diff --git a/tests/newman/flow-engine-definitions.mjs b/tests/newman/flow-engine-definitions.mjs index bc42c3b42b..8a48c5c66c 100644 --- a/tests/newman/flow-engine-definitions.mjs +++ b/tests/newman/flow-engine-definitions.mjs @@ -35,45 +35,149 @@ export const CASES = [ { key: 'api-sync', title: '1 — Synchronise objects from an external API', - description: 'Fetch a collection from an external API and write each item as a local object.', + description: + 'Fetch a collection from an external API and write each item as a local object.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 'call1', type: 'openconnector.source-call', config: { method: 'GET', source: '{{extSource}}', endpoint: '/users' } }, - { id: 'x1', type: 'openregister.explode', config: { path: 'response.body', as: 'item', keepRecord: false } }, - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: '{{ item.name }}', sourceId: '{{ item.username }}', status: 'synced' } } }, + { + id: 'call1', + type: 'openconnector.source-call', + config: { + method: 'GET', + source: '{{extSource}}', + endpoint: '/users', + }, + }, + { + id: 'x1', + type: 'openregister.explode', + config: { path: 'response.body', as: 'item', keepRecord: false }, + }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { + name: '{{ item.name }}', + sourceId: '{{ item.username }}', + status: 'synced', + }, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'call1' }, { id: 'b', from: 'call1', to: 'x1' }, { id: 'c', from: 'x1', to: 'w1' }, { id: 'd', from: 'w1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'call1' }, + { id: 'b', from: 'call1', to: 'x1' }, + { id: 'c', from: 'x1', to: 'w1' }, + { id: 'd', from: 'w1', to: 'e1' }, + ], // jsonplaceholder /users is a fixed fixture of 10. expect: { status: 'synced', atLeast: 10 }, }, { key: 'notify-on-change', title: '2 — Alter an object and notify a human', - description: 'Read a synced object, move it to reviewed, and raise a Nextcloud notification.', + description: + 'Read a synced object, move it to reviewed, and raise a Nextcloud notification.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 'r1', type: 'openregister.object-read', config: { register: '{{register}}', schema: '{{schema}}', filters: { status: 'synced' }, limit: 1, fanOut: true } }, - { id: 's1', type: 'openregister.set-fields', config: { set: { status: 'reviewed' } } }, - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'update', match: [{ property: 'sourceId', value: '{{ sourceId }}' }], fields: { status: 'reviewed' } } }, - { id: 'n1', type: 'openconnector.source-call', config: { method: 'POST', source: '{{ncSource}}', endpoint: '/ocs/v2.php/apps/notifications/api/v2/admin_notifications/admin', body: { shortMessage: 'Flow engine test: an object was reviewed' } } }, + { + id: 'r1', + type: 'openregister.object-read', + config: { + register: '{{register}}', + schema: '{{schema}}', + filters: { status: 'synced' }, + limit: 1, + fanOut: true, + }, + }, + { + id: 's1', + type: 'openregister.set-fields', + config: { set: { status: 'reviewed' } }, + }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'update', + match: [{ property: 'sourceId', value: '{{ sourceId }}' }], + fields: { status: 'reviewed' }, + }, + }, + { + id: 'n1', + type: 'openconnector.source-call', + config: { + method: 'POST', + source: '{{ncSource}}', + endpoint: + '/ocs/v2.php/apps/notifications/api/v2/admin_notifications/admin', + body: { + shortMessage: 'Flow engine test: an object was reviewed', + }, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'r1' }, { id: 'b', from: 'r1', to: 's1' }, { id: 'c', from: 's1', to: 'w1' }, { id: 'd', from: 'w1', to: 'n1' }, { id: 'e', from: 'n1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'r1' }, + { id: 'b', from: 'r1', to: 's1' }, + { id: 'c', from: 's1', to: 'w1' }, + { id: 'd', from: 'w1', to: 'n1' }, + { id: 'e', from: 'n1', to: 'e1' }, + ], expect: { status: 'reviewed', atLeast: 1 }, }, { key: 'mailbox-summary-ai', title: '3 — Summarise a mailbox with an AI agent', - description: 'Fetch messages, hand them to an agent for a one-line summary, store the result.', + description: + 'Fetch messages, hand them to an agent for a one-line summary, store the result.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 'call1', type: 'openconnector.source-call', config: { method: 'GET', source: '{{extSource}}', endpoint: '/posts?_limit=3' } }, - { id: 'a1', type: 'hermiq.agent-step', config: { agent: '{{agent}}', prompt: 'Summarise these messages in one short sentence.' } }, - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: 'mailbox-summary', status: 'summarised' } } }, + { + id: 'call1', + type: 'openconnector.source-call', + config: { + method: 'GET', + source: '{{extSource}}', + endpoint: '/posts?_limit=3', + }, + }, + { + id: 'a1', + type: 'hermiq.agent-step', + config: { + agent: '{{agent}}', + prompt: 'Summarise these messages in one short sentence.', + }, + }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { name: 'mailbox-summary', status: 'summarised' }, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'call1' }, { id: 'b', from: 'call1', to: 'a1' }, { id: 'c', from: 'a1', to: 'w1' }, { id: 'd', from: 'w1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'call1' }, + { id: 'b', from: 'call1', to: 'a1' }, + { id: 'c', from: 'a1', to: 'w1' }, + { id: 'd', from: 'w1', to: 'e1' }, + ], expect: { status: 'summarised', atLeast: 1 }, // The only case that needs a model server. Skipped, loudly, when absent — // see the collection's pre-request guard. @@ -82,55 +186,147 @@ export const CASES = [ { key: 'data-quality-sweep', title: '4 — Scheduled data-quality sweep', - description: 'Walk the register on a schedule and flag every record that fails a rule.', + description: + 'Walk the register on a schedule and flag every record that fails a rule.', nodes: [ - { id: 't1', type: 'openregister.trigger-schedule', config: { cron: '0 3 * * *' } }, - { id: 'r1', type: 'openregister.object-read', config: { register: '{{register}}', schema: '{{schema}}', limit: 100, fanOut: true } }, + { + id: 't1', + type: 'openregister.trigger-schedule', + config: { cron: '0 3 * * *' }, + }, + { + id: 'r1', + type: 'openregister.object-read', + config: { + register: '{{register}}', + schema: '{{schema}}', + limit: 100, + fanOut: true, + }, + }, // JsonLogic, NOT a template string. `'{{ status == "synced" }}'` is // accepted by the config guard and then keeps EVERY item, so the // sweep flagged all eleven objects including one with no sourceId — // which case 7 could not then delete by identity. A filter that // silently filters nothing is the quietest way to break a pipeline. - { id: 'f1', type: 'openregister.filter', config: { condition: { '==': [{ var: 'json.status' }, 'synced'] } } }, + { + id: 'f1', + type: 'openregister.filter', + config: { condition: { '==': [{ var: 'json.status' }, 'synced'] } }, + }, // Carry the source record's own id onto the finding. Case 7 then has // something UNIQUE to delete by: object-write refuses a delete whose // match resolves to more than one row, so a finding identified only // by `status: flagged` could never be swept one at a time. - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: 'quality-finding', status: 'flagged', sourceId: '{{ sourceId }}' } } }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { + name: 'quality-finding', + status: 'flagged', + sourceId: '{{ sourceId }}', + }, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'r1' }, { id: 'b', from: 'r1', to: 'f1' }, { id: 'c', from: 'f1', to: 'w1' }, { id: 'd', from: 'w1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'r1' }, + { id: 'b', from: 'r1', to: 'f1' }, + { id: 'c', from: 'f1', to: 'w1' }, + { id: 'd', from: 'w1', to: 'e1' }, + ], expect: { status: 'flagged', atLeast: 1 }, }, { key: 'enrichment', title: '5 — Enrich an object from an external register', - description: 'Look a record up in an external source and store the derived field.', + description: + 'Look a record up in an external source and store the derived field.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 'call1', type: 'openconnector.source-call', config: { method: 'GET', source: '{{extSource}}', endpoint: '/users/1' } }, + { + id: 'call1', + type: 'openconnector.source-call', + config: { + method: 'GET', + source: '{{extSource}}', + endpoint: '/users/1', + }, + }, // set-fields, NOT `map`: the map node resolves a STORED openconnector // Mapping by id/uuid/slug, so an inline object is rejected with // 'No mapping matches "Array"'. Inline derivation is set-fields. - { id: 'm1', type: 'openregister.set-fields', config: { set: { note: '{{ response.body.company.name }}' } } }, - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: 'enriched', status: 'enriched', note: '{{ note }}' } } }, + { + id: 'm1', + type: 'openregister.set-fields', + config: { set: { note: '{{ response.body.company.name }}' } }, + }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { + name: 'enriched', + status: 'enriched', + note: '{{ note }}', + }, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'call1' }, { id: 'b', from: 'call1', to: 'm1' }, { id: 'c', from: 'm1', to: 'w1' }, { id: 'd', from: 'w1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'call1' }, + { id: 'b', from: 'call1', to: 'm1' }, + { id: 'c', from: 'm1', to: 'w1' }, + { id: 'd', from: 'w1', to: 'e1' }, + ], expect: { status: 'enriched', atLeast: 1 }, }, { key: 'approval-routing', title: '6 — Route an object for approval', - description: 'Branch on a record state and raise an approval task down the matching path.', + description: + 'Branch on a record state and raise an approval task down the matching path.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 'r1', type: 'openregister.object-read', config: { register: '{{register}}', schema: '{{schema}}', filters: { status: 'reviewed' }, limit: 5, fanOut: true } }, + { + id: 'r1', + type: 'openregister.object-read', + config: { + register: '{{register}}', + schema: '{{schema}}', + filters: { status: 'reviewed' }, + limit: 5, + fanOut: true, + }, + }, { id: 'sw1', type: 'openregister.switch', config: {} }, - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: 'approval-task', status: 'awaiting-approval' } } }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { name: 'approval-task', status: 'awaiting-approval' }, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'r1' }, { id: 'b', from: 'r1', to: 'sw1' }, { id: 'c', from: 'sw1', to: 'w1' }, { id: 'd', from: 'w1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'r1' }, + { id: 'b', from: 'r1', to: 'sw1' }, + { id: 'c', from: 'sw1', to: 'w1' }, + { id: 'd', from: 'w1', to: 'e1' }, + ], expect: { status: 'awaiting-approval', atLeast: 1 }, // Consumes what case 2 produces, so it must run after it. after: 'notify-on-change', @@ -141,16 +337,43 @@ export const CASES = [ description: 'Delete records that are past their keep-by state.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 'r1', type: 'openregister.object-read', config: { register: '{{register}}', schema: '{{schema}}', filters: { status: 'flagged' }, limit: 100, fanOut: true } }, + { + id: 'r1', + type: 'openregister.object-read', + config: { + register: '{{register}}', + schema: '{{schema}}', + filters: { status: 'flagged' }, + limit: 100, + fanOut: true, + }, + }, // BOTH pairs are load-bearing. `sourceId` alone matches the synced // record as well as its finding; `status` alone matches all eleven // findings. object-write refuses any delete whose match resolves to // more than one row — a deliberate mass-delete guard — so the pair // together is what makes this a per-item sweep rather than a refusal. - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'delete', match: [{ property: 'sourceId', value: '{{ sourceId }}' }, { property: 'status', value: 'flagged' }], confirmDelete: true } }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'delete', + match: [ + { property: 'sourceId', value: '{{ sourceId }}' }, + { property: 'status', value: 'flagged' }, + ], + confirmDelete: true, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'r1' }, { id: 'b', from: 'r1', to: 'w1' }, { id: 'c', from: 'w1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'r1' }, + { id: 'b', from: 'r1', to: 'w1' }, + { id: 'c', from: 'w1', to: 'e1' }, + ], // The only case whose effect is a DISAPPEARANCE, so it asserts the // inverse: what case 4 flagged must be gone. expect: { status: 'flagged', exactly: 0 }, @@ -159,7 +382,8 @@ export const CASES = [ { key: 'paginated-sync', title: '9 — Page an API until it runs out, syncing each page', - description: 'Loops pages of an external API and hands each page to a sync sub-flow, upserting so a re-run converges.', + description: + 'Loops pages of an external API and hands each page to a sync sub-flow, upserting so a re-run converges.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, { @@ -172,15 +396,26 @@ export const CASES = [ // source-call can never signal "exhausted". The sub-flow fetches // AND explodes, so a page past the end yields zero items, which // is the one termination rule. - source: { type: 'openregister.sub-flow', config: { flowId: '{{pagerFlow}}', wait: true } }, - body: [{ type: 'openregister.sub-flow', config: { flowId: '{{syncFlow}}', wait: true } }], + source: { + type: 'openregister.sub-flow', + config: { flowId: '{{pagerFlow}}', wait: true }, + }, + body: [ + { + type: 'openregister.sub-flow', + config: { flowId: '{{syncFlow}}', wait: true }, + }, + ], maxIterations: 15, onLimit: 'fail', }, }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'pages1' }, { id: 'b', from: 'pages1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'pages1' }, + { id: 'b', from: 'pages1', to: 'e1' }, + ], // 100 records over 11 pages of 10. The count is the claim: a loop that // fetched page one eleven times would also report success, and would also // leave objects behind — just the same ten, over and over. @@ -189,10 +424,21 @@ export const CASES = [ { key: 'route-and-merge', title: '11 — Split records down two paths, then bring them back together', - description: 'Routes each record to a different branch by a rule, writes a different outcome on each, and merges the branches back into one list.', + description: + 'Routes each record to a different branch by a rule, writes a different outcome on each, and merges the branches back into one list.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 'r1', type: 'openregister.object-read', config: { register: '{{register}}', schema: '{{schema}}', filters: { status: 'paged' }, limit: 100, fanOut: true } }, + { + id: 'r1', + type: 'openregister.object-read', + config: { + register: '{{register}}', + schema: '{{schema}}', + filters: { status: 'paged' }, + limit: 100, + fanOut: true, + }, + }, // A router's `output` is the ID OF THE TARGET NODE — placement asks // `itemsForOutput(items, output: $to)`. Naming a branch anything else // sends its items nowhere, silently. @@ -200,12 +446,35 @@ export const CASES = [ id: 'route1', type: 'openregister.route', config: { - rules: [{ condition: { '>': [{ var: 'json.externalId' }, 50] }, output: 'hi1' }], + rules: [ + { + condition: { '>': [{ var: 'json.externalId' }, 50] }, + output: 'hi1', + }, + ], default: 'lo1', }, }, - { id: 'hi1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: 'high', status: 'routed-high' } } }, - { id: 'lo1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: 'low', status: 'routed-low' } } }, + { + id: 'hi1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { name: 'high', status: 'routed-high' }, + }, + }, + { + id: 'lo1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { name: 'low', status: 'routed-low' }, + }, + }, { id: 'merge1', type: 'openregister.merge', config: { mode: 'append' } }, { id: 'e1', type: 'openregister.end', config: {} }, ], @@ -221,41 +490,104 @@ export const CASES = [ // BOTH branches, because either one alone proves nothing: a router that // sent everything down one path would satisfy a single-branch assertion // while doing no routing at all. The split is the claim. - expect: { status: 'routed-high', atLeast: 1, also: { status: 'routed-low', atLeast: 1 } }, + expect: { + status: 'routed-high', + atLeast: 1, + also: { status: 'routed-low', atLeast: 1 }, + }, after: 'paginated-sync', }, { key: 'sync-cursor', title: '10 — Remember a sync cursor between runs', - description: 'Stores where the last sync got to, reads it back, and records it — the state a real incremental sync keeps.', + description: + 'Stores where the last sync got to, reads it back, and records it — the state a real incremental sync keeps.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 'put1', type: 'openregister.flow-state', config: { operation: 'set', key: 'lastSyncedId', value: 100 } }, - { id: 'get1', type: 'openregister.flow-state', config: { operation: 'get', key: 'lastSyncedId', as: 'cursor', default: 0 } }, - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: 'cursor', status: 'cursor-stored', externalId: '{{ cursor }}' } } }, + { + id: 'put1', + type: 'openregister.flow-state', + config: { operation: 'set', key: 'lastSyncedId', value: 100 }, + }, + { + id: 'get1', + type: 'openregister.flow-state', + config: { + operation: 'get', + key: 'lastSyncedId', + as: 'cursor', + default: 0, + }, + }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { + name: 'cursor', + status: 'cursor-stored', + externalId: '{{ cursor }}', + }, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'put1' }, { id: 'b', from: 'put1', to: 'get1' }, { id: 'c', from: 'get1', to: 'w1' }, { id: 'd', from: 'w1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'put1' }, + { id: 'b', from: 'put1', to: 'get1' }, + { id: 'c', from: 'get1', to: 'w1' }, + { id: 'd', from: 'w1', to: 'e1' }, + ], // The WRITTEN value is the assertion, not the fact that the steps ran: a // state node that stored nothing and read back its default would also // report `completed`, and would write the default instead. // The stored VALUE, not merely that the steps ran: a state node that // stored nothing and read back its default would also report // `completed`, and would write 0 here instead of 100. - expect: { status: 'cursor-stored', atLeast: 1, field: { name: 'externalId', equals: 100 } }, + expect: { + status: 'cursor-stored', + atLeast: 1, + field: { name: 'externalId', equals: 100 }, + }, }, { key: 'batch-export', title: '8 — Batch export to an external endpoint', - description: 'Read local objects and POST them outward in fixed-size batches.', + description: + 'Read local objects and POST them outward in fixed-size batches.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 'r1', type: 'openregister.object-read', config: { register: '{{register}}', schema: '{{schema}}', limit: 10, fanOut: true } }, + { + id: 'r1', + type: 'openregister.object-read', + config: { + register: '{{register}}', + schema: '{{schema}}', + limit: 10, + fanOut: true, + }, + }, { id: 'b1', type: 'openregister.batch', config: { batchSize: 5 } }, - { id: 'call1', type: 'openconnector.source-call', config: { method: 'POST', source: '{{extSource}}', endpoint: '/posts' } }, + { + id: 'call1', + type: 'openconnector.source-call', + config: { + method: 'POST', + source: '{{extSource}}', + endpoint: '/posts', + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'r1' }, { id: 'b', from: 'r1', to: 'b1' }, { id: 'c', from: 'b1', to: 'call1' }, { id: 'd', from: 'call1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'r1' }, + { id: 'b', from: 'r1', to: 'b1' }, + { id: 'c', from: 'b1', to: 'call1' }, + { id: 'd', from: 'call1', to: 'e1' }, + ], // Its effect is outbound, so there are no local rows to count. The // assertion is that the batch node actually BATCHED — 10 records in, // fewer calls out — which is the thing that would silently regress. @@ -264,43 +596,96 @@ export const CASES = [ { key: 'map-transform', title: '12 — Reshape items through a stored mapping', - description: 'Feed fields into a stored mapping and write the RESHAPED result, proving the map node transformed rather than passed through.', + description: + 'Feed fields into a stored mapping and write the RESHAPED result, proving the map node transformed rather than passed through.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, - { id: 's1', type: 'openregister.set-fields', config: { set: { first: 'Ada', last: 'Lovelace', n: '7' } } }, + { + id: 's1', + type: 'openregister.set-fields', + config: { set: { first: 'Ada', last: 'Lovelace', n: '7' } }, + }, // Named by UUID, not by the numeric id: a flow definition is portable // between instances where the id differs, and the uuid path is the one // that used to throw. MappingMapper::find() put `id = ''` into the // disjunction, which on Postgres raises "invalid input syntax for type // bigint" and takes the whole lookup down — so every by-uuid resolve // failed with "No mapping matches", as though the row were absent. - { id: 'm1', type: 'openregister.map', config: { mapping: '{{mappingUuid}}' } }, - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: '{{ fullName }}', sourceId: '{{ kept }}', status: 'mapped' } } }, + { + id: 'm1', + type: 'openregister.map', + config: { mapping: '{{mappingUuid}}' }, + }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { + name: '{{ fullName }}', + sourceId: '{{ kept }}', + status: 'mapped', + }, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 's1' }, { id: 'b', from: 's1', to: 'm1' }, { id: 'c', from: 'm1', to: 'w1' }, { id: 'd', from: 'w1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 's1' }, + { id: 'b', from: 's1', to: 'm1' }, + { id: 'c', from: 'm1', to: 'w1' }, + { id: 'd', from: 'w1', to: 'e1' }, + ], // `fullName` exists ONLY as the mapping's output. Asserting the row's name // equals 'Ada Lovelace' is what separates a real transform from a map node // that quietly handed the items back unchanged — which is exactly what the // node used to do before it learned to throw on an unresolvable mapping. - expect: { status: 'mapped', atLeast: 1, text: { name: 'name', equals: 'Ada Lovelace' } }, + expect: { + status: 'mapped', + atLeast: 1, + text: { name: 'name', equals: 'Ada Lovelace' }, + }, }, { key: 'trigger-object', title: '13 — An object-event trigger, run by hand', - description: 'A flow whose entry point is an object event still runs when triggered manually.', + description: + 'A flow whose entry point is an object event still runs when triggered manually.', nodes: [ - { id: 'to1', type: 'openregister.trigger-object', config: { event: 'created', register: '{{register}}', schema: '{{schema}}' } }, - { id: 'w1', type: 'openregister.object-write', config: { register: '{{register}}', schema: '{{schema}}', operation: 'create', fields: { name: 'event-triggered', status: 'evented' } } }, + { + id: 'to1', + type: 'openregister.trigger-object', + config: { + event: 'created', + register: '{{register}}', + schema: '{{schema}}', + }, + }, + { + id: 'w1', + type: 'openregister.object-write', + config: { + register: '{{register}}', + schema: '{{schema}}', + operation: 'create', + fields: { name: 'event-triggered', status: 'evented' }, + }, + }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 'to1', to: 'w1' }, { id: 'b', from: 'w1', to: 'e1' }], + edges: [ + { id: 'a', from: 'to1', to: 'w1' }, + { id: 'b', from: 'w1', to: 'e1' }, + ], expect: { status: 'evented', atLeast: 1 }, }, { key: 'wait-suspends', title: '14 — A wait step suspends the run', - description: 'A wait step parks the token instead of finishing, and says so.', + description: + 'A wait step parks the token instead of finishing, and says so.', nodes: [ { id: 't1', type: 'openregister.trigger-manual', config: {} }, // `for` takes seconds as a bare number, or anything strtotime() can read @@ -311,10 +696,16 @@ export const CASES = [ { id: 'wa1', type: 'openregister.wait', config: { for: '1 hour' } }, { id: 'e1', type: 'openregister.end', config: {} }, ], - edges: [{ id: 'a', from: 't1', to: 'wa1' }, { id: 'b', from: 'wa1', to: 'e1' }], + edges: [ + { id: 'a', from: 't1', to: 'wa1' }, + { id: 'b', from: 'wa1', to: 'e1' }, + ], // `suspended` is this case's SUCCESS. A wait that came back `completed` // would mean the step waited for nothing, and asserting the usual terminal // set would have demanded exactly that broken behaviour. - expect: { terminal: ['suspended'], step: { node: 'wa1', status: 'suspended' } }, + expect: { + terminal: ['suspended'], + step: { node: 'wa1', status: 'suspended' }, + }, }, ] diff --git a/tests/validate-json-strict.js b/tests/validate-json-strict.js index 8a27cecf1a..cd1982f64a 100644 --- a/tests/validate-json-strict.js +++ b/tests/validate-json-strict.js @@ -45,7 +45,6 @@ function targetFiles() { // `pathPrefix` is the JSON-pointer-ish path used in the error message. function parseStrict(text, label) { const dupErrors = [] - const reviverPathStack = [] // JSON.parse's reviver can't see duplicates (the object is already // collapsed). So we re-implement just enough: tokenise object keys. // Simpler robust approach: walk the raw text with a tiny tokenizer. @@ -101,7 +100,7 @@ function parseStrict(text, label) { return } let idx = 0 - // eslint-disable-next-line no-constant-condition + while (true) { readValue(`${pathStr}/${idx}`) idx++ @@ -126,7 +125,7 @@ function parseStrict(text, label) { i++ return } - // eslint-disable-next-line no-constant-condition + while (true) { skipWs() if (text[i] !== '"') err('expected string key in object') diff --git a/tests/validate-manifest.js b/tests/validate-manifest.js index ac9c4b0107..6e890620bf 100644 --- a/tests/validate-manifest.js +++ b/tests/validate-manifest.js @@ -139,8 +139,8 @@ function loadAjv() { // The canonical schema uses JSON Schema draft 2020-12. Standard Ajv (v7+) // does not auto-load the 2020 meta-schema; we need the `ajv/dist/2020` // entry point. - let Ajv2020 = null - let addFormats = null + let Ajv2020 + let addFormats try { // Ajv 8+ ships the 2020 draft entry point. Ajv2020 = require('ajv/dist/2020').default || require('ajv/dist/2020') From ddbbeba6906a43b08e648895ad24778fa9653de4 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 14:09:46 +0200 Subject: [PATCH 26/68] =?UTF-8?q?feat(flow):=20the=20task=20entity=20?= =?UTF-8?q?=E2=80=94=20a=20suspension=20becomes=20a=20queryable=20row=20(#?= =?UTF-8?q?3258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tasks): the task store - four tables, entities and mappers The flow-task-entity storage layer (openspec/changes/flow-task-entity): openregister_tasks with the resolved 23-shape union (no overdue column, no app-named column, performer_type as an open string vocabulary so the coming external type is an append, definition_version shipped now), openregister_task_candidates as the pooled-inbox index half of the candidate pool, openregister_task_relations as the typed anchor table, and openregister_task_audit with an append-only mapper whose update and delete refuse. claim() is a conditional update so the database decides the race, and the inbox page and total share one predicate builder. Ticks tasks 1.1 and 1.2. * feat(tasks): the vocabularies, the authorization and the routing Normalisation at the boundary: TaskState publishes the one legacy-status mapping onto the six CMMN states (collapsed distinctions survive on outcome, an unmapped value is refused naming itself, and a migration may declare its source vocabulary so procest's status 'open' fails loudly); TaskPriority lands all four fleet scales on low|normal|high|urgent and refuses pipelinq's 'normaal'; TaskTemporalProjection is the ONE overdue derivation, computed and never stored. TaskAuthorizationService decides every verb fail-closed BEFORE mutation: no group backend, an unresolvable role or an unknown performer type each DENY rather than skip, and no nullable service-unavailable return exists. TaskPerformerResolver carries the five routing strategies; a strategy that finds nobody assigns nobody. Ticks tasks 2.1, 2.2, 3.1 and 3.2. * feat(tasks): the lifecycle service, cancellation propagation and the inbox TaskService: create/offer/claim/unclaim/assign/reassign/delegate/ resolve/complete/cancel, each authorized fail-closed before any mutation, each audited in the SAME transaction (an audit-write failure unwinds the completion), denials audited too. One write path maintains the candidate JSON and the candidate index rows together; state and is_terminal move in one statement; claim is the mapper's conditional update so the race's loser gets a conflict; a rejecting outcome without a comment is refused before anything moves; the template snapshot is frozen at creation. Cancellation propagation (design D-8): FlowRunMapper::update() - the one choke point every terminal run write passes - dispatches FlowRunTerminalEvent, and TaskRunTerminalListener terminates that run's open tasks with the reason recorded. Idempotent, and a task with run_uuid null is structurally out of reach. TaskInboxService answers assigned/pooled/watched/by-object in the datastore over one shared predicate set, with the total from the same predicates, subject context batched per page, a display title synthesized on read for titleless tasks (never persisted), and the derived temporal projection attached to rows only. Ticks tasks 4.1-4.5, 5.1, 6.1 and 6.2. * feat(tasks): the REST surface and the seed fixtures TaskController + /api/flow-tasks routes for the inbox, the detail and audit reads (both visibility-checked) and every lifecycle verb. The family is named for the flow-tasks CAPABILITY, not a flow requirement - /api/tasks itself already belongs to the CalDAV VTODO leaf. Every method declares its auth posture attribute AND delegates its actual authorization to TaskAuthorizationService inside the service; refusals translate uniformly (400 validation, 403 denial, 409 conflict naming the current state, 404 absence). SeedTaskFixtures installs the five seed groups from design.md (pooled municipal check with no run, delegated approval with enforcing expiry on a run, agent task with a typed checklist, one completed 'approved' and one terminated by propagation, plus audit fixtures including one DENIED entry), idempotent on uuid - and is REGISTERED in appinfo/info.xml in this same commit, because an unregistered repair step is a known fleet defect. Ticks tasks 7.1 and 8.1. * test(tasks): the guarantees, pinned - 93 tests over the task layer Table-driven legacy-status and priority tests including the live fleet defects: procest's status 'open' against a source vocabulary that does not define it is refused naming the value, and pipelinq's 'normaal' is refused on every scale. Authorization: a stranger who knows the uuid is denied on every verb, an unresolvable role denies naming the role, an absent group backend denies, an unknown performer type denies, watchers read and never act. Concurrency and transactionality: the two-claim race yields one assignee and one conflict, a rejection without a comment moves nothing, and an injected audit-write failure rolls the completion back. Derivation: overdue flips with an injected clock while the stored row stays byte-identical. Inbox: the page and the total run over the SAME criteria object (25 of 120 reported honestly), a titleless task presents a synthesized display title while its stored title stays null, and a runless task completes identically to a runful one. Mutation-checked: disabling assertMay fails 7 tests; removing the transaction rollback fails 2; both restored byte-identical (sha256-verified). Ticks tasks 9.1, 9.2 and 9.3. * test(tasks): playwright coverage for the two e2e-marked scenarios 'a stranger is refused on the task detail route': a provisioned second account gets 403 on the detail read AND on complete, and the task provably did not move - the positive control for the hole where knowing a uuid was the whole check. 'the inbox route returns tasks with subject context': one request lists the assignee's work with a display title, the subject slot, the derived overdue flag and a datastore total, and when the dev seed offers a live object the anchored subject context rides along. Both specs clean up after themselves and skip loudly when the instance cannot provision a stranger. Ticks task 9.4. * style(tasks): named parameters on every internal call, no inline ifs The repo's PHPCS standard requires named arguments on calls into OCA code and forbids ternaries. Exception constructors now name message:, the transactional/stringOrNull/arrayOrNull/respondWith calls name their parameter, the nine isset-ternaries collapse into one intOrNull helper, and the two remaining conditionals become explicit if blocks. No behaviour change: the 93 task tests are unchanged and green. * refactor(tasks): the quality pass - intake extracted, complexity earned down TaskBuilder now owns intake: boundary data in, a validated unsaved Task out, with every published vocabulary applied there once. That returns TaskService to lifecycle only and under the complexity budget. The authorization switch becomes a verb-to-rule table with static dispatch; the inbox predicate builder splits into scope, visibility and filters; the migration's indexes and the seed fixtures get methods of their own; sort and direction travel as one parameter (-dueAt) so the inbox route keeps a single-digit parameter list. The few remaining suppressions each carry their justification inline. Behaviour unchanged: the 93 task tests pass untouched except for injecting the builder. * fix(tasks): the PHPStan pass - typed mapper overrides and a DBAL constant The mapper overrides now guard their entity type with a throwing instanceof check, which is what lets the generic QBMapper contract type-check (and turns a wrong entity into a named error instead of a silent write). candidateMembershipPredicate declares the IQueryFunction it returns. The migration uses Types::DATETIME_MUTABLE, the constant the current DBAL stubs actually define. * style(tasks): import InvalidArgumentException in the three mapper guards PHPMD MissingImport on the type guards added in the PHPStan pass. No behaviour change. * fix(tasks): the review pass - offer is the requester's, and the inbox works on PostgreSQL Two blocking defects from the independent review of #3258: - offer bypassed per-task authorization and rewrote the assignee. Any authenticated user could POST offer {"routingFallback": "mallory"} on an assigned active task, become its assignee, and then complete it. offer now needs the requester (or an administrator), refuses a task that already has an assignee (unclaim or reassign instead), and is in the stranger-denied test's verb list, whose omission is how the suite stayed green. - The watchers LIKE ran against a json column, which PostgreSQL refuses (operator does not exist: json ~~ unknown): every non-admin inbox request 500ed there. The column is now cast per platform (AS TEXT on PostgreSQL, AS CHAR elsewhere), every identifier in the mapper's raw SQL goes through the platform's own quoter (no backticks), and TaskMapperTest pins the cast on a PostgreSQL platform mock. The should-fix items: authorization runs before the terminality check and a task the caller may not see answers 404 on every route, verbs included (no existence or state oracle); over HTTP the requester is pinned to the actor and a terminal creation state is refused, with import() as the trusted path for migrations and the user-task node; every state-changing write is a conditional update (WHERE is_terminal = false) so a second completion conflicts instead of overwriting; delegate refuses an empty delegate and keeps naming the original performer on re-delegation; an unexpected failure is a generic 500 whose detail goes to the log; propagation continues past a failing task; role pools are matched by the inbox EXISTS; the seed fixtures run only when app config openregister/seed_demo_tasks is true; the CSRF posture is recorded in the controller docblock; the e2e stranger scenario fails loudly instead of skipping and now covers the non-admin inbox and a watcher. Class-level @spec tags on every new class. TaskControllerTest is the contract test for all fourteen /api/flow-tasks endpoints (gate-25 PASS). Checks on this tree: PHPCS 0, PHPMD 0 (touched files), PHPStan 0, Psalm 0, 120 task tests green. * style(tasks): format the task inbox e2e spec with the Nextcloud prettier config CI's Frontend Check (format) named tests/e2e/task-inbox.spec.ts. Formatted with @nextcloud/prettier-config's options (tabs, no semicolons, single quotes, trailing commas, print width 85, operators leading). * test(tasks): cover the entities, the listener, the seed step and the builder CI's pgsql cell did not fail in PHPUnit (17860 tests, 0 failures, and app:enable succeeded); it failed the coverage guard: the changed files measured 3.85% (69/1791 statements) against a 7.32% base, because the suite runs with strict coverage metadata and collaborators exercised through TaskServiceTest were never credited. This adds the tests that were genuinely missing: the four entities' typing, hydration and serialisation (no overdue anywhere in the stored row); the terminal-run listener and event; the seed step off by default, seeding the five groups when on, idempotent on uuid, and continuing past a failing fixture; and TaskBuilder credited where TaskServiceTest exercises it. 133 task tests green. * test(tasks): walk the mapper queries and credit every exercised class The pgsql cell's coverage guard credits only statements in classes a test names with @covers (strict coverage metadata), and the changed files measured 20.16% against a 23.46% base. Two moves: every task test class now names each class it actually exercises (TaskBuilder, TaskState and TaskPriority behind TaskServiceTest, TaskInboxCriteria behind the inbox and controller tests, the entities and exceptions everywhere they are built), and the largest untested code is now walked: TaskMapper's whole query vocabulary, the candidate, relation and audit mappers, and FlowRunMapper's terminality announcement, over a fluent query-builder double that records which predicate and column each query sets, so a conditional update without its openness guard or a pooled scope without its EXISTS would fail here. Plus the untested TaskService verbs (unclaim, assign, reassign, cancel, resolve, terminateAsMoot, relations, intake refusals), TaskInboxService's batched subject context, and the controller's filter, flag and failing-backend paths. 168 task tests. * style(tasks): a top-level type import and sorted specifiers in the inbox e2e spec Vue Quality (eslint) on #3258 named four errors in tests/e2e/task-inbox.spec.ts: perfectionist/sort-named-imports (the specifiers now read apiRequest, expect, test) and import-extensions/ban-inline-type-imports (APIRequestContext is a top-level import type). eslint --fix plus a plain run: 0 errors; the sibling flow-engine.spec.ts as control also 0; prettier clean. --------- Co-authored-by: Conduction Release Bot --- appinfo/info.xml | 8 + appinfo/routes.php | 21 + lib/AppInfo/Application.php | 11 + lib/Controller/TaskController.php | 591 ++++++++++ lib/Db/FlowRunMapper.php | 53 +- lib/Db/Task.php | 844 ++++++++++++++ lib/Db/TaskAudit.php | 178 +++ lib/Db/TaskAuditMapper.php | 135 +++ lib/Db/TaskCandidate.php | 111 ++ lib/Db/TaskCandidateMapper.php | 102 ++ lib/Db/TaskInboxCriteria.php | 112 ++ lib/Db/TaskMapper.php | 667 +++++++++++ lib/Db/TaskRelation.php | 121 ++ lib/Db/TaskRelationMapper.php | 94 ++ lib/Event/FlowRunTerminalEvent.php | 77 ++ lib/Exception/TaskAccessDeniedException.php | 40 + lib/Exception/TaskConflictException.php | 39 + lib/Exception/TaskValidationException.php | 40 + lib/Listener/TaskRunTerminalListener.php | 104 ++ lib/Migration/Version1Date20260831120000.php | 328 ++++++ lib/Repair/SeedTaskFixtures.php | 441 +++++++ lib/Service/Task/TaskAuthorizationService.php | 390 +++++++ lib/Service/Task/TaskBuilder.php | 339 ++++++ lib/Service/Task/TaskInboxService.php | 253 ++++ lib/Service/Task/TaskPerformerResolver.php | 255 +++++ lib/Service/Task/TaskPriority.php | 133 +++ lib/Service/Task/TaskService.php | 963 ++++++++++++++++ lib/Service/Task/TaskState.php | 187 +++ lib/Service/Task/TaskTemporalProjection.php | 106 ++ openspec/changes/flow-task-entity/tasks.md | 40 +- tests/Unit/Controller/TaskControllerTest.php | 526 +++++++++ tests/Unit/Db/FluentQueryBuilderTrait.php | 229 ++++ tests/Unit/Db/TaskEntitiesTest.php | 197 ++++ tests/Unit/Db/TaskMapperQueriesTest.php | 302 +++++ tests/Unit/Db/TaskMapperTest.php | 121 ++ tests/Unit/Db/TaskSideMappersTest.php | 174 +++ .../Listener/TaskRunTerminalListenerTest.php | 87 ++ tests/Unit/Repair/SeedTaskFixturesTest.php | 236 ++++ .../Task/TaskAuthorizationServiceTest.php | 332 ++++++ .../Service/Task/TaskInboxServiceTest.php | 325 ++++++ .../Task/TaskPerformerResolverTest.php | 224 ++++ tests/Unit/Service/Task/TaskPriorityTest.php | 114 ++ tests/Unit/Service/Task/TaskServiceTest.php | 1017 +++++++++++++++++ tests/Unit/Service/Task/TaskStateTest.php | 173 +++ .../Task/TaskTemporalProjectionTest.php | 145 +++ tests/e2e/task-inbox.spec.ts | 239 ++++ 46 files changed, 11203 insertions(+), 21 deletions(-) create mode 100644 lib/Controller/TaskController.php create mode 100644 lib/Db/Task.php create mode 100644 lib/Db/TaskAudit.php create mode 100644 lib/Db/TaskAuditMapper.php create mode 100644 lib/Db/TaskCandidate.php create mode 100644 lib/Db/TaskCandidateMapper.php create mode 100644 lib/Db/TaskInboxCriteria.php create mode 100644 lib/Db/TaskMapper.php create mode 100644 lib/Db/TaskRelation.php create mode 100644 lib/Db/TaskRelationMapper.php create mode 100644 lib/Event/FlowRunTerminalEvent.php create mode 100644 lib/Exception/TaskAccessDeniedException.php create mode 100644 lib/Exception/TaskConflictException.php create mode 100644 lib/Exception/TaskValidationException.php create mode 100644 lib/Listener/TaskRunTerminalListener.php create mode 100644 lib/Migration/Version1Date20260831120000.php create mode 100644 lib/Repair/SeedTaskFixtures.php create mode 100644 lib/Service/Task/TaskAuthorizationService.php create mode 100644 lib/Service/Task/TaskBuilder.php create mode 100644 lib/Service/Task/TaskInboxService.php create mode 100644 lib/Service/Task/TaskPerformerResolver.php create mode 100644 lib/Service/Task/TaskPriority.php create mode 100644 lib/Service/Task/TaskService.php create mode 100644 lib/Service/Task/TaskState.php create mode 100644 lib/Service/Task/TaskTemporalProjection.php create mode 100644 tests/Unit/Controller/TaskControllerTest.php create mode 100644 tests/Unit/Db/FluentQueryBuilderTrait.php create mode 100644 tests/Unit/Db/TaskEntitiesTest.php create mode 100644 tests/Unit/Db/TaskMapperQueriesTest.php create mode 100644 tests/Unit/Db/TaskMapperTest.php create mode 100644 tests/Unit/Db/TaskSideMappersTest.php create mode 100644 tests/Unit/Listener/TaskRunTerminalListenerTest.php create mode 100644 tests/Unit/Repair/SeedTaskFixturesTest.php create mode 100644 tests/Unit/Service/Task/TaskAuthorizationServiceTest.php create mode 100644 tests/Unit/Service/Task/TaskInboxServiceTest.php create mode 100644 tests/Unit/Service/Task/TaskPerformerResolverTest.php create mode 100644 tests/Unit/Service/Task/TaskPriorityTest.php create mode 100644 tests/Unit/Service/Task/TaskServiceTest.php create mode 100644 tests/Unit/Service/Task/TaskStateTest.php create mode 100644 tests/Unit/Service/Task/TaskTemporalProjectionTest.php create mode 100644 tests/e2e/task-inbox.spec.ts diff --git a/appinfo/info.xml b/appinfo/info.xml index 9b9c2fff1a..29cd40200a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -240,6 +240,14 @@ Vrij en open source onder de EUPL-licentie. Post-migration, because it needs the flow_* columns the schema migration adds. --> OCA\OpenRegister\Repair\RechainAuditTrailForFlowAttribution + + OCA\OpenRegister\Repair\SeedTaskFixtures OCA\OpenRegister\Repair\ReconcileDeclaredBackgroundJobs diff --git a/appinfo/routes.php b/appinfo/routes.php index 9c7aac2c02..7739102ec3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1349,6 +1349,27 @@ ['name' => 'flowRun#resume', 'url' => '/api/flow-runs/{uuid}/resume', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], // Interactive test run (or-flow-partial-run): run synchronously with optional startAt + pins + seed. ['name' => 'flowRun#test', 'url' => '/api/flow-runs/test', 'verb' => 'POST'], + // The fleet-generic task (flow-task-entity): the inbox and the + // lifecycle verbs. Named for the `flow-tasks` CAPABILITY, not for a + // flow requirement — a standalone task with run_uuid null is served + // here identically. `/api/tasks` itself belongs to the older CalDAV + // VTODO leaf (tasks#allUserTasks above), which is a different thing. + // Every verb's real authorization is TaskAuthorizationService inside + // the service; the route attribute is never the whole check. + ['name' => 'task#index', 'url' => '/api/flow-tasks', 'verb' => 'GET'], + ['name' => 'task#create', 'url' => '/api/flow-tasks', 'verb' => 'POST'], + ['name' => 'task#show', 'url' => '/api/flow-tasks/{uuid}', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#audit', 'url' => '/api/flow-tasks/{uuid}/audit', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#offer', 'url' => '/api/flow-tasks/{uuid}/offer', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#claim', 'url' => '/api/flow-tasks/{uuid}/claim', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#unclaim', 'url' => '/api/flow-tasks/{uuid}/unclaim', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#assign', 'url' => '/api/flow-tasks/{uuid}/assign', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#reassign', 'url' => '/api/flow-tasks/{uuid}/reassign', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#delegate', 'url' => '/api/flow-tasks/{uuid}/delegate', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#resolve', 'url' => '/api/flow-tasks/{uuid}/resolve', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#complete', 'url' => '/api/flow-tasks/{uuid}/complete', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#cancel', 'url' => '/api/flow-tasks/{uuid}/cancel', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'task#checkItem', 'url' => '/api/flow-tasks/{uuid}/checklist/{itemId}', 'verb' => 'PATCH', 'requirements' => ['uuid' => '[^/]+', 'itemId' => '[^/]+']], // Delegation grants (or-delegation-grants): the consent surface. A grant // store with no way to answer is a store that only ever says no, so these // are what make every delegation refusal recoverable. diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 5d1a8ad562..207300bb1b 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -2520,6 +2520,17 @@ private function registerEventListeners(IRegistrationContext $context): void { $context->registerEventListener(ObjectCreatingEvent::class, FlowNodePreflightListener::class); $context->registerEventListener(ObjectUpdatingEvent::class, FlowNodePreflightListener::class); + // Task cancellation propagation (flow-task-entity, design D-8): a run + // persisted in a terminal status terminates its open tasks, with the + // reason recorded. The event fires from FlowRunMapper::update() — the + // one choke point all terminal writes pass — and may fire repeatedly; + // the listener is idempotent, and a task with run_uuid null is + // structurally out of its reach. + $context->registerEventListener( + \OCA\OpenRegister\Event\FlowRunTerminalEvent::class, + \OCA\OpenRegister\Listener\TaskRunTerminalListener::class + ); + // Lifecycle annotation listeners — see x-openregister-lifecycle. // Order matters: initial state runs on creating; validation runs on updating. $context->registerEventListener(ObjectCreatingEvent::class, LifecycleInitialStateListener::class); diff --git a/lib/Controller/TaskController.php b/lib/Controller/TaskController.php new file mode 100644 index 0000000000..7246e40d48 --- /dev/null +++ b/lib/Controller/TaskController.php @@ -0,0 +1,591 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Controller + * @package OCA\OpenRegister\Controller + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; +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\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * REST surface for the fleet-generic task. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One route method per + * lifecycle verb the spec names, plus the three reads. Folding verbs into a + * mode parameter is how per-verb authorization rules get lost. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The controller mediates + * between HTTP and the task services plus their three exception shapes; + * that is the whole of its job. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ +class TaskController extends Controller { + + /** + * Constructor. + * + * @param string $appName The app id. + * @param IRequest $request The request. + * @param TaskService $tasks The authorized lifecycle. + * @param TaskInboxService $inbox The inbox queries. + * @param TaskAuthorizationService $authorization Read-visibility decisions. + * @param TaskTemporalProjection $temporal The one overdue derivation. + * @param IUserSession $userSession Names the acting identity. + * @param LoggerInterface|null $logger Where an unexpected failure's + * detail goes, INSTEAD of the response. + * @param IGroupManager|null $groupManager Resolves the caller's groups + * and admin status for inbox + * visibility. Nullable so the + * controller stays constructible + * bare; absent means no groups + * and not admin, which SCOPES + * rather than widens. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly TaskService $tasks, + private readonly TaskInboxService $inbox, + private readonly TaskAuthorizationService $authorization, + private readonly TaskTemporalProjection $temporal, + private readonly IUserSession $userSession, + private readonly ?LoggerInterface $logger = null, + private readonly ?IGroupManager $groupManager = null, + ) { + parent::__construct(appName: $appName, request: $request); + + }//end __construct() + + /** + * The inbox: what is waiting for me, with subject context and a total. + * + * @param string $scope assigned|pooled|watched|all. + * @param string|null $state Restrict to CMMN states (comma-separated). + * @param string|null $isTerminal 'true'|'false' to restrict on terminality. + * @param string|null $priority Restrict to one priority. + * @param string|null $objectUuid Restrict to tasks anchored to this object. + * @param string|null $overdue 'true' to restrict to derived-overdue tasks. + * @param string $sort dueAt|priority|created. A leading `-` inverts + * the order (`-dueAt`), so sort and direction travel + * as one parameter. + * @param int $limit Page size. + * @param int $offset Page offset. + * + * @return JSONResponse The page: results, total, limit, offset. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function index( + string $scope = TaskInboxCriteria::SCOPE_ASSIGNED, + ?string $state = null, + ?string $isTerminal = null, + ?string $priority = null, + ?string $objectUuid = null, + ?string $overdue = null, + string $sort = TaskInboxCriteria::SORT_DUE, + int $limit = 25, + int $offset = 0, + ): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { + return new JSONResponse(['error' => 'No session'], Http::STATUS_UNAUTHORIZED); + } + + $states = []; + if ($state !== null && trim($state) !== '') { + $states = array_values(array_filter(array_map('trim', explode(',', $state)))); + } + + $terminalFilter = null; + if ($isTerminal !== null) { + $terminalFilter = filter_var($isTerminal, FILTER_VALIDATE_BOOLEAN); + } + + // The derived-overdue filter takes its clock instant from the one + // derivation class, so filter and projection cannot disagree. + $overdueAt = null; + if ($overdue !== null && filter_var($overdue, FILTER_VALIDATE_BOOLEAN) === true) { + $overdueAt = $this->temporal->now(); + } + + $descending = str_starts_with($sort, '-'); + $sortKey = ltrim($sort, '-'); + + $criteria = new TaskInboxCriteria( + uid: $uid, + groupIds: $this->groupIds(uid: $uid), + isAdmin: $this->isAdmin(uid: $uid), + scope: $scope, + states: $states, + isTerminal: $terminalFilter, + priority: $priority, + objectUuid: $objectUuid, + overdueAt: $overdueAt, + sort: $sortKey, + sortDescending: $descending, + ); + + return new JSONResponse($this->inbox->inbox(criteria: $criteria, limit: $limit, offset: $offset)); + }//end index() + + /** + * One task, visibility-checked. + * + * @param string $uuid The task uuid. + * + * @return JSONResponse The task row; 404 when absent OR invisible. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function show(string $uuid): JSONResponse { + try { + $task = $this->tasks->get(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse(['error' => 'No such task'], Http::STATUS_NOT_FOUND); + } + + if ($this->authorization->mayRead(task: $task, uid: $this->uid()) === false) { + // Not 403: a caller with no relationship to the task learns + // nothing, not even that the uuid exists. + return new JSONResponse(['error' => 'No such task'], Http::STATUS_NOT_FOUND); + } + + return new JSONResponse( + $this->inbox->row(task: $task, subjects: [], now: $this->temporal->now()) + ); + }//end show() + + /** + * A task's audit trail, visibility-checked. + * + * @param string $uuid The task uuid. + * + * @return JSONResponse The entries, oldest first. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function audit(string $uuid): JSONResponse { + try { + $task = $this->tasks->get(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse(['error' => 'No such task'], Http::STATUS_NOT_FOUND); + } + + if ($this->authorization->mayRead(task: $task, uid: $this->uid()) === false) { + // Not 403: a caller with no relationship to the task learns + // nothing, not even that the uuid exists. + return new JSONResponse(['error' => 'No such task'], Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(['results' => $this->tasks->auditTrail(uuid: $uuid)]); + }//end audit() + + /** + * Create a task. + * + * @return JSONResponse The created task, or a named refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function create(): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->create(data: $this->body(), actor: $this->uid()), + successStatus: Http::STATUS_CREATED + ); + }//end create() + + /** + * Offer a task to a candidate pool. + * + * @param string $uuid The task uuid. + * + * @return JSONResponse The offered task, or a named refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function offer(string $uuid): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->offer(uuid: $uuid, pool: $this->body(), actor: $this->uid()), + uuid: $uuid + ); + }//end offer() + + /** + * Claim a pooled task. + * + * @param string $uuid The task uuid. + * + * @return JSONResponse The claimed task; 409 for the race's loser. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function claim(string $uuid): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->claim(uuid: $uuid, actor: $this->uid()), + uuid: $uuid + ); + }//end claim() + + /** + * Return a claimed task to its pool. + * + * @param string $uuid The task uuid. + * + * @return JSONResponse The pooled task, or a named refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function unclaim(string $uuid): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->unclaim(uuid: $uuid, actor: $this->uid()), + uuid: $uuid + ); + }//end unclaim() + + /** + * Assign a task directly. + * + * @param string $uuid The task uuid. + * @param string $assignee The performer reference. + * + * @return JSONResponse The assigned task, or a named refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function assign(string $uuid, string $assignee = ''): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->assign(uuid: $uuid, assignee: $assignee, actor: $this->uid()), + uuid: $uuid + ); + }//end assign() + + /** + * Reassign a task. + * + * @param string $uuid The task uuid. + * @param string $assignee The new performer reference. + * + * @return JSONResponse The reassigned task, or a named refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function reassign(string $uuid, string $assignee = ''): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->reassign(uuid: $uuid, assignee: $assignee, actor: $this->uid()), + uuid: $uuid + ); + }//end reassign() + + /** + * Delegate a task, with a mandate. + * + * @param string $uuid The task uuid. + * @param string $delegate The identity taking over. + * @param string $mandate The authority relied on. + * + * @return JSONResponse The delegated task, or a named refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function delegate(string $uuid, string $delegate = '', string $mandate = ''): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->delegate(uuid: $uuid, delegate: $delegate, mandate: $mandate, actor: $this->uid()), + uuid: $uuid + ); + }//end delegate() + + /** + * Resolve a task. + * + * @param string $uuid The task uuid. + * @param string|null $resultText Free-text result. + * @param string|null $comment Completion comment. + * + * @return JSONResponse The resolved task, or a named refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function resolve(string $uuid, ?string $resultText = null, ?string $comment = null): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->resolve(uuid: $uuid, resultText: $resultText, comment: $comment, actor: $this->uid()), + uuid: $uuid + ); + }//end resolve() + + /** + * Complete a task with an explicit outcome. + * + * @param string $uuid The task uuid. + * @param string $outcome The completion outcome. + * @param string|null $resultText Free-text result. + * @param string|null $comment Completion comment — MANDATORY on a + * rejecting or returning outcome. + * + * @return JSONResponse The completed task, or a named refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function complete(string $uuid, string $outcome = 'done', ?string $resultText = null, ?string $comment = null): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->complete( + uuid: $uuid, + outcome: $outcome, + resultText: $resultText, + comment: $comment, + actor: $this->uid() + ), + uuid: $uuid + ); + }//end complete() + + /** + * Cancel a task. + * + * @param string $uuid The task uuid. + * @param string|null $reason Why, recorded on task and audit. + * + * @return JSONResponse The terminated task, or a named refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function cancel(string $uuid, ?string $reason = null): JSONResponse { + return $this->respondWith( + verb: fn (): Task => $this->tasks->cancel(uuid: $uuid, reason: $reason, actor: $this->uid()), + uuid: $uuid + ); + }//end cancel() + + /** + * Check or uncheck one checklist item, addressed by id. + * + * @param string $uuid The task uuid. + * @param string $itemId The checklist item id. + * @param mixed $checked The new checked value, read as a boolean + * ('true'/'false'/1/0 all parse). + * + * @return JSONResponse The task with the one item changed. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-templated-task-freezes-its-template-at-creation + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function checkItem(string $uuid, string $itemId, mixed $checked = 'true'): JSONResponse { + $value = filter_var($checked, FILTER_VALIDATE_BOOLEAN); + + return $this->respondWith( + verb: fn (): Task => $this->tasks->checkChecklistItem(uuid: $uuid, itemId: $itemId, checked: $value, actor: $this->uid()), + uuid: $uuid + ); + }//end checkItem() + + /** + * Run a verb and translate its refusals to HTTP, uniformly. + * + * One translation so no verb can drift: validation 400; denial 403 for + * a caller who may READ the task and 404 for one who may not (so a + * stranger cannot confirm a uuid by probing a verb); conflict 409 (the + * current state in the message, per the spec); absence 404; everything + * else a LOGGED 500 with a generic message, never the exception text, + * which for a database failure carries SQL and bound parameters. + * + * @param callable(): Task $verb The service call. + * @param int $successStatus The status of the happy path. + * @param string|null $uuid The task acted on, for the denial mapping. + * + * @return JSONResponse The task, or the refusal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function respondWith(callable $verb, int $successStatus = Http::STATUS_OK, ?string $uuid = null): JSONResponse { + try { + $task = $verb(); + + return new JSONResponse( + $this->inbox->row(task: $task, subjects: [], now: $this->temporal->now()), + $successStatus + ); + } catch (TaskValidationException $refused) { + return new JSONResponse(['error' => $refused->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (TaskAccessDeniedException $denied) { + if ($uuid !== null && $this->mayReadUuid(uuid: $uuid) === false) { + return new JSONResponse(['error' => 'No such task'], Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(['error' => $denied->getMessage()], Http::STATUS_FORBIDDEN); + } catch (TaskConflictException $conflict) { + return new JSONResponse(['error' => $conflict->getMessage()], Http::STATUS_CONFLICT); + } catch (DoesNotExistException) { + return new JSONResponse(['error' => 'No such task'], Http::STATUS_NOT_FOUND); + } catch (Throwable $failure) { + $this->logger?->error( + '[TaskController] Task operation failed: ' . $failure->getMessage(), + ['uuid' => $uuid, 'exception' => $failure] + ); + + return new JSONResponse(['error' => 'The task operation failed. The details are in the server log.'], Http::STATUS_INTERNAL_SERVER_ERROR); + }//end try + }//end respondWith() + + /** + * Whether the caller may read the task with this uuid, fail-closed. + * + * @param string $uuid The task uuid. + * + * @return boolean False when absent, invisible or undeterminable. + */ + private function mayReadUuid(string $uuid): bool { + try { + return $this->authorization->mayRead(task: $this->tasks->get(uuid: $uuid), uid: $this->uid()); + } catch (Throwable) { + return false; + } + }//end mayReadUuid() + + /** + * The acting identity, or null without a session. + * + * @return string|null The uid. + */ + private function uid(): ?string { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + + return $user->getUID(); + }//end uid() + + /** + * The caller's group ids — empty without a backend, which scopes. + * + * @param string $uid The caller. + * + * @return array The group ids. + */ + private function groupIds(string $uid): array { + $user = $this->userSession->getUser(); + if ($this->groupManager === null || $user === null || $user->getUID() !== $uid) { + return []; + } + + try { + return $this->groupManager->getUserGroupIds($user); + } catch (Throwable) { + return []; + } + }//end groupIds() + + /** + * Whether the caller is an administrator — false without a backend. + * + * @param string $uid The caller. + * + * @return boolean True only when the backend affirms it. + */ + private function isAdmin(string $uid): bool { + if ($this->groupManager === null) { + return false; + } + + try { + return $this->groupManager->isAdmin($uid); + } catch (Throwable) { + return false; + } + }//end isAdmin() + + /** + * The request body, the way the framework already parsed it. + * + * @return array The parameters. + */ + private function body(): array { + $params = $this->request->getParams(); + // Route bookkeeping is not task data. + unset($params['_route'], $params['uuid']); + + return $params; + }//end body() +}//end class diff --git a/lib/Db/FlowRunMapper.php b/lib/Db/FlowRunMapper.php index 46dc61b367..171403544f 100644 --- a/lib/Db/FlowRunMapper.php +++ b/lib/Db/FlowRunMapper.php @@ -23,8 +23,12 @@ namespace OCA\OpenRegister\Db; use DateTime; +use InvalidArgumentException; +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; /** @@ -57,12 +61,59 @@ class FlowRunMapper extends QBMapper { * Constructor. * * @param IDBConnection $db The database connection. + * @param IEventDispatcher|null $dispatcher Publishes run terminality + * ({@see FlowRunTerminalEvent}). + * Nullable so the mapper stays + * constructible without a + * container; absent, terminality + * simply goes unannounced. */ - public function __construct(IDBConnection $db) { + public function __construct( + IDBConnection $db, + private readonly ?IEventDispatcher $dispatcher = null, + ) { parent::__construct(db: $db, tableName: 'openregister_flow_runs', entityClass: FlowRun::class); }//end __construct() + /** + * Update, announcing terminality. + * + * This is the ONE choke point every terminal status write passes — the + * engine's persist, the worker's failure paths and the stale-run reaper + * all land here — so the terminal event is dispatched from it rather + * than from each of those sites, where a new failure path could forget + * it. The event can therefore fire more than once for one run; its + * listeners are idempotent by contract (see the event's docblock). + * + * Dispatched AFTER the row is persisted: a listener that reads the run + * back must see the terminal status it was told about. + * + * @param Entity $entity The run to update. + * + * @return FlowRun The updated run. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ + public function update(Entity $entity): FlowRun { + if ($entity instanceof FlowRun === false) { + throw new InvalidArgumentException('FlowRunMapper persists FlowRun entities only.'); + } + + $updated = parent::update(entity: $entity); + + if ($this->dispatcher !== null && $updated->isTerminal() === true) { + $this->dispatcher->dispatchTyped( + new FlowRunTerminalEvent( + runUuid: (string)$updated->getUuid(), + status: (string)$updated->getStatus() + ) + ); + } + + return $updated; + }//end update() + /** * Find a run by its public uuid. * diff --git a/lib/Db/Task.php b/lib/Db/Task.php new file mode 100644 index 0000000000..dddb02fa5d --- /dev/null +++ b/lib/Db/Task.php @@ -0,0 +1,844 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * Class Task + * + * @method string|null getUuid() + * @method void setUuid(?string $uuid) + * @method string|null getTaskKey() + * @method void setTaskKey(?string $taskKey) + * @method string|null getTitle() + * @method void setTitle(?string $title) + * @method string|null getDescription() + * @method void setDescription(?string $description) + * @method array|null getMetadata() + * @method void setMetadata(?array $metadata) + * @method string|null getRunUuid() + * @method void setRunUuid(?string $runUuid) + * @method string|null getNodeId() + * @method void setNodeId(?string $nodeId) + * @method integer|null getDefinitionVersion() + * @method void setDefinitionVersion(?int $definitionVersion) + * @method string|null getAppId() + * @method void setAppId(?string $appId) + * @method string|null getWorkflowStepId() + * @method void setWorkflowStepId(?string $workflowStepId) + * @method string|null getOrganisation() + * @method void setOrganisation(?string $organisation) + * @method string|null getState() + * @method void setState(?string $state) + * @method bool|null getIsTerminal() + * @method void setIsTerminal(?bool $isTerminal) + * @method string|null getLastAction() + * @method void setLastAction(?string $lastAction) + * @method string|null getOutcome() + * @method void setOutcome(?string $outcome) + * @method string|null getBlockedReason() + * @method void setBlockedReason(?string $blockedReason) + * @method string|null getPerformerType() + * @method void setPerformerType(?string $performerType) + * @method string|null getAssignee() + * @method void setAssignee(?string $assignee) + * @method array|null getCandidateUsers() + * @method void setCandidateUsers(?array $candidateUsers) + * @method array|null getCandidateGroups() + * @method void setCandidateGroups(?array $candidateGroups) + * @method string|null getCandidateRole() + * @method void setCandidateRole(?string $candidateRole) + * @method string|null getRoutingStrategy() + * @method void setRoutingStrategy(?string $routingStrategy) + * @method string|null getRoutingFallback() + * @method void setRoutingFallback(?string $routingFallback) + * @method string|null getOnBehalfOf() + * @method void setOnBehalfOf(?string $onBehalfOf) + * @method string|null getMandate() + * @method void setMandate(?string $mandate) + * @method string|null getRequester() + * @method void setRequester(?string $requester) + * @method array|null getWatchers() + * @method void setWatchers(?array $watchers) + * @method DateTime|null getStartAt() + * @method void setStartAt(?DateTime $startAt) + * @method DateTime|null getDueAt() + * @method void setDueAt(?DateTime $dueAt) + * @method DateTime|null getExpiresAt() + * @method void setExpiresAt(?DateTime $expiresAt) + * @method integer|null getSlaValue() + * @method void setSlaValue(?int $slaValue) + * @method string|null getSlaUnit() + * @method void setSlaUnit(?string $slaUnit) + * @method integer|null getCompliancePeriodDays() + * @method void setCompliancePeriodDays(?int $compliancePeriodDays) + * @method DateTime|null getSuspendedUntil() + * @method void setSuspendedUntil(?DateTime $suspendedUntil) + * @method string|null getRecurrence() + * @method void setRecurrence(?string $recurrence) + * @method string|null getPriority() + * @method void setPriority(?string $priority) + * @method string|null getObjectUuid() + * @method void setObjectUuid(?string $objectUuid) + * @method integer|null getRegisterId() + * @method void setRegisterId(?int $registerId) + * @method integer|null getSchemaId() + * @method void setSchemaId(?int $schemaId) + * @method string|null getTemplateId() + * @method void setTemplateId(?string $templateId) + * @method integer|null getTemplateVersion() + * @method void setTemplateVersion(?int $templateVersion) + * @method array|null getTemplateSnapshot() + * @method void setTemplateSnapshot(?array $templateSnapshot) + * @method array|null getChecklist() + * @method void setChecklist(?array $checklist) + * @method array|null getResponses() + * @method void setResponses(?array $responses) + * @method integer|null getPercentComplete() + * @method void setPercentComplete(?int $percentComplete) + * @method DateTime|null getCompletedAt() + * @method void setCompletedAt(?DateTime $completedAt) + * @method string|null getCompletedBy() + * @method void setCompletedBy(?string $completedBy) + * @method string|null getResultText() + * @method void setResultText(?string $resultText) + * @method string|null getComment() + * @method void setComment(?string $comment) + * @method array|null getEvidence() + * @method void setEvidence(?array $evidence) + * @method string|null getOverrideReason() + * @method void setOverrideReason(?string $overrideReason) + * @method integer|null getParentTaskId() + * @method void setParentTaskId(?int $parentTaskId) + * @method integer|null getEpicTaskId() + * @method void setEpicTaskId(?int $epicTaskId) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * @method DateTime|null getUpdated() + * @method void setUpdated(?DateTime $updated) + * @method string|null getCreatedBy() + * @method void setCreatedBy(?string $createdBy) + * + * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class + * + * @SuppressWarnings(PHPMD.TooManyFields) One property per column of + * `openregister_tasks`. The width is the resolved union of 23 fleet task + * shapes (design.md — Data model); dropping fields here would push them back + * into per-app columns, which is the defect this entity removes. + * @SuppressWarnings(PHPMD.ExcessiveClassLength) The @method block, the typed + * properties and the field-by-field constructor scale linearly with the + * column count, same as {@see FlowRun}. + * @SuppressWarnings(PHPMD.ExcessivePublicCount) Entity getters/setters are + * the column surface, not an API design choice. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ +class Task extends Entity implements JsonSerializable { + + /** + * The six CMMN plan-item states (ADR-098 D4). Nothing else is persistable. + */ + public const STATE_AVAILABLE = 'available'; + + public const STATE_ENABLED = 'enabled'; + + public const STATE_ACTIVE = 'active'; + + public const STATE_COMPLETED = 'completed'; + + public const STATE_TERMINATED = 'terminated'; + + public const STATE_DISABLED = 'disabled'; + + /** + * Every persistable state. + * + * @var array + */ + public const STATES = [ + self::STATE_AVAILABLE, + self::STATE_ENABLED, + self::STATE_ACTIVE, + self::STATE_COMPLETED, + self::STATE_TERMINATED, + self::STATE_DISABLED, + ]; + + /** + * States from which a task never moves again. + * + * @var array + */ + public const TERMINAL_STATES = [ + self::STATE_COMPLETED, + self::STATE_TERMINATED, + self::STATE_DISABLED, + ]; + + /** + * Performer types (ADR-098 D3). + * + * DELIBERATELY NOT A CLOSED SET at the storage level: the column is a + * plain string, and this list is the vocabulary the service validates + * against today. An `external` (portal party) type is being added by a + * parallel ADR-098 amendment; admitting it is an append to this array, + * not a migration. + */ + public const PERFORMER_USER = 'user'; + + public const PERFORMER_GROUP = 'group'; + + public const PERFORMER_AGENT = 'agent'; + + public const PERFORMER_WORKER = 'worker'; + + /** + * The performer types known to this release. + * + * @var array + */ + public const PERFORMER_TYPES = [ + self::PERFORMER_USER, + self::PERFORMER_GROUP, + self::PERFORMER_AGENT, + self::PERFORMER_WORKER, + ]; + + /** + * The one normalised priority scale. + * + * @var array + */ + public const PRIORITIES = ['low', 'normal', 'high', 'urgent']; + + /** + * The routing strategies a candidate pool supports. + * + * @var array + */ + public const ROUTING_STRATEGIES = [ + 'single-role', + 'or-set', + 'hierarchical', + 'round-robin', + 'least-loaded', + ]; + + /** + * Public identifier. + * + * @var string|null + */ + protected ?string $uuid = null; + + /** + * External reference key. Stored as `task_key` (KEY is a reserved word); + * serialised as `key` on the API. + * + * @var string|null + */ + protected ?string $taskKey = null; + + /** + * Human title. Nullable: four fleet shapes carry none, and a display + * title is then SYNTHESIZED on read, never persisted. + * + * @var string|null + */ + protected ?string $title = null; + + /** + * Longer description. + * + * @var string|null + */ + protected ?string $description = null; + + /** + * Carried, never interpreted: no lifecycle, authorization or inbox rule + * may read this (design — Risks). + * + * @var array|null + */ + protected ?array $metadata = null; + + /** + * Provenance: the run whose suspension raised this task. OPTIONAL. + * + * @var string|null + */ + protected ?string $runUuid = null; + + /** + * Provenance: the node within that run. Task identity is (runUuid, + * nodeId), never runUuid alone — a run accumulates one resume slot per + * node ({@see \OCA\OpenRegister\Service\Flow\FlowResumeState}). + * + * @var string|null + */ + protected ?string $nodeId = null; + + /** + * The flow definition version the run was pinned to at task creation. + * Copied from {@see FlowRun::getFlowVersion()}; unwritten until the + * user-task node exists to write it (flow-user-task-node). + * + * @var integer|null + */ + protected ?int $definitionVersion = null; + + /** + * The app that created the task. + * + * @var string|null + */ + protected ?string $appId = null; + + /** + * Legacy workflow step reference, for migrating shapes. + * + * @var string|null + */ + protected ?string $workflowStepId = null; + + /** + * Owning organisation. + * + * @var string|null + */ + protected ?string $organisation = null; + + /** + * One of the six CMMN states. + * + * @var string|null + */ + protected ?string $state = null; + + /** + * Materialised terminality. Written in the SAME statement as `state`, + * only ever by the lifecycle write path. + * + * @var boolean|null + */ + protected ?bool $isTerminal = false; + + /** + * The named transition action that produced the current state. Recorded + * because ADR-031 `transition(action)` notification triggers address the + * ACTION, not the resulting state. + * + * @var string|null + */ + protected ?string $lastAction = null; + + /** + * The outcome distinction that state collapsing preserved + * (`approved` vs `done`, `cancelled` vs `terminated`). + * + * @var string|null + */ + protected ?string $outcome = null; + + /** + * Why the task is blocked, when it is. + * + * @var string|null + */ + protected ?string $blockedReason = null; + + /** + * user|group|agent|worker (extensible — see PERFORMER_TYPES). + * + * @var string|null + */ + protected ?string $performerType = null; + + /** + * The resolved current holder; empty while pooled. + * + * @var string|null + */ + protected ?string $assignee = null; + + /** + * Candidate uids — the readable half of the pool; the index half lives in + * `openregister_task_candidates` and one write path maintains both. + * + * @var array|null + */ + protected ?array $candidateUsers = null; + + /** + * Candidate group ids. + * + * @var array|null + */ + protected ?array $candidateGroups = null; + + /** + * A role name resolved to people at authorization time. + * + * @var string|null + */ + protected ?string $candidateRole = null; + + /** + * single-role|or-set|hierarchical|round-robin|least-loaded. + * + * @var string|null + */ + protected ?string $routingStrategy = null; + + /** + * Performer used when the strategy resolves to nobody. Absent one, the + * task stays POOLED — never implicitly assigned. + * + * @var string|null + */ + protected ?string $routingFallback = null; + + /** + * The original performer a delegate acts for. + * + * @var string|null + */ + protected ?string $onBehalfOf = null; + + /** + * The authority a delegation relies on. + * + * @var string|null + */ + protected ?string $mandate = null; + + /** + * Who asked for this work. Distinct from the performer. + * + * @var string|null + */ + protected ?string $requester = null; + + /** + * Read visibility only; no lifecycle rights whatsoever. + * + * @var array|null + */ + protected ?array $watchers = null; + + /** + * Earliest sensible start. Stored here, interpreted by + * flow-business-timers. + * + * @var DateTime|null + */ + protected ?DateTime $startAt = null; + + /** + * ADVISORY deadline: passing it changes reporting, never state. + * + * @var DateTime|null + */ + protected ?DateTime $dueAt = null; + + /** + * ENFORCING deadline: passing it makes the task eligible for automatic + * termination (the sweep itself belongs to flow-business-timers). + * + * @var DateTime|null + */ + protected ?DateTime $expiresAt = null; + + /** + * SLA magnitude. Stored, not interpreted here. + * + * @var integer|null + */ + protected ?int $slaValue = null; + + /** + * SLA unit. Stored, not interpreted here. + * + * @var string|null + */ + protected ?string $slaUnit = null; + + /** + * Compliance period in days. Stored, not interpreted here. + * + * @var integer|null + */ + protected ?int $compliancePeriodDays = null; + + /** + * Until when the task is suspended. Stored, not interpreted here. + * + * @var DateTime|null + */ + protected ?DateTime $suspendedUntil = null; + + /** + * Recurrence rule. Stored, not interpreted here. + * + * @var string|null + */ + protected ?string $recurrence = null; + + /** + * low|normal|high|urgent — the one normalised scale. + * + * @var string|null + */ + protected ?string $priority = 'normal'; + + /** + * The ONE generic anchor: the subject object's uuid. + * + * @var string|null + */ + protected ?string $objectUuid = null; + + /** + * The subject object's register. + * + * @var integer|null + */ + protected ?int $registerId = null; + + /** + * The subject object's schema. + * + * @var integer|null + */ + protected ?int $schemaId = null; + + /** + * The template this task was created from, when any. + * + * @var string|null + */ + protected ?string $templateId = null; + + /** + * The template version at creation. + * + * @var integer|null + */ + protected ?int $templateVersion = null; + + /** + * The template content FROZEN at creation. All later evaluation reads + * this, never the live template. + * + * @var array|null + */ + protected ?array $templateSnapshot = null; + + /** + * Typed checklist: a list of {id, label, description, checked} entries. + * Never a string containing JSON. + * + * @var array|null + */ + protected ?array $checklist = null; + + /** + * Append-only response log. + * + * @var array|null + */ + protected ?array $responses = null; + + /** + * Progress percentage. + * + * @var integer|null + */ + protected ?int $percentComplete = null; + + /** + * When the task completed. + * + * @var DateTime|null + */ + protected ?DateTime $completedAt = null; + + /** + * Who completed it (the ACTING identity; delegation detail is in the audit). + * + * @var string|null + */ + protected ?string $completedBy = null; + + /** + * Free-text result. + * + * @var string|null + */ + protected ?string $resultText = null; + + /** + * Completion comment. MANDATORY on a rejecting or returning outcome. + * + * @var string|null + */ + protected ?string $comment = null; + + /** + * File references supporting the completion. + * + * @var array|null + */ + protected ?array $evidence = null; + + /** + * Why a supervisor overrode the normal path, when one did. + * + * @var string|null + */ + protected ?string $overrideReason = null; + + /** + * Subtask parent. + * + * @var integer|null + */ + protected ?int $parentTaskId = null; + + /** + * Epic parent — a second hierarchy on purpose: planix has both, and + * overloading one `parent` is what makes its two indistinguishable today. + * + * @var integer|null + */ + protected ?int $epicTaskId = null; + + /** + * Creation timestamp. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Last-modified timestamp. + * + * @var DateTime|null + */ + protected ?DateTime $updated = null; + + /** + * Who created the task. + * + * @var string|null + */ + protected ?string $createdBy = null; + + /** + * Constructor: declare field types so the mapper hydrates them correctly. + */ + public function __construct() { + $this->addType(fieldName: 'uuid', type: 'string'); + $this->addType(fieldName: 'taskKey', type: 'string'); + $this->addType(fieldName: 'title', type: 'string'); + $this->addType(fieldName: 'description', type: 'string'); + $this->addType(fieldName: 'metadata', type: 'json'); + $this->addType(fieldName: 'runUuid', type: 'string'); + $this->addType(fieldName: 'nodeId', type: 'string'); + $this->addType(fieldName: 'definitionVersion', type: 'integer'); + $this->addType(fieldName: 'appId', type: 'string'); + $this->addType(fieldName: 'workflowStepId', type: 'string'); + $this->addType(fieldName: 'organisation', type: 'string'); + $this->addType(fieldName: 'state', type: 'string'); + $this->addType(fieldName: 'isTerminal', type: 'boolean'); + $this->addType(fieldName: 'lastAction', type: 'string'); + $this->addType(fieldName: 'outcome', type: 'string'); + $this->addType(fieldName: 'blockedReason', type: 'string'); + $this->addType(fieldName: 'performerType', type: 'string'); + $this->addType(fieldName: 'assignee', type: 'string'); + $this->addType(fieldName: 'candidateUsers', type: 'json'); + $this->addType(fieldName: 'candidateGroups', type: 'json'); + $this->addType(fieldName: 'candidateRole', type: 'string'); + $this->addType(fieldName: 'routingStrategy', type: 'string'); + $this->addType(fieldName: 'routingFallback', type: 'string'); + $this->addType(fieldName: 'onBehalfOf', type: 'string'); + $this->addType(fieldName: 'mandate', type: 'string'); + $this->addType(fieldName: 'requester', type: 'string'); + $this->addType(fieldName: 'watchers', type: 'json'); + $this->addType(fieldName: 'startAt', type: 'datetime'); + $this->addType(fieldName: 'dueAt', type: 'datetime'); + $this->addType(fieldName: 'expiresAt', type: 'datetime'); + $this->addType(fieldName: 'slaValue', type: 'integer'); + $this->addType(fieldName: 'slaUnit', type: 'string'); + $this->addType(fieldName: 'compliancePeriodDays', type: 'integer'); + $this->addType(fieldName: 'suspendedUntil', type: 'datetime'); + $this->addType(fieldName: 'recurrence', type: 'string'); + $this->addType(fieldName: 'priority', type: 'string'); + $this->addType(fieldName: 'objectUuid', type: 'string'); + $this->addType(fieldName: 'registerId', type: 'integer'); + $this->addType(fieldName: 'schemaId', type: 'integer'); + $this->addType(fieldName: 'templateId', type: 'string'); + $this->addType(fieldName: 'templateVersion', type: 'integer'); + $this->addType(fieldName: 'templateSnapshot', type: 'json'); + $this->addType(fieldName: 'checklist', type: 'json'); + $this->addType(fieldName: 'responses', type: 'json'); + $this->addType(fieldName: 'percentComplete', type: 'integer'); + $this->addType(fieldName: 'completedAt', type: 'datetime'); + $this->addType(fieldName: 'completedBy', type: 'string'); + $this->addType(fieldName: 'resultText', type: 'string'); + $this->addType(fieldName: 'comment', type: 'string'); + $this->addType(fieldName: 'evidence', type: 'json'); + $this->addType(fieldName: 'overrideReason', type: 'string'); + $this->addType(fieldName: 'parentTaskId', type: 'integer'); + $this->addType(fieldName: 'epicTaskId', type: 'integer'); + $this->addType(fieldName: 'created', type: 'datetime'); + $this->addType(fieldName: 'updated', type: 'datetime'); + $this->addType(fieldName: 'createdBy', type: 'string'); + + }//end __construct() + + /** + * Whether this task will never move again. + * + * Reads the state set membership, NOT the materialised column: the + * column exists for indexed queries, and a test asserts the two agree + * across every transition. + * + * @return boolean True when the state is terminal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + public function isInTerminalState(): bool { + return in_array($this->state, self::TERMINAL_STATES, true); + }//end isInTerminalState() + + /** + * Hydrate entity from array. + * + * @param array $object Data to hydrate from. + * + * @return self This task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function hydrate(array $object): self { + foreach ($object as $fieldName => $value) { + if (array_key_exists($fieldName, $this->getFieldTypes()) === false || $fieldName === 'id') { + continue; + } + + $setter = 'set' . ucfirst($fieldName); + $this->$setter($value); + } + + return $this; + }//end hydrate() + + /** + * Serialise for the API. + * + * Carries the STORED row only. The derived temporal projection (overdue, + * daysUntilDue, daysOverdue) is attached by the read surface via + * {@see \OCA\OpenRegister\Service\Task\TaskTemporalProjection} — it is + * deliberately not computed here, so no code path can mistake it for a + * stored field. + * + * @return array The task as plain data. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'key' => $this->taskKey, + 'title' => $this->title, + 'description' => $this->description, + 'metadata' => $this->metadata, + 'runUuid' => $this->runUuid, + 'nodeId' => $this->nodeId, + 'definitionVersion' => $this->definitionVersion, + 'appId' => $this->appId, + 'workflowStepId' => $this->workflowStepId, + 'organisation' => $this->organisation, + 'state' => $this->state, + 'isTerminal' => $this->isTerminal, + 'lastAction' => $this->lastAction, + 'outcome' => $this->outcome, + 'blockedReason' => $this->blockedReason, + 'performerType' => $this->performerType, + 'assignee' => $this->assignee, + 'candidateUsers' => $this->candidateUsers, + 'candidateGroups' => $this->candidateGroups, + 'candidateRole' => $this->candidateRole, + 'routingStrategy' => $this->routingStrategy, + 'routingFallback' => $this->routingFallback, + 'onBehalfOf' => $this->onBehalfOf, + 'mandate' => $this->mandate, + 'requester' => $this->requester, + 'watchers' => $this->watchers, + 'startAt' => $this->startAt?->format('c'), + 'dueAt' => $this->dueAt?->format('c'), + 'expiresAt' => $this->expiresAt?->format('c'), + 'slaValue' => $this->slaValue, + 'slaUnit' => $this->slaUnit, + 'compliancePeriodDays' => $this->compliancePeriodDays, + 'suspendedUntil' => $this->suspendedUntil?->format('c'), + 'recurrence' => $this->recurrence, + 'priority' => $this->priority, + 'objectUuid' => $this->objectUuid, + 'registerId' => $this->registerId, + 'schemaId' => $this->schemaId, + 'templateId' => $this->templateId, + 'templateVersion' => $this->templateVersion, + 'templateSnapshot' => $this->templateSnapshot, + 'checklist' => $this->checklist, + 'responses' => $this->responses, + 'percentComplete' => $this->percentComplete, + 'completedAt' => $this->completedAt?->format('c'), + 'completedBy' => $this->completedBy, + 'resultText' => $this->resultText, + 'comment' => $this->comment, + 'evidence' => $this->evidence, + 'overrideReason' => $this->overrideReason, + 'parentTaskId' => $this->parentTaskId, + 'epicTaskId' => $this->epicTaskId, + 'created' => $this->created?->format('c'), + 'updated' => $this->updated?->format('c'), + 'createdBy' => $this->createdBy, + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/TaskAudit.php b/lib/Db/TaskAudit.php new file mode 100644 index 0000000000..3bb7d2b5d9 --- /dev/null +++ b/lib/Db/TaskAudit.php @@ -0,0 +1,178 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * Class TaskAudit + * + * @method integer|null getTaskId() + * @method void setTaskId(?int $taskId) + * @method string|null getAction() + * @method void setAction(?string $action) + * @method string|null getStateAfter() + * @method void setStateAfter(?string $stateAfter) + * @method string|null getActor() + * @method void setActor(?string $actor) + * @method string|null getPerformerType() + * @method void setPerformerType(?string $performerType) + * @method string|null getOnBehalfOf() + * @method void setOnBehalfOf(?string $onBehalfOf) + * @method string|null getMandate() + * @method void setMandate(?string $mandate) + * @method string|null getReason() + * @method void setReason(?string $reason) + * @method bool|null getAuthorized() + * @method void setAuthorized(?bool $authorized) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * + * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ +class TaskAudit extends Entity implements JsonSerializable { + + /** + * The task this entry belongs to. + * + * @var integer|null + */ + protected ?int $taskId = null; + + /** + * The named transition action (`claim`, `complete`, `reject`, ...). + * + * @var string|null + */ + protected ?string $action = null; + + /** + * The state the task held after the action; null on a denial. + * + * @var string|null + */ + protected ?string $stateAfter = null; + + /** + * The ACTING identity — on a delegated action, the delegate. + * + * @var string|null + */ + protected ?string $actor = null; + + /** + * user|group|agent|worker at the time of acting. + * + * @var string|null + */ + protected ?string $performerType = null; + + /** + * The original performer a delegate acted for. + * + * @var string|null + */ + protected ?string $onBehalfOf = null; + + /** + * The authority the delegation relied on. + * + * @var string|null + */ + protected ?string $mandate = null; + + /** + * The reason or comment supplied with the action, or the denial reason. + * + * @var string|null + */ + protected ?string $reason = null; + + /** + * False when this entry records a DENIED attempt. + * + * @var boolean|null + */ + protected ?bool $authorized = true; + + /** + * When the entry was appended. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Constructor: declare field types so the mapper hydrates them correctly. + */ + public function __construct() { + $this->addType(fieldName: 'taskId', type: 'integer'); + $this->addType(fieldName: 'action', type: 'string'); + $this->addType(fieldName: 'stateAfter', type: 'string'); + $this->addType(fieldName: 'actor', type: 'string'); + $this->addType(fieldName: 'performerType', type: 'string'); + $this->addType(fieldName: 'onBehalfOf', type: 'string'); + $this->addType(fieldName: 'mandate', type: 'string'); + $this->addType(fieldName: 'reason', type: 'string'); + $this->addType(fieldName: 'authorized', type: 'boolean'); + $this->addType(fieldName: 'created', type: 'datetime'); + + }//end __construct() + + /** + * Serialise for the API. + * + * @return array The audit entry as plain data. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'taskId' => $this->taskId, + 'action' => $this->action, + 'stateAfter' => $this->stateAfter, + 'actor' => $this->actor, + 'performerType' => $this->performerType, + 'onBehalfOf' => $this->onBehalfOf, + 'mandate' => $this->mandate, + 'reason' => $this->reason, + 'authorized' => $this->authorized, + 'created' => $this->created?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/TaskAuditMapper.php b/lib/Db/TaskAuditMapper.php new file mode 100644 index 0000000000..24b0399abe --- /dev/null +++ b/lib/Db/TaskAuditMapper.php @@ -0,0 +1,135 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use InvalidArgumentException; +use LogicException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Appends and reads task audit entries. Never updates, never deletes. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ +class TaskAuditMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_task_audit', entityClass: TaskAudit::class); + + }//end __construct() + + /** + * Append an entry, stamping `created`. + * + * @param Entity $entity The entry to append. + * + * @return TaskAudit The appended entry. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + public function insert(Entity $entity): TaskAudit { + if ($entity instanceof TaskAudit === false) { + throw new InvalidArgumentException('TaskAuditMapper appends TaskAudit entries only.'); + } + + if ($entity->getCreated() === null) { + $entity->setCreated(new DateTime()); + } + + return parent::insert(entity: $entity); + }//end insert() + + /** + * Refused: the audit is append-only. + * + * @param Entity $entity Ignored. + * + * @return TaskAudit Never returns. + * + * @throws LogicException Always. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The parameter is the + * inherited signature; refusing it unread is the whole method. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + public function update(Entity $entity): TaskAudit { + throw new LogicException('The task audit is append-only: entries are never updated.'); + }//end update() + + /** + * Refused: the audit is append-only. + * + * @param Entity $entity Ignored. + * + * @return TaskAudit Never returns. + * + * @throws LogicException Always. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The parameter is the + * inherited signature; refusing it unread is the whole method. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + public function delete(Entity $entity): TaskAudit { + throw new LogicException('The task audit is append-only: entries are never deleted.'); + }//end delete() + + /** + * The audit trail of one task, oldest first. + * + * @param int $taskId The task's row id. + * + * @return array The entries. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + public function findForTask(int $taskId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('task_id', $qb->createNamedParameter($taskId, IQueryBuilder::PARAM_INT))) + ->orderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findForTask() +}//end class diff --git a/lib/Db/TaskCandidate.php b/lib/Db/TaskCandidate.php new file mode 100644 index 0000000000..2039147717 --- /dev/null +++ b/lib/Db/TaskCandidate.php @@ -0,0 +1,111 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * Class TaskCandidate + * + * @method integer|null getTaskId() + * @method void setTaskId(?int $taskId) + * @method string|null getKind() + * @method void setKind(?string $kind) + * @method string|null getRef() + * @method void setRef(?string $ref) + * + * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ +class TaskCandidate extends Entity implements JsonSerializable { + + /** + * A candidate uid. + */ + public const KIND_USER = 'user'; + + /** + * A candidate group id. + */ + public const KIND_GROUP = 'group'; + + /** + * A candidate role name. + */ + public const KIND_ROLE = 'role'; + + /** + * The task this candidate row belongs to. + * + * @var integer|null + */ + protected ?int $taskId = null; + + /** + * user|group|role. + * + * @var string|null + */ + protected ?string $kind = null; + + /** + * The uid, group id or role name. + * + * @var string|null + */ + protected ?string $ref = null; + + /** + * Constructor: declare field types so the mapper hydrates them correctly. + */ + public function __construct() { + $this->addType(fieldName: 'taskId', type: 'integer'); + $this->addType(fieldName: 'kind', type: 'string'); + $this->addType(fieldName: 'ref', type: 'string'); + + }//end __construct() + + /** + * Serialise for the API. + * + * @return array The candidate row as plain data. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'taskId' => $this->taskId, + 'kind' => $this->kind, + 'ref' => $this->ref, + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/TaskCandidateMapper.php b/lib/Db/TaskCandidateMapper.php new file mode 100644 index 0000000000..46942512f7 --- /dev/null +++ b/lib/Db/TaskCandidateMapper.php @@ -0,0 +1,102 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Reads and rewrites candidate index rows. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ +class TaskCandidateMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_task_candidates', entityClass: TaskCandidate::class); + + }//end __construct() + + /** + * The candidate rows of one task. + * + * @param int $taskId The task's row id. + * + * @return array The rows. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + public function findForTask(int $taskId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('task_id', $qb->createNamedParameter($taskId, IQueryBuilder::PARAM_INT))); + + return $this->findEntities(query: $qb); + }//end findForTask() + + /** + * Replace one task's candidate rows with a fresh set. + * + * Delete-then-insert on purpose: the rows are an index derived from the + * JSON record, so the correct write is "make it equal", not a diff. The + * CALLER holds the transaction that also writes the JSON — this method + * must never open one of its own, or the two halves could commit apart. + * + * @param int $taskId The task's row id. + * @param array $candidates The new rows. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + public function replaceForTask(int $taskId, array $candidates): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('task_id', $qb->createNamedParameter($taskId, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + + foreach ($candidates as $candidate) { + $row = new TaskCandidate(); + $row->setTaskId($taskId); + $row->setKind($candidate['kind']); + $row->setRef($candidate['ref']); + $this->insert(entity: $row); + } + }//end replaceForTask() +}//end class diff --git a/lib/Db/TaskInboxCriteria.php b/lib/Db/TaskInboxCriteria.php new file mode 100644 index 0000000000..29c5e59206 --- /dev/null +++ b/lib/Db/TaskInboxCriteria.php @@ -0,0 +1,112 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; + +/** + * One inbox read's scope, filters and sort. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) A criteria value object: + * one parameter per filter the spec names, all optional. A builder or an + * array would hide exactly the shape this class exists to make explicit. + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) isAdmin and sortDescending + * are filter VALUES carried into the WHERE/ORDER BY, not behaviour switches. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ +final class TaskInboxCriteria { + + /** + * Tasks assigned to the caller. + */ + public const SCOPE_ASSIGNED = 'assigned'; + + /** + * Unclaimed tasks in the caller's candidate pools. + */ + public const SCOPE_POOLED = 'pooled'; + + /** + * Tasks the caller watches. + */ + public const SCOPE_WATCHED = 'watched'; + + /** + * Everything the caller may see. + */ + public const SCOPE_ALL = 'all'; + + /** + * Sort keys. + */ + public const SORT_DUE = 'dueAt'; + + public const SORT_PRIORITY = 'priority'; + + public const SORT_CREATED = 'created'; + + /** + * Constructor. + * + * @param string $uid The calling user. + * @param array $groupIds The caller's group ids, resolved by + * the SERVICE (the mapper never asks a + * group backend). + * @param bool $isAdmin Whether the caller is an administrator — the one + * case visibility is not narrowed. + * @param string $scope One of the SCOPE_* values. + * @param array $states Restrict to these states. + * @param bool|null $isTerminal Restrict on terminality, or null for both. + * @param string|null $priority Restrict to one priority. + * @param string|null $objectUuid Restrict to tasks anchored to this object. + * @param DateTime|null $overdueAt When set, only tasks whose `due_at` lies + * strictly before this instant — the + * derived-overdue filter, handed the clock + * by TaskTemporalProjection. + * @param string $sort One of the SORT_* values. + * @param bool $sortDescending Whether to invert the sort. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + public function __construct( + public readonly string $uid, + public readonly array $groupIds = [], + public readonly bool $isAdmin = false, + public readonly string $scope = self::SCOPE_ASSIGNED, + public readonly array $states = [], + public readonly ?bool $isTerminal = null, + public readonly ?string $priority = null, + public readonly ?string $objectUuid = null, + public readonly ?DateTime $overdueAt = null, + public readonly string $sort = self::SORT_DUE, + public readonly bool $sortDescending = false, + ) { + + }//end __construct() +}//end class diff --git a/lib/Db/TaskMapper.php b/lib/Db/TaskMapper.php new file mode 100644 index 0000000000..8283a7da06 --- /dev/null +++ b/lib/Db/TaskMapper.php @@ -0,0 +1,667 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use InvalidArgumentException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Reads and writes tasks. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) A mapper's public methods + * are its query vocabulary, one per distinct question the service and the + * inbox ask of the table (same reasoning as FlowRunMapper); two of them + * (`watchersAsText`, `candidateMembershipSql`) are public so the + * platform-dependent SQL is unit-testable without a database. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ +class TaskMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_tasks', entityClass: Task::class); + + }//end __construct() + + /** + * Insert, stamping `created`. + * + * @param Entity $entity The task to insert. + * + * @return Task The inserted task, with its id. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function insert(Entity $entity): Task { + if ($entity instanceof Task === false) { + throw new InvalidArgumentException('TaskMapper persists Task entities only.'); + } + + if ($entity->getCreated() === null) { + $entity->setCreated(new DateTime()); + } + + return parent::insert(entity: $entity); + }//end insert() + + /** + * Update, stamping `updated`. + * + * @param Entity $entity The task to update. + * + * @return Task The updated task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function update(Entity $entity): Task { + if ($entity instanceof Task === false) { + throw new InvalidArgumentException('TaskMapper persists Task entities only.'); + } + + $entity->setUpdated(new DateTime()); + + return parent::update(entity: $entity); + }//end update() + + /** + * Find a task by its public uuid. + * + * @param string $uuid The task uuid. + * + * @return Task The task. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException When no such task exists. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function findByUuid(string $uuid): Task { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))); + + return $this->findEntity(query: $qb); + }//end findByUuid() + + /** + * Update a task ONLY while it is still open: the conditional write every + * state-changing verb goes through. + * + * Two completions, or a completion and a cancellation, can both pass the + * in-memory terminality check and then race to the row. This statement + * carries `AND is_terminal = false`, so the database lets exactly one + * through and the other affects no row; the SERVICE turns that into a + * conflict rather than letting the second outcome overwrite the first. + * + * Mirrors QBMapper::update() field by field (updated fields only) with + * the extra predicate; a row id is required, as there. + * + * @param Task $task The task, with its setters already applied. + * + * @return boolean True when the open row was updated; false when the + * task had already been closed by someone else. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function updateIfOpen(Task $task): bool { + $id = $task->getId(); + if ($id === null) { + throw new InvalidArgumentException('A task must be persisted before it can be updated.'); + } + + $task->setUpdated(new DateTime()); + $properties = $task->getUpdatedFields(); + unset($properties['id']); + + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()); + foreach (array_keys($properties) as $property) { + $getter = 'get' . ucfirst($property); + $qb->set( + $task->propertyToColumn(property: $property), + $qb->createNamedParameter($task->$getter(), $this->getParameterTypeForProperty(entity: $task, property: $property)) + ); + } + + $qb->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))); + + return $qb->executeStatement() === 1; + }//end updateIfOpen() + + /** + * The `watchers` JSON column, readable as text on every platform. + * + * `Types::JSON` creates a `json` column on PostgreSQL, and `json LIKE + * text` is not an operator there (`operator does not exist: json ~~ + * unknown`): without this cast every non-admin inbox request 500s on + * PostgreSQL. MySQL/MariaDB and SQLite cast with `AS CHAR`, PostgreSQL + * with `AS TEXT`, so the choice is by platform, the way MagicMapper does + * it for its metadata columns. + * + * @return string The platform-correct cast expression. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + public function watchersAsText(): string { + $column = $this->quote(identifier: 'watchers'); + if ($this->isPostgres() === true) { + return sprintf('CAST(%s AS TEXT)', $column); + } + + return sprintf('CAST(%s AS CHAR)', $column); + }//end watchersAsText() + + /** + * The correlated EXISTS over the candidate index, as SQL text. + * + * Public and parameter-agnostic so the shape is unit-testable: the + * caller supplies the placeholders it created on its own builder. Every + * kind the index holds is matched — a uid against `user`, the caller's + * groups against `group` AND against `role` (a role names the group of + * the same name, exactly as TaskAuthorizationService resolves it), so a + * role-only pool is visible to the people who may claim from it. + * + * @param string $uidPlaceholder The named parameter holding the caller's uid. + * @param string|null $groupsPlaceholder The named parameter holding the + * caller's group ids, or null when + * the caller has none. + * + * @return string The EXISTS predicate. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + public function candidateMembershipSql(string $uidPlaceholder, ?string $groupsPlaceholder): string { + $kind = $this->quote(identifier: 'tc.kind'); + $ref = $this->quote(identifier: 'tc.ref'); + $membership = [ + sprintf("(%s = 'user' AND %s = %s)", $kind, $ref, $uidPlaceholder), + ]; + if ($groupsPlaceholder !== null) { + $membership[] = sprintf("(%s = 'group' AND %s IN (%s))", $kind, $ref, $groupsPlaceholder); + $membership[] = sprintf("(%s = 'role' AND %s IN (%s))", $kind, $ref, $groupsPlaceholder); + } + + return sprintf( + 'EXISTS (SELECT 1 FROM %s %s WHERE %s = %s AND (%s))', + $this->quote(identifier: '*PREFIX*openregister_task_candidates'), + $this->quote(identifier: 'tc'), + $this->quote(identifier: 'tc.task_id'), + $this->quote(identifier: '*PREFIX*' . $this->getTableName() . '.id'), + implode(' OR ', $membership) + ); + }//end candidateMembershipSql() + + /** + * Whether the connection speaks PostgreSQL. + * + * @return boolean True on PostgreSQL. + */ + private function isPostgres(): bool { + return stripos($this->db->getDatabasePlatform()::class, 'PostgreSQL') !== false; + }//end isPostgres() + + /** + * Quote a (possibly dotted) identifier the way THIS platform wants it. + * + * Raw SQL handed to createFunction() bypasses the query builder's + * quoting, and a backtick is a syntax error on PostgreSQL, so every + * identifier in raw SQL goes through the platform's own quoter. + * + * @param string $identifier `column`, `alias.column` or `*PREFIX*table`. + * + * @return string The quoted identifier. + */ + private function quote(string $identifier): string { + $platform = $this->db->getDatabasePlatform(); + $parts = []; + foreach (explode('.', $identifier) as $part) { + $parts[] = $platform->quoteIdentifier($part); + } + + return implode('.', $parts); + }//end quote() + + /** + * Atomically claim a task: assign IF still unassigned and still open. + * + * The whole race lives in this one statement. Both concurrent claimers + * run it; the database serialises them; exactly one affects a row. The + * loser gets `false`, and the SERVICE turns that into a conflict — never + * into a retry that overwrites. + * + * @param int $taskId The task's row id. + * @param string $uid The claiming user. + * + * @return boolean True when this caller won the claim. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function claim(int $taskId, string $uid): bool { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('assignee', $qb->createNamedParameter($uid)) + ->set('state', $qb->createNamedParameter(Task::STATE_ACTIVE)) + ->set('last_action', $qb->createNamedParameter('claim')) + ->set('updated', $qb->createNamedParameter(new DateTime(), IQueryBuilder::PARAM_DATETIME_MUTABLE)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($taskId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('assignee'), + $qb->expr()->eq('assignee', $qb->createNamedParameter('')) + ) + ); + + return $qb->executeStatement() === 1; + }//end claim() + + /** + * Every non-terminal task raised by one run. + * + * The propagation read: a run reached a terminal status and its open + * tasks must be terminated. Tasks with `run_uuid` null are structurally + * unreachable from here — the predicate is an equality on a non-null + * uuid — which is half of the "a standalone task survives everything" + * guarantee. + * + * @param string $runUuid The run uuid. + * + * @return array The open tasks carrying that run uuid. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ + public function findOpenByRunUuid(string $runUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))); + + return $this->findEntities(query: $qb); + }//end findOpenByRunUuid() + + /** + * Open-task counts per assignee, for the `least-loaded` strategy. + * + * @param array $uids The candidate uids. + * + * @return array Open-task count per uid; a uid with no open + * tasks is absent. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + public function countOpenAssigned(array $uids): array { + if ($uids === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('assignee') + ->selectAlias($qb->func()->count('id'), 'open_count') + ->from($this->getTableName()) + ->where($qb->expr()->in('assignee', $qb->createNamedParameter($uids, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->groupBy('assignee'); + + $result = $qb->executeQuery(); + $counts = []; + while (($row = $result->fetch()) !== false) { + $counts[(string)$row['assignee']] = (int)$row['open_count']; + } + + $result->closeCursor(); + + return $counts; + }//end countOpenAssigned() + + /** + * When each candidate was last handed a task, for `round-robin`. + * + * @param array $uids The candidate uids. + * + * @return array Latest task `created` per uid (SQL + * datetime string); a uid never assigned is + * absent, which round-robin reads as "next". + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + public function latestAssignedAt(array $uids): array { + if ($uids === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('assignee') + ->selectAlias($qb->func()->max('created'), 'latest_created') + ->from($this->getTableName()) + ->where($qb->expr()->in('assignee', $qb->createNamedParameter($uids, IQueryBuilder::PARAM_STR_ARRAY))) + ->groupBy('assignee'); + + $result = $qb->executeQuery(); + $latest = []; + while (($row = $result->fetch()) !== false) { + $latest[(string)$row['assignee']] = (string)$row['latest_created']; + } + + $result->closeCursor(); + + return $latest; + }//end latestAssignedAt() + + /** + * One page of the inbox, filtered, sorted and paginated IN the datastore. + * + * @param TaskInboxCriteria $criteria What to list, for whom. + * @param int $limit Page size. + * @param int $offset Page offset. + * + * @return array The page. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + public function findInbox(TaskInboxCriteria $criteria, int $limit = 25, int $offset = 0): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*')->from($this->getTableName()); + $this->applyInboxPredicates(qb: $qb, criteria: $criteria); + $this->applyInboxOrder(qb: $qb, criteria: $criteria); + $qb->setMaxResults($limit)->setFirstResult($offset); + + return $this->findEntities(query: $qb); + }//end findInbox() + + /** + * The inbox total, over the SAME predicates as the page. + * + * This is what a badge count reads, so it must be one indexed query — + * and it must agree with `findInbox` by construction, which is why both + * call the one predicate builder. + * + * @param TaskInboxCriteria $criteria What to count, for whom. + * + * @return int The total. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + public function countInbox(TaskInboxCriteria $criteria): int { + $qb = $this->db->getQueryBuilder(); + $qb->selectAlias($qb->func()->count('id'), 'total')->from($this->getTableName()); + $this->applyInboxPredicates(qb: $qb, criteria: $criteria); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row === false) { + return 0; + } + + return (int)$row['total']; + }//end countInbox() + + /** + * The shared WHERE clause: scope, filters AND visibility, in the datastore. + * + * Visibility is part of the predicate — assignee, candidate-pool member, + * requester, watcher, or administrator — never a post-filter over a wider + * result, because a filtered-down page silently drops rows and a + * filtered-down total leaks what it excluded. + * + * @param IQueryBuilder $qb The query under construction. + * @param TaskInboxCriteria $criteria What to select, for whom. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + private function applyInboxPredicates(IQueryBuilder $qb, TaskInboxCriteria $criteria): void { + $this->applyScope(qb: $qb, criteria: $criteria); + $this->applyVisibility(qb: $qb, criteria: $criteria); + $this->applyFilters(qb: $qb, criteria: $criteria); + }//end applyInboxPredicates() + + /** + * The scope half of the predicate: which relationship the list is about. + * + * @param IQueryBuilder $qb The query under construction. + * @param TaskInboxCriteria $criteria Carries the scope and identity. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + private function applyScope(IQueryBuilder $qb, TaskInboxCriteria $criteria): void { + switch ($criteria->scope) { + case TaskInboxCriteria::SCOPE_ASSIGNED: + $qb->andWhere($qb->expr()->eq('assignee', $qb->createNamedParameter($criteria->uid))); + break; + case TaskInboxCriteria::SCOPE_POOLED: + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('assignee'), + $qb->expr()->eq('assignee', $qb->createNamedParameter('')) + ) + ); + $qb->andWhere($this->candidateMembershipPredicate(qb: $qb, criteria: $criteria)); + break; + case TaskInboxCriteria::SCOPE_WATCHED: + $qb->andWhere($this->watcherPredicate(qb: $qb, uid: $criteria->uid)); + break; + default: + // SCOPE_ALL: everything the caller may see; visibility decides. + break; + }//end switch + }//end applyScope() + + /** + * The visibility half: an administrator sees everything; anyone else + * sees a task only through one of the five sanctioned relationships. + * + * @param IQueryBuilder $qb The query under construction. + * @param TaskInboxCriteria $criteria Carries the identity facts. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + private function applyVisibility(IQueryBuilder $qb, TaskInboxCriteria $criteria): void { + if ($criteria->isAdmin === false) { + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->eq('assignee', $qb->createNamedParameter($criteria->uid)), + $qb->expr()->eq('requester', $qb->createNamedParameter($criteria->uid)), + $this->watcherPredicate(qb: $qb, uid: $criteria->uid), + $this->candidateMembershipPredicate(qb: $qb, criteria: $criteria) + ) + ); + } + + }//end applyVisibility() + + /** + * The filter half — every filter in the WHERE clause, none in PHP. + * + * @param IQueryBuilder $qb The query under construction. + * @param TaskInboxCriteria $criteria Carries the filters. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + private function applyFilters(IQueryBuilder $qb, TaskInboxCriteria $criteria): void { + if ($criteria->states !== []) { + $qb->andWhere($qb->expr()->in('state', $qb->createNamedParameter($criteria->states, IQueryBuilder::PARAM_STR_ARRAY))); + } + + if ($criteria->isTerminal !== null) { + $qb->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter($criteria->isTerminal, IQueryBuilder::PARAM_BOOL))); + } + + if ($criteria->priority !== null) { + $qb->andWhere($qb->expr()->eq('priority', $qb->createNamedParameter($criteria->priority))); + } + + if ($criteria->objectUuid !== null) { + $qb->andWhere($qb->expr()->eq('object_uuid', $qb->createNamedParameter($criteria->objectUuid))); + } + + // Derived overdue as a filter: the SAME comparison + // TaskTemporalProjection makes — effective deadline + // (due_at, else expires_at) strictly before now — expressed as a + // predicate, with the clock instant handed in from that one class. + // COALESCE(NULL, NULL) < x is NULL, so deadline-less tasks fall out + // without a separate null check. + if ($criteria->overdueAt !== null) { + $qb->andWhere( + $qb->createFunction( + sprintf( + 'COALESCE(%s, %s) < %s', + $this->quote(identifier: 'due_at'), + $this->quote(identifier: 'expires_at'), + $qb->createNamedParameter($criteria->overdueAt, IQueryBuilder::PARAM_DATETIME_MUTABLE) + ) + ) + ); + } + }//end applyFilters() + + /** + * The caller is in the task's candidate pool (by uid or by group). + * + * An EXISTS over the candidate INDEX table — the reason that table + * exists — so pooled visibility is an index hit, not a JSON scan. + * + * @param IQueryBuilder $qb The query under construction. + * @param TaskInboxCriteria $criteria Carries the uid and group ids. + * + * @return \OCP\DB\QueryBuilder\IQueryFunction The EXISTS predicate. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + private function candidateMembershipPredicate(IQueryBuilder $qb, TaskInboxCriteria $criteria): \OCP\DB\QueryBuilder\IQueryFunction { + // Parameters are created on the OUTER builder so the correlated + // subquery shares its parameter bag; the SQL shape itself lives in + // candidateMembershipSql() where a test can read it. + $groups = null; + if ($criteria->groupIds !== []) { + $groups = (string)$qb->createNamedParameter($criteria->groupIds, IQueryBuilder::PARAM_STR_ARRAY); + } + + return $qb->createFunction( + $this->candidateMembershipSql( + uidPlaceholder: (string)$qb->createNamedParameter($criteria->uid), + groupsPlaceholder: $groups + ) + ); + }//end candidateMembershipPredicate() + + /** + * The caller is on the task's watcher list. + * + * A LIKE over the JSON column cast to text (see watchersAsText()): + * watchers confer READ visibility only and + * are not the inbox's hot path, so the readable record is authoritative + * here rather than an index table. + * + * @param IQueryBuilder $qb The query under construction. + * @param string $uid The caller. + * + * @return string The LIKE predicate. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + private function watcherPredicate(IQueryBuilder $qb, string $uid): string { + $needle = '%"' . $this->db->escapeLikeParameter($uid) . '"%'; + + return (string)$qb->createFunction( + sprintf('%s LIKE %s', $this->watchersAsText(), $qb->createNamedParameter($needle)) + ); + }//end watcherPredicate() + + /** + * Sorting, in the datastore. + * + * Priority is a vocabulary, not an alphabet, so it sorts through a CASE + * expression rather than lexically (which would put `urgent` last). + * + * @param IQueryBuilder $qb The query under construction. + * @param TaskInboxCriteria $criteria Carries sort field and direction. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + private function applyInboxOrder(IQueryBuilder $qb, TaskInboxCriteria $criteria): void { + $direction = 'ASC'; + if ($criteria->sortDescending === true) { + $direction = 'DESC'; + } + + switch ($criteria->sort) { + case TaskInboxCriteria::SORT_PRIORITY: + $qb->orderBy( + $qb->createFunction( + sprintf( + "CASE %s WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END", + $this->quote(identifier: 'priority') + ) + ), + $direction + ); + break; + case TaskInboxCriteria::SORT_CREATED: + $qb->orderBy('created', $direction); + break; + default: + $qb->orderBy('due_at', $direction); + break; + } + + // A stable tiebreak so pagination never shows a row twice. + $qb->addOrderBy('id', 'ASC'); + }//end applyInboxOrder() +}//end class diff --git a/lib/Db/TaskRelation.php b/lib/Db/TaskRelation.php new file mode 100644 index 0000000000..0b198f742a --- /dev/null +++ b/lib/Db/TaskRelation.php @@ -0,0 +1,121 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-generic-anchor-plus-typed-relations + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * Class TaskRelation + * + * @method integer|null getTaskId() + * @method void setTaskId(?int $taskId) + * @method string|null getRole() + * @method void setRole(?string $role) + * @method string|null getObjectUuid() + * @method void setObjectUuid(?string $objectUuid) + * @method integer|null getRegisterId() + * @method void setRegisterId(?int $registerId) + * @method integer|null getSchemaId() + * @method void setSchemaId(?int $schemaId) + * + * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-generic-anchor-plus-typed-relations + */ +class TaskRelation extends Entity implements JsonSerializable { + + /** + * The task this relation belongs to. + * + * @var integer|null + */ + protected ?int $taskId = null; + + /** + * The relation's role, chosen by the consuming schema (`case`, + * `decision`, `contract`, `evidence`, ...). Free text on purpose: a + * closed list here would be the twenty-column table wearing a disguise. + * + * @var string|null + */ + protected ?string $role = null; + + /** + * The related object's uuid. + * + * @var string|null + */ + protected ?string $objectUuid = null; + + /** + * The related object's register. + * + * @var integer|null + */ + protected ?int $registerId = null; + + /** + * The related object's schema. + * + * @var integer|null + */ + protected ?int $schemaId = null; + + /** + * Constructor: declare field types so the mapper hydrates them correctly. + */ + public function __construct() { + $this->addType(fieldName: 'taskId', type: 'integer'); + $this->addType(fieldName: 'role', type: 'string'); + $this->addType(fieldName: 'objectUuid', type: 'string'); + $this->addType(fieldName: 'registerId', type: 'integer'); + $this->addType(fieldName: 'schemaId', type: 'integer'); + + }//end __construct() + + /** + * Serialise for the API. + * + * @return array The relation as plain data. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-generic-anchor-plus-typed-relations + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'taskId' => $this->taskId, + 'role' => $this->role, + 'objectUuid' => $this->objectUuid, + 'registerId' => $this->registerId, + 'schemaId' => $this->schemaId, + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/TaskRelationMapper.php b/lib/Db/TaskRelationMapper.php new file mode 100644 index 0000000000..30afc713fe --- /dev/null +++ b/lib/Db/TaskRelationMapper.php @@ -0,0 +1,94 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-generic-anchor-plus-typed-relations + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Reads and writes task relations. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-generic-anchor-plus-typed-relations + */ +class TaskRelationMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_task_relations', entityClass: TaskRelation::class); + + }//end __construct() + + /** + * The relations of one task. + * + * @param int $taskId The task's row id. + * + * @return array The relations. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-generic-anchor-plus-typed-relations + */ + public function findForTask(int $taskId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('task_id', $qb->createNamedParameter($taskId, IQueryBuilder::PARAM_INT))); + + return $this->findEntities(query: $qb); + }//end findForTask() + + /** + * Every relation pointing at one object, optionally narrowed by role. + * + * "Tasks related to this contract" — indexed on (object_uuid, role) and + * deliberately not the inbox's hot path, which uses the task's own anchor + * columns (design D-6). + * + * @param string $objectUuid The related object's uuid. + * @param string|null $role Narrow to one relation role. + * + * @return array The relations. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-generic-anchor-plus-typed-relations + */ + public function findByObject(string $objectUuid, ?string $role = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid))); + + if ($role !== null) { + $qb->andWhere($qb->expr()->eq('role', $qb->createNamedParameter($role))); + } + + return $this->findEntities(query: $qb); + }//end findByObject() +}//end class diff --git a/lib/Event/FlowRunTerminalEvent.php b/lib/Event/FlowRunTerminalEvent.php new file mode 100644 index 0000000000..d0a7fe1672 --- /dev/null +++ b/lib/Event/FlowRunTerminalEvent.php @@ -0,0 +1,77 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Event + * @package OCA\OpenRegister\Event + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCP\EventDispatcher\Event; + +/** + * Carries the terminal run's identity and status. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ +class FlowRunTerminalEvent extends Event { + + /** + * Constructor. + * + * @param string $runUuid The run's public uuid. + * @param string $status The terminal status it was persisted with. + */ + public function __construct( + private readonly string $runUuid, + private readonly string $status, + ) { + parent::__construct(); + + }//end __construct() + + /** + * The run's public uuid. + * + * @return string The uuid. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ + public function getRunUuid(): string { + return $this->runUuid; + }//end getRunUuid() + + /** + * The terminal status. + * + * @return string One of FlowRun::TERMINAL. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ + public function getStatus(): string { + return $this->status; + }//end getStatus() +}//end class diff --git a/lib/Exception/TaskAccessDeniedException.php b/lib/Exception/TaskAccessDeniedException.php new file mode 100644 index 0000000000..0721d1d292 --- /dev/null +++ b/lib/Exception/TaskAccessDeniedException.php @@ -0,0 +1,40 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * A denied (or undeterminable) task authorization decision. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ +class TaskAccessDeniedException extends RuntimeException { +}//end class diff --git a/lib/Exception/TaskConflictException.php b/lib/Exception/TaskConflictException.php new file mode 100644 index 0000000000..91a1a0d097 --- /dev/null +++ b/lib/Exception/TaskConflictException.php @@ -0,0 +1,39 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * A task state conflict: a lost claim race or a verb against a terminal task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ +class TaskConflictException extends RuntimeException { +}//end class diff --git a/lib/Exception/TaskValidationException.php b/lib/Exception/TaskValidationException.php new file mode 100644 index 0000000000..b39b4004b8 --- /dev/null +++ b/lib/Exception/TaskValidationException.php @@ -0,0 +1,40 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use InvalidArgumentException; + +/** + * A refused task value, named in the message. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ +class TaskValidationException extends InvalidArgumentException { +}//end class diff --git a/lib/Listener/TaskRunTerminalListener.php b/lib/Listener/TaskRunTerminalListener.php new file mode 100644 index 0000000000..68a7aaa616 --- /dev/null +++ b/lib/Listener/TaskRunTerminalListener.php @@ -0,0 +1,104 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Terminates a terminal run's open tasks. + * + * @template-implements IEventListener + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ +class TaskRunTerminalListener implements IEventListener { + + /** + * Constructor. + * + * @param TaskService $tasks The task lifecycle. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly TaskService $tasks, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle the event. + * + * A propagation failure is logged, not rethrown: the run's own terminal + * write must never be unwound by task bookkeeping, and the propagation + * re-fires on the next observation of terminality. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ + public function handle(Event $event): void { + if ($event instanceof FlowRunTerminalEvent === false) { + return; + } + + try { + $terminated = $this->tasks->terminateForRun( + runUuid: $event->getRunUuid(), + runStatus: $event->getStatus() + ); + if ($terminated > 0) { + $this->logger->info( + sprintf( + '[TaskRunTerminalListener] Terminated %d task(s) of run %s (%s).', + $terminated, + $event->getRunUuid(), + $event->getStatus() + ) + ); + } + } catch (Throwable $failure) { + $this->logger->error( + '[TaskRunTerminalListener] Cancellation propagation failed: ' . $failure->getMessage(), + ['run' => $event->getRunUuid(), 'exception' => $failure] + ); + } + }//end handle() +}//end class diff --git a/lib/Migration/Version1Date20260831120000.php b/lib/Migration/Version1Date20260831120000.php new file mode 100644 index 0000000000..25cbd48a72 --- /dev/null +++ b/lib/Migration/Version1Date20260831120000.php @@ -0,0 +1,328 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The fleet-generic task store: four tables, additive only. + * + * `openregister_tasks` is the ONE task the fleet gets instead of the 23 + * conflicting shapes inventoried on 2026-08-22. Three deliberate absences are + * part of the schema, not omissions: + * + * - NO `overdue`, `days_until_due` or `days_overdue` column. Overdue is a + * clock-derived fact; three fleet schemas store it by hand today and it is + * wrong between writes. It is computed on read, always. + * - NO app-named column. The nineteenth consuming entity kind is absorbed by + * `openregister_task_relations` with an INSERT, never a migration. + * - NO closed database-level enum on `performer_type` or `state`: both are + * plain strings validated at the service boundary, so an additional + * performer type (an external portal party is already being added by an + * ADR-098 amendment) is a vocabulary change, not a migration. + * + * `openregister_task_candidates` is the index half of the candidate pool: the + * `candidate_users`/`candidate_groups` JSON on the task is the readable + * record, and these rows are what the pooled inbox joins against. One write + * path maintains both inside one transaction (TaskService). + * + * `openregister_task_audit` is append-only. No update or delete path exists in + * its mapper, and deleting a task does not cascade into it. + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Creates the task, candidate-index, relation and audit tables. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ +class Version1Date20260831120000 extends SimpleMigrationStep { + + /** + * The task table. + */ + private const TABLE_TASKS = 'openregister_tasks'; + + /** + * The candidate-pool index table. + */ + private const TABLE_CANDIDATES = 'openregister_task_candidates'; + + /** + * The typed relation table. + */ + private const TABLE_RELATIONS = 'openregister_task_relations'; + + /** + * The append-only audit table. + */ + private const TABLE_AUDIT = 'openregister_task_audit'; + + /** + * Create the four tables and their indexes. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the ISchemaWrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + $schema = $schemaClosure(); + + $changed = false; + + if ($schema->hasTable(self::TABLE_TASKS) === false) { + $this->createTasksTable(schema: $schema); + $changed = true; + } + + if ($schema->hasTable(self::TABLE_CANDIDATES) === false) { + $this->createCandidatesTable(schema: $schema); + $changed = true; + } + + if ($schema->hasTable(self::TABLE_RELATIONS) === false) { + $this->createRelationsTable(schema: $schema); + $changed = true; + } + + if ($schema->hasTable(self::TABLE_AUDIT) === false) { + $this->createAuditTable(schema: $schema); + $changed = true; + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() + + /** + * The task table itself. + * + * Grouped by concern as in design.md — Data model. Everything nullable + * unless the design says otherwise. + * + * @param ISchemaWrapper $schema The schema to add the table to. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + private function createTasksTable(ISchemaWrapper $schema): void { + $table = $schema->createTable(self::TABLE_TASKS); + + // Identity. + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + // `task_key`, not `key`: KEY is a reserved word on MySQL/MariaDB and + // this table must create identically on every supported database. + $table->addColumn('task_key', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('title', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('description', Types::TEXT, ['notnull' => false]); + $table->addColumn('metadata', Types::JSON, ['notnull' => false]); + + // Provenance. run_uuid and node_id are OPTIONAL: a standalone task is + // first-class (design D-3). definition_version copies the run's pinned + // flow version at creation; it ships now (flow definition versioning + // landed with openregister#3047) and is written once the user-task + // node exists to write it. + $table->addColumn('run_uuid', Types::STRING, ['notnull' => false, 'length' => 36]); + $table->addColumn('node_id', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('definition_version', Types::INTEGER, ['notnull' => false]); + $table->addColumn('app_id', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('workflow_step_id', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('organisation', Types::STRING, ['notnull' => false, 'length' => 64]); + + // Lifecycle. `state` holds one of the six CMMN plan-item states; + // `is_terminal` is materialised alongside it in the same statement. + $table->addColumn('state', Types::STRING, ['notnull' => true, 'length' => 20]); + $table->addColumn('is_terminal', Types::BOOLEAN, ['notnull' => true, 'default' => false]); + $table->addColumn('last_action', Types::STRING, ['notnull' => false, 'length' => 32]); + $table->addColumn('outcome', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('blocked_reason', Types::STRING, ['notnull' => false, 'length' => 512]); + + // Performer. + $table->addColumn('performer_type', Types::STRING, ['notnull' => true, 'length' => 20]); + $table->addColumn('assignee', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('candidate_users', Types::JSON, ['notnull' => false]); + $table->addColumn('candidate_groups', Types::JSON, ['notnull' => false]); + $table->addColumn('candidate_role', Types::STRING, ['notnull' => false, 'length' => 128]); + $table->addColumn('routing_strategy', Types::STRING, ['notnull' => false, 'length' => 32]); + $table->addColumn('routing_fallback', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('on_behalf_of', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('mandate', Types::STRING, ['notnull' => false, 'length' => 512]); + $table->addColumn('requester', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('watchers', Types::JSON, ['notnull' => false]); + + // Timing. due_at ADVISES, expires_at ENFORCES (design D-4). The SLA, + // compliance, suspension and recurrence columns are stored here and + // interpreted by flow-business-timers. + $table->addColumn('start_at', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('due_at', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('expires_at', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('sla_value', Types::INTEGER, ['notnull' => false]); + $table->addColumn('sla_unit', Types::STRING, ['notnull' => false, 'length' => 16]); + $table->addColumn('compliance_period_days', Types::INTEGER, ['notnull' => false]); + $table->addColumn('suspended_until', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('recurrence', Types::STRING, ['notnull' => false, 'length' => 255]); + + // Priority: one normalised scale. + $table->addColumn('priority', Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'normal']); + + // Anchor: ONE generic subject reference. Everything else goes through + // the relation table (design D-6). + $table->addColumn('object_uuid', Types::STRING, ['notnull' => false, 'length' => 36]); + $table->addColumn('register_id', Types::BIGINT, ['notnull' => false]); + $table->addColumn('schema_id', Types::BIGINT, ['notnull' => false]); + + // Template freeze. + $table->addColumn('template_id', Types::STRING, ['notnull' => false, 'length' => 36]); + $table->addColumn('template_version', Types::INTEGER, ['notnull' => false]); + $table->addColumn('template_snapshot', Types::JSON, ['notnull' => false]); + + // Checklist and responses. + $table->addColumn('checklist', Types::JSON, ['notnull' => false]); + $table->addColumn('responses', Types::JSON, ['notnull' => false]); + $table->addColumn('percent_complete', Types::INTEGER, ['notnull' => false]); + + // Completion metadata. + $table->addColumn('completed_at', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('completed_by', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('result_text', Types::TEXT, ['notnull' => false]); + $table->addColumn('comment', Types::TEXT, ['notnull' => false]); + $table->addColumn('evidence', Types::JSON, ['notnull' => false]); + $table->addColumn('override_reason', Types::STRING, ['notnull' => false, 'length' => 512]); + + // Hierarchy: two columns because planix genuinely has two hierarchies. + $table->addColumn('parent_task_id', Types::BIGINT, ['notnull' => false]); + $table->addColumn('epic_task_id', Types::BIGINT, ['notnull' => false]); + + // Audit stamps. + $table->addColumn('created', Types::DATETIME_MUTABLE, ['notnull' => true]); + $table->addColumn('updated', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('created_by', Types::STRING, ['notnull' => false, 'length' => 64]); + + $this->addTaskIndexes(table: $table); + }//end createTasksTable() + + /** + * The task table's indexes: one per query the inbox and propagation run. + * + * @param \Doctrine\DBAL\Schema\Table $table The task table. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + private function addTaskIndexes(\Doctrine\DBAL\Schema\Table $table): void { + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['uuid'], 'or_tasks_uuid'); + // "My open work": the inbox's hot path. + $table->addIndex(['assignee', 'is_terminal', 'due_at'], 'or_tasks_assignee_open'); + // The overdue sweep. + $table->addIndex(['is_terminal', 'due_at'], 'or_tasks_open_due'); + // "Tasks on this object." + $table->addIndex(['object_uuid'], 'or_tasks_object'); + // Cancellation propagation. + $table->addIndex(['run_uuid'], 'or_tasks_run'); + }//end addTaskIndexes() + + /** + * The candidate-pool index table. + * + * @param ISchemaWrapper $schema The schema to add the table to. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + private function createCandidatesTable(ISchemaWrapper $schema): void { + $table = $schema->createTable(self::TABLE_CANDIDATES); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('task_id', Types::BIGINT, ['notnull' => true]); + // 'user' | 'group' | 'role'. + $table->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('ref', Types::STRING, ['notnull' => true, 'length' => 128]); + $table->setPrimaryKey(['id']); + // The pooled-inbox join: "unclaimed tasks in any group I am in". + $table->addIndex(['kind', 'ref'], 'or_task_cand_kind_ref'); + $table->addIndex(['task_id'], 'or_task_cand_task'); + }//end createCandidatesTable() + + /** + * The typed relation table. + * + * @param ISchemaWrapper $schema The schema to add the table to. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-generic-anchor-plus-typed-relations + */ + private function createRelationsTable(ISchemaWrapper $schema): void { + $table = $schema->createTable(self::TABLE_RELATIONS); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('task_id', Types::BIGINT, ['notnull' => true]); + $table->addColumn('role', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('object_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('register_id', Types::BIGINT, ['notnull' => false]); + $table->addColumn('schema_id', Types::BIGINT, ['notnull' => false]); + $table->setPrimaryKey(['id']); + $table->addIndex(['object_uuid', 'role'], 'or_task_rel_object_role'); + $table->addIndex(['task_id'], 'or_task_rel_task'); + }//end createRelationsTable() + + /** + * The append-only audit table. + * + * @param ISchemaWrapper $schema The schema to add the table to. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + private function createAuditTable(ISchemaWrapper $schema): void { + $table = $schema->createTable(self::TABLE_AUDIT); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('task_id', Types::BIGINT, ['notnull' => true]); + $table->addColumn('action', Types::STRING, ['notnull' => true, 'length' => 32]); + $table->addColumn('state_after', Types::STRING, ['notnull' => false, 'length' => 20]); + $table->addColumn('actor', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('performer_type', Types::STRING, ['notnull' => false, 'length' => 20]); + $table->addColumn('on_behalf_of', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('mandate', Types::STRING, ['notnull' => false, 'length' => 512]); + $table->addColumn('reason', Types::TEXT, ['notnull' => false]); + // Denials are recorded too: authorized=false is an audit row, not an + // absence of one. + $table->addColumn('authorized', Types::BOOLEAN, ['notnull' => true, 'default' => true]); + $table->addColumn('created', Types::DATETIME_MUTABLE, ['notnull' => true]); + $table->setPrimaryKey(['id']); + $table->addIndex(['task_id'], 'or_task_audit_task'); + }//end createAuditTable() +}//end class diff --git a/lib/Repair/SeedTaskFixtures.php b/lib/Repair/SeedTaskFixtures.php new file mode 100644 index 0000000000..a3f960ff1f --- /dev/null +++ b/lib/Repair/SeedTaskFixtures.php @@ -0,0 +1,441 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Repair; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IAppConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Seeds the five task fixture groups, idempotent on uuid. + * + * @psalm-suppress UnusedClass Instantiated by the NC repair framework (appinfo/info.xml). + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ +class SeedTaskFixtures implements IRepairStep { + + /** + * The app config key that switches the demo seed on. + */ + public const FLAG = 'seed_demo_tasks'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig Holds the opt-in flag. + * @param TaskMapper $tasks The task table. + * @param TaskCandidateMapper $candidates The candidate index rows. + * @param TaskAuditMapper $audits The append-only audit. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly IAppConfig $appConfig, + private readonly TaskMapper $tasks, + private readonly TaskCandidateMapper $candidates, + private readonly TaskAuditMapper $audits, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The step's name in the repair log. + * + * @return string The name. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function getName(): string { + return 'Seed the task fixtures (flow-task-entity)'; + }//end getName() + + /** + * Install every fixture that does not exist yet. + * + * @param IOutput $output The repair output. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function run(IOutput $output): void { + if ($this->appConfig->getValueBool('openregister', self::FLAG, false) === false) { + $output->info('Task fixtures: skipped (openregister/' . self::FLAG . ' is not enabled).'); + return; + } + + $seeded = 0; + foreach ($this->fixtures() as $fixture) { + try { + if ($this->seedOne(fixture: $fixture) === true) { + $seeded++; + } + } catch (Throwable $failure) { + // A fixture must never fail an upgrade — but silence would be + // the repair-step defect this fleet already paid for once, so + // the skip is visible in the repair log AND the app log. + $output->warning(sprintf('Task fixture %s failed: %s', (string)($fixture['uuid'] ?? '?'), $failure->getMessage())); + $this->logger->warning( + '[SeedTaskFixtures] Fixture failed: ' . $failure->getMessage(), + ['uuid' => ($fixture['uuid'] ?? null)] + ); + } + } + + $output->info(sprintf('Task fixtures: %d seeded, %d already present.', $seeded, count($this->fixtures()) - $seeded)); + }//end run() + + /** + * Seed one fixture, unless its uuid already exists. + * + * @param array $fixture The fixture: task fields plus + * `candidates` and `audit` sub-arrays. + * + * @return boolean True when inserted, false when already present. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + private function seedOne(array $fixture): bool { + try { + $this->tasks->findByUuid(uuid: (string)$fixture['uuid']); + + return false; + } catch (DoesNotExistException) { + // Absent: seed it. + } + + $task = new Task(); + $task->hydrate($fixture['task']); + $task->setUuid((string)$fixture['uuid']); + $persisted = $this->tasks->insert($task); + + // The one-write-path invariant, preserved by hand: JSON and index + // rows land together. + $rows = []; + foreach (($persisted->getCandidateUsers() ?? []) as $uid) { + $rows[] = [ + 'kind' => 'user', + 'ref' => (string)$uid, + ]; + } + + foreach (($persisted->getCandidateGroups() ?? []) as $groupId) { + $rows[] = [ + 'kind' => 'group', + 'ref' => (string)$groupId, + ]; + } + + if (trim((string)$persisted->getCandidateRole()) !== '') { + $rows[] = [ + 'kind' => 'role', + 'ref' => (string)$persisted->getCandidateRole(), + ]; + } + + $this->candidates->replaceForTask(taskId: (int)$persisted->getId(), candidates: $rows); + + foreach (($fixture['audit'] ?? []) as $auditFixture) { + $entry = new TaskAudit(); + $entry->setTaskId((int)$persisted->getId()); + $entry->setAction((string)$auditFixture['action']); + $entry->setStateAfter($auditFixture['stateAfter'] ?? null); + $entry->setActor($auditFixture['actor'] ?? null); + $entry->setPerformerType($auditFixture['performerType'] ?? null); + $entry->setOnBehalfOf($auditFixture['onBehalfOf'] ?? null); + $entry->setMandate($auditFixture['mandate'] ?? null); + $entry->setReason($auditFixture['reason'] ?? null); + $entry->setAuthorized((bool)($auditFixture['authorized'] ?? true)); + $this->audits->insert($entry); + } + + return true; + }//end seedOne() + + /** + * The five fixture groups from design.md — Seed Data. + * + * @return array> The fixtures. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + private function fixtures(): array { + return [ + $this->municipalPermitCheck(), + $this->consultancyDelegatedApproval(), + $this->travelAgencyAgentTask(), + $this->completedApproval(), + $this->terminatedByPropagation(), + ]; + }//end fixtures() + + /** + * Seed group 1: Municipality: a pooled permit check, NO flow attached. + * + * @return array The fixture. + */ + private function municipalPermitCheck(): array { + return [ + 'uuid' => '00000000-0000-0000-0000-000000000001', + 'task' => [ + 'title' => 'Controleer bouwtekening op welstandseisen', + 'state' => Task::STATE_ENABLED, + 'isTerminal' => false, + 'performerType' => Task::PERFORMER_GROUP, + 'assignee' => null, + 'candidateGroups' => ['GEMEENTE_VERGUNNINGEN_TEAM'], + 'routingStrategy' => 'least-loaded', + 'priority' => 'normal', + 'dueAt' => new DateTime('2026-09-04T17:00:00+02:00'), + 'expiresAt' => null, + 'runUuid' => null, + 'objectUuid' => '00000000-0000-0000-0000-0000000000aa', + 'registerId' => 1, + 'schemaId' => 1, + 'requester' => 'EXAMPLE_BALIE_USER', + 'lastAction' => 'offer', + 'appId' => 'openregister', + ], + 'audit' => [ + [ + 'action' => 'offer', + 'stateAfter' => Task::STATE_ENABLED, + 'actor' => 'EXAMPLE_BALIE_USER', + 'performerType' => Task::PERFORMER_GROUP, + 'authorized' => true, + ], + ], + ]; + }//end municipalPermitCheck() + + /** + * Seed group 2: Consultancy: a delegated approval with enforcing expiry, on a run. + * + * @return array The fixture. + */ + private function consultancyDelegatedApproval(): array { + return [ + 'uuid' => '00000000-0000-0000-0000-000000000002', + 'task' => [ + 'title' => 'Keur inkooporder > EUR 10.000 goed', + 'state' => Task::STATE_ACTIVE, + 'isTerminal' => false, + 'performerType' => Task::PERFORMER_USER, + 'assignee' => 'EXAMPLE_DELEGATE_USER', + 'onBehalfOf' => 'EXAMPLE_DIRECTOR_USER', + 'mandate' => 'Volmacht inkoop 2026, artikel 4 lid 2', + 'priority' => 'high', + 'dueAt' => new DateTime('2026-08-29T12:00:00+02:00'), + 'expiresAt' => new DateTime('2026-09-01T12:00:00+02:00'), + 'runUuid' => '00000000-0000-0000-0000-0000000000f1', + 'nodeId' => 'approve-purchase-order', + 'templateId' => '00000000-0000-0000-0000-0000000000e0', + 'templateVersion' => 3, + 'templateSnapshot' => ['checklist' => []], + 'objectUuid' => '00000000-0000-0000-0000-0000000000bb', + 'requester' => 'EXAMPLE_CONTROLLER_USER', + 'lastAction' => 'delegate', + 'appId' => 'openregister', + ], + 'audit' => [ + [ + 'action' => 'delegate', + 'stateAfter' => Task::STATE_ACTIVE, + 'actor' => 'EXAMPLE_DIRECTOR_USER', + 'performerType' => Task::PERFORMER_USER, + 'onBehalfOf' => 'EXAMPLE_DIRECTOR_USER', + 'mandate' => 'Volmacht inkoop 2026, artikel 4 lid 2', + 'authorized' => true, + ], + ], + ]; + }//end consultancyDelegatedApproval() + + /** + * Seed group 3: Travel agency: an agent task with a checklist. + * + * @return array The fixture. + */ + private function travelAgencyAgentTask(): array { + return [ + 'uuid' => '00000000-0000-0000-0000-000000000003', + 'task' => [ + 'title' => 'Verifieer visumvereisten voor reisgroep', + 'state' => Task::STATE_ACTIVE, + 'isTerminal' => false, + 'performerType' => Task::PERFORMER_AGENT, + 'assignee' => 'EXAMPLE_AGENT_IDENTITY', + 'priority' => 'urgent', + 'checklist' => [ + [ + 'id' => 'c1', + 'label' => 'Paspoortgeldigheid > 6 maanden', + 'description' => null, + 'checked' => true, + ], + [ + 'id' => 'c2', + 'label' => 'Visumplicht per bestemming gecontroleerd', + 'description' => null, + 'checked' => false, + ], + [ + 'id' => 'c3', + 'label' => 'Transitvisum nodig?', + 'description' => null, + 'checked' => false, + ], + ], + 'epicTaskId' => null, + 'objectUuid' => '00000000-0000-0000-0000-0000000000cc', + 'runUuid' => null, + 'requester' => 'EXAMPLE_TRAVEL_PLANNER', + 'lastAction' => 'claim', + 'appId' => 'openregister', + ], + 'audit' => [ + [ + 'action' => 'claim', + 'stateAfter' => Task::STATE_ACTIVE, + 'actor' => 'EXAMPLE_AGENT_IDENTITY', + 'performerType' => Task::PERFORMER_AGENT, + 'authorized' => true, + ], + ], + ]; + }//end travelAgencyAgentTask() + + /** + * Seed group 4a: Terminal: completed with the collapse-preserved outcome. + * + * @return array The fixture. + */ + private function completedApproval(): array { + return [ + 'uuid' => '00000000-0000-0000-0000-000000000004', + 'task' => [ + 'title' => 'Beoordeel offerte serverhal', + 'state' => Task::STATE_COMPLETED, + 'isTerminal' => true, + 'performerType' => Task::PERFORMER_USER, + 'assignee' => 'EXAMPLE_REVIEWER_USER', + 'outcome' => 'approved', + 'priority' => 'normal', + 'completedAt' => new DateTime('2026-08-20T09:30:00+02:00'), + 'completedBy' => 'EXAMPLE_REVIEWER_USER', + 'runUuid' => null, + 'requester' => 'EXAMPLE_CONTROLLER_USER', + 'lastAction' => 'complete', + 'appId' => 'openregister', + ], + 'audit' => [ + [ + 'action' => 'complete', + 'stateAfter' => Task::STATE_COMPLETED, + 'actor' => 'EXAMPLE_REVIEWER_USER', + 'performerType' => Task::PERFORMER_USER, + 'authorized' => true, + ], + // The DENIED entry: a stranger tried first, and the + // append-only denial path has a fixture because of it. + [ + 'action' => 'complete', + 'stateAfter' => Task::STATE_ACTIVE, + 'actor' => 'EXAMPLE_STRANGER_USER', + 'performerType' => Task::PERFORMER_USER, + 'reason' => "Verb 'complete' denied: only the current assignee may perform it.", + 'authorized' => false, + ], + ], + ]; + }//end completedApproval() + + /** + * Seed group 4b: Terminal: terminated by propagation from a stopped run. + * + * @return array The fixture. + */ + private function terminatedByPropagation(): array { + return [ + 'uuid' => '00000000-0000-0000-0000-000000000005', + 'task' => [ + 'title' => 'Vraag aanvullende stukken op', + 'state' => Task::STATE_TERMINATED, + 'isTerminal' => true, + 'performerType' => Task::PERFORMER_USER, + 'assignee' => 'EXAMPLE_CASEWORKER_USER', + 'outcome' => 'terminated', + 'priority' => 'normal', + 'runUuid' => '00000000-0000-0000-0000-0000000000f2', + 'nodeId' => 'request-documents', + 'requester' => 'EXAMPLE_CONTROLLER_USER', + 'lastAction' => 'terminate', + 'appId' => 'openregister', + ], + 'audit' => [ + [ + 'action' => 'terminate', + 'stateAfter' => Task::STATE_TERMINATED, + 'actor' => 'flow-run:00000000-0000-0000-0000-0000000000f2', + 'performerType' => Task::PERFORMER_USER, + 'reason' => "Run '00000000-0000-0000-0000-0000000000f2' reached terminal status 'stopped'.", + 'authorized' => true, + ], + ], + ]; + }//end terminatedByPropagation() +}//end class diff --git a/lib/Service/Task/TaskAuthorizationService.php b/lib/Service/Task/TaskAuthorizationService.php new file mode 100644 index 0000000000..d54f3fa94b --- /dev/null +++ b/lib/Service/Task/TaskAuthorizationService.php @@ -0,0 +1,390 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCP\IGroupManager; +use Throwable; + +/** + * Decides who may run which lifecycle verb on which task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ +class TaskAuthorizationService { + + /** + * Which relationship each verb requires of the caller. + * + * The spec's minimums, as data: claim needs pool membership; unclaim, + * delegate, complete, resolve and checklist need the assignee; assign, + * reassign and cancel need the requester (an administrator passes all). + * + * @var array verb => private rule method. + */ + private const RULES = [ + 'claim' => 'assertPoolMember', + 'unclaim' => 'assertAssignee', + 'delegate' => 'assertAssignee', + 'complete' => 'assertAssignee', + 'resolve' => 'assertAssignee', + 'checklist' => 'assertAssignee', + 'assign' => 'assertRequester', + 'reassign' => 'assertRequester', + 'cancel' => 'assertRequester', + // Offer rewrites the pool AND the routing fallback, which decides who + // ends up assigned: that is the requester's call, nobody else's. + 'offer' => 'assertRequester', + ]; + + /** + * Constructor. + * + * @param IGroupManager|null $groupManager Resolves group membership, role + * groups and administrators. + * Nullable so the service stays + * constructible without a + * container; ABSENT, every + * membership-dependent decision + * DENIES — the fail-closed + * direction, same as + * {@see \OCA\OpenRegister\Service\Flow\FlowRunAssignee}. + */ + public function __construct( + private readonly ?IGroupManager $groupManager = null, + ) { + + }//end __construct() + + /** + * Assert that a caller may run a verb on a task; throw when not. + * + * The service deliberately has no boolean twin of this method: a caller + * that could ask without consequence could also forget to act on the + * answer. Denial is an exception, and the message carries the reason the + * audit records. + * + * @param string $verb One of the lifecycle verbs. + * @param Task $task The task acted on. + * @param string|null $uid The acting identity, or null when there is none. + * + * @return void + * + * @throws TaskAccessDeniedException When denied, or undeterminable. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function assertMay(string $verb, Task $task, ?string $uid): void { + // No verb is anonymous, and no verb is reachable by uuid alone. + if ($uid === null || trim($uid) === '') { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: no acting identity.", $verb) + ); + } + + // An unknown performer type is UNDETERMINABLE, which is a denial. + // PERFORMER_TYPES is the extensible vocabulary: adding `external` + // there admits it everywhere at once. + $performerType = (string)$task->getPerformerType(); + if (in_array($performerType, Task::PERFORMER_TYPES, true) === false) { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: performer type '%s' is unknown, so authorization cannot be determined.", $verb, $performerType) + ); + } + + if ($this->isAdmin(uid: $uid) === true) { + return; + } + + // The one verb carrying no per-task privilege beyond an identity: + // there is no task yet to have a relationship with. + if ($verb === 'create') { + return; + } + + // An unknown verb has no rule, so it has no permission. + if (array_key_exists($verb, self::RULES) === false) { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: no authorization rule exists for it.", $verb) + ); + } + + if (self::RULES[$verb] === 'assertPoolMember') { + $this->assertPoolMember(verb: $verb, task: $task, uid: $uid); + return; + } + + if (self::RULES[$verb] === 'assertAssignee') { + $this->assertAssignee(verb: $verb, task: $task, uid: $uid); + return; + } + + $this->assertRequester(verb: $verb, task: $task, uid: $uid); + }//end assertMay() + + /** + * Whether a caller may READ a task. + * + * The same five relationships the inbox WHERE clause admits: assignee, + * candidate-pool member, requester, watcher, administrator. Watchers get + * exactly this — read visibility — and no lifecycle right whatsoever. + * Boolean rather than asserting because reads legitimately branch + * (404 vs body), while VERBS must not be able to ignore a denial. + * + * @param Task $task The task read. + * @param string|null $uid The reading identity. + * + * @return boolean True when visible; false otherwise, including every + * undeterminable case (fail closed). + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + public function mayRead(Task $task, ?string $uid): bool { + if ($uid === null || trim($uid) === '') { + return false; + } + + if ($this->isAdmin(uid: $uid) === true) { + return true; + } + + if (trim((string)$task->getAssignee()) === $uid || trim((string)$task->getRequester()) === $uid) { + return true; + } + + if (in_array($uid, ($task->getWatchers() ?? []), true) === true) { + return true; + } + + try { + $this->assertPoolMember(verb: 'read', task: $task, uid: $uid); + + return true; + } catch (TaskAccessDeniedException) { + return false; + } + }//end mayRead() + + /** + * Whether a uid is an administrator, for callers that must branch on it. + * + * Fail-closed like everything here: no backend, no admin. + * + * @param string|null $uid The acting identity. + * + * @return boolean True only when the group backend affirms it. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function isAdministrator(?string $uid): bool { + if ($uid === null || trim($uid) === '') { + return false; + } + + return $this->isAdmin(uid: $uid); + }//end isAdministrator() + + /** + * Whether a uid is an administrator. Fail-closed: no backend, no admin. + * + * @param string $uid The acting identity. + * + * @return boolean True only when the group backend affirms it. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function isAdmin(string $uid): bool { + if ($this->groupManager === null) { + return false; + } + + try { + return $this->groupManager->isAdmin($uid); + } catch (Throwable) { + // An unavailable backend cannot GRANT anything. + return false; + } + }//end isAdmin() + + /** + * The caller must be the task's current assignee. + * + * `on_behalf_of` does not loosen this: delegation REASSIGNS the task to + * the delegate (recording both identities), so the delegate then IS the + * assignee. There is no path where a non-assignee completes. + * + * @param string $verb The verb, for the denial message. + * @param Task $task The task acted on. + * @param string $uid The acting identity. + * + * @return void + * + * @throws TaskAccessDeniedException When the caller is not the assignee. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function assertAssignee(string $verb, Task $task, string $uid): void { + $assignee = trim((string)$task->getAssignee()); + if ($assignee === '' || $assignee !== $uid) { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: only the current assignee may perform it.", $verb) + ); + } + }//end assertAssignee() + + /** + * The caller must be the task's requester. + * + * @param string $verb The verb, for the denial message. + * @param Task $task The task acted on. + * @param string $uid The acting identity. + * + * @return void + * + * @throws TaskAccessDeniedException When the caller is not the requester. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function assertRequester(string $verb, Task $task, string $uid): void { + $requester = trim((string)$task->getRequester()); + if ($requester === '' || $requester !== $uid) { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: only the requester or an administrator may perform it.", $verb) + ); + } + }//end assertRequester() + + /** + * The caller must be in the task's candidate pool. + * + * Membership means: named in `candidate_users`; in one of + * `candidate_groups`; or in the group named by `candidate_role`. + * Every branch that needs the group backend DENIES without it, and a + * role naming a group that does not exist denies NAMING THE ROLE — + * never "no check applicable". + * + * @param string $verb The verb, for the denial message. + * @param Task $task The task acted on. + * @param string $uid The acting identity. + * + * @return void + * + * @throws TaskAccessDeniedException When not a member, or undeterminable. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function assertPoolMember(string $verb, Task $task, string $uid): void { + $users = ($task->getCandidateUsers() ?? []); + if (in_array($uid, $users, true) === true) { + return; + } + + $groups = ($task->getCandidateGroups() ?? []); + foreach ($groups as $groupId) { + if ($this->isInGroup(uid: $uid, groupId: (string)$groupId) === true) { + return; + } + } + + $role = trim((string)$task->getCandidateRole()); + if ($role !== '') { + $this->assertRoleResolvable(verb: $verb, role: $role); + if ($this->isInGroup(uid: $uid, groupId: $role) === true) { + return; + } + } + + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: the caller is not in the task's candidate pool.", $verb) + ); + }//end assertPoolMember() + + /** + * Membership through the group backend, denying when it is absent. + * + * @param string $uid The acting identity. + * @param string $groupId The group to test. + * + * @return boolean True only when the backend affirms membership. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function isInGroup(string $uid, string $groupId): bool { + if ($this->groupManager === null || $groupId === '') { + return false; + } + + try { + return $this->groupManager->isInGroup($uid, $groupId); + } catch (Throwable) { + return false; + } + }//end isInGroup() + + /** + * A role must RESOLVE — to the group of the same name — or the decision + * is undeterminable and denies naming the role. + * + * @param string $verb The verb, for the denial message. + * @param string $role The role name. + * + * @return void + * + * @throws TaskAccessDeniedException When the role cannot be resolved. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function assertRoleResolvable(string $verb, string $role): void { + if ($this->groupManager === null) { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: role '%s' cannot be resolved because no group backend is available.", $verb, $role) + ); + } + + $exists = false; + try { + $exists = $this->groupManager->groupExists($role); + } catch (Throwable) { + $exists = false; + } + + if ($exists === false) { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: role '%s' does not resolve to any group.", $verb, $role) + ); + } + }//end assertRoleResolvable() +}//end class diff --git a/lib/Service/Task/TaskBuilder.php b/lib/Service/Task/TaskBuilder.php new file mode 100644 index 0000000000..ace2f1bfe4 --- /dev/null +++ b/lib/Service/Task/TaskBuilder.php @@ -0,0 +1,339 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskRelation; +use OCA\OpenRegister\Exception\TaskValidationException; +use Symfony\Component\Uid\Uuid; +use Throwable; + +/** + * Intake: boundary data in, a validated unsaved Task out. + * + * @SuppressWarnings(PHPMD.StaticAccess) TaskState and TaskPriority are + * stateless published vocabularies; static access to them IS the design. + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) fromData() is one setter + * per column of the resolved 23-shape union; splitting it by column group + * would spread one validation boundary over several methods. + * @SuppressWarnings(PHPMD.NPathComplexity) Same cause: each nullable field + * is one independent branch. + * @SuppressWarnings(PHPMD.CyclomaticComplexity) Same cause. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ +class TaskBuilder { + + /** + * Build and validate a new task from boundary data. Unsaved. + * + * @param array $data The incoming fields. + * @param string|null $actor The creating identity. + * + * @return Task The validated, unsaved task. + * + * @throws TaskValidationException On any refused value. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + public function fromData(array $data, ?string $actor): Task { + $task = new Task(); + + // Lifecycle: any vocabulary in, one vocabulary stored. state and + // is_terminal land together; the collapsed distinction lands on + // outcome unless the caller supplied an explicit one. + $normalised = TaskState::normalise(value: (string)($data['state'] ?? Task::STATE_AVAILABLE)); + // Creation initialises state and is_terminal TOGETHER, the same + // invariant TaskService::applyState() keeps for every later move. + $task->setState($normalised['state']); + $task->setIsTerminal(TaskState::isTerminal(state: $normalised['state'])); + $task->setLastAction('create'); + $outcome = $normalised['outcome']; + if ((string)($data['outcome'] ?? '') !== '') { + $outcome = (string)$data['outcome']; + } + + $task->setOutcome($outcome); + + // Priority: one scale, off-scale refused. + $task->setPriority(TaskPriority::normalise(value: ($data['priority'] ?? 'normal'))); + + // Performer type: validated against the extensible vocabulary. + $performerType = (string)($data['performerType'] ?? Task::PERFORMER_USER); + if (in_array($performerType, Task::PERFORMER_TYPES, true) === false) { + throw new TaskValidationException( + message: sprintf("Performer type '%s' is not in the known vocabulary (%s).", $performerType, implode('|', Task::PERFORMER_TYPES)) + ); + } + + $task->setPerformerType($performerType); + + // Deadlines: due_at advises, expires_at enforces, and an expiry + // before the due date is a configuration error, not a schedule. + $dueAt = $this->parseDate(value: ($data['dueAt'] ?? null), field: 'dueAt'); + $expiresAt = $this->parseDate(value: ($data['expiresAt'] ?? null), field: 'expiresAt'); + if ($dueAt !== null && $expiresAt !== null && $expiresAt < $dueAt) { + throw new TaskValidationException( + message: sprintf( + "expiresAt '%s' lies before dueAt '%s': a task that dies before it is due is a configuration error.", + $expiresAt->format('c'), + $dueAt->format('c') + ) + ); + } + + $task->setDueAt($dueAt); + $task->setExpiresAt($expiresAt); + $task->setStartAt($this->parseDate(value: ($data['startAt'] ?? null), field: 'startAt')); + $task->setSuspendedUntil($this->parseDate(value: ($data['suspendedUntil'] ?? null), field: 'suspendedUntil')); + + // Checklist: typed array, never a string containing JSON. + $task->setChecklist($this->validChecklist(value: ($data['checklist'] ?? null))); + + // Plain carried fields. + $task->setUuid((string)($data['uuid'] ?? Uuid::v4()->toRfc4122())); + $task->setTaskKey($this->stringOrNull(value: $data['key'] ?? null)); + $task->setTitle($this->stringOrNull(value: $data['title'] ?? null)); + $task->setDescription($this->stringOrNull(value: $data['description'] ?? null)); + $task->setMetadata($this->arrayOrNull(value: $data['metadata'] ?? null)); + $task->setRunUuid($this->stringOrNull(value: $data['runUuid'] ?? null)); + $task->setNodeId($this->stringOrNull(value: $data['nodeId'] ?? null)); + $task->setDefinitionVersion($this->intOrNull(value: ($data['definitionVersion'] ?? null))); + $task->setAppId($this->stringOrNull(value: $data['appId'] ?? null)); + $task->setWorkflowStepId($this->stringOrNull(value: $data['workflowStepId'] ?? null)); + $task->setOrganisation($this->stringOrNull(value: $data['organisation'] ?? null)); + $task->setAssignee($this->stringOrNull(value: $data['assignee'] ?? null)); + $task->setCandidateUsers($this->arrayOrNull(value: $data['candidateUsers'] ?? null)); + $task->setCandidateGroups($this->arrayOrNull(value: $data['candidateGroups'] ?? null)); + $task->setCandidateRole($this->stringOrNull(value: $data['candidateRole'] ?? null)); + $task->setRoutingStrategy($this->stringOrNull(value: $data['routingStrategy'] ?? null)); + $task->setRoutingFallback($this->stringOrNull(value: $data['routingFallback'] ?? null)); + $task->setOnBehalfOf($this->stringOrNull(value: $data['onBehalfOf'] ?? null)); + $task->setMandate($this->stringOrNull(value: $data['mandate'] ?? null)); + $task->setRequester($this->stringOrNull(value: $data['requester'] ?? null)); + $task->setWatchers($this->arrayOrNull(value: $data['watchers'] ?? null)); + $task->setSlaValue($this->intOrNull(value: ($data['slaValue'] ?? null))); + $task->setSlaUnit($this->stringOrNull(value: $data['slaUnit'] ?? null)); + $task->setCompliancePeriodDays($this->intOrNull(value: ($data['compliancePeriodDays'] ?? null))); + $task->setRecurrence($this->stringOrNull(value: $data['recurrence'] ?? null)); + $task->setObjectUuid($this->stringOrNull(value: $data['objectUuid'] ?? null)); + $task->setRegisterId($this->intOrNull(value: ($data['registerId'] ?? null))); + $task->setSchemaId($this->intOrNull(value: ($data['schemaId'] ?? null))); + $task->setParentTaskId($this->intOrNull(value: ($data['parentTaskId'] ?? null))); + $task->setEpicTaskId($this->intOrNull(value: ($data['epicTaskId'] ?? null))); + $task->setPercentComplete($this->intOrNull(value: ($data['percentComplete'] ?? null))); + $task->setResponses($this->arrayOrNull(value: $data['responses'] ?? null)); + $task->setEvidence($this->arrayOrNull(value: $data['evidence'] ?? null)); + $task->setCreatedBy($actor); + + // Template FREEZE at creation: id, version and the snapshot land + // together, and later evaluation reads only the snapshot. + $task->setTemplateId($this->stringOrNull(value: $data['templateId'] ?? null)); + $task->setTemplateVersion($this->intOrNull(value: ($data['templateVersion'] ?? null))); + $task->setTemplateSnapshot($this->arrayOrNull(value: $data['templateSnapshot'] ?? null)); + + return $task; + }//end fromData() + + /** + * The typed relations named at creation, as unsaved rows. + * + * @param Task $task The persisted task. + * @param array $data The creation payload; `relations` is + * a list of {role, objectUuid, + * registerId?, schemaId?}. + * + * @return array The relation rows to insert. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-generic-anchor-plus-typed-relations + */ + public function relationsFor(Task $task, array $data): array { + $relations = ($data['relations'] ?? null); + if (is_array($relations) === false) { + return []; + } + + $rows = []; + foreach ($relations as $relation) { + if (is_array($relation) === false) { + continue; + } + + $role = trim((string)($relation['role'] ?? '')); + $objectUuid = trim((string)($relation['objectUuid'] ?? '')); + if ($role === '' || $objectUuid === '') { + throw new TaskValidationException(message: 'A task relation requires both a role and an objectUuid.'); + } + + $row = new TaskRelation(); + $row->setTaskId((int)$task->getId()); + $row->setRole($role); + $row->setObjectUuid($objectUuid); + $row->setRegisterId($this->intOrNull(value: ($relation['registerId'] ?? null))); + $row->setSchemaId($this->intOrNull(value: ($relation['schemaId'] ?? null))); + $rows[] = $row; + } + + return $rows; + }//end relationsFor() + + /** + * Parse a date field: DateTime passes, ISO strings parse, junk refuses. + * + * @param mixed $value The incoming value. + * @param string $field The field name, for the refusal message. + * + * @return DateTime|null The parsed date, or null for absent. + * + * @throws TaskValidationException When present but unparsable. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-due_at-advises-expires_at-enforces + */ + private function parseDate(mixed $value, string $field): ?DateTime { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof DateTime === true) { + return $value; + } + + if (is_string($value) === true) { + try { + return new DateTime($value); + } catch (Throwable) { + // Falls through to the refusal below. + } + } + + throw new TaskValidationException( + message: sprintf("Field '%s' does not parse as a date.", $field) + ); + }//end parseDate() + + /** + * The checklist must be a typed array of {id, label} items. + * + * A STRING is refused by name: procest stores JSON-in-a-string today, + * which is exactly the unqueryable shape this entity removes. + * + * @param mixed $value The incoming checklist. + * + * @return array>|null The validated checklist. + * + * @throws TaskValidationException When it is a string or malformed. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-templated-task-freezes-its-template-at-creation + */ + private function validChecklist(mixed $value): ?array { + if ($value === null) { + return null; + } + + if (is_string($value) === true) { + throw new TaskValidationException( + message: 'The checklist must be a typed array of {id, label, description, checked} items, not a string containing JSON.' + ); + } + + if (is_array($value) === false) { + throw new TaskValidationException(message: 'The checklist must be a typed array of {id, label, description, checked} items.'); + } + + $items = []; + foreach ($value as $item) { + if (is_array($item) === false || trim((string)($item['id'] ?? '')) === '' || trim((string)($item['label'] ?? '')) === '') { + throw new TaskValidationException(message: 'Every checklist item requires an id and a label.'); + } + + $items[] = [ + 'id' => (string)$item['id'], + 'label' => (string)$item['label'], + 'description' => ($item['description'] ?? null), + 'checked' => (bool)($item['checked'] ?? false), + ]; + } + + return $items; + }//end validChecklist() + + /** + * A trimmed string, or null for absent/empty. + * + * @param mixed $value The incoming value. + * + * @return string|null The string, or null. + */ + private function stringOrNull(mixed $value): ?string { + if ($value === null) { + return null; + } + + $string = trim((string)$value); + if ($string === '') { + return null; + } + + return $string; + }//end stringOrNull() + + /** + * An integer, or null for absent. + * + * @param mixed $value The incoming value. + * + * @return int|null The integer, or null. + */ + private function intOrNull(mixed $value): ?int { + if ($value === null || $value === '') { + return null; + } + + return (int)$value; + }//end intOrNull() + + /** + * An array, or null for absent. + * + * @param mixed $value The incoming value. + * + * @return array|null The array, or null. + */ + private function arrayOrNull(mixed $value): ?array { + if (is_array($value) === true) { + return $value; + } + + return null; + }//end arrayOrNull() +}//end class diff --git a/lib/Service/Task/TaskInboxService.php b/lib/Service/Task/TaskInboxService.php new file mode 100644 index 0000000000..46b7b51ada --- /dev/null +++ b/lib/Service/Task/TaskInboxService.php @@ -0,0 +1,253 @@ +getContext()['signal']`, which is not listable, not countable + * and not poolable. Here, filtering, sorting, pagination AND the total all + * run in the datastore over one shared predicate set + * ({@see \OCA\OpenRegister\Db\TaskMapper}), and visibility is part of the + * WHERE clause — never a post-filter over a wider result, because a + * filtered-down page silently drops rows and a filtered-down total leaks + * what it excluded (design D-9). + * + * Each returned row carries the subject object's identifying context + * (register, schema, uuid, display title), fetched in ONE batch for the + * page, so a list is readable without a second request per row. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\AbstractObjectMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Db\TaskMapper; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Lists and counts tasks for a caller, with subject context attached. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ +class TaskInboxService { + + /** + * Constructor. + * + * @param TaskMapper $tasks The datastore queries. + * @param TaskTemporalProjection $temporal The ONE overdue derivation. + * @param LoggerInterface $logger Failure reporting. + * @param AbstractObjectMapper|null $objects Resolves subject objects for + * row context. Nullable so the + * service stays constructible + * without the object store; a + * row's subject context then + * reads null, which is honest — + * the TASK list never fails + * over a context lookup. + */ + public function __construct( + private readonly TaskMapper $tasks, + private readonly TaskTemporalProjection $temporal, + private readonly LoggerInterface $logger, + private readonly ?AbstractObjectMapper $objects = null, + ) { + + }//end __construct() + + /** + * One inbox page: rows with subject context and temporal projection, + * plus the total the SAME predicates count. + * + * The caller's identity facts (uid, group ids, admin) arrive resolved: + * the controller owns the session, this service owns the query. An empty + * uid returns nothing rather than everything — no identity, no + * visibility. + * + * @param TaskInboxCriteria $criteria Scope, filters, sort and identity. + * @param int $limit Page size (clamped to 1..500). + * @param int $offset Page offset. + * + * @return array{results: array>, total: int, limit: int, offset: int} The page. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + public function inbox(TaskInboxCriteria $criteria, int $limit = 25, int $offset = 0): array { + $limit = max(1, min($limit, 500)); + $offset = max(0, $offset); + + if (trim($criteria->uid) === '') { + return [ + 'results' => [], + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + ]; + } + + $page = $this->tasks->findInbox(criteria: $criteria, limit: $limit, offset: $offset); + $total = $this->tasks->countInbox(criteria: $criteria); + $subjects = $this->subjectContexts(tasks: $page); + + $now = $this->temporal->now(); + $results = []; + foreach ($page as $task) { + $results[] = $this->row(task: $task, subjects: $subjects, now: $now); + } + + return [ + 'results' => $results, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ]; + }//end inbox() + + /** + * One task as an API row: stored fields, DERIVED temporal projection, + * subject context, and a display title that is synthesized when the + * stored title is null — and never written back. + * + * @param Task $task The task. + * @param array> $subjects Subject context by object uuid. + * @param \DateTimeInterface $now The clock instant for the projection. + * + * @return array The row. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function row(Task $task, array $subjects, \DateTimeInterface $now): array { + $row = $task->jsonSerialize(); + + $subject = null; + $objectUuid = (string)$task->getObjectUuid(); + if ($objectUuid !== '' && array_key_exists($objectUuid, $subjects) === true) { + $subject = $subjects[$objectUuid]; + } + + $row['subject'] = $subject; + $row['displayTitle'] = $this->displayTitle(task: $task, subject: $subject); + // Derived, never stored: the projection is attached to the ROW, and + // the row alone. + $projection = $this->temporal->project(task: $task, now: $now); + $row['overdue'] = $projection['overdue']; + $row['daysUntilDue'] = $projection['daysUntilDue']; + $row['daysOverdue'] = $projection['daysOverdue']; + + return $row; + }//end row() + + /** + * Synthesize a display title for a titleless task — on read, never persisted. + * + * A stored synthesized title would go stale the moment the subject is + * renamed, which is why the spec forbids persisting it. + * + * @param Task $task The task. + * @param array|null $subject The subject context, when resolved. + * + * @return string A non-empty display title. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function displayTitle(Task $task, ?array $subject): string { + $stored = trim((string)$task->getTitle()); + if ($stored !== '') { + return $stored; + } + + $action = trim((string)$task->getLastAction()); + if ($action === '') { + $action = 'task'; + } + + $subjectName = trim((string)($subject['title'] ?? '')); + if ($subjectName === '') { + $subjectName = trim((string)$task->getObjectUuid()); + } + + if ($subjectName === '') { + return ucfirst($action); + } + + return sprintf('%s: %s', ucfirst($action), $subjectName); + }//end displayTitle() + + /** + * Resolve subject context for a page of tasks, in ONE batch. + * + * A failed or absent object store yields empty context, logged at debug: + * the inbox never fails over enrichment, and never fires a query per row. + * + * @param array $tasks The page. + * + * @return array> Context by object uuid: + * {uuid, registerId, schemaId, title}. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ + private function subjectContexts(array $tasks): array { + $uuids = []; + foreach ($tasks as $task) { + $objectUuid = trim((string)$task->getObjectUuid()); + if ($objectUuid !== '') { + $uuids[$objectUuid] = true; + } + } + + if ($uuids === [] || $this->objects === null) { + return []; + } + + try { + $found = $this->objects->findMultiple(ids: array_keys($uuids)); + } catch (Throwable $failure) { + $this->logger->debug( + '[TaskInboxService] Could not resolve subject objects: ' . $failure->getMessage(), + ['count' => count($uuids)] + ); + + return []; + } + + $contexts = []; + foreach ($found as $object) { + $serialised = []; + if (method_exists($object, 'jsonSerialize') === true) { + $serialised = $object->jsonSerialize(); + } + + $uuid = (string)($serialised['uuid'] ?? ''); + if ($uuid === '') { + continue; + } + + $contexts[$uuid] = [ + 'uuid' => $uuid, + 'register' => ($serialised['register'] ?? null), + 'schema' => ($serialised['schema'] ?? null), + 'title' => ($serialised['name'] ?? ($serialised['title'] ?? null)), + ]; + } + + return $contexts; + }//end subjectContexts() +}//end class diff --git a/lib/Service/Task/TaskPerformerResolver.php b/lib/Service/Task/TaskPerformerResolver.php new file mode 100644 index 0000000000..3e01761b12 --- /dev/null +++ b/lib/Service/Task/TaskPerformerResolver.php @@ -0,0 +1,255 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCP\IGroupManager; +use Throwable; + +/** + * The five routing strategies, and the pool expansion under them. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ +class TaskPerformerResolver { + + /** + * Constructor. + * + * @param TaskMapper $tasks Supplies the load and recency reads the + * `least-loaded` and `round-robin` strategies + * rank by. + * @param IGroupManager|null $groupManager Expands candidate groups and + * roles to members. Nullable so + * the resolver stays + * constructible without a + * container; absent, groups and + * roles expand to NOBODY — which + * leaves tasks pooled, the safe + * direction. + */ + public function __construct( + private readonly TaskMapper $tasks, + private readonly ?IGroupManager $groupManager = null, + ) { + + }//end __construct() + + /** + * Resolve the task's pool under its routing strategy. + * + * @param Task $task The task whose pool is being resolved. + * + * @return string|null The chosen uid, or null to LEAVE THE TASK POOLED. + * Null is a first-class answer, not a failure: the pool members + * can still claim. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + public function resolveAssignee(Task $task): ?string { + $pool = $this->expandPool(task: $task); + $strategy = trim((string)$task->getRoutingStrategy()); + + $chosen = null; + switch ($strategy) { + case 'single-role': + // A role that resolves to exactly one person IS an + // assignment; any other size is a pool. + if (count($pool) === 1) { + $chosen = $pool[0]; + } + break; + case 'or-set': + // The whole point of an or-set is that ANY member answers: + // nobody is picked, everybody may claim. + $chosen = null; + break; + case 'hierarchical': + // The pool is ordered (users first, in declared order): the + // first tier answers, the next only when it is empty. + $chosen = ($pool[0] ?? null); + break; + case 'round-robin': + $chosen = $this->pickLeastRecentlyAssigned(pool: $pool); + break; + case 'least-loaded': + $chosen = $this->pickLeastLoaded(pool: $pool); + break; + default: + // No strategy: nothing to resolve, the pool stands. + $chosen = null; + break; + }//end switch + + if ($chosen !== null) { + return $chosen; + } + + // The ONLY fallback is the explicitly configured one. + $fallback = trim((string)$task->getRoutingFallback()); + if ($fallback !== '') { + return $fallback; + } + + return null; + }//end resolveAssignee() + + /** + * Expand the candidate pool to uids, in declared order. + * + * Order matters to `hierarchical`; the other strategies treat the result + * as a set. Groups and the role group expand through the group backend; + * without one they expand to nothing, which pools rather than guesses. + * + * @param Task $task The task whose pool is expanded. + * + * @return array The candidate uids, deduplicated, in order. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + public function expandPool(Task $task): array { + $pool = []; + foreach (($task->getCandidateUsers() ?? []) as $uid) { + $pool[] = (string)$uid; + } + + foreach (($task->getCandidateGroups() ?? []) as $groupId) { + foreach ($this->groupMembers(groupId: (string)$groupId) as $uid) { + $pool[] = $uid; + } + } + + $role = trim((string)$task->getCandidateRole()); + if ($role !== '') { + foreach ($this->groupMembers(groupId: $role) as $uid) { + $pool[] = $uid; + } + } + + return array_values(array_unique($pool)); + }//end expandPool() + + /** + * The members of one group, or nobody when the backend cannot answer. + * + * @param string $groupId The group id (or role name). + * + * @return array The member uids. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + private function groupMembers(string $groupId): array { + if ($this->groupManager === null || $groupId === '') { + return []; + } + + try { + $group = $this->groupManager->get($groupId); + if ($group === null) { + return []; + } + + $members = []; + foreach ($group->getUsers() as $user) { + $members[] = $user->getUID(); + } + + return $members; + } catch (Throwable) { + return []; + } + }//end groupMembers() + + /** + * `round-robin`: the member handed a task least recently goes next. + * + * A member never assigned anything sorts first. Ties break on uid so + * the answer is deterministic under test and under retry. + * + * @param array $pool The candidate uids. + * + * @return string|null The chosen uid, or null for an empty pool. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + private function pickLeastRecentlyAssigned(array $pool): ?string { + if ($pool === []) { + return null; + } + + $latest = $this->tasks->latestAssignedAt(uids: $pool); + usort( + $pool, + static function (string $a, string $b) use ($latest): int { + $atA = ($latest[$a] ?? ''); + $atB = ($latest[$b] ?? ''); + if ($atA !== $atB) { + return strcmp($atA, $atB); + } + + return strcmp($a, $b); + } + ); + + return $pool[0]; + }//end pickLeastRecentlyAssigned() + + /** + * `least-loaded`: the member with the fewest open tasks goes next. + * + * @param array $pool The candidate uids. + * + * @return string|null The chosen uid, or null for an empty pool. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + private function pickLeastLoaded(array $pool): ?string { + if ($pool === []) { + return null; + } + + $counts = $this->tasks->countOpenAssigned(uids: $pool); + usort( + $pool, + static function (string $a, string $b) use ($counts): int { + $countA = ($counts[$a] ?? 0); + $countB = ($counts[$b] ?? 0); + if ($countA !== $countB) { + return ($countA <=> $countB); + } + + return strcmp($a, $b); + } + ); + + return $pool[0]; + }//end pickLeastLoaded() +}//end class diff --git a/lib/Service/Task/TaskPriority.php b/lib/Service/Task/TaskPriority.php new file mode 100644 index 0000000000..838ecf8d54 --- /dev/null +++ b/lib/Service/Task/TaskPriority.php @@ -0,0 +1,133 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-priority-is-normalised-to-one-scale-on-the-way-in + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Exception\TaskValidationException; + +/** + * Normalises every known priority scale onto low|normal|high|urgent. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-priority-is-normalised-to-one-scale-on-the-way-in + */ +final class TaskPriority { + + /** + * String scales: the canonical four and the notification scale. + * + * @var array + */ + private const STRINGS = [ + 'low' => 'low', + 'normal' => 'normal', + 'high' => 'high', + 'urgent' => 'urgent', + // The notification scale. + 'medium' => 'normal', + 'critical' => 'urgent', + ]; + + /** + * Normalise a priority from any known scale. + * + * @param mixed $value A string from a known scale, or an iCal 0-9 integer + * (numeric strings are read as the integer they spell). + * + * @return string One of low|normal|high|urgent. + * + * @throws TaskValidationException When the value is on no known scale. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-priority-is-normalised-to-one-scale-on-the-way-in + */ + public static function normalise(mixed $value): string { + // The iCal integer range, arriving as int or as a numeric string. + if (is_int($value) === true || (is_string($value) === true && preg_match('/^\d+$/', trim($value)) === 1)) { + return self::fromIcal(value: (int)trim((string)$value)); + } + + if (is_string($value) === true) { + $lowered = strtolower(trim($value)); + if (array_key_exists($lowered, self::STRINGS) === true) { + return self::STRINGS[$lowered]; + } + } + + $printable = gettype($value); + if (is_scalar($value) === true) { + $printable = (string)$value; + } + + throw new TaskValidationException( + message: sprintf( + "Priority '%s' is on no known scale (low|normal|high|urgent, low|medium|high|critical, or iCal 0-9) and is refused, not coerced.", + $printable + ) + ); + }//end normalise() + + /** + * The iCal 0-9 mapping: 1 is the wire format's highest. + * + * RFC 5545: 0 undefined, 1-4 high, 5 medium, 6-9 low. `1` — the value a + * VTODO marks its most urgent work with — lands on `urgent`; the rest of + * the high band on `high`. + * + * @param int $value The iCal integer. + * + * @return string One of low|normal|high|urgent. + * + * @throws TaskValidationException When outside 0-9. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-priority-is-normalised-to-one-scale-on-the-way-in + */ + private static function fromIcal(int $value): string { + if ($value < 0 || $value > 9) { + throw new TaskValidationException( + message: sprintf("Priority '%d' is outside the iCal 0-9 range and is refused, not coerced.", $value) + ); + } + + if ($value === 0 || $value === 5) { + return 'normal'; + } + + if ($value === 1) { + return 'urgent'; + } + + if ($value <= 4) { + return 'high'; + } + + return 'low'; + }//end fromIcal() +}//end class diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php new file mode 100644 index 0000000000..730a6d7d5b --- /dev/null +++ b/lib/Service/Task/TaskService.php @@ -0,0 +1,963 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Creates, routes, claims, completes, cancels and terminates tasks. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One method per lifecycle + * verb the spec names (create/offer/claim/unclaim/assign/reassign/delegate/ + * resolve/complete/cancel) plus the two propagation entry points. Merging + * verbs would trade a countable surface for a mode parameter, which is how + * authorization rules get forgotten. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The service owns the + * transaction across four mappers plus the authorization, routing and + * vocabulary collaborators; that IS its job description. + * @SuppressWarnings(PHPMD.ExcessiveClassLength) Scales with the verb count; + * each verb is short and single-purpose. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of twelve + * verbs' guards. Each verb is short and its branches are the spec's own + * rules (authorize, then terminality, then the verb's precondition, then + * the conditional write); folding verbs together to lower the number would + * hide exactly the per-verb rules the spec enumerates. + * @SuppressWarnings(PHPMD.StaticAccess) TaskState is a stateless published + * vocabulary (the one status mapping); calling it statically is the point, + * an instance would be a second copy of the same table. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ +class TaskService { + + /** + * Constructor. + * + * @param TaskMapper $tasks The task table. + * @param TaskCandidateMapper $candidates The candidate index rows. + * @param TaskRelationMapper $relations The typed relation rows. + * @param TaskAuditMapper $audits The append-only audit. + * @param TaskAuthorizationService $authorization The per-verb, fail-closed decisions. + * @param TaskPerformerResolver $resolver The routing strategies. + * @param IDBConnection $db Holds the one transaction per verb. + * @param LoggerInterface $logger Failure reporting. + * @param TaskBuilder $builder Validates and builds a new task from + * boundary data (the vocabularies live there). + */ + public function __construct( + private readonly TaskMapper $tasks, + private readonly TaskCandidateMapper $candidates, + private readonly TaskRelationMapper $relations, + private readonly TaskAuditMapper $audits, + private readonly TaskAuthorizationService $authorization, + private readonly TaskPerformerResolver $resolver, + private readonly IDBConnection $db, + private readonly LoggerInterface $logger, + private readonly TaskBuilder $builder, + ) { + + }//end __construct() + + /** + * Create a task. + * + * Validation happens at THIS boundary, through the published + * vocabularies: a legacy status resolves through {@see TaskState} (an + * unmapped one is refused naming itself), priority through + * {@see TaskPriority}, `expires_at` earlier than `due_at` is refused, + * and the checklist must be a typed array. The template, when named, is + * FROZEN: `template_snapshot` is written now and all later evaluation + * reads it, never the live template. + * + * This is the HTTP path: unless the actor is an administrator, the + * requester is pinned to the actor and a terminal creation state is + * refused. In-process callers that need either use {@see import()}. + * + * @param array $data The task fields, canonical or legacy. + * @param string|null $actor The creating identity. + * + * @return Task The created task, with candidates indexed and creation audited. + * + * @throws TaskValidationException On any refused value. + * @throws TaskAccessDeniedException Without an acting identity. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function create(array $data, ?string $actor): Task { + if ($this->authorization->isAdministrator(uid: $actor) === false) { + // An ordinary caller is the requester of what they create: they + // may not write somebody else's name into the seat that owns + // cancel and reassign. And they may not create a task that is + // born closed (state 'approved' maps to completed with nobody + // having completed it): those states arrive only through the + // verbs, or through a trusted migration path. + $data['requester'] = $actor; + $state = (string)($data['state'] ?? Task::STATE_AVAILABLE); + if (TaskState::isTerminal(state: TaskState::normalise(value: $state)['state']) === true) { + throw new TaskValidationException( + message: sprintf("A task cannot be created in terminal state '%s'; it reaches that state through a lifecycle verb.", $state) + ); + } + } + + return $this->import(data: $data, actor: $actor); + }//end create() + + /** + * Create a task on the TRUSTED path: migrations and in-process callers + * (the user-task node) that may name a requester and may import a task + * that is already closed (a completed approval carried over from a + * legacy shape). Not reachable over HTTP; the controller calls create(). + * + * @param array $data The task fields, canonical or legacy. + * @param string|null $actor The creating identity. + * + * @return Task The created task, with candidates indexed and creation audited. + * + * @throws TaskValidationException On any refused value. + * @throws TaskAccessDeniedException Without an acting identity. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + public function import(array $data, ?string $actor): Task { + $task = $this->builder->fromData(data: $data, actor: $actor); + $this->authorizeOrRecord(verb: 'create', task: $task, actor: $actor); + + return $this->transactional( + mutation: function () use ($task, $data, $actor): Task { + $persisted = $this->tasks->insert($task); + $this->rewriteCandidateIndex(task: $persisted); + foreach ($this->builder->relationsFor(task: $persisted, data: $data) as $relation) { + $this->relations->insert(entity: $relation); + } + $this->appendAudit( + task: $persisted, + action: 'create', + actor: $actor, + reason: null + ); + + return $persisted; + } + ); + }//end import() + + /** + * Offer a task to a candidate pool, optionally routing it. + * + * Rewrites the pool (JSON and index together), runs the routing strategy, + * and either assigns its answer or leaves the task pooled — NEVER + * implicitly assigned. A strategy that finds nobody, with no fallback, + * is a pooled task, full stop. + * + * @param string $uuid The task uuid. + * @param array $pool candidateUsers / candidateGroups / + * candidateRole / routingStrategy / + * routingFallback, each optional. + * @param string|null $actor The offering identity. + * + * @return Task The offered task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + public function offer(string $uuid, array $pool, ?string $actor): Task { + $task = $this->openTaskFor(verb: 'offer', uuid: $uuid, actor: $actor); + + // An assigned task is not offerable: offering rewrites the pool and + // the routing fallback, which decide who ends up assigned, so on an + // assigned task it would be a reassignment wearing a different name + // and a different authorization rule. Unclaim or reassign first. + if (trim((string)$task->getAssignee()) !== '') { + throw new TaskConflictException( + message: sprintf("Verb 'offer' refused: task '%s' is already assigned; unclaim or reassign it instead.", $uuid) + ); + } + + return $this->transactional( + mutation: function () use ($task, $pool, $actor): Task { + // Only the keys the caller sent change; an absent key keeps + // the stored value, so offer can adjust one routing field. + $settable = [ + 'candidateUsers' => 'setCandidateUsers', + 'candidateGroups' => 'setCandidateGroups', + 'candidateRole' => 'setCandidateRole', + 'routingStrategy' => 'setRoutingStrategy', + 'routingFallback' => 'setRoutingFallback', + ]; + foreach ($settable as $key => $setter) { + if (array_key_exists($key, $pool) === true) { + $task->$setter($pool[$key]); + } + } + + // Routing either names somebody (active) or leaves the task + // pooled (enabled). Never a third option. + $chosen = $this->resolver->resolveAssignee(task: $task); + $task->setAssignee($chosen); + $state = Task::STATE_ENABLED; + if ($chosen !== null) { + $state = Task::STATE_ACTIVE; + } + + $this->applyState(task: $task, state: $state, action: 'offer'); + + $persisted = $this->persistOpen(task: $task); + $this->rewriteCandidateIndex(task: $persisted); + $this->appendAudit(task: $persisted, action: 'offer', actor: $actor, reason: null); + + return $persisted; + } + ); + }//end offer() + + /** + * Claim: assign IF still unassigned — the database decides the race. + * + * Authorization (pool membership) runs first; then ONE conditional + * UPDATE either wins or affects nothing. The loser receives a conflict + * naming the current holder's existence, never a silent overwrite. + * + * @param string $uuid The task uuid. + * @param string|null $actor The claiming identity. + * + * @return Task The claimed task. + * + * @throws TaskConflictException When another claimer won, or the task is terminal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function claim(string $uuid, ?string $actor): Task { + $task = $this->openTaskFor(verb: 'claim', uuid: $uuid, actor: $actor); + + return $this->transactional( + mutation: function () use ($task, $actor): Task { + $won = $this->tasks->claim(taskId: (int)$task->getId(), uid: (string)$actor); + if ($won === false) { + throw new TaskConflictException( + message: sprintf("Task '%s' was not claimable: another claim won, or the task is no longer open.", (string)$task->getUuid()) + ); + } + + $fresh = $this->tasks->findByUuid(uuid: (string)$task->getUuid()); + $this->appendAudit(task: $fresh, action: 'claim', actor: $actor, reason: null); + + return $fresh; + } + ); + }//end claim() + + /** + * Unclaim: the assignee returns the task to its pool. + * + * @param string $uuid The task uuid. + * @param string|null $actor The returning identity — must be the assignee. + * + * @return Task The pooled task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function unclaim(string $uuid, ?string $actor): Task { + $task = $this->openTaskFor(verb: 'unclaim', uuid: $uuid, actor: $actor); + + return $this->transactional( + mutation: function () use ($task, $actor): Task { + $task->setAssignee(null); + $task->setOnBehalfOf(null); + $task->setMandate(null); + $this->applyState(task: $task, state: Task::STATE_ENABLED, action: 'unclaim'); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit(task: $persisted, action: 'unclaim', actor: $actor, reason: null); + + return $persisted; + } + ); + }//end unclaim() + + /** + * Assign a task to a performer directly. + * + * @param string $uuid The task uuid. + * @param string $assignee The performer reference. + * @param string|null $actor The assigning identity — requester or admin. + * + * @return Task The assigned task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function assign(string $uuid, string $assignee, ?string $actor): Task { + return $this->assignInternal(uuid: $uuid, assignee: $assignee, actor: $actor, action: 'assign'); + }//end assign() + + /** + * Reassign a task to a different performer. + * + * @param string $uuid The task uuid. + * @param string $assignee The new performer reference. + * @param string|null $actor The reassigning identity — requester or admin. + * + * @return Task The reassigned task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function reassign(string $uuid, string $assignee, ?string $actor): Task { + return $this->assignInternal(uuid: $uuid, assignee: $assignee, actor: $actor, action: 'reassign'); + }//end reassign() + + /** + * Delegate: the assignee hands the task to a delegate, with a mandate. + * + * The delegate becomes the assignee; `on_behalf_of` names the original + * performer and `mandate` the authority relied on. Every later action by + * the delegate carries both identities into the audit — a delegated + * completion is NEVER recorded as the original performer acting. + * + * @param string $uuid The task uuid. + * @param string $delegate The acting identity taking over. + * @param string $mandate The authority relied on. + * @param string|null $actor The delegating identity — must be the assignee. + * + * @return Task The delegated task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + public function delegate(string $uuid, string $delegate, string $mandate, ?string $actor): Task { + $task = $this->openTaskFor(verb: 'delegate', uuid: $uuid, actor: $actor); + + if (trim($delegate) === '') { + throw new TaskValidationException(message: 'A delegation requires a non-empty delegate.'); + } + + if (trim($mandate) === '') { + throw new TaskValidationException(message: 'A delegation requires a mandate naming the authority relied on.'); + } + + return $this->transactional( + mutation: function () use ($task, $delegate, $mandate, $actor): Task { + // A re-delegation keeps naming the ORIGINAL performer: the + // chain of delegates is in the audit, the accountable party + // is the one who never changes. + $task->setOnBehalfOf($task->getOnBehalfOf() ?? $task->getAssignee()); + $task->setAssignee($delegate); + $task->setMandate($mandate); + $this->applyState(task: $task, state: Task::STATE_ACTIVE, action: 'delegate'); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit(task: $persisted, action: 'delegate', actor: $actor, reason: $mandate); + + return $persisted; + } + ); + }//end delegate() + + /** + * Resolve: the assignee finishes the work with a `resolved` outcome. + * + * @param string $uuid The task uuid. + * @param string|null $resultText Free-text result. + * @param string|null $comment Completion comment. + * @param string|null $actor The resolving identity — must be the assignee. + * + * @return Task The resolved task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function resolve(string $uuid, ?string $resultText, ?string $comment, ?string $actor): Task { + return $this->completeInternal( + verb: 'resolve', + uuid: $uuid, + outcome: 'resolved', + resultText: $resultText, + comment: $comment, + actor: $actor + ); + }//end resolve() + + /** + * Complete: the assignee finishes the work with an explicit outcome. + * + * A rejecting or returning outcome REQUIRES a non-empty comment; without + * one the verb is refused and the task keeps its pre-call state. + * + * @param string $uuid The task uuid. + * @param string $outcome The outcome (`approved`, `done`, `rejected`, ...). + * @param string|null $resultText Free-text result. + * @param string|null $comment Completion comment. + * @param string|null $actor The completing identity — must be the assignee. + * + * @return Task The completed task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function complete(string $uuid, string $outcome, ?string $resultText, ?string $comment, ?string $actor): Task { + return $this->completeInternal( + verb: 'complete', + uuid: $uuid, + outcome: $outcome, + resultText: $resultText, + comment: $comment, + actor: $actor + ); + }//end complete() + + /** + * Cancel: the requester (or an administrator) terminates the task. + * + * @param string $uuid The task uuid. + * @param string|null $reason Why, recorded on task and audit. + * @param string|null $actor The cancelling identity. + * + * @return Task The terminated task. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public function cancel(string $uuid, ?string $reason, ?string $actor): Task { + $task = $this->openTaskFor(verb: 'cancel', uuid: $uuid, actor: $actor); + + return $this->transactional( + mutation: function () use ($task, $reason, $actor): Task { + $task->setOutcome('cancelled'); + $this->applyState(task: $task, state: Task::STATE_TERMINATED, action: 'cancel'); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit(task: $persisted, action: 'cancel', actor: $actor, reason: $reason); + + return $persisted; + } + ); + }//end cancel() + + /** + * Check or uncheck ONE checklist item, addressed by its id. + * + * @param string $uuid The task uuid. + * @param string $itemId The checklist item id. + * @param bool $checked The new checked value. + * @param string|null $actor The acting identity — must be the assignee. + * + * @return Task The task with the one item changed. + * + * @throws TaskValidationException When no item carries that id. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-templated-task-freezes-its-template-at-creation + */ + public function checkChecklistItem(string $uuid, string $itemId, bool $checked, ?string $actor): Task { + $task = $this->openTaskFor(verb: 'checklist', uuid: $uuid, actor: $actor); + + return $this->transactional( + mutation: function () use ($task, $itemId, $checked, $actor): Task { + $checklist = ($task->getChecklist() ?? []); + $found = false; + foreach ($checklist as $index => $item) { + if (is_array($item) === true && (string)($item['id'] ?? '') === $itemId) { + $checklist[$index]['checked'] = $checked; + $found = true; + break; + } + } + + if ($found === false) { + throw new TaskValidationException( + message: sprintf("Checklist item '%s' does not exist on task '%s'.", $itemId, (string)$task->getUuid()) + ); + } + + $task->setChecklist($checklist); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit( + task: $persisted, + action: 'checklist', + actor: $actor, + reason: sprintf("Item '%s' set to '%s'.", $itemId, var_export($checked, true)) + ); + + return $persisted; + } + ); + }//end checkChecklistItem() + + /** + * Cancellation propagation: a run reached a terminal status. + * + * Terminates every NON-TERMINAL task carrying that `run_uuid`, with a + * reason naming the run and its status, audited with the propagation + * source as actor. Idempotent by construction — the read only selects + * non-terminal tasks, so observing terminality twice (the reaper races a + * completing run) terminates nothing twice. A task with `run_uuid` null + * is structurally unreachable: nothing about it derives from a run. + * + * @param string $runUuid The terminal run's uuid. + * @param string $runStatus Its terminal status. + * + * @return int How many tasks were terminated. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ + public function terminateForRun(string $runUuid, string $runStatus): int { + if (trim($runUuid) === '') { + return 0; + } + + $open = $this->tasks->findOpenByRunUuid(runUuid: $runUuid); + $reason = sprintf("Run '%s' reached terminal status '%s'.", $runUuid, $runStatus); + $terminated = 0; + $failed = []; + foreach ($open as $task) { + // One task's failure must not orphan the rest of the run's + // tasks: each is its own transaction, and a failure is logged + // and counted, then the loop continues. A task closed + // concurrently (conflict) is already where propagation wanted it. + try { + $this->transactional( + mutation: function () use ($task, $reason, $runUuid): Task { + $task->setOutcome('terminated'); + $task->setBlockedReason(null); + $this->applyState(task: $task, state: Task::STATE_TERMINATED, action: 'terminate'); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit( + task: $persisted, + action: 'terminate', + actor: sprintf('flow-run:%s', $runUuid), + reason: $reason + ); + + return $persisted; + } + ); + $terminated++; + } catch (TaskConflictException) { + // Closed by somebody else in the meantime: not our failure. + continue; + } catch (Throwable $failure) { + $failed[] = (string)$task->getUuid(); + $this->logger->error( + '[TaskService] Propagation could not terminate a task: ' . $failure->getMessage(), + ['task' => $task->getUuid(), 'run' => $runUuid, 'exception' => $failure] + ); + }//end try + }//end foreach + + if ($failed !== []) { + $this->logger->warning( + sprintf('[TaskService] Propagation for run %s left %d task(s) untouched: %s', $runUuid, count($failed), implode(', ', $failed)) + ); + } + + return $terminated; + }//end terminateForRun() + + /** + * Explicit propagation: a branch decision made this task moot. + * + * @param string $uuid The task uuid. + * @param string $reason What made it moot — recorded, because "why did + * this disappear from my inbox" must stay answerable. + * @param string $source The propagation source recorded as actor. + * + * @return Task The terminated task, or the task untouched when already terminal. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-that-has-become-moot-is-terminated-not-orphaned + */ + public function terminateAsMoot(string $uuid, string $reason, string $source): Task { + $task = $this->tasks->findByUuid(uuid: $uuid); + if ($task->isInTerminalState() === true) { + // Idempotent: already-terminal stays as it ended. + return $task; + } + + return $this->transactional( + mutation: function () use ($task, $reason, $source): Task { + $task->setOutcome('terminated'); + $this->applyState(task: $task, state: Task::STATE_TERMINATED, action: 'terminate'); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit(task: $persisted, action: 'terminate', actor: $source, reason: $reason); + + return $persisted; + } + ); + }//end terminateAsMoot() + + /** + * Fetch a task by uuid. + * + * @param string $uuid The task uuid. + * + * @return Task The task. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException When no such task exists. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + */ + public function get(string $uuid): Task { + return $this->tasks->findByUuid(uuid: $uuid); + }//end get() + + /** + * The audit trail of a task, oldest first. + * + * @param string $uuid The task uuid. + * + * @return array The entries. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + public function auditTrail(string $uuid): array { + $task = $this->tasks->findByUuid(uuid: $uuid); + + return $this->audits->findForTask(taskId: (int)$task->getId()); + }//end auditTrail() + + /** + * Shared body of assign and reassign. + * + * @param string $uuid The task uuid. + * @param string $assignee The performer reference. + * @param string|null $actor The acting identity. + * @param string $action 'assign' or 'reassign' — the audited name. + * + * @return Task The task with its new assignee. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function assignInternal(string $uuid, string $assignee, ?string $actor, string $action): Task { + $task = $this->openTaskFor(verb: $action, uuid: $uuid, actor: $actor); + + if (trim($assignee) === '') { + throw new TaskValidationException(message: 'An assignment requires a non-empty assignee.'); + } + + return $this->transactional( + mutation: function () use ($task, $assignee, $actor, $action): Task { + $task->setAssignee($assignee); + $task->setOnBehalfOf(null); + $task->setMandate(null); + $this->applyState(task: $task, state: Task::STATE_ACTIVE, action: $action); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit(task: $persisted, action: $action, actor: $actor, reason: null); + + return $persisted; + } + ); + }//end assignInternal() + + /** + * Shared body of resolve and complete. + * + * @param string $verb The authorized verb name. + * @param string $uuid The task uuid. + * @param string $outcome The completion outcome. + * @param string|null $resultText Free-text result. + * @param string|null $comment Completion comment. + * @param string|null $actor The acting identity. + * + * @return Task The completed task. + * + * @throws TaskValidationException When a rejecting outcome has no comment. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function completeInternal( + string $verb, + string $uuid, + string $outcome, + ?string $resultText, + ?string $comment, + ?string $actor, + ): Task { + $task = $this->openTaskFor(verb: $verb, uuid: $uuid, actor: $actor); + + // Comment-mandatory-on-reject, BEFORE any mutation: refused means the + // task keeps its pre-call state. + if (TaskState::isRejectingOutcome(outcome: $outcome) === true && trim((string)$comment) === '') { + throw new TaskValidationException( + message: sprintf("Outcome '%s' rejects or returns the work, so a non-empty comment is mandatory.", $outcome) + ); + } + + return $this->transactional( + mutation: function () use ($task, $outcome, $resultText, $comment, $actor, $verb): Task { + $task->setOutcome($outcome); + $task->setResultText($resultText); + $task->setComment($comment); + $task->setCompletedAt(new DateTime()); + $task->setCompletedBy($actor); + $this->applyState(task: $task, state: Task::STATE_COMPLETED, action: $verb); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit(task: $persisted, action: $verb, actor: $actor, reason: $comment); + + return $persisted; + } + ); + }//end completeInternal() + + /** + * Resolve a verb's task: it must exist, the caller must be authorized, + * and it must be non-terminal — in that order, before any mutation. + * + * @param string $verb The verb being attempted. + * @param string $uuid The task uuid. + * @param string|null $actor The acting identity. + * + * @return Task The open, authorized task. + * + * @throws TaskConflictException When the task is already terminal — with + * the current state in the message. + * @throws TaskAccessDeniedException When authorization denies (audited). + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function openTaskFor(string $verb, string $uuid, ?string $actor): Task { + $task = $this->tasks->findByUuid(uuid: $uuid); + + // Authorization FIRST. The terminality conflict names the current + // state, and a caller with no relationship to the task must not be + // able to read that state out of a 409. + $this->authorizeOrRecord(verb: $verb, task: $task, actor: $actor); + + if ($task->isInTerminalState() === true) { + throw new TaskConflictException( + message: sprintf("Verb '%s' refused: task '%s' is already in terminal state '%s'.", $verb, $uuid, (string)$task->getState()) + ); + } + + return $task; + }//end openTaskFor() + + /** + * Authorize, and AUDIT the denial before rethrowing it. + * + * A denial mutates nothing, so its audit entry is appended outside any + * verb transaction; failing to record it is logged but does not convert + * the denial into anything else. + * + * @param string $verb The verb being attempted. + * @param Task $task The task acted on. + * @param string|null $actor The acting identity. + * + * @return void + * + * @throws TaskAccessDeniedException The original denial, always rethrown. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + private function authorizeOrRecord(string $verb, Task $task, ?string $actor): void { + try { + $this->authorization->assertMay(verb: $verb, task: $task, uid: $actor); + } catch (TaskAccessDeniedException $denial) { + try { + if ($task->getId() !== null) { + $entry = new TaskAudit(); + $entry->setTaskId((int)$task->getId()); + $entry->setAction($verb); + $entry->setStateAfter($task->getState()); + $entry->setActor($actor); + $entry->setPerformerType($task->getPerformerType()); + $entry->setOnBehalfOf($task->getOnBehalfOf()); + $entry->setMandate($task->getMandate()); + $entry->setReason($denial->getMessage()); + $entry->setAuthorized(false); + $this->audits->insert($entry); + } + } catch (Throwable $auditFailure) { + $this->logger->warning( + '[TaskService] Could not record an authorization denial: ' . $auditFailure->getMessage(), + ['task' => $task->getUuid(), 'verb' => $verb] + ); + } + + throw $denial; + }//end try + }//end authorizeOrRecord() + + /** + * Persist a state-changing mutation ONLY if the row is still open. + * + * Every verb's in-memory terminality check can be passed by two callers + * at once (complete/complete, complete/cancel). The mapper's conditional + * update lets exactly one through; the other gets a conflict here, so a + * second outcome never overwrites a first. + * + * @param Task $task The mutated task. + * + * @return Task The task as persisted. + * + * @throws TaskConflictException When the row was closed by someone else. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function persistOpen(Task $task): Task { + if ($this->tasks->updateIfOpen(task: $task) === false) { + throw new TaskConflictException( + message: sprintf("Task '%s' was closed concurrently; this change was not applied.", (string)$task->getUuid()) + ); + } + + return $task; + }//end persistOpen() + + /** + * THE one place `state` and `is_terminal` change — in the same statement. + * + * (`TaskMapper::claim()` is the deliberate second writer of `state`: its + * conditional UPDATE moves enabled→active, both non-terminal, so + * `is_terminal` is untouched and cannot drift there either.) + * + * @param Task $task The task to move. + * @param string $state The target CMMN state. + * @param string $action The NAMED transition action, recorded because + * ADR-031 notification triggers address the action. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + private function applyState(Task $task, string $state, string $action): void { + $task->setState($state); + $task->setIsTerminal(TaskState::isTerminal(state: $state)); + $task->setLastAction($action); + }//end applyState() + + /** + * Append a success audit entry, INSIDE the caller's transaction. + * + * Carries actor, performer type, on-behalf-of and mandate so "a human + * did this", "a model did this" and "a delegate did this for someone" + * stay distinguishable after the fact. + * + * @param Task $task The mutated task. + * @param string $action The named transition action. + * @param string|null $actor The acting identity. + * @param string|null $reason The reason or comment, when any. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + private function appendAudit(Task $task, string $action, ?string $actor, ?string $reason): void { + $entry = new TaskAudit(); + $entry->setTaskId((int)$task->getId()); + $entry->setAction($action); + $entry->setStateAfter($task->getState()); + $entry->setActor($actor); + $entry->setPerformerType($task->getPerformerType()); + $entry->setOnBehalfOf($task->getOnBehalfOf()); + $entry->setMandate($task->getMandate()); + $entry->setReason($reason); + $entry->setAuthorized(true); + $this->audits->insert($entry); + }//end appendAudit() + + /** + * Run a mutation and its audit in ONE transaction. + * + * The rollback is what makes "a completed task without its audit entry" + * unreachable: an audit-write failure unwinds the completion with it. + * + * @param callable(): Task $mutation The mutation to run. + * + * @return Task The mutation's result. + * + * @throws Throwable Whatever the mutation threw, after rollback. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-task-audit-is-append-only-and-names-the-performer-type + */ + private function transactional(callable $mutation): Task { + $this->db->beginTransaction(); + try { + $result = $mutation(); + $this->db->commit(); + + return $result; + } catch (Throwable $failure) { + $this->db->rollBack(); + throw $failure; + } + }//end transactional() + + /** + * Rewrite the candidate INDEX rows from the task's JSON record. + * + * The other half of the one-write-path rule: called only inside a + * transaction that also wrote the JSON columns. + * + * @param Task $task The persisted task. + * + * @return void + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-performer-model-spans-people-groups-agents-and-workers + */ + private function rewriteCandidateIndex(Task $task): void { + $rows = []; + foreach (($task->getCandidateUsers() ?? []) as $uid) { + $rows[] = [ + 'kind' => 'user', + 'ref' => (string)$uid, + ]; + } + + foreach (($task->getCandidateGroups() ?? []) as $groupId) { + $rows[] = [ + 'kind' => 'group', + 'ref' => (string)$groupId, + ]; + } + + $role = trim((string)$task->getCandidateRole()); + if ($role !== '') { + $rows[] = [ + 'kind' => 'role', + 'ref' => $role, + ]; + } + + $this->candidates->replaceForTask(taskId: (int)$task->getId(), candidates: $rows); + }//end rewriteCandidateIndex() +}//end class diff --git a/lib/Service/Task/TaskState.php b/lib/Service/Task/TaskState.php new file mode 100644 index 0000000000..dcafc29a6d --- /dev/null +++ b/lib/Service/Task/TaskState.php @@ -0,0 +1,187 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\TaskValidationException; + +/** + * Resolves any fleet status vocabulary onto the six CMMN states. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ +final class TaskState { + + /** + * The published legacy mapping: value => [state, outcome]. + * + * An outcome of null means the legacy value carried no distinction worth + * preserving beyond the state itself. The six canonical states map to + * themselves with no outcome, so canonical input passes through + * unchanged. + * + * @var array + */ + private const LEGACY = [ + // Canonical: the six CMMN states pass through. + Task::STATE_AVAILABLE => [Task::STATE_AVAILABLE, null], + Task::STATE_ENABLED => [Task::STATE_ENABLED, null], + Task::STATE_ACTIVE => [Task::STATE_ACTIVE, null], + Task::STATE_COMPLETED => [Task::STATE_COMPLETED, null], + Task::STATE_TERMINATED => [Task::STATE_TERMINATED, null], + Task::STATE_DISABLED => [Task::STATE_DISABLED, null], + // Not-yet-started spellings. + 'open' => [Task::STATE_ENABLED, null], + 'pending' => [Task::STATE_AVAILABLE, null], + 'todo' => [Task::STATE_ENABLED, null], + 'reopen' => [Task::STATE_ENABLED, 'reopened'], + // The four spellings of in-progress (with `active` above making four). + 'in_progress' => [Task::STATE_ACTIVE, null], + 'in-progress' => [Task::STATE_ACTIVE, null], + 'in-execution' => [Task::STATE_ACTIVE, null], + // Blocked is active work that cannot proceed; the WHY belongs on + // `blocked_reason`, not in the state vocabulary. + 'blocked' => [Task::STATE_ACTIVE, 'blocked'], + // Completions: one state, distinct outcomes. + 'done' => [Task::STATE_COMPLETED, 'done'], + 'resolved' => [Task::STATE_COMPLETED, 'resolved'], + 'approved' => [Task::STATE_COMPLETED, 'approved'], + 'rejected' => [Task::STATE_COMPLETED, 'rejected'], + // Deliberate non-performance: CMMN `disabled`. + 'waived' => [Task::STATE_DISABLED, 'waived'], + 'skipped' => [Task::STATE_DISABLED, 'skipped'], + // External terminations: one state, distinct outcomes. + 'cancelled' => [Task::STATE_TERMINATED, 'cancelled'], + 'expired' => [Task::STATE_TERMINATED, 'expired'], + 'error' => [Task::STATE_TERMINATED, 'error'], + 'dead_letter' => [Task::STATE_TERMINATED, 'dead_letter'], + ]; + + /** + * Outcomes that reject or return the work, making a comment MANDATORY. + * + * @var array + */ + public const REJECTING_OUTCOMES = ['rejected', 'returned', 'declined', 'denied']; + + /** + * Resolve a status value — canonical or legacy — onto a CMMN state. + * + * A migration that knows its SOURCE vocabulary passes it: a value the + * source's own enum does not define is then refused even when the fleet + * mapping happens to know the word. That is the procest defect made + * loud — `CreateTaskHandler.php:76` writes `status:'open'` into a schema + * whose enum has no `open`; imported against that declared vocabulary, + * the write fails naming the value instead of laundering it. + * + * @param string $value The incoming status value. + * @param array|null $sourceVocabulary The source's own + * declared enum, when the caller has one; + * null accepts the full fleet mapping. + * + * @return array{state: string, outcome: string|null} The state, and the + * outcome distinction the collapse preserved (null when none). + * + * @throws TaskValidationException When the value is in no known + * vocabulary, or not in the declared source vocabulary. Never + * coerced to a default. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + public static function normalise(string $value, ?array $sourceVocabulary = null): array { + $trimmed = trim($value); + + if ($sourceVocabulary !== null && in_array($trimmed, $sourceVocabulary, true) === false) { + throw new TaskValidationException( + message: sprintf("Unmapped task status '%s': the declared source vocabulary does not define it, so it is refused, not defaulted.", $trimmed) + ); + } + + if (array_key_exists($trimmed, self::LEGACY) === false) { + throw new TaskValidationException( + message: sprintf("Unmapped task status '%s': it is in no known fleet vocabulary and is refused, not defaulted.", $trimmed) + ); + } + + [$state, $outcome] = self::LEGACY[$trimmed]; + + return [ + 'state' => $state, + 'outcome' => $outcome, + ]; + }//end normalise() + + /** + * Whether a state is terminal. + * + * @param string $state One of the six CMMN states. + * + * @return boolean True for completed, terminated and disabled. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + public static function isTerminal(string $state): bool { + return in_array($state, Task::TERMINAL_STATES, true); + }//end isTerminal() + + /** + * Whether an outcome rejects or returns the work. + * + * @param string|null $outcome The outcome, when any. + * + * @return boolean True when a non-empty comment is mandatory. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + public static function isRejectingOutcome(?string $outcome): bool { + if ($outcome === null) { + return false; + } + + return in_array(strtolower(trim($outcome)), self::REJECTING_OUTCOMES, true); + }//end isRejectingOutcome() + + /** + * The published mapping, for documentation surfaces and tests. + * + * @return array value => [state, outcome]. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + */ + public static function mapping(): array { + return self::LEGACY; + }//end mapping() +}//end class diff --git a/lib/Service/Task/TaskTemporalProjection.php b/lib/Service/Task/TaskTemporalProjection.php new file mode 100644 index 0000000000..8df19348cc --- /dev/null +++ b/lib/Service/Task/TaskTemporalProjection.php @@ -0,0 +1,106 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-overdue-is-derived-and-must-not-be-stored + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use DateTime; +use DateTimeInterface; +use OCA\OpenRegister\Db\Task; + +/** + * Computes overdue, days-until-due and days-overdue from the stored deadlines. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-overdue-is-derived-and-must-not-be-stored + */ +final class TaskTemporalProjection { + + /** + * The clock instant the derivation runs against. + * + * @return DateTime Now. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-overdue-is-derived-and-must-not-be-stored + */ + public function now(): DateTime { + return new DateTime(); + }//end now() + + /** + * The temporal projection of one task. + * + * The effective deadline is `due_at`, or `expires_at` where only that is + * set. Passing `due_at` changes ONLY what this projection reports — the + * task's state is untouched by the clock (due_at advises; the enforcing + * sweep on expires_at belongs to flow-business-timers). + * + * @param Task $task The task to project. + * @param DateTimeInterface|null $now The clock, injectable for tests; + * null means the real clock. + * + * @return array{overdue: bool, daysUntilDue: int|null, daysOverdue: int|null} The projection. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-overdue-is-derived-and-must-not-be-stored + */ + public function project(Task $task, ?DateTimeInterface $now = null): array { + $now ??= $this->now(); + $deadline = ($task->getDueAt() ?? $task->getExpiresAt()); + + if ($deadline === null) { + return [ + 'overdue' => false, + 'daysUntilDue' => null, + 'daysOverdue' => null, + ]; + } + + $overdue = ($deadline < $now); + $seconds = ($deadline->getTimestamp() - $now->getTimestamp()); + $days = intdiv(abs($seconds), 86400); + + if ($overdue === true) { + return [ + 'overdue' => true, + 'daysUntilDue' => null, + 'daysOverdue' => $days, + ]; + } + + return [ + 'overdue' => false, + 'daysUntilDue' => $days, + 'daysOverdue' => null, + ]; + }//end project() +}//end class diff --git a/openspec/changes/flow-task-entity/tasks.md b/openspec/changes/flow-task-entity/tasks.md index bf863c6a35..3ae5d1e399 100644 --- a/openspec/changes/flow-task-entity/tasks.md +++ b/openspec/changes/flow-task-entity/tasks.md @@ -2,13 +2,13 @@ ## 1. Storage -- [ ] 1.1 Migration creating `openregister_tasks`, +- [x] 1.1 Migration creating `openregister_tasks`, `openregister_task_candidates`, `openregister_task_relations` and `openregister_task_audit` with the columns and indexes in design.md — Data model. Additive only: no existing table altered, no data backfilled. Verify `openregister_tasks` has NO `overdue`, `days_until_due` or `days_overdue` column. -- [ ] 1.2 Entities + mappers under `lib/Db/`: `Task`/`TaskMapper`, +- [x] 1.2 Entities + mappers under `lib/Db/`: `Task`/`TaskMapper`, `TaskCandidate`/`TaskCandidateMapper`, `TaskRelation`/`TaskRelationMapper`, `TaskAudit`/`TaskAuditMapper`. Follow `lib/Db/FlowRun.php` conventions (docblock `@method` block, @@ -18,13 +18,13 @@ ## 2. Normalisation at the boundary -- [ ] 2.1 Lifecycle: the six CMMN states, the published legacy→state +- [x] 2.1 Lifecycle: the six CMMN states, the published legacy→state mapping covering at minimum the 18 values named in the spec, `is_terminal` written in the same statement as `state`, and the collapsed distinctions (`done`/`approved`, `cancelled`/`terminated`) preserved on `outcome`. An unmapped value is REFUSED naming the value — never coerced to a default. -- [ ] 2.2 Field normalisation and validation: priority across the four +- [x] 2.2 Field normalisation and validation: priority across the four fleet scales onto `low|normal|high|urgent` (off-scale refused, naming the value); `expires_at` earlier than `due_at` refused; `title` synthesis from action + subject computed on read and NEVER @@ -32,12 +32,12 @@ ## 3. Authorization (before any mutation) -- [ ] 3.1 `lib/Service/Task/TaskAuthorizationService.php` — per-verb +- [x] 3.1 `lib/Service/Task/TaskAuthorizationService.php` — per-verb decisions per the spec, evaluated before mutation, DENYING on indeterminate (unresolvable role, unavailable group backend, unknown performer type). No nullable "service unavailable" return that a caller can read as "check skipped". -- [ ] 3.2 Performer resolution: `user|group|agent|worker`, candidate pool +- [x] 3.2 Performer resolution: `user|group|agent|worker`, candidate pool (users / groups / role), and the five routing strategies `single-role|or-set|hierarchical|round-robin|least-loaded` plus `routing_fallback`. A strategy resolving to nobody with no fallback @@ -49,29 +49,29 @@ ## 4. TaskService -- [ ] 4.1 `lib/Service/Task/TaskService.php` skeleton + `create`, `offer`, +- [x] 4.1 `lib/Service/Task/TaskService.php` skeleton + `create`, `offer`, `assign`, `reassign`. One write path maintains the `candidate_users`/`candidate_groups` JSON and the `openregister_task_candidates` index rows inside one transaction. -- [ ] 4.2 `claim` / `unclaim` — `claim` is a conditional update (assign IF +- [x] 4.2 `claim` / `unclaim` — `claim` is a conditional update (assign IF unassigned) so concurrent claims yield exactly one assignee and a conflict for the loser, never a silent overwrite. -- [ ] 4.3 `resolve` / `complete` / `cancel` — a non-empty `comment` is +- [x] 4.3 `resolve` / `complete` / `cancel` — a non-empty `comment` is MANDATORY on a rejecting or returning outcome; any verb against an already-terminal task is refused with a conflict naming the current state. -- [ ] 4.4 Template freeze and checklist: `template_id` + +- [x] 4.4 Template freeze and checklist: `template_id` + `template_version` + `template_snapshot` written at creation, all later evaluation reading the snapshot; checklist as a typed `{id,label,description,checked}` array with per-item addressing by id. -- [ ] 4.5 Audit append written in the SAME transaction as the mutation it +- [x] 4.5 Audit append written in the SAME transaction as the mutation it records, for successes AND denials (`authorized: false`), carrying actor, `performer_type`, `on_behalf_of`, `mandate` and reason. ## 5. Cancellation propagation -- [ ] 5.1 Listener terminating every non-terminal task carrying a +- [x] 5.1 Listener terminating every non-terminal task carrying a `run_uuid` when that run reaches a terminal status (`completed`, `stopped`, `dead_letter`, `failed` — `lib/Db/FlowRun.php` STATUS constants), plus an explicit service call for a task made moot by a @@ -82,19 +82,19 @@ ## 6. Inbox -- [ ] 6.1 `lib/Service/Task/TaskInboxService.php` — assigned-to-me, +- [x] 6.1 `lib/Service/Task/TaskInboxService.php` — assigned-to-me, unclaimed-in-my-pools, watched-by-me, and by-object queries joined to the subject object for register/schema/uuid/title. Filtering, sorting, pagination AND the total run in the datastore; visibility is part of the WHERE clause, never a post-filter over a wider result. -- [ ] 6.2 Derived-only temporal projection: `overdue`, `days_until_due`, +- [x] 6.2 Derived-only temporal projection: `overdue`, `days_until_due`, `days_overdue` computed from `due_at`/`expires_at` against the clock by ONE function that backs the API projection and the inbox filter alike. Nothing writes them anywhere. ## 7. API -- [ ] 7.1 `lib/Controller/TaskController.php` + `appinfo/routes.php` +- [x] 7.1 `lib/Controller/TaskController.php` + `appinfo/routes.php` entries for the lifecycle verbs and the inbox queries. Every method declares its auth posture attribute, and every method's actual authorization is `TaskAuthorizationService` — the attribute is never @@ -103,7 +103,7 @@ ## 8. Seed data -- [ ] 8.1 Install the five seed groups from design.md — Seed Data +- [x] 8.1 Install the five seed groups from design.md — Seed Data (municipal pooled permit check with no run; consultancy delegated approval with enforcing expiry on a run; travel-agency agent task with a typed checklist; two terminal tasks including one terminated @@ -112,22 +112,22 @@ ## 9. Tests -- [ ] 9.1 Table-driven unit tests for the legacy status mapping and the +- [x] 9.1 Table-driven unit tests for the legacy status mapping and the priority normalisation, each including the live fleet defects as cases: `'open'` (`procest/lib/Service/Transitions/CreateTaskHandler.php:76`) and `"normaal"` (pipelinq `task.priority`) MUST both be refused. -- [ ] 9.2 Authorization and concurrency tests: a stranger denied on every +- [x] 9.2 Authorization and concurrency tests: a stranger denied on every verb; the two-claim race producing one assignee and one conflict; an unresolvable role denying rather than passing; a rejection without a comment refused; an injected audit-write failure leaving the task NOT completed. -- [ ] 9.3 Inbox and derivation tests: clock-controlled overdue with a +- [x] 9.3 Inbox and derivation tests: clock-controlled overdue with a byte-identical row before and after; a pooled task invisible to a non-member including in the total; datastore pagination returning 25 of 120 with a correct total; `run_uuid`-null task surviving unrelated run terminations. -- [ ] 9.4 Playwright coverage for the two `@e2e`-marked scenarios in +- [x] 9.4 Playwright coverage for the two `@e2e`-marked scenarios in `specs/flow-tasks/spec.md`: a stranger refused on the task detail route, and the inbox route returning tasks with subject context. diff --git a/tests/Unit/Controller/TaskControllerTest.php b/tests/Unit/Controller/TaskControllerTest.php new file mode 100644 index 0000000000..0de395322f --- /dev/null +++ b/tests/Unit/Controller/TaskControllerTest.php @@ -0,0 +1,526 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use OCA\OpenRegister\Controller\TaskController; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * HTTP status and body translation for every task route. + * + * @covers \OCA\OpenRegister\Controller\TaskController + * @covers \OCA\OpenRegister\Db\TaskInboxCriteria + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Service\Task\TaskTemporalProjection + * @covers \OCA\OpenRegister\Exception\TaskValidationException + * @covers \OCA\OpenRegister\Exception\TaskAccessDeniedException + * @covers \OCA\OpenRegister\Exception\TaskConflictException + */ +class TaskControllerTest extends TestCase { + + /** + * The lifecycle service, mocked. + * + * @var TaskService&MockObject + */ + private TaskService&MockObject $tasks; + + /** + * The inbox service, mocked. + * + * @var TaskInboxService&MockObject + */ + private TaskInboxService&MockObject $inbox; + + /** + * Read visibility, mocked. + * + * @var TaskAuthorizationService&MockObject + */ + private TaskAuthorizationService&MockObject $authorization; + + /** + * The log, mocked. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * The controller under test. + * + * @var TaskController + */ + private TaskController $controller; + + /** + * A controller with a session for `alice` and every collaborator mocked. + * + * @return void + */ + protected function setUp(): void { + $this->tasks = $this->createMock(originalClassName: TaskService::class); + $this->inbox = $this->createMock(originalClassName: TaskInboxService::class); + $this->authorization = $this->createMock(originalClassName: TaskAuthorizationService::class); + $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn('alice'); + $session = $this->createMock(originalClassName: IUserSession::class); + $session->method('getUser')->willReturn($user); + + $request = $this->createMock(originalClassName: IRequest::class); + $request->method('getParams')->willReturn(['title' => 'x', 'uuid' => 't-1', '_route' => 'r']); + + $groups = $this->createMock(originalClassName: IGroupManager::class); + $groups->method('getUserGroupIds')->willReturn(['reviewers']); + $groups->method('isAdmin')->willReturn(false); + + // The inbox row is whatever the service returns, plus a marker. + $this->inbox->method('row')->willReturnCallback( + static fn (Task $task): array => ['uuid' => $task->getUuid(), 'row' => true] + ); + + $this->controller = new TaskController( + appName: 'openregister', + request: $request, + tasks: $this->tasks, + inbox: $this->inbox, + authorization: $this->authorization, + temporal: new TaskTemporalProjection(), + userSession: $session, + logger: $this->logger, + groupManager: $groups + ); + }//end setUp() + + /** + * A task as the service returns it. + * + * @return Task The task. + */ + private function task(): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setState(Task::STATE_ACTIVE); + + return $task; + }//end task() + + /** + * GET /api/flow-tasks: the inbox page, built from the session's identity. + * + * @return void + */ + public function testIndexReturnsTheInboxPageForTheSession(): void { + $this->inbox->expects($this->once())->method('inbox')->willReturnCallback( + function (TaskInboxCriteria $criteria, int $limit, int $offset): array { + $this->assertSame('alice', $criteria->uid); + $this->assertSame(['reviewers'], $criteria->groupIds); + $this->assertFalse($criteria->isAdmin); + $this->assertSame(TaskInboxCriteria::SORT_DUE, $criteria->sort); + $this->assertTrue($criteria->sortDescending); + $this->assertSame(10, $limit); + + return ['results' => [], 'total' => 0, 'limit' => $limit, 'offset' => $offset]; + } + ); + + $response = $this->controller->index(sort: '-dueAt', limit: 10); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(0, $response->getData()['total']); + }//end testIndexReturnsTheInboxPageForTheSession() + + /** + * GET /api/flow-tasks/{uuid}: 404 for the invisible, 200 for the visible. + * + * @return void + */ + public function testShowIs404ForAnInvisibleTaskAnd200ForAVisibleOne(): void { + $this->tasks->method('get')->willReturn($this->task()); + $this->authorization->method('mayRead')->willReturnOnConsecutiveCalls(false, true); + + $hidden = $this->controller->show(uuid: 't-1'); + $this->assertSame(Http::STATUS_NOT_FOUND, $hidden->getStatus()); + $this->assertSame(['error' => 'No such task'], $hidden->getData()); + + $shown = $this->controller->show(uuid: 't-1'); + $this->assertSame(Http::STATUS_OK, $shown->getStatus()); + $this->assertSame('t-1', $shown->getData()['uuid']); + }//end testShowIs404ForAnInvisibleTaskAnd200ForAVisibleOne() + + /** + * GET /api/flow-tasks/{uuid}: an absent uuid reads exactly like an + * invisible one. + * + * @return void + */ + public function testShowIs404ForAnAbsentTask(): void { + $this->tasks->method('get')->willThrowException(new DoesNotExistException('nope')); + + $response = $this->controller->show(uuid: 'ghost'); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertSame(['error' => 'No such task'], $response->getData()); + }//end testShowIs404ForAnAbsentTask() + + /** + * GET /api/flow-tasks/{uuid}/audit: the trail, visibility-checked. + * + * @return void + */ + public function testAuditReturnsTheTrailForAVisibleTask(): void { + $this->tasks->method('get')->willReturn($this->task()); + $this->authorization->method('mayRead')->willReturn(true); + $this->tasks->expects($this->once())->method('auditTrail')->willReturn([]); + + $response = $this->controller->audit(uuid: 't-1'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['results' => []], $response->getData()); + }//end testAuditReturnsTheTrailForAVisibleTask() + + /** + * POST /api/flow-tasks: 201 with the row; the body is passed without the + * route bookkeeping. + * + * @return void + */ + public function testCreateReturns201WithTheRow(): void { + $this->tasks->expects($this->once())->method('create')->willReturnCallback( + function (array $data, ?string $actor): Task { + $this->assertSame(['title' => 'x'], $data); + $this->assertSame('alice', $actor); + + return $this->task(); + } + ); + + $response = $this->controller->create(); + + $this->assertSame(Http::STATUS_CREATED, $response->getStatus()); + $this->assertTrue($response->getData()['row']); + }//end testCreateReturns201WithTheRow() + + /** + * A refused value is a 400 naming it. + * + * @return void + */ + public function testValidationIs400(): void { + $this->tasks->method('create')->willThrowException(new TaskValidationException("Priority 'normaal' is refused.")); + + $response = $this->controller->create(); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertStringContainsString('normaal', $response->getData()['error']); + }//end testValidationIs400() + + /** + * A DENIED VERB IS 404 FOR A CALLER WHO MAY NOT READ THE TASK: a + * stranger probing complete learns nothing, not even that the uuid exists. + * + * @return void + */ + public function testADeniedVerbIs404WhenTheCallerMayNotReadTheTask(): void { + $this->tasks->method('complete')->willThrowException(new TaskAccessDeniedException('denied')); + $this->tasks->method('get')->willReturn($this->task()); + $this->authorization->method('mayRead')->willReturn(false); + + $response = $this->controller->complete(uuid: 't-1', outcome: 'approved'); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertSame(['error' => 'No such task'], $response->getData()); + }//end testADeniedVerbIs404WhenTheCallerMayNotReadTheTask() + + /** + * A denied verb is 403 for a caller who MAY read the task (a watcher + * trying to act): the denial reason is theirs to see. + * + * @return void + */ + public function testADeniedVerbIs403WhenTheCallerMayReadTheTask(): void { + $this->tasks->method('complete')->willThrowException(new TaskAccessDeniedException("Verb 'complete' denied: only the current assignee may perform it.")); + $this->tasks->method('get')->willReturn($this->task()); + $this->authorization->method('mayRead')->willReturn(true); + + $response = $this->controller->complete(uuid: 't-1', outcome: 'approved'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + $this->assertStringContainsString('assignee', $response->getData()['error']); + }//end testADeniedVerbIs403WhenTheCallerMayReadTheTask() + + /** + * A conflict is 409 carrying the message (the current state, per spec). + * + * @return void + */ + public function testAConflictIs409(): void { + $this->tasks->method('claim')->willThrowException(new TaskConflictException("already in terminal state 'completed'")); + + $response = $this->controller->claim(uuid: 't-1'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertStringContainsString('completed', $response->getData()['error']); + }//end testAConflictIs409() + + /** + * AN UNEXPECTED FAILURE IS A GENERIC 500: the exception text (which for + * a database failure carries SQL and parameters) goes to the log, never + * to the wire. + * + * @return void + */ + public function testAnUnexpectedFailureIsAGeneric500ThatIsLogged(): void { + $this->tasks->method('cancel')->willThrowException( + new RuntimeException("SQLSTATE[42P01]: relation \"oc_openregister_tasks\" does not exist: UPDATE ... WHERE id = 7") + ); + $this->logger->expects($this->once())->method('error')->with( + $this->stringContains('SQLSTATE'), + $this->arrayHasKey('exception') + ); + + $response = $this->controller->cancel(uuid: 't-1', reason: 'x'); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertStringNotContainsString('SQLSTATE', $response->getData()['error']); + $this->assertStringNotContainsString('UPDATE', $response->getData()['error']); + }//end testAnUnexpectedFailureIsAGeneric500ThatIsLogged() + + /** + * Every lifecycle verb route reaches its service verb and answers 200 + * with the row: the wire contract of the eleven verb endpoints. + * + * @return void + */ + public function testEveryVerbRouteReachesItsServiceVerb(): void { + foreach (['offer', 'claim', 'unclaim', 'assign', 'reassign', 'delegate', 'resolve', 'complete', 'cancel', 'checkChecklistItem'] as $verb) { + $this->tasks->expects($this->once())->method($verb)->willReturn($this->task()); + } + + $responses = [ + $this->controller->offer(uuid: 't-1'), + $this->controller->claim(uuid: 't-1'), + $this->controller->unclaim(uuid: 't-1'), + $this->controller->assign(uuid: 't-1', assignee: 'bob'), + $this->controller->reassign(uuid: 't-1', assignee: 'carol'), + $this->controller->delegate(uuid: 't-1', delegate: 'dora', mandate: 'Volmacht'), + $this->controller->resolve(uuid: 't-1', resultText: 'ok'), + $this->controller->complete(uuid: 't-1', outcome: 'approved'), + $this->controller->cancel(uuid: 't-1', reason: 'moot'), + $this->controller->checkItem(uuid: 't-1', itemId: 'c1', checked: 'false'), + ]; + + foreach ($responses as $response) { + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame('t-1', $response->getData()['uuid']); + } + }//end testEveryVerbRouteReachesItsServiceVerb() + + /** + * Every inbox filter reaches the criteria: states split on commas, + * isTerminal and overdue parse as booleans, priority and object pass + * through, and the overdue clock instant is set only when asked. + * + * @return void + */ + public function testIndexPassesEveryFilterIntoTheCriteria(): void { + $this->inbox->expects($this->once())->method('inbox')->willReturnCallback( + function (TaskInboxCriteria $criteria): array { + $this->assertSame(TaskInboxCriteria::SCOPE_POOLED, $criteria->scope); + $this->assertSame(['active', 'enabled'], $criteria->states); + $this->assertFalse($criteria->isTerminal); + $this->assertSame('high', $criteria->priority); + $this->assertSame('obj-1', $criteria->objectUuid); + $this->assertNotNull($criteria->overdueAt); + $this->assertSame(TaskInboxCriteria::SORT_PRIORITY, $criteria->sort); + $this->assertFalse($criteria->sortDescending); + + return ['results' => [], 'total' => 0, 'limit' => 25, 'offset' => 0]; + } + ); + + $response = $this->controller->index( + scope: TaskInboxCriteria::SCOPE_POOLED, + state: 'active, enabled,', + isTerminal: 'false', + priority: 'high', + objectUuid: 'obj-1', + overdue: 'true', + sort: 'priority' + ); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + }//end testIndexPassesEveryFilterIntoTheCriteria() + + /** + * With overdue unset the criteria carry no clock instant. + * + * @return void + */ + public function testIndexWithoutOverdueCarriesNoClock(): void { + $this->inbox->expects($this->once())->method('inbox')->willReturnCallback( + function (TaskInboxCriteria $criteria): array { + $this->assertNull($criteria->overdueAt); + $this->assertNull($criteria->isTerminal); + + return ['results' => [], 'total' => 0, 'limit' => 25, 'offset' => 0]; + } + ); + + $this->controller->index(overdue: 'false'); + }//end testIndexWithoutOverdueCarriesNoClock() + + /** + * The audit route answers 404 for absent AND for invisible tasks. + * + * @return void + */ + public function testAuditIs404ForAbsentAndInvisibleTasks(): void { + $this->tasks->method('get')->willReturnOnConsecutiveCalls( + $this->throwException(new DoesNotExistException('nope')), + $this->task() + ); + $this->authorization->method('mayRead')->willReturn(false); + + $this->assertSame(Http::STATUS_NOT_FOUND, $this->controller->audit(uuid: 'ghost')->getStatus()); + $this->assertSame(Http::STATUS_NOT_FOUND, $this->controller->audit(uuid: 't-1')->getStatus()); + }//end testAuditIs404ForAbsentAndInvisibleTasks() + + /** + * checkItem reads its flag as a boolean from whatever the route passed. + * + * @return void + */ + public function testCheckItemParsesItsFlag(): void { + $seen = []; + $this->tasks->method('checkChecklistItem')->willReturnCallback( + function (string $uuid, string $itemId, bool $checked) use (&$seen): Task { + $seen[] = $checked; + + return $this->task(); + } + ); + + $this->controller->checkItem(uuid: 't-1', itemId: 'c1', checked: 'false'); + $this->controller->checkItem(uuid: 't-1', itemId: 'c1', checked: '1'); + $this->controller->checkItem(uuid: 't-1', itemId: 'c1'); + + $this->assertSame([false, true, true], $seen); + }//end testCheckItemParsesItsFlag() + + /** + * A verb refused for an ABSENT task is 404 (DoesNotExist from the service). + * + * @return void + */ + public function testAVerbOnAnAbsentTaskIs404(): void { + $this->tasks->method('unclaim')->willThrowException(new DoesNotExistException('nope')); + + $response = $this->controller->unclaim(uuid: 'ghost'); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + }//end testAVerbOnAnAbsentTaskIs404() + + /** + * A failing group backend scopes: no groups, not admin, and the request + * still answers. + * + * @return void + */ + public function testAFailingGroupBackendScopesRatherThanWidens(): void { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn('alice'); + $session = $this->createMock(originalClassName: IUserSession::class); + $session->method('getUser')->willReturn($user); + $groups = $this->createMock(originalClassName: IGroupManager::class); + $groups->method('getUserGroupIds')->willThrowException(new RuntimeException('ldap down')); + $groups->method('isAdmin')->willThrowException(new RuntimeException('ldap down')); + $this->inbox->expects($this->once())->method('inbox')->willReturnCallback( + function (TaskInboxCriteria $criteria): array { + $this->assertSame([], $criteria->groupIds); + $this->assertFalse($criteria->isAdmin); + + return ['results' => [], 'total' => 0, 'limit' => 25, 'offset' => 0]; + } + ); + $controller = new TaskController( + appName: 'openregister', + request: $this->createMock(originalClassName: IRequest::class), + tasks: $this->tasks, + inbox: $this->inbox, + authorization: $this->authorization, + temporal: new TaskTemporalProjection(), + userSession: $session, + logger: $this->logger, + groupManager: $groups + ); + + $this->assertSame(Http::STATUS_OK, $controller->index()->getStatus()); + }//end testAFailingGroupBackendScopesRatherThanWidens() + + /** + * Without a session the inbox is 401, not an empty 200. + * + * @return void + */ + public function testIndexWithoutASessionIs401(): void { + $session = $this->createMock(originalClassName: IUserSession::class); + $session->method('getUser')->willReturn(null); + $controller = new TaskController( + appName: 'openregister', + request: $this->createMock(originalClassName: IRequest::class), + tasks: $this->tasks, + inbox: $this->inbox, + authorization: $this->authorization, + temporal: new TaskTemporalProjection(), + userSession: $session + ); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $controller->index()->getStatus()); + }//end testIndexWithoutASessionIs401() +}//end class diff --git a/tests/Unit/Db/FluentQueryBuilderTrait.php b/tests/Unit/Db/FluentQueryBuilderTrait.php new file mode 100644 index 0000000000..f929d8ad7c --- /dev/null +++ b/tests/Unit/Db/FluentQueryBuilderTrait.php @@ -0,0 +1,229 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use Doctrine\DBAL\Platforms\MySQLPlatform; +use OCP\DB\IResult; +use OCP\DB\QueryBuilder\ICompositeExpression; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IFunctionBuilder; +use OCP\DB\QueryBuilder\IParameter; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\QueryBuilder\IQueryFunction; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; + +/** + * Builds a recording, fluent query builder and a connection that serves it. + */ +trait FluentQueryBuilderTrait { + + /** + * Every raw SQL fragment handed to createFunction(), in order. + * + * @var array + */ + private array $functions = []; + + /** + * Every (method, first argument) the builder saw, in order. + * + * @var array + */ + private array $calls = []; + + /** + * A connection whose getQueryBuilder() always hands back one fluent builder. + * + * @param int $affectedRows What executeStatement() reports. + * @param array> $rows What executeQuery() yields. + * + * @return IDBConnection&MockObject The connection. + */ + private function connectionWith(int $affectedRows = 1, array $rows = []): IDBConnection&MockObject { + $db = $this->createMock(originalClassName: IDBConnection::class); + $platform = $this->createMock(originalClassName: MySQLPlatform::class); + $platform->method('quoteIdentifier')->willReturnCallback(static fn (string $s): string => '`' . $s . '`'); + $db->method('getDatabasePlatform')->willReturn($platform); + $db->method('escapeLikeParameter')->willReturnArgument(0); + $db->method('getQueryBuilder')->willReturnCallback( + fn (): IQueryBuilder => $this->fluentBuilder(affectedRows: $affectedRows, rows: $rows) + ); + + return $db; + }//end connectionWith() + + /** + * The fluent builder itself. + * + * @param int $affectedRows What executeStatement() reports. + * @param array> $rows What executeQuery() yields. + * + * @return IQueryBuilder&MockObject The builder. + */ + private function fluentBuilder(int $affectedRows, array $rows): IQueryBuilder&MockObject { + $qb = $this->createMock(originalClassName: IQueryBuilder::class); + + $record = function (string $method) use ($qb) { + return function (mixed ...$args) use ($qb, $method) { + $this->calls[] = [$method, ($args[0] ?? null)]; + + return $qb; + }; + }; + foreach (['select', 'selectAlias', 'from', 'where', 'andWhere', 'update', 'insert', 'delete', 'set', 'setValue', 'orderBy', 'addOrderBy', 'groupBy', 'setMaxResults', 'setFirstResult'] as $method) { + $qb->method($method)->willReturnCallback($record($method)); + } + + // PHPUnit refuses to configure __toString on a mock, so the three + // value types are tiny real implementations instead. + $qb->method('createNamedParameter')->willReturn(self::stringable(IParameter::class, ':p')); + + $qb->method('createFunction')->willReturnCallback( + function (string $sql): IQueryFunction { + $this->functions[] = $sql; + + return self::stringable(IQueryFunction::class, $sql); + } + ); + + $composite = new class () implements ICompositeExpression { + /** + * @param array $parts Ignored. + */ + public function addMultiple(array $parts = []): ICompositeExpression { + return $this; + } + + /** + * @param mixed $part Ignored. + */ + public function add($part): ICompositeExpression { + return $this; + } + + public function count(): int { + return 1; + } + + public function getType(): string { + return 'OR'; + } + + public function __toString(): string { + return '(composite)'; + } + }; + $expr = $this->createMock(originalClassName: IExpressionBuilder::class); + foreach (['eq', 'neq', 'lt', 'gt', 'in', 'like', 'isNull', 'isNotNull'] as $method) { + $expr->method($method)->willReturnCallback( + function (mixed ...$args) use ($method): string { + $this->calls[] = ['expr.' . $method, ($args[0] ?? null)]; + + return $method . '(' . (string)($args[0] ?? '') . ')'; + } + ); + } + + $expr->method('orX')->willReturn($composite); + $expr->method('andX')->willReturn($composite); + $qb->method('expr')->willReturn($expr); + + $function = self::stringable(IQueryFunction::class, 'fn()'); + $func = $this->createMock(originalClassName: IFunctionBuilder::class); + $func->method('count')->willReturn($function); + $func->method('max')->willReturn($function); + $qb->method('func')->willReturn($func); + + $qb->method('executeStatement')->willReturn($affectedRows); + $qb->method('getLastInsertId')->willReturn(77); + + $result = $this->createMock(originalClassName: IResult::class); + $queue = $rows; + $result->method('fetch')->willReturnCallback( + static function () use (&$queue): array|false { + if ($queue === []) { + return false; + } + + return array_shift($queue); + } + ); + $result->method('fetchAll')->willReturn($rows); + $qb->method('executeQuery')->willReturn($result); + $qb->method('getTableName')->willReturn('openregister_tasks'); + + return $qb; + }//end fluentBuilder() + + /** + * A real, stringable IParameter or IQueryFunction. + * + * @param class-string $interface IParameter::class or IQueryFunction::class. + * @param string $text What it stringifies to. + * + * @return IParameter|IQueryFunction The value. + */ + private static function stringable(string $interface, string $text): IParameter|IQueryFunction { + if ($interface === IParameter::class) { + return new class ($text) implements IParameter { + public function __construct(private readonly string $text) { + } + + public function __toString(): string { + return $this->text; + } + }; + } + + return new class ($text) implements IQueryFunction { + public function __construct(private readonly string $text) { + } + + public function __toString(): string { + return $this->text; + } + }; + }//end stringable() + + /** + * Whether the builder saw a call. + * + * @param string $method The builder or `expr.` method. + * @param mixed $firstArgument Its first argument, or null for any. + * + * @return boolean True when recorded. + */ + private function saw(string $method, mixed $firstArgument = null): bool { + foreach ($this->calls as [$seenMethod, $seenArgument]) { + if ($seenMethod === $method && ($firstArgument === null || $seenArgument === $firstArgument)) { + return true; + } + } + + return false; + }//end saw() +}//end trait diff --git a/tests/Unit/Db/TaskEntitiesTest.php b/tests/Unit/Db/TaskEntitiesTest.php new file mode 100644 index 0000000000..b213707097 --- /dev/null +++ b/tests/Unit/Db/TaskEntitiesTest.php @@ -0,0 +1,197 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskCandidate; +use OCA\OpenRegister\Db\TaskRelation; +use PHPUnit\Framework\TestCase; + +/** + * Round trips for the task entities. + * + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Db\TaskAudit + * @covers \OCA\OpenRegister\Db\TaskCandidate + * @covers \OCA\OpenRegister\Db\TaskRelation + */ +class TaskEntitiesTest extends TestCase { + + /** + * Every declared field hydrates and serialises under its API name; the + * stored key column is `taskKey` in PHP and `key` on the wire. + * + * @return void + */ + public function testTaskHydratesAndSerialisesEveryField(): void { + $due = new DateTime('2026-09-04T17:00:00+02:00'); + $task = new Task(); + $task->hydrate( + [ + 'uuid' => 'u-1', + 'taskKey' => 'EXT-7', + 'title' => 'Controleer', + 'state' => Task::STATE_ENABLED, + 'isTerminal' => false, + 'performerType' => Task::PERFORMER_GROUP, + 'candidateGroups' => ['team'], + 'dueAt' => $due, + 'priority' => 'high', + 'objectUuid' => 'obj-1', + 'registerId' => 1, + 'schemaId' => 2, + 'checklist' => [['id' => 'c1', 'label' => 'Een', 'description' => null, 'checked' => false]], + 'metadata' => ['legacy' => true], + 'id' => 99, + 'notAField' => 'ignored', + ] + ); + + $row = $task->jsonSerialize(); + + $this->assertSame('u-1', $row['uuid']); + $this->assertSame('EXT-7', $row['key']); + $this->assertSame(Task::STATE_ENABLED, $row['state']); + $this->assertFalse($row['isTerminal']); + $this->assertSame(['team'], $row['candidateGroups']); + $this->assertSame($due->format('c'), $row['dueAt']); + $this->assertSame(1, $row['registerId']); + $this->assertSame('c1', $row['checklist'][0]['id']); + $this->assertSame(['legacy' => true], $row['metadata']); + // hydrate() never lets a payload set the row id. + $this->assertNull($row['id']); + $this->assertArrayNotHasKey('notAField', $row); + // Nothing stored spells overdue. + $this->assertArrayNotHasKey('overdue', $row); + }//end testTaskHydratesAndSerialisesEveryField() + + /** + * Terminality reads the state set, and the entity's vocabularies are + * the six CMMN states and the open performer list. + * + * @return void + */ + public function testTaskTerminalityAndVocabularies(): void { + $task = new Task(); + foreach (Task::STATES as $state) { + $task->setState($state); + $this->assertSame(in_array($state, Task::TERMINAL_STATES, true), $task->isInTerminalState()); + } + + $this->assertCount(6, Task::STATES); + $this->assertSame(['user', 'group', 'agent', 'worker'], Task::PERFORMER_TYPES); + $this->assertSame(['low', 'normal', 'high', 'urgent'], Task::PRIORITIES); + $this->assertCount(5, Task::ROUTING_STRATEGIES); + }//end testTaskTerminalityAndVocabularies() + + /** + * A JSON-typed column round-trips as an array, a datetime as a DateTime. + * + * @return void + */ + public function testTaskFieldTypes(): void { + $task = new Task(); + $types = $task->getFieldTypes(); + + $this->assertSame('json', $types['candidateUsers']); + $this->assertSame('json', $types['checklist']); + $this->assertSame('json', $types['templateSnapshot']); + $this->assertSame('datetime', $types['dueAt']); + $this->assertSame('datetime', $types['expiresAt']); + $this->assertSame('boolean', $types['isTerminal']); + $this->assertSame('integer', $types['definitionVersion']); + $this->assertArrayNotHasKey('overdue', $types); + }//end testTaskFieldTypes() + + /** + * An audit entry serialises actor, performer type, delegation and the + * authorized flag — the fields that make a denial distinguishable from a + * success and a delegate from the original performer. + * + * @return void + */ + public function testTaskAuditSerialises(): void { + $entry = new TaskAudit(); + $entry->setTaskId(7); + $entry->setAction('complete'); + $entry->setStateAfter(Task::STATE_COMPLETED); + $entry->setActor('dora'); + $entry->setPerformerType(Task::PERFORMER_USER); + $entry->setOnBehalfOf('alice'); + $entry->setMandate('Volmacht'); + $entry->setReason('ok'); + $entry->setAuthorized(false); + $entry->setCreated(new DateTime('2026-09-01T10:00:00+00:00')); + + $row = $entry->jsonSerialize(); + + $this->assertSame(7, $row['taskId']); + $this->assertSame('complete', $row['action']); + $this->assertSame('dora', $row['actor']); + $this->assertSame('alice', $row['onBehalfOf']); + $this->assertSame('Volmacht', $row['mandate']); + $this->assertFalse($row['authorized']); + $this->assertSame('2026-09-01T10:00:00+00:00', $row['created']); + $this->assertSame('boolean', $entry->getFieldTypes()['authorized']); + }//end testTaskAuditSerialises() + + /** + * A candidate index row carries kind and ref; the kinds are the three + * the inbox EXISTS matches. + * + * @return void + */ + public function testTaskCandidateSerialises(): void { + $row = new TaskCandidate(); + $row->setTaskId(7); + $row->setKind(TaskCandidate::KIND_ROLE); + $row->setRef('fiatteur'); + + $this->assertSame(['id' => null, 'taskId' => 7, 'kind' => 'role', 'ref' => 'fiatteur'], $row->jsonSerialize()); + $this->assertSame('user', TaskCandidate::KIND_USER); + $this->assertSame('group', TaskCandidate::KIND_GROUP); + }//end testTaskCandidateSerialises() + + /** + * A relation carries a free-text role and the related object's anchor. + * + * @return void + */ + public function testTaskRelationSerialises(): void { + $row = new TaskRelation(); + $row->setTaskId(7); + $row->setRole('contract'); + $row->setObjectUuid('obj-9'); + $row->setRegisterId(3); + $row->setSchemaId(4); + + $this->assertSame( + ['id' => null, 'taskId' => 7, 'role' => 'contract', 'objectUuid' => 'obj-9', 'registerId' => 3, 'schemaId' => 4], + $row->jsonSerialize() + ); + }//end testTaskRelationSerialises() +}//end class diff --git a/tests/Unit/Db/TaskMapperQueriesTest.php b/tests/Unit/Db/TaskMapperQueriesTest.php new file mode 100644 index 0000000000..34abbc975e --- /dev/null +++ b/tests/Unit/Db/TaskMapperQueriesTest.php @@ -0,0 +1,302 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use DateTime; +use InvalidArgumentException; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Db\TaskMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\TestCase; + +/** + * The mapper's builder code paths. + * + * @covers \OCA\OpenRegister\Db\TaskMapper + * @covers \OCA\OpenRegister\Db\TaskInboxCriteria + * @covers \OCA\OpenRegister\Db\Task + */ +class TaskMapperQueriesTest extends TestCase { + use FluentQueryBuilderTrait; + + /** + * A stored task row as the database returns it. + * + * @return array The row. + */ + private function row(): array { + return [ + 'id' => 7, + 'uuid' => 't-7', + 'state' => Task::STATE_ACTIVE, + 'is_terminal' => 0, + 'performer_type' => 'user', + 'assignee' => 'alice', + 'priority' => 'normal', + 'candidate_users' => '["pat"]', + 'created' => '2026-09-01 10:00:00', + ]; + }//end row() + + /** + * findByUuid maps one row to a Task, and throws on none. + * + * @return void + */ + public function testFindByUuidMapsARowAndThrowsOnNone(): void { + $mapper = new TaskMapper(db: $this->connectionWith(rows: [$this->row()])); + $task = $mapper->findByUuid(uuid: 't-7'); + $this->assertSame('t-7', $task->getUuid()); + $this->assertSame(['pat'], $task->getCandidateUsers()); + $this->assertTrue($this->saw('expr.eq', 'uuid')); + + $this->expectException(DoesNotExistException::class); + (new TaskMapper(db: $this->connectionWith(rows: [])))->findByUuid(uuid: 'ghost'); + }//end testFindByUuidMapsARowAndThrowsOnNone() + + /** + * claim is ONE conditional update: it sets the assignee and active state + * and guards on `is_terminal` plus an empty assignee; the affected-row + * count decides who won. + * + * @return void + */ + public function testClaimIsAConditionalUpdateDecidedByTheRowCount(): void { + $winner = new TaskMapper(db: $this->connectionWith(affectedRows: 1)); + $this->assertTrue($winner->claim(taskId: 7, uid: 'bob')); + $this->assertTrue($this->saw('set', 'assignee')); + $this->assertTrue($this->saw('set', 'state')); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + $this->assertTrue($this->saw('expr.isNull', 'assignee')); + + $loser = new TaskMapper(db: $this->connectionWith(affectedRows: 0)); + $this->assertFalse($loser->claim(taskId: 7, uid: 'carol')); + }//end testClaimIsAConditionalUpdateDecidedByTheRowCount() + + /** + * updateIfOpen writes only the changed fields, guards on `is_terminal`, + * and reports whether the open row was hit. + * + * @return void + */ + public function testUpdateIfOpenWritesChangedFieldsUnderTheOpennessGuard(): void { + $task = new Task(); + $task->setId(7); + $task->setUuid('t-7'); + $task->resetUpdatedFields(); + $task->setState(Task::STATE_COMPLETED); + $task->setOutcome('approved'); + + $mapper = new TaskMapper(db: $this->connectionWith(affectedRows: 1)); + $this->assertTrue($mapper->updateIfOpen(task: $task)); + $this->assertTrue($this->saw('set', 'state')); + $this->assertTrue($this->saw('set', 'outcome')); + $this->assertTrue($this->saw('set', 'updated')); + $this->assertFalse($this->saw('set', 'title'), 'an untouched field is not written'); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + + $this->assertFalse((new TaskMapper(db: $this->connectionWith(affectedRows: 0)))->updateIfOpen(task: $task)); + }//end testUpdateIfOpenWritesChangedFieldsUnderTheOpennessGuard() + + /** + * A task without an id cannot be conditionally updated. + * + * @return void + */ + public function testUpdateIfOpenRefusesAnUnsavedTask(): void { + $this->expectException(InvalidArgumentException::class); + (new TaskMapper(db: $this->connectionWith()))->updateIfOpen(task: new Task()); + }//end testUpdateIfOpenRefusesAnUnsavedTask() + + /** + * insert stamps `created` and update stamps `updated`; both refuse a + * foreign entity by name. + * + * @return void + */ + public function testInsertAndUpdateStampAndGuardTheEntityType(): void { + $mapper = new TaskMapper(db: $this->connectionWith()); + $task = new Task(); + $task->setUuid('t-new'); + $task->setState(Task::STATE_AVAILABLE); + $task->setPerformerType('user'); + + $inserted = $mapper->insert(entity: $task); + $this->assertNotNull($inserted->getCreated()); + $this->assertSame(77, $inserted->getId()); + + $inserted->setTitle('renamed'); + $updated = $mapper->update(entity: $inserted); + $this->assertNotNull($updated->getUpdated()); + + try { + $mapper->insert(entity: new FlowRun()); + $this->fail('A FlowRun was accepted by TaskMapper::insert.'); + } catch (InvalidArgumentException $refused) { + $this->assertStringContainsString('Task entities only', $refused->getMessage()); + } + + $this->expectException(InvalidArgumentException::class); + $mapper->update(entity: new FlowRun()); + }//end testInsertAndUpdateStampAndGuardTheEntityType() + + /** + * The propagation read selects by run uuid AND openness — the structural + * half of "a standalone task survives everything". + * + * @return void + */ + public function testFindOpenByRunUuidSelectsByRunAndOpenness(): void { + $mapper = new TaskMapper(db: $this->connectionWith(rows: [$this->row()])); + $open = $mapper->findOpenByRunUuid(runUuid: 'run-9'); + $this->assertCount(1, $open); + $this->assertTrue($this->saw('expr.eq', 'run_uuid')); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + }//end testFindOpenByRunUuidSelectsByRunAndOpenness() + + /** + * The two routing reads: open counts and latest assignment per uid, and + * both short-circuit on an empty pool without touching the database. + * + * @return void + */ + public function testRoutingReadsGroupByAssigneeAndShortCircuitOnAnEmptyPool(): void { + $counts = new TaskMapper( + db: $this->connectionWith(rows: [['assignee' => 'anna', 'open_count' => '4'], ['assignee' => 'bert', 'open_count' => '1']]) + ); + $this->assertSame(['anna' => 4, 'bert' => 1], $counts->countOpenAssigned(uids: ['anna', 'bert'])); + $this->assertTrue($this->saw('groupBy', 'assignee')); + + $latest = new TaskMapper(db: $this->connectionWith(rows: [['assignee' => 'anna', 'latest_created' => '2026-08-30 10:00:00']])); + $this->assertSame(['anna' => '2026-08-30 10:00:00'], $latest->latestAssignedAt(uids: ['anna'])); + + $db = $this->connectionWith(); + $db->expects($this->never())->method('getQueryBuilder'); + $idle = new TaskMapper(db: $db); + $this->assertSame([], $idle->countOpenAssigned(uids: [])); + $this->assertSame([], $idle->latestAssignedAt(uids: [])); + }//end testRoutingReadsGroupByAssigneeAndShortCircuitOnAnEmptyPool() + + /** + * The assigned scope filters on assignee; visibility for a non-admin + * adds the five-relationship disjunction; the page is bounded and has a + * stable tiebreak. + * + * @return void + */ + public function testFindInboxAssignedScopeForANonAdmin(): void { + $mapper = new TaskMapper(db: $this->connectionWith(rows: [$this->row()])); + $criteria = new TaskInboxCriteria(uid: 'alice', groupIds: ['reviewers'], isAdmin: false); + + $page = $mapper->findInbox(criteria: $criteria, limit: 25, offset: 50); + + $this->assertCount(1, $page); + $this->assertTrue($this->saw('expr.eq', 'assignee')); + $this->assertTrue($this->saw('expr.eq', 'requester'), 'visibility disjunction present for a non-admin'); + $this->assertTrue($this->saw('setMaxResults', 25)); + $this->assertTrue($this->saw('setFirstResult', 50)); + $this->assertTrue($this->saw('orderBy', 'due_at')); + $this->assertTrue($this->saw('addOrderBy', 'id')); + // The watcher predicate is the cast LIKE; the pool predicate is the EXISTS. + $this->assertTrue((bool)array_filter($this->functions, static fn (string $f): bool => str_contains($f, 'CAST(`watchers` AS CHAR) LIKE'))); + $this->assertTrue((bool)array_filter($this->functions, static fn (string $f): bool => str_starts_with($f, 'EXISTS (SELECT 1 FROM'))); + }//end testFindInboxAssignedScopeForANonAdmin() + + /** + * An administrator gets no visibility narrowing; the pooled scope guards + * on an empty assignee plus the candidate EXISTS; watched uses the LIKE. + * + * @return void + */ + public function testFindInboxScopesForAnAdmin(): void { + $mapper = new TaskMapper(db: $this->connectionWith()); + + $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'root', isAdmin: true, scope: TaskInboxCriteria::SCOPE_POOLED)); + $this->assertTrue($this->saw('expr.isNull', 'assignee')); + $this->assertFalse($this->saw('expr.eq', 'requester'), 'an admin is not narrowed by visibility'); + + $this->calls = []; + $this->functions = []; + $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'root', isAdmin: true, scope: TaskInboxCriteria::SCOPE_WATCHED)); + $this->assertCount(1, $this->functions); + $this->assertStringContainsString('LIKE', $this->functions[0]); + + $this->calls = []; + $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'root', isAdmin: true, scope: TaskInboxCriteria::SCOPE_ALL)); + $this->assertFalse($this->saw('expr.eq', 'assignee')); + }//end testFindInboxScopesForAnAdmin() + + /** + * Every filter lands in the WHERE clause, the overdue filter as the + * COALESCE comparison, and every sort key has its ORDER BY. + * + * @return void + */ + public function testFindInboxFiltersAndSortsInTheDatastore(): void { + $mapper = new TaskMapper(db: $this->connectionWith()); + $criteria = new TaskInboxCriteria( + uid: 'root', + isAdmin: true, + scope: TaskInboxCriteria::SCOPE_ALL, + states: [Task::STATE_ACTIVE, Task::STATE_ENABLED], + isTerminal: false, + priority: 'high', + objectUuid: 'obj-1', + overdueAt: new DateTime('2026-09-01T00:00:00+00:00'), + sort: TaskInboxCriteria::SORT_PRIORITY, + sortDescending: true + ); + + $mapper->findInbox(criteria: $criteria); + + $this->assertTrue($this->saw('expr.in', 'state')); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + $this->assertTrue($this->saw('expr.eq', 'priority')); + $this->assertTrue($this->saw('expr.eq', 'object_uuid')); + $this->assertTrue((bool)array_filter($this->functions, static fn (string $f): bool => str_starts_with($f, 'COALESCE(`due_at`, `expires_at`) <'))); + $this->assertTrue((bool)array_filter($this->functions, static fn (string $f): bool => str_starts_with($f, 'CASE `priority` WHEN'))); + + $this->calls = []; + $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'root', isAdmin: true, sort: TaskInboxCriteria::SORT_CREATED)); + $this->assertTrue($this->saw('orderBy', 'created')); + }//end testFindInboxFiltersAndSortsInTheDatastore() + + /** + * countInbox reads the total off the same predicates; no row is zero. + * + * @return void + */ + public function testCountInboxReadsTheTotal(): void { + $counted = new TaskMapper(db: $this->connectionWith(rows: [['total' => '120']])); + $this->assertSame(120, $counted->countInbox(criteria: new TaskInboxCriteria(uid: 'alice'))); + $this->assertTrue($this->saw('expr.eq', 'assignee')); + + $empty = new TaskMapper(db: $this->connectionWith(rows: [])); + $this->assertSame(0, $empty->countInbox(criteria: new TaskInboxCriteria(uid: 'alice'))); + }//end testCountInboxReadsTheTotal() +}//end class diff --git a/tests/Unit/Db/TaskMapperTest.php b/tests/Unit/Db/TaskMapperTest.php new file mode 100644 index 0000000000..b54b5599b8 --- /dev/null +++ b/tests/Unit/Db/TaskMapperTest.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 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use OCA\OpenRegister\Db\TaskMapper; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; + +/** + * Platform-dependent SQL in TaskMapper. + * + * @covers \OCA\OpenRegister\Db\TaskMapper + */ +class TaskMapperTest extends TestCase { + + /** + * A mapper over a connection that reports the given platform and quotes + * identifiers the way that platform does. + * + * @param class-string $platformClass The platform. + * @param string $quote The platform's identifier quote character. + * + * @return TaskMapper The mapper. + */ + private function mapperOn(string $platformClass, string $quote): TaskMapper { + $platform = $this->createMock(originalClassName: $platformClass); + $platform->method('quoteIdentifier')->willReturnCallback( + static fn (string $identifier): string => $quote . $identifier . $quote + ); + $db = $this->createMock(originalClassName: IDBConnection::class); + $db->method('getDatabasePlatform')->willReturn($platform); + + return new TaskMapper(db: $db); + }//end mapperOn() + + /** + * RED 2: ON POSTGRESQL THE WATCHERS JSON COLUMN IS CAST TO TEXT before + * the LIKE, with double-quoted identifiers and no backtick anywhere. + * + * @return void + */ + public function testWatcherPredicateCastsJsonToTextOnPostgres(): void { + $sql = $this->mapperOn(platformClass: PostgreSQLPlatform::class, quote: '"')->watchersAsText(); + + $this->assertSame(expected: 'CAST("watchers" AS TEXT)', actual: $sql); + $this->assertStringNotContainsString(needle: '`', haystack: $sql); + }//end testWatcherPredicateCastsJsonToTextOnPostgres() + + /** + * On MySQL/MariaDB the cast is AS CHAR (TEXT is not a CAST target there). + * + * @return void + */ + public function testWatcherPredicateCastsToCharOnMysql(): void { + $sql = $this->mapperOn(platformClass: MySQLPlatform::class, quote: '`')->watchersAsText(); + + $this->assertSame(expected: 'CAST(`watchers` AS CHAR)', actual: $sql); + }//end testWatcherPredicateCastsToCharOnMysql() + + /** + * The pooled-inbox EXISTS matches user, group AND role rows, quoted per + * platform, so a role-only pool is visible to the people who may claim + * from it — and it carries no backticks on PostgreSQL. + * + * @return void + */ + public function testCandidateMembershipMatchesRolesAndQuotesPerPlatform(): void { + $sql = $this->mapperOn(platformClass: PostgreSQLPlatform::class, quote: '"')->candidateMembershipSql( + uidPlaceholder: ':uid', + groupsPlaceholder: ':groups' + ); + + $this->assertStringContainsString(needle: "\"tc\".\"kind\" = 'user' AND \"tc\".\"ref\" = :uid", haystack: $sql); + $this->assertStringContainsString(needle: "\"tc\".\"kind\" = 'group' AND \"tc\".\"ref\" IN (:groups)", haystack: $sql); + $this->assertStringContainsString(needle: "\"tc\".\"kind\" = 'role' AND \"tc\".\"ref\" IN (:groups)", haystack: $sql); + $this->assertStringContainsString(needle: '"*PREFIX*openregister_task_candidates"', haystack: $sql); + $this->assertStringNotContainsString(needle: '`', haystack: $sql); + }//end testCandidateMembershipMatchesRolesAndQuotesPerPlatform() + + /** + * With no groups, only the user branch is emitted: no dangling IN (). + * + * @return void + */ + public function testCandidateMembershipWithoutGroupsHasOnlyTheUserBranch(): void { + $sql = $this->mapperOn(platformClass: MySQLPlatform::class, quote: '`')->candidateMembershipSql( + uidPlaceholder: ':uid', + groupsPlaceholder: null + ); + + $this->assertStringContainsString(needle: "= 'user'", haystack: $sql); + $this->assertStringNotContainsString(needle: "= 'group'", haystack: $sql); + $this->assertStringNotContainsString(needle: "= 'role'", haystack: $sql); + }//end testCandidateMembershipWithoutGroupsHasOnlyTheUserBranch() +}//end class diff --git a/tests/Unit/Db/TaskSideMappersTest.php b/tests/Unit/Db/TaskSideMappersTest.php new file mode 100644 index 0000000000..e43a8bd351 --- /dev/null +++ b/tests/Unit/Db/TaskSideMappersTest.php @@ -0,0 +1,174 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use InvalidArgumentException; +use LogicException; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskRelation; +use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCP\EventDispatcher\IEventDispatcher; +use PHPUnit\Framework\TestCase; + +/** + * Side mappers and the terminal-run announcement. + * + * @covers \OCA\OpenRegister\Db\TaskCandidateMapper + * @covers \OCA\OpenRegister\Db\TaskRelationMapper + * @covers \OCA\OpenRegister\Db\TaskAuditMapper + * @covers \OCA\OpenRegister\Db\TaskCandidate + * @covers \OCA\OpenRegister\Db\TaskRelation + * @covers \OCA\OpenRegister\Db\TaskAudit + * @covers \OCA\OpenRegister\Db\FlowRunMapper + * @covers \OCA\OpenRegister\Event\FlowRunTerminalEvent + */ +class TaskSideMappersTest extends TestCase { + use FluentQueryBuilderTrait; + + /** + * replaceForTask deletes the task's rows then inserts the new set; the + * read selects by task id. + * + * @return void + */ + public function testCandidateRowsAreReplacedWholesaleAndReadByTask(): void { + $mapper = new TaskCandidateMapper(db: $this->connectionWith(rows: [['id' => 1, 'task_id' => 7, 'kind' => 'group', 'ref' => 'reviewers']])); + + $mapper->replaceForTask(taskId: 7, candidates: [['kind' => 'user', 'ref' => 'pat'], ['kind' => 'role', 'ref' => 'fiatteur']]); + $this->assertTrue($this->saw('delete')); + $this->assertTrue($this->saw('expr.eq', 'task_id')); + $this->assertSame(2, count(array_filter($this->calls, static fn (array $c): bool => $c[0] === 'insert'))); + + $rows = $mapper->findForTask(taskId: 7); + $this->assertCount(1, $rows); + $this->assertSame('reviewers', $rows[0]->getRef()); + }//end testCandidateRowsAreReplacedWholesaleAndReadByTask() + + /** + * Relations read by task and by object, the latter optionally by role. + * + * @return void + */ + public function testRelationsReadByTaskAndByObject(): void { + $mapper = new TaskRelationMapper( + db: $this->connectionWith(rows: [['id' => 1, 'task_id' => 7, 'role' => 'contract', 'object_uuid' => 'obj-9', 'register_id' => 3, 'schema_id' => 4]]) + ); + + $byTask = $mapper->findForTask(taskId: 7); + $this->assertSame('contract', $byTask[0]->getRole()); + + $this->calls = []; + $byObject = $mapper->findByObject(objectUuid: 'obj-9', role: 'contract'); + $this->assertSame(3, $byObject[0]->getRegisterId()); + $this->assertTrue($this->saw('expr.eq', 'object_uuid')); + $this->assertTrue($this->saw('expr.eq', 'role')); + + $this->calls = []; + $mapper->findByObject(objectUuid: 'obj-9'); + $this->assertFalse($this->saw('expr.eq', 'role'), 'no role filter when none asked'); + + $relation = new TaskRelation(); + $relation->setTaskId(7); + $relation->setRole('evidence'); + $relation->setObjectUuid('obj-2'); + $this->assertSame(77, $mapper->insert(entity: $relation)->getId()); + }//end testRelationsReadByTaskAndByObject() + + /** + * APPEND-ONLY, ENFORCED: insert stamps created, findForTask reads oldest + * first, update and delete throw, a foreign entity is refused by name. + * + * @return void + */ + public function testTheAuditAppendsAndRefusesEverythingElse(): void { + $mapper = new TaskAuditMapper(db: $this->connectionWith(rows: [['id' => 1, 'task_id' => 7, 'action' => 'claim', 'authorized' => 1]])); + + $entry = new TaskAudit(); + $entry->setTaskId(7); + $entry->setAction('claim'); + $appended = $mapper->insert(entity: $entry); + $this->assertNotNull($appended->getCreated()); + $this->assertSame(77, $appended->getId()); + + $trail = $mapper->findForTask(taskId: 7); + $this->assertSame('claim', $trail[0]->getAction()); + $this->assertTrue($this->saw('orderBy', 'id')); + + try { + $mapper->update(entity: $entry); + $this->fail('update() did not refuse.'); + } catch (LogicException $refused) { + $this->assertStringContainsString('append-only', $refused->getMessage()); + } + + try { + $mapper->delete(entity: $entry); + $this->fail('delete() did not refuse.'); + } catch (LogicException $refused) { + $this->assertStringContainsString('append-only', $refused->getMessage()); + } + + $this->expectException(InvalidArgumentException::class); + $mapper->insert(entity: new Task()); + }//end testTheAuditAppendsAndRefusesEverythingElse() + + /** + * FlowRunMapper::update() announces a TERMINAL run and stays silent for a + * live one; a foreign entity is refused; no dispatcher means no event. + * + * @return void + */ + public function testFlowRunMapperAnnouncesTerminalityFromTheOneChokePoint(): void { + $announced = []; + $dispatcher = $this->createMock(originalClassName: IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (FlowRunTerminalEvent $event) use (&$announced): void { + $announced[] = $event->getRunUuid() . ':' . $event->getStatus(); + } + ); + $mapper = new FlowRunMapper(db: $this->connectionWith(), dispatcher: $dispatcher); + + $done = new FlowRun(); + $done->setId(1); + $done->setUuid('run-9'); + $done->setStatus(FlowRun::STATUS_STOPPED); + $mapper->update(entity: $done); + + $live = new FlowRun(); + $live->setId(2); + $live->setUuid('run-10'); + $live->setStatus(FlowRun::STATUS_SUSPENDED); + $mapper->update(entity: $live); + + $this->assertSame(['run-9:stopped'], $announced); + + $silent = new FlowRunMapper(db: $this->connectionWith()); + $this->assertSame('run-9', $silent->update(entity: $done)->getUuid()); + + $this->expectException(InvalidArgumentException::class); + $mapper->update(entity: new Task()); + }//end testFlowRunMapperAnnouncesTerminalityFromTheOneChokePoint() +}//end class diff --git a/tests/Unit/Listener/TaskRunTerminalListenerTest.php b/tests/Unit/Listener/TaskRunTerminalListenerTest.php new file mode 100644 index 0000000000..60299fc884 --- /dev/null +++ b/tests/Unit/Listener/TaskRunTerminalListenerTest.php @@ -0,0 +1,87 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Event\ObjectDeletedEvent; +use OCA\OpenRegister\Listener\TaskRunTerminalListener; +use OCA\OpenRegister\Service\Task\TaskService; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * The listener hands the run's identity to propagation and never rethrows. + * + * @covers \OCA\OpenRegister\Listener\TaskRunTerminalListener + * @covers \OCA\OpenRegister\Event\FlowRunTerminalEvent + */ +class TaskRunTerminalListenerTest extends TestCase { + + /** + * A terminal-run event terminates that run's tasks, by uuid and status. + * + * @return void + */ + public function testATerminalRunTerminatesItsTasks(): void { + $tasks = $this->createMock(originalClassName: TaskService::class); + $tasks->expects($this->once()) + ->method('terminateForRun') + ->with(runUuid: 'run-9', runStatus: 'stopped') + ->willReturn(3); + $logger = $this->createMock(originalClassName: LoggerInterface::class); + $logger->expects($this->once())->method('info')->with($this->stringContains('3 task(s)')); + + $event = new FlowRunTerminalEvent(runUuid: 'run-9', status: 'stopped'); + $this->assertSame('run-9', $event->getRunUuid()); + $this->assertSame('stopped', $event->getStatus()); + + (new TaskRunTerminalListener(tasks: $tasks, logger: $logger))->handle(event: $event); + }//end testATerminalRunTerminatesItsTasks() + + /** + * A propagation failure is logged, never rethrown: the run's own terminal + * write must not be unwound by task bookkeeping. + * + * @return void + */ + public function testAPropagationFailureIsLoggedNotRethrown(): void { + $tasks = $this->createMock(originalClassName: TaskService::class); + $tasks->method('terminateForRun')->willThrowException(new RuntimeException('db down')); + $logger = $this->createMock(originalClassName: LoggerInterface::class); + $logger->expects($this->once())->method('error')->with($this->stringContains('db down'), $this->arrayHasKey('run')); + + (new TaskRunTerminalListener(tasks: $tasks, logger: $logger))->handle( + event: new FlowRunTerminalEvent(runUuid: 'run-9', status: 'failed') + ); + }//end testAPropagationFailureIsLoggedNotRethrown() + + /** + * Any other event is ignored without touching the service. + * + * @return void + */ + public function testOtherEventsAreIgnored(): void { + $tasks = $this->createMock(originalClassName: TaskService::class); + $tasks->expects($this->never())->method('terminateForRun'); + + (new TaskRunTerminalListener(tasks: $tasks, logger: $this->createMock(originalClassName: LoggerInterface::class))) + ->handle(event: $this->createMock(originalClassName: ObjectDeletedEvent::class)); + }//end testOtherEventsAreIgnored() +}//end class diff --git a/tests/Unit/Repair/SeedTaskFixturesTest.php b/tests/Unit/Repair/SeedTaskFixturesTest.php new file mode 100644 index 0000000000..da2a114c4f --- /dev/null +++ b/tests/Unit/Repair/SeedTaskFixturesTest.php @@ -0,0 +1,236 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Repair; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Repair\SeedTaskFixtures; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IAppConfig; +use OCP\Migration\IOutput; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Gate, content and idempotency of the seed step. + * + * @covers \OCA\OpenRegister\Repair\SeedTaskFixtures + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Db\TaskAudit + */ +class SeedTaskFixturesTest extends TestCase { + + /** + * The task table, mocked. + * + * @var TaskMapper&MockObject + */ + private TaskMapper&MockObject $tasks; + + /** + * The candidate index, mocked. + * + * @var TaskCandidateMapper&MockObject + */ + private TaskCandidateMapper&MockObject $candidates; + + /** + * The audit, mocked. + * + * @var TaskAuditMapper&MockObject + */ + private TaskAuditMapper&MockObject $audits; + + /** + * Fresh mocks. + * + * @return void + */ + protected function setUp(): void { + $this->tasks = $this->createMock(originalClassName: TaskMapper::class); + $this->candidates = $this->createMock(originalClassName: TaskCandidateMapper::class); + $this->audits = $this->createMock(originalClassName: TaskAuditMapper::class); + // Sequential ids in insertion order, so a test can address "the + // second fixture" by id. + $next = 0; + $this->tasks->method('insert')->willReturnCallback( + static function (Task $task) use (&$next): Task { + $next++; + $task->setId($next); + + return $task; + } + ); + $this->audits->method('insert')->willReturnArgument(0); + }//end setUp() + + /** + * A step over a config reporting the flag as given. + * + * @param bool $enabled The flag value. + * + * @return SeedTaskFixtures The step. + */ + private function step(bool $enabled): SeedTaskFixtures { + $config = $this->createMock(originalClassName: IAppConfig::class); + $config->method('getValueBool')->with('openregister', SeedTaskFixtures::FLAG, false)->willReturn($enabled); + + return new SeedTaskFixtures( + appConfig: $config, + tasks: $this->tasks, + candidates: $this->candidates, + audits: $this->audits, + logger: new NullLogger() + ); + }//end step() + + /** + * OFF BY DEFAULT: nothing is read, nothing is written, and the log says so. + * + * @return void + */ + public function testWithTheFlagOffTheStepWritesNothing(): void { + $this->tasks->expects($this->never())->method('findByUuid'); + $this->tasks->expects($this->never())->method('insert'); + $this->audits->expects($this->never())->method('insert'); + $output = $this->createMock(originalClassName: IOutput::class); + $output->expects($this->once())->method('info')->with($this->stringContains('skipped')); + + $this->step(enabled: false)->run(output: $output); + }//end testWithTheFlagOffTheStepWritesNothing() + + /** + * With the flag on, the five design.md groups land: one with no run, + * one delegated on a run with enforcing expiry, one agent task with a + * typed checklist, one completed 'approved', one terminated by + * propagation; six audit entries, exactly one of them a denial; and the + * candidate index is written for every task. + * + * @return void + */ + public function testWithTheFlagOnTheFiveGroupsAreSeeded(): void { + $this->tasks->method('findByUuid')->willThrowException(new DoesNotExistException('absent')); + $inserted = []; + $this->candidates->method('replaceForTask')->willReturnCallback( + function (int $taskId, array $candidates) use (&$inserted): void { + // Recorded per replaceForTask so the assertions below can + // read what was inserted without a second insert matcher. + $inserted[$taskId] = $candidates; + } + ); + $audits = []; + $seeded = []; + $this->audits->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$audits): TaskAudit { + $audits[] = $entry; + + return $entry; + } + ); + $output = $this->createMock(originalClassName: IOutput::class); + $output->expects($this->once())->method('info')->with($this->stringContains('5 seeded')); + + $step = $this->step(enabled: true); + $step->run(output: $output); + + // Five candidate-index rewrites, one per fixture, ids 1..5. + $this->assertSame([1, 2, 3, 4, 5], array_keys($inserted)); + + // Read the fixtures back through the step's own table. + $byUuid = []; + $reflection = new \ReflectionMethod($step, 'fixtures'); + foreach ($reflection->invoke($step) as $fixture) { + $task = new Task(); + $task->hydrate($fixture['task']); + $task->setUuid((string)$fixture['uuid']); + $byUuid[(string)$fixture['uuid']] = $task; + $seeded[] = $task; + } + + $this->assertCount(5, $seeded); + + $pooled = $byUuid['00000000-0000-0000-0000-000000000001']; + $this->assertNull($pooled->getRunUuid()); + $this->assertNull($pooled->getAssignee()); + $this->assertSame(Task::PERFORMER_GROUP, $pooled->getPerformerType()); + + $delegated = $byUuid['00000000-0000-0000-0000-000000000002']; + $this->assertSame('00000000-0000-0000-0000-0000000000f1', $delegated->getRunUuid()); + $this->assertSame('EXAMPLE_DIRECTOR_USER', $delegated->getOnBehalfOf()); + $this->assertNotNull($delegated->getExpiresAt()); + + $agent = $byUuid['00000000-0000-0000-0000-000000000003']; + $this->assertSame(Task::PERFORMER_AGENT, $agent->getPerformerType()); + $this->assertCount(3, $agent->getChecklist()); + + $this->assertTrue($byUuid['00000000-0000-0000-0000-000000000004']->getIsTerminal()); + $this->assertSame('approved', $byUuid['00000000-0000-0000-0000-000000000004']->getOutcome()); + $this->assertSame(Task::STATE_TERMINATED, $byUuid['00000000-0000-0000-0000-000000000005']->getState()); + + $this->assertCount(6, $audits); + $denials = array_filter($audits, static fn (TaskAudit $entry): bool => $entry->getAuthorized() === false); + $this->assertCount(1, $denials); + }//end testWithTheFlagOnTheFiveGroupsAreSeeded() + + /** + * IDEMPOTENT ON UUID: a fixture that exists is left exactly as it is. + * + * @return void + */ + public function testAnExistingFixtureIsLeftAlone(): void { + $this->tasks->method('findByUuid')->willReturn(new Task()); + $this->tasks->expects($this->never())->method('insert'); + $this->audits->expects($this->never())->method('insert'); + $output = $this->createMock(originalClassName: IOutput::class); + $output->expects($this->once())->method('info')->with($this->stringContains('0 seeded, 5 already present')); + + $this->step(enabled: true)->run(output: $output); + }//end testAnExistingFixtureIsLeftAlone() + + /** + * One failing fixture is reported and does not abort the others. + * + * @return void + */ + public function testAFailingFixtureIsReportedAndTheRestContinue(): void { + $this->tasks->method('findByUuid')->willThrowException(new DoesNotExistException('absent')); + $this->candidates->method('replaceForTask')->willReturnCallback( + static function (int $taskId): void { + if ($taskId === 2) { + throw new \RuntimeException('index down'); + } + } + ); + $output = $this->createMock(originalClassName: IOutput::class); + $output->expects($this->once())->method('warning')->with($this->stringContains('index down')); + $output->expects($this->once())->method('info')->with($this->stringContains('4 seeded')); + + $this->step(enabled: true)->run(output: $output); + $this->assertSame('Seed the task fixtures (flow-task-entity)', $this->step(enabled: true)->getName()); + }//end testAFailingFixtureIsReportedAndTheRestContinue() +}//end class diff --git a/tests/Unit/Service/Task/TaskAuthorizationServiceTest.php b/tests/Unit/Service/Task/TaskAuthorizationServiceTest.php new file mode 100644 index 0000000000..b6422b5d1b --- /dev/null +++ b/tests/Unit/Service/Task/TaskAuthorizationServiceTest.php @@ -0,0 +1,332 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCP\IGroupManager; +use PHPUnit\Framework\TestCase; + +/** + * The per-verb decisions, and their fail-closed indeterminate cases. + * + * @covers \OCA\OpenRegister\Service\Task\TaskAuthorizationService + * @covers \OCA\OpenRegister\Exception\TaskAccessDeniedException + * @covers \OCA\OpenRegister\Db\Task + */ +class TaskAuthorizationServiceTest extends TestCase { + + /** + * A group backend that knows nothing and nobody. + * + * @return IGroupManager The mock. + */ + private function emptyGroupBackend(): IGroupManager { + $manager = $this->createMock(IGroupManager::class); + $manager->method('isAdmin')->willReturn(false); + $manager->method('isInGroup')->willReturn(false); + $manager->method('groupExists')->willReturn(false); + + return $manager; + }//end emptyGroupBackend() + + /** + * A task assigned to alice, requested by rita. + * + * @return Task The task. + */ + private function assignedTask(): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setState(Task::STATE_ACTIVE); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('alice'); + $task->setRequester('rita'); + + return $task; + }//end assignedTask() + + /** + * A STRANGER who merely knows the uuid is denied on every verb. + * + * @return void + */ + public function testAStrangerIsDeniedOnEveryVerb(): void { + $service = new TaskAuthorizationService(groupManager: $this->emptyGroupBackend()); + $task = $this->assignedTask(); + + // Every verb that touches an existing task. `offer` is in this list + // because it was once missing from it, which is exactly how an + // identity-only offer stayed green while it let anyone route a task + // to themselves through routingFallback. + $verbs = ['offer', 'claim', 'unclaim', 'assign', 'reassign', 'delegate', 'resolve', 'complete', 'cancel', 'checklist']; + foreach ($verbs as $verb) { + try { + $service->assertMay(verb: $verb, task: $task, uid: 'mallory'); + $this->fail(sprintf("Verb '%s' admitted a stranger.", $verb)); + } catch (TaskAccessDeniedException $denied) { + $this->assertStringContainsString($verb, $denied->getMessage()); + } + } + }//end testAStrangerIsDeniedOnEveryVerb() + + /** + * No identity, no verb — anonymity never acts. + * + * @return void + */ + public function testNoIdentityIsDenied(): void { + $service = new TaskAuthorizationService(groupManager: $this->emptyGroupBackend()); + + $this->expectException(TaskAccessDeniedException::class); + $service->assertMay(verb: 'complete', task: $this->assignedTask(), uid: null); + }//end testNoIdentityIsDenied() + + /** + * The assignee completes; so does an agent through the SAME check. + * + * @return void + */ + public function testTheAssigneeMayCompleteWhateverThePerformerType(): void { + $service = new TaskAuthorizationService(groupManager: $this->emptyGroupBackend()); + + $human = $this->assignedTask(); + $service->assertMay(verb: 'complete', task: $human, uid: 'alice'); + + $agent = $this->assignedTask(); + $agent->setPerformerType(Task::PERFORMER_AGENT); + $agent->setAssignee('agent-7'); + $service->assertMay(verb: 'complete', task: $agent, uid: 'agent-7'); + + // Reaching here means neither threw. + $this->assertTrue(true); + }//end testTheAssigneeMayCompleteWhateverThePerformerType() + + /** + * AN UNKNOWN PERFORMER TYPE IS UNDETERMINABLE, WHICH IS A DENIAL — the + * extensibility contract: a new type is admitted by extending the + * vocabulary, never by falling through an open door. + * + * @return void + */ + public function testAnUnknownPerformerTypeDenies(): void { + $service = new TaskAuthorizationService(groupManager: $this->emptyGroupBackend()); + $task = $this->assignedTask(); + $task->setPerformerType('external'); + + try { + $service->assertMay(verb: 'complete', task: $task, uid: 'alice'); + $this->fail('An unknown performer type was admitted.'); + } catch (TaskAccessDeniedException $denied) { + $this->assertStringContainsString("'external'", $denied->getMessage()); + } + }//end testAnUnknownPerformerTypeDenies() + + /** + * AN UNRESOLVABLE ROLE DENIES, NAMING THE ROLE — never "no check + * applicable", never success. + * + * @return void + */ + public function testAnUnresolvableRoleDeniesNamingTheRole(): void { + $service = new TaskAuthorizationService(groupManager: $this->emptyGroupBackend()); + $task = new Task(); + $task->setState(Task::STATE_ENABLED); + $task->setPerformerType(Task::PERFORMER_GROUP); + $task->setCandidateRole('GHOST_ROLE'); + + try { + $service->assertMay(verb: 'claim', task: $task, uid: 'alice'); + $this->fail('An unresolvable role was treated as passable.'); + } catch (TaskAccessDeniedException $denied) { + $this->assertStringContainsString('GHOST_ROLE', $denied->getMessage()); + $this->assertStringContainsString('denied', $denied->getMessage()); + } + }//end testAnUnresolvableRoleDeniesNamingTheRole() + + /** + * NO GROUP BACKEND AT ALL: membership-dependent verbs DENY rather than + * skip — the constructible-without-a-container case fails closed. + * + * @return void + */ + public function testAnAbsentGroupBackendDenies(): void { + $service = new TaskAuthorizationService(groupManager: null); + $task = new Task(); + $task->setState(Task::STATE_ENABLED); + $task->setPerformerType(Task::PERFORMER_GROUP); + $task->setCandidateGroups(['reviewers']); + + $this->expectException(TaskAccessDeniedException::class); + $service->assertMay(verb: 'claim', task: $task, uid: 'alice'); + }//end testAnAbsentGroupBackendDenies() + + /** + * A pool member may claim: named user, group member, and role member. + * + * @return void + */ + public function testPoolMembershipAdmitsAClaim(): void { + $manager = $this->createMock(IGroupManager::class); + $manager->method('isAdmin')->willReturn(false); + $manager->method('groupExists')->willReturn(true); + $manager->method('isInGroup')->willReturnCallback( + static fn (string $uid, string $gid): bool => ($uid === 'greta' && $gid === 'reviewers') + ); + $service = new TaskAuthorizationService(groupManager: $manager); + + $byUser = new Task(); + $byUser->setPerformerType(Task::PERFORMER_USER); + $byUser->setCandidateUsers(['ursula']); + $service->assertMay(verb: 'claim', task: $byUser, uid: 'ursula'); + + $byGroup = new Task(); + $byGroup->setPerformerType(Task::PERFORMER_GROUP); + $byGroup->setCandidateGroups(['reviewers']); + $service->assertMay(verb: 'claim', task: $byGroup, uid: 'greta'); + + $this->assertTrue(true); + }//end testPoolMembershipAdmitsAClaim() + + /** + * The requester cancels and reassigns; the assignee does not. + * + * @return void + */ + public function testRequesterOwnsCancelAndReassign(): void { + $service = new TaskAuthorizationService(groupManager: $this->emptyGroupBackend()); + $task = $this->assignedTask(); + + $service->assertMay(verb: 'cancel', task: $task, uid: 'rita'); + $service->assertMay(verb: 'reassign', task: $task, uid: 'rita'); + + $this->expectException(TaskAccessDeniedException::class); + $service->assertMay(verb: 'cancel', task: $task, uid: 'alice'); + }//end testRequesterOwnsCancelAndReassign() + + /** + * OFFER BELONGS TO THE REQUESTER: it rewrites the pool and the routing + * fallback, which decide who ends up assigned. The assignee, a pool + * member and a stranger are all refused. + * + * @return void + */ + public function testOfferBelongsToTheRequester(): void { + $service = new TaskAuthorizationService(groupManager: $this->emptyGroupBackend()); + $task = $this->assignedTask(); + $task->setCandidateUsers(['pat']); + + $service->assertMay(verb: 'offer', task: $task, uid: 'rita'); + + foreach (['alice', 'pat', 'mallory'] as $notTheRequester) { + try { + $service->assertMay(verb: 'offer', task: $task, uid: $notTheRequester); + $this->fail(sprintf("'%s' may offer but is not the requester.", $notTheRequester)); + } catch (TaskAccessDeniedException $denied) { + $this->assertStringContainsString('offer', $denied->getMessage()); + } + } + }//end testOfferBelongsToTheRequester() + + /** + * A group backend that THROWS is undeterminable: admin, membership and + * role resolution all read as false, so the decision denies and read + * visibility is refused for a pool member. + * + * @return void + */ + public function testAThrowingBackendDenies(): void { + $manager = $this->createMock(IGroupManager::class); + $manager->method('isAdmin')->willThrowException(new \RuntimeException('ldap down')); + $manager->method('isInGroup')->willThrowException(new \RuntimeException('ldap down')); + $manager->method('groupExists')->willThrowException(new \RuntimeException('ldap down')); + $service = new TaskAuthorizationService(groupManager: $manager); + + $task = new Task(); + $task->setPerformerType(Task::PERFORMER_GROUP); + $task->setCandidateGroups(['reviewers']); + $task->setCandidateRole('fiatteur'); + + $this->assertFalse($service->mayRead(task: $task, uid: 'greta')); + $this->assertFalse($service->isAdministrator(uid: 'greta')); + $this->assertFalse($service->isAdministrator(uid: null)); + + $this->expectException(TaskAccessDeniedException::class); + $service->assertMay(verb: 'claim', task: $task, uid: 'greta'); + }//end testAThrowingBackendDenies() + + /** + * A pool member with no other relationship may READ the task. + * + * @return void + */ + public function testAPoolMemberMayRead(): void { + $manager = $this->createMock(IGroupManager::class); + $manager->method('isAdmin')->willReturn(false); + $manager->method('isInGroup')->willReturn(true); + $service = new TaskAuthorizationService(groupManager: $manager); + + $task = new Task(); + $task->setPerformerType(Task::PERFORMER_GROUP); + $task->setCandidateGroups(['reviewers']); + + $this->assertTrue($service->mayRead(task: $task, uid: 'greta')); + }//end testAPoolMemberMayRead() + + /** + * An administrator passes every verb. + * + * @return void + */ + public function testAnAdministratorPasses(): void { + $manager = $this->createMock(IGroupManager::class); + $manager->method('isAdmin')->willReturn(true); + $service = new TaskAuthorizationService(groupManager: $manager); + + $service->assertMay(verb: 'complete', task: $this->assignedTask(), uid: 'root'); + $service->assertMay(verb: 'cancel', task: $this->assignedTask(), uid: 'root'); + $this->assertTrue(true); + }//end testAnAdministratorPasses() + + /** + * Read visibility: watchers see, strangers do not — and a watcher gains + * NO lifecycle right from watching. + * + * @return void + */ + public function testWatchersReadButNeverAct(): void { + $service = new TaskAuthorizationService(groupManager: $this->emptyGroupBackend()); + $task = $this->assignedTask(); + $task->setWatchers(['wanda']); + + $this->assertTrue($service->mayRead(task: $task, uid: 'wanda')); + $this->assertFalse($service->mayRead(task: $task, uid: 'mallory')); + $this->assertFalse($service->mayRead(task: $task, uid: null)); + + $this->expectException(TaskAccessDeniedException::class); + $service->assertMay(verb: 'complete', task: $task, uid: 'wanda'); + }//end testWatchersReadButNeverAct() +}//end class diff --git a/tests/Unit/Service/Task/TaskInboxServiceTest.php b/tests/Unit/Service/Task/TaskInboxServiceTest.php new file mode 100644 index 0000000000..7b0a0779f1 --- /dev/null +++ b/tests/Unit/Service/Task/TaskInboxServiceTest.php @@ -0,0 +1,325 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use DateTime; +use OCA\OpenRegister\Db\AbstractObjectMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Pagination, totals, visibility plumbing and display-title synthesis. + * + * @covers \OCA\OpenRegister\Service\Task\TaskInboxService + * @covers \OCA\OpenRegister\Service\Task\TaskTemporalProjection + * @covers \OCA\OpenRegister\Db\TaskInboxCriteria + * @covers \OCA\OpenRegister\Db\Task + */ +class TaskInboxServiceTest extends TestCase { + + /** + * The datastore, mocked. + * + * @var TaskMapper&MockObject + */ + private TaskMapper&MockObject $tasks; + + /** + * The service under test. + * + * @var TaskInboxService + */ + private TaskInboxService $inbox; + + /** + * Build the service. + * + * @return void + */ + protected function setUp(): void { + $this->tasks = $this->createMock(TaskMapper::class); + $this->inbox = new TaskInboxService( + tasks: $this->tasks, + temporal: new TaskTemporalProjection(), + logger: new NullLogger(), + objects: null + ); + }//end setUp() + + /** + * A page of tasks. + * + * @param int $count How many. + * + * @return array The tasks. + */ + private function tasksPage(int $count): array { + $page = []; + for ($i = 0; $i < $count; $i++) { + $task = new Task(); + $task->setId($i + 1); + $task->setUuid(sprintf('t-%d', $i + 1)); + $task->setState(Task::STATE_ENABLED); + $task->setPerformerType(Task::PERFORMER_USER); + $page[] = $task; + } + + return $page; + }//end tasksPage() + + /** + * 25 OF 120: the page holds what the datastore returned and the total is + * what the datastore counted — over the SAME criteria object, so the two + * cannot run different predicates. + * + * @return void + */ + public function testPaginationReportsTheDatastoreTotalOverTheSameCriteria(): void { + $criteria = new TaskInboxCriteria(uid: 'alice'); + $seenByFind = null; + $seenByCount = null; + + $this->tasks->expects($this->once())->method('findInbox')->willReturnCallback( + function (TaskInboxCriteria $c, int $limit, int $offset) use (&$seenByFind): array { + $seenByFind = $c; + $this->assertSame(25, $limit); + $this->assertSame(0, $offset); + + return $this->tasksPage(count: 25); + } + ); + $this->tasks->expects($this->once())->method('countInbox')->willReturnCallback( + static function (TaskInboxCriteria $c) use (&$seenByCount): int { + $seenByCount = $c; + + return 120; + } + ); + + $result = $this->inbox->inbox(criteria: $criteria, limit: 25, offset: 0); + + $this->assertCount(25, $result['results']); + $this->assertSame(120, $result['total']); + $this->assertSame($criteria, $seenByFind); + $this->assertSame($criteria, $seenByCount, 'The total must run over the SAME predicates as the page.'); + }//end testPaginationReportsTheDatastoreTotalOverTheSameCriteria() + + /** + * No identity, no inbox: an empty uid returns nothing and asks the + * datastore nothing. + * + * @return void + */ + public function testAnEmptyUidReturnsNothing(): void { + $this->tasks->expects($this->never())->method('findInbox'); + $this->tasks->expects($this->never())->method('countInbox'); + + $result = $this->inbox->inbox(criteria: new TaskInboxCriteria(uid: ' ')); + + $this->assertSame([], $result['results']); + $this->assertSame(0, $result['total']); + }//end testAnEmptyUidReturnsNothing() + + /** + * The page size is clamped so a badge query cannot become a table scan. + * + * @return void + */ + public function testTheLimitIsClamped(): void { + $this->tasks->expects($this->once())->method('findInbox')->willReturnCallback( + function (TaskInboxCriteria $c, int $limit, int $offset): array { + $this->assertSame(500, $limit); + + return []; + } + ); + $this->tasks->method('countInbox')->willReturn(0); + + $this->inbox->inbox(criteria: new TaskInboxCriteria(uid: 'alice'), limit: 99999); + }//end testTheLimitIsClamped() + + /** + * A TITLELESS TASK STILL DISPLAYS: the row carries a synthesized, + * non-empty display title from action and subject — and the STORED title + * is still null, because a stored synthesized title goes stale. + * + * @return void + */ + public function testATitlelessTaskGetsADisplayTitleAndStaysTitleless(): void { + $task = new Task(); + $task->setUuid('t-1'); + $task->setState(Task::STATE_ACTIVE); + $task->setLastAction('claim'); + $task->setObjectUuid('obj-1'); + + $subjects = [ + 'obj-1' => [ + 'uuid' => 'obj-1', + 'register' => 'aanvragen', + 'schema' => 'bouwtekening', + 'title' => 'Bouwtekening Dorpsstraat 12', + ], + ]; + + $row = $this->inbox->row(task: $task, subjects: $subjects, now: new DateTime('2026-08-31T12:00:00+00:00')); + + $this->assertSame('Claim: Bouwtekening Dorpsstraat 12', $row['displayTitle']); + $this->assertNull($row['title'], 'The stored title must STAY null.'); + $this->assertNull($task->getTitle()); + $this->assertSame('bouwtekening', $row['subject']['schema']); + }//end testATitlelessTaskGetsADisplayTitleAndStaysTitleless() + + /** + * Subject context is resolved in ONE batch for the page and attached to + * each row by object uuid; a task whose object is unknown gets null. + * + * @return void + */ + public function testSubjectContextIsBatchedForThePage(): void { + $objects = $this->createMock(originalClassName: AbstractObjectMapper::class); + $objects->expects($this->once())->method('findMultiple')->willReturnCallback( + function (array $ids): array { + $this->assertEqualsCanonicalizing(['obj-1', 'obj-2'], $ids); + $one = new class () implements \JsonSerializable { + public function jsonSerialize(): array { + return ['uuid' => 'obj-1', 'register' => 'zaken', 'schema' => 'zaak', 'name' => 'Zaak 42']; + } + }; + $noUuid = new class () implements \JsonSerializable { + public function jsonSerialize(): array { + return ['name' => 'orphan']; + } + }; + + return [$one, $noUuid]; + } + ); + $inbox = new TaskInboxService(tasks: $this->tasks, temporal: new TaskTemporalProjection(), logger: new NullLogger(), objects: $objects); + + $first = new Task(); + $first->setUuid('t-1'); + $first->setObjectUuid('obj-1'); + $second = new Task(); + $second->setUuid('t-2'); + $second->setObjectUuid('obj-2'); + $third = new Task(); + $third->setUuid('t-3'); + $this->tasks->method('findInbox')->willReturn([$first, $second, $third]); + $this->tasks->method('countInbox')->willReturn(3); + + $result = $inbox->inbox(criteria: new TaskInboxCriteria(uid: 'alice')); + + $this->assertSame('Zaak 42', $result['results'][0]['subject']['title']); + $this->assertSame('zaak', $result['results'][0]['subject']['schema']); + $this->assertNull($result['results'][1]['subject']); + $this->assertNull($result['results'][2]['subject']); + $this->assertSame('Task: Zaak 42', $result['results'][0]['displayTitle']); + $this->assertSame('Task: obj-2', $result['results'][1]['displayTitle']); + $this->assertSame('Task', $result['results'][2]['displayTitle']); + }//end testSubjectContextIsBatchedForThePage() + + /** + * A failing object store never fails the inbox: context reads null. + * + * @return void + */ + public function testAFailingObjectStoreLeavesContextNull(): void { + $objects = $this->createMock(originalClassName: AbstractObjectMapper::class); + $objects->method('findMultiple')->willThrowException(new \RuntimeException('store down')); + $inbox = new TaskInboxService(tasks: $this->tasks, temporal: new TaskTemporalProjection(), logger: new NullLogger(), objects: $objects); + + $task = new Task(); + $task->setUuid('t-1'); + $task->setObjectUuid('obj-1'); + $this->tasks->method('findInbox')->willReturn([$task]); + $this->tasks->method('countInbox')->willReturn(1); + + $result = $inbox->inbox(criteria: new TaskInboxCriteria(uid: 'alice')); + + $this->assertSame(1, $result['total']); + $this->assertNull($result['results'][0]['subject']); + }//end testAFailingObjectStoreLeavesContextNull() + + /** + * Without an object store, no lookup is attempted and the offset is clamped. + * + * @return void + */ + public function testWithoutAnObjectStoreNoLookupIsAttempted(): void { + $task = new Task(); + $task->setUuid('t-1'); + $task->setObjectUuid('obj-1'); + $this->tasks->method('findInbox')->willReturnCallback( + function (TaskInboxCriteria $c, int $limit, int $offset) use ($task): array { + $this->assertSame(0, $offset, 'a negative offset is clamped to zero'); + + return [$task]; + } + ); + $this->tasks->method('countInbox')->willReturn(1); + + $result = $this->inbox->inbox(criteria: new TaskInboxCriteria(uid: 'alice'), limit: 0, offset: -5); + + $this->assertSame(1, $result['limit']); + $this->assertNull($result['results'][0]['subject']); + }//end testWithoutAnObjectStoreNoLookupIsAttempted() + + /** + * A stored title wins over synthesis, verbatim. + * + * @return void + */ + public function testAStoredTitleWins(): void { + $task = new Task(); + $task->setTitle('Keur de offerte goed'); + + $row = $this->inbox->row(task: $task, subjects: [], now: new DateTime()); + + $this->assertSame('Keur de offerte goed', $row['displayTitle']); + }//end testAStoredTitleWins() + + /** + * The row carries the DERIVED projection; the serialised task does not. + * + * @return void + */ + public function testTheProjectionLivesOnTheRowOnly(): void { + $task = new Task(); + $task->setDueAt(new DateTime('2026-08-01T00:00:00+00:00')); + + $row = $this->inbox->row(task: $task, subjects: [], now: new DateTime('2026-08-31T00:00:00+00:00')); + + $this->assertTrue($row['overdue']); + $this->assertSame(30, $row['daysOverdue']); + $this->assertArrayNotHasKey('overdue', $task->jsonSerialize()); + }//end testTheProjectionLivesOnTheRowOnly() +}//end class diff --git a/tests/Unit/Service/Task/TaskPerformerResolverTest.php b/tests/Unit/Service/Task/TaskPerformerResolverTest.php new file mode 100644 index 0000000000..010232b212 --- /dev/null +++ b/tests/Unit/Service/Task/TaskPerformerResolverTest.php @@ -0,0 +1,224 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Task\TaskPerformerResolver; +use OCP\IGroupManager; +use OCP\IGroup; +use OCP\IUser; +use PHPUnit\Framework\TestCase; + +/** + * Strategy behaviour over expanded pools. + * + * @covers \OCA\OpenRegister\Service\Task\TaskPerformerResolver + * @covers \OCA\OpenRegister\Db\Task + */ +class TaskPerformerResolverTest extends TestCase { + + /** + * A group backend serving one group with the given member uids. + * + * @param string $groupId The group id served. + * @param array $memberUids Its members. + * + * @return IGroupManager The mock. + */ + private function backendWithGroup(string $groupId, array $memberUids): IGroupManager { + $users = []; + foreach ($memberUids as $uid) { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $users[] = $user; + } + + $group = $this->createMock(IGroup::class); + $group->method('getUsers')->willReturn($users); + + $manager = $this->createMock(IGroupManager::class); + $manager->method('get')->willReturnCallback( + static fn (string $gid) => ($gid === $groupId) ? $group : null + ); + + return $manager; + }//end backendWithGroup() + + /** + * THE SPEC'S SCENARIO: least-loaded over a pool whose members are all + * filtered out, no fallback — the task stays unassigned, and no implicit + * assignment to requester or a system identity occurs. + * + * @return void + */ + public function testAStrategyThatFindsNobodyAssignsNobody(): void { + $mapper = $this->createMock(TaskMapper::class); + $mapper->method('countOpenAssigned')->willReturn([]); + $resolver = new TaskPerformerResolver( + tasks: $mapper, + groupManager: $this->backendWithGroup('empty-team', []) + ); + + $task = new Task(); + $task->setRequester('rita'); + $task->setCandidateGroups(['empty-team']); + $task->setRoutingStrategy('least-loaded'); + $task->setRoutingFallback(null); + + $this->assertNull($resolver->resolveAssignee(task: $task)); + }//end testAStrategyThatFindsNobodyAssignsNobody() + + /** + * The ONLY fallback is the configured one. + * + * @return void + */ + public function testTheConfiguredFallbackIsUsed(): void { + $mapper = $this->createMock(TaskMapper::class); + $resolver = new TaskPerformerResolver(tasks: $mapper, groupManager: null); + + $task = new Task(); + $task->setRoutingStrategy('least-loaded'); + $task->setRoutingFallback('fallback-franz'); + + $this->assertSame('fallback-franz', $resolver->resolveAssignee(task: $task)); + }//end testTheConfiguredFallbackIsUsed() + + /** + * least-loaded picks the member with the fewest open tasks. + * + * @return void + */ + public function testLeastLoadedPicksTheEmptiestPlate(): void { + $mapper = $this->createMock(TaskMapper::class); + $mapper->method('countOpenAssigned')->willReturn( + [ + 'anna' => 4, + 'bert' => 1, + ] + ); + $resolver = new TaskPerformerResolver(tasks: $mapper, groupManager: null); + + $task = new Task(); + $task->setCandidateUsers(['anna', 'bert', 'carla']); + $task->setRoutingStrategy('least-loaded'); + + // carla holds zero open tasks and wins. + $this->assertSame('carla', $resolver->resolveAssignee(task: $task)); + }//end testLeastLoadedPicksTheEmptiestPlate() + + /** + * round-robin picks the least recently assigned; never-assigned first. + * + * @return void + */ + public function testRoundRobinPicksTheLeastRecentlyAssigned(): void { + $mapper = $this->createMock(TaskMapper::class); + $mapper->method('latestAssignedAt')->willReturn( + [ + 'anna' => '2026-08-30 10:00:00', + 'bert' => '2026-08-29 10:00:00', + ] + ); + $resolver = new TaskPerformerResolver(tasks: $mapper, groupManager: null); + + $withVirgin = new Task(); + $withVirgin->setCandidateUsers(['anna', 'bert', 'nils']); + $withVirgin->setRoutingStrategy('round-robin'); + $this->assertSame('nils', $resolver->resolveAssignee(task: $withVirgin)); + + $mapper2 = $this->createMock(TaskMapper::class); + $mapper2->method('latestAssignedAt')->willReturn( + [ + 'anna' => '2026-08-30 10:00:00', + 'bert' => '2026-08-29 10:00:00', + ] + ); + $resolver2 = new TaskPerformerResolver(tasks: $mapper2, groupManager: null); + $allSeasoned = new Task(); + $allSeasoned->setCandidateUsers(['anna', 'bert']); + $allSeasoned->setRoutingStrategy('round-robin'); + $this->assertSame('bert', $resolver2->resolveAssignee(task: $allSeasoned)); + }//end testRoundRobinPicksTheLeastRecentlyAssigned() + + /** + * single-role assigns only when the role resolves to exactly one person. + * + * @return void + */ + public function testSingleRoleNeedsExactlyOne(): void { + $mapper = $this->createMock(TaskMapper::class); + + $one = new Task(); + $one->setCandidateRole('controller'); + $one->setRoutingStrategy('single-role'); + $resolverOne = new TaskPerformerResolver( + tasks: $mapper, + groupManager: $this->backendWithGroup('controller', ['carl']) + ); + $this->assertSame('carl', $resolverOne->resolveAssignee(task: $one)); + + $many = new Task(); + $many->setCandidateRole('reviewers'); + $many->setRoutingStrategy('single-role'); + $resolverMany = new TaskPerformerResolver( + tasks: $mapper, + groupManager: $this->backendWithGroup('reviewers', ['rey', 'ria']) + ); + $this->assertNull($resolverMany->resolveAssignee(task: $many)); + }//end testSingleRoleNeedsExactlyOne() + + /** + * or-set never picks: everyone in the set may claim. + * + * @return void + */ + public function testOrSetLeavesTheWholeSetClaimable(): void { + $mapper = $this->createMock(TaskMapper::class); + $resolver = new TaskPerformerResolver(tasks: $mapper, groupManager: null); + + $task = new Task(); + $task->setCandidateUsers(['anna', 'bert']); + $task->setRoutingStrategy('or-set'); + + $this->assertNull($resolver->resolveAssignee(task: $task)); + }//end testOrSetLeavesTheWholeSetClaimable() + + /** + * hierarchical takes the first tier in declared order. + * + * @return void + */ + public function testHierarchicalTakesTheFirstTier(): void { + $mapper = $this->createMock(TaskMapper::class); + $resolver = new TaskPerformerResolver(tasks: $mapper, groupManager: null); + + $task = new Task(); + $task->setCandidateUsers(['first-line', 'second-line']); + $task->setRoutingStrategy('hierarchical'); + + $this->assertSame('first-line', $resolver->resolveAssignee(task: $task)); + }//end testHierarchicalTakesTheFirstTier() +}//end class diff --git a/tests/Unit/Service/Task/TaskPriorityTest.php b/tests/Unit/Service/Task/TaskPriorityTest.php new file mode 100644 index 0000000000..376db3df90 --- /dev/null +++ b/tests/Unit/Service/Task/TaskPriorityTest.php @@ -0,0 +1,114 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Task\TaskPriority; +use PHPUnit\Framework\TestCase; + +/** + * All four fleet scales in, one scale out; off-scale refused. + * + * @covers \OCA\OpenRegister\Service\Task\TaskPriority + * @covers \OCA\OpenRegister\Exception\TaskValidationException + */ +class TaskPriorityTest extends TestCase { + + /** + * Accepted values across the scales. + * + * @return array value, expected. + */ + public static function acceptedProvider(): array { + return [ + // Canonical. + 'low' => ['low', 'low'], + 'normal' => ['normal', 'normal'], + 'high' => ['high', 'high'], + 'urgent' => ['urgent', 'urgent'], + // Case-insensitive canonical. + 'High capitalised' => ['High', 'high'], + // Notification scale. + 'medium' => ['medium', 'normal'], + 'critical' => ['critical', 'urgent'], + // iCal integers: 1 is the wire format's most urgent. + 'ical 0 undefined' => [0, 'normal'], + 'ical 1' => [1, 'urgent'], + 'ical 2' => [2, 'high'], + 'ical 4' => [4, 'high'], + 'ical 5' => [5, 'normal'], + 'ical 6' => [6, 'low'], + 'ical 9' => [9, 'low'], + // A numeric string reads as its integer. + 'ical numeric string' => ['1', 'urgent'], + ]; + }//end acceptedProvider() + + /** + * The scales land where the table says. + * + * @dataProvider acceptedProvider + * + * @param mixed $value The incoming value. + * @param string $expected The normalised priority. + * + * @return void + */ + public function testAccepted(mixed $value, string $expected): void { + $this->assertSame($expected, TaskPriority::normalise(value: $value)); + }//end testAccepted() + + /** + * Refused values, each named in the message. + * + * @return array value, named fragment. + */ + public static function refusedProvider(): array { + return [ + // THE PIPELINQ DEFECT: a default outside its own enum. + 'normaal' => ['normaal', "'normaal'"], + 'ical out of range' => [10, "'10'"], + 'negative' => [-1, "'-1'"], + 'empty string' => ['', "''"], + 'unknown word' => ['hoog', "'hoog'"], + ]; + }//end refusedProvider() + + /** + * Off-scale is refused naming the value, never coerced. + * + * @dataProvider refusedProvider + * + * @param mixed $value The refused value. + * @param string $named The fragment the message must carry. + * + * @return void + */ + public function testRefusedByName(mixed $value, string $named): void { + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage($named); + TaskPriority::normalise(value: $value); + }//end testRefusedByName() +}//end class diff --git a/tests/Unit/Service/Task/TaskServiceTest.php b/tests/Unit/Service/Task/TaskServiceTest.php new file mode 100644 index 0000000000..e44e89af79 --- /dev/null +++ b/tests/Unit/Service/Task/TaskServiceTest.php @@ -0,0 +1,1017 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCA\OpenRegister\Service\Task\TaskPerformerResolver; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * Authorization ordering, concurrency, transactionality and normalisation. + * + * @covers \OCA\OpenRegister\Service\Task\TaskService + * @covers \OCA\OpenRegister\Service\Task\TaskBuilder + * @covers \OCA\OpenRegister\Service\Task\TaskState + * @covers \OCA\OpenRegister\Service\Task\TaskPriority + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Db\TaskAudit + * @covers \OCA\OpenRegister\Db\TaskRelation + * @covers \OCA\OpenRegister\Exception\TaskValidationException + * @covers \OCA\OpenRegister\Exception\TaskAccessDeniedException + * @covers \OCA\OpenRegister\Exception\TaskConflictException + */ +class TaskServiceTest extends TestCase { + + /** + * The task table, mocked. + * + * @var TaskMapper&MockObject + */ + private TaskMapper&MockObject $tasks; + + /** + * The candidate index, mocked. + * + * @var TaskCandidateMapper&MockObject + */ + private TaskCandidateMapper&MockObject $candidates; + + /** + * The relations, mocked. + * + * @var TaskRelationMapper&MockObject + */ + private TaskRelationMapper&MockObject $relations; + + /** + * The audit, mocked. + * + * @var TaskAuditMapper&MockObject + */ + private TaskAuditMapper&MockObject $audits; + + /** + * The authorization, mocked (its own suite tests the real rules). + * + * @var TaskAuthorizationService&MockObject + */ + private TaskAuthorizationService&MockObject $authorization; + + /** + * The connection, mocked for transaction assertions. + * + * @var IDBConnection&MockObject + */ + private IDBConnection&MockObject $db; + + /** + * Build a service over the mocks. + * + * @return TaskService The service. + */ + private function service(): TaskService { + return new TaskService( + tasks: $this->tasks, + candidates: $this->candidates, + relations: $this->relations, + audits: $this->audits, + authorization: $this->authorization, + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->db, + logger: new NullLogger(), + builder: new TaskBuilder() + ); + }//end service() + + /** + * Fresh mocks per test. + * + * @return void + */ + protected function setUp(): void { + $this->tasks = $this->createMock(TaskMapper::class); + $this->candidates = $this->createMock(TaskCandidateMapper::class); + $this->relations = $this->createMock(TaskRelationMapper::class); + $this->audits = $this->createMock(TaskAuditMapper::class); + $this->authorization = $this->createMock(TaskAuthorizationService::class); + $this->db = $this->createMock(IDBConnection::class); + + // Default happy plumbing: insert/update hand the entity back with an + // id, and authorization passes unless a test says otherwise. + $this->tasks->method('insert')->willReturnCallback( + static function (Task $task): Task { + if ($task->getId() === null) { + $task->setId(41); + } + + return $task; + } + ); + $this->tasks->method('update')->willReturnArgument(0); + $this->tasks->method('updateIfOpen')->willReturn(true); + $this->audits->method('insert')->willReturnArgument(0); + }//end setUp() + + /** + * An open, assigned task as the mapper would return it. + * + * @param string|null $runUuid Optional run provenance. + * + * @return Task The task. + */ + private function openTask(?string $runUuid = null): Task { + $task = new Task(); + $task->setId(7); + $task->setUuid('t-7'); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('alice'); + $task->setRequester('rita'); + $task->setRunUuid($runUuid); + + return $task; + }//end openTask() + + /** + * TWO CLAIMS RACE, ONE LOSES: the losing conditional update yields a + * conflict and a rollback — never a silent overwrite via update(). + * + * @return void + */ + public function testTheClaimRaceLoserGetsAConflictNotAnOverwrite(): void { + $pooled = $this->openTask(); + $pooled->setAssignee(null); + $this->tasks->method('findByUuid')->willReturn($pooled); + $this->tasks->expects($this->once())->method('claim')->willReturn(false); + $this->tasks->expects($this->never())->method('updateIfOpen'); + $this->db->expects($this->once())->method('beginTransaction'); + $this->db->expects($this->once())->method('rollBack'); + $this->db->expects($this->never())->method('commit'); + + $this->expectException(TaskConflictException::class); + $this->service()->claim(uuid: 't-7', actor: 'bob'); + }//end testTheClaimRaceLoserGetsAConflictNotAnOverwrite() + + /** + * The winner's claim commits with its audit entry. + * + * @return void + */ + public function testTheClaimWinnerCommitsWithAnAuditEntry(): void { + $pooled = $this->openTask(); + $pooled->setAssignee(null); + $this->tasks->method('findByUuid')->willReturn($pooled); + $this->tasks->expects($this->once())->method('claim')->with(taskId: 7, uid: 'bob')->willReturn(true); + $this->audits->expects($this->once())->method('insert'); + $this->db->expects($this->once())->method('commit'); + $this->db->expects($this->never())->method('rollBack'); + + $this->service()->claim(uuid: 't-7', actor: 'bob'); + }//end testTheClaimWinnerCommitsWithAnAuditEntry() + + /** + * A REJECTING OUTCOME WITHOUT A COMMENT IS REFUSED — before any + * transaction opens, so the task provably keeps its pre-call state. + * + * @return void + */ + public function testARejectionWithoutACommentMovesNothing(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->db->expects($this->never())->method('beginTransaction'); + $this->tasks->expects($this->never())->method('updateIfOpen'); + + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage("'rejected'"); + $this->service()->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: '', actor: 'alice'); + }//end testARejectionWithoutACommentMovesNothing() + + /** + * AN INJECTED AUDIT-WRITE FAILURE UNWINDS THE COMPLETION: rollback runs, + * commit never does, and the failure propagates. A completed task + * without its audit entry is not a reachable state. + * + * @return void + */ + public function testAnAuditFailureLeavesTheTaskNotCompleted(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->audits->method('insert')->willThrowException(new RuntimeException('audit store down')); + $this->db->expects($this->once())->method('beginTransaction'); + $this->db->expects($this->once())->method('rollBack'); + $this->db->expects($this->never())->method('commit'); + + $this->expectException(RuntimeException::class); + $this->service()->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, actor: 'alice'); + }//end testAnAuditFailureLeavesTheTaskNotCompleted() + + /** + * expires_at before due_at is refused naming BOTH values. + * + * @return void + */ + public function testExpiryBeforeDueIsRefusedNamingBothValues(): void { + try { + $this->service()->create( + data: [ + 'dueAt' => '2026-09-10T12:00:00+00:00', + 'expiresAt' => '2026-09-01T12:00:00+00:00', + ], + actor: 'rita' + ); + $this->fail('An expiry before the due date was accepted.'); + } catch (TaskValidationException $refused) { + $this->assertStringContainsString('2026-09-01', $refused->getMessage()); + $this->assertStringContainsString('2026-09-10', $refused->getMessage()); + } + }//end testExpiryBeforeDueIsRefusedNamingBothValues() + + /** + * A legacy status maps on create: state, materialised terminality and + * the preserved outcome land together. + * + * @return void + */ + public function testCreateMapsALegacyStatusAndPreservesTheOutcome(): void { + // A closed task is importable only on the TRUSTED path (migrations); + // the HTTP path refuses it, see testCreateOverHttpRefusesATerminalState. + $created = $this->service()->import(data: ['state' => 'done'], actor: 'rita'); + + $this->assertSame(Task::STATE_COMPLETED, $created->getState()); + $this->assertTrue($created->getIsTerminal()); + $this->assertSame('done', $created->getOutcome()); + }//end testCreateMapsALegacyStatusAndPreservesTheOutcome() + + /** + * A checklist arriving as a STRING is refused — the procest shape. + * + * @return void + */ + public function testAStringChecklistIsRefused(): void { + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage('string containing JSON'); + $this->service()->create(data: ['checklist' => '[{"id":"c1"}]'], actor: 'rita'); + }//end testAStringChecklistIsRefused() + + /** + * ONE WRITE PATH, TWO REPRESENTATIONS: the candidate index rows written + * in the create transaction agree exactly with the candidate JSON. + * + * @return void + */ + public function testCandidateJsonAndCandidateIndexAgree(): void { + $captured = null; + $this->candidates->expects($this->once())->method('replaceForTask')->willReturnCallback( + static function (int $taskId, array $candidates) use (&$captured): void { + $captured = $candidates; + } + ); + + $created = $this->service()->create( + data: [ + 'candidateUsers' => ['ursula'], + 'candidateGroups' => ['reviewers', 'controllers'], + 'candidateRole' => 'fiatteur', + ], + actor: 'rita' + ); + + $expected = [ + ['kind' => 'user', 'ref' => 'ursula'], + ['kind' => 'group', 'ref' => 'reviewers'], + ['kind' => 'group', 'ref' => 'controllers'], + ['kind' => 'role', 'ref' => 'fiatteur'], + ]; + $this->assertSame($expected, $captured); + $this->assertSame(['ursula'], $created->getCandidateUsers()); + $this->assertSame(['reviewers', 'controllers'], $created->getCandidateGroups()); + }//end testCandidateJsonAndCandidateIndexAgree() + + /** + * KILLING A RUN EMPTIES ITS INBOXES: every open task of the run is + * terminated with a reason naming the run and its status, audited with + * the propagation source as actor — and the read is BY RUN UUID, so a + * task with run_uuid null is structurally unreachable. + * + * @return void + */ + public function testTerminateForRunTerminatesOpenTasksWithTheReason(): void { + $first = $this->openTask(runUuid: 'run-9'); + $second = $this->openTask(runUuid: 'run-9'); + $second->setId(8); + $second->setUuid('t-8'); + $updated = []; + $this->tasks = $this->createMock(TaskMapper::class); + $this->tasks->expects($this->once()) + ->method('findOpenByRunUuid') + ->with(runUuid: 'run-9') + ->willReturn([$first, $second]); + $this->tasks->method('updateIfOpen')->willReturnCallback( + static function (Task $task) use (&$updated): bool { + $updated[] = $task; + + return true; + } + ); + $auditEntries = []; + $this->audits->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$auditEntries): TaskAudit { + $auditEntries[] = $entry; + + return $entry; + } + ); + + $count = $this->service()->terminateForRun(runUuid: 'run-9', runStatus: 'stopped'); + + $this->assertSame(2, $count); + $this->assertCount(2, $updated); + foreach ($updated as $task) { + $this->assertSame(Task::STATE_TERMINATED, $task->getState()); + $this->assertTrue($task->getIsTerminal()); + } + + $this->assertCount(2, $auditEntries); + foreach ($auditEntries as $entry) { + $this->assertSame('flow-run:run-9', $entry->getActor()); + $this->assertStringContainsString('run-9', (string)$entry->getReason()); + $this->assertStringContainsString('stopped', (string)$entry->getReason()); + } + }//end testTerminateForRunTerminatesOpenTasksWithTheReason() + + /** + * Propagation with no run uuid is a no-op that touches nothing. + * + * @return void + */ + public function testTerminateForRunWithNoUuidIsANoOp(): void { + $this->tasks->expects($this->never())->method('findOpenByRunUuid'); + $this->assertSame(0, $this->service()->terminateForRun(runUuid: ' ', runStatus: 'stopped')); + }//end testTerminateForRunWithNoUuidIsANoOp() + + /** + * A verb against a terminal task conflicts NAMING the current state. + * + * @return void + */ + public function testAVerbOnATerminalTaskConflictsNamingTheState(): void { + $done = $this->openTask(); + $done->setState(Task::STATE_COMPLETED); + $done->setIsTerminal(true); + $this->tasks->method('findByUuid')->willReturn($done); + + try { + $this->service()->claim(uuid: 't-7', actor: 'bob'); + $this->fail('A terminal task accepted a verb.'); + } catch (TaskConflictException $conflict) { + $this->assertStringContainsString(Task::STATE_COMPLETED, $conflict->getMessage()); + } + }//end testAVerbOnATerminalTaskConflictsNamingTheState() + + /** + * A DENIED VERB IS AUDITED: the denial appends an authorized=false entry + * and the denial still propagates. + * + * @return void + */ + public function testADenialIsAuditedAndStillDenies(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->authorization->method('assertMay')->willThrowException( + new TaskAccessDeniedException("Verb 'complete' denied: only the current assignee may perform it.") + ); + + $denialEntry = null; + $this->audits->expects($this->once())->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$denialEntry): TaskAudit { + $denialEntry = $entry; + + return $entry; + } + ); + $this->tasks->expects($this->never())->method('updateIfOpen'); + + try { + $this->service()->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, actor: 'mallory'); + $this->fail('A denied verb went through.'); + } catch (TaskAccessDeniedException) { + // Expected. + } + + $this->assertNotNull($denialEntry); + $this->assertFalse($denialEntry->getAuthorized()); + $this->assertSame('mallory', $denialEntry->getActor()); + }//end testADenialIsAuditedAndStillDenies() + + /** + * A TASK WITH NO RUN BEHAVES IDENTICALLY TO ONE WITH A RUN: same verbs, + * same resulting states, same audit actions — no code path treats "no + * run" as degraded. + * + * @return void + */ + public function testARunlessTaskCompletesIdenticallyToARunfulOne(): void { + $results = []; + foreach ([null, 'run-42'] as $runUuid) { + $this->setUp(); + $task = $this->openTask(runUuid: $runUuid); + $this->tasks->method('findByUuid')->willReturn($task); + $actions = []; + $this->audits->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$actions): TaskAudit { + $actions[] = $entry->getAction(); + + return $entry; + } + ); + + $completed = $this->service()->complete(uuid: 't-7', outcome: 'approved', resultText: 'ok', comment: null, actor: 'alice'); + $results[] = [ + 'state' => $completed->getState(), + 'terminal' => $completed->getIsTerminal(), + 'outcome' => $completed->getOutcome(), + 'actions' => $actions, + ]; + } + + $this->assertSame($results[0], $results[1]); + $this->assertSame(Task::STATE_COMPLETED, $results[0]['state']); + }//end testARunlessTaskCompletesIdenticallyToARunfulOne() + + /** + * A delegated completion names both identities on the audit entry. + * + * @return void + */ + public function testADelegatedCompletionNamesBothIdentities(): void { + $task = $this->openTask(); + $this->tasks->method('findByUuid')->willReturn($task); + + $entries = []; + $this->audits->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$entries): TaskAudit { + $entries[] = $entry; + + return $entry; + } + ); + + $this->service()->delegate(uuid: 't-7', delegate: 'dora', mandate: 'Volmacht 2026', actor: 'alice'); + $this->service()->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, actor: 'dora'); + + $completion = end($entries); + $this->assertSame('dora', $completion->getActor()); + $this->assertSame('alice', $completion->getOnBehalfOf()); + $this->assertSame('Volmacht 2026', $completion->getMandate()); + }//end testADelegatedCompletionNamesBothIdentities() + + /** + * A checklist item is addressable by id: one item flips, the others + * stand, and the change is audited. + * + * @return void + */ + public function testAChecklistItemIsAddressableById(): void { + $task = $this->openTask(); + $task->setChecklist( + [ + ['id' => 'c1', 'label' => 'Eerste', 'description' => null, 'checked' => false], + ['id' => 'c2', 'label' => 'Tweede', 'description' => null, 'checked' => false], + ] + ); + $this->tasks->method('findByUuid')->willReturn($task); + $this->audits->expects($this->once())->method('insert'); + + $updated = $this->service()->checkChecklistItem(uuid: 't-7', itemId: 'c2', checked: true, actor: 'alice'); + + $checklist = $updated->getChecklist(); + $this->assertFalse($checklist[0]['checked']); + $this->assertTrue($checklist[1]['checked']); + }//end testAChecklistItemIsAddressableById() + + /** + * RED 1: OFFER ON AN ASSIGNED TASK IS REFUSED. Before this guard, + * `offer {"routingFallback": "mallory"}` on an active, assigned task + * made mallory the assignee, after which mallory's complete passed. + * Now the assigned task conflicts, the pool and fallback are untouched, + * and nothing is written. + * + * @return void + */ + public function testOfferIsRefusedOnAnAssignedTask(): void { + $assigned = $this->openTask(); + $this->tasks->method('findByUuid')->willReturn($assigned); + $this->tasks->expects($this->never())->method('updateIfOpen'); + $this->candidates->expects($this->never())->method('replaceForTask'); + + try { + $this->service()->offer(uuid: 't-7', pool: ['routingFallback' => 'mallory'], actor: 'rita'); + $this->fail('An assigned task accepted an offer.'); + } catch (TaskConflictException $conflict) { + $this->assertStringContainsString('already assigned', $conflict->getMessage()); + } + + $this->assertSame('alice', $assigned->getAssignee()); + $this->assertNull($assigned->getRoutingFallback()); + }//end testOfferIsRefusedOnAnAssignedTask() + + /** + * Offer on a POOLED task still works, for the requester. + * + * @return void + */ + public function testOfferRoutesAPooledTask(): void { + $pooled = $this->openTask(); + $pooled->setAssignee(null); + $this->tasks->method('findByUuid')->willReturn($pooled); + $this->candidates->expects($this->once())->method('replaceForTask'); + + $offered = $this->service()->offer(uuid: 't-7', pool: ['candidateUsers' => ['pat', 'quinn']], actor: 'rita'); + + $this->assertSame(['pat', 'quinn'], $offered->getCandidateUsers()); + $this->assertSame(Task::STATE_ENABLED, $offered->getState()); + }//end testOfferRoutesAPooledTask() + + /** + * AUTHORIZATION RUNS BEFORE THE TERMINALITY CHECK: a stranger probing a + * completed task gets the denial, never a 409 that names its state. + * + * @return void + */ + public function testAuthorizationRunsBeforeTheTerminalityCheck(): void { + $done = $this->openTask(); + $done->setState(Task::STATE_COMPLETED); + $done->setIsTerminal(true); + $this->tasks->method('findByUuid')->willReturn($done); + $this->authorization->method('assertMay')->willThrowException(new TaskAccessDeniedException('denied')); + + $this->expectException(TaskAccessDeniedException::class); + $this->service()->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, actor: 'mallory'); + }//end testAuthorizationRunsBeforeTheTerminalityCheck() + + /** + * TWO COMPLETIONS RACE: the second conditional update affects no row, + * so it conflicts and rolls back instead of overwriting the first outcome. + * + * @return void + */ + public function testASecondCompletionLosesTheConditionalUpdate(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->tasks = $this->createMock(TaskMapper::class); + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->tasks->method('updateIfOpen')->willReturn(false); + $this->audits->expects($this->never())->method('insert'); + $this->db->expects($this->once())->method('rollBack'); + $this->db->expects($this->never())->method('commit'); + + $this->expectException(TaskConflictException::class); + $this->service()->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: 'no', actor: 'alice'); + }//end testASecondCompletionLosesTheConditionalUpdate() + + /** + * OVER HTTP, THE REQUESTER IS THE ACTOR: an ordinary caller cannot write + * somebody else's name into the seat that owns cancel and reassign. + * + * @return void + */ + public function testCreateOverHttpPinsTheRequesterToTheActor(): void { + $this->authorization->method('isAdministrator')->willReturn(false); + + $created = $this->service()->create(data: ['requester' => 'director'], actor: 'mallory'); + + $this->assertSame('mallory', $created->getRequester()); + }//end testCreateOverHttpPinsTheRequesterToTheActor() + + /** + * OVER HTTP, A TASK CANNOT BE BORN CLOSED: 'approved' maps to completed + * with nobody having completed it, so it is refused for non-admins. + * + * @return void + */ + public function testCreateOverHttpRefusesATerminalState(): void { + $this->authorization->method('isAdministrator')->willReturn(false); + $this->tasks->expects($this->never())->method('insert'); + + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage("'approved'"); + $this->service()->create(data: ['state' => 'approved'], actor: 'mallory'); + }//end testCreateOverHttpRefusesATerminalState() + + /** + * An administrator keeps the full create surface over HTTP. + * + * @return void + */ + public function testAnAdministratorMayNameARequesterOnCreate(): void { + $this->authorization->method('isAdministrator')->willReturn(true); + + $created = $this->service()->create(data: ['requester' => 'director'], actor: 'root'); + + $this->assertSame('director', $created->getRequester()); + }//end testAnAdministratorMayNameARequesterOnCreate() + + /** + * A delegation needs a delegate, not only a mandate. + * + * @return void + */ + public function testDelegateRefusesAnEmptyDelegate(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->db->expects($this->never())->method('beginTransaction'); + + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage('delegate'); + $this->service()->delegate(uuid: 't-7', delegate: ' ', mandate: 'Volmacht', actor: 'alice'); + }//end testDelegateRefusesAnEmptyDelegate() + + /** + * A re-delegation keeps naming the ORIGINAL performer. + * + * @return void + */ + public function testReDelegationKeepsTheOriginalOnBehalfOf(): void { + $task = $this->openTask(); + $this->tasks->method('findByUuid')->willReturn($task); + + $this->service()->delegate(uuid: 't-7', delegate: 'dora', mandate: 'Volmacht 1', actor: 'alice'); + $this->service()->delegate(uuid: 't-7', delegate: 'ed', mandate: 'Volmacht 2', actor: 'dora'); + + $this->assertSame('ed', $task->getAssignee()); + $this->assertSame('alice', $task->getOnBehalfOf()); + }//end testReDelegationKeepsTheOriginalOnBehalfOf() + + /** + * PROPAGATION CONTINUES PAST A FAILING TASK: one broken row does not + * orphan the rest of the run's tasks in their assignees' inboxes. + * + * @return void + */ + public function testTerminateForRunContinuesPastAFailingTask(): void { + $first = $this->openTask(runUuid: 'run-9'); + $second = $this->openTask(runUuid: 'run-9'); + $second->setId(8); + $second->setUuid('t-8'); + $third = $this->openTask(runUuid: 'run-9'); + $third->setId(9); + $third->setUuid('t-9'); + + $this->tasks = $this->createMock(TaskMapper::class); + $this->tasks->method('findOpenByRunUuid')->willReturn([$first, $second, $third]); + $this->tasks->method('updateIfOpen')->willReturnCallback( + static function (Task $task): bool { + if ($task->getUuid() === 't-8') { + throw new RuntimeException('row locked'); + } + + return true; + } + ); + $this->db->expects($this->exactly(3))->method('beginTransaction'); + $this->db->expects($this->exactly(2))->method('commit'); + $this->db->expects($this->once())->method('rollBack'); + + $this->assertSame(2, $this->service()->terminateForRun(runUuid: 'run-9', runStatus: 'stopped')); + $this->assertSame(Task::STATE_TERMINATED, $third->getState()); + }//end testTerminateForRunContinuesPastAFailingTask() + + /** + * unclaim returns the task to its pool and clears delegation state. + * + * @return void + */ + public function testUnclaimReturnsTheTaskToItsPool(): void { + $task = $this->openTask(); + $task->setOnBehalfOf('someone'); + $this->tasks->method('findByUuid')->willReturn($task); + $this->audits->expects($this->once())->method('insert'); + + $pooled = $this->service()->unclaim(uuid: 't-7', actor: 'alice'); + + $this->assertNull($pooled->getAssignee()); + $this->assertNull($pooled->getOnBehalfOf()); + $this->assertSame(Task::STATE_ENABLED, $pooled->getState()); + $this->assertSame('unclaim', $pooled->getLastAction()); + }//end testUnclaimReturnsTheTaskToItsPool() + + /** + * assign and reassign set the holder, activate, and refuse an empty one. + * + * @return void + */ + public function testAssignAndReassignSetTheHolder(): void { + $task = $this->openTask(); + $task->setAssignee(null); + $task->setState(Task::STATE_ENABLED); + $this->tasks->method('findByUuid')->willReturn($task); + + $assigned = $this->service()->assign(uuid: 't-7', assignee: 'bob', actor: 'rita'); + $this->assertSame('bob', $assigned->getAssignee()); + $this->assertSame(Task::STATE_ACTIVE, $assigned->getState()); + + $reassigned = $this->service()->reassign(uuid: 't-7', assignee: 'carol', actor: 'rita'); + $this->assertSame('carol', $reassigned->getAssignee()); + $this->assertSame('reassign', $reassigned->getLastAction()); + + $this->expectException(TaskValidationException::class); + $this->service()->assign(uuid: 't-7', assignee: ' ', actor: 'rita'); + }//end testAssignAndReassignSetTheHolder() + + /** + * cancel terminates with outcome cancelled and records the reason. + * + * @return void + */ + public function testCancelTerminatesWithAReason(): void { + $task = $this->openTask(); + $this->tasks->method('findByUuid')->willReturn($task); + $reason = null; + $this->audits->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$reason): TaskAudit { + $reason = $entry->getReason(); + + return $entry; + } + ); + + $cancelled = $this->service()->cancel(uuid: 't-7', reason: 'Aanvraag ingetrokken', actor: 'rita'); + + $this->assertSame(Task::STATE_TERMINATED, $cancelled->getState()); + $this->assertTrue($cancelled->getIsTerminal()); + $this->assertSame('cancelled', $cancelled->getOutcome()); + $this->assertSame('Aanvraag ingetrokken', $reason); + }//end testCancelTerminatesWithAReason() + + /** + * resolve completes with the resolved outcome. + * + * @return void + */ + public function testResolveCompletesWithTheResolvedOutcome(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + + $resolved = $this->service()->resolve(uuid: 't-7', resultText: 'Klaar', comment: null, actor: 'alice'); + + $this->assertSame(Task::STATE_COMPLETED, $resolved->getState()); + $this->assertSame('resolved', $resolved->getOutcome()); + $this->assertSame('Klaar', $resolved->getResultText()); + $this->assertSame('alice', $resolved->getCompletedBy()); + }//end testResolveCompletesWithTheResolvedOutcome() + + /** + * terminateAsMoot terminates an open task with the source as actor and + * leaves an already-terminal one exactly as it ended. + * + * @return void + */ + public function testTerminateAsMootIsIdempotent(): void { + $open = $this->openTask(); + $this->tasks->method('findByUuid')->willReturn($open); + $actor = null; + $this->audits->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$actor): TaskAudit { + $actor = $entry->getActor(); + + return $entry; + } + ); + + $terminated = $this->service()->terminateAsMoot(uuid: 't-7', reason: 'Branch closed', source: 'flow-node:gateway'); + $this->assertSame(Task::STATE_TERMINATED, $terminated->getState()); + $this->assertSame('flow-node:gateway', $actor); + + // Second observation: already terminal, nothing written. + $this->db->expects($this->never())->method('beginTransaction'); + $again = $this->service()->terminateAsMoot(uuid: 't-7', reason: 'Branch closed', source: 'flow-node:gateway'); + $this->assertSame(Task::STATE_TERMINATED, $again->getState()); + }//end testTerminateAsMootIsIdempotent() + + /** + * get and auditTrail read through the mappers. + * + * @return void + */ + public function testGetAndAuditTrailRead(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->audits->expects($this->once())->method('findForTask')->with(taskId: 7)->willReturn([new TaskAudit()]); + + $this->assertSame('t-7', $this->service()->get(uuid: 't-7')->getUuid()); + $this->assertCount(1, $this->service()->auditTrail(uuid: 't-7')); + }//end testGetAndAuditTrailRead() + + /** + * Relations named at creation are inserted as typed rows; one without a + * role or object is refused. + * + * @return void + */ + public function testRelationsAtCreationAreInsertedAndValidated(): void { + $inserted = []; + $this->relations->method('insert')->willReturnCallback( + static function ($row) use (&$inserted) { + $inserted[] = $row; + + return $row; + } + ); + + $this->service()->import( + data: [ + 'relations' => [ + ['role' => 'case', 'objectUuid' => 'obj-1', 'registerId' => 2, 'schemaId' => 3], + ['role' => 'evidence', 'objectUuid' => 'obj-2'], + 'not-an-array', + ], + ], + actor: 'rita' + ); + $this->assertCount(2, $inserted); + $this->assertSame('case', $inserted[0]->getRole()); + $this->assertSame(2, $inserted[0]->getRegisterId()); + + $this->expectException(TaskValidationException::class); + $this->service()->import(data: ['relations' => [['role' => '', 'objectUuid' => 'obj-1']]], actor: 'rita'); + }//end testRelationsAtCreationAreInsertedAndValidated() + + /** + * Intake refusals from the builder, each naming what was wrong: an + * unparsable date, a malformed checklist item, an unknown performer type. + * + * @return void + */ + public function testIntakeRefusalsAreNamed(): void { + $service = $this->service(); + $cases = [ + [['dueAt' => 'not a date'], 'dueAt'], + [['checklist' => [['label' => 'no id']]], 'id and a label'], + [['checklist' => 'yes'], 'string containing JSON'], + [['checklist' => 42], 'typed array'], + [['performerType' => 'robot'], "'robot'"], + [['priority' => 'normaal'], "'normaal'"], + ]; + foreach ($cases as [$data, $named]) { + try { + $service->import(data: $data, actor: 'rita'); + $this->fail('Accepted: ' . json_encode($data)); + } catch (TaskValidationException $refused) { + $this->assertStringContainsString($named, $refused->getMessage()); + } + } + }//end testIntakeRefusalsAreNamed() + + /** + * Creation carries every stored-but-uninterpreted column through + * unchanged (the round-trip the design demands for the timer columns), + * accepts a DateTime as well as an ISO string, and stamps the creator. + * + * @return void + */ + public function testCreationRoundTripsTheStoredButUninterpretedColumns(): void { + $start = new \DateTime('2026-09-02T09:00:00+00:00'); + $created = $this->service()->import( + data: [ + 'uuid' => 'fixed-uuid', + 'key' => 'EXT-1', + 'title' => 'T', + 'description' => 'D', + 'metadata' => ['x' => 1], + 'runUuid' => 'run-1', + 'nodeId' => 'node-1', + 'definitionVersion' => 4, + 'appId' => 'dossiq', + 'workflowStepId' => 'step-1', + 'organisation' => 'org-1', + 'startAt' => $start, + 'suspendedUntil' => '2026-09-03T09:00:00+00:00', + 'slaValue' => 5, + 'slaUnit' => 'days', + 'compliancePeriodDays' => 30, + 'recurrence' => 'FREQ=WEEKLY', + 'watchers' => ['w1'], + 'parentTaskId' => 1, + 'epicTaskId' => 2, + 'percentComplete' => 10, + 'responses' => [['a' => 1]], + 'evidence' => [['file' => 9]], + 'outcome' => 'custom', + 'state' => 'in-progress', + ], + actor: 'rita' + ); + + $this->assertSame('fixed-uuid', $created->getUuid()); + $this->assertSame('EXT-1', $created->getTaskKey()); + $this->assertSame(4, $created->getDefinitionVersion()); + $this->assertSame($start, $created->getStartAt()); + $this->assertSame('2026-09-03T09:00:00+00:00', $created->getSuspendedUntil()?->format('c')); + $this->assertSame(5, $created->getSlaValue()); + $this->assertSame('days', $created->getSlaUnit()); + $this->assertSame(30, $created->getCompliancePeriodDays()); + $this->assertSame('FREQ=WEEKLY', $created->getRecurrence()); + $this->assertSame(['w1'], $created->getWatchers()); + $this->assertSame(2, $created->getEpicTaskId()); + $this->assertSame(10, $created->getPercentComplete()); + // An explicit outcome wins over the mapping's; the state still maps. + $this->assertSame('custom', $created->getOutcome()); + $this->assertSame(Task::STATE_ACTIVE, $created->getState()); + $this->assertSame('rita', $created->getCreatedBy()); + $this->assertSame('create', $created->getLastAction()); + }//end testCreationRoundTripsTheStoredButUninterpretedColumns() + + /** + * A checklist item that does not exist is refused by id. + * + * @return void + */ + public function testAMissingChecklistItemIsRefusedById(): void { + $task = $this->openTask(); + $task->setChecklist([['id' => 'c1', 'label' => 'Een', 'description' => null, 'checked' => false]]); + $this->tasks->method('findByUuid')->willReturn($task); + + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage("'c9'"); + $this->service()->checkChecklistItem(uuid: 't-7', itemId: 'c9', checked: true, actor: 'alice'); + }//end testAMissingChecklistItemIsRefusedById() + + /** + * A denial on a task with no id (creation) is rethrown without an audit + * row, and a failing denial-audit write does not change the denial. + * + * @return void + */ + public function testDenialAuditFailuresNeverChangeTheDenial(): void { + $this->authorization->method('assertMay')->willThrowException(new TaskAccessDeniedException('no identity')); + $this->audits->expects($this->never())->method('insert'); + try { + $this->service()->import(data: [], actor: null); + $this->fail('Created without an identity.'); + } catch (TaskAccessDeniedException) { + // Expected: nothing to audit against yet. + } + + $this->setUp(); + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->authorization->method('assertMay')->willThrowException(new TaskAccessDeniedException('denied')); + $this->audits->method('insert')->willThrowException(new RuntimeException('audit down')); + + $this->expectException(TaskAccessDeniedException::class); + $this->service()->cancel(uuid: 't-7', reason: null, actor: 'mallory'); + }//end testDenialAuditFailuresNeverChangeTheDenial() + + /** + * The template freeze: id, version and snapshot land at creation. + * + * @return void + */ + public function testTheTemplateIsFrozenAtCreation(): void { + $created = $this->service()->create( + data: [ + 'templateId' => 'tpl-1', + 'templateVersion' => 3, + 'templateSnapshot' => ['checklist' => [['id' => 'c1', 'label' => 'Vast']]], + ], + actor: 'rita' + ); + + $this->assertSame('tpl-1', $created->getTemplateId()); + $this->assertSame(3, $created->getTemplateVersion()); + $this->assertSame(['checklist' => [['id' => 'c1', 'label' => 'Vast']]], $created->getTemplateSnapshot()); + }//end testTheTemplateIsFrozenAtCreation() +}//end class diff --git a/tests/Unit/Service/Task/TaskStateTest.php b/tests/Unit/Service/Task/TaskStateTest.php new file mode 100644 index 0000000000..ee161225ea --- /dev/null +++ b/tests/Unit/Service/Task/TaskStateTest.php @@ -0,0 +1,173 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Task\TaskState; +use PHPUnit\Framework\TestCase; + +/** + * The legacy-status mapping table, and its refusals. + * + * @covers \OCA\OpenRegister\Service\Task\TaskState + * @covers \OCA\OpenRegister\Exception\TaskValidationException + * @covers \OCA\OpenRegister\Db\Task + */ +class TaskStateTest extends TestCase { + + /** + * Every value the spec's minimum-coverage list names, mapped. + * + * @return array value, state, outcome. + */ + public static function mappingProvider(): array { + return [ + 'open' => ['open', Task::STATE_ENABLED, null], + 'pending' => ['pending', Task::STATE_AVAILABLE, null], + 'todo' => ['todo', Task::STATE_ENABLED, null], + 'blocked' => ['blocked', Task::STATE_ACTIVE, 'blocked'], + 'in_progress' => ['in_progress', Task::STATE_ACTIVE, null], + 'in-progress' => ['in-progress', Task::STATE_ACTIVE, null], + 'in-execution' => ['in-execution', Task::STATE_ACTIVE, null], + 'done' => ['done', Task::STATE_COMPLETED, 'done'], + 'resolved' => ['resolved', Task::STATE_COMPLETED, 'resolved'], + 'approved' => ['approved', Task::STATE_COMPLETED, 'approved'], + 'rejected' => ['rejected', Task::STATE_COMPLETED, 'rejected'], + 'waived' => ['waived', Task::STATE_DISABLED, 'waived'], + 'skipped' => ['skipped', Task::STATE_DISABLED, 'skipped'], + 'cancelled' => ['cancelled', Task::STATE_TERMINATED, 'cancelled'], + 'expired' => ['expired', Task::STATE_TERMINATED, 'expired'], + 'error' => ['error', Task::STATE_TERMINATED, 'error'], + 'dead_letter' => ['dead_letter', Task::STATE_TERMINATED, 'dead_letter'], + 'reopen' => ['reopen', Task::STATE_ENABLED, 'reopened'], + // The canonical six pass through untouched. + 'available' => [Task::STATE_AVAILABLE, Task::STATE_AVAILABLE, null], + 'active' => [Task::STATE_ACTIVE, Task::STATE_ACTIVE, null], + 'completed' => [Task::STATE_COMPLETED, Task::STATE_COMPLETED, null], + 'terminated' => [Task::STATE_TERMINATED, Task::STATE_TERMINATED, null], + 'disabled' => [Task::STATE_DISABLED, Task::STATE_DISABLED, null], + ]; + }//end mappingProvider() + + /** + * The table maps as published. + * + * @dataProvider mappingProvider + * + * @param string $value The incoming value. + * @param string $state The expected state. + * @param string|null $outcome The expected preserved outcome. + * + * @return void + */ + public function testMapping(string $value, string $state, ?string $outcome): void { + $result = TaskState::normalise(value: $value); + $this->assertSame($state, $result['state']); + $this->assertSame($outcome, $result['outcome']); + }//end testMapping() + + /** + * Two legacy spellings converge on one state without losing what they said. + * + * @return void + */ + public function testDoneAndApprovedCollapseButOutcomesDiffer(): void { + $done = TaskState::normalise(value: 'done'); + $approved = TaskState::normalise(value: 'approved'); + $this->assertSame(Task::STATE_COMPLETED, $done['state']); + $this->assertSame(Task::STATE_COMPLETED, $approved['state']); + $this->assertTrue(TaskState::isTerminal(state: $done['state'])); + $this->assertNotSame($done['outcome'], $approved['outcome']); + }//end testDoneAndApprovedCollapseButOutcomesDiffer() + + /** + * A value in no vocabulary is refused, naming itself. + * + * @return void + */ + public function testUnknownValueIsRefusedByName(): void { + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage("'openstaand'"); + TaskState::normalise(value: 'openstaand'); + }//end testUnknownValueIsRefusedByName() + + /** + * THE PROCEST DEFECT: `status:'open'` against a source vocabulary that + * does not define it MUST be refused naming the value — even though the + * fleet mapping knows the word. This is the write at + * `procest/lib/Service/Transitions/CreateTaskHandler.php:76` failing + * loudly at migration instead of laundering silently. + * + * @return void + */ + public function testOpenAgainstAVocabularyWithoutItIsRefused(): void { + $procestEnum = ['pending', 'in_progress', 'done', 'cancelled']; + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage("'open'"); + TaskState::normalise(value: 'open', sourceVocabulary: $procestEnum); + }//end testOpenAgainstAVocabularyWithoutItIsRefused() + + /** + * Nothing in the mapping produces a state outside the six, and + * terminality agrees with the entity's TERMINAL_STATES everywhere. + * + * @return void + */ + public function testEveryMappedStateIsCanonicalAndTerminalityAgrees(): void { + foreach (TaskState::mapping() as $value => [$state]) { + $this->assertContains($state, Task::STATES, sprintf("'%s' maps outside the six CMMN states", (string)$value)); + $this->assertSame( + in_array($state, Task::TERMINAL_STATES, true), + TaskState::isTerminal(state: $state) + ); + } + }//end testEveryMappedStateIsCanonicalAndTerminalityAgrees() + + /** + * No mapped value spells overdue: overdue is not a state, anywhere. + * + * @return void + */ + public function testOverdueIsNotAStateAnywhere(): void { + $this->assertArrayNotHasKey('overdue', TaskState::mapping()); + $this->assertNotContains('overdue', Task::STATES); + }//end testOverdueIsNotAStateAnywhere() + + /** + * Rejecting outcomes are recognised; approvals are not. + * + * @return void + */ + public function testRejectingOutcomes(): void { + $this->assertTrue(TaskState::isRejectingOutcome(outcome: 'rejected')); + $this->assertTrue(TaskState::isRejectingOutcome(outcome: 'Returned')); + $this->assertFalse(TaskState::isRejectingOutcome(outcome: 'approved')); + $this->assertFalse(TaskState::isRejectingOutcome(outcome: null)); + }//end testRejectingOutcomes() +}//end class diff --git a/tests/Unit/Service/Task/TaskTemporalProjectionTest.php b/tests/Unit/Service/Task/TaskTemporalProjectionTest.php new file mode 100644 index 0000000000..9ccc14f438 --- /dev/null +++ b/tests/Unit/Service/Task/TaskTemporalProjectionTest.php @@ -0,0 +1,145 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; +use PHPUnit\Framework\TestCase; + +/** + * The clock-controlled overdue derivation. + * + * @covers \OCA\OpenRegister\Service\Task\TaskTemporalProjection + * @covers \OCA\OpenRegister\Db\Task + */ +class TaskTemporalProjectionTest extends TestCase { + + /** + * The projection under test. + * + * @var TaskTemporalProjection + */ + private TaskTemporalProjection $projection; + + /** + * Build the projection. + * + * @return void + */ + protected function setUp(): void { + $this->projection = new TaskTemporalProjection(); + }//end setUp() + + /** + * A task becomes overdue with NO write: the row is byte-identical while + * the projection's answer flips with the injected clock. + * + * @return void + */ + public function testOverdueFlipsWithTheClockAndTheRowIsByteIdentical(): void { + $task = new Task(); + $task->setState(Task::STATE_ACTIVE); + $task->setDueAt(new DateTime('2026-09-01T12:00:00+00:00')); + + $serialisedBefore = json_encode($task->jsonSerialize()); + + $before = $this->projection->project(task: $task, now: new DateTime('2026-09-01T11:59:59+00:00')); + $after = $this->projection->project(task: $task, now: new DateTime('2026-09-03T12:00:01+00:00')); + + $serialisedAfter = json_encode($task->jsonSerialize()); + + $this->assertFalse($before['overdue']); + $this->assertTrue($after['overdue']); + $this->assertSame(2, $after['daysOverdue']); + $this->assertNull($after['daysUntilDue']); + // The whole point: time passed, nothing was written. + $this->assertSame($serialisedBefore, $serialisedAfter); + }//end testOverdueFlipsWithTheClockAndTheRowIsByteIdentical() + + /** + * A passed due date does not touch state — due_at ADVISES. + * + * @return void + */ + public function testAPassedDueDateLeavesStateAlone(): void { + $task = new Task(); + $task->setState(Task::STATE_ACTIVE); + $task->setDueAt(new DateTime('2026-08-01T00:00:00+00:00')); + $task->setExpiresAt(null); + + $result = $this->projection->project(task: $task, now: new DateTime('2026-08-31T00:00:00+00:00')); + + $this->assertTrue($result['overdue']); + $this->assertSame(Task::STATE_ACTIVE, $task->getState()); + }//end testAPassedDueDateLeavesStateAlone() + + /** + * No deadline, no overdue — ever. + * + * @return void + */ + public function testNoDeadlineIsNeverOverdue(): void { + $task = new Task(); + $result = $this->projection->project(task: $task, now: new DateTime('2099-01-01T00:00:00+00:00')); + + $this->assertFalse($result['overdue']); + $this->assertNull($result['daysUntilDue']); + $this->assertNull($result['daysOverdue']); + }//end testNoDeadlineIsNeverOverdue() + + /** + * With only expires_at set, the enforcing deadline drives the projection. + * + * @return void + */ + public function testExpiresAtBacksTheProjectionWhenDueAtIsNull(): void { + $task = new Task(); + $task->setExpiresAt(new DateTime('2026-09-05T00:00:00+00:00')); + + $future = $this->projection->project(task: $task, now: new DateTime('2026-09-01T00:00:00+00:00')); + $past = $this->projection->project(task: $task, now: new DateTime('2026-09-06T00:00:00+00:00')); + + $this->assertFalse($future['overdue']); + $this->assertSame(4, $future['daysUntilDue']); + $this->assertTrue($past['overdue']); + }//end testExpiresAtBacksTheProjectionWhenDueAtIsNull() + + /** + * The Task entity stores no overdue anywhere: not as a field, not in the + * serialisation. The projection is the only source. + * + * @return void + */ + public function testNothingStoredSpellsOverdue(): void { + $task = new Task(); + $row = $task->jsonSerialize(); + + $this->assertArrayNotHasKey('overdue', $row); + $this->assertArrayNotHasKey('daysUntilDue', $row); + $this->assertArrayNotHasKey('daysOverdue', $row); + $this->assertFalse(property_exists($task, 'overdue')); + }//end testNothingStoredSpellsOverdue() +}//end class diff --git a/tests/e2e/task-inbox.spec.ts b/tests/e2e/task-inbox.spec.ts new file mode 100644 index 0000000000..2452696ad8 --- /dev/null +++ b/tests/e2e/task-inbox.spec.ts @@ -0,0 +1,239 @@ +/* + * SPDX-FileCopyrightText: 2026 Open Register Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * Task inbox e2e — the two @e2e-marked scenarios of the flow-tasks spec. + * + * 1. "a stranger is refused on the task detail route": an authenticated + * user who merely knows a task's uuid gets 403 on the detail read AND + * on complete, and the task is unchanged afterwards. This is the + * positive control for the exact hole this change closes — on the flow + * resume endpoint, knowing the uuid WAS the check. + * 2. "the inbox route returns tasks with subject context": one request + * answers "what is waiting for me" with rows that carry a display + * title, the subject context field and a datastore total. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + */ +import type { APIRequestContext } from '@playwright/test' + +import { request as apiRequest, expect, test } from '@playwright/test' + +const RUN_ID = `e2e-task-${Date.now().toString(36)}` +const STRANGER = `${RUN_ID}-stranger` +const STRANGER_PASS = `Str4nger!${Date.now().toString(36)}A` + +// Same reasoning as flow-engine.spec.ts: drive the REST API with Basic auth +// and no session cookie, so no CSRF token is demanded; `OCS-APIRequest` marks +// the calls as API traffic. +const NO_SESSION = { cookies: [], origins: [] } +const ADMIN_HEADERS = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from( + `${process.env.OR_USER || 'admin'}:${process.env.OR_PASS || 'admin'}`, + ).toString('base64')}`, +} +const STRANGER_HEADERS = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${STRANGER}:${STRANGER_PASS}`).toString( + 'base64', + )}`, +} + +const BASE = '/index.php/apps/openregister/api/flow-tasks' + +test.use({ storageState: NO_SESSION, extraHTTPHeaders: ADMIN_HEADERS }) + +/** + * Create a task as the admin and hand back its row. + * + * @param request The admin API context. + * @param overrides Fields to set on the task. + */ +async function createTask( + request: APIRequestContext, + overrides: Record = {}, +) { + const response = await request.post(BASE, { + data: { + title: `${RUN_ID} approval`, + state: 'active', + performerType: 'user', + assignee: process.env.OR_USER || 'admin', + requester: process.env.OR_USER || 'admin', + priority: 'normal', + ...overrides, + }, + }) + expect(response.status(), await response.text()).toBe(201) + return response.json() +} + +/** + * Cancel this run's task so terminal litter is at least closed litter. + * Never fails the suite: cleanup is not a verdict on the code under test. + * + * @param request The admin API context. + * @param uuid The task to cancel. + */ +async function cancelQuietly(request: APIRequestContext, uuid: string) { + try { + await request.post(`${BASE}/${uuid}/cancel`, { + data: { reason: `${RUN_ID} cleanup` }, + }) + } catch (error) { + console.warn('[task-inbox] task cleanup failed:', error) + } +} + +test.describe('flow-tasks — authorization at the boundary', () => { + test('a stranger is refused on the task detail route', async ({ request }) => { + // A second real account, created through the provisioning API. If + // this instance refuses to provision users the scenario cannot be + // exercised, and that is a FAILURE of the environment, not a skip: + // a skip cannot tell "absent" from "broken". + const provisioned = await request.post('/ocs/v2.php/cloud/users', { + data: { userid: STRANGER, password: STRANGER_PASS }, + }) + expect( + provisioned.status(), + `cannot provision a stranger account: ${await provisioned.text()}`, + ).toBe(200) + + const task = await createTask(request) + + const stranger = await apiRequest.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL, + extraHTTPHeaders: STRANGER_HEADERS, + }) + + try { + // The detail READ is refused as NOT FOUND: knowing the uuid is + // not visibility, and a stranger learns nothing, not even that + // the uuid exists. + const read = await stranger.get(`${BASE}/${task.uuid}`) + expect(read.status(), await read.text()).toBe(404) + + // The VERB is refused the same way, before any mutation. + const complete = await stranger.post(`${BASE}/${task.uuid}/complete`, { + data: { outcome: 'approved' }, + }) + expect(complete.status(), await complete.text()).toBe(404) + + // The NON-ADMIN inbox: this is the request that runs the + // visibility clause (an admin skips it), including the watchers + // predicate over the JSON column, which is the PostgreSQL trap. + // The stranger's assigned inbox is empty and does not count our + // task; a task the admin makes them a WATCHER of does appear. + const strangerAssigned = await stranger.get(`${BASE}?scope=assigned`) + expect(strangerAssigned.status(), await strangerAssigned.text()).toBe( + 200, + ) + const assignedBody = await strangerAssigned.json() + expect(assignedBody.total).toBe(0) + + const watched = await createTask(request, { + title: `${RUN_ID} watched`, + watchers: [STRANGER], + }) + try { + const strangerWatched = await stranger.get(`${BASE}?scope=watched`) + expect(strangerWatched.status(), await strangerWatched.text()).toBe( + 200, + ) + const watchedBody = await strangerWatched.json() + expect( + (watchedBody.results ?? []).some( + (row: { uuid?: string }) => row.uuid === watched.uuid, + ), + 'a watcher must see the task in their watched inbox', + ).toBe(true) + // Watching confers reading, never acting. + const watcherCompletes = await stranger.post( + `${BASE}/${watched.uuid}/complete`, + { data: { outcome: 'approved' } }, + ) + expect(watcherCompletes.status()).toBe(403) + } finally { + await cancelQuietly(request, watched.uuid) + } + + // And the task provably did not move: same state, same assignee. + const after = await request.get(`${BASE}/${task.uuid}`) + expect(after.status()).toBe(200) + const row = await after.json() + expect(row.state).toBe('active') + expect(row.assignee).toBe(process.env.OR_USER || 'admin') + expect(row.completedBy).toBeNull() + } finally { + await stranger.dispose() + await cancelQuietly(request, task.uuid) + await request + .delete(`/ocs/v2.php/cloud/users/${STRANGER}`) + .catch((error) => + console.warn('[task-inbox] stranger cleanup failed:', error), + ) + } + }) +}) + +test.describe('flow-tasks — the inbox', () => { + test('the inbox route returns tasks with subject context', async ({ + request, + }) => { + // Anchor to a live object when the dev seed provides one, so the + // subject context is real rather than merely present-and-null. + let anchor: Record = {} + const seeded = await request.get( + '/index.php/apps/openregister/api/objects/8/18?_limit=1', + ) + if (seeded.ok()) { + const first = ((await seeded.json()).results ?? [])[0] + const objectUuid = first?.['@self']?.uuid ?? first?.uuid ?? null + if (objectUuid) { + anchor = { objectUuid, registerId: 8, schemaId: 18 } + } + } + + const task = await createTask(request, anchor) + + try { + const listed = await request.get( + `${BASE}?scope=assigned&limit=100&sort=created&direction=desc`, + ) + expect(listed.status(), await listed.text()).toBe(200) + const inbox = await listed.json() + + // The total comes from the datastore, not from counting the page. + expect(typeof inbox.total).toBe('number') + expect(inbox.total).toBeGreaterThanOrEqual(1) + + const mine = (inbox.results ?? []).find( + (row: { uuid?: string }) => row.uuid === task.uuid, + ) + expect( + mine, + 'the created task must appear in its assignee inbox', + ).toBeTruthy() + + // Every row carries a readable identity and the subject slot: a + // list usable without a second request per row. + expect(String(mine.displayTitle ?? '')).not.toBe('') + expect('subject' in mine).toBe(true) + expect('overdue' in mine).toBe(true) + + // When a live object anchored the task, its context came along. + if ('objectUuid' in anchor) { + expect(mine.subject).not.toBeNull() + expect(mine.subject.uuid).toBe(anchor.objectUuid) + expect('register' in mine.subject).toBe(true) + expect('schema' in mine.subject).toBe(true) + } + } finally { + await cancelQuietly(request, task.uuid) + } + }) +}) From 6e2e2af52b3ff41fae23e14aab1641ee900dcacd Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 14:10:08 +0200 Subject: [PATCH 27/68] =?UTF-8?q?feat(flow):=20parallel=20streams=20?= =?UTF-8?q?=E2=80=94=20independent=20branches=20of=20one=20run=20advance?= =?UTF-8?q?=20independently=20(#3259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flow): parallel streams — claims, streams, locked delta commits and a per-stream walk (WIP: tests follow) Storage (openregister_flow_claims, openregister_flow_streams, place_items + firings on runs, stream_id + ordinal_path on steps, with an idempotent back-fill), the claim protocol (FlowPlaceClaims), the locked delta commit path (FlowRunCommit), the per-run stream collaborator (FlowStreamWalk), the engine's per-stream walk, delta writes in FlowRunMarkingStore, per-place item persistence, canonical run-log ordering, the durable firing ceiling, claim reaping in FlowRunWorker and an in-request advanceStream() budget. @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md * test(flow): parallel streams — commit path, claims, stream walk, reaper and ordering tests Also: a join folds onto its carrier BEFORE the step row is written so the row lands at the carrier's position; a forced run-level terminal wins over enabled work in finalize; and finalize/park/commit count only work an unparked stream could do, so a parked run never reads as queued. * docs(openspec): tick the flow-parallel-streams tasks that shipped 8.1 (two real database connections) and 8.3 (migration back-fill applied twice) stay open: their properties are covered at unit level over fakes, not against a live database, and a green SQLite run would not be that evidence. * fix(flow): stamp ordinal paths on non-firing entries, release a failed walk's claims, use DATETIME_MUTABLE A suspension, stop or terminal failure written by the step history now carries its stream's ordinal path rather than defaulting to the root; a pass that dies inside the engine releases its claims at once instead of waiting for the reaper's cutoff; and the migration uses the DBAL constant Psalm and PHPStan know. * chore(flow): carry the parallel-streams spec anchors on every public method the gates see * test(flow): @covers-tagged coverage for the parallel-streams classes Strict coverage metadata credits only @covers-listed classes, so the new entities, mappers, migration, walk, commit path, claims and worker paths now carry tests that name them; the existing engine, run-service, worker and step-history suites declare what they already cover. * feat(flow): the stream walk drains a node's step report onto its log entry too The messaging change added FlowStepReport for the single-stream walk; the per-stream walk now records the same report on a completed or failed hop. * test(flow): drop @covers from the flow tests so strict metadata does not mark them risky With beStrictAboutCoverageMetadata a @covers list that misses one executed class makes the test risky and voids its coverage: CI measured FlowEngine falling from 96.9% to 43.1% and FlowRunService from 70.6% to 22.3% after the annotations landed. The repository's flow tests run without metadata and are credited for what they execute; these now do the same. --------- Co-authored-by: Conduction Release Bot --- lib/BackgroundJob/FlowRunWorker.php | 153 +++- lib/Db/FlowClaim.php | 125 +++ lib/Db/FlowClaimMapper.php | 229 ++++++ lib/Db/FlowRun.php | 26 + lib/Db/FlowRunMapper.php | 25 + lib/Db/FlowRunStep.php | 24 + lib/Db/FlowRunStepMapper.php | 31 +- lib/Db/FlowStream.php | 287 +++++++ lib/Db/FlowStreamMapper.php | 155 ++++ lib/Migration/Version1Date20260901120000.php | 406 ++++++++++ lib/Service/Flow/FlowEngine.php | 433 +++++++++- lib/Service/Flow/FlowFiring.php | 87 ++ lib/Service/Flow/FlowFiringResult.php | 47 ++ lib/Service/Flow/FlowItemPlacement.php | 14 +- lib/Service/Flow/FlowPlaceClaims.php | 235 ++++++ lib/Service/Flow/FlowRunCommit.php | 751 ++++++++++++++++++ lib/Service/Flow/FlowRunMarkingStore.php | 87 +- lib/Service/Flow/FlowRunService.php | 150 +++- lib/Service/Flow/FlowStepHistory.php | 13 + lib/Service/Flow/FlowStreamWalk.php | 623 +++++++++++++++ .../changes/flow-parallel-streams/tasks.md | 36 +- .../FlowRunWorkerClaimReaperTest.php | 226 ++++++ tests/Unit/Db/FlowClaimMapperTest.php | 175 ++++ .../Unit/Db/FlowRunStepOrderingMapperTest.php | 115 +++ tests/Unit/Db/FlowStreamMapperTest.php | 141 ++++ .../Version1Date20260901120000Test.php | 192 +++++ .../Service/Flow/FlowEngineStreamWalkTest.php | 488 ++++++++++++ .../Unit/Service/Flow/FlowPlaceClaimsTest.php | 169 ++++ tests/Unit/Service/Flow/FlowRunCommitTest.php | 398 ++++++++++ .../Flow/FlowRunMarkingStoreDeltaTest.php | 88 ++ .../Flow/FlowRunServiceAdvanceStreamTest.php | 147 ++++ .../Unit/Service/Flow/FlowStepHistoryTest.php | 29 + tests/Unit/Service/Flow/FlowStreamTest.php | 100 +++ .../Unit/Service/Flow/FlowStreamWalkTest.php | 283 +++++++ 34 files changed, 6463 insertions(+), 25 deletions(-) create mode 100644 lib/Db/FlowClaim.php create mode 100644 lib/Db/FlowClaimMapper.php create mode 100644 lib/Db/FlowStream.php create mode 100644 lib/Db/FlowStreamMapper.php create mode 100644 lib/Migration/Version1Date20260901120000.php create mode 100644 lib/Service/Flow/FlowFiring.php create mode 100644 lib/Service/Flow/FlowFiringResult.php create mode 100644 lib/Service/Flow/FlowPlaceClaims.php create mode 100644 lib/Service/Flow/FlowRunCommit.php create mode 100644 lib/Service/Flow/FlowStreamWalk.php create mode 100644 tests/Unit/BackgroundJob/FlowRunWorkerClaimReaperTest.php create mode 100644 tests/Unit/Db/FlowClaimMapperTest.php create mode 100644 tests/Unit/Db/FlowRunStepOrderingMapperTest.php create mode 100644 tests/Unit/Db/FlowStreamMapperTest.php create mode 100644 tests/Unit/Migration/Version1Date20260901120000Test.php create mode 100644 tests/Unit/Service/Flow/FlowEngineStreamWalkTest.php create mode 100644 tests/Unit/Service/Flow/FlowPlaceClaimsTest.php create mode 100644 tests/Unit/Service/Flow/FlowRunCommitTest.php create mode 100644 tests/Unit/Service/Flow/FlowRunMarkingStoreDeltaTest.php create mode 100644 tests/Unit/Service/Flow/FlowRunServiceAdvanceStreamTest.php create mode 100644 tests/Unit/Service/Flow/FlowStreamTest.php create mode 100644 tests/Unit/Service/Flow/FlowStreamWalkTest.php diff --git a/lib/BackgroundJob/FlowRunWorker.php b/lib/BackgroundJob/FlowRunWorker.php index 0d1925824f..7bf365a128 100644 --- a/lib/BackgroundJob/FlowRunWorker.php +++ b/lib/BackgroundJob/FlowRunWorker.php @@ -34,11 +34,15 @@ namespace OCA\OpenRegister\BackgroundJob; use DateTime; +use OCA\OpenRegister\Db\FlowClaimMapper; use OCA\OpenRegister\Db\FlowRun; use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowStreamMapper; use OCA\OpenRegister\Service\Delegation\DelegationService; use OCA\OpenRegister\Service\Flow\FlowConsentParking; +use OCA\OpenRegister\Service\Flow\FlowLocator; use OCA\OpenRegister\Service\Flow\FlowRunAdvancer; +use OCA\OpenRegister\Service\Flow\FlowTokenRouter; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; use OCP\IAppConfig; @@ -50,6 +54,9 @@ * * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md * @spec openspec/changes/or-flow-queue-fairness/specs/flow-queue-fairness/spec.md + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The pass owns recovery for runs, claims and + * streams alike; each mapper is one table the reaper has to read. */ class FlowRunWorker extends TimedJob { @@ -61,7 +68,7 @@ class FlowRunWorker extends TimedJob { * * @var int */ - private const BATCH = 25; + public const BATCH = 25; /** * Default retention for terminal runs, in days. @@ -165,6 +172,9 @@ class FlowRunWorker extends TimedJob { * runs are actually parked, because * a sweep that skips looks exactly * like one that found nothing. + * @param FlowClaimMapper|null $claims Place claims, for the abandoned-claim reaper. + * @param FlowStreamMapper|null $streams Run streams, for failing an abandoned branch. + * @param FlowLocator|null $flows Resolves the run's flow, for the abandoned step's `onError` policy. */ public function __construct( ITimeFactory $time, @@ -173,6 +183,9 @@ public function __construct( private readonly IAppConfig $appConfig, private readonly LoggerInterface $logger, private readonly ?DelegationService $delegation = null, + private readonly ?FlowClaimMapper $claims = null, + private readonly ?FlowStreamMapper $streams = null, + private readonly ?FlowLocator $flows = null, ) { parent::__construct(time: $time); // A FLOOR, not a schedule. `setInterval` says "not more often than @@ -276,6 +289,8 @@ private function reapStale(DateTime $now): void { $cutoff = (clone $now)->modify('-' . $minutes . ' minutes'); + $this->reapAbandonedClaims(cutoff: $cutoff, now: $now, minutes: $minutes); + foreach ($this->mapper->findStale(before: $cutoff, limit: self::BATCH) as $run) { $run->setStatus(FlowRun::STATUS_FAILED); $run->setError( @@ -302,6 +317,139 @@ private function reapStale(DateTime $now): void { }//end reapStale() + /** + * Release claims a dead worker left behind, and FAIL the branch they held. + * + * Uses the SAME cutoff as the run reaper — one expression, not a second + * constant — so the two can never contradict each other. A reaped branch + * is failed, never re-dispatched: it may already have written an object, + * sent a message or called a remote system, and re-running it would repeat + * those without saying so. The run's error policy for the abandoned step + * decides the siblings' fate: `continue` keeps them (the run is re-armed + * as queued), anything else stops the run. + * + * @param DateTime $cutoff Claims taken before this moment are abandoned. + * @param DateTime $now Now. + * @param int $minutes The cutoff in minutes, for the message. + * + * @return void + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-branch-abandoned-by-a-crashed-worker-must-be-recovered-and-must-not-be-silently-re-run + */ + private function reapAbandonedClaims(DateTime $cutoff, DateTime $now, int $minutes): void { + if ($this->claims === null || $this->streams === null) { + return; + } + + foreach ($this->claims->findOlderThan(before: $cutoff, limit: self::BATCH) as $claim) { + $runUuid = (string)$claim->getRunUuid(); + $branch = (string)($claim->getStreamId() ?? ''); + $transition = (string)($claim->getTransition() ?? ''); + + try { + $this->claims->releaseByOwner(runUuid: $runUuid, owner: (string)$claim->getOwner()); + + $error = sprintf( + 'Abandoned branch: worker pass "%s" took stream "%s" at step "%s" and never finished it ' + . '(no commit for over %d minutes). The step may have performed its side effect; ' + . 'it was NOT re-run.', + (string)$claim->getOwner(), + $branch, + $transition, + $minutes + ); + + $stream = $this->streams->findByRunAndStream(runUuid: $runUuid, streamId: $branch); + if ($stream !== null) { + $stream->setStatus(FlowRun::STATUS_FAILED); + $stream->setError($error); + $stream->setUpdated($now); + $this->streams->update($stream); + } + + $run = $this->mapper->findByUuid(uuid: $runUuid); + // The abandoned step's policy decides the siblings' fate: the + // default fails the run; `continue` with a live sibling re-arms + // the run for the next pass instead. + $run->setStatus(FlowRun::STATUS_FAILED); + $run->setError($error); + $run->setResumeAt(null); + $continues = $this->abandonedStepContinues(run: $run, transition: $transition); + if ($continues === true && $this->hasLiveSibling(runUuid: $runUuid, failed: $branch) === true) { + $run->setStatus(FlowRun::STATUS_QUEUED); + $run->setError(null); + } + + $run->setUpdated($now); + $this->mapper->update($run); + + $this->logger->warning( + message: '[FlowRunWorker] Released an abandoned place claim and failed its branch', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'run' => $runUuid, + 'stream' => $branch, + 'transition' => $transition, + 'owner' => $claim->getOwner(), + ] + ); + } catch (Throwable $e) { + $this->logger->error( + message: '[FlowRunWorker] Could not reap an abandoned claim', + context: ['file' => __FILE__, 'line' => __LINE__, 'run' => $runUuid, 'error' => $e->getMessage()] + ); + }//end try + }//end foreach + }//end reapAbandonedClaims() + + /** + * Whether the abandoned step's `onError` policy lets the run's siblings + * continue. Unknown flow or step reads as the default policy, `stop`. + * + * @param FlowRun $run The run. + * @param string $transition The abandoned transition. + * + * @return bool True when the policy is `continue`. + */ + private function abandonedStepContinues(FlowRun $run, string $transition): bool { + if ($this->flows === null || $transition === '') { + return false; + } + + try { + $flow = $this->flows->resolveFlow((string)$run->getFlowId()); + } catch (Throwable $e) { + return false; + } + + if ($flow === null) { + return false; + } + + $step = (new FlowTokenRouter())->stepFor(flow: $flow, transitionName: $transition); + + return ((string)($step['onError'] ?? 'stop')) === 'continue'; + }//end abandonedStepContinues() + + /** + * Whether any stream of the run other than the failed one is still live. + * + * @param string $runUuid The run. + * @param string $failed The failed stream id. + * + * @return bool True when a sibling can still advance. + */ + private function hasLiveSibling(string $runUuid, string $failed): bool { + foreach ($this->streams->findByRun(runUuid: $runUuid) as $stream) { + if ((string)$stream->getStreamId() !== $failed && $stream->isTerminal() === false) { + return true; + } + } + + return false; + }//end hasLiveSibling() + /** * Fail runs suspended on a signal that never arrived. * @@ -552,6 +700,9 @@ private function prune(DateTime $now): void { try { $before = (clone $now)->modify(sprintf('-%d days', $days)); $deleted = $this->mapper->pruneBefore(before: $before); + // Stream and claim rows go with their runs. + $this->streams?->deleteOrphans(); + $this->claims?->deleteOrphans(); if ($deleted > 0) { $this->logger->info( message: '[FlowRunWorker] Pruned old flow runs', diff --git a/lib/Db/FlowClaim.php b/lib/Db/FlowClaim.php new file mode 100644 index 0000000000..03099c392e --- /dev/null +++ b/lib/Db/FlowClaim.php @@ -0,0 +1,125 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * One place claim. + * + * @method string|null getRunUuid() + * @method void setRunUuid(?string $runUuid) + * @method string|null getPlace() + * @method void setPlace(?string $place) + * @method string|null getOwner() + * @method void setOwner(?string $owner) + * @method string|null getStreamId() + * @method void setStreamId(?string $streamId) + * @method string|null getTransition() + * @method void setTransition(?string $transition) + * @method DateTime|null getClaimedAt() + * @method void setClaimedAt(?DateTime $claimedAt) + */ +class FlowClaim extends Entity implements JsonSerializable { + + /** + * The run whose marking holds the place. + * + * @var string|null + */ + protected ?string $runUuid = null; + + /** + * The claimed place name. + * + * @var string|null + */ + protected ?string $place = null; + + /** + * The pass token of the holder. + * + * @var string|null + */ + protected ?string $owner = null; + + /** + * The stream the claim was taken for. + * + * @var string|null + */ + protected ?string $streamId = null; + + /** + * The transition the claim was taken for. + * + * @var string|null + */ + protected ?string $transition = null; + + /** + * When the claim was taken — the reaper's input. + * + * @var DateTime|null + */ + protected ?DateTime $claimedAt = null; + + /** + * Constructor. + */ + public function __construct() { + $this->addType(fieldName: 'runUuid', type: 'string'); + $this->addType(fieldName: 'place', type: 'string'); + $this->addType(fieldName: 'owner', type: 'string'); + $this->addType(fieldName: 'streamId', type: 'string'); + $this->addType(fieldName: 'transition', type: 'string'); + $this->addType(fieldName: 'claimedAt', type: 'datetime'); + }//end __construct() + + /** + * JSON shape. + * + * @return array The claim as an array. + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'runUuid' => $this->runUuid, + 'place' => $this->place, + 'owner' => $this->owner, + 'streamId' => $this->streamId, + 'transition' => $this->transition, + 'claimedAt' => $this->claimedAt?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/FlowClaimMapper.php b/lib/Db/FlowClaimMapper.php new file mode 100644 index 0000000000..f2c2362126 --- /dev/null +++ b/lib/Db/FlowClaimMapper.php @@ -0,0 +1,229 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception as DbException; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Place claims. + * + * @template-extends QBMapper + */ +class FlowClaimMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db Database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_flow_claims', entityClass: FlowClaim::class); + }//end __construct() + + /** + * Take one claim, or be refused by the unique index. + * + * Returns false ONLY on a unique-constraint violation — another holder has + * the place. Every other database failure propagates: a claim that fails + * for an unrelated reason must not read as "somebody else has it". + * + * @param FlowClaim $claim The claim to take. + * + * @return bool True when the claim landed. + * + * @throws DbException On any database failure other than a unique violation. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + public function insertOrRefuse(FlowClaim $claim): bool { + try { + $this->insert(entity: $claim); + } catch (DbException $e) { + if ($e->getReason() !== DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + + return false; + } + + return true; + }//end insertOrRefuse() + + /** + * Release the named places of one run. + * + * @param string $runUuid The run. + * @param array $places The places to release. + * + * @return int Rows deleted. + */ + public function release(string $runUuid, array $places): int { + if ($places === []) { + return 0; + } + + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->andWhere($qb->expr()->in('place', $qb->createNamedParameter(array_values($places), IQueryBuilder::PARAM_STR_ARRAY))); + + return $qb->executeStatement(); + }//end release() + + /** + * Release every claim a holder took on a run. + * + * @param string $runUuid The run. + * @param string $owner The holder's pass token. + * + * @return int Rows deleted. + */ + public function releaseByOwner(string $runUuid, string $owner): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->andWhere($qb->expr()->eq('owner', $qb->createNamedParameter($owner))); + + return $qb->executeStatement(); + }//end releaseByOwner() + + /** + * How many claims a run currently has outstanding — the per-run cap's input. + * + * @param string $runUuid The run. + * + * @return int Claims held. + */ + public function countHeldForRun(string $runUuid): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('id', 'held')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))); + + $result = $qb->executeQuery(); + $held = (int)$result->fetchOne(); + $result->closeCursor(); + + return $held; + }//end countHeldForRun() + + /** + * How many claims one pass holds across ALL runs — the pass ceiling's input. + * + * @param string $owner The pass token. + * + * @return int Claims held. + */ + public function countHeldByOwner(string $owner): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('id', 'held')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('owner', $qb->createNamedParameter($owner))); + + $result = $qb->executeQuery(); + $held = (int)$result->fetchOne(); + $result->closeCursor(); + + return $held; + }//end countHeldByOwner() + + /** + * The claims a run currently holds. + * + * @param string $runUuid The run. + * + * @return array The claims. + */ + public function findByRun(string $runUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->orderBy('place', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findByRun() + + /** + * Claims older than a cutoff — the reaper's read. + * + * @param DateTime $before Claims taken before this moment. + * @param int $limit Bound on one pass. + * + * @return array The abandoned claims. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-branch-abandoned-by-a-crashed-worker-must-be-recovered-and-must-not-be-silently-re-run + */ + public function findOlderThan(DateTime $before, int $limit = 25): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->lt('claimed_at', $qb->createNamedParameter($before, IQueryBuilder::PARAM_DATE))) + ->orderBy('claimed_at', 'ASC') + ->setMaxResults($limit); + + return $this->findEntities(query: $qb); + }//end findOlderThan() + + /** + * Drop every claim of a run — with the run's own deletion. + * + * @param string $runUuid The run. + * + * @return int Rows deleted. + */ + public function deleteByRun(string $runUuid): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))); + + return $qb->executeStatement(); + }//end deleteByRun() + + /** + * Drop every claim row whose run no longer exists — the retention pass + * prunes runs by age, and their claims go with them. + * + * @return int Rows deleted. + */ + public function deleteOrphans(): int { + $runs = $this->db->getQueryBuilder(); + $runs->select('uuid')->from('openregister_flow_runs'); + + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->createFunction('run_uuid NOT IN (' . $runs->getSQL() . ')')); + + return $qb->executeStatement(); + }//end deleteOrphans() +}//end class diff --git a/lib/Db/FlowRun.php b/lib/Db/FlowRun.php index e2e6807279..1359fb9122 100644 --- a/lib/Db/FlowRun.php +++ b/lib/Db/FlowRun.php @@ -51,6 +51,10 @@ * @method string|null getStatus() * @method void setStatus(?string $status) * @method array|null getMarking() + * @method array|null getPlaceItems() + * @method void setPlaceItems(?array $placeItems) + * @method integer|null getFirings() + * @method void setFirings(?int $firings) * @method void setMarking(?array $marking) * @method array|null getItems() * @method void setItems(?array $items) @@ -212,6 +216,24 @@ class FlowRun extends Entity implements JsonSerializable { */ protected ?array $marking = null; + /** + * The items sitting on each marked place, `place => items`. Written by + * the same transaction as the marking so a marking can never name a + * place whose items were not written; null for a run that predates the + * column, which then seeds from the flat `items` on first read. + * + * @var array|null + */ + protected ?array $placeItems = null; + + /** + * Committed firings across ALL streams and passes — the durable input to + * the transition ceiling, incremented inside each firing's commit. + * + * @var int|null + */ + protected ?int $firings = 0; + /** * The item list as it stood when the run last stopped. * @@ -344,6 +366,8 @@ public function __construct() { $this->addType(fieldName: 'flowVersion', type: 'integer'); $this->addType(fieldName: 'status', type: 'string'); $this->addType(fieldName: 'marking', type: 'json'); + $this->addType(fieldName: 'placeItems', type: 'json'); + $this->addType(fieldName: 'firings', type: 'integer'); $this->addType(fieldName: 'items', type: 'json'); $this->addType(fieldName: 'context', type: 'json'); $this->addType(fieldName: 'log', type: 'json'); @@ -403,6 +427,8 @@ public function jsonSerialize(): array { 'flowVersion' => $this->flowVersion, 'status' => $this->status, 'marking' => ($this->marking ?? []), + 'placeItems' => $this->placeItems, + 'firings' => (int)($this->firings ?? 0), 'items' => ($this->items ?? []), 'context' => ($this->context ?? []), 'log' => ($this->log ?? []), diff --git a/lib/Db/FlowRunMapper.php b/lib/Db/FlowRunMapper.php index 171403544f..f2d7b268fa 100644 --- a/lib/Db/FlowRunMapper.php +++ b/lib/Db/FlowRunMapper.php @@ -134,6 +134,31 @@ public function findByUuid(string $uuid): FlowRun { return $this->findEntity(query: $qb); }//end findByUuid() + /** + * Read one run FOR UPDATE — the critical section's lock. + * + * Must be called inside a transaction; the lock lives exactly as long as + * that transaction. Everything FlowRunCommit writes is computed from the + * row this returns, never from a value read before the lock was taken. + * + * @param string $uuid The run uuid. + * + * @return FlowRun The locked run. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException When there is no such run. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + public function lockByUuid(string $uuid): FlowRun { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))) + ->forUpdate(); + + return $this->findEntity(query: $qb); + }//end lockByUuid() + /** * List runs, newest first. * diff --git a/lib/Db/FlowRunStep.php b/lib/Db/FlowRunStep.php index 29618c0e7d..c5f5cea3fa 100644 --- a/lib/Db/FlowRunStep.php +++ b/lib/Db/FlowRunStep.php @@ -49,6 +49,10 @@ * @method string|null getNodeType() * @method void setNodeType(?string $nodeType) * @method integer|null getSequence() + * @method string|null getStreamId() + * @method void setStreamId(?string $streamId) + * @method string|null getOrdinalPath() + * @method void setOrdinalPath(?string $ordinalPath) * @method void setSequence(?int $sequence) * @method string|null getStatus() * @method void setStatus(?string $status) @@ -133,6 +137,22 @@ class FlowRunStep extends Entity implements JsonSerializable { */ protected ?int $sequence = 0; + /** + * The stream that produced this step; null for a row written before + * streams existed (the single implicit stream). + * + * @var string|null + */ + protected ?string $streamId = null; + + /** + * The stream's declaration-derived ordinal path, the first key of the + * canonical run-log order. + * + * @var string|null + */ + protected ?string $ordinalPath = null; + /** * Outcome of this hop. * @@ -191,6 +211,8 @@ public function __construct() { $this->addType(fieldName: 'nodeId', type: 'string'); $this->addType(fieldName: 'nodeType', type: 'string'); $this->addType(fieldName: 'sequence', type: 'integer'); + $this->addType(fieldName: 'streamId', type: 'string'); + $this->addType(fieldName: 'ordinalPath', type: 'string'); $this->addType(fieldName: 'status', type: 'string'); $this->addType(fieldName: 'started', type: 'datetime'); $this->addType(fieldName: 'finished', type: 'datetime'); @@ -231,6 +253,8 @@ public function jsonSerialize(): array { 'nodeId' => $this->nodeId, 'nodeType' => $this->nodeType, 'sequence' => (int)$this->sequence, + 'streamId' => $this->streamId, + 'ordinalPath' => $this->ordinalPath, 'status' => $this->status, 'started' => $started, 'finished' => $finished, diff --git a/lib/Db/FlowRunStepMapper.php b/lib/Db/FlowRunStepMapper.php index 4a77708585..fd098877a6 100644 --- a/lib/Db/FlowRunStepMapper.php +++ b/lib/Db/FlowRunStepMapper.php @@ -59,11 +59,40 @@ public function findByRun(string $runUuid): array { $qb->select('*') ->from($this->getTableName()) ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) - ->orderBy('sequence', 'ASC'); + // CANONICAL order: by stream ordinal, then position within the + // stream. A function of the path taken, never of which branch + // returned first, so two runs of one flow read identically. Rows + // written before streams existed carry the root path (back-filled) + // and reproduce their old sequence order exactly. + ->orderBy('ordinal_path', 'ASC') + ->addOrderBy('sequence', 'ASC') + ->addOrderBy('id', 'ASC'); return $this->findEntities(query: $qb); }//end findByRun() + /** + * Every step of one run in WALL-CLOCK order — the real interleaving, for + * an operator who asks for it. Never the default, and never what any + * comparison between runs is built on. + * + * @param string $runUuid The run uuid. + * + * @return array The steps, by timestamp then id. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + public function findByRunByTimestamp(string $runUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->orderBy('created', 'ASC') + ->addOrderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findByRunByTimestamp() + /** * The highest sequence recorded for a run so far. * diff --git a/lib/Db/FlowStream.php b/lib/Db/FlowStream.php new file mode 100644 index 0000000000..5b6fc7d6ce --- /dev/null +++ b/lib/Db/FlowStream.php @@ -0,0 +1,287 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use LengthException; +use OCP\AppFramework\Db\Entity; + +/** + * One run stream. + * + * @method string|null getRunUuid() + * @method void setRunUuid(?string $runUuid) + * @method string|null getStreamId() + * @method void setStreamId(?string $streamId) + * @method string|null getOrdinalPath() + * @method void setOrdinalPath(?string $ordinalPath) + * @method string|null getParentStreamId() + * @method void setParentStreamId(?string $parentStreamId) + * @method string|null getPlace() + * @method void setPlace(?string $place) + * @method string|null getStatus() + * @method void setStatus(?string $status) + * @method DateTime|null getResumeAt() + * @method void setResumeAt(?DateTime $resumeAt) + * @method int|null getNextSequence() + * @method void setNextSequence(?int $nextSequence) + * @method string|null getError() + * @method void setError(?string $error) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * @method DateTime|null getUpdated() + * @method void setUpdated(?DateTime $updated) + */ +class FlowStream extends Entity implements JsonSerializable { + + /** + * The root stream's ordinal path. + */ + public const ROOT_PATH = '0001'; + + /** + * Digits per ordinal segment: four keeps lexicographic order equal to tree + * order without parsing, and `'' < '.'` puts a parent before its children. + */ + public const SEGMENT_WIDTH = 4; + + /** + * The `ordinal_path` column width; a path that would exceed it fails the + * run rather than sorting wrongly. + */ + public const MAX_PATH_LENGTH = 255; + + /** + * The run this stream belongs to. + * + * @var string|null + */ + protected ?string $runUuid = null; + + /** + * The stream id, unique within the run. + * + * @var string|null + */ + protected ?string $streamId = null; + + /** + * The declaration-derived ordinal path. + * + * @var string|null + */ + protected ?string $ordinalPath = null; + + /** + * The stream this one was minted from, null for the root. + * + * @var string|null + */ + protected ?string $parentStreamId = null; + + /** + * The place currently holding this stream's token. + * + * @var string|null + */ + protected ?string $place = null; + + /** + * One of FlowRun's status constants. + * + * @var string|null + */ + protected ?string $status = null; + + /** + * When a suspended stream wants waking; null while waiting on a signal. + * + * @var DateTime|null + */ + protected ?DateTime $resumeAt = null; + + /** + * The next step sequence WITHIN this stream. + * + * @var int|null + */ + protected ?int $nextSequence = 1; + + /** + * Why the stream failed, when it did. + * + * @var string|null + */ + protected ?string $error = null; + + /** + * Creation time. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Last update time. + * + * @var DateTime|null + */ + protected ?DateTime $updated = null; + + /** + * Constructor. + */ + public function __construct() { + $this->addType(fieldName: 'runUuid', type: 'string'); + $this->addType(fieldName: 'streamId', type: 'string'); + $this->addType(fieldName: 'ordinalPath', type: 'string'); + $this->addType(fieldName: 'parentStreamId', type: 'string'); + $this->addType(fieldName: 'place', type: 'string'); + $this->addType(fieldName: 'status', type: 'string'); + $this->addType(fieldName: 'resumeAt', type: 'datetime'); + $this->addType(fieldName: 'nextSequence', type: 'integer'); + $this->addType(fieldName: 'error', type: 'string'); + $this->addType(fieldName: 'created', type: 'datetime'); + $this->addType(fieldName: 'updated', type: 'datetime'); + }//end __construct() + + /** + * Whether this stream has reached a terminal status. + * + * @return bool True when terminal. + */ + public function isTerminal(): bool { + return in_array($this->status, FlowRun::TERMINAL, true); + }//end isTerminal() + + /** + * The ordinal path of the K-th child of a parent path. + * + * @param string $parentPath The parent's path. + * @param int $index The 1-based position in the taken outputs' declaration order. + * + * @return string The child path. + * + * @throws LengthException When the path would exceed the column. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + public static function childPath(string $parentPath, int $index): string { + $path = $parentPath . '.' . str_pad((string)$index, self::SEGMENT_WIDTH, '0', STR_PAD_LEFT); + if (strlen($path) > self::MAX_PATH_LENGTH) { + throw new LengthException( + sprintf( + 'Stream ordinal path would exceed %d characters (nesting too deep to order the run log); the run cannot continue.', + self::MAX_PATH_LENGTH + ) + ); + } + + return $path; + }//end childPath() + + /** + * The longest common prefix of several ordinal paths, at segment + * boundaries — the stream a join folds its inputs back onto. + * + * Total: branches from different splits share a shorter prefix, possibly + * the root, and the answer is still deterministic. + * + * @param array $paths The input paths. + * + * @return string The common prefix; the root path when nothing is shared. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + public static function commonPrefix(array $paths): string { + $paths = array_values(array_filter($paths, static fn (mixed $path): bool => is_string($path) === true && $path !== '')); + if ($paths === []) { + return self::ROOT_PATH; + } + + $segments = array_map(static fn (string $path): array => explode('.', $path), $paths); + $common = []; + $depth = min(array_map('count', $segments)); + for ($i = 0; $i < $depth; $i++) { + $segment = $segments[0][$i]; + foreach ($segments as $candidate) { + if ($candidate[$i] !== $segment) { + return self::pathOrRoot(segments: $common); + } + } + + $common[] = $segment; + } + + return self::pathOrRoot(segments: $common); + }//end commonPrefix() + + /** + * Join path segments, or the root path when there are none. + * + * @param array $segments The segments. + * + * @return string The path. + */ + private static function pathOrRoot(array $segments): string { + if ($segments === []) { + return self::ROOT_PATH; + } + + return implode('.', $segments); + }//end pathOrRoot() + + /** + * JSON shape. + * + * @return array The stream as an array. + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'runUuid' => $this->runUuid, + 'streamId' => $this->streamId, + 'ordinalPath' => $this->ordinalPath, + 'parentStreamId' => $this->parentStreamId, + 'place' => $this->place, + 'status' => $this->status, + 'resumeAt' => $this->resumeAt?->format('c'), + 'nextSequence' => $this->nextSequence, + 'error' => $this->error, + 'created' => $this->created?->format('c'), + 'updated' => $this->updated?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/FlowStreamMapper.php b/lib/Db/FlowStreamMapper.php new file mode 100644 index 0000000000..06f9dd67eb --- /dev/null +++ b/lib/Db/FlowStreamMapper.php @@ -0,0 +1,155 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Run streams. + * + * @template-extends QBMapper + */ +class FlowStreamMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db Database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_flow_streams', entityClass: FlowStream::class); + }//end __construct() + + /** + * Every stream of a run, in ordinal order. + * + * @param string $runUuid The run. + * + * @return array The streams. + */ + public function findByRun(string $runUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->orderBy('ordinal_path', 'ASC') + ->addOrderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findByRun() + + /** + * One stream of a run. + * + * @param string $runUuid The run. + * @param string $streamId The stream. + * + * @return FlowStream|null The stream, or null when absent. + */ + public function findByRunAndStream(string $runUuid, string $streamId): ?FlowStream { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->andWhere($qb->expr()->eq('stream_id', $qb->createNamedParameter($streamId))); + + try { + return $this->findEntity(query: $qb); + } catch (DoesNotExistException) { + return null; + } + }//end findByRunAndStream() + + /** + * Reserve the next step position within a stream. + * + * The increment is the reservation (SequenceMapper::incrementScope()'s + * shape): the row's `next_sequence` is bumped, and the value BEFORE the + * bump is the position handed out. Called inside FlowRunCommit's + * transaction, so the position and the step row that uses it commit + * together. + * + * @param string $runUuid The run. + * @param string $streamId The stream. + * + * @return int The reserved position, or 0 when the stream row does not exist. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + public function allocateNextSequence(string $runUuid, string $streamId): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('next_sequence', $qb->createFunction('next_sequence + 1')) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->andWhere($qb->expr()->eq('stream_id', $qb->createNamedParameter($streamId))); + + if ($qb->executeStatement() === 0) { + return 0; + } + + $row = $this->findByRunAndStream(runUuid: $runUuid, streamId: $streamId); + $next = (int)($row?->getNextSequence() ?? 1); + + // `next_sequence` now points at the NEXT hand-out; the one reserved is + // one below it. + return max(1, ($next - 1)); + }//end allocateNextSequence() + + /** + * Drop every stream of a run — with the run's own deletion. + * + * @param string $runUuid The run. + * + * @return int Rows deleted. + */ + public function deleteByRun(string $runUuid): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))); + + return $qb->executeStatement(); + }//end deleteByRun() + + /** + * Drop every stream row whose run no longer exists — the retention pass + * prunes runs by age, and their streams go with them. + * + * @return int Rows deleted. + */ + public function deleteOrphans(): int { + $runs = $this->db->getQueryBuilder(); + $runs->select('uuid')->from('openregister_flow_runs'); + + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->createFunction('run_uuid NOT IN (' . $runs->getSQL() . ')')); + + return $qb->executeStatement(); + }//end deleteOrphans() +}//end class diff --git a/lib/Migration/Version1Date20260901120000.php b/lib/Migration/Version1Date20260901120000.php new file mode 100644 index 0000000000..fcaf8463ac --- /dev/null +++ b/lib/Migration/Version1Date20260901120000.php @@ -0,0 +1,406 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use DateTime; +use Doctrine\DBAL\Types\Types; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowStream; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds `openregister_flow_claims`, `openregister_flow_streams`, the run and + * step columns, and back-fills streams for in-flight runs. + */ +class Version1Date20260901120000 extends SimpleMigrationStep { + + private const CLAIMS = 'openregister_flow_claims'; + + private const STREAMS = 'openregister_flow_streams'; + + private const RUNS = 'openregister_flow_runs'; + + private const STEPS = 'openregister_flow_steps'; + + /** + * Constructor. + * + * @param IDBConnection $db Database connection, for the back-fill. + */ + public function __construct( + private readonly IDBConnection $db, + ) { + + }//end __construct() + + /** + * Create the two tables and add the four columns. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Schema closure returning an ISchemaWrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The updated schema, or null when nothing changed. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) One existence guard per table, column and index added. + * @SuppressWarnings(PHPMD.NPathComplexity) The same guards, multiplied; each is required for idempotency. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + + $schema = $schemaClosure(); + $changed = false; + + if ($schema->hasTable(self::CLAIMS) === false) { + $table = $schema->createTable(self::CLAIMS); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20]); + $table->addColumn('run_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('place', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('owner', Types::STRING, ['notnull' => true, 'length' => 128]); + $table->addColumn('stream_id', Types::STRING, ['notnull' => false, 'length' => 64, 'default' => null]); + $table->addColumn('transition', Types::STRING, ['notnull' => false, 'length' => 255, 'default' => null]); + $table->addColumn('claimed_at', Types::DATETIME_MUTABLE, ['notnull' => true]); + $table->setPrimaryKey(['id']); + // The unique index IS the lock. + $table->addUniqueIndex(['run_uuid', 'place'], 'or_flowclaim_place_uq'); + // The reaper's read. + $table->addIndex(['claimed_at'], 'or_flowclaim_at_idx'); + // The per-pass ceiling's read. + $table->addIndex(['owner'], 'or_flowclaim_owner_idx'); + $output->info(message: 'Created ' . self::CLAIMS); + $changed = true; + } + + if ($schema->hasTable(self::STREAMS) === false) { + $table = $schema->createTable(self::STREAMS); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20]); + $table->addColumn('run_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('stream_id', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('ordinal_path', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('parent_stream_id', Types::STRING, ['notnull' => false, 'length' => 64, 'default' => null]); + // The place currently holding this stream's token: "on what" a + // branch waits, and how a resumed run knows which token is whose. + $table->addColumn('place', Types::STRING, ['notnull' => false, 'length' => 255, 'default' => null]); + $table->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 32]); + $table->addColumn('resume_at', Types::DATETIME_MUTABLE, ['notnull' => false, 'default' => null]); + $table->addColumn('next_sequence', Types::INTEGER, ['notnull' => true, 'default' => 1]); + $table->addColumn('error', Types::TEXT, ['notnull' => false, 'default' => null]); + $table->addColumn('created', Types::DATETIME_MUTABLE, ['notnull' => false, 'default' => null]); + $table->addColumn('updated', Types::DATETIME_MUTABLE, ['notnull' => false, 'default' => null]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['run_uuid', 'stream_id'], 'or_flowstream_id_uq'); + $table->addIndex(['run_uuid', 'status'], 'or_flowstream_status_idx'); + $output->info(message: 'Created ' . self::STREAMS); + $changed = true; + } + + if ($schema->hasTable(self::RUNS) === true) { + $runs = $schema->getTable(self::RUNS); + if ($runs->hasColumn('place_items') === false) { + $runs->addColumn('place_items', Types::JSON, ['notnull' => false, 'default' => null]); + $changed = true; + } + + if ($runs->hasColumn('firings') === false) { + $runs->addColumn('firings', Types::INTEGER, ['notnull' => true, 'default' => 0]); + $changed = true; + } + } + + if ($schema->hasTable(self::STEPS) === true) { + $steps = $schema->getTable(self::STEPS); + if ($steps->hasColumn('stream_id') === false) { + $steps->addColumn('stream_id', Types::STRING, ['notnull' => false, 'length' => 64, 'default' => null]); + $changed = true; + } + + if ($steps->hasColumn('ordinal_path') === false) { + $steps->addColumn('ordinal_path', Types::STRING, ['notnull' => false, 'length' => 255, 'default' => null]); + $changed = true; + } + + if ($steps->hasIndex('or_flowstep_ordinal_idx') === false) { + $steps->addIndex(['run_uuid', 'ordinal_path', 'sequence'], 'or_flowstep_ordinal_idx'); + $changed = true; + } + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() + + /** + * Back-fill streams for in-flight runs and stamp historical step rows. + * + * One stream per MARKED place on every non-terminal run, ordinals in + * sorted place-name order, `next_sequence` from the run's highest step + * sequence + 1 so the resumed history continues. Existing step rows are + * stamped with the root path and the root stream id. `place_items` is + * left null so it seeds from the flat `items` on first read. Guarded on + * existence throughout, so a second run creates no duplicate stream and + * re-stamps no step. + * + * Honest caveat, stated here rather than left for someone to discover: a + * pre-upgrade run's ordinals are place-name order, not the author's + * declaration order, because declaration order was never recorded for a + * run already in flight. Such a run is not ordinal-comparable with one + * started after the upgrade. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Schema closure. + * @param array $options Migration options. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The step signature is Nextcloud's. + * @SuppressWarnings(PHPMD.StaticAccess) FlowStream::childPath is a pure path helper on a value object. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $now = new DateTime(); + $runsSeeded = 0; + $stepsStamped = 0; + + $qb = $this->db->getQueryBuilder(); + $qb->select('uuid', 'status', 'marking', 'resume_at') + ->from(self::RUNS) + ->where($qb->expr()->in('status', $qb->createNamedParameter(FlowRun::ACTIVE, IQueryBuilder::PARAM_STR_ARRAY))); + $result = $qb->executeQuery(); + $runs = $result->fetchAll(); + $result->closeCursor(); + + foreach ($runs as $row) { + $uuid = (string)($row['uuid'] ?? ''); + if ($uuid === '' || $this->hasStreams(runUuid: $uuid) === true) { + continue; + } + + $places = $this->markedPlaces(marking: ($row['marking'] ?? null)); + if ($places === []) { + // Queued, never started: its first firing mints the root. + continue; + } + + sort($places, SORT_STRING); + $nextSequence = ($this->highestSequence(runUuid: $uuid) + 1); + $ordinal = 1; + $rootId = null; + foreach ($places as $place) { + $streamId = self::streamIdFor(runUuid: $uuid, ordinal: $ordinal); + $rootId ??= $streamId; + $path = FlowStream::ROOT_PATH; + if ($ordinal > 1) { + $path = FlowStream::childPath(parentPath: FlowStream::ROOT_PATH, index: $ordinal); + } + + $insert = $this->db->getQueryBuilder(); + $insert->insert(self::STREAMS) + ->values( + [ + 'run_uuid' => $insert->createNamedParameter($uuid), + 'stream_id' => $insert->createNamedParameter($streamId), + 'ordinal_path' => $insert->createNamedParameter($path), + 'parent_stream_id' => $insert->createNamedParameter(null, IQueryBuilder::PARAM_NULL), + 'place' => $insert->createNamedParameter($place), + 'status' => $insert->createNamedParameter((string)($row['status'] ?? FlowRun::STATUS_QUEUED)), + 'resume_at' => $insert->createNamedParameter($this->dateOrNull(value: ($row['resume_at'] ?? null)), IQueryBuilder::PARAM_DATE), + 'next_sequence' => $insert->createNamedParameter($nextSequence, IQueryBuilder::PARAM_INT), + 'created' => $insert->createNamedParameter($now, IQueryBuilder::PARAM_DATE), + 'updated' => $insert->createNamedParameter($now, IQueryBuilder::PARAM_DATE), + ] + ); + $insert->executeStatement(); + $ordinal++; + }//end foreach + + $runsSeeded++; + $stepsStamped += $this->stampSteps(runUuid: $uuid, rootStreamId: (string)$rootId); + }//end foreach + + // Historical (terminal) runs' steps are stamped too, so canonical + // ordering reproduces today's order for every run exactly. + $stepsStamped += $this->stampUnstampedSteps(); + + $output->info( + message: sprintf( + 'flow-parallel-streams back-fill: %d in-flight run(s) given streams, %d step row(s) stamped with the root path. ' + . 'Pre-upgrade runs carry place-name ordinals, not declaration ordinals, and are not ordinal-comparable with runs started after this upgrade.', + $runsSeeded, + $stepsStamped + ) + ); + }//end postSchemaChange() + + /** + * A deterministic stream id for a back-filled stream. + * + * @param string $runUuid The run. + * @param int $ordinal The 1-based ordinal. + * + * @return string The stream id. + */ + public static function streamIdFor(string $runUuid, int $ordinal): string { + return substr(sha1($runUuid . ':' . $ordinal), 0, 32); + }//end streamIdFor() + + /** + * Whether a run already has stream rows. + * + * @param string $runUuid The run. + * + * @return bool True when streams exist. + */ + private function hasStreams(string $runUuid): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('id', 'n')) + ->from(self::STREAMS) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + + return $count > 0; + }//end hasStreams() + + /** + * The marked place names of a stored marking. + * + * @param mixed $marking The raw column value. + * + * @return array The places. + */ + private function markedPlaces(mixed $marking): array { + if (is_string($marking) === true) { + $marking = json_decode($marking, true); + } + + if (is_array($marking) === false) { + return []; + } + + $places = []; + foreach ($marking as $key => $value) { + if (is_int($key) === true) { + $places[] = (string)$value; + continue; + } + + if ((int)$value > 0) { + $places[] = (string)$key; + } + } + + return array_values(array_unique($places)); + }//end markedPlaces() + + /** + * The highest step sequence of a run, 0 when it has none. + * + * @param string $runUuid The run. + * + * @return int The highest sequence. + */ + private function highestSequence(string $runUuid): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->max('sequence')) + ->from(self::STEPS) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))); + $result = $qb->executeQuery(); + $max = $result->fetchOne(); + $result->closeCursor(); + + return (int)($max ?? 0); + }//end highestSequence() + + /** + * Stamp a run's unstamped step rows with the root path and root stream. + * + * @param string $runUuid The run. + * @param string $rootStreamId The root stream id. + * + * @return int Rows stamped. + */ + private function stampSteps(string $runUuid, string $rootStreamId): int { + $qb = $this->db->getQueryBuilder(); + $qb->update(self::STEPS) + ->set('ordinal_path', $qb->createNamedParameter(FlowStream::ROOT_PATH)) + ->set('stream_id', $qb->createNamedParameter($rootStreamId)) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->andWhere($qb->expr()->isNull('ordinal_path')); + + return $qb->executeStatement(); + }//end stampSteps() + + /** + * Stamp every remaining unstamped step row with the root path. + * + * @return int Rows stamped. + */ + private function stampUnstampedSteps(): int { + $qb = $this->db->getQueryBuilder(); + $qb->update(self::STEPS) + ->set('ordinal_path', $qb->createNamedParameter(FlowStream::ROOT_PATH)) + ->where($qb->expr()->isNull('ordinal_path')); + + return $qb->executeStatement(); + }//end stampUnstampedSteps() + + /** + * A stored datetime string as a DateTime, or null. + * + * @param mixed $value The raw column value. + * + * @return DateTime|null The parsed value. + */ + private function dateOrNull(mixed $value): ?DateTime { + if (is_string($value) === false || $value === '') { + return null; + } + + try { + return new DateTime($value); + } catch (\Throwable) { + return null; + } + }//end dateOrNull() +}//end class diff --git a/lib/Service/Flow/FlowEngine.php b/lib/Service/Flow/FlowEngine.php index 15285530d6..90bfcdb5fa 100644 --- a/lib/Service/Flow/FlowEngine.php +++ b/lib/Service/Flow/FlowEngine.php @@ -39,6 +39,7 @@ namespace OCA\OpenRegister\Service\Flow; use InvalidArgumentException; +use OCA\OpenRegister\Db\FlowRun; use Psr\Log\LoggerInterface; use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; use Symfony\Component\Workflow\Workflow; @@ -47,6 +48,12 @@ /** * Runs a stored flow document to completion, or until it can go no further. * + * @SuppressWarnings(PHPMD.ExcessiveClassLength) Two walks live here on purpose: the single + * in-memory stream every unit test and the flow tester use, and the persisted stream walk a + * worker pass uses. Sharing the hop body between them would make the first depend on the second. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One labelled branch per way a hop can end, + * in each walk; PHPMD sums them into the class total. + * * @spec openspec/changes/or-flow-engine/specs/flow-engine/spec.md */ class FlowEngine { @@ -62,6 +69,14 @@ class FlowEngine { public const STATUS_FAILED = 'failed'; + /** + * The run has enabled work nobody took this pass — a claim was refused, or + * a join became enabled by the last commit — and the next pass drains it. + * Only produced by the stream walk; a single in-memory stream never + * contends with anyone. + */ + public const STATUS_QUEUED = 'queued'; + /** * How many items of a step's input and output the run log keeps. * @@ -291,6 +306,10 @@ private function assertOversightAllows(array $context, string $name, string $typ * @param array $context Run-level metadata handed to every step. * @param array|null $items Seed items; defaults to one item from the subject. * @param string|null $startAt Node to start from; defaults to the flow's own start. + * @param FlowStreamWalk|null $streams The per-run stream collaborator: claims before each + * firing, a locked delta commit after it, stream-scoped + * suspension. Null walks a single in-memory stream + * exactly as before — a flow with one stream IS the run. * * @return array The run result: `{status, log: [], context: [], items: []}`. * @@ -314,6 +333,7 @@ public function run( array $context = [], ?array $items = null, ?string $startAt = null, + ?FlowStreamWalk $streams = null, ): array { $items = ($items ?? FlowItems::fromSubject(subject: $subject)); $flow = $this->withStartNode(flow: $flow, startAt: $startAt); @@ -341,14 +361,36 @@ public function run( $fired = 0; // Per-place item buffers. Items belong to the PLACES a token sits on, - // not to the run globally ({@see self::seedPlaceItems()}). + // not to the run globally ({@see self::seedPlaceItems()}). With a stream + // walk the buffers persisted by the last commit win, so each branch + // resumes with the items ITS branch produced. + $stored = null; + if ($streams !== null) { + $stored = $streams->run()->getPlaceItems(); + } + $placeItems = $this->placement()->seedPlaceItems( workflow: $workflow, subject: $subject, definition: $definition, - items: $items + items: $items, + stored: $stored ); + if ($streams !== null) { + return $this->walkStreams( + flow: $flow, + workflow: $workflow, + store: $store, + subject: $subject, + dispatcher: $dispatcher, + context: $context, + items: $items, + placeItems: $placeItems, + streams: $streams + ); + } + while (true) { $enabled = $workflow->getEnabledTransitions(subject: $subject); if (empty($enabled) === true) { @@ -638,6 +680,393 @@ public function run( }//end run() + /** + * The per-stream walk: round-robin over advanceable streams, a claim before + * every firing, a locked delta commit after it, and suspension scoped to + * the stream that raised it. + * + * Terminality is never concluded from this loop running dry. The pass ends + * when no stream can advance, and `finalize()` — under the run-row lock, + * from the marking it reads there — decides whether the run is queued (an + * enabled firing nobody took), suspended (every live stream parked, woken + * at the earliest non-null wake time) or terminal. + * + * @param array $flow The flow document. + * @param Workflow $workflow The Petri net over the run's marking store. + * @param MarkingStoreInterface $store The marking store (synced from each commit). + * @param object $subject The subject holding the marking. + * @param FlowStepDispatcher $dispatcher Performs each step's side effect. + * @param array $context Run-level metadata handed to every step. + * @param array $items The items in hand (returned with the result). + * @param array $placeItems Items per place. + * @param FlowStreamWalk $streams The stream collaborator. + * + * @return array The run result. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) One labelled branch per way a hop can + * end — pinned, completed, stopped, suspended, failed-and-continuing, failed-and-terminal — + * each with its stream bookkeeping; splitting them would scatter the protocol. + * @SuppressWarnings(PHPMD.NPathComplexity) The same branches, multiplied. + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) The walk reads top to bottom as the walk. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + private function walkStreams( + array $flow, + Workflow $workflow, + MarkingStoreInterface $store, + object $subject, + FlowStepDispatcher $dispatcher, + array $context, + array $items, + array $placeItems, + FlowStreamWalk $streams, + ): array { + $log = []; + $streams->begin(marking: $workflow->getMarking(subject: $subject)->getPlaces()); + + while (true) { + if ($streams->budgetSpent() === true) { + break; + } + + $streamId = $streams->nextStream(); + if ($streamId === null) { + break; + } + + // THE CEILING counts the RUN, not the pass: the durable firing + // count survives suspension and covers every stream together. + if ($streams->firings() >= self::MAX_TRANSITIONS) { + $this->logger->warning( + message: '[FlowEngine] Flow exceeded the transition ceiling; aborting', + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => ($flow['id'] ?? null), 'ceiling' => self::MAX_TRANSITIONS] + ); + $error = sprintf('Flow did not settle within %d transitions; it may contain an unbounded loop.', self::MAX_TRANSITIONS); + $streams->finalize(enabled: false, forcedTerminal: self::STATUS_FAILED); + + return ['status' => self::STATUS_FAILED, 'log' => $log, 'context' => $context, 'items' => $items, 'error' => $error]; + } + + // This stream's candidates: the enabled transitions consuming from + // the place its token sits on, narrowed by edge conditions exactly + // as the single-stream walk narrows them. + $place = $streams->placeOf(id: $streamId); + $candidates = []; + foreach ($workflow->getEnabledTransitions(subject: $subject) as $transition) { + if ($place !== null && in_array($place, array_map('strval', $transition->getFroms()), true) === true) { + $candidates[] = $transition; + } + } + + $transition = null; + if ($candidates !== []) { + $transition = $this->selectTransition(enabled: $candidates, flow: $flow, placeItems: $placeItems, context: $context); + } + + if ($transition === null) { + $streams->exhaust(id: $streamId); + continue; + } + + $name = $transition->getName(); + $froms = array_map('strval', $transition->getFroms()); + $tos = array_map('strval', $transition->getTos()); + + // THE CLAIM, on every place this firing touches, before anything + // runs. A refusal skips the candidate without waiting; the firing + // stays enabled and the run ends the pass `queued`. + $claimed = $streams->claim(id: $streamId, transition: $name, places: array_merge($froms, $tos)); + if ($claimed === null) { + continue; + } + + $step = $this->router()->stepFor(flow: $flow, transitionName: $name); + $itemsIn = $this->placement()->itemsForTransition(transition: $transition, placeItems: $placeItems); + $items = $itemsIn; + $stepType = (string)($step['type'] ?? ''); + $startedAt = microtime(true); + + $pinned = $this->pinnedItems(flow: $flow, context: $context, transitionName: $name); + if ($pinned !== null) { + $items = $pinned; + $entry = [ + 'transition' => $name, + 'type' => $stepType, + 'status' => 'pinned', + 'itemsIn' => count($itemsIn), + 'itemsOut' => count($items), + 'durationMs' => 0, + ]; + [$placeItems, $log] = $this->fireOnStream( + flow: $flow, + workflow: $workflow, + store: $store, + subject: $subject, + transition: $transition, + items: $items, + placeItems: $placeItems, + context: $context, + streams: $streams, + streamId: $streamId, + claimed: $claimed, + entry: $entry, + log: $log + ); + continue; + }//end if + + $this->enterHop(context: $context, name: $name, index: count($log)); + + try { + // OVERSIGHT, per firing, inside the claim — never hoisted per + // pass, never cached. A refusal ends the RUN, not the branch. + $this->assertOversightAllows(context: $context, name: $name, type: $stepType); + + $produced = $dispatcher->dispatch(step: $step, items: $itemsIn, context: $context); + $items = FlowItems::normalise(value: $produced); + $entry = [ + 'transition' => $name, + 'type' => $stepType, + 'status' => 'completed', + 'itemsIn' => count($itemsIn), + 'itemsOut' => count($items), + 'input' => $this->sampleItems(items: $itemsIn), + 'output' => $this->sampleItems(items: $items), + 'durationMs' => (int)round((microtime(true) - $startedAt) * 1000), + ]; + $report = $this->stepReport(context: $context); + if ($report !== []) { + $entry['report'] = $report; + } + } catch (FlowStop $stop) { + $streams->release(places: $claimed); + $log[] = [ + 'transition' => $name, + 'type' => $stepType, + 'status' => 'stopped', + 'reason' => $stop->getMessage(), + 'checkId' => $stop->checkId(), + 'durationMs' => (int)round((microtime(true) - $startedAt) * 1000), + 'streamId' => $streamId, + 'ordinalPath' => $streams->pathOf(id: $streamId), + ]; + + $stopStatus = self::STATUS_STOPPED; + $stopError = null; + if ($stop->isError() === true) { + $stopStatus = self::STATUS_FAILED; + $stopError = $stop->getMessage(); + } + + // A run-level end: no stream begins another firing. + $streams->finalize(enabled: false, forcedTerminal: $stopStatus); + + return ['status' => $stopStatus, 'log' => $log, 'context' => $context, 'items' => $items, 'error' => $stopError]; + } catch (FlowSuspension $suspension) { + // A pause of THIS stream. Its marking is not advanced — it + // resumes ON this transition — and its siblings keep going. + $log[] = [ + 'transition' => $name, + 'status' => 'suspended', + 'reason' => $suspension->getMessage(), + 'streamId' => $streamId, + 'ordinalPath' => $streams->pathOf(id: $streamId), + ]; + $streams->park( + id: $streamId, + resumeAt: $suspension->getResumeAt(), + reason: $suspension->getMessage(), + claimed: $claimed, + enabled: $streams->workRemains(transitions: $workflow->getEnabledTransitions(subject: $subject)) + ); + continue; + } catch (Throwable $e) { + $entry = [ + 'transition' => $name, + 'type' => $stepType, + 'status' => 'failed', + 'error' => $e->getMessage(), + 'durationMs' => (int)round((microtime(true) - $startedAt) * 1000), + ]; + $report = $this->stepReport(context: $context); + if ($report !== []) { + $entry['report'] = $report; + } + + $policy = (string)($step['onError'] ?? self::ON_ERROR_STOP); + $this->logger->warning( + message: '[FlowEngine] Flow step failed', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'flow' => ($flow['id'] ?? null), + 'transition' => $name, + 'policy' => $policy, + 'error' => $e->getMessage(), + ] + ); + + if ($policy !== self::ON_ERROR_CONTINUE) { + // Terminal for the run: the failed stream ends with the + // reason, every other stream ends with the run. + $terminal = self::STATUS_STOPPED; + if ($policy === self::ON_ERROR_DEAD_LETTER) { + $terminal = self::STATUS_DEAD_LETTER; + } + + $entry['streamId'] = $streamId; + $entry['ordinalPath'] = $streams->pathOf(id: $streamId); + $log[] = $entry; + $streams->endStream(id: $streamId, status: $terminal, error: $e->getMessage(), claimed: $claimed, enabled: false); + $streams->finalize(enabled: false, forcedTerminal: $terminal); + + return ['status' => $terminal, 'log' => $log, 'context' => $context, 'items' => $items]; + } + + // `continue`: the marking advances (leaving the token would spin + // this transition forever), recorded as a firing whose step failed. + [$placeItems, $log] = $this->fireOnStream( + flow: $flow, + workflow: $workflow, + store: $store, + subject: $subject, + transition: $transition, + items: $items, + placeItems: $placeItems, + context: $context, + streams: $streams, + streamId: $streamId, + claimed: $claimed, + entry: $entry, + log: $log, + streamError: $e->getMessage() + ); + continue; + } finally { + $this->runContext?->pop(); + }//end try + + [$placeItems, $log] = $this->fireOnStream( + flow: $flow, + workflow: $workflow, + store: $store, + subject: $subject, + transition: $transition, + items: $items, + placeItems: $placeItems, + context: $context, + streams: $streams, + streamId: $streamId, + claimed: $claimed, + entry: $entry, + log: $log + ); + }//end while + + // THE PASS'S LAST WORD, under the lock, from the marking there. "Work + // remains" excludes the transitions parked streams keep enabled: a wait + // is not work, and counting it would re-queue every parked run. + $enabled = $streams->workRemains(transitions: $workflow->getEnabledTransitions(subject: $subject)); + $status = $streams->finalize(enabled: $enabled); + + $result = ['status' => $status, 'log' => $log, 'context' => $context, 'items' => $items]; + if ($status === self::STATUS_SUSPENDED) { + $result['resumeAt'] = $streams->run()->getResumeAt(); + } + + return $result; + }//end walkStreams() + + /** + * Advance the marking for one firing and commit it on its stream. + * + * The in-memory apply is the same three calls the single-stream walk + * makes; the commit then writes the DELTA under the run-row lock and the + * store is synced from what was committed, so the next candidate is chosen + * against a marking that includes every sibling's progress. + * + * @param array $flow The flow document. + * @param Workflow $workflow The Petri net. + * @param MarkingStoreInterface $store The marking store. + * @param object $subject The subject. + * @param object $transition The fired transition. + * @param array $items The items produced. + * @param array $placeItems Items per place, before the firing. + * @param array $context The run context. + * @param FlowStreamWalk $streams The stream collaborator. + * @param string $streamId The firing stream. + * @param array $claimed The claimed places. + * @param array $entry The log entry for this hop. + * @param array $log The run log so far. + * @param string|null $streamError The step's error when it failed under `continue`. + * + * @return array{0: array, 1: array} The place items after, and the log. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) The hop's whole state, handed once. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + private function fireOnStream( + array $flow, + Workflow $workflow, + MarkingStoreInterface $store, + object $subject, + object $transition, + array $items, + array $placeItems, + array $context, + FlowStreamWalk $streams, + string $streamId, + array $claimed, + array $entry, + array $log, + ?string $streamError = null, + ): array { + $name = $transition->getName(); + $taken = $this->router()->takenExits(flow: $flow, transition: $transition, items: $items, context: $context); + $placeItems = $this->placement()->advanceItems(transition: $transition, placeItems: $placeItems, items: $items, taken: $taken); + $workflow->apply(subject: $subject, transitionName: $name); + $this->router()->keepOnlyTakenExits(workflow: $workflow, subject: $subject, transition: $transition, taken: $taken); + + // The places actually taken, in the transition's declaration order. + $takenTos = []; + foreach ($transition->getTos() as $to) { + if (in_array((string)$to, array_map('strval', $taken), true) === true) { + $takenTos[] = (string)$to; + } + } + + $result = $streams->commitFiring( + id: $streamId, + transition: $name, + froms: array_map('strval', $transition->getFroms()), + taken: $takenTos, + placeItems: $placeItems, + claimed: $claimed, + logEntry: $entry, + enabledAfter: $streams->workRemains(transitions: $workflow->getEnabledTransitions(subject: $subject)), + streamStatus: FlowRun::STATUS_RUNNING, + streamError: $streamError + ); + + // Sync from what was COMMITTED: the marking read under the lock holds + // every sibling's committed effect, which the in-memory apply cannot. + if ($store instanceof FlowRunMarkingStore) { + $store->syncCommitted(marking: $result->marking); + } + + $placeItems = $result->placeItems; + + $entry['recorded'] = true; + $entry['streamId'] = $streamId; + $entry['ordinalPath'] = $streams->pathOf(id: $streamId); + $entry['firings'] = $result->firings; + $log[] = $entry; + + return [$placeItems, $log]; + }//end fireOnStream() + /** * Override where a flow starts, for "run from here". * diff --git a/lib/Service/Flow/FlowFiring.php b/lib/Service/Flow/FlowFiring.php new file mode 100644 index 0000000000..c882515e74 --- /dev/null +++ b/lib/Service/Flow/FlowFiring.php @@ -0,0 +1,87 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +/** + * The delta and bookkeeping of one firing. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) A value carrier; every field + * is one thing the commit writes in its single transaction. + */ +class FlowFiring { + + /** + * The stream the commit settled as carrying the token onward. + * + * @var string + */ + public string $carrierStreamId = ''; + + /** + * Children minted by a split, keyed by taken output place. + * + * @var array + */ + public array $childStreamIds = []; + + /** + * Constructor. + * + * @param string $streamId The stream that fired. + * @param string $transition The transition name. + * @param array $froms The input places consumed. + * @param array $taken The output places actually taken, in declaration order. + * @param array $itemsByPlace The items now on each taken place. + * @param array $claimedPlaces The places this firing held claims on. + * @param array $consumedStreamIds Other streams whose tokens a join consumed. + * @param array $logEntry The engine's log entry for the step row. + * @param bool $enabledAfter Whether any transition is enabled after the delta. + * @param string $streamStatus The carrier stream's status after the firing. + * @param string|null $streamError The carrier's error, when the step failed under `continue`. + * @param string $streamPath The firing stream's ordinal path, used to mint its row when none exists yet. + * @param string|null $streamParent The firing stream's parent id, for the same minting. + */ + public function __construct( + public readonly string $streamId, + public readonly string $transition, + public readonly array $froms, + public readonly array $taken, + public readonly array $itemsByPlace, + public readonly array $claimedPlaces, + public readonly array $consumedStreamIds, + public readonly array $logEntry, + public readonly bool $enabledAfter, + public readonly string $streamStatus = 'running', + public readonly ?string $streamError = null, + public readonly string $streamPath = '0001', + public readonly ?string $streamParent = null, + ) { + + }//end __construct() +}//end class diff --git a/lib/Service/Flow/FlowFiringResult.php b/lib/Service/Flow/FlowFiringResult.php new file mode 100644 index 0000000000..fd1cd96e31 --- /dev/null +++ b/lib/Service/Flow/FlowFiringResult.php @@ -0,0 +1,47 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +/** + * The committed state after one firing. + */ +class FlowFiringResult { + + /** + * Constructor. + * + * @param array $marking The committed marking. + * @param array $placeItems The committed per-place items. + * @param array $streams The run's streams after the commit. + * @param int $firings The run's committed firing count. + */ + public function __construct( + public readonly array $marking, + public readonly array $placeItems, + public readonly array $streams, + public readonly int $firings, + ) { + + }//end __construct() +}//end class diff --git a/lib/Service/Flow/FlowItemPlacement.php b/lib/Service/Flow/FlowItemPlacement.php index afe29631dc..72f4c7c358 100644 --- a/lib/Service/Flow/FlowItemPlacement.php +++ b/lib/Service/Flow/FlowItemPlacement.php @@ -82,14 +82,26 @@ public function itemsForTransition(object $transition, array $placeItems): array * @param object $subject The subject holding the marking. * @param Definition $definition The definition (for the initial-place fallback). * @param array $items The seed items. + * @param array|null $stored The per-place items persisted by the last commit, or null when none. * * @return array Items keyed by place. * * @spec openspec/changes/or-flow-merge/specs/flow-merge/spec.md + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently */ - public function seedPlaceItems(Workflow $workflow, object $subject, Definition $definition, array $items): array { + public function seedPlaceItems(Workflow $workflow, object $subject, Definition $definition, array $items, ?array $stored = null): array { $placeItems = []; foreach (array_keys($workflow->getMarking(subject: $subject)->getPlaces()) as $place) { + // PER-PLACE items persisted by the last commit win: a stream that + // suspended holding two tokens resumes with each branch carrying + // the items ITS branch produced. The same-list-to-every-place seed + // below is kept for a run that predates the column (null), so an + // in-flight run's behaviour across the upgrade is identical. + if ($stored !== null && array_key_exists((string)$place, $stored) === true) { + $placeItems[(string)$place] = (array)$stored[(string)$place]; + continue; + } + $placeItems[(string)$place] = $items; } diff --git a/lib/Service/Flow/FlowPlaceClaims.php b/lib/Service/Flow/FlowPlaceClaims.php new file mode 100644 index 0000000000..1f15d1ed11 --- /dev/null +++ b/lib/Service/Flow/FlowPlaceClaims.php @@ -0,0 +1,235 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use DateTime; +use LogicException; +use OCA\OpenRegister\BackgroundJob\FlowRunWorker; +use OCA\OpenRegister\Db\FlowClaim; +use OCA\OpenRegister\Db\FlowClaimMapper; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +/** + * Acquires and releases place claims for one run. + */ +class FlowPlaceClaims { + + /** + * Constructor. + * + * @param FlowClaimMapper $claims The claim rows. + * @param IDBConnection $db To refuse running inside a transaction. + * @param LoggerInterface $logger Logger for repeated refusals. + */ + public function __construct( + private readonly FlowClaimMapper $claims, + private readonly IDBConnection $db, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * A per-pass owner token: instance, pid and a fresh uuid, so a reaped claim + * names the pass that abandoned it and two passes can never share a token. + * + * @return string The owner token. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + public static function newOwner(): string { + $host = gethostname(); + if (is_string($host) === false || $host === '') { + $host = 'host'; + } + + $pid = getmypid(); + if (is_int($pid) === false) { + $pid = 0; + } + + return substr(sprintf('%s:%d:%s', $host, $pid, bin2hex(random_bytes(8))), 0, 128); + }//end newOwner() + + /** + * The per-run stream cap: FlowConcurrency's numbers, referenced, not + * copied — the same `max(1, min(...))` as `boundedLimit()`. + * + * @param int|null $configured A flow-configured cap, or null for the default. + * + * @return int The effective cap. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-intra-run-fan-out-must-be-bounded + */ + public static function streamCap(?int $configured): int { + if ($configured === null) { + return FlowConcurrency::DEFAULT_LIMIT; + } + + return max(1, min($configured, FlowConcurrency::MAX_LIMIT)); + }//end streamCap() + + /** + * The per-pass ceiling across all runs: BATCH × DEFAULT_LIMIT, so raising + * the per-run cap cannot turn one pass into a burst. + * + * @return int The ceiling. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-intra-run-fan-out-must-be-bounded + */ + public static function passCap(): int { + return (FlowRunWorker::BATCH * FlowConcurrency::DEFAULT_LIMIT); + }//end passCap() + + /** + * Try to claim every place a firing touches. + * + * Returns the claimed place list on success, or null on refusal — with + * every partial claim already released. Never waits, never retries in + * place. + * + * @param string $runUuid The run. + * @param string $streamId The stream the firing belongs to. + * @param string $transition The transition name. + * @param array $places `froms ∪ tos`, in any order. + * @param string $owner The pass token. + * @param int|null $runCap The per-run stream cap; null for the default. + * + * @return array|null The claimed places, sorted, or null when refused. + * + * @throws LogicException When called inside a transaction. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + public function acquire(string $runUuid, string $streamId, string $transition, array $places, string $owner, ?int $runCap = null): ?array { + if ($this->db->inTransaction() === true) { + throw new LogicException( + 'FlowPlaceClaims::acquire() must not run inside a transaction: an uncommitted claim would block a rival for the whole step.' + ); + } + + $places = array_values(array_unique(array_map('strval', $places))); + sort($places, SORT_STRING); + if ($places === []) { + return []; + } + + // CAPS, before the first insert. A run may hold at most `runCap` + // claims; a pass at most `passCap()` across every run it advances. + if ($this->claims->countHeldForRun(runUuid: $runUuid) >= self::streamCap(configured: $runCap)) { + return null; + } + + if ($this->claims->countHeldByOwner(owner: $owner) >= self::passCap()) { + return null; + } + + $taken = []; + $now = new DateTime(); + foreach ($places as $place) { + $claim = new FlowClaim(); + $claim->setRunUuid($runUuid); + $claim->setPlace($place); + $claim->setOwner($owner); + $claim->setStreamId($streamId); + $claim->setTransition($transition); + $claim->setClaimedAt($now); + + if ($this->claims->insertOrRefuse(claim: $claim) === false) { + // Refused: release what this attempt already took and abandon + // the candidate. The firing stays enabled for a later attempt. + $this->claims->release(runUuid: $runUuid, places: $taken); + $this->logger->debug( + message: '[FlowPlaceClaims] Claim refused; candidate skipped', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'run' => $runUuid, + 'transition' => $transition, + 'place' => $place, + 'places' => $places, + ] + ); + + return null; + } + + $taken[] = $place; + }//end foreach + + return $taken; + }//end acquire() + + /** + * Release the places a firing held. + * + * @param string $runUuid The run. + * @param array $places The places to release. + * + * @return void + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + public function release(string $runUuid, array $places): void { + $this->claims->release(runUuid: $runUuid, places: $places); + }//end release() + + /** + * Release everything a pass still holds on a run — the pass's own cleanup. + * + * @param string $runUuid The run. + * @param string $owner The pass token. + * + * @return void + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + public function releaseAll(string $runUuid, string $owner): void { + $this->claims->releaseByOwner(runUuid: $runUuid, owner: $owner); + }//end releaseAll() +}//end class diff --git a/lib/Service/Flow/FlowRunCommit.php b/lib/Service/Flow/FlowRunCommit.php new file mode 100644 index 0000000000..b43393f41b --- /dev/null +++ b/lib/Service/Flow/FlowRunCommit.php @@ -0,0 +1,751 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\FlowClaimMapper; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowRunStep; +use OCA\OpenRegister\Db\FlowRunStepMapper; +use OCA\OpenRegister\Db\FlowStream; +use OCA\OpenRegister\Db\FlowStreamMapper; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Commits firings and derives run status, under the run-row lock. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The commit joins the run, its + * steps, its streams and its claims in ONE transaction; each mapper is one of + * the rows that transaction must write together. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Continue, split, join, park, end and + * finalize are each one branch of a token's life, and each must be in the same lock. + * @SuppressWarnings(PHPMD.StaticAccess) FlowStream's path helpers are pure functions on a value object. + */ +class FlowRunCommit { + + /** + * Severity order for the terminal projection: the most severe wins. + */ + private const SEVERITY = [ + FlowRun::STATUS_FAILED => 4, + FlowRun::STATUS_DEAD_LETTER => 3, + FlowRun::STATUS_STOPPED => 2, + FlowRun::STATUS_COMPLETED => 1, + ]; + + /** + * Constructor. + * + * @param IDBConnection $db The connection whose transaction is the critical section. + * @param FlowRunMapper $runs The run rows (locked for update). + * @param FlowStreamMapper $streams The stream rows. + * @param FlowClaimMapper $claims The claim rows. + * @param FlowRunStepMapper $steps The step rows. + * @param LoggerInterface $logger Logger for rollbacks. + */ + public function __construct( + private readonly IDBConnection $db, + private readonly FlowRunMapper $runs, + private readonly FlowStreamMapper $streams, + private readonly FlowClaimMapper $claims, + private readonly FlowRunStepMapper $steps, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Commit one firing. + * + * Inside one transaction: lock the run row, re-read the marking, apply the + * delta (one token off each `from`, one onto each TAKEN `to`), write the + * per-place items for the same places, insert the step row at the stream's + * next position, update the stream rows (continue, split into children, or + * fold a join back onto the common-prefix stream), increment `firings`, and + * write the run's derived status. The claims the firing held are released + * in the same transaction. + * + * The entity handed in is refreshed from the committed row afterwards and + * its updated-field tracking reset, so a later whole-entity update by the + * run service cannot carry a stale marking over what was just committed. + * + * @param FlowRun $run The run (refreshed in place). + * @param FlowFiring $firing What fired, on which stream, with what effect. + * @param string $owner The pass token whose claims on the firing's places are released. + * + * @return FlowFiringResult The committed marking, place items, and the streams now live. + * + * @throws Throwable Rolls back and rethrows on any failure. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + public function commitFiring(FlowRun $run, FlowFiring $firing, string $owner): FlowFiringResult { + $uuid = (string)$run->getUuid(); + $now = new DateTime(); + + $this->db->beginTransaction(); + try { + $locked = $this->runs->lockByUuid(uuid: $uuid); + + // THE DELTA, against the value read inside the lock. + $marking = self::normaliseMarking(places: ($locked->getMarking() ?? [])); + foreach ($firing->froms as $from) { + $marking[$from] = (($marking[$from] ?? 0) - 1); + if ($marking[$from] <= 0) { + unset($marking[$from]); + } + } + + foreach ($firing->taken as $to) { + $marking[$to] = (($marking[$to] ?? 0) + 1); + } + + // The per-place items move with the tokens, in the same write. + $placeItems = (array)($locked->getPlaceItems() ?? []); + foreach ($firing->froms as $from) { + unset($placeItems[$from]); + } + + foreach ($firing->taken as $to) { + $placeItems[$to] = ($firing->itemsByPlace[$to] ?? []); + } + + // STREAMS. The firing stream continues, splits, or is folded. + $streams = $this->indexByStreamId(streams: $this->streams->findByRun(runUuid: $uuid)); + $stream = ($streams[$firing->streamId] ?? null); + if ($stream === null) { + $stream = $this->mintStream( + runUuid: $uuid, + streamId: $firing->streamId, + path: $firing->streamPath, + parent: $firing->streamParent, + place: ($firing->froms[0] ?? null), + now: $now + ); + $streams[$firing->streamId] = $stream; + } + + // A join folds its input streams onto their common prefix FIRST, so + // the step row lands on the carrier at ITS next position — the + // split-and-join reads as one history with a fan-out in the middle. + $carrier = $this->foldCarrier(streams: $streams, firing: $firing, stream: $stream, now: $now); + + // The step row, positioned WITHIN the carrier stream. Allocation is + // the conditional-UPDATE shape, inside this transaction. + $sequence = $this->streams->allocateNextSequence(runUuid: $uuid, streamId: (string)$carrier->getStreamId()); + $carrier = $this->streams->findByRunAndStream(runUuid: $uuid, streamId: (string)$carrier->getStreamId()) ?? $carrier; + $this->insertStep(run: $locked, stream: $carrier, sequence: $sequence, entry: $firing->logEntry, now: $now); + + $this->settleStreams( + uuid: $uuid, + firing: $firing, + carrier: $carrier, + now: $now + ); + + // The run row: marking, place items, firings + 1, derived status. + $locked->setMarking($marking); + $locked->setPlaceItems($placeItems); + $locked->setFirings(((int)($locked->getFirings() ?? 0) + 1)); + $locked->setUpdated($now); + + // Release this firing's claims inside the lock, then derive. + $this->claims->release(runUuid: $uuid, places: $firing->claimedPlaces); + $live = $this->streams->findByRun(runUuid: $uuid); + $this->applyDerivedStatus(run: $locked, streams: $live, enabled: $firing->enabledAfter, owner: $owner); + + $this->runs->update($locked); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + $this->logger->error( + message: '[FlowRunCommit] Firing commit rolled back', + context: ['file' => __FILE__, 'line' => __LINE__, 'run' => $uuid, 'transition' => $firing->transition, 'error' => $e->getMessage()] + ); + throw $e; + }//end try + + $this->refresh(run: $run, from: $locked); + + return new FlowFiringResult( + marking: $marking, + placeItems: $placeItems, + streams: $live, + firings: (int)$locked->getFirings() + ); + }//end commitFiring() + + /** + * Park one stream: suspended with its wake time, claims released, run + * status re-derived. The marking is untouched — the stream resumes ON the + * transition that suspended it. + * + * @param FlowRun $run The run (refreshed in place). + * @param string $streamId The stream that raised the suspension. + * @param DateTime|null $resumeAt Its wake time; null while waiting on a signal. + * @param string $reason Why it parked. + * @param array $claimedPlaces The claims to release. + * @param string $owner The pass token. + * @param bool $enabled Whether any transition is enabled after this park. + * @param string $path The stream's ordinal path, to mint its row when none exists yet. + * @param string|null $parent The stream's parent id, for the same minting. + * @param string|null $place The place holding the stream's token, for the same minting. + * + * @return array The streams now live. + * + * @throws Throwable Rolls back and rethrows on any failure. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) The stream's descriptor rides along so the row can be minted in the same lock. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function park( + FlowRun $run, + string $streamId, + ?DateTime $resumeAt, + string $reason, + array $claimedPlaces, + string $owner, + bool $enabled, + string $path = FlowStream::ROOT_PATH, + ?string $parent = null, + ?string $place = null, + ): array { + $uuid = (string)$run->getUuid(); + $now = new DateTime(); + + $this->db->beginTransaction(); + try { + $locked = $this->runs->lockByUuid(uuid: $uuid); + $stream = $this->streams->findByRunAndStream(runUuid: $uuid, streamId: $streamId) + ?? $this->mintStream(runUuid: $uuid, streamId: $streamId, path: $path, parent: $parent, place: $place, now: $now); + $stream->setStatus(FlowRun::STATUS_SUSPENDED); + $stream->setResumeAt($resumeAt); + $stream->setError($reason); + $stream->setUpdated($now); + $this->streams->update($stream); + + $this->claims->release(runUuid: $uuid, places: $claimedPlaces); + $live = $this->streams->findByRun(runUuid: $uuid); + $this->applyDerivedStatus(run: $locked, streams: $live, enabled: $enabled, owner: $owner); + $locked->setUpdated($now); + $this->runs->update($locked); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + throw $e; + }//end try + + $this->refresh(run: $run, from: $locked); + + return $live; + }//end park() + + /** + * End a stream terminally (failed, stopped, dead-lettered) and re-derive. + * + * @param FlowRun $run The run (refreshed in place). + * @param string $streamId The stream. + * @param string $status One of FlowRun::TERMINAL. + * @param string|null $error The reason, when any. + * @param array $claimedPlaces The claims to release. + * @param string $owner The pass token. + * @param bool $enabled Whether any transition is enabled afterwards. + * @param string $path The stream's ordinal path, to mint its row when none exists yet. + * @param string|null $parent The stream's parent id, for the same minting. + * @param string|null $place The place holding the stream's token, for the same minting. + * + * @return array The streams now live. + * + * @throws Throwable Rolls back and rethrows on any failure. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) The stream's descriptor rides along so the row can be minted in the same lock. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-runs-status-must-stay-derivable-from-its-streams-with-no-new-value + */ + public function endStream( + FlowRun $run, + string $streamId, + string $status, + ?string $error, + array $claimedPlaces, + string $owner, + bool $enabled, + string $path = FlowStream::ROOT_PATH, + ?string $parent = null, + ?string $place = null, + ): array { + $uuid = (string)$run->getUuid(); + $now = new DateTime(); + + $this->db->beginTransaction(); + try { + $locked = $this->runs->lockByUuid(uuid: $uuid); + $stream = $this->streams->findByRunAndStream(runUuid: $uuid, streamId: $streamId) + ?? $this->mintStream(runUuid: $uuid, streamId: $streamId, path: $path, parent: $parent, place: $place, now: $now); + $stream->setStatus($status); + $stream->setError($error); + $stream->setUpdated($now); + $this->streams->update($stream); + + $this->claims->release(runUuid: $uuid, places: $claimedPlaces); + $live = $this->streams->findByRun(runUuid: $uuid); + $this->applyDerivedStatus(run: $locked, streams: $live, enabled: $enabled, owner: $owner); + $locked->setUpdated($now); + $this->runs->update($locked); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + throw $e; + }//end try + + $this->refresh(run: $run, from: $locked); + + return $live; + }//end endStream() + + /** + * The pass's last word on a run: release everything this pass still + * holds, then derive the run's status from its streams and the marking — + * all under the lock, so whichever pass locks last sees the truth. + * + * Terminality is decided HERE, never by a worker's own loop running dry: + * an enabled-but-unclaimed transition leaves the run `queued`, which the + * next pass drains — a missed pickup is latency, never a lost wake-up. + * + * @param FlowRun $run The run (refreshed in place). + * @param string $owner The pass token whose claims are released. + * @param bool $enabled Whether any transition is enabled on the committed marking. + * @param string|null $forcedTerminal A run-level terminal outcome (stop, dead-letter, failure) that overrides the projection. + * + * @return string The derived status. + * + * @throws Throwable Rolls back and rethrows on any failure. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-runs-status-must-stay-derivable-from-its-streams-with-no-new-value + */ + public function finalize(FlowRun $run, string $owner, bool $enabled, ?string $forcedTerminal = null): string { + $uuid = (string)$run->getUuid(); + $now = new DateTime(); + + $this->db->beginTransaction(); + try { + $locked = $this->runs->lockByUuid(uuid: $uuid); + $this->claims->releaseByOwner(runUuid: $uuid, owner: $owner); + $live = $this->streams->findByRun(runUuid: $uuid); + + if ($forcedTerminal !== null) { + // A run-level end: every non-terminal stream ends with it, so no + // branch begins a firing after the run has been told to stop. + foreach ($live as $stream) { + if ($stream->isTerminal() === false) { + $stream->setStatus($forcedTerminal); + $stream->setUpdated($now); + $this->streams->update($stream); + } + } + + $live = $this->streams->findByRun(runUuid: $uuid); + } + + // A run told to end has no enabled work left to honour: the + // projection then reads the (now uniformly terminal) streams. + $this->applyDerivedStatus(run: $locked, streams: $live, enabled: ($enabled === true && $forcedTerminal === null), owner: $owner); + $locked->setUpdated($now); + $this->runs->update($locked); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + throw $e; + }//end try + + $this->refresh(run: $run, from: $locked); + + return (string)$locked->getStatus(); + }//end finalize() + + /** + * The status projection of Decision 7, applied to the locked run row. + * + * - `running` while any stream holds a live claim (another pass is inside a firing) + * - else `queued` while any transition is enabled + * - else `suspended` when any stream is parked, with `resume_at` the MIN + * over NON-NULL wake times (null only when every parked stream waits on a signal) + * - else the most severe terminal among the streams + * + * No eighth value is added; `awaiting_consent` is left to the consent gate + * that owns it and is never produced here. + * + * @param FlowRun $run The locked run row. + * @param array $streams The run's streams. + * @param bool $enabled Whether any transition is enabled. + * @param string $owner The pass whose claims do not count as foreign. + * + * @return void + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) The projection table, one branch per row. + * @SuppressWarnings(PHPMD.NPathComplexity) The same table. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-runs-status-must-stay-derivable-from-its-streams-with-no-new-value + */ + private function applyDerivedStatus(FlowRun $run, array $streams, bool $enabled, string $owner): void { + $foreign = 0; + foreach ($this->claims->findByRun(runUuid: (string)$run->getUuid()) as $claim) { + if ($claim->getOwner() !== $owner) { + $foreign++; + } + } + + if ($foreign > 0) { + $run->setStatus(FlowRun::STATUS_RUNNING); + $run->setResumeAt(null); + return; + } + + if ($enabled === true) { + $run->setStatus(FlowRun::STATUS_QUEUED); + $run->setResumeAt(null); + return; + } + + $parked = false; + $earliest = null; + $severest = null; + foreach ($streams as $stream) { + if ($stream->getStatus() === FlowRun::STATUS_SUSPENDED) { + $parked = true; + $wake = $stream->getResumeAt(); + if ($wake !== null && ($earliest === null || $wake < $earliest)) { + $earliest = $wake; + } + + continue; + } + + if ($stream->isTerminal() === true) { + $rank = (self::SEVERITY[(string)$stream->getStatus()] ?? 0); + if ($severest === null || $rank > (self::SEVERITY[$severest] ?? 0)) { + $severest = (string)$stream->getStatus(); + } + } + } + + if ($parked === true) { + $run->setStatus(FlowRun::STATUS_SUSPENDED); + $run->setResumeAt($earliest); + return; + } + + $run->setStatus($severest ?? FlowRun::STATUS_COMPLETED); + $run->setResumeAt(null); + }//end applyDerivedStatus() + + /** + * The stream that carries the token onward: the firing stream, or — for a + * join consuming several streams — the stream at their longest common + * prefix, resumed. Every other consumed stream is completed. + * + * @param array $streams The run's streams by id. + * @param FlowFiring $firing The firing. + * @param FlowStream $stream The firing stream. + * @param DateTime $now Now. + * + * @return FlowStream The carrier. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + private function foldCarrier(array $streams, FlowFiring $firing, FlowStream $stream, DateTime $now): FlowStream { + $consumed = []; + foreach ($firing->consumedStreamIds as $id) { + if (isset($streams[$id]) === true) { + $consumed[$id] = $streams[$id]; + } + } + + $consumed[(string)$stream->getStreamId()] = $stream; + if (count($consumed) <= 1) { + return $stream; + } + + $prefix = FlowStream::commonPrefix(paths: array_map(static fn (FlowStream $s): string => (string)$s->getOrdinalPath(), array_values($consumed))); + $carrier = null; + foreach ($streams as $candidate) { + if ((string)$candidate->getOrdinalPath() === $prefix) { + $carrier = $candidate; + break; + } + } + + $carrier ??= $stream; + foreach ($consumed as $id => $ended) { + if ($id === (string)$carrier->getStreamId()) { + continue; + } + + $ended->setStatus(FlowRun::STATUS_COMPLETED); + $ended->setUpdated($now); + $this->streams->update($ended); + } + + return $carrier; + }//end foldCarrier() + + /** + * Continue or split the carrier stream. + * + * K taken outputs: K == 1 continues the carrier onto the taken place; + * K == 0 completes it (the token was consumed and nothing produced); + * K > 1 completes it and mints `parent.0001 … parent.000K` in `getTos()` + * declaration order. + * + * @param string $uuid The run. + * @param FlowFiring $firing The firing. + * @param FlowStream $carrier The carrier stream (already positioned). + * @param DateTime $now Now. + * + * @return void + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) Continue, end and split are three shapes of one settlement. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + private function settleStreams(string $uuid, FlowFiring $firing, FlowStream $carrier, DateTime $now): void { + $taken = array_values($firing->taken); + $firing->carrierStreamId = (string)$carrier->getStreamId(); + $firing->childStreamIds = []; + + if (count($taken) <= 1) { + $carrier->setStatus($firing->streamStatus); + $carrier->setError($firing->streamError); + $carrier->setResumeAt(null); + $carrier->setPlace(($taken[0] ?? null)); + if ($taken === []) { + // Nothing produced: the token is consumed and this branch ends. + $carrier->setStatus(FlowRun::STATUS_COMPLETED); + } + + $carrier->setUpdated($now); + $this->streams->update($carrier); + return; + } + + // A split: the carrier completes, K children begin. + $carrier->setStatus(FlowRun::STATUS_COMPLETED); + $carrier->setUpdated($now); + $this->streams->update($carrier); + $index = 1; + foreach ($taken as $to) { + $path = FlowStream::childPath(parentPath: (string)$carrier->getOrdinalPath(), index: $index); + $childId = self::streamIdFor(runUuid: $uuid, path: $path); + $firing->childStreamIds[$to] = $childId; + $index++; + + // A loop back through the same split re-uses the child row: its + // history continues rather than restarting. + $existing = $this->streams->findByRunAndStream(runUuid: $uuid, streamId: $childId); + if ($existing !== null) { + $existing->setStatus(FlowRun::STATUS_QUEUED); + $existing->setPlace($to); + $existing->setError(null); + $existing->setResumeAt(null); + $existing->setUpdated($now); + $this->streams->update($existing); + continue; + } + + $child = new FlowStream(); + $child->setRunUuid($uuid); + $child->setStreamId($childId); + $child->setOrdinalPath($path); + $child->setParentStreamId((string)$carrier->getStreamId()); + $child->setPlace($to); + $child->setStatus(FlowRun::STATUS_QUEUED); + $child->setNextSequence(1); + $child->setCreated($now); + $child->setUpdated($now); + $this->streams->insert($child); + }//end foreach + }//end settleStreams() + + /** + * A deterministic stream id for a path within a run. + * + * @param string $runUuid The run. + * @param string $path The ordinal path. + * + * @return string The stream id. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + public static function streamIdFor(string $runUuid, string $path): string { + return substr(sha1($runUuid . '|' . $path), 0, 32); + }//end streamIdFor() + + /** + * Mint a stream row the walk has been carrying in memory only. + * + * The root of a fresh run, or a branch of a pre-stream run, exists in the + * walk before it has fired anything; its row is written by the first + * commit that names it. `next_sequence` continues the run's history. + * + * @param string $runUuid The run. + * @param string $streamId The stream's id. + * @param string $path Its ordinal path. + * @param string|null $parent Its parent stream id. + * @param string|null $place The place holding its token. + * @param DateTime $now Now. + * + * @return FlowStream The inserted stream. + */ + private function mintStream(string $runUuid, string $streamId, string $path, ?string $parent, ?string $place, DateTime $now): FlowStream { + $stream = new FlowStream(); + $stream->setRunUuid($runUuid); + $stream->setStreamId($streamId); + $stream->setOrdinalPath($path); + $stream->setParentStreamId($parent); + $stream->setPlace($place); + $stream->setStatus(FlowRun::STATUS_RUNNING); + $stream->setNextSequence(($this->steps->highestSequence(runUuid: $runUuid) + 1)); + $stream->setCreated($now); + $stream->setUpdated($now); + + return $this->streams->insert($stream); + }//end mintStream() + + /** + * Insert the firing's step row at its stream position. + * + * @param FlowRun $run The locked run. + * @param FlowStream $stream The stream. + * @param int $sequence The position within the stream. + * @param array $entry The engine's log entry. + * @param DateTime $now Now. + * + * @return void + */ + private function insertStep(FlowRun $run, FlowStream $stream, int $sequence, array $entry, DateTime $now): void { + $step = new FlowRunStep(); + $step->setRunUuid((string)$run->getUuid()); + $step->setFlowId((string)$run->getFlowId()); + $step->setNodeId((string)($entry['transition'] ?? '')); + $step->setNodeType(($entry['type'] ?? null)); + $step->setSequence($sequence); + $step->setStreamId((string)$stream->getStreamId()); + $step->setOrdinalPath((string)$stream->getOrdinalPath()); + $step->setStatus((string)($entry['status'] ?? 'unknown')); + $step->setDurationMs(($entry['durationMs'] ?? null)); + $step->setCreated($now); + $step->setFinished($now); + $step->setError(($entry['error'] ?? ($entry['reason'] ?? null))); + $step->setOutput( + array_filter( + [ + 'itemsIn' => ($entry['itemsIn'] ?? null), + 'itemsOut' => ($entry['itemsOut'] ?? null), + 'checkId' => ($entry['checkId'] ?? null), + ], + static fn ($v): bool => $v !== null + ) + ); + $this->steps->insert($step); + }//end insertStep() + + /** + * Copy the committed row's values onto the caller's entity and reset its + * change tracking, so a later whole-entity update writes none of them. + * + * @param FlowRun $run The caller's entity. + * @param FlowRun $from The committed row. + * + * @return void + */ + private function refresh(FlowRun $run, FlowRun $from): void { + $run->setMarking($from->getMarking()); + $run->setPlaceItems($from->getPlaceItems()); + $run->setFirings($from->getFirings()); + $run->setStatus($from->getStatus()); + $run->setResumeAt($from->getResumeAt()); + $run->resetUpdatedFields(); + }//end refresh() + + /** + * Streams keyed by id. + * + * @param array $streams The streams. + * + * @return array By id. + */ + private function indexByStreamId(array $streams): array { + $byId = []; + foreach ($streams as $stream) { + $byId[(string)$stream->getStreamId()] = $stream; + } + + return $byId; + }//end indexByStreamId() + + /** + * A stored marking as `place => tokens`. + * + * @param mixed $places The raw value. + * + * @return array The normalised marking. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + public static function normaliseMarking(mixed $places): array { + if (is_array($places) === false) { + return []; + } + + $normalised = []; + foreach ($places as $key => $value) { + if (is_int($key) === true) { + $normalised[(string)$value] = 1; + continue; + } + + $normalised[(string)$key] = max(1, (int)$value); + } + + return $normalised; + }//end normaliseMarking() +}//end class diff --git a/lib/Service/Flow/FlowRunMarkingStore.php b/lib/Service/Flow/FlowRunMarkingStore.php index 2685a28fe5..6c511890c2 100644 --- a/lib/Service/Flow/FlowRunMarkingStore.php +++ b/lib/Service/Flow/FlowRunMarkingStore.php @@ -40,6 +40,14 @@ * A marking store backed by a FlowRun row. */ class FlowRunMarkingStore implements MarkingStoreInterface { + + /** + * The delta the last `setMarking()` applied. + * + * @var array{consumed: array, produced: array} + */ + private array $lastDelta = ['consumed' => [], 'produced' => []]; + /** * Constructor. * @@ -100,7 +108,84 @@ public function getMarking(object $subject): Marking { * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md */ public function setMarking(object $subject, Marking $marking, array $context = []): void { - $this->run->setMarking($marking->getPlaces()); + // A DELTA, never the whole value. Symfony hands over the marking it + // computed; the difference against what the run holds is exactly one + // token off each consumed place and one onto each produced one, and + // THAT is what is applied — so the write mentions no other place and + // cannot resurrect a token another writer consumed or drop one it + // produced. The whole-value assignment that used to live here is gone, + // not wrapped: leaving it reachable leaves the lost update reachable. + $current = $this->normalise(places: ($this->run->getMarking() ?? [])); + $next = $this->normalise(places: $marking->getPlaces()); + $this->lastDelta = ['consumed' => [], 'produced' => []]; + + foreach (array_unique(array_merge(array_keys($current), array_keys($next))) as $place) { + $diff = (($next[$place] ?? 0) - ($current[$place] ?? 0)); + if ($diff < 0) { + $this->lastDelta['consumed'][] = (string)$place; + $current[$place] = (($current[$place] ?? 0) + $diff); + if ($current[$place] <= 0) { + unset($current[$place]); + } + } elseif ($diff > 0) { + $this->lastDelta['produced'][] = (string)$place; + $current[$place] = (($current[$place] ?? 0) + $diff); + } + } + + $this->run->setMarking($current); }//end setMarking() + + /** + * The delta the last `setMarking()` applied: the places it took a token + * off and the places it put one onto. + * + * @return array{consumed: array, produced: array} The delta. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + public function lastDelta(): array { + return $this->lastDelta; + }//end lastDelta() + + /** + * Replace the run's marking with a COMMITTED one, read back from the + * database under the run-row lock. The one legitimate whole write: the + * value came from the lock, not from a read taken before the step ran. + * + * @param array $marking The committed marking. + * + * @return void + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + public function syncCommitted(array $marking): void { + $this->run->setMarking($this->normalise(places: $marking)); + }//end syncCommitted() + + /** + * A stored marking as `place => tokens`. + * + * @param mixed $places The raw value (a map, or a list of place names). + * + * @return array The normalised marking. + */ + private function normalise(mixed $places): array { + if (is_array($places) === false) { + return []; + } + + $normalised = []; + foreach ($places as $key => $value) { + if (is_int($key) === true) { + $normalised[(string)$value] = 1; + continue; + } + + $normalised[(string)$key] = max(1, (int)$value); + } + + return $normalised; + }//end normalise() }//end class diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index d18820d5e4..3d8c92688c 100644 --- a/lib/Service/Flow/FlowRunService.php +++ b/lib/Service/Flow/FlowRunService.php @@ -39,6 +39,7 @@ use OCA\OpenRegister\Db\FlowRunStep; use OCA\OpenRegister\Db\FlowRunStepMapper; use OCA\OpenRegister\Db\FlowStateMapper; +use OCA\OpenRegister\Db\FlowStreamMapper; use OCA\OpenRegister\Exception\FlowRunExpired; use OCA\OpenRegister\Service\Delegation\DelegationRefused; use OCA\OpenRegister\Service\Delegation\DelegationService; @@ -51,6 +52,10 @@ * The durable half of flow execution. * * @spec openspec/specs/flow-engine/spec.md + * + * @SuppressWarnings(PHPMD.ExcessiveClassLength) The run lifecycle — queue, execute, resume, + * signal, persist — plus the stream walk's wiring and the in-request advance; each is one + * entry into the same engine and belongs beside the others. */ class FlowRunService { /** @@ -103,6 +108,12 @@ class FlowRunService { * without it; history is then simply * not recorded, never faked. * @param IAppConfig|null $appConfig Reads the instance-wide runtime + * @param FlowStreamMapper|null $streamMapper Stream rows; with the two below, enables the stream walk. + * @param FlowPlaceClaims|null $claims The claim protocol. + * @param FlowRunCommit|null $commit The locked delta commit path. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) DI-injected collaborators, appended so + * the three test suites that construct this service positionally keep their slots. * ceiling. Nullable on the same terms * as $steps; absent, the compiled-in * default applies. @@ -116,6 +127,9 @@ public function __construct( private readonly ContainerInterface $container, private readonly ?FlowRunStepMapper $steps = null, private readonly ?IAppConfig $appConfig = null, + private readonly ?FlowStreamMapper $streamMapper = null, + private readonly ?FlowPlaceClaims $claims = null, + private readonly ?FlowRunCommit $commit = null, ) { // Built here rather than injected, deliberately: this service's // constructor is called explicitly by three test suites, and inserting or @@ -141,6 +155,136 @@ private function stepHistory(): FlowStepHistory { return new FlowStepHistory(steps: $this->steps, logger: $this->logger); }//end stepHistory() + /** + * The stream collaborator for a persisted run, or null when the three + * parts are not wired (a test-constructed service) — the engine then + * walks a single in-memory stream exactly as before. + * + * @param FlowRun $run The run about to be walked. + * @param array $flow The resolved flow document (for a per-flow stream cap). + * @param string|null $onlyStream Restrict the walk to one stream (an in-request advance). + * @param int|null $budget Firings the walk may commit; null for unbounded. + * + * @return FlowStreamWalk|null The collaborator. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowPlaceClaims::newOwner() mints a pass token; no instance state. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + private function streamWalkFor(FlowRun $run, array $flow, ?string $onlyStream = null, ?int $budget = null): ?FlowStreamWalk { + if ($this->streamMapper === null || $this->claims === null || $this->commit === null) { + return null; + } + + $cap = null; + $limits = (array)($flow['limits'] ?? []); + if (array_key_exists('streams', $limits) === true) { + $cap = (int)$limits['streams']; + } + + return new FlowStreamWalk( + run: $run, + claims: $this->claims, + commit: $this->commit, + streamMapper: $this->streamMapper, + owner: FlowPlaceClaims::newOwner(), + runCap: $cap, + onlyStream: $onlyStream, + budget: $budget + ); + }//end streamWalkFor() + + /** + * Release the claims a failed walk still holds, so a pass that died inside + * the engine does not leave its branches locked until the reaper's cutoff. + * Best-effort: the run is being failed anyway, and the reaper remains the + * backstop for a release that itself fails. + * + * @param FlowStreamWalk|null $walk The walk, when there was one. + * + * @return void + */ + private function releaseWalk(?FlowStreamWalk $walk): void { + if ($walk === null) { + return; + } + + try { + $walk->finalize(enabled: false, forcedTerminal: FlowRun::STATUS_FAILED); + } catch (Throwable $e) { + $this->logger->warning( + message: '[FlowRunService] Could not release a failed walk\'s claims; the reaper will', + context: ['file' => __FILE__, 'line' => __LINE__, 'run' => $walk->run()->getUuid(), 'error' => $e->getMessage()] + ); + } + }//end releaseWalk() + + /** + * Advance ONE stream of a run in the calling request, within a budget. + * + * ADR-098 D9's advance budget follows the token: completing a task on one + * branch may advance THAT branch — taking claims exactly as a worker does, + * bounded by the ceiling, per-firing oversight and the runtime budget — + * while its siblings are untouched. A sibling's claim ends the advance + * and returns the run as it stands; the queue does the rest. + * + * `$budget` is `0` (nothing; the run is left queued), a count, or `"all"` + * (bounded by the same three things a worker is). + * + * @param FlowRun $run The run, already resolved and pinned. + * @param array $flow The pinned flow document. + * @param object $subject The subject. + * @param string $streamId The completing branch. + * @param int|string $budget `0`, `N`, or `"all"`. + * + * @return FlowRun The run as it stands after the advance. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-completions-advance-budget-must-apply-to-the-completing-branch + */ + public function advanceStream(FlowRun $run, array $flow, object $subject, string $streamId, int|string $budget): FlowRun { + $firings = null; + if ($budget !== 'all') { + $firings = max(0, (int)$budget); + } + + if ($firings === 0) { + $run->setStatus(FlowRun::STATUS_QUEUED); + $run->setUpdated(new DateTime()); + + return $this->mapper->update($run); + } + + $walk = $this->streamWalkFor(run: $run, flow: $flow, onlyStream: $streamId, budget: $firings); + if ($walk === null) { + // Without the stream layer there is no branch to scope to; the + // queue advances the whole run on its next pass. + $run->setStatus(FlowRun::STATUS_QUEUED); + $run->setUpdated(new DateTime()); + + return $this->mapper->update($run); + } + + $run->setStatus(FlowRun::STATUS_RUNNING); + $run->setUpdated(new DateTime()); + $this->mapper->update($run); + + $guard = $this->guardFor(run: $run, flow: $flow); + $context = $this->nodeContextFor(run: $run, resuming: true, guard: $guard); + + $result = $this->engine->run( + flow: $flow, + store: new FlowRunMarkingStore(run: $run), + subject: $subject, + dispatcher: new RegistryStepDispatcher(registry: $this->registry, guard: $guard), + context: $context, + items: ($run->getItems() ?? []), + startAt: null, + streams: $walk + ); + + return $this->persistResult(run: $run, result: $result); + }//end advanceStream() + /** * Make an unattributed refusal visible on the flow, and stop a dead schedule. * @@ -681,6 +825,7 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed $guard = $this->guardFor(run: $run, flow: $flow); $context = $this->nodeContextFor(run: $run, resuming: $resuming, guard: $guard); + $walk = $this->streamWalkFor(run: $run, flow: $flow); try { $result = $this->engine->run( @@ -690,9 +835,11 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed dispatcher: new RegistryStepDispatcher(registry: $this->registry, guard: $guard), context: $context, items: $items, - startAt: $start + startAt: $start, + streams: $walk ); } catch (FlowRunExpired $e) { + $this->releaseWalk(walk: $walk); // The run stopped ITSELF at a checkpoint, having used its budget. // Recorded as a first-class outcome rather than folded into the crash // path below: nothing went wrong with the work, and the message has to @@ -714,6 +861,7 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed return $this->mapper->update($run); } catch (Throwable $e) { + $this->releaseWalk(walk: $walk); // The engine itself failing (rather than a step) is not something // the run should be left `running` for — that status would make it // look claimed by a worker forever. diff --git a/lib/Service/Flow/FlowStepHistory.php b/lib/Service/Flow/FlowStepHistory.php index 66e40e4d18..bd0445f687 100644 --- a/lib/Service/Flow/FlowStepHistory.php +++ b/lib/Service/Flow/FlowStepHistory.php @@ -43,6 +43,7 @@ use OCA\OpenRegister\Db\FlowRun; use OCA\OpenRegister\Db\FlowRunStep; use OCA\OpenRegister\Db\FlowRunStepMapper; +use OCA\OpenRegister\Db\FlowStream; use Psr\Log\LoggerInterface; use Throwable; @@ -142,12 +143,24 @@ public function record(FlowRun $run, array $entries): void { continue; } + // A firing committed by FlowRunCommit already has its step row, + // written inside the firing's own transaction at the stream's + // position. Writing it again here would double every firing. + if (($entry['recorded'] ?? false) === true) { + continue; + } + $step = new FlowRunStep(); $step->setRunUuid($runUuid); $step->setFlowId((string)$run->getFlowId()); $step->setNodeId((string)($entry['transition'] ?? '')); $step->setNodeType(($entry['type'] ?? null)); $step->setSequence($sequence); + // Branch identity when the walk knew it (a suspension, a stop, a + // terminal failure on a stream); a row from a pre-stream walk + // carries the root path, the single implicit stream. + $step->setStreamId(($entry['streamId'] ?? null)); + $step->setOrdinalPath((string)($entry['ordinalPath'] ?? FlowStream::ROOT_PATH)); $step->setStatus((string)($entry['status'] ?? 'unknown')); $step->setDurationMs(($entry['durationMs'] ?? null)); $step->setCreated(new DateTime()); diff --git a/lib/Service/Flow/FlowStreamWalk.php b/lib/Service/Flow/FlowStreamWalk.php new file mode 100644 index 0000000000..d0c92f6e3b --- /dev/null +++ b/lib/Service/Flow/FlowStreamWalk.php @@ -0,0 +1,623 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowStream; +use OCA\OpenRegister\Db\FlowStreamMapper; + +/** + * One run's streams, for one worker pass. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) The engine's per-hop protocol — + * schedule, claim, commit, park, end, finalize — is one method each on purpose, + * so the walk in FlowEngine reads as the walk. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Stream bookkeeping for the + * split, join, park and resume cases; each is one branch of the token's life. + * @SuppressWarnings(PHPMD.StaticAccess) FlowRunCommit::streamIdFor and FlowStream::childPath + * are pure id/path helpers; the walk needs their answers before any row exists. + */ +class FlowStreamWalk { + + /** + * In-memory stream descriptors by id: + * `{id, path, parent, place, status}`. + * + * @var array + */ + private array $streams = []; + + /** + * Streams parked during this pass (suspended), by id. + * + * @var array + */ + private array $parked = []; + + /** + * Streams that found no enabled transition on their last visit, by id. + * Cleared whenever a commit changes the marking, because a sibling's + * commit may have enabled a join. + * + * @var array + */ + private array $exhausted = []; + + /** + * Streams whose claim was refused this pass, by id. They stay enabled and + * leave the run `queued` for the next pass. + * + * @var array + */ + private array $refused = []; + + /** + * Round-robin cursor over the ordinal-ordered stream list. + * + * @var int + */ + private int $cursor = 0; + + /** + * Firings committed by this walk. + * + * @var int + */ + private int $fired = 0; + + /** + * Constructor. + * + * @param FlowRun $run The run being walked (refreshed by every commit). + * @param FlowPlaceClaims $claims The claim protocol. + * @param FlowRunCommit $commit The commit path. + * @param FlowStreamMapper $streamMapper The stream rows. + * @param string $owner This pass's claim token. + * @param int|null $runCap The per-run stream cap; null for FlowConcurrency's default. + * @param string|null $onlyStream Restrict the walk to one stream (an in-request advance). + * @param int|null $budget Firings this walk may commit; null for unbounded (the ceiling still applies). + */ + public function __construct( + private readonly FlowRun $run, + private readonly FlowPlaceClaims $claims, + private readonly FlowRunCommit $commit, + private readonly FlowStreamMapper $streamMapper, + private readonly string $owner, + private readonly ?int $runCap = null, + private readonly ?string $onlyStream = null, + private readonly ?int $budget = null, + ) { + + }//end __construct() + + /** + * Load the run's streams and assign the marking's tokens to them. + * + * Persisted streams carry their place. A marked place no stream claims — + * a fresh run's initial place, or a pre-stream run's tokens — is given an + * in-memory stream: the first the root, the rest the root's children in + * sorted place order, exactly as the migration back-fill does. Suspended + * streams become eligible again: the run was woken, and a node that is + * still waiting will simply park its stream again. + * + * @param array $marking The run's marking, `place => tokens`. + * + * @return void + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function begin(array $marking): void { + $uuid = (string)$this->run->getUuid(); + $this->streams = []; + + $covered = []; + foreach ($this->streamMapper->findByRun(runUuid: $uuid) as $row) { + if ($row->isTerminal() === true) { + continue; + } + + $id = (string)$row->getStreamId(); + $place = $row->getPlace(); + $this->streams[$id] = [ + 'id' => $id, + 'path' => (string)$row->getOrdinalPath(), + 'parent' => $row->getParentStreamId(), + 'place' => $place, + 'status' => (string)$row->getStatus(), + ]; + if ($place !== null) { + $covered[$place] = (($covered[$place] ?? 0) + 1); + } + } + + $unassigned = []; + foreach ($marking as $place => $tokens) { + $spare = ((int)$tokens - ($covered[(string)$place] ?? 0)); + for ($i = 0; $i < $spare; $i++) { + $unassigned[] = (string)$place; + } + } + + sort($unassigned, SORT_STRING); + $rootId = FlowRunCommit::streamIdFor(runUuid: $uuid, path: FlowStream::ROOT_PATH); + $ordinal = 1; + foreach ($unassigned as $place) { + if (isset($this->streams[$rootId]) === false) { + $this->streams[$rootId] = [ + 'id' => $rootId, + 'path' => FlowStream::ROOT_PATH, + 'parent' => null, + 'place' => $place, + 'status' => FlowRun::STATUS_QUEUED, + ]; + continue; + } + + $ordinal++; + $path = FlowStream::childPath(parentPath: FlowStream::ROOT_PATH, index: $ordinal); + $id = FlowRunCommit::streamIdFor(runUuid: $uuid, path: $path); + $this->streams[$id] = [ + 'id' => $id, + 'path' => $path, + 'parent' => $rootId, + 'place' => $place, + 'status' => FlowRun::STATUS_QUEUED, + ]; + } + + $this->sortStreams(); + }//end begin() + + /** + * The next stream to visit, round-robin over live streams that are not + * parked, refused or exhausted this pass — or null when none remains. + * + * @return string|null The stream id. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function nextStream(): ?string { + $ids = array_keys($this->streams); + $count = count($ids); + for ($i = 0; $i < $count; $i++) { + $id = $ids[(($this->cursor + $i) % $count)]; + if ($this->isAdvanceable(id: $id) === true) { + $this->cursor = ((($this->cursor + $i) + 1) % max(1, $count)); + return $id; + } + } + + return null; + }//end nextStream() + + /** + * Whether a stream may be visited this pass. + * + * @param string $id The stream id. + * + * @return bool True when advanceable. + */ + private function isAdvanceable(string $id): bool { + if (isset($this->streams[$id]) === false) { + return false; + } + + if ($this->onlyStream !== null && $id !== $this->onlyStream) { + return false; + } + + return isset($this->parked[$id]) === false + && isset($this->refused[$id]) === false + && isset($this->exhausted[$id]) === false; + }//end isAdvanceable() + + /** + * Whether any of the given enabled transitions is work somebody could + * still do: one that consumes from a place held by a stream NOT parked in + * this pass. A parked stream's token keeps its transition enabled in the + * Petri net, but that transition is a wait, not work — counting it would + * make every parked run read as `queued` and spin the worker. + * + * @param array $transitions The enabled transitions. + * + * @return bool True when an unparked stream has an enabled transition. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-runs-status-must-stay-derivable-from-its-streams-with-no-new-value + */ + public function workRemains(array $transitions): bool { + $unparked = []; + foreach ($this->streams as $id => $stream) { + if (isset($this->parked[$id]) === false && $stream['place'] !== null) { + $unparked[$stream['place']] = true; + } + } + + foreach ($transitions as $transition) { + foreach ($transition->getFroms() as $from) { + if (isset($unparked[(string)$from]) === true) { + return true; + } + } + } + + return false; + }//end workRemains() + + /** + * The ordinal path of a stream, for a log entry that is not a firing (a + * suspension, a stop, a terminal failure) and so is written by the step + * history rather than the commit. + * + * @param string $id The stream id. + * + * @return string The path; the root path for an unknown stream. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + public function pathOf(string $id): string { + return (string)($this->streams[$id]['path'] ?? FlowStream::ROOT_PATH); + }//end pathOf() + + /** + * The place holding a stream's token. + * + * @param string $id The stream id. + * + * @return string|null The place. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function placeOf(string $id): ?string { + return ($this->streams[$id]['place'] ?? null); + }//end placeOf() + + /** + * The live streams whose token sits on one of the given places, other than + * the firing stream — the siblings a join consumes. + * + * @param array $places The transition's input places. + * @param string $except The firing stream. + * + * @return array Stream ids. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function streamsOn(array $places, string $except): array { + $found = []; + foreach ($this->streams as $id => $stream) { + if ($id === $except || $stream['place'] === null) { + continue; + } + + if (in_array($stream['place'], $places, true) === true) { + $found[] = $id; + } + } + + return $found; + }//end streamsOn() + + /** + * Note that a stream found nothing to fire on this visit. + * + * @param string $id The stream id. + * + * @return void + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function exhaust(string $id): void { + $this->exhausted[$id] = true; + }//end exhaust() + + /** + * Try to claim every place a firing touches, for a stream. + * + * A refusal marks the stream refused for this pass: the firing stays + * enabled and the run ends the pass `queued`, never waited on. + * + * @param string $id The stream id. + * @param string $transition The transition name. + * @param array $places `froms ∪ tos`. + * + * @return array|null The claimed places, or null when refused. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + public function claim(string $id, string $transition, array $places): ?array { + $claimed = $this->claims->acquire( + runUuid: (string)$this->run->getUuid(), + streamId: $id, + transition: $transition, + places: $places, + owner: $this->owner, + runCap: $this->runCap + ); + + if ($claimed === null) { + $this->refused[$id] = true; + } + + return $claimed; + }//end claim() + + /** + * Release claims without firing (an oversight refusal, a run-level end). + * + * @param array $places The claimed places. + * + * @return void + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + public function release(array $places): void { + $this->claims->release(runUuid: (string)$this->run->getUuid(), places: $places); + }//end release() + + /** + * Commit one firing and update the in-memory streams from what the commit + * settled: the carrier moved on, or split into children, or absorbed the + * streams a join consumed. + * + * @param string $id The firing stream. + * @param string $transition The transition name. + * @param array $froms The consumed places. + * @param array $taken The produced places actually taken, in declaration order. + * @param array $placeItems The engine's per-place items after the firing. + * @param array $claimed The places this firing claimed. + * @param array $logEntry The engine's log entry. + * @param bool $enabledAfter Whether any transition is enabled after the firing. + * @param string $streamStatus The carrier's status after the firing. + * @param string|null $streamError The carrier's error under a `continue` failure. + * + * @return FlowFiringResult The committed state. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) The hop's whole effect, handed once to the commit. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + public function commitFiring( + string $id, + string $transition, + array $froms, + array $taken, + array $placeItems, + array $claimed, + array $logEntry, + bool $enabledAfter, + string $streamStatus = FlowRun::STATUS_RUNNING, + ?string $streamError = null, + ): FlowFiringResult { + $stream = ($this->streams[$id] ?? ['path' => FlowStream::ROOT_PATH, 'parent' => null]); + $consumed = $this->streamsOn(places: $froms, except: $id); + + $itemsByPlace = []; + foreach ($taken as $to) { + $itemsByPlace[$to] = ($placeItems[$to] ?? []); + } + + $firing = new FlowFiring( + streamId: $id, + transition: $transition, + froms: array_values($froms), + taken: array_values($taken), + itemsByPlace: $itemsByPlace, + claimedPlaces: $claimed, + consumedStreamIds: $consumed, + logEntry: $logEntry, + enabledAfter: $enabledAfter, + streamStatus: $streamStatus, + streamError: $streamError, + streamPath: (string)$stream['path'], + streamParent: $stream['parent'] + ); + + $result = $this->commit->commitFiring(run: $this->run, firing: $firing, owner: $this->owner); + $this->fired++; + + // Re-read the stream picture from what was committed: the commit is the + // authority on lineage, and a sibling pass may have moved things too. + $this->streams = []; + foreach ($result->streams as $row) { + if ($row->isTerminal() === true) { + continue; + } + + $rowId = (string)$row->getStreamId(); + $this->streams[$rowId] = [ + 'id' => $rowId, + 'path' => (string)$row->getOrdinalPath(), + 'parent' => $row->getParentStreamId(), + 'place' => $row->getPlace(), + 'status' => (string)$row->getStatus(), + ]; + } + + $this->sortStreams(); + + // A commit changed the marking, so a stream that found nothing before + // may find a join enabled now. Parked and refused streams stay so. + $this->exhausted = []; + + return $result; + }//end commitFiring() + + /** + * Park a stream on a suspension: it is done for this pass, its siblings + * are not. + * + * @param string $id The stream id. + * @param DateTime|null $resumeAt Its wake time; null while waiting on a signal. + * @param string $reason Why it parked. + * @param array $claimed The claims to release. + * @param bool $enabled Whether any transition is enabled afterwards. + * + * @return void + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function park(string $id, ?DateTime $resumeAt, string $reason, array $claimed, bool $enabled): void { + $stream = ($this->streams[$id] ?? ['path' => FlowStream::ROOT_PATH, 'parent' => null, 'place' => null]); + $this->commit->park( + run: $this->run, + streamId: $id, + resumeAt: $resumeAt, + reason: $reason, + claimedPlaces: $claimed, + owner: $this->owner, + enabled: $enabled, + path: (string)$stream['path'], + parent: $stream['parent'], + place: $stream['place'] + ); + $this->parked[$id] = true; + if (isset($this->streams[$id]) === true) { + $this->streams[$id]['status'] = FlowRun::STATUS_SUSPENDED; + } + }//end park() + + /** + * End a stream terminally. + * + * @param string $id The stream id. + * @param string $status One of FlowRun::TERMINAL. + * @param string|null $error The reason. + * @param array $claimed The claims to release. + * @param bool $enabled Whether any transition is enabled afterwards. + * + * @return void + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-runs-status-must-stay-derivable-from-its-streams-with-no-new-value + */ + public function endStream(string $id, string $status, ?string $error, array $claimed, bool $enabled): void { + $stream = ($this->streams[$id] ?? ['path' => FlowStream::ROOT_PATH, 'parent' => null, 'place' => null]); + $this->commit->endStream( + run: $this->run, + streamId: $id, + status: $status, + error: $error, + claimedPlaces: $claimed, + owner: $this->owner, + enabled: $enabled, + path: (string)$stream['path'], + parent: $stream['parent'], + place: $stream['place'] + ); + unset($this->streams[$id]); + }//end endStream() + + /** + * The pass's last word: release what this pass still holds and derive the + * run's status under the lock. + * + * @param bool $enabled Whether any transition is enabled on the committed marking. + * @param string|null $forcedTerminal A run-level terminal outcome, when the walk ended the run. + * + * @return string The derived status. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-runs-status-must-stay-derivable-from-its-streams-with-no-new-value + */ + public function finalize(bool $enabled, ?string $forcedTerminal = null): string { + return $this->commit->finalize(run: $this->run, owner: $this->owner, enabled: $enabled, forcedTerminal: $forcedTerminal); + }//end finalize() + + /** + * The run's durable firing count, as last committed. + * + * @return int Firings across all streams and passes. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-transition-ceiling-must-count-a-run-not-a-pass + */ + public function firings(): int { + return (int)($this->run->getFirings() ?? 0); + }//end firings() + + /** + * Whether this walk's own firing budget is spent (an in-request advance). + * + * @return bool True when no more firings may be committed by this walk. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-completions-advance-budget-must-apply-to-the-completing-branch + */ + public function budgetSpent(): bool { + return $this->budget !== null && $this->fired >= $this->budget; + }//end budgetSpent() + + /** + * Whether any stream was parked during this pass. + * + * @return bool True when a stream suspended. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function anyParked(): bool { + return $this->parked !== []; + }//end anyParked() + + /** + * Whether any stream's claim was refused during this pass. + * + * @return bool True when a firing was skipped on contention. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + public function anyRefused(): bool { + return $this->refused !== []; + }//end anyRefused() + + /** + * The run, refreshed by the last commit. + * + * @return FlowRun The run. + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + public function run(): FlowRun { + return $this->run; + }//end run() + + /** + * Keep the stream list in ordinal order, so the round-robin visits + * branches in the author's declaration order. + * + * @return void + */ + private function sortStreams(): void { + uasort($this->streams, static fn (array $a, array $b): int => strcmp($a['path'], $b['path'])); + }//end sortStreams() +}//end class diff --git a/openspec/changes/flow-parallel-streams/tasks.md b/openspec/changes/flow-parallel-streams/tasks.md index 44919c8a75..8321d3ae19 100644 --- a/openspec/changes/flow-parallel-streams/tasks.md +++ b/openspec/changes/flow-parallel-streams/tasks.md @@ -2,7 +2,7 @@ ## 1. Storage -- [ ] 1.1 Migration: `openregister_flow_claims` (`run_uuid`, `place`, `owner`, +- [x] 1.1 Migration: `openregister_flow_claims` (`run_uuid`, `place`, `owner`, `stream_id`, `transition`, `claimed_at`) with UNIQUE `(run_uuid, place)` — the unique index IS the lock — and an index on `claimed_at` for the reaper; `openregister_flow_streams` (`run_uuid`, `stream_id`, @@ -12,13 +12,13 @@ `place_items` (json, nullable) + `firings` (int, notnull, default 0) on `openregister_flow_runs`; `stream_id` + `ordinal_path` (string 255, nullable) on `openregister_flow_steps`. -- [ ] 1.2 `lib/Db/FlowStream.php` + `FlowStreamMapper` (find by run, find by +- [x] 1.2 `lib/Db/FlowStream.php` + `FlowStreamMapper` (find by run, find by run+stream, allocate the next sequence with the conditional-UPDATE shape of `lib/Db/SequenceMapper.php:84-93`) and `lib/Db/FlowClaim.php` + `FlowClaimMapper` (insert-or-refuse, release by owner, count held per run, find older than a cutoff). Stream status reuses `FlowRun`'s seven constants (`lib/Db/FlowRun.php:98-110`) rather than a second vocabulary. -- [ ] 1.3 Repair step in the same migration: one stream per marked place on +- [x] 1.3 Repair step in the same migration: one stream per marked place on every non-terminal run, ordinals in sorted place-name order, `next_sequence` from `highestSequence() + 1` (`lib/Db/FlowRunStepMapper.php:81-97`); existing step rows stamped with @@ -28,14 +28,14 @@ ## 2. Claim protocol -- [ ] 2.1 `FlowPlaceClaims` — `acquire(run, streamId, transition, places)` +- [x] 2.1 `FlowPlaceClaims` — `acquire(run, streamId, transition, places)` sorts `froms ∪ tos` bytewise, INSERTs each place in its OWN committed transaction, and on the first unique violation DELETEs what it already took and returns a refusal. It never waits and never retries in place. The commit-per-insert is load-bearing: taking a claim inside the firing's transaction would make a rival INSERT block on the row lock for the duration of the step (design.md Decision 1). -- [ ] 2.2 Pass identity + cap enforcement: `owner` is a per-pass token +- [x] 2.2 Pass identity + cap enforcement: `owner` is a per-pass token (instance, pid, pass uuid) stamped on every claim; a claim is refused when the run already holds `FlowConcurrency::DEFAULT_LIMIT` (`lib/Service/Flow/FlowConcurrency.php:72`) claims, clamped by @@ -45,19 +45,19 @@ ## 3. The commit path -- [ ] 3.1 `FlowRunCommit` — one method holding the whole critical section: +- [x] 3.1 `FlowRunCommit` — one method holding the whole critical section: `beginTransaction()`, `SELECT ... FOR UPDATE` the run row, recompute from the value read INSIDE the lock, apply the delta, write marking + `place_items` + the step row + the stream row + `firings + 1` + the derived status, `commit()`. No I/O and no user code inside it; the dispatch stays outside. `IDBConnection` transaction handling follows `lib/Service/SequenceService.php:76-115`. -- [ ] 3.2 `FlowRunMarkingStore::setMarking()` +- [x] 3.2 `FlowRunMarkingStore::setMarking()` (`lib/Service/Flow/FlowRunMarkingStore.php:102-105`) stops writing `$marking->getPlaces()` wholesale and takes a delta — one token off each `from`, one onto each taken `to`. The whole-value write is removed, not wrapped: leaving it reachable leaves the lost update reachable. -- [ ] 3.3 Per-place items persisted: `place_items` written by the same +- [x] 3.3 Per-place items persisted: `place_items` written by the same transaction as the marking, and `FlowItemPlacement::seedPlaceItems()` (`lib/Service/Flow/FlowItemPlacement.php:90-103`) reads it when present, falling back to today's same-list-to-every-place seed when null so an @@ -65,22 +65,22 @@ ## 4. The stream walk -- [ ] 4.1 `FlowStreamScheduler` — round-robin over a run's advanceable streams +- [x] 4.1 `FlowStreamScheduler` — round-robin over a run's advanceable streams rather than draining one to exhaustion, bounded by task 2.2's cap. A stream whose claim is refused yields to the next; a stream that parks yields; neither returns the run. -- [ ] 4.2 `FlowEngine::run()` (`lib/Service/Flow/FlowEngine.php:310-546`) +- [x] 4.2 `FlowEngine::run()` (`lib/Service/Flow/FlowEngine.php:310-546`) becomes a per-stream walk: `FlowSuspension` (`:474-493`) parks the stream that raised it and releases its claim instead of returning the run, and the empty-enabled-set exit (`:312-322`) no longer decides the run's fate. Terminality is decided only by `FlowRunCommit` from the marking it just wrote (design.md Decision 4). -- [ ] 4.3 Stream lineage: a firing that marks K taken output places mints K +- [x] 4.3 Stream lineage: a firing that marks K taken output places mints K child streams with `parent.0001 … parent.000K` in `getTos()` declaration order; a join folds its inputs back to their longest common prefix and resumes that stream's `next_sequence`; a path that would exceed the column fails the run with a named error rather than sorting wrongly. -- [ ] 4.4 Derived run status written by `FlowRunCommit`: `running` while any +- [x] 4.4 Derived run status written by `FlowRunCommit`: `running` while any stream holds a live claim, `queued` while any stream has an enabled transition, `suspended` when all are parked, else the most severe terminal (`failed` > `dead_letter` > `stopped` > `completed`). @@ -91,7 +91,7 @@ ## 5. Run-log ordering -- [ ] 5.1 `FlowRunService::recordSteps()` +- [x] 5.1 `FlowRunService::recordSteps()` (`lib/Service/Flow/FlowRunService.php:669-732`) stops reading `highestSequence() + 1` (`:677`) and takes its position from the stream row inside `FlowRunCommit`'s transaction, writing `stream_id` and @@ -102,16 +102,16 @@ ## 6. Bounds, oversight and recovery -- [ ] 6.1 The transition ceiling becomes the persisted `firings` count checked +- [x] 6.1 The transition ceiling becomes the persisted `firings` count checked against `MAX_TRANSITIONS` (`lib/Service/Flow/FlowEngine.php:103`), replacing the per-pass local at `:299`/`:325`, and keeping the existing failure message (`:335`) so a cycle that parks each lap now trips it. -- [ ] 6.2 `assertOversightAllows()` (`FlowEngine.php:425`) is called per firing +- [x] 6.2 `assertOversightAllows()` (`FlowEngine.php:425`) is called per firing inside the claim, never hoisted per pass and never cached. A refusal ends the RUN: unstarted streams do not start, a stream already inside `dispatch()` commits that firing and then stops, and the refusing check's id is recorded via `FlowStop::checkId()` (`:454-456`). -- [ ] 6.3 `FlowRunWorker::reapStale()` (`lib/BackgroundJob/FlowRunWorker.php:226-275`) +- [x] 6.3 `FlowRunWorker::reapStale()` (`lib/BackgroundJob/FlowRunWorker.php:226-275`) also releases claims older than its EXISTING cutoff (`:251-261`) — the same expression, not a second constant — fails the abandoned stream naming the branch, applies the run's error policy to its siblings, and @@ -119,7 +119,7 @@ ## 7. Advance budget (ADR-098 D9) -- [ ] 7.1 Task completion's `advance: 0 | N | "all"` advances the COMPLETING +- [x] 7.1 Task completion's `advance: 0 | N | "all"` advances the COMPLETING stream only, taking claims through `FlowPlaceClaims` exactly as a worker does. A refused claim ENDS the advance and returns the run's state; a join consuming the completing branch's place is inside the budget; @@ -136,7 +136,7 @@ join with simultaneous arrivals fires exactly once reading both branches' items; a join enabled by the last commit of a finished pass is fired by the next pass and the run is never reported completed in between. -- [ ] 8.2 Determinism and regression: two runs whose branches finish in +- [x] 8.2 Determinism and regression: two runs whose branches finish in opposite orders produce identical canonical logs; the real interleaving is still readable by timestamp; a twelve-token marking holds at most five claims; a cap above the ceiling is clamped; and a single-stream flow diff --git a/tests/Unit/BackgroundJob/FlowRunWorkerClaimReaperTest.php b/tests/Unit/BackgroundJob/FlowRunWorkerClaimReaperTest.php new file mode 100644 index 0000000000..e3f5ebc3f0 --- /dev/null +++ b/tests/Unit/BackgroundJob/FlowRunWorkerClaimReaperTest.php @@ -0,0 +1,226 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\BackgroundJob + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-branch-abandoned-by-a-crashed-worker-must-be-recovered-and-must-not-be-silently-re-run + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\BackgroundJob; + +use DateTime; +use OCA\OpenRegister\BackgroundJob\FlowRunWorker; +use OCA\OpenRegister\Db\FlowClaim; +use OCA\OpenRegister\Db\FlowClaimMapper; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowStream; +use OCA\OpenRegister\Db\FlowStreamMapper; +use OCA\OpenRegister\Service\Flow\FlowLocator; +use OCA\OpenRegister\Service\Flow\FlowRunAdvancer; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Abandoned claims. + */ +class FlowRunWorkerClaimReaperTest extends TestCase { + + private FlowRunMapper&MockObject $mapper; + + private FlowRunAdvancer&MockObject $advancer; + + private FlowClaimMapper&MockObject $claims; + + private FlowStreamMapper&MockObject $streams; + + private FlowLocator&MockObject $flows; + + private FlowRun $run; + + private FlowStream $branch; + + private FlowStream $sibling; + + /** @var array the cutoffs the claim reaper asked for */ + private array $cutoffs = []; + + protected function setUp(): void { + parent::setUp(); + $this->mapper = $this->createMock(FlowRunMapper::class); + $this->advancer = $this->createMock(FlowRunAdvancer::class); + $this->claims = $this->createMock(FlowClaimMapper::class); + $this->streams = $this->createMock(FlowStreamMapper::class); + $this->flows = $this->createMock(FlowLocator::class); + + $this->mapper->method('findQueued')->willReturn([]); + $this->mapper->method('findDue')->willReturn([]); + $this->mapper->method('findStale')->willReturn([]); + $this->mapper->method('update')->willReturnCallback(static fn (FlowRun $r): FlowRun => $r); + + $this->run = new FlowRun(); + $this->run->setUuid('run-1'); + $this->run->setFlowId('flow-1'); + $this->run->setStatus(FlowRun::STATUS_RUNNING); + $this->mapper->method('findByUuid')->willReturn($this->run); + + $this->branch = new FlowStream(); + $this->branch->setRunUuid('run-1'); + $this->branch->setStreamId('branch-2'); + $this->branch->setOrdinalPath('0001.0002'); + $this->branch->setStatus(FlowRun::STATUS_RUNNING); + + $this->sibling = new FlowStream(); + $this->sibling->setRunUuid('run-1'); + $this->sibling->setStreamId('branch-1'); + $this->sibling->setOrdinalPath('0001.0001'); + $this->sibling->setStatus(FlowRun::STATUS_SUSPENDED); + + $this->streams->method('findByRunAndStream')->willReturn($this->branch); + $this->streams->method('findByRun')->willReturnCallback(fn (): array => [$this->sibling, $this->branch]); + $this->streams->method('update')->willReturnCallback(static fn (FlowStream $s): FlowStream => $s); + }//end setUp() + + /** + * The worker under test. + * + * @param array $config App-config values. + * + * @return FlowRunWorker The worker. + */ + private function worker(array $config = []): FlowRunWorker { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback( + static fn (string $app, string $key, string $default = ''): string => ($config[$key] ?? $default) + ); + + return new FlowRunWorker( + $this->createMock(ITimeFactory::class), + $this->mapper, + $this->advancer, + $appConfig, + new NullLogger(), + null, + $this->claims, + $this->streams, + $this->flows + ); + }//end worker() + + /** + * One pass. + * + * @param FlowRunWorker $worker The worker. + * + * @return void + */ + private function pass(FlowRunWorker $worker): void { + $method = new \ReflectionMethod(FlowRunWorker::class, 'run'); + $method->invoke($worker, null); + }//end pass() + + /** + * A stale claim on the branch. + * + * @return FlowClaim The claim. + */ + private function staleClaim(): FlowClaim { + $claim = new FlowClaim(); + $claim->setRunUuid('run-1'); + $claim->setPlace('review'); + $claim->setOwner('dead-pass'); + $claim->setStreamId('branch-2'); + $claim->setTransition('review'); + $claim->setClaimedAt(new DateTime('-3 hours')); + + return $claim; + }//end staleClaim() + + public function testAStaleClaimIsReleasedItsBranchFailedAndNamedAndNeverReDispatched(): void { + $this->claims->method('findOlderThan')->willReturnCallback(function (DateTime $before): array { + $this->cutoffs[] = $before; + return [$this->staleClaim()]; + }); + $this->claims->expects($this->once())->method('releaseByOwner')->with('run-1', 'dead-pass')->willReturn(1); + // Reaped, never re-dispatched. + $this->advancer->expects($this->never())->method('advance'); + + $this->pass($this->worker(['flow_run_retention_days' => '0'])); + + $this->assertSame(FlowRun::STATUS_FAILED, $this->branch->getStatus()); + $this->assertStringContainsString('branch-2', (string)$this->branch->getError()); + $this->assertStringContainsString('review', (string)$this->branch->getError()); + $this->assertStringContainsString('NOT re-run', (string)$this->branch->getError()); + // Default policy (`stop`): the run fails with the branch. + $this->assertSame(FlowRun::STATUS_FAILED, $this->run->getStatus()); + }//end testAStaleClaimIsReleasedItsBranchFailedAndNamedAndNeverReDispatched() + + public function testAContinuePolicyKeepsTheSiblingsAndReArmsTheRun(): void { + $this->claims->method('findOlderThan')->willReturn([$this->staleClaim()]); + $this->claims->method('releaseByOwner')->willReturn(1); + $this->flows->method('resolveFlow')->willReturn( + [ + 'id' => 'flow-1', + 'nodes' => [['id' => 'review', 'type' => 'x', 'onError' => 'continue']], + 'edges' => [], + ] + ); + + $this->pass($this->worker(['flow_run_retention_days' => '0'])); + + $this->assertSame(FlowRun::STATUS_FAILED, $this->branch->getStatus()); + $this->assertSame(FlowRun::STATUS_QUEUED, $this->run->getStatus()); + }//end testAContinuePolicyKeepsTheSiblingsAndReArmsTheRun() + + public function testTheClaimReaperUsesTheRunReapersCutoff(): void { + // stale 15 min vs max runtime 60 + grace 5: the run reaper waits 65 + // minutes, and so must the claim reaper — one expression, not two. + $this->claims->method('findOlderThan')->willReturnCallback(function (DateTime $before): array { + $this->cutoffs[] = $before; + return []; + }); + + $before = new DateTime(); + $this->pass($this->worker(['flow_run_retention_days' => '0', 'flow_run_stale_minutes' => '15', 'flow_max_runtime_minutes' => '60'])); + + $this->assertCount(1, $this->cutoffs); + $minutes = (int)round(($before->getTimestamp() - $this->cutoffs[0]->getTimestamp()) / 60); + $this->assertSame(65, $minutes); + }//end testTheClaimReaperUsesTheRunReapersCutoff() + + public function testALiveLongRunningFiringIsNotReaped(): void { + // The mapper is asked for claims older than the cutoff only; a claim + // inside its granted runtime is simply not in the answer, and nothing + // is released. + $this->claims->method('findOlderThan')->willReturn([]); + $this->claims->expects($this->never())->method('releaseByOwner'); + $this->streams->expects($this->never())->method('update'); + + $this->pass($this->worker(['flow_run_retention_days' => '0'])); + $this->assertSame(FlowRun::STATUS_RUNNING, $this->run->getStatus()); + }//end testALiveLongRunningFiringIsNotReaped() + + public function testTheReaperIsOffWhenTheStaleWindowIsZero(): void { + $this->claims->expects($this->never())->method('findOlderThan'); + $this->pass($this->worker(['flow_run_retention_days' => '0', 'flow_run_stale_minutes' => '0'])); + }//end testTheReaperIsOffWhenTheStaleWindowIsZero() +}//end class diff --git a/tests/Unit/Db/FlowClaimMapperTest.php b/tests/Unit/Db/FlowClaimMapperTest.php new file mode 100644 index 0000000000..aea94590db --- /dev/null +++ b/tests/Unit/Db/FlowClaimMapperTest.php @@ -0,0 +1,175 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use DateTime; +use OCA\OpenRegister\Db\FlowClaim; +use OCA\OpenRegister\Db\FlowClaimMapper; +use OCP\DB\Exception as DbException; +use OCP\DB\IResult; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IFunctionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\QueryBuilder\IQueryFunction; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Claims mapper. + */ +class FlowClaimMapperTest extends TestCase { + + private IDBConnection&MockObject $db; + + private IQueryBuilder&MockObject $qb; + + private FlowClaimMapper $mapper; + + /** @var array fluent methods called on the builder */ + private array $calls = []; + + protected function setUp(): void { + parent::setUp(); + $this->db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + foreach (['insert', 'setValue', 'delete', 'update', 'set', 'select', 'from', 'where', 'andWhere', 'orderBy', 'addOrderBy', 'setMaxResults', 'values', 'forUpdate'] as $fluent) { + $this->qb->method($fluent)->willReturnCallback(function () use ($fluent): IQueryBuilder { + $this->calls[] = $fluent; + return $this->qb; + }); + } + + $this->qb->method('createNamedParameter')->willReturnCallback(static fn (mixed $v): string => ':p'); + $this->qb->method('createFunction')->willReturnCallback(static fn (string $f): string => $f); + $this->qb->method('getSQL')->willReturn('SELECT uuid FROM runs'); + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('eq'); + $expr->method('lt')->willReturn('lt'); + $expr->method('in')->willReturn('in'); + $this->qb->method('expr')->willReturn($expr); + $func = $this->createMock(IFunctionBuilder::class); + $func->method('count')->willReturn($this->createMock(IQueryFunction::class)); + $this->qb->method('func')->willReturn($func); + $this->db->method('getQueryBuilder')->willReturn($this->qb); + + $this->mapper = new FlowClaimMapper($this->db); + }//end setUp() + + /** + * A result cursor over rows. + * + * @param array> $rows The rows. + * @param mixed $one The scalar `fetchOne()` answers. + * + * @return IResult&MockObject The cursor. + */ + private function cursor(array $rows = [], mixed $one = null): IResult&MockObject { + $result = $this->createMock(IResult::class); + $queue = $rows; + $result->method('fetch')->willReturnCallback(static function () use (&$queue): mixed { + return array_shift($queue) ?? false; + }); + $result->method('fetchOne')->willReturn($one); + + return $result; + }//end cursor() + + /** + * A claim. + * + * @return FlowClaim The claim. + */ + private function claim(): FlowClaim { + $claim = new FlowClaim(); + $claim->setRunUuid('run-1'); + $claim->setPlace('a'); + $claim->setOwner('pass'); + $claim->setStreamId('s1'); + $claim->setTransition('T'); + $claim->setClaimedAt(new DateTime('2026-09-01 08:00:00')); + + return $claim; + }//end claim() + + public function testInsertOrRefuseLandsAClaim(): void { + $this->qb->method('executeStatement')->willReturn(1); + $this->qb->method('getLastInsertId')->willReturn(7); + + $this->assertTrue($this->mapper->insertOrRefuse(claim: $this->claim())); + }//end testInsertOrRefuseLandsAClaim() + + public function testInsertOrRefuseReturnsFalseOnlyOnAUniqueViolation(): void { + $violation = $this->createMock(DbException::class); + $violation->method('getReason')->willReturn(DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION); + $this->qb->method('executeStatement')->willThrowException($violation); + + $this->assertFalse($this->mapper->insertOrRefuse(claim: $this->claim())); + }//end testInsertOrRefuseReturnsFalseOnlyOnAUniqueViolation() + + public function testAnyOtherDatabaseFailurePropagates(): void { + $other = $this->createMock(DbException::class); + $other->method('getReason')->willReturn(DbException::REASON_CONNECTION_LOST); + $this->qb->method('executeStatement')->willThrowException($other); + + $this->expectException(DbException::class); + $this->mapper->insertOrRefuse(claim: $this->claim()); + }//end testAnyOtherDatabaseFailurePropagates() + + public function testReleaseOfNothingTouchesNothing(): void { + $this->db->expects($this->never())->method('getQueryBuilder'); + $this->assertSame(0, $this->mapper->release(runUuid: 'run-1', places: [])); + }//end testReleaseOfNothingTouchesNothing() + + public function testReleaseAndReleaseByOwnerDelete(): void { + $this->qb->method('executeStatement')->willReturn(2); + $this->assertSame(2, $this->mapper->release(runUuid: 'run-1', places: ['a', 'b'])); + $this->assertSame(2, $this->mapper->releaseByOwner(runUuid: 'run-1', owner: 'pass')); + $this->assertSame(2, $this->mapper->deleteByRun(runUuid: 'run-1')); + $this->assertSame(2, $this->mapper->deleteOrphans()); + $this->assertContains('delete', $this->calls); + }//end testReleaseAndReleaseByOwnerDelete() + + public function testCountsReadTheScalar(): void { + $this->qb->method('executeQuery')->willReturn($this->cursor(one: '3')); + $this->assertSame(3, $this->mapper->countHeldForRun(runUuid: 'run-1')); + $this->assertSame(3, $this->mapper->countHeldByOwner(owner: 'pass')); + }//end testCountsReadTheScalar() + + public function testFindByRunAndFindOlderThanMapRows(): void { + $row = ['id' => 1, 'run_uuid' => 'run-1', 'place' => 'a', 'owner' => 'pass', 'stream_id' => 's1', 'transition' => 'T', 'claimed_at' => '2026-09-01 08:00:00']; + $this->qb->method('executeQuery')->willReturnCallback(fn (): IResult => $this->cursor(rows: [$row])); + + $byRun = $this->mapper->findByRun(runUuid: 'run-1'); + $this->assertCount(1, $byRun); + $this->assertSame('a', $byRun[0]->getPlace()); + $this->assertSame('pass', $byRun[0]->getOwner()); + + $stale = $this->mapper->findOlderThan(before: new DateTime('-1 hour'), limit: 5); + $this->assertCount(1, $stale); + $this->assertSame('run-1', $stale[0]->getRunUuid()); + $this->assertContains('setMaxResults', $this->calls); + $this->assertSame('s1', $stale[0]->jsonSerialize()['streamId']); + }//end testFindByRunAndFindOlderThanMapRows() +}//end class diff --git a/tests/Unit/Db/FlowRunStepOrderingMapperTest.php b/tests/Unit/Db/FlowRunStepOrderingMapperTest.php new file mode 100644 index 0000000000..8a8df331a3 --- /dev/null +++ b/tests/Unit/Db/FlowRunStepOrderingMapperTest.php @@ -0,0 +1,115 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowRunStepMapper; +use OCP\DB\IResult; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Ordering and locking reads. + */ +class FlowRunStepOrderingMapperTest extends TestCase { + + private IDBConnection&MockObject $db; + + private IQueryBuilder&MockObject $qb; + + /** @var array fluent calls with their arguments */ + private array $calls = []; + + /** @var array> */ + private array $rows = []; + + protected function setUp(): void { + parent::setUp(); + $this->db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + foreach (['select', 'from', 'where', 'andWhere', 'orderBy', 'addOrderBy', 'forUpdate', 'setMaxResults'] as $fluent) { + $this->qb->method($fluent)->willReturnCallback(function (...$args) use ($fluent): IQueryBuilder { + $this->calls[] = [$fluent, $args]; + return $this->qb; + }); + } + + $this->qb->method('createNamedParameter')->willReturn(':p'); + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('eq'); + $this->qb->method('expr')->willReturn($expr); + $this->qb->method('executeQuery')->willReturnCallback(function (): IResult { + $result = $this->createMock(IResult::class); + $queue = $this->rows; + $result->method('fetch')->willReturnCallback(static function () use (&$queue): mixed { + return array_shift($queue) ?? false; + }); + return $result; + }); + $this->db->method('getQueryBuilder')->willReturn($this->qb); + }//end setUp() + + /** + * The ordering clauses issued, as `method:column`. + * + * @return array The clauses. + */ + private function ordering(): array { + $out = []; + foreach ($this->calls as [$method, $args]) { + if (in_array($method, ['orderBy', 'addOrderBy'], true) === true) { + $out[] = $method . ':' . (string)($args[0] ?? ''); + } + } + + return $out; + }//end ordering() + + public function testFindByRunIsCanonicalByOrdinalPathThenSequence(): void { + $this->rows = [['id' => 1, 'run_uuid' => 'r', 'flow_id' => 'f', 'node_id' => 'n', 'sequence' => 1, 'status' => 'completed', 'stream_id' => 's', 'ordinal_path' => '0001', 'created' => '2026-09-01 08:00:00']]; + $steps = (new FlowRunStepMapper($this->db))->findByRun(runUuid: 'r'); + + $this->assertSame(['orderBy:ordinal_path', 'addOrderBy:sequence', 'addOrderBy:id'], $this->ordering()); + $this->assertSame('0001', $steps[0]->getOrdinalPath()); + $this->assertSame('s', $steps[0]->jsonSerialize()['streamId']); + }//end testFindByRunIsCanonicalByOrdinalPathThenSequence() + + public function testTheTimestampReadIsExplicitAndSeparate(): void { + (new FlowRunStepMapper($this->db))->findByRunByTimestamp(runUuid: 'r'); + $this->assertSame(['orderBy:created', 'addOrderBy:id'], $this->ordering()); + }//end testTheTimestampReadIsExplicitAndSeparate() + + public function testLockByUuidReadsForUpdate(): void { + $this->rows = [['id' => 9, 'uuid' => 'r', 'flow_id' => 'f', 'status' => 'running', 'marking' => json_encode(['a' => 1]), 'firings' => 2, 'place_items' => json_encode(['a' => []])]]; + $run = (new FlowRunMapper($this->db))->lockByUuid(uuid: 'r'); + + $this->assertContains('forUpdate', array_column($this->calls, 0)); + $this->assertSame(2, (int)$run->getFirings()); + $this->assertSame(['a' => []], $run->getPlaceItems()); + $this->assertSame(2, $run->jsonSerialize()['firings']); + }//end testLockByUuidReadsForUpdate() +}//end class diff --git a/tests/Unit/Db/FlowStreamMapperTest.php b/tests/Unit/Db/FlowStreamMapperTest.php new file mode 100644 index 0000000000..b451bc36df --- /dev/null +++ b/tests/Unit/Db/FlowStreamMapperTest.php @@ -0,0 +1,141 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\FlowStreamMapper; +use OCP\DB\IResult; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Streams mapper. + */ +class FlowStreamMapperTest extends TestCase { + + private IDBConnection&MockObject $db; + + private IQueryBuilder&MockObject $qb; + + private FlowStreamMapper $mapper; + + /** @var array */ + private array $calls = []; + + /** @var array> rows the next query answers */ + private array $rows = []; + + protected function setUp(): void { + parent::setUp(); + $this->db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + foreach (['delete', 'update', 'set', 'select', 'from', 'where', 'andWhere', 'orderBy', 'addOrderBy', 'setMaxResults'] as $fluent) { + $this->qb->method($fluent)->willReturnCallback(function () use ($fluent): IQueryBuilder { + $this->calls[] = $fluent; + return $this->qb; + }); + } + + $this->qb->method('createNamedParameter')->willReturn(':p'); + $this->qb->method('createFunction')->willReturnCallback(static fn (string $f): string => $f); + $this->qb->method('getSQL')->willReturn('SELECT uuid FROM runs'); + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('eq'); + $this->qb->method('expr')->willReturn($expr); + $this->qb->method('executeQuery')->willReturnCallback(function (): IResult { + $result = $this->createMock(IResult::class); + $queue = $this->rows; + $result->method('fetch')->willReturnCallback(static function () use (&$queue): mixed { + return array_shift($queue) ?? false; + }); + return $result; + }); + $this->db->method('getQueryBuilder')->willReturn($this->qb); + + $this->mapper = new FlowStreamMapper($this->db); + }//end setUp() + + /** + * A stream row. + * + * @param int $next The row's next_sequence. + * + * @return array The row. + */ + private function row(int $next = 1): array { + return [ + 'id' => 1, + 'run_uuid' => 'run-1', + 'stream_id' => 's1', + 'ordinal_path' => '0001', + 'parent_stream_id' => null, + 'place' => 'p', + 'status' => 'running', + 'resume_at' => null, + 'next_sequence' => $next, + 'error' => null, + 'created' => '2026-09-01 08:00:00', + 'updated' => '2026-09-01 08:00:00', + ]; + }//end row() + + public function testFindByRunOrdersByOrdinalPath(): void { + $this->rows = [$this->row()]; + $found = $this->mapper->findByRun(runUuid: 'run-1'); + $this->assertCount(1, $found); + $this->assertSame('0001', $found[0]->getOrdinalPath()); + $this->assertSame('p', $found[0]->getPlace()); + $this->assertContains('orderBy', $this->calls); + $this->assertContains('addOrderBy', $this->calls); + }//end testFindByRunOrdersByOrdinalPath() + + public function testFindByRunAndStreamReturnsNullWhenAbsent(): void { + $this->rows = []; + $this->assertNull($this->mapper->findByRunAndStream(runUuid: 'run-1', streamId: 'nope')); + $this->rows = [$this->row()]; + $this->assertSame('s1', $this->mapper->findByRunAndStream(runUuid: 'run-1', streamId: 's1')?->getStreamId()); + }//end testFindByRunAndStreamReturnsNullWhenAbsent() + + public function testAllocateNextSequenceReturnsZeroWhenTheStreamRowIsMissing(): void { + $this->qb->method('executeStatement')->willReturn(0); + $this->assertSame(0, $this->mapper->allocateNextSequence(runUuid: 'run-1', streamId: 'nope')); + }//end testAllocateNextSequenceReturnsZeroWhenTheStreamRowIsMissing() + + public function testAllocateNextSequenceHandsOutTheValueBeforeTheIncrement(): void { + // The UPDATE bumps next_sequence to 5; the position reserved is 4. + $this->qb->method('executeStatement')->willReturn(1); + $this->rows = [$this->row(next: 5)]; + $this->assertSame(4, $this->mapper->allocateNextSequence(runUuid: 'run-1', streamId: 's1')); + $this->assertContains('update', $this->calls); + $this->assertContains('set', $this->calls); + }//end testAllocateNextSequenceHandsOutTheValueBeforeTheIncrement() + + public function testDeletesRunAndOrphans(): void { + $this->qb->method('executeStatement')->willReturn(3); + $this->assertSame(3, $this->mapper->deleteByRun(runUuid: 'run-1')); + $this->assertSame(3, $this->mapper->deleteOrphans()); + }//end testDeletesRunAndOrphans() +}//end class diff --git a/tests/Unit/Migration/Version1Date20260901120000Test.php b/tests/Unit/Migration/Version1Date20260901120000Test.php new file mode 100644 index 0000000000..7263df6a6a --- /dev/null +++ b/tests/Unit/Migration/Version1Date20260901120000Test.php @@ -0,0 +1,192 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Migration; + +use Doctrine\DBAL\Schema\Table; +use OCA\OpenRegister\Migration\Version1Date20260901120000; +use OCP\DB\IResult; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IFunctionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\QueryBuilder\IQueryFunction; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * The migration. + */ +class Version1Date20260901120000Test extends TestCase { + + private IDBConnection&MockObject $db; + + private IQueryBuilder&MockObject $qb; + + /** @var array> rows `fetchAll()` answers */ + private array $rows = []; + + /** @var array scalars `fetchOne()` answers, in call order */ + private array $scalars = []; + + private int $statements = 0; + + protected function setUp(): void { + parent::setUp(); + $this->db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + foreach (['insert', 'values', 'update', 'set', 'select', 'from', 'where', 'andWhere'] as $fluent) { + $this->qb->method($fluent)->willReturnSelf(); + } + + $this->qb->method('createNamedParameter')->willReturn(':p'); + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('eq'); + $expr->method('in')->willReturn('in'); + $expr->method('isNull')->willReturn('isNull'); + $this->qb->method('expr')->willReturn($expr); + $func = $this->createMock(IFunctionBuilder::class); + $func->method('count')->willReturn($this->createMock(IQueryFunction::class)); + $func->method('max')->willReturn($this->createMock(IQueryFunction::class)); + $this->qb->method('func')->willReturn($func); + $this->qb->method('executeStatement')->willReturnCallback(function (): int { + $this->statements++; + return 1; + }); + $this->qb->method('executeQuery')->willReturnCallback(function (): IResult { + $result = $this->createMock(IResult::class); + $result->method('fetchAll')->willReturn($this->rows); + $result->method('fetchOne')->willReturnCallback(fn (): mixed => array_shift($this->scalars)); + return $result; + }); + $this->db->method('getQueryBuilder')->willReturn($this->qb); + }//end setUp() + + /** + * A table mock that reports nothing present and records what gets added. + * + * @return Table&MockObject The table. + */ + private function emptyTable(): Table&MockObject { + $table = $this->createMock(Table::class); + $table->method('hasColumn')->willReturn(false); + $table->method('hasIndex')->willReturn(false); + + return $table; + }//end emptyTable() + + public function testChangeSchemaCreatesBothTablesAndAddsTheFourColumns(): void { + $claims = $this->emptyTable(); + $claims->expects($this->exactly(7))->method('addColumn'); + $claims->expects($this->once())->method('addUniqueIndex')->with(['run_uuid', 'place'], 'or_flowclaim_place_uq'); + $claims->expects($this->exactly(2))->method('addIndex'); + + $streams = $this->emptyTable(); + $streams->expects($this->exactly(12))->method('addColumn'); + $streams->expects($this->once())->method('addUniqueIndex')->with(['run_uuid', 'stream_id'], 'or_flowstream_id_uq'); + + $runs = $this->emptyTable(); + $runs->expects($this->exactly(2))->method('addColumn'); + $steps = $this->emptyTable(); + $steps->expects($this->exactly(2))->method('addColumn'); + $steps->expects($this->once())->method('addIndex')->with(['run_uuid', 'ordinal_path', 'sequence'], 'or_flowstep_ordinal_idx'); + + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->willReturnCallback( + static fn (string $name): bool => in_array($name, ['openregister_flow_runs', 'openregister_flow_steps'], true) + ); + $schema->method('createTable')->willReturnCallback( + static fn (string $name): Table => ($name === 'openregister_flow_claims') ? $claims : $streams + ); + $schema->method('getTable')->willReturnCallback( + static fn (string $name): Table => ($name === 'openregister_flow_runs') ? $runs : $steps + ); + + $output = $this->createMock(IOutput::class); + $migration = new Version1Date20260901120000($this->db); + $this->assertSame($schema, $migration->changeSchema($output, static fn (): ISchemaWrapper => $schema, [])); + }//end testChangeSchemaCreatesBothTablesAndAddsTheFourColumns() + + public function testChangeSchemaIsANoOpWhenEverythingExists(): void { + $present = $this->createMock(Table::class); + $present->method('hasColumn')->willReturn(true); + $present->method('hasIndex')->willReturn(true); + $present->expects($this->never())->method('addColumn'); + + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->willReturn(true); + $schema->expects($this->never())->method('createTable'); + $schema->method('getTable')->willReturn($present); + + $migration = new Version1Date20260901120000($this->db); + $this->assertNull($migration->changeSchema($this->createMock(IOutput::class), static fn (): ISchemaWrapper => $schema, [])); + }//end testChangeSchemaIsANoOpWhenEverythingExists() + + public function testBackFillSeedsOneStreamPerMarkedPlaceAndStampsSteps(): void { + // One in-flight run with two marked places and no streams yet; its + // highest step sequence is 4, so the streams continue from 5. + $this->rows = [['uuid' => 'run-1', 'status' => 'suspended', 'marking' => json_encode(['zeta' => 1, 'alpha' => 1]), 'resume_at' => '2026-09-02 08:00:00']]; + $this->scalars = ['0', '4']; + + $output = $this->createMock(IOutput::class); + $output->expects($this->once())->method('info')->with($this->stringContains('1 in-flight run(s) given streams')); + + $migration = new Version1Date20260901120000($this->db); + $migration->postSchemaChange($output, static fn (): ?ISchemaWrapper => null, []); + + // Two stream inserts, one per-run step stamp, one global stamp. + $this->assertSame(4, $this->statements); + }//end testBackFillSeedsOneStreamPerMarkedPlaceAndStampsSteps() + + public function testBackFillIsIdempotentAndSkipsQueuedRunsWithoutAMarking(): void { + $this->rows = [ + ['uuid' => 'run-seeded', 'status' => 'running', 'marking' => json_encode(['a' => 1]), 'resume_at' => null], + ['uuid' => 'run-queued', 'status' => 'queued', 'marking' => null, 'resume_at' => null], + ]; + // run-seeded already has streams (count 1); run-queued has none but no marking either. + $this->scalars = ['1', '0']; + + $output = $this->createMock(IOutput::class); + $output->expects($this->once())->method('info')->with($this->stringContains('0 in-flight run(s) given streams')); + + $migration = new Version1Date20260901120000($this->db); + $migration->postSchemaChange($output, static fn (): ?ISchemaWrapper => null, []); + + // Only the global stamp of unstamped steps ran; nothing was seeded twice. + $this->assertSame(1, $this->statements); + }//end testBackFillIsIdempotentAndSkipsQueuedRunsWithoutAMarking() + + public function testStreamIdsAreDeterministicPerRunAndOrdinal(): void { + $this->assertSame( + Version1Date20260901120000::streamIdFor(runUuid: 'run-1', ordinal: 1), + Version1Date20260901120000::streamIdFor(runUuid: 'run-1', ordinal: 1) + ); + $this->assertNotSame( + Version1Date20260901120000::streamIdFor(runUuid: 'run-1', ordinal: 1), + Version1Date20260901120000::streamIdFor(runUuid: 'run-1', ordinal: 2) + ); + $this->assertSame(32, strlen(Version1Date20260901120000::streamIdFor(runUuid: 'run-1', ordinal: 1))); + }//end testStreamIdsAreDeterministicPerRunAndOrdinal() +}//end class diff --git a/tests/Unit/Service/Flow/FlowEngineStreamWalkTest.php b/tests/Unit/Service/Flow/FlowEngineStreamWalkTest.php new file mode 100644 index 0000000000..aa0caedc6d --- /dev/null +++ b/tests/Unit/Service/Flow/FlowEngineStreamWalkTest.php @@ -0,0 +1,488 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowStream; +use OCA\OpenRegister\Service\Flow\FlowDefinitionBuilder; +use OCA\OpenRegister\Service\Flow\FlowEngine; +use OCA\OpenRegister\Service\Flow\FlowFiringResult; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowOversightRegistry; +use OCA\OpenRegister\Service\Flow\FlowRunMarkingStore; +use OCA\OpenRegister\Service\Flow\FlowStepDispatcher; +use OCA\OpenRegister\Service\Flow\FlowStreamWalk; +use OCA\OpenRegister\Service\Flow\FlowSuspension; +use OCA\OpenRegister\Service\Flow\IFlowOversightCheck; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use stdClass; + +/** + * A dispatcher that suspends on a named step and records the rest. + */ +class StreamWalkDispatcher implements FlowStepDispatcher { + /** @var array */ + public array $dispatched = []; + + public function __construct( + private readonly ?string $suspendOn = null, + private readonly ?string $failOn = null, + ) { + } + + public function dispatch(array $step, array $items, array $context): array { + $id = (string)($step['id'] ?? ''); + $this->dispatched[] = $id; + if ($id === $this->suspendOn) { + throw new FlowSuspension(resumeAt: null, reason: 'waiting on a person'); + } + + if ($id === $this->failOn) { + throw new \RuntimeException('boom'); + } + + return $items; + } +}//end class + +/** + * The walk. + */ +class FlowEngineStreamWalkTest extends TestCase { + + private FlowEngine $engine; + + private FlowRun $run; + + /** + * What the fake collaborator saw. + * + * @var array + */ + private array $seen = ['claims' => [], 'commits' => [], 'parked' => [], 'ended' => [], 'finalized' => null, 'released' => []]; + + /** + * Transition names whose claims the fake refuses. + * + * @var array + */ + private array $refuse = []; + + /** + * The firing count the fake reports before any commit. + * + * @var int + */ + private int $firings = 0; + + protected function setUp(): void { + parent::setUp(); + $this->engine = new FlowEngine(new FlowDefinitionBuilder(), new NullLogger()); + $this->run = new FlowRun(); + $this->run->setUuid('run-1'); + $this->run->setFirings(0); + }//end setUp() + + /** + * A split into two branches, one of which waits. + * + * @return array The flow. + */ + private function splitFlow(): array { + return [ + 'id' => 'split', + 'nodes' => [ + ['id' => 'start', 'type' => 'passthrough'], + ['id' => 'advice', 'type' => 'wait-for-person'], + ['id' => 'hearing', 'type' => 'passthrough'], + ['id' => 'hearing-done', 'type' => 'passthrough'], + ], + 'edges' => [ + ['id' => 'e1', 'from' => 'start', 'to' => 'advice'], + ['id' => 'e2', 'from' => 'start', 'to' => 'hearing'], + ['id' => 'e3', 'from' => 'hearing', 'to' => 'hearing-done'], + ], + ]; + }//end splitFlow() + + /** + * A fake stream collaborator that tracks streams in memory and records + * every protocol call. + * + * @return FlowStreamWalk&MockObject The fake. + */ + private function fakeWalk(): FlowStreamWalk&MockObject { + $walk = $this->createMock(FlowStreamWalk::class); + $streams = []; + $parked = []; + $exhausted = []; + $refused = []; + $cursor = 0; + + $walk->method('run')->willReturnCallback(fn (): FlowRun => $this->run); + $walk->method('begin')->willReturnCallback(function (array $marking) use (&$streams): void { + $this->seen['begun'] = $marking; + $i = 1; + foreach (array_keys($marking) as $place) { + $id = 's' . $i; + $streams[$id] = ['place' => (string)$place, 'path' => ($i === 1) ? '0001' : FlowStream::childPath('0001', $i)]; + $i++; + } + }); + $walk->method('nextStream')->willReturnCallback(function () use (&$streams, &$parked, &$exhausted, &$refused, &$cursor): ?string { + $ids = array_keys($streams); + $n = count($ids); + for ($k = 0; $k < $n; $k++) { + $id = $ids[(($cursor + $k) % $n)]; + if (isset($parked[$id]) === false && isset($exhausted[$id]) === false && isset($refused[$id]) === false) { + $cursor = ((($cursor + $k) + 1) % max(1, $n)); + $this->seen['visits'][] = $id; + return $id; + } + } + + return null; + }); + // A closure, not an arrow function: arrow functions capture by VALUE at + // creation, which would freeze the empty stream list forever. + $walk->method('placeOf')->willReturnCallback(function (string $id) use (&$streams): ?string { + return ($streams[$id]['place'] ?? null); + }); + $walk->method('exhaust')->willReturnCallback(function (string $id) use (&$exhausted): void { + $this->seen['exhausted'][] = $id; + $exhausted[$id] = true; + }); + $walk->method('claim')->willReturnCallback(function (string $id, string $transition, array $places) use (&$refused): ?array { + $this->seen['claims'][] = $transition; + if (in_array($transition, $this->refuse, true) === true) { + $refused[$id] = true; + return null; + } + + sort($places, SORT_STRING); + return $places; + }); + $walk->method('release')->willReturnCallback(function (array $places): void { + $this->seen['released'][] = $places; + }); + $walk->method('firings')->willReturnCallback(fn (): int => $this->firings); + $walk->method('workRemains')->willReturnCallback(function (array $transitions) use (&$streams, &$parked): bool { + $unparked = []; + foreach ($streams as $id => $stream) { + if (isset($parked[$id]) === false && $stream['place'] !== null) { + $unparked[$stream['place']] = true; + } + } + + foreach ($transitions as $transition) { + foreach ($transition->getFroms() as $from) { + if (isset($unparked[(string)$from]) === true) { + return true; + } + } + } + + return false; + }); + $walk->method('budgetSpent')->willReturn(false); + $walk->method('commitFiring')->willReturnCallback( + function (string $id, string $transition, array $froms, array $taken, array $placeItems, array $claimed, array $logEntry, bool $enabledAfter, string $streamStatus = 'running', ?string $streamError = null) use (&$streams, &$exhausted): FlowFiringResult { + $this->seen['commits'][] = ['stream' => $id, 'transition' => $transition, 'froms' => $froms, 'taken' => $taken, 'status' => $logEntry['status']]; + $this->firings++; + $this->run->setFirings($this->firings); + // Continue or split, as the real commit would. + $marking = FlowRunMarkingStoreTestAccess::marking($this->run); + if (count($taken) <= 1) { + $streams[$id]['place'] = ($taken[0] ?? null); + if ($taken === []) { + unset($streams[$id]); + } + } else { + $parentPath = $streams[$id]['path']; + unset($streams[$id]); + $k = 1; + foreach ($taken as $to) { + $streams[$id . '.' . $k] = ['place' => $to, 'path' => FlowStream::childPath($parentPath, $k)]; + $k++; + } + } + + $exhausted = []; + return new FlowFiringResult(marking: $marking, placeItems: $placeItems, streams: [], firings: $this->firings); + } + ); + $walk->method('park')->willReturnCallback(function (string $id, ?DateTime $resumeAt, string $reason, array $claimed, bool $enabled) use (&$parked): void { + $this->seen['parked'][] = $id; + $parked[$id] = true; + }); + $walk->method('endStream')->willReturnCallback(function (string $id, string $status, ?string $error) use (&$streams): void { + $this->seen['ended'][] = [$id, $status]; + unset($streams[$id]); + }); + $walk->method('finalize')->willReturnCallback(function (bool $enabled, ?string $forcedTerminal = null) use (&$parked, &$refused): string { + $this->seen['finalized'] = ['enabled' => $enabled, 'forced' => $forcedTerminal]; + if ($forcedTerminal !== null) { + return $forcedTerminal; + } + + if ($enabled === true) { + return FlowRun::STATUS_QUEUED; + } + + if ($parked !== []) { + return FlowRun::STATUS_SUSPENDED; + } + + return FlowRun::STATUS_COMPLETED; + }); + + return $walk; + }//end fakeWalk() + + /** + * Run a flow through the stream walk. + * + * @param array $flow The flow. + * @param FlowStepDispatcher $dispatcher The dispatcher. + * @param FlowStreamWalk $walk The collaborator. + * @param array $context The context. + * + * @return array The result. + */ + private function walk(array $flow, FlowStepDispatcher $dispatcher, FlowStreamWalk $walk, array $context = []): array { + return $this->engine->run( + flow: $flow, + store: new FlowRunMarkingStore(run: $this->run), + subject: new stdClass(), + dispatcher: $dispatcher, + context: $context, + items: [FlowItems::item(json: ['n' => 1])], + startAt: null, + streams: $walk + ); + }//end walk() + + public function testAHumanTaskOnOneBranchDoesNotStopItsSibling(): void { + $dispatcher = new StreamWalkDispatcher(suspendOn: 'advice'); + $result = $this->walk($this->splitFlow(), $dispatcher, $this->fakeWalk()); + + // The hearing branch advanced to its end while the advice branch parked. + $this->assertContains('hearing', $dispatcher->dispatched); + $this->assertContains('hearing-done', $dispatcher->dispatched); + $this->assertCount(1, $this->seen['parked']); + // The run is reported parked only once nothing else can fire, and the + // suspension entry names its stream. + $this->assertSame(FlowEngine::STATUS_SUSPENDED, $result['status']); + $suspended = array_values(array_filter($result['log'], static fn (array $e): bool => ($e['status'] ?? '') === 'suspended')); + $this->assertCount(1, $suspended); + $this->assertArrayHasKey('streamId', $suspended[0]); + // The firings the walk committed carry their stream and are marked as + // recorded, so the step history does not write them twice. + $committed = array_values(array_filter($result['log'], static fn (array $e): bool => ($e['recorded'] ?? false) === true)); + $this->assertCount(3, $committed); + }//end testAHumanTaskOnOneBranchDoesNotStopItsSibling() + + public function testARefusedClaimSkipsTheCandidateWithoutDispatchingAndLeavesTheRunQueued(): void { + $this->refuse = ['hearing']; + $dispatcher = new StreamWalkDispatcher(); + $result = $this->walk($this->splitFlow(), $dispatcher, $this->fakeWalk()); + + $this->assertNotContains('hearing', $dispatcher->dispatched); + $this->assertNotContains('hearing-done', $dispatcher->dispatched); + $this->assertContains('advice', $dispatcher->dispatched); + // The skipped firing stays enabled, so finalize sees enabled work and + // the run ends the pass queued — never completed with a stranded token. + $this->assertTrue($this->seen['finalized']['enabled']); + $this->assertSame(FlowEngine::STATUS_QUEUED, $result['status']); + }//end testARefusedClaimSkipsTheCandidateWithoutDispatchingAndLeavesTheRunQueued() + + public function testEveryFiringIsClaimedBeforeItIsDispatched(): void { + $dispatcher = new StreamWalkDispatcher(); + $this->walk($this->splitFlow(), $dispatcher, $this->fakeWalk()); + + // Same set: nothing was dispatched without a claim, and nothing was + // claimed and then not accounted for by a commit or a release. + sort($dispatcher->dispatched); + $claimed = $this->seen['claims']; + sort($claimed); + $this->assertSame($dispatcher->dispatched, $claimed); + $this->assertCount(count($dispatcher->dispatched), $this->seen['commits']); + }//end testEveryFiringIsClaimedBeforeItIsDispatched() + + public function testTheCeilingCountsTheRunAcrossPasses(): void { + // The persisted count already sits at the ceiling from earlier passes + // — a cycle that parked once per lap — so this pass fires nothing and + // fails the run with the existing message. + $this->firings = 1000; + $this->run->setFirings(1000); + $dispatcher = new StreamWalkDispatcher(); + $result = $this->walk($this->splitFlow(), $dispatcher, $this->fakeWalk()); + + $this->assertSame(FlowEngine::STATUS_FAILED, $result['status']); + $this->assertStringContainsString('1000 transitions', (string)$result['error']); + $this->assertSame([], $dispatcher->dispatched); + $this->assertSame(FlowRun::STATUS_FAILED, $this->seen['finalized']['forced']); + }//end testTheCeilingCountsTheRunAcrossPasses() + + public function testAnOversightRefusalEndsTheRunForEveryStream(): void { + $check = new class implements IFlowOversightCheck { + public int $asked = 0; + + public function getId(): string { + return 'test.refuse-second'; + } + + public function veto(array $context): ?string { + $this->asked++; + // Consent to the first hop (the split), refuse everything after. + return ($this->asked > 1) ? 'kill switch thrown' : null; + } + }; + $registry = new FlowOversightRegistry(logger: new NullLogger()); + $registry->register(check: $check); + $this->engine = new FlowEngine(new FlowDefinitionBuilder(), new NullLogger(), $registry); + + $dispatcher = new StreamWalkDispatcher(); + $result = $this->walk($this->splitFlow(), $dispatcher, $this->fakeWalk()); + + $this->assertSame(FlowEngine::STATUS_STOPPED, $result['status']); + // Only the split ran; neither branch began a firing after the refusal. + $this->assertSame(['start'], $dispatcher->dispatched); + $this->assertSame(FlowRun::STATUS_STOPPED, $this->seen['finalized']['forced']); + // The refusing check is recorded, and the refused hop's claim released. + $stopped = array_values(array_filter($result['log'], static fn (array $e): bool => ($e['status'] ?? '') === 'stopped')); + $this->assertSame('test.refuse-second', $stopped[0]['checkId']); + $this->assertCount(1, $this->seen['released']); + }//end testAnOversightRefusalEndsTheRunForEveryStream() + + public function testATerminalStepFailureEndsTheStreamAndTheRun(): void { + $dispatcher = new StreamWalkDispatcher(failOn: 'hearing'); + $result = $this->walk($this->splitFlow(), $dispatcher, $this->fakeWalk()); + + $this->assertSame(FlowEngine::STATUS_STOPPED, $result['status']); + $this->assertCount(1, $this->seen['ended']); + $this->assertSame(FlowRun::STATUS_STOPPED, $this->seen['ended'][0][1]); + }//end testATerminalStepFailureEndsTheStreamAndTheRun() + + public function testASingleStreamFlowWalksExactlyAsTheLegacyWalk(): void { + $flow = [ + 'id' => 'linear', + 'nodes' => [ + ['id' => 'one', 'type' => 'passthrough'], + ['id' => 'two', 'type' => 'passthrough'], + ['id' => 'three', 'type' => 'passthrough'], + ], + 'edges' => [ + ['id' => 'e1', 'from' => 'one', 'to' => 'two'], + ['id' => 'e2', 'from' => 'two', 'to' => 'three'], + ], + ]; + + $legacyRun = new FlowRun(); + $legacyDispatcher = new StreamWalkDispatcher(); + $legacy = $this->engine->run( + flow: $flow, + store: new FlowRunMarkingStore(run: $legacyRun), + subject: new stdClass(), + dispatcher: $legacyDispatcher, + items: [FlowItems::item(json: ['n' => 1])] + ); + + $streamDispatcher = new StreamWalkDispatcher(); + $streamed = $this->walk($flow, $streamDispatcher, $this->fakeWalk()); + + $this->assertSame($legacyDispatcher->dispatched, $streamDispatcher->dispatched); + $this->assertSame($legacy['status'], $streamed['status']); + $this->assertSame($legacyRun->getMarking(), $this->run->getMarking()); + $this->assertSame( + array_column($legacy['log'], 'transition'), + array_column($streamed['log'], 'transition') + ); + $this->assertSame(array_column($legacy['log'], 'status'), array_column($streamed['log'], 'status')); + $this->assertArrayNotHasKey('resumeAt', $streamed); + }//end testASingleStreamFlowWalksExactlyAsTheLegacyWalk() + + public function testPerPlaceItemsSeedFromTheStoredColumnWhenPresent(): void { + // A resumed run whose branches produced different items: each branch + // resumes with ITS items, not the flat list. + $flow = [ + 'id' => 'resume', + 'nodes' => [ + ['id' => 'a', 'type' => 'passthrough'], + ['id' => 'b', 'type' => 'passthrough'], + ], + 'edges' => [], + ]; + $this->run->setMarking(['a' => 1, 'b' => 1]); + $this->run->setPlaceItems(['a' => [FlowItems::item(json: ['branch' => 'A'])], 'b' => [FlowItems::item(json: ['branch' => 'B'])]]); + + $seen = []; + $dispatcher = new class($seen) implements FlowStepDispatcher { + public array $seen = []; + + public function __construct(array $seen) { + $this->seen = $seen; + } + + public function dispatch(array $step, array $items, array $context): array { + $this->seen[(string)$step['id']] = array_column(array_column($items, 'json'), 'branch'); + return $items; + } + }; + + $this->walk($flow, $dispatcher, $this->fakeWalk()); + + $this->assertSame(['A'], $dispatcher->seen['a']); + $this->assertSame(['B'], $dispatcher->seen['b']); + }//end testPerPlaceItemsSeedFromTheStoredColumnWhenPresent() +}//end class + +/** + * Reads the run's marking the way the store normalises it. + */ +class FlowRunMarkingStoreTestAccess { + public static function marking(FlowRun $run): array { + $out = []; + foreach ((array)($run->getMarking() ?? []) as $k => $v) { + if (is_int($k) === true) { + $out[(string)$v] = 1; + continue; + } + + $out[(string)$k] = max(1, (int)$v); + } + + return $out; + } +}//end class diff --git a/tests/Unit/Service/Flow/FlowPlaceClaimsTest.php b/tests/Unit/Service/Flow/FlowPlaceClaimsTest.php new file mode 100644 index 0000000000..de56f0d9a4 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowPlaceClaimsTest.php @@ -0,0 +1,169 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-firing-must-exclusively-claim-every-place-it-touches + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use LogicException; +use OCA\OpenRegister\BackgroundJob\FlowRunWorker; +use OCA\OpenRegister\Db\FlowClaim; +use OCA\OpenRegister\Db\FlowClaimMapper; +use OCA\OpenRegister\Service\Flow\FlowConcurrency; +use OCA\OpenRegister\Service\Flow\FlowPlaceClaims; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Claims. + */ +class FlowPlaceClaimsTest extends TestCase { + + /** @var array keyed by run|place */ + private array $index = []; + + /** @var array every insert attempted, in order */ + private array $attempts = []; + + private bool $inTransaction = false; + + private FlowPlaceClaims $claims; + + protected function setUp(): void { + parent::setUp(); + $mapper = $this->createMock(FlowClaimMapper::class); + $mapper->method('insertOrRefuse')->willReturnCallback(function (FlowClaim $claim): bool { + $key = $claim->getRunUuid() . '|' . $claim->getPlace(); + $this->attempts[] = (string)$claim->getPlace(); + if (isset($this->index[$key]) === true) { + return false; + } + + $this->index[$key] = $claim; + return true; + }); + $mapper->method('release')->willReturnCallback(function (string $runUuid, array $places): int { + $n = 0; + foreach ($places as $place) { + if (isset($this->index[$runUuid . '|' . $place]) === true) { + unset($this->index[$runUuid . '|' . $place]); + $n++; + } + } + + return $n; + }); + $mapper->method('countHeldForRun')->willReturnCallback(function (string $runUuid): int { + return count(array_filter($this->index, static fn (FlowClaim $c): bool => $c->getRunUuid() === $runUuid)); + }); + $mapper->method('countHeldByOwner')->willReturnCallback(function (string $owner): int { + return count(array_filter($this->index, static fn (FlowClaim $c): bool => $c->getOwner() === $owner)); + }); + + $db = $this->createMock(IDBConnection::class); + $db->method('inTransaction')->willReturnCallback(fn (): bool => $this->inTransaction); + + $this->claims = new FlowPlaceClaims(claims: $mapper, db: $db, logger: new NullLogger()); + }//end setUp() + + public function testPlacesAreClaimedInOneFixedBytewiseOrder(): void { + $taken = $this->claims->acquire(runUuid: 'r', streamId: 's', transition: 'T', places: ['c', 'a', 'b'], owner: 'A'); + $this->assertSame(['a', 'b', 'c'], $taken); + $this->assertSame(['a', 'b', 'c'], $this->attempts); + }//end testPlacesAreClaimedInOneFixedBytewiseOrder() + + public function testTwoWorkersOnTheSamePlaceExactlyOneWins(): void { + $first = $this->claims->acquire(runUuid: 'r', streamId: 's', transition: 'T1', places: ['a', 'c'], owner: 'A'); + $second = $this->claims->acquire(runUuid: 'r', streamId: 's', transition: 'T1', places: ['a', 'c'], owner: 'B'); + + $this->assertSame(['a', 'c'], $first); + $this->assertNull($second); + // The loser holds nothing: no partial claim survives a refusal. + $this->assertSame(['A', 'A'], array_map(static fn (FlowClaim $c): string => (string)$c->getOwner(), array_values($this->index))); + }//end testTwoWorkersOnTheSamePlaceExactlyOneWins() + + public function testDisjointBranchesBothProceed(): void { + $this->assertSame(['a', 'c'], $this->claims->acquire(runUuid: 'r', streamId: 's1', transition: 'T1', places: ['a', 'c'], owner: 'A')); + $this->assertSame(['b', 'd'], $this->claims->acquire(runUuid: 'r', streamId: 's2', transition: 'T2', places: ['b', 'd'], owner: 'B')); + }//end testDisjointBranchesBothProceed() + + public function testARefusalReleasesWhatTheAttemptTookAndTheFiringStaysClaimable(): void { + // B holds c. A wants {a, c}: takes a, is refused c, releases a. + $this->claims->acquire(runUuid: 'r', streamId: 's2', transition: 'T2', places: ['c'], owner: 'B'); + $this->assertNull($this->claims->acquire(runUuid: 'r', streamId: 's1', transition: 'T1', places: ['a', 'c'], owner: 'A')); + $this->assertArrayNotHasKey('r|a', $this->index); + + // Once B releases c, A's next attempt succeeds — the firing was skipped, never lost. + $this->claims->release(runUuid: 'r', places: ['c']); + $this->assertSame(['a', 'c'], $this->claims->acquire(runUuid: 'r', streamId: 's1', transition: 'T1', places: ['a', 'c'], owner: 'A')); + }//end testARefusalReleasesWhatTheAttemptTookAndTheFiringStaysClaimable() + + public function testATwelveTokenMarkingHoldsAtMostFiveClaimsPerRun(): void { + $held = 0; + for ($i = 0; $i < 12; $i++) { + $taken = $this->claims->acquire(runUuid: 'r', streamId: 's' . $i, transition: 'T' . $i, places: ['p' . $i], owner: 'W'); + if ($taken !== null) { + $held++; + } + } + + $this->assertSame(FlowConcurrency::DEFAULT_LIMIT, $held); + $this->assertSame(5, $held); + }//end testATwelveTokenMarkingHoldsAtMostFiveClaimsPerRun() + + public function testTheCapReadsFlowConcurrencysNumbersAndClampsAboveTheCeiling(): void { + $this->assertSame(FlowConcurrency::DEFAULT_LIMIT, FlowPlaceClaims::streamCap(configured: null)); + $this->assertSame(FlowConcurrency::MAX_LIMIT, FlowPlaceClaims::streamCap(configured: 500)); + $this->assertSame(1, FlowPlaceClaims::streamCap(configured: 0)); + $this->assertSame(1, FlowPlaceClaims::streamCap(configured: -3)); + $this->assertSame(FlowRunWorker::BATCH * FlowConcurrency::DEFAULT_LIMIT, FlowPlaceClaims::passCap()); + }//end testTheCapReadsFlowConcurrencysNumbersAndClampsAboveTheCeiling() + + public function testAPassIsBoundedAcrossRuns(): void { + // One pass holding passCap() claims across many runs is refused the next. + $cap = FlowPlaceClaims::passCap(); + for ($i = 0; $i < $cap; $i++) { + $this->assertNotNull($this->claims->acquire(runUuid: 'run' . $i, streamId: 's', transition: 'T', places: ['p'], owner: 'pass')); + } + + $this->assertNull($this->claims->acquire(runUuid: 'run-extra', streamId: 's', transition: 'T', places: ['p'], owner: 'pass')); + // Another pass is unaffected. + $this->assertNotNull($this->claims->acquire(runUuid: 'run-extra', streamId: 's', transition: 'T', places: ['p'], owner: 'other-pass')); + }//end testAPassIsBoundedAcrossRuns() + + public function testAcquiringInsideATransactionIsRefusedLoudly(): void { + $this->inTransaction = true; + $this->expectException(LogicException::class); + $this->claims->acquire(runUuid: 'r', streamId: 's', transition: 'T', places: ['a'], owner: 'A'); + }//end testAcquiringInsideATransactionIsRefusedLoudly() + + public function testOwnerTokensAreUniquePerPass(): void { + $this->assertNotSame(FlowPlaceClaims::newOwner(), FlowPlaceClaims::newOwner()); + $this->assertLessThanOrEqual(128, strlen(FlowPlaceClaims::newOwner())); + }//end testOwnerTokensAreUniquePerPass() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunCommitTest.php b/tests/Unit/Service/Flow/FlowRunCommitTest.php new file mode 100644 index 0000000000..eb336bf606 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowRunCommitTest.php @@ -0,0 +1,398 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\FlowClaim; +use OCA\OpenRegister\Db\FlowClaimMapper; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowRunStep; +use OCA\OpenRegister\Db\FlowRunStepMapper; +use OCA\OpenRegister\Db\FlowStream; +use OCA\OpenRegister\Db\FlowStreamMapper; +use OCA\OpenRegister\Service\Flow\FlowFiring; +use OCA\OpenRegister\Service\Flow\FlowRunCommit; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * The commit path over an in-memory row. + */ +class FlowRunCommitTest extends TestCase { + + /** + * The "database": one run row, its streams, its claims, its steps. + */ + private FlowRun $row; + + /** @var array */ + private array $streams = []; + + /** @var array */ + private array $claims = []; + + /** @var array */ + private array $steps = []; + + private int $transactions = 0; + + private FlowRunCommit $commit; + + protected function setUp(): void { + parent::setUp(); + $this->row = new FlowRun(); + $this->row->setUuid('run-1'); + $this->row->setFlowId('flow-1'); + $this->row->setStatus(FlowRun::STATUS_RUNNING); + $this->row->setMarking(['a' => 1, 'b' => 1]); + $this->row->setFirings(0); + + $db = $this->createMock(IDBConnection::class); + $db->method('beginTransaction')->willReturnCallback(function (): void { + $this->transactions++; + }); + + $runs = $this->createMock(FlowRunMapper::class); + // The LOCKED read: always the row as it stands NOW, never a copy taken earlier. + $runs->method('lockByUuid')->willReturnCallback(fn (): FlowRun => $this->row); + $runs->method('update')->willReturnCallback(fn (FlowRun $run): FlowRun => $run); + + $streams = $this->createMock(FlowStreamMapper::class); + $streams->method('findByRun')->willReturnCallback(function (): array { + $list = array_values($this->streams); + usort($list, static fn (FlowStream $a, FlowStream $b): int => strcmp((string)$a->getOrdinalPath(), (string)$b->getOrdinalPath())); + return $list; + }); + $streams->method('findByRunAndStream')->willReturnCallback(fn (string $runUuid, string $streamId): ?FlowStream => ($this->streams[$streamId] ?? null)); + $streams->method('insert')->willReturnCallback(function (FlowStream $stream): FlowStream { + $this->streams[(string)$stream->getStreamId()] = $stream; + return $stream; + }); + $streams->method('update')->willReturnCallback(function (FlowStream $stream): FlowStream { + $this->streams[(string)$stream->getStreamId()] = $stream; + return $stream; + }); + $streams->method('allocateNextSequence')->willReturnCallback(function (string $runUuid, string $streamId): int { + $stream = ($this->streams[$streamId] ?? null); + if ($stream === null) { + return 0; + } + + $next = (int)$stream->getNextSequence(); + $stream->setNextSequence($next + 1); + return $next; + }); + + $claims = $this->createMock(FlowClaimMapper::class); + $claims->method('findByRun')->willReturnCallback(fn (): array => array_values($this->claims)); + $claims->method('release')->willReturnCallback(function (string $runUuid, array $places): int { + $before = count($this->claims); + $this->claims = array_values(array_filter($this->claims, static fn (FlowClaim $c): bool => in_array($c->getPlace(), $places, true) === false)); + return ($before - count($this->claims)); + }); + $claims->method('releaseByOwner')->willReturnCallback(function (string $runUuid, string $owner): int { + $before = count($this->claims); + $this->claims = array_values(array_filter($this->claims, static fn (FlowClaim $c): bool => $c->getOwner() !== $owner)); + return ($before - count($this->claims)); + }); + + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(0); + $steps->method('insert')->willReturnCallback(function (FlowRunStep $step): FlowRunStep { + $this->steps[] = $step; + return $step; + }); + + $this->commit = new FlowRunCommit( + db: $db, + runs: $runs, + streams: $streams, + claims: $claims, + steps: $steps, + logger: new NullLogger() + ); + }//end setUp() + + /** + * Seed a stream row. + * + * @param string $id The stream id. + * @param string $path Its ordinal path. + * @param string $place Its place. + * @param string $status Its status. + * @param DateTime|null $resumeAt Its wake time. + * + * @return void + */ + private function stream(string $id, string $path, string $place, string $status = FlowRun::STATUS_RUNNING, ?DateTime $resumeAt = null): void { + $stream = new FlowStream(); + $stream->setRunUuid('run-1'); + $stream->setStreamId($id); + $stream->setOrdinalPath($path); + $stream->setPlace($place); + $stream->setStatus($status); + $stream->setResumeAt($resumeAt); + $stream->setNextSequence(1); + $this->streams[$id] = $stream; + }//end stream() + + /** + * Hold a claim. + * + * @param string $place The place. + * @param string $owner The holder. + * + * @return void + */ + private function claim(string $place, string $owner): void { + $claim = new FlowClaim(); + $claim->setRunUuid('run-1'); + $claim->setPlace($place); + $claim->setOwner($owner); + $this->claims[] = $claim; + }//end claim() + + /** + * A firing on a stream. + * + * @param string $stream The stream id. + * @param string $transition The transition. + * @param array $froms Consumed places. + * @param array $taken Produced places. + * @param bool $enabledAfter Whether anything is enabled afterwards. + * + * @return FlowFiring The firing. + */ + private function firing(string $stream, string $transition, array $froms, array $taken, bool $enabledAfter = false): FlowFiring { + $items = []; + foreach ($taken as $to) { + $items[$to] = [['json' => ['via' => $transition]]]; + } + + return new FlowFiring( + streamId: $stream, + transition: $transition, + froms: $froms, + taken: $taken, + itemsByPlace: $items, + claimedPlaces: array_merge($froms, $taken), + consumedStreamIds: [], + logEntry: ['transition' => $transition, 'type' => 'x', 'status' => 'completed', 'itemsIn' => 1, 'itemsOut' => 1], + enabledAfter: $enabledAfter + ); + }//end firing() + + public function testTwoInterleavedCommitsKeepBothEffects(): void { + // design.md Decision 3, t=1..24: A and B both read {a:1, b:1} before + // either writes. B commits T2 (-b +d) first; A commits T1 (-a +c) from + // a locked RE-READ that already contains d. + $this->stream('sA', '0001.0001', 'a'); + $this->stream('sB', '0001.0002', 'b'); + $this->claim('a', 'A'); + $this->claim('c', 'A'); + $this->claim('b', 'B'); + $this->claim('d', 'B'); + + $runA = clone $this->row; + $runB = clone $this->row; + + $resultB = $this->commit->commitFiring(run: $runB, firing: $this->firing('sB', 'T2', ['b'], ['d']), owner: 'B'); + $this->assertEqualsCanonicalizing(['a' => 1, 'd' => 1], $resultB->marking); + + $resultA = $this->commit->commitFiring(run: $runA, firing: $this->firing('sA', 'T1', ['a'], ['c']), owner: 'A'); + + // Both consumed, both successors marked, nothing neither produced. + $this->assertEqualsCanonicalizing(['c' => 1, 'd' => 1], $resultA->marking); + $this->assertEqualsCanonicalizing(['c' => 1, 'd' => 1], $this->row->getMarking()); + // Marking and items committed together: c's items are readable in the same state. + $this->assertSame([['json' => ['via' => 'T1']]], $this->row->getPlaceItems()['c']); + $this->assertSame([['json' => ['via' => 'T2']]], $this->row->getPlaceItems()['d']); + // One firing per commit, counted on the run. + $this->assertSame(2, (int)$this->row->getFirings()); + // The caller's entity was refreshed and its change tracking reset, so a + // later whole-entity update cannot carry a stale marking. + $this->assertEqualsCanonicalizing(['c' => 1, 'd' => 1], $runA->getMarking()); + $this->assertSame([], $runA->getUpdatedFields()); + // Each stream got exactly one step row at its own position 1. + $this->assertCount(2, $this->steps); + $this->assertSame([1, 1], array_map(static fn (FlowRunStep $s): int => (int)$s->getSequence(), $this->steps)); + $this->assertSame(['0001.0002', '0001.0001'], array_map(static fn (FlowRunStep $s): string => (string)$s->getOrdinalPath(), $this->steps)); + // Claims released inside the commits; two transactions, one per firing. + $this->assertSame([], $this->claims); + $this->assertSame(2, $this->transactions); + }//end testTwoInterleavedCommitsKeepBothEffects() + + public function testASplitMintsChildrenInDeclarationOrderAndAJoinFoldsBack(): void { + $this->row->setMarking(['s' => 1]); + $this->stream('root', '0001', 's'); + + $this->commit->commitFiring(run: $this->row, firing: $this->firing('root', 'split', ['s'], ['x', 'y'], true), owner: 'W'); + + $paths = array_map(static fn (FlowStream $s): string => (string)$s->getOrdinalPath(), array_values($this->streams)); + sort($paths); + $this->assertSame(['0001', '0001.0001', '0001.0002'], $paths); + $this->assertSame(FlowRun::STATUS_COMPLETED, $this->streams['root']->getStatus()); + + $childX = FlowRunCommit::streamIdFor(runUuid: 'run-1', path: '0001.0001'); + $childY = FlowRunCommit::streamIdFor(runUuid: 'run-1', path: '0001.0002'); + $this->assertSame('x', $this->streams[$childX]->getPlace()); + $this->assertSame('y', $this->streams[$childY]->getPlace()); + + // Both branches arrive at the join's two input places, then the join + // fires ONCE consuming both, and folds onto the common prefix — the + // root — resuming ITS sequence. + $this->commit->commitFiring(run: $this->row, firing: $this->firing($childX, 'ex', ['x'], ['j#e1'], false), owner: 'W'); + $this->commit->commitFiring(run: $this->row, firing: $this->firing($childY, 'ey', ['y'], ['j#e2'], true), owner: 'W'); + $this->assertEqualsCanonicalizing(['j#e1' => 1, 'j#e2' => 1], $this->row->getMarking()); + + $join = new FlowFiring( + streamId: $childX, + transition: 'j', + froms: ['j#e1', 'j#e2'], + taken: ['after'], + itemsByPlace: ['after' => []], + claimedPlaces: ['j#e1', 'j#e2', 'after'], + consumedStreamIds: [$childY], + logEntry: ['transition' => 'j', 'status' => 'completed'], + enabledAfter: false + ); + $this->commit->commitFiring(run: $this->row, firing: $join, owner: 'W'); + + $this->assertSame(['after' => 1], $this->row->getMarking()); + $this->assertSame(FlowRun::STATUS_RUNNING, $this->streams['root']->getStatus()); + $this->assertSame('after', $this->streams['root']->getPlace()); + $this->assertSame(FlowRun::STATUS_COMPLETED, $this->streams[$childX]->getStatus()); + $this->assertSame(FlowRun::STATUS_COMPLETED, $this->streams[$childY]->getStatus()); + // The join's step row sits on the root's path at the root's next position (2, after the split). + $last = $this->steps[count($this->steps) - 1]; + $this->assertSame('0001', $last->getOrdinalPath()); + $this->assertSame(2, (int)$last->getSequence()); + $this->assertSame(4, (int)$this->row->getFirings()); + }//end testASplitMintsChildrenInDeclarationOrderAndAJoinFoldsBack() + + public function testStatusIsRunningWhileAnotherPassHoldsAClaim(): void { + $this->stream('s1', '0001', 'a'); + $this->claim('a', 'other-pass'); + + $status = $this->commit->finalize(run: $this->row, owner: 'me', enabled: false); + + $this->assertSame(FlowRun::STATUS_RUNNING, $status); + }//end testStatusIsRunningWhileAnotherPassHoldsAClaim() + + public function testAnEnabledUnclaimedFiringLeavesTheRunQueuedNeverCompleted(): void { + // The lost-wake-up guard: a join enabled by the last commit of a pass + // is fired by the next pass, and the run is never reported completed + // in between. + $this->stream('s1', '0001', 'j#e1'); + $status = $this->commit->finalize(run: $this->row, owner: 'me', enabled: true); + $this->assertSame(FlowRun::STATUS_QUEUED, $status); + }//end testAnEnabledUnclaimedFiringLeavesTheRunQueuedNeverCompleted() + + public function testAllParkedReadsSuspendedWithTheEarliestNonNullWake(): void { + // One stream waits on a signal (null), one on a timer: a plain MIN + // would hide the due timer behind the null. + $timer = new DateTime('+10 minutes'); + $this->stream('s1', '0001.0001', 'w', FlowRun::STATUS_SUSPENDED, null); + $this->stream('s2', '0001.0002', 't', FlowRun::STATUS_SUSPENDED, $timer); + + $status = $this->commit->finalize(run: $this->row, owner: 'me', enabled: false); + + $this->assertSame(FlowRun::STATUS_SUSPENDED, $status); + $this->assertEquals($timer, $this->row->getResumeAt()); + }//end testAllParkedReadsSuspendedWithTheEarliestNonNullWake() + + public function testOnlySignalWaitersLeavesResumeAtNull(): void { + $this->stream('s1', '0001', 'w', FlowRun::STATUS_SUSPENDED, null); + $this->commit->finalize(run: $this->row, owner: 'me', enabled: false); + $this->assertSame(FlowRun::STATUS_SUSPENDED, $this->row->getStatus()); + $this->assertNull($this->row->getResumeAt()); + }//end testOnlySignalWaitersLeavesResumeAtNull() + + public function testOneWaitingAndOneWorkingReadsAsRunningViaItsClaim(): void { + $this->stream('s1', '0001.0001', 'w', FlowRun::STATUS_SUSPENDED, null); + $this->stream('s2', '0001.0002', 'x', FlowRun::STATUS_RUNNING); + $this->claim('x', 'sibling-pass'); + + $status = $this->commit->finalize(run: $this->row, owner: 'me', enabled: false); + + $this->assertSame(FlowRun::STATUS_RUNNING, $status); + // The per-branch detail still says which branch waits. + $this->assertSame(FlowRun::STATUS_SUSPENDED, $this->streams['s1']->getStatus()); + }//end testOneWaitingAndOneWorkingReadsAsRunningViaItsClaim() + + public function testTerminalProjectionTakesTheMostSevereStream(): void { + $this->stream('s1', '0001.0001', 'e', FlowRun::STATUS_COMPLETED); + $this->stream('s2', '0001.0002', 'f', FlowRun::STATUS_STOPPED); + $this->stream('s3', '0001.0003', 'g', FlowRun::STATUS_FAILED); + + $this->assertSame(FlowRun::STATUS_FAILED, $this->commit->finalize(run: $this->row, owner: 'me', enabled: false)); + + $this->streams['s3']->setStatus(FlowRun::STATUS_COMPLETED); + $this->assertSame(FlowRun::STATUS_STOPPED, $this->commit->finalize(run: $this->row, owner: 'me', enabled: false)); + + $this->streams['s2']->setStatus(FlowRun::STATUS_COMPLETED); + $this->assertSame(FlowRun::STATUS_COMPLETED, $this->commit->finalize(run: $this->row, owner: 'me', enabled: false)); + }//end testTerminalProjectionTakesTheMostSevereStream() + + public function testARunLevelStopEndsEveryStream(): void { + $this->stream('s1', '0001.0001', 'a'); + $this->stream('s2', '0001.0002', 'b', FlowRun::STATUS_SUSPENDED); + $this->claim('a', 'me'); + + $status = $this->commit->finalize(run: $this->row, owner: 'me', enabled: true, forcedTerminal: FlowRun::STATUS_STOPPED); + + $this->assertSame(FlowRun::STATUS_STOPPED, $status); + $this->assertSame(FlowRun::STATUS_STOPPED, $this->streams['s1']->getStatus()); + $this->assertSame(FlowRun::STATUS_STOPPED, $this->streams['s2']->getStatus()); + // This pass's own claims were released inside the same lock. + $this->assertSame([], $this->claims); + }//end testARunLevelStopEndsEveryStream() + + public function testFinalizeReleasesOwnClaimsBeforeDerivingSoNoRunIsLeftRunningWithNone(): void { + $this->stream('s1', '0001', 'a', FlowRun::STATUS_COMPLETED); + $this->claim('a', 'me'); + + $status = $this->commit->finalize(run: $this->row, owner: 'me', enabled: false); + + $this->assertSame(FlowRun::STATUS_COMPLETED, $status); + $this->assertSame([], $this->claims); + }//end testFinalizeReleasesOwnClaimsBeforeDerivingSoNoRunIsLeftRunningWithNone() + + public function testAStatusValueOutsideTheSevenIsNeverProduced(): void { + $allowed = [FlowRun::STATUS_QUEUED, FlowRun::STATUS_RUNNING, FlowRun::STATUS_SUSPENDED, FlowRun::STATUS_COMPLETED, FlowRun::STATUS_STOPPED, FlowRun::STATUS_DEAD_LETTER, FlowRun::STATUS_FAILED]; + $this->stream('s1', '0001.0001', 'a', FlowRun::STATUS_DEAD_LETTER); + $this->stream('s2', '0001.0002', 'b', FlowRun::STATUS_SUSPENDED, new DateTime('+1 hour')); + $this->assertContains($this->commit->finalize(run: $this->row, owner: 'me', enabled: false), $allowed); + $this->streams['s2']->setStatus(FlowRun::STATUS_COMPLETED); + $this->assertContains($this->commit->finalize(run: $this->row, owner: 'me', enabled: false), $allowed); + }//end testAStatusValueOutsideTheSevenIsNeverProduced() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunMarkingStoreDeltaTest.php b/tests/Unit/Service/Flow/FlowRunMarkingStoreDeltaTest.php new file mode 100644 index 0000000000..9470578aab --- /dev/null +++ b/tests/Unit/Service/Flow/FlowRunMarkingStoreDeltaTest.php @@ -0,0 +1,88 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-marking-must-be-written-as-a-delta-never-as-a-whole-overwrite + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Service\Flow\FlowRunMarkingStore; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Marking; +use stdClass; + +/** + * Delta writes. + */ +class FlowRunMarkingStoreDeltaTest extends TestCase { + + public function testASiblingsCommittedEffectSurvivesThisProcessesNextWrite(): void { + // This process read {a, b} at the top of its pass. A sibling pass then + // committed T2 (-b +d) and the commit path synced that read back; the + // next in-process apply of T1 (-a +c) must be a DELTA on the synced + // view, so d survives and b is not resurrected. + $run = new FlowRun(); + $run->setMarking(['a' => 1, 'b' => 1]); + $store = new FlowRunMarkingStore(run: $run); + $subject = new stdClass(); + + $store->syncCommitted(marking: ['a' => 1, 'd' => 1]); + + // Symfony's apply() re-reads the marking immediately before writing. + $marking = $store->getMarking(subject: $subject); + $marking->unmark('a'); + $marking->mark('c'); + $store->setMarking(subject: $subject, marking: $marking); + + $this->assertSame(['consumed' => ['a'], 'produced' => ['c']], $store->lastDelta()); + $this->assertEqualsCanonicalizing(['c' => 1, 'd' => 1], $run->getMarking()); + }//end testASiblingsCommittedEffectSurvivesThisProcessesNextWrite() + + public function testASingleStreamWriteIsIdenticalToBefore(): void { + $run = new FlowRun(); + $run->setMarking(['start' => 1]); + $store = new FlowRunMarkingStore(run: $run); + $subject = new stdClass(); + + $marking = $store->getMarking(subject: $subject); + $marking->unmark('start'); + $marking->mark('next'); + $store->setMarking(subject: $subject, marking: $marking); + + $this->assertSame(['next' => 1], $run->getMarking()); + }//end testASingleStreamWriteIsIdenticalToBefore() + + public function testSyncCommittedReplacesTheViewWithTheLockedRead(): void { + $run = new FlowRun(); + $run->setMarking(['a' => 1]); + $store = new FlowRunMarkingStore(run: $run); + $store->syncCommitted(marking: ['x' => 2, 'y' => 1]); + + $this->assertSame(['x' => 2, 'y' => 1], $run->getMarking()); + }//end testSyncCommittedReplacesTheViewWithTheLockedRead() + + public function testTheWholeValueWriteNoLongerExists(): void { + // The acceptance criterion, asserted rather than argued: no line of the + // store assigns `$marking->getPlaces()` onto the run wholesale. + $source = (string)file_get_contents((new \ReflectionClass(FlowRunMarkingStore::class))->getFileName()); + $this->assertStringNotContainsString('setMarking($marking->getPlaces())', $source); + }//end testTheWholeValueWriteNoLongerExists() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunServiceAdvanceStreamTest.php b/tests/Unit/Service/Flow/FlowRunServiceAdvanceStreamTest.php new file mode 100644 index 0000000000..9e72d3c3d3 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowRunServiceAdvanceStreamTest.php @@ -0,0 +1,147 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-completions-advance-budget-must-apply-to-the-completing-branch + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowStateMapper; +use OCA\OpenRegister\Db\FlowStreamMapper; +use OCA\OpenRegister\Service\Flow\FlowEngine; +use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; +use OCA\OpenRegister\Service\Flow\FlowPlaceClaims; +use OCA\OpenRegister\Service\Flow\FlowRunCommit; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\FlowStreamWalk; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use stdClass; + +/** + * The advance budget. + */ +class FlowRunServiceAdvanceStreamTest extends TestCase { + + private FlowRunMapper&MockObject $mapper; + + private FlowEngine&MockObject $engine; + + protected function setUp(): void { + parent::setUp(); + $this->mapper = $this->createMock(FlowRunMapper::class); + $this->mapper->method('update')->willReturnArgument(0); + $this->engine = $this->createMock(FlowEngine::class); + }//end setUp() + + /** + * The service, with or without the stream layer. + * + * @param bool $withStreams Whether to wire the three stream collaborators. + * + * @return FlowRunService The service. + */ + private function service(bool $withStreams): FlowRunService { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willThrowException(new \RuntimeException('not available')); + $registry = $this->createMock(FlowNodeRegistry::class); + + if ($withStreams === false) { + return new FlowRunService( + $this->mapper, + $this->createMock(FlowStateMapper::class), + $this->engine, + $registry, + $this->createMock(LoggerInterface::class), + $container + ); + } + + return new FlowRunService( + $this->mapper, + $this->createMock(FlowStateMapper::class), + $this->engine, + $registry, + $this->createMock(LoggerInterface::class), + $container, + null, + null, + $this->createMock(FlowStreamMapper::class), + $this->createMock(FlowPlaceClaims::class), + $this->createMock(FlowRunCommit::class) + ); + }//end service() + + /** + * A suspended run. + * + * @return FlowRun The run. + */ + private function aRun(): FlowRun { + $run = new FlowRun(); + $run->setUuid('run-1'); + $run->setFlowId('flow-1'); + $run->setStatus(FlowRun::STATUS_SUSPENDED); + $run->setMarking(['task' => 1, 'other' => 1]); + $run->setItems([]); + + return $run; + }//end aRun() + + public function testABudgetOfZeroLeavesTheRunToTheQueue(): void { + $this->engine->expects($this->never())->method('run'); + + $run = $this->service(withStreams: true)->advanceStream(run: $this->aRun(), flow: ['id' => 'flow-1'], subject: new stdClass(), streamId: 's1', budget: 0); + + $this->assertSame(FlowRun::STATUS_QUEUED, $run->getStatus()); + }//end testABudgetOfZeroLeavesTheRunToTheQueue() + + public function testWithoutTheStreamLayerThereIsNoBranchToScopeToSoTheQueueAdvances(): void { + $this->engine->expects($this->never())->method('run'); + + $run = $this->service(withStreams: false)->advanceStream(run: $this->aRun(), flow: ['id' => 'flow-1'], subject: new stdClass(), streamId: 's1', budget: 'all'); + + $this->assertSame(FlowRun::STATUS_QUEUED, $run->getStatus()); + }//end testWithoutTheStreamLayerThereIsNoBranchToScopeToSoTheQueueAdvances() + + public function testABudgetWalksOnlyTheCompletingStreamThroughTheEngine(): void { + $seen = null; + $this->engine->expects($this->once())->method('run')->willReturnCallback( + function () use (&$seen): array { + $args = func_get_args(); + // `streams` is the last argument of run(); PHPUnit hands named + // arguments through positionally. + $seen = end($args); + return ['status' => FlowEngine::STATUS_QUEUED, 'log' => [], 'context' => [], 'items' => []]; + } + ); + + $run = $this->service(withStreams: true)->advanceStream(run: $this->aRun(), flow: ['id' => 'flow-1', 'limits' => ['streams' => 3]], subject: new stdClass(), streamId: 's1', budget: 2); + + $this->assertInstanceOf(FlowStreamWalk::class, $seen); + // The walk is scoped: two firings at most, one stream only. + $this->assertFalse($seen->budgetSpent()); + $this->assertSame(FlowRun::STATUS_QUEUED, $run->getStatus()); + }//end testABudgetWalksOnlyTheCompletingStreamThroughTheEngine() +}//end class diff --git a/tests/Unit/Service/Flow/FlowStepHistoryTest.php b/tests/Unit/Service/Flow/FlowStepHistoryTest.php index 3729514ffb..6a1d7a90ce 100644 --- a/tests/Unit/Service/Flow/FlowStepHistoryTest.php +++ b/tests/Unit/Service/Flow/FlowStepHistoryTest.php @@ -219,4 +219,33 @@ function (FlowRunStep $step) use (&$errors) { $this->assertSame(['it threw', 'author stopped it'], $errors); }//end testAStopReasonIsRecordedAsTheStepsError() + + public function testAFiringTheCommitAlreadyRecordedIsNotWrittenTwice(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(0); + $written = []; + $steps->method('insert')->willReturnCallback(function (FlowRunStep $step) use (&$written): FlowRunStep { + $written[] = $step; + return $step; + }); + + (new FlowStepHistory(steps: $steps))->record( + run: $this->aRun(), + entries: [ + ['transition' => 'fired', 'status' => 'completed', 'recorded' => true, 'streamId' => 's1', 'ordinalPath' => '0001.0001'], + ['transition' => 'waited', 'status' => 'suspended', 'streamId' => 's2', 'ordinalPath' => '0001.0002'], + ['transition' => 'legacy', 'status' => 'completed'], + ] + ); + + // The committed firing is skipped; the suspension carries its branch; + // a pre-stream entry sits on the root path, the single implicit stream. + $this->assertCount(2, $written); + $this->assertSame('waited', $written[0]->getNodeId()); + $this->assertSame('s2', $written[0]->getStreamId()); + $this->assertSame('0001.0002', $written[0]->getOrdinalPath()); + $this->assertSame('legacy', $written[1]->getNodeId()); + $this->assertNull($written[1]->getStreamId()); + $this->assertSame('0001', $written[1]->getOrdinalPath()); + }//end testAFiringTheCommitAlreadyRecordedIsNotWrittenTwice() }//end class diff --git a/tests/Unit/Service/Flow/FlowStreamTest.php b/tests/Unit/Service/Flow/FlowStreamTest.php new file mode 100644 index 0000000000..9ead220543 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowStreamTest.php @@ -0,0 +1,100 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-the-run-log-must-be-ordered-by-branch-never-by-completion + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use LengthException; +use OCA\OpenRegister\Db\FlowStream; +use PHPUnit\Framework\TestCase; + +/** + * Ordinal paths. + */ +class FlowStreamTest extends TestCase { + + public function testChildPathsFollowDeclarationOrderAndSortAsATree(): void { + $root = FlowStream::ROOT_PATH; + $first = FlowStream::childPath(parentPath: $root, index: 1); + $second = FlowStream::childPath(parentPath: $root, index: 2); + $grandchild = FlowStream::childPath(parentPath: $first, index: 1); + + $this->assertSame('0001.0001', $first); + $this->assertSame('0001.0002', $second); + $this->assertSame('0001.0001.0001', $grandchild); + + // Lexicographic order IS tree order: a parent's own steps come before + // its children's, and siblings sort by the author's declaration index. + $paths = [$second, $grandchild, $root, $first]; + sort($paths, SORT_STRING); + $this->assertSame([$root, $first, $grandchild, $second], $paths); + }//end testChildPathsFollowDeclarationOrderAndSortAsATree() + + public function testTwoRunsWithOppositeTimingProduceIdenticalCanonicalOrder(): void { + // Run 1: branch two finishes first; run 2: branch one finishes first. + // The canonical key is (ordinal path, position within the stream) and + // carries no trace of which branch returned first. + $run1 = [ + ['0001.0002', 1, 'hearing'], + ['0001.0001', 1, 'advice'], + ['0001', 1, 'split'], + ]; + $run2 = [ + ['0001', 1, 'split'], + ['0001.0001', 1, 'advice'], + ['0001.0002', 1, 'hearing'], + ]; + $canonical = static function (array $rows): array { + usort($rows, static fn (array $a, array $b): int => [$a[0], $a[1]] <=> [$b[0], $b[1]]); + return $rows; + }; + + $this->assertSame($canonical($run1), $canonical($run2)); + $this->assertSame(['split', 'advice', 'hearing'], array_column($canonical($run1), 2)); + }//end testTwoRunsWithOppositeTimingProduceIdenticalCanonicalOrder() + + public function testAJoinFoldsBackToTheLongestCommonPrefix(): void { + $this->assertSame('0002', FlowStream::commonPrefix(paths: ['0002.0001', '0002.0002'])); + $this->assertSame('0001.0003', FlowStream::commonPrefix(paths: ['0001.0003.0001', '0001.0003.0002.0001'])); + // Branches from different splits share the root, deterministically. + $this->assertSame('0001', FlowStream::commonPrefix(paths: ['0001.0001', '0002.0001'])); + $this->assertSame(FlowStream::ROOT_PATH, FlowStream::commonPrefix(paths: [])); + }//end testAJoinFoldsBackToTheLongestCommonPrefix() + + public function testAPathThatWouldExceedTheColumnIsRefusedNotTruncated(): void { + $path = FlowStream::ROOT_PATH; + while (strlen($path) + 5 <= FlowStream::MAX_PATH_LENGTH) { + $path = FlowStream::childPath(parentPath: $path, index: 1); + } + + $this->expectException(LengthException::class); + FlowStream::childPath(parentPath: $path, index: 1); + }//end testAPathThatWouldExceedTheColumnIsRefusedNotTruncated() + + public function testStreamStatusReusesTheRunVocabulary(): void { + $stream = new FlowStream(); + $stream->setStatus('failed'); + $this->assertTrue($stream->isTerminal()); + $stream->setStatus('suspended'); + $this->assertFalse($stream->isTerminal()); + }//end testStreamStatusReusesTheRunVocabulary() +}//end class diff --git a/tests/Unit/Service/Flow/FlowStreamWalkTest.php b/tests/Unit/Service/Flow/FlowStreamWalkTest.php new file mode 100644 index 0000000000..0690256591 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowStreamWalkTest.php @@ -0,0 +1,283 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-independent-branches-of-one-run-must-advance-independently + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowStream; +use OCA\OpenRegister\Db\FlowStreamMapper; +use OCA\OpenRegister\Service\Flow\FlowFiring; +use OCA\OpenRegister\Service\Flow\FlowFiringResult; +use OCA\OpenRegister\Service\Flow\FlowPlaceClaims; +use OCA\OpenRegister\Service\Flow\FlowRunCommit; +use OCA\OpenRegister\Service\Flow\FlowStreamWalk; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Transition; + +/** + * The walk's bookkeeping. + */ +class FlowStreamWalkTest extends TestCase { + + private FlowRun $run; + + private FlowPlaceClaims&MockObject $claims; + + private FlowRunCommit&MockObject $commit; + + private FlowStreamMapper&MockObject $streams; + + /** @var array */ + private array $rows = []; + + protected function setUp(): void { + parent::setUp(); + $this->run = new FlowRun(); + $this->run->setUuid('run-1'); + $this->run->setFirings(3); + $this->claims = $this->createMock(FlowPlaceClaims::class); + $this->commit = $this->createMock(FlowRunCommit::class); + $this->streams = $this->createMock(FlowStreamMapper::class); + $this->streams->method('findByRun')->willReturnCallback(fn (): array => $this->rows); + }//end setUp() + + /** + * A stream row. + * + * @param string $id The id. + * @param string $path The ordinal path. + * @param string|null $place The place. + * @param string $status The status. + * + * @return FlowStream The row. + */ + private function row(string $id, string $path, ?string $place, string $status = FlowRun::STATUS_RUNNING): FlowStream { + $row = new FlowStream(); + $row->setRunUuid('run-1'); + $row->setStreamId($id); + $row->setOrdinalPath($path); + $row->setPlace($place); + $row->setStatus($status); + + return $row; + }//end row() + + /** + * The walk under test. + * + * @param string|null $only Restrict to one stream. + * @param int|null $budget A firing budget. + * + * @return FlowStreamWalk The walk. + */ + private function walk(?string $only = null, ?int $budget = null): FlowStreamWalk { + return new FlowStreamWalk( + run: $this->run, + claims: $this->claims, + commit: $this->commit, + streamMapper: $this->streams, + owner: 'pass-1', + runCap: null, + onlyStream: $only, + budget: $budget + ); + }//end walk() + + public function testAFreshRunMintsARootForItsSingleTokenInMemory(): void { + $walk = $this->walk(); + $walk->begin(marking: ['start' => 1]); + + $root = FlowRunCommit::streamIdFor(runUuid: 'run-1', path: FlowStream::ROOT_PATH); + $this->assertSame($root, $walk->nextStream()); + $this->assertSame('start', $walk->placeOf(id: $root)); + $this->assertSame(FlowStream::ROOT_PATH, $walk->pathOf(id: $root)); + $this->assertSame($this->run, $walk->run()); + $this->assertSame(3, $walk->firings()); + }//end testAFreshRunMintsARootForItsSingleTokenInMemory() + + public function testUnassignedTokensBecomeRootChildrenInSortedPlaceOrder(): void { + // A pre-stream run resumed with two tokens and no rows: the back-fill's + // rule, so the two agree. + $walk = $this->walk(); + $walk->begin(marking: ['zeta' => 1, 'alpha' => 1]); + + $root = FlowRunCommit::streamIdFor(runUuid: 'run-1', path: '0001'); + $child = FlowRunCommit::streamIdFor(runUuid: 'run-1', path: '0001.0002'); + $this->assertSame('alpha', $walk->placeOf(id: $root)); + $this->assertSame('zeta', $walk->placeOf(id: $child)); + $this->assertSame('0001.0002', $walk->pathOf(id: $child)); + }//end testUnassignedTokensBecomeRootChildrenInSortedPlaceOrder() + + public function testPersistedStreamsKeepTheirPlacesAndTerminalOnesAreIgnored(): void { + $this->rows = [ + $this->row('a', '0001.0001', 'p1'), + $this->row('b', '0001.0002', 'p2', FlowRun::STATUS_SUSPENDED), + $this->row('done', '0001.0003', 'p3', FlowRun::STATUS_COMPLETED), + ]; + $walk = $this->walk(); + $walk->begin(marking: ['p1' => 1, 'p2' => 1]); + + // Round-robin in ordinal order; the suspended stream is eligible again + // (the run was woken), the completed one is not a stream any more. + $this->assertSame('a', $walk->nextStream()); + $this->assertSame('b', $walk->nextStream()); + $this->assertSame('a', $walk->nextStream()); + $this->assertNull($walk->placeOf(id: 'done')); + $this->assertSame(['b'], $walk->streamsOn(places: ['p2', 'p9'], except: 'a')); + }//end testPersistedStreamsKeepTheirPlacesAndTerminalOnesAreIgnored() + + public function testExhaustedParkedAndRefusedStreamsLeaveTheRotation(): void { + $this->rows = [$this->row('a', '0001.0001', 'p1'), $this->row('b', '0001.0002', 'p2'), $this->row('c', '0001.0003', 'p3')]; + $walk = $this->walk(); + $walk->begin(marking: ['p1' => 1, 'p2' => 1, 'p3' => 1]); + + $walk->exhaust(id: 'a'); + $this->claims->method('acquire')->willReturn(null); + $this->assertNull($walk->claim(id: 'b', transition: 'T', places: ['p2', 'q'])); + $this->assertTrue($walk->anyRefused()); + $this->commit->method('park')->willReturn([]); + $walk->park(id: 'c', resumeAt: new DateTime('+1 hour'), reason: 'timer', claimed: ['p3'], enabled: false); + $this->assertTrue($walk->anyParked()); + + $this->assertNull($walk->nextStream()); + }//end testExhaustedParkedAndRefusedStreamsLeaveTheRotation() + + public function testOnlyStreamAndBudgetScopeAnInRequestAdvance(): void { + $this->rows = [$this->row('a', '0001.0001', 'p1'), $this->row('b', '0001.0002', 'p2')]; + $walk = $this->walk(only: 'b', budget: 1); + $walk->begin(marking: ['p1' => 1, 'p2' => 1]); + + $this->assertSame('b', $walk->nextStream()); + $this->assertSame('b', $walk->nextStream()); + $this->assertFalse($walk->budgetSpent()); + + $this->commit->method('commitFiring')->willReturnCallback( + fn (FlowRun $run, FlowFiring $firing, string $owner): FlowFiringResult => new FlowFiringResult( + marking: ['p1' => 1, 'q' => 1], + placeItems: [], + streams: [$this->row('a', '0001.0001', 'p1'), $this->row('b', '0001.0002', 'q')], + firings: 4 + ) + ); + $walk->commitFiring(id: 'b', transition: 'T', froms: ['p2'], taken: ['q'], placeItems: ['q' => []], claimed: ['p2', 'q'], logEntry: ['status' => 'completed'], enabledAfter: false); + + $this->assertTrue($walk->budgetSpent()); + $this->assertSame('q', $walk->placeOf(id: 'b')); + }//end testOnlyStreamAndBudgetScopeAnInRequestAdvance() + + public function testAClaimIsTakenOnFromsUnionTosWithThePassOwner(): void { + $this->rows = [$this->row('a', '0001', 'p1')]; + $walk = $this->walk(); + $walk->begin(marking: ['p1' => 1]); + + $this->claims->expects($this->once())->method('acquire') + ->with('run-1', 'a', 'T', ['p1', 'q'], 'pass-1', null) + ->willReturn(['p1', 'q']); + $this->assertSame(['p1', 'q'], $walk->claim(id: 'a', transition: 'T', places: ['p1', 'q'])); + $this->assertFalse($walk->anyRefused()); + + $this->claims->expects($this->once())->method('release')->with('run-1', ['p1', 'q']); + $walk->release(places: ['p1', 'q']); + }//end testAClaimIsTakenOnFromsUnionTosWithThePassOwner() + + public function testACommitHandsTheFiringsDescriptorAndReReadsTheStreamPicture(): void { + $this->rows = [$this->row('a', '0001', 's')]; + $walk = $this->walk(); + $walk->begin(marking: ['s' => 1]); + + $captured = null; + $this->commit->method('commitFiring')->willReturnCallback( + function (FlowRun $run, FlowFiring $firing, string $owner) use (&$captured): FlowFiringResult { + $captured = $firing; + return new FlowFiringResult( + marking: ['x' => 1, 'y' => 1], + placeItems: ['x' => [], 'y' => []], + streams: [ + $this->row('a', '0001', null, FlowRun::STATUS_COMPLETED), + $this->row('a1', '0001.0001', 'x', FlowRun::STATUS_QUEUED), + $this->row('a2', '0001.0002', 'y', FlowRun::STATUS_QUEUED), + ], + firings: 4 + ); + } + ); + + $walk->exhaust(id: 'a'); + $result = $walk->commitFiring( + id: 'a', + transition: 'split', + froms: ['s'], + taken: ['x', 'y'], + placeItems: ['x' => [['json' => 1]], 'y' => [['json' => 2]]], + claimed: ['s', 'x', 'y'], + logEntry: ['transition' => 'split', 'status' => 'completed'], + enabledAfter: true + ); + + $this->assertSame('pass-1', 'pass-1'); + $this->assertInstanceOf(FlowFiring::class, $captured); + $this->assertSame('0001', $captured->streamPath); + $this->assertSame(['s'], $captured->froms); + $this->assertSame(['x', 'y'], $captured->taken); + $this->assertSame([['json' => 1]], $captured->itemsByPlace['x']); + $this->assertSame(4, $result->firings); + // The picture is the commit's: the carrier is gone, the children are live + // in ordinal order, and a commit clears the exhausted set. + $this->assertSame('a1', $walk->nextStream()); + $this->assertSame('a2', $walk->nextStream()); + $this->assertSame('x', $walk->placeOf(id: 'a1')); + }//end testACommitHandsTheFiringsDescriptorAndReReadsTheStreamPicture() + + public function testWorkRemainsIgnoresTransitionsOnlyParkedStreamsKeepEnabled(): void { + $this->rows = [$this->row('a', '0001.0001', 'wait'), $this->row('b', '0001.0002', 'work')]; + $walk = $this->walk(); + $walk->begin(marking: ['wait' => 1, 'work' => 1]); + $this->commit->method('park')->willReturn([]); + + $onWait = new Transition('wait', ['wait'], ['after-wait']); + $onWork = new Transition('work', ['work'], ['after-work']); + + $this->assertTrue($walk->workRemains(transitions: [$onWait, $onWork])); + $walk->park(id: 'a', resumeAt: null, reason: 'signal', claimed: ['wait', 'after-wait'], enabled: true); + $this->assertTrue($walk->workRemains(transitions: [$onWait, $onWork])); + $this->assertFalse($walk->workRemains(transitions: [$onWait])); + }//end testWorkRemainsIgnoresTransitionsOnlyParkedStreamsKeepEnabled() + + public function testEndStreamAndFinalizeDelegateToTheCommitPath(): void { + $this->rows = [$this->row('a', '0001', 'p')]; + $walk = $this->walk(); + $walk->begin(marking: ['p' => 1]); + + $this->commit->expects($this->once())->method('endStream')->willReturn([]); + $walk->endStream(id: 'a', status: FlowRun::STATUS_FAILED, error: 'boom', claimed: ['p'], enabled: false); + $this->assertNull($walk->nextStream()); + + $this->commit->expects($this->once())->method('finalize') + ->with($this->run, 'pass-1', false, FlowRun::STATUS_FAILED) + ->willReturn(FlowRun::STATUS_FAILED); + $this->assertSame(FlowRun::STATUS_FAILED, $walk->finalize(enabled: false, forcedTerminal: FlowRun::STATUS_FAILED)); + }//end testEndStreamAndFinalizeDelegateToTheCommitPath() +}//end class From 63da91d4f3098e4c4e6f6ccca330d4f9abc3c5a5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 14:18:34 +0200 Subject: [PATCH 28/68] fix(controllers): two more inverted call sites, and a guard that can see them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3266 fixed 27 call sites and added a static guard. Both were narrower than the defect: the regex pinned the receiver to `$this->objectService`, so it could not see `ObjectsController::contracts()` and `::used()`, which call the same two setters on an INJECTED `$objectService` parameter and with named arguments. A guard written from the instances you already found will only ever find the instances you already found. This one now matches any receiver, and requires the SAME receiver on both calls — which is what separates the defect from the entity setters that share the method names (`$objectEntity->setSchema()`, `$auditTrail->setSchema()`), and which a receiver-blind regex would have flagged as false positives. Both endpoints back the related-objects surface, so this is the same user-visible failure as before: `/contracts` and `/used` returned 500 on every object while `/uses` beside them returned 200. Verified the widened guard fails on the two sites before fixing them, then passes after. `/contracts` and `/used` return 200 on the dev instance where they returned 500. --- lib/Controller/ObjectsController.php | 14 ++++++++++---- .../Controller/RegisterBeforeSchemaOrderTest.php | 13 ++++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index 39eee258a2..7e82eeeffa 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -3538,9 +3538,12 @@ public function canDelete( * @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md */ public function contracts(string $id, string $register, string $schema, ObjectService $objectService): JSONResponse { - // Set the schema and register to the object service. - $objectService->setSchema(schema: $schema); + // REGISTER FIRST. `setSchema()` scopes its slug lookup to whatever + // register is currently set, and ObjectService is reused across many + // operations in one process, so naming the schema first resolves it + // against a register an unrelated call left behind. $objectService->setRegister(register: $register); + $objectService->setSchema(schema: $schema); // Get request parameters for filtering. $requestParams = $this->request->getParams(); @@ -3753,9 +3756,12 @@ public function uses(string $id, string $register, string $schema, ObjectService * @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md */ public function used(string $id, string $register, string $schema, ObjectService $objectService): JSONResponse { - // Set the schema and register to the object service. - $objectService->setSchema(schema: $schema); + // REGISTER FIRST. `setSchema()` scopes its slug lookup to whatever + // register is currently set, and ObjectService is reused across many + // operations in one process, so naming the schema first resolves it + // against a register an unrelated call left behind. $objectService->setRegister(register: $register); + $objectService->setSchema(schema: $schema); // Build search query from request parameters. $queryParams = $this->request->getParams(); diff --git a/tests/Unit/Controller/RegisterBeforeSchemaOrderTest.php b/tests/Unit/Controller/RegisterBeforeSchemaOrderTest.php index 2444c6f26b..a9a21afe70 100644 --- a/tests/Unit/Controller/RegisterBeforeSchemaOrderTest.php +++ b/tests/Unit/Controller/RegisterBeforeSchemaOrderTest.php @@ -75,8 +75,19 @@ public function testRegisterIsNamedBeforeSchema(string $file): void { $source = file_get_contents($file); $this->assertIsString($source, 'controller source is readable'); + // Matches ANY receiver, not just `$this->objectService`. The first pass + // of this guard pinned the receiver and so missed + // `ObjectsController::contracts()` and `::used()`, which call the same + // two setters on an INJECTED `$objectService` parameter and with named + // arguments. A guard narrower than the defect finds only the instances + // you already fixed. + // + // Entity setters (`$objectEntity->setSchema()`, `$auditTrail->…`) share + // the method names and are NOT this defect, so the receiver must repeat + // between the two calls: the bug is one object being configured in the + // wrong order, not two unrelated writes. $inverted = preg_match_all( - '/setSchema\(\s*[^)]*\s*\)\s*;\s*\n\s*\$this->objectService->setRegister\(/', + '/(\$[A-Za-z_>\-\[\]\'\"\w]*?)->setSchema\((?:schema:\s*)?[^)]*\)\s*;\s*\n\s*\1->setRegister\(/', $source ); From 90a37fe4446abdb128233b06921b5ff2fcb7c016 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 14:48:09 +0200 Subject: [PATCH 29/68] feat(flow): the openregister.user-task node puts a person into the graph (#3269) * feat(tasks): the task store - four tables, entities and mappers The flow-task-entity storage layer (openspec/changes/flow-task-entity): openregister_tasks with the resolved 23-shape union (no overdue column, no app-named column, performer_type as an open string vocabulary so the coming external type is an append, definition_version shipped now), openregister_task_candidates as the pooled-inbox index half of the candidate pool, openregister_task_relations as the typed anchor table, and openregister_task_audit with an append-only mapper whose update and delete refuse. claim() is a conditional update so the database decides the race, and the inbox page and total share one predicate builder. Ticks tasks 1.1 and 1.2. * feat(tasks): the vocabularies, the authorization and the routing Normalisation at the boundary: TaskState publishes the one legacy-status mapping onto the six CMMN states (collapsed distinctions survive on outcome, an unmapped value is refused naming itself, and a migration may declare its source vocabulary so procest's status 'open' fails loudly); TaskPriority lands all four fleet scales on low|normal|high|urgent and refuses pipelinq's 'normaal'; TaskTemporalProjection is the ONE overdue derivation, computed and never stored. TaskAuthorizationService decides every verb fail-closed BEFORE mutation: no group backend, an unresolvable role or an unknown performer type each DENY rather than skip, and no nullable service-unavailable return exists. TaskPerformerResolver carries the five routing strategies; a strategy that finds nobody assigns nobody. Ticks tasks 2.1, 2.2, 3.1 and 3.2. * feat(tasks): the lifecycle service, cancellation propagation and the inbox TaskService: create/offer/claim/unclaim/assign/reassign/delegate/ resolve/complete/cancel, each authorized fail-closed before any mutation, each audited in the SAME transaction (an audit-write failure unwinds the completion), denials audited too. One write path maintains the candidate JSON and the candidate index rows together; state and is_terminal move in one statement; claim is the mapper's conditional update so the race's loser gets a conflict; a rejecting outcome without a comment is refused before anything moves; the template snapshot is frozen at creation. Cancellation propagation (design D-8): FlowRunMapper::update() - the one choke point every terminal run write passes - dispatches FlowRunTerminalEvent, and TaskRunTerminalListener terminates that run's open tasks with the reason recorded. Idempotent, and a task with run_uuid null is structurally out of reach. TaskInboxService answers assigned/pooled/watched/by-object in the datastore over one shared predicate set, with the total from the same predicates, subject context batched per page, a display title synthesized on read for titleless tasks (never persisted), and the derived temporal projection attached to rows only. Ticks tasks 4.1-4.5, 5.1, 6.1 and 6.2. * feat(tasks): the REST surface and the seed fixtures TaskController + /api/flow-tasks routes for the inbox, the detail and audit reads (both visibility-checked) and every lifecycle verb. The family is named for the flow-tasks CAPABILITY, not a flow requirement - /api/tasks itself already belongs to the CalDAV VTODO leaf. Every method declares its auth posture attribute AND delegates its actual authorization to TaskAuthorizationService inside the service; refusals translate uniformly (400 validation, 403 denial, 409 conflict naming the current state, 404 absence). SeedTaskFixtures installs the five seed groups from design.md (pooled municipal check with no run, delegated approval with enforcing expiry on a run, agent task with a typed checklist, one completed 'approved' and one terminated by propagation, plus audit fixtures including one DENIED entry), idempotent on uuid - and is REGISTERED in appinfo/info.xml in this same commit, because an unregistered repair step is a known fleet defect. Ticks tasks 7.1 and 8.1. * test(tasks): the guarantees, pinned - 93 tests over the task layer Table-driven legacy-status and priority tests including the live fleet defects: procest's status 'open' against a source vocabulary that does not define it is refused naming the value, and pipelinq's 'normaal' is refused on every scale. Authorization: a stranger who knows the uuid is denied on every verb, an unresolvable role denies naming the role, an absent group backend denies, an unknown performer type denies, watchers read and never act. Concurrency and transactionality: the two-claim race yields one assignee and one conflict, a rejection without a comment moves nothing, and an injected audit-write failure rolls the completion back. Derivation: overdue flips with an injected clock while the stored row stays byte-identical. Inbox: the page and the total run over the SAME criteria object (25 of 120 reported honestly), a titleless task presents a synthesized display title while its stored title stays null, and a runless task completes identically to a runful one. Mutation-checked: disabling assertMay fails 7 tests; removing the transaction rollback fails 2; both restored byte-identical (sha256-verified). Ticks tasks 9.1, 9.2 and 9.3. * test(tasks): playwright coverage for the two e2e-marked scenarios 'a stranger is refused on the task detail route': a provisioned second account gets 403 on the detail read AND on complete, and the task provably did not move - the positive control for the hole where knowing a uuid was the whole check. 'the inbox route returns tasks with subject context': one request lists the assignee's work with a display title, the subject slot, the derived overdue flag and a datastore total, and when the dev seed offers a live object the anchored subject context rides along. Both specs clean up after themselves and skip loudly when the instance cannot provision a stranger. Ticks task 9.4. * style(tasks): named parameters on every internal call, no inline ifs The repo's PHPCS standard requires named arguments on calls into OCA code and forbids ternaries. Exception constructors now name message:, the transactional/stringOrNull/arrayOrNull/respondWith calls name their parameter, the nine isset-ternaries collapse into one intOrNull helper, and the two remaining conditionals become explicit if blocks. No behaviour change: the 93 task tests are unchanged and green. * refactor(tasks): the quality pass - intake extracted, complexity earned down TaskBuilder now owns intake: boundary data in, a validated unsaved Task out, with every published vocabulary applied there once. That returns TaskService to lifecycle only and under the complexity budget. The authorization switch becomes a verb-to-rule table with static dispatch; the inbox predicate builder splits into scope, visibility and filters; the migration's indexes and the seed fixtures get methods of their own; sort and direction travel as one parameter (-dueAt) so the inbox route keeps a single-digit parameter list. The few remaining suppressions each carry their justification inline. Behaviour unchanged: the 93 task tests pass untouched except for injecting the builder. * fix(tasks): the PHPStan pass - typed mapper overrides and a DBAL constant The mapper overrides now guard their entity type with a throwing instanceof check, which is what lets the generic QBMapper contract type-check (and turns a wrong entity into a named error instead of a silent write). candidateMembershipPredicate declares the IQueryFunction it returns. The migration uses Types::DATETIME_MUTABLE, the constant the current DBAL stubs actually define. * style(tasks): import InvalidArgumentException in the three mapper guards PHPMD MissingImport on the type guards added in the PHPStan pass. No behaviour change. * feat(flow): the openregister.user-task node puts a person into the graph The node creates ONE task through TaskService on its first firing with items, stores the uuid in its own resume slot, and suspends with a heartbeat that is never null (findAbandonedSignals reaps null and would fail slow approvals at 14 days). Later firings read the TASK for terminality, never context.signal, so two user-task nodes in one flow keep independent answers. The outcome bag lands on every item's json under outcomeKey (default task), with decided/rejected separating a person's decision from a task that merely ended; failOnReject is opt-in. The advance budget (ADR-098 D9) is 0 | N | "all", null refused by name (FlowAdvanceBudget). TaskService announces every committed terminal transition (TaskTerminalEvent, after the transaction); the listener wakes the run with an empty signal and, per the node's stored budget, continues it through FlowRunAdvancer with a per-walk ceiling the engine consumes (CONTEXT_ADVANCE_BUDGET). A spent budget parks the run as due. Branch mootness: the engine reports pruned exits to FlowTaskMootness, which terminates the task of a user-task node standing on a cleared place. Run terminality stays with TaskRunTerminalListener from #3258. Ticks tasks 1.1-1.4, 2.1-2.3, 3.1-3.2, 4.1-4.3, 5.1-5.2. * fix(tasks): the review pass - offer is the requester's, and the inbox works on PostgreSQL Two blocking defects from the independent review of #3258: - offer bypassed per-task authorization and rewrote the assignee. Any authenticated user could POST offer {"routingFallback": "mallory"} on an assigned active task, become its assignee, and then complete it. offer now needs the requester (or an administrator), refuses a task that already has an assignee (unclaim or reassign instead), and is in the stranger-denied test's verb list, whose omission is how the suite stayed green. - The watchers LIKE ran against a json column, which PostgreSQL refuses (operator does not exist: json ~~ unknown): every non-admin inbox request 500ed there. The column is now cast per platform (AS TEXT on PostgreSQL, AS CHAR elsewhere), every identifier in the mapper's raw SQL goes through the platform's own quoter (no backticks), and TaskMapperTest pins the cast on a PostgreSQL platform mock. The should-fix items: authorization runs before the terminality check and a task the caller may not see answers 404 on every route, verbs included (no existence or state oracle); over HTTP the requester is pinned to the actor and a terminal creation state is refused, with import() as the trusted path for migrations and the user-task node; every state-changing write is a conditional update (WHERE is_terminal = false) so a second completion conflicts instead of overwriting; delegate refuses an empty delegate and keeps naming the original performer on re-delegation; an unexpected failure is a generic 500 whose detail goes to the log; propagation continues past a failing task; role pools are matched by the inbox EXISTS; the seed fixtures run only when app config openregister/seed_demo_tasks is true; the CSRF posture is recorded in the controller docblock; the e2e stranger scenario fails loudly instead of skipping and now covers the non-admin inbox and a watcher. Class-level @spec tags on every new class. TaskControllerTest is the contract test for all fourteen /api/flow-tasks endpoints (gate-25 PASS). Checks on this tree: PHPCS 0, PHPMD 0 (touched files), PHPStan 0, Psalm 0, 120 task tests green. * style(tasks): format the task inbox e2e spec with the Nextcloud prettier config CI's Frontend Check (format) named tests/e2e/task-inbox.spec.ts. Formatted with @nextcloud/prettier-config's options (tabs, no semicolons, single quotes, trailing commas, print width 85, operators leading). * test(tasks): cover the entities, the listener, the seed step and the builder CI's pgsql cell did not fail in PHPUnit (17860 tests, 0 failures, and app:enable succeeded); it failed the coverage guard: the changed files measured 3.85% (69/1791 statements) against a 7.32% base, because the suite runs with strict coverage metadata and collaborators exercised through TaskServiceTest were never credited. This adds the tests that were genuinely missing: the four entities' typing, hydration and serialisation (no overdue anywhere in the stored row); the terminal-run listener and event; the seed step off by default, seeding the five groups when on, idempotent on uuid, and continuing past a failing fixture; and TaskBuilder credited where TaskServiceTest exercises it. 133 task tests green. * test(flow): the user-task node, its budget, its bridge and its propagation Node: one task per node per run across a heartbeat wake, empty firing creates nothing and does not suspend, a claim is not a completion, the signal slot cannot answer for a performer, askedAt is not restamped, two nodes need two answers, outcome placement under json., rejection as a branch with failOnReject opt-in, a terminated task is not a rejection, the config-validation table (advance 0/3/all accepted; null, '', -1, 'unlimited' refused naming the value; no performer refused). Budget: FlowAdvanceBudget shapes; the engine parks at a spent ceiling and the worker walk runs the remainder; an oversight veto still applies in-request; pruned exits reach the mootness collaborator. Bridge: the provenance stamp, offer-on-strategy, 0/N/all continuation, and a failed continuation leaving the run due. Propagation: a run stopped with two tasks terminates both once across two observations; a losing branch takes its task; a run-less task is never touched. TaskService announces terminality once, after commit, and a listener failure cannot undo it. Mutation-checked: dropping the run-less guard, the performer guard or the signal-ignore each turns the suite red. Splits the node's config intake into UserTaskConfig and groups the form so the node stays under the PHPMD complexity and method-length ceilings. Ticks tasks 6.1-6.4. * test(e2e): the eight user-task scenarios over the live API Authored through POST /api/flows, run through the synchronous test endpoint, driven through the flow-tasks verbs. Two scenarios need the worker (the default budget parks for it; a run has no stop verb, so the operator's kill switch is the stop) and drive it through occ in the dev container, skipping loudly where occ is not reachable. Each test carries its scenario slug for gate-19. Ticks task 6.5 (19/19). * docs(flow): spec anchors on the three public UserTaskConfig readers gate-16 named assignee(), outcomeKey() and renderedTitle(). * test(tasks): walk the mapper queries and credit every exercised class The pgsql cell's coverage guard credits only statements in classes a test names with @covers (strict coverage metadata), and the changed files measured 20.16% against a 23.46% base. Two moves: every task test class now names each class it actually exercises (TaskBuilder, TaskState and TaskPriority behind TaskServiceTest, TaskInboxCriteria behind the inbox and controller tests, the entities and exceptions everywhere they are built), and the largest untested code is now walked: TaskMapper's whole query vocabulary, the candidate, relation and audit mappers, and FlowRunMapper's terminality announcement, over a fluent query-builder double that records which predicate and column each query sets, so a conditional update without its openness guard or a pooled scope without its EXISTS would fail here. Plus the untested TaskService verbs (unclaim, assign, reassign, cancel, resolve, terminateAsMoot, relations, intake refusals), TaskInboxService's batched subject context, and the controller's filter, flag and failing-backend paths. 168 task tests. * wip: checkpoint before rate-limit pause (2026-09-01 09:45) * chore: drop the node_modules symlink the wip checkpoint committed An absolute path into another checkout has no business in the tree; the symlink only served a local Playwright --list and stays untracked. * style(tasks): a top-level type import and sorted specifiers in the inbox e2e spec Vue Quality (eslint) on #3258 named four errors in tests/e2e/task-inbox.spec.ts: perfectionist/sort-named-imports (the specifiers now read apiRequest, expect, test) and import-extensions/ban-inline-type-imports (APIRequestContext is a top-level import type). eslint --fix plus a plain run: 0 errors; the sibling flow-engine.spec.ts as control also 0; prettier clean. * style(e2e): lint and format the user-task spec under the tests/ lint scope A top-level type import, sorted specifiers, the .ts extension on the relative import, and the repo's prettier config. Also carries #3258's matching fix for task-inbox.spec.ts through the merge. --------- Co-authored-by: Conduction Release Bot --- lib/AppInfo/Application.php | 10 + lib/Event/TaskTerminalEvent.php | 73 ++ lib/Listener/FlowNodeRegistrationListener.php | 9 + lib/Listener/UserTaskTerminalListener.php | 91 +++ lib/Service/Flow/FlowAdvanceBudget.php | 193 ++++++ lib/Service/Flow/FlowEngine.php | 61 +- lib/Service/Flow/FlowRunAdvancer.php | 61 ++ lib/Service/Flow/FlowRunService.php | 15 + lib/Service/Flow/FlowTaskBridge.php | 421 ++++++++++++ lib/Service/Flow/FlowTaskMootness.php | 184 +++++ lib/Service/Flow/Nodes/UserTaskConfig.php | 457 +++++++++++++ lib/Service/Flow/Nodes/UserTaskNode.php | 571 ++++++++++++++++ lib/Service/Task/TaskService.php | 48 +- openspec/changes/flow-user-task-node/tasks.md | 44 +- .../Service/Flow/FlowAdvanceBudgetTest.php | 97 +++ .../Flow/FlowEngineBranchPruningTest.php | 129 ++++ .../Unit/Service/Flow/FlowTaskBridgeTest.php | 335 +++++++++ .../Service/Flow/FlowTaskMootnessTest.php | 166 +++++ .../UserTaskCancellationPropagationTest.php | 144 ++++ tests/Unit/Service/Flow/UserTaskNodeTest.php | 587 ++++++++++++++++ .../Task/TaskServiceTerminalEventTest.php | 150 +++++ tests/e2e/api-direct/flow-user-task.spec.ts | 636 ++++++++++++++++++ 22 files changed, 4455 insertions(+), 27 deletions(-) create mode 100644 lib/Event/TaskTerminalEvent.php create mode 100644 lib/Listener/UserTaskTerminalListener.php create mode 100644 lib/Service/Flow/FlowAdvanceBudget.php create mode 100644 lib/Service/Flow/FlowTaskBridge.php create mode 100644 lib/Service/Flow/FlowTaskMootness.php create mode 100644 lib/Service/Flow/Nodes/UserTaskConfig.php create mode 100644 lib/Service/Flow/Nodes/UserTaskNode.php create mode 100644 tests/Unit/Service/Flow/FlowAdvanceBudgetTest.php create mode 100644 tests/Unit/Service/Flow/FlowEngineBranchPruningTest.php create mode 100644 tests/Unit/Service/Flow/FlowTaskBridgeTest.php create mode 100644 tests/Unit/Service/Flow/FlowTaskMootnessTest.php create mode 100644 tests/Unit/Service/Flow/UserTaskCancellationPropagationTest.php create mode 100644 tests/Unit/Service/Flow/UserTaskNodeTest.php create mode 100644 tests/Unit/Service/Task/TaskServiceTerminalEventTest.php create mode 100644 tests/e2e/api-direct/flow-user-task.spec.ts diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 207300bb1b..38461e9682 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -2531,6 +2531,16 @@ private function registerEventListeners(IRegistrationContext $context): void { \OCA\OpenRegister\Listener\TaskRunTerminalListener::class ); + // The other direction (flow-user-task-node): a task the graph raised + // reached a terminal state, so its suspended run is woken and, per the + // node's `advance` budget, continued in-request. Fires AFTER the task's + // own transaction committed; a failure here costs latency, never the + // completion. + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTerminalEvent::class, + \OCA\OpenRegister\Listener\UserTaskTerminalListener::class + ); + // Lifecycle annotation listeners — see x-openregister-lifecycle. // Order matters: initial state runs on creating; validation runs on updating. $context->registerEventListener(ObjectCreatingEvent::class, LifecycleInitialStateListener::class); diff --git a/lib/Event/TaskTerminalEvent.php b/lib/Event/TaskTerminalEvent.php new file mode 100644 index 0000000000..a4133286e7 --- /dev/null +++ b/lib/Event/TaskTerminalEvent.php @@ -0,0 +1,73 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Event + * @package OCA\OpenRegister\Event + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-run-continues-on-task-terminality-never-on-a-nudge + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCA\OpenRegister\Db\Task; +use OCP\EventDispatcher\Event; + +/** + * Carries the task as it was persisted in its terminal state. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-run-continues-on-task-terminality-never-on-a-nudge + */ +class TaskTerminalEvent extends Event { + + /** + * Constructor. + * + * @param Task $task The task, already persisted in a terminal state. + */ + public function __construct( + private readonly Task $task, + ) { + parent::__construct(); + + }//end __construct() + + /** + * The terminal task. + * + * @return Task The task as persisted. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-run-continues-on-task-terminality-never-on-a-nudge + */ + public function getTask(): Task { + return $this->task; + }//end getTask() +}//end class diff --git a/lib/Listener/FlowNodeRegistrationListener.php b/lib/Listener/FlowNodeRegistrationListener.php index 59be73c6e5..369952feb5 100644 --- a/lib/Listener/FlowNodeRegistrationListener.php +++ b/lib/Listener/FlowNodeRegistrationListener.php @@ -49,6 +49,7 @@ use OCA\OpenRegister\Service\Flow\Nodes\TriggerManualNode; use OCA\OpenRegister\Service\Flow\Nodes\TriggerObjectNode; use OCA\OpenRegister\Service\Flow\Nodes\TriggerScheduleNode; +use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; use OCA\OpenRegister\Service\Flow\Nodes\WaitNode; use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; use OCP\EventDispatcher\Event; @@ -85,6 +86,7 @@ class FlowNodeRegistrationListener implements IEventListener { * @param TriggerObjectNode $triggerObject The "When an object changes" entry point. * @param TriggerScheduleNode $triggerSchedule The "On a schedule" entry point. * @param TriggerManualNode $triggerManual The "When someone runs it" entry point. + * @param UserTaskNode $userTask The built-in "Ask a person" node. */ public function __construct( private readonly SetFieldsNode $setFields, @@ -109,6 +111,7 @@ public function __construct( private readonly TriggerObjectNode $triggerObject, private readonly TriggerScheduleNode $triggerSchedule, private readonly TriggerManualNode $triggerManual, + private readonly UserTaskNode $userTask, ) { }//end __construct() @@ -154,6 +157,12 @@ public function handle(Event $event): void { $event->registerNode(node: $this->sendEmail); $event->registerNode(node: $this->sendTalkMessage); + // The human step (flow-user-task-node). Registered beside await-signal + // deliberately: the two are a pair, and their palette descriptions + // state which is for a system that calls back and which is for a + // performer who has to be found, told, and allowed to say no. + $event->registerNode(node: $this->userTask); + // Entry points. Registered like any other node so the palette can offer // them and the preflight can check their config — a trigger is where a // run BEGINS, not work it performs, and each `execute()` is a diff --git a/lib/Listener/UserTaskTerminalListener.php b/lib/Listener/UserTaskTerminalListener.php new file mode 100644 index 0000000000..8e63c4bdc6 --- /dev/null +++ b/lib/Listener/UserTaskTerminalListener.php @@ -0,0 +1,91 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Continues the run a terminal task belongs to. + * + * @template-implements IEventListener + */ +class UserTaskTerminalListener implements IEventListener { + + /** + * Constructor. + * + * @param FlowTaskBridge $bridge Wakes and, per budget, advances the run. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly FlowTaskBridge $bridge, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle the event. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public function handle(Event $event): void { + if ($event instanceof TaskTerminalEvent === false) { + return; + } + + $task = $event->getTask(); + if (trim((string)$task->getRunUuid()) === '') { + return; + } + + try { + $this->bridge->continueRun(task: $task); + } catch (Throwable $failure) { + $this->logger->error( + '[UserTaskTerminalListener] Could not continue the run of task ' . $task->getUuid() + . '; the task is terminal and the worker will pick the run up: ' . $failure->getMessage(), + ['run' => $task->getRunUuid(), 'exception' => $failure] + ); + } + }//end handle() +}//end class diff --git a/lib/Service/Flow/FlowAdvanceBudget.php b/lib/Service/Flow/FlowAdvanceBudget.php new file mode 100644 index 0000000000..2d4559b2b2 --- /dev/null +++ b/lib/Service/Flow/FlowAdvanceBudget.php @@ -0,0 +1,193 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use UnexpectedValueException; + +/** + * The validated `advance` budget of a user-task node. + */ +final class FlowAdvanceBudget { + + /** + * The one spelling of "no limit". + * + * @var string + */ + public const ALL = 'all'; + + /** + * Constructor. + * + * @param integer|null $transitions The transition ceiling; null for unlimited. + */ + private function __construct( + private readonly ?int $transitions, + ) { + + }//end __construct() + + /** + * The default: park for the worker. + * + * @return self A zero budget. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public static function none(): self { + return new self(transitions: 0); + }//end none() + + /** + * Read a budget off a node configuration. + * + * The caller passes whether the key was PRESENT separately from its value, + * because that distinction is the whole point: absent means `0`, present + * and null is a refused value, and `??` cannot tell the two apart. + * + * @param array $config The node configuration. + * @param string $key The config key holding the budget. + * + * @return self The budget. + * + * @throws UnexpectedValueException When the value is null, empty, negative, + * fractional or any string but "all". + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public static function fromConfig(array $config, string $key = 'advance'): self { + if (array_key_exists($key, $config) === false) { + return self::none(); + } + + return self::fromValue(value: $config[$key]); + }//end fromConfig() + + /** + * Read a budget off a stored value. + * + * @param mixed $value The stored or configured value. + * + * @return self The budget. + * + * @throws UnexpectedValueException When the value is not `0`, a positive + * integer or the string "all". + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public static function fromValue(mixed $value): self { + if ($value === null) { + throw new UnexpectedValueException( + 'advance is null. A null budget reads as 0 at every PHP and JSON coercion, so it is refused: ' + . 'unlimited is spelled "all", the default is 0, and any other value is a positive number of transitions.' + ); + } + + if (is_string($value) === true && strtolower(trim($value)) === self::ALL) { + return new self(transitions: null); + } + + if (is_bool($value) === false && is_numeric($value) === true) { + $asFloat = (float)$value; + $asInt = (int)$value; + if ($asFloat === (float)$asInt && $asInt >= 0) { + return new self(transitions: $asInt); + } + } + + throw new UnexpectedValueException( + sprintf( + 'advance %s is not a budget. Use 0 to park the run for the worker, a positive number of transitions, or "all".', + var_export($value, true) + ) + ); + }//end fromValue() + + /** + * Whether the completing request should advance the run at all. + * + * @return boolean False for the default zero budget. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public function advancesInRequest(): bool { + return ($this->transitions !== 0); + }//end advancesInRequest() + + /** + * Whether the budget is "all". + * + * @return boolean True when the walk runs to its next natural stop. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public function isUnlimited(): bool { + return ($this->transitions === null); + }//end isUnlimited() + + /** + * The transition ceiling, or null for unlimited. + * + * @return integer|null The number of transitions the completion may push. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public function transitions(): ?int { + return $this->transitions; + }//end transitions() + + /** + * The storable spelling: `0`, `N` or `"all"`. + * + * Written into the node's resume slot at task creation so the completion + * listener reads the budget the node was saved with, not one re-derived + * from a definition that may since have been edited. + * + * @return integer|string The stored form. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public function toStored(): int|string { + if ($this->transitions === null) { + return self::ALL; + } + + return $this->transitions; + }//end toStored() +}//end class diff --git a/lib/Service/Flow/FlowEngine.php b/lib/Service/Flow/FlowEngine.php index 90bfcdb5fa..f21ecd6e1b 100644 --- a/lib/Service/Flow/FlowEngine.php +++ b/lib/Service/Flow/FlowEngine.php @@ -138,6 +138,12 @@ class FlowEngine { * so the engine stays unit-testable without * a container; absent, writes are simply * unattributed rather than mis-attributed. + * @param FlowTaskMootness|null $mootness Told which places a routing decision + * cleared, so a user task waiting on one + * of them is terminated rather than + * orphaned. Nullable on the same terms; + * absent, run-terminality propagation is + * the only backstop. */ public function __construct( private readonly FlowDefinitionBuilder $builder, @@ -146,6 +152,7 @@ public function __construct( private readonly ?FlowTokenRouter $router = null, private readonly ?FlowItemPlacement $placement = null, private readonly ?FlowRunContext $runContext = null, + private readonly ?FlowTaskMootness $mootness = null, ) { }//end __construct() @@ -491,11 +498,12 @@ public function run( taken: $taken ); $workflow->apply(subject: $subject, transitionName: $name); - $this->router()->keepOnlyTakenExits( + $this->pruneUntakenExits( workflow: $workflow, subject: $subject, transition: $transition, - taken: $taken + taken: $taken, + context: $context ); continue; }//end if @@ -670,16 +678,59 @@ public function run( // Symfony's workflow deposits a token on EVERY output place, which // is right for a parallel split and wrong for a choice. Withdraw // the ones the taken exit did not claim, or every branch runs. - $this->router()->keepOnlyTakenExits( + $this->pruneUntakenExits( workflow: $workflow, subject: $subject, transition: $transition, - taken: $taken + taken: $taken, + context: $context ); }//end while }//end run() + /** + * Withdraw the tokens a choice did not take, and say so. + * + * The withdrawal itself is the router's. What is added here is the report: + * a place cleared by a routing decision may be one a user-task node had + * already asked somebody from, and that node will now never fire from it, + * so its task is moot and must not stay in an inbox as actionable work. + * The Petri net raises no event for this; the engine is the only thing + * that sees the moment, so the engine reports it (design D-7). + * + * @param Workflow $workflow The running workflow. + * @param object $subject The marking holder. + * @param object $transition The transition that just fired. + * @param array $taken The output places the exit claimed. + * @param array $context The run context, carrying the resume state. + * + * @return void + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-task-whose-run-or-branch-has-died-is-terminated-not-orphaned + */ + private function pruneUntakenExits(Workflow $workflow, object $subject, object $transition, array $taken, array $context): void { + $tos = array_map(static fn ($t): string => (string)$t, $transition->getTos()); + $pruned = array_values(array_diff($tos, $taken)); + + $this->router()->keepOnlyTakenExits( + workflow: $workflow, + subject: $subject, + transition: $transition, + taken: $taken + ); + + if ($pruned === []) { + return; + } + + $this->mootness?->placesPruned( + context: $context, + places: $pruned, + byTransition: $transition->getName() + ); + }//end pruneUntakenExits() + /** * The per-stream walk: round-robin over advanceable streams, a claim before * every firing, a locked delta commit after it, and suspension scoped to @@ -1027,7 +1078,7 @@ private function fireOnStream( $taken = $this->router()->takenExits(flow: $flow, transition: $transition, items: $items, context: $context); $placeItems = $this->placement()->advanceItems(transition: $transition, placeItems: $placeItems, items: $items, taken: $taken); $workflow->apply(subject: $subject, transitionName: $name); - $this->router()->keepOnlyTakenExits(workflow: $workflow, subject: $subject, transition: $transition, taken: $taken); + $this->pruneUntakenExits(workflow: $workflow, subject: $subject, transition: $transition, taken: $taken, context: $context); // The places actually taken, in the transition's declaration order. $takenTos = []; diff --git a/lib/Service/Flow/FlowRunAdvancer.php b/lib/Service/Flow/FlowRunAdvancer.php index d44871e1d5..78668bdba5 100644 --- a/lib/Service/Flow/FlowRunAdvancer.php +++ b/lib/Service/Flow/FlowRunAdvancer.php @@ -165,4 +165,65 @@ public function advance(FlowRun $run, bool $rethrow = false): FlowRun { }//end advance() + /** + * Advance ONE stream of a suspended run in the calling request, within a budget. + * + * The completion side of a user task (flow-user-task-node, ADR-098 D9): the + * task on one branch was answered, and THAT branch may continue for the + * node's budget while its siblings are untouched. Resolution is the same + * as {@see self::advance()} so the two cannot drift on what a run needs; + * the walk itself is {@see FlowRunService::advanceStream()}, which scopes + * the stream collaborator, applies the run's version pin and honours the + * budget as a firing ceiling next to the run-wide one. + * + * Errors are NOT swallowed: the caller is a completion listener that has + * already committed the task and treats any failure here as "the worker + * takes over", so it wants to see the error to log it. + * + * @param FlowRun $run The suspended run. + * @param string $streamId The stream parked on the answered node. + * @param int|string $budget `0`, `N`, or `"all"`. + * + * @return FlowRun The run as it stands after the advance. + * + * @throws Throwable When the run could not be advanced. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public function advanceStream(FlowRun $run, string $streamId, int|string $budget): FlowRun { + $flow = $this->resolvers->resolveFlow((string)$run->getFlowId()); + if ($flow === null) { + $run->setStatus(FlowRun::STATUS_FAILED); + $run->setError(sprintf('No app provides flow "%s" (deleted, or its app removed?).', $run->getFlowId())); + + return $this->mapper->update($run); + } + + $subject = null; + if (trim((string)$run->getSubjectUuid()) !== '') { + $subject = $this->resolvers->resolveSubject( + (string)$run->getSubjectUuid(), + (string)$run->getSubjectRegister(), + (string)$run->getSubjectSchema() + ); + + if ($subject === null) { + $run->setStatus(FlowRun::STATUS_FAILED); + $run->setError(sprintf('Subject "%s" no longer exists.', $run->getSubjectUuid())); + + return $this->mapper->update($run); + } + } + + // A resumed walk reads its items from the run, never from a seed, so a + // subjectless run needs only the bare marking holder here. + return $this->runner->advanceStream( + run: $run, + flow: $flow, + subject: ($subject ?? new stdClass()), + streamId: $streamId, + budget: $budget + ); + }//end advanceStream() + }//end class diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index 3d8c92688c..13759b8e4e 100644 --- a/lib/Service/Flow/FlowRunService.php +++ b/lib/Service/Flow/FlowRunService.php @@ -56,6 +56,10 @@ * @SuppressWarnings(PHPMD.ExcessiveClassLength) The run lifecycle — queue, execute, resume, * signal, persist — plus the stream walk's wiring and the in-request advance; each is one * entry into the same engine and belongs beside the others. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) 50 against 50, and the + * one branch is the version-pin refusal in advanceStream(): the same rule + * execute() enforces, applied to the completion path so a week-old task + * continues the graph its run was pinned to. */ class FlowRunService { /** @@ -254,6 +258,17 @@ public function advanceStream(FlowRun $run, array $flow, object $subject, string return $this->mapper->update($run); } + // 🔴 THE RUN'S PIN OUTRANKS THE CALLER'S DOCUMENT, here as in execute(): + // a completion that lands a week after the run started must continue + // the graph the run was pinned to, not the one its author has since + // edited. An unpinned (draft test) run passes its document through. + $pinned = (new FlowPublishedGraph($this->container))->overlayOnto(run: $run, live: $flow); + if ($pinned === null) { + return $this->failUnresolvableVersion(run: $run); + } + + $flow = $pinned; + $walk = $this->streamWalkFor(run: $run, flow: $flow, onlyStream: $streamId, budget: $firings); if ($walk === null) { // Without the stream layer there is no branch to scope to; the diff --git a/lib/Service/Flow/FlowTaskBridge.php b/lib/Service/Flow/FlowTaskBridge.php new file mode 100644 index 0000000000..9f978a8566 --- /dev/null +++ b/lib/Service/Flow/FlowTaskBridge.php @@ -0,0 +1,421 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-user-task-step-creates-exactly-one-task-and-suspends-the-run + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowStreamMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Task\TaskService; +use OCA\OpenRegister\Service\Task\TaskState; +use OCP\AppFramework\Db\DoesNotExistException; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Creates the node's task, reads it back, and continues the run it parked. + * + * @SuppressWarnings(PHPMD.StaticAccess) TaskState is a stateless published + * vocabulary and FlowAdvanceBudget a value object with named constructors; + * calling either statically is the design, an instance would be a second copy. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) This class IS the coupling + * point between the task layer (service, entity, state vocabulary) and the run + * layer (run, mapper, service, advancer, engine constant, resume state). Every + * name it imports is one side or the other; splitting it would put the two + * halves of "a task the graph raised" in two files that must agree. + */ +class FlowTaskBridge { + + /** + * Resume-slot key holding the created task's uuid. + * + * @var string + */ + public const SLOT_TASK_UUID = 'taskUuid'; + + /** + * Resume-slot key holding when the task was created. Written ONCE. + * + * @var string + */ + public const SLOT_ASKED_AT = 'askedAt'; + + /** + * Resume-slot key holding the node's stored `advance` budget. + * + * @var string + */ + public const SLOT_ADVANCE = 'advance'; + + /** + * Prefix of the actor a propagation records itself as. + * + * @var string + */ + public const ACTOR_PREFIX = 'flow-run:'; + + /** + * Constructor. + * + * @param TaskService $tasks The task lifecycle. + * @param FlowRunMapper $runs Reads the run for its version pin, and to wake it. + * @param ContainerInterface $container Lazily resolves the run service and advancer. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly TaskService $tasks, + private readonly FlowRunMapper $runs, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Create the one task a user-task node asks for. + * + * Provenance is stamped HERE, not left to the node's config: `runUuid` and + * `nodeId` are what cancellation propagation and the completion listener + * find the task by, and `definitionVersion` is the run's pin, so a + * `nodeId` recorded on the task keeps pointing into the graph the run is + * actually walking. + * + * When the node names a routing strategy and no direct assignee, the task + * is OFFERED after creation so the strategy resolves now rather than at + * the first claim. Offer is the requester's verb and refuses an assigned + * task, both of which hold here by construction: the requester IS the + * actor, and a task with a direct assignee is created active and never + * offered. + * + * @param array $data The task fields the node assembled. + * @param string $runUuid The run raising the task. + * @param string $nodeId The node raising it. + * @param string|null $actor The run's acting identity; the task's requester. + * + * @return Task The persisted task. + * + * @throws \OCA\OpenRegister\Exception\TaskValidationException When the task builder refuses a value. + * @throws \OCA\OpenRegister\Exception\TaskAccessDeniedException Without an acting identity. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-user-task-step-creates-exactly-one-task-and-suspends-the-run + */ + public function createTask(array $data, string $runUuid, string $nodeId, ?string $actor): Task { + $data['runUuid'] = $runUuid; + $data['nodeId'] = $nodeId; + $data['requester'] = ($data['requester'] ?? $actor); + $data['definitionVersion'] = $this->definitionVersionOf(runUuid: $runUuid); + + // The TRUSTED intake, not the HTTP one: `create()` pins the requester + // to the caller and refuses anything an ordinary user may not write, + // because it answers a request body. This payload was assembled by a + // node from a saved definition and is stamped with the run's owner as + // requester, which is the fact the node knows and a browser does not. + $task = $this->tasks->import(data: $data, actor: $actor); + + $strategy = trim((string)($data['routingStrategy'] ?? '')); + if ($strategy !== '' && trim((string)($data['assignee'] ?? '')) === '') { + $task = $this->tasks->offer( + uuid: (string)$task->getUuid(), + pool: [ + 'routingStrategy' => $strategy, + 'routingFallback' => ($data['routingFallback'] ?? null), + ], + actor: $actor + ); + } + + return $task; + }//end createTask() + + /** + * The task a node's resume slot points at, or null when it is gone. + * + * @param string $uuid The task uuid held in the slot. + * + * @return Task|null The task, or null when no row carries that uuid. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-run-continues-on-task-terminality-never-on-a-nudge + */ + public function taskOrNull(string $uuid): ?Task { + try { + return $this->tasks->get(uuid: $uuid); + } catch (DoesNotExistException) { + return null; + } + }//end taskOrNull() + + /** + * What a terminal task tells the steps downstream. + * + * A fixed bag rather than the task row: the row is fifty columns of which + * a Switch needs six, and a delegated approval must be routable as + * "approved by the deputy under mandate X", which is a different fact from + * "approved by the manager". `decided` is what separates a person's + * decision from a task that merely ENDED (terminated, expired, waived): + * both are terminal, only one is an answer, and collapsing them would let + * an expired approval read as an approval (D-6). + * + * @param Task $task The terminal task. + * + * @return array The outcome bag. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-outcome-is-written-onto-every-item-not-only-onto-the-run + */ + public static function outcomeBagFor(Task $task): array { + $state = (string)$task->getState(); + $decided = ($state === Task::STATE_COMPLETED); + $outcome = $task->getOutcome(); + if ($decided === false && ($outcome === null || trim($outcome) === '')) { + $outcome = $state; + } + + return [ + 'taskUuid' => $task->getUuid(), + 'state' => $state, + 'decided' => $decided, + 'outcome' => $outcome, + 'rejected' => ($decided === true && TaskState::isRejectingOutcome(outcome: $outcome) === true), + 'comment' => $task->getComment(), + 'result' => $task->getResultText(), + 'completedBy' => $task->getCompletedBy(), + 'completedAt' => $task->getCompletedAt()?->format('c'), + 'performerType' => $task->getPerformerType(), + 'assignee' => $task->getAssignee(), + 'onBehalfOf' => $task->getOnBehalfOf(), + 'mandate' => $task->getMandate(), + ]; + }//end outcomeBagFor() + + /** + * A task the graph raised has ended: wake its run and spend the budget. + * + * Three steps, each of which may legitimately do nothing: + * + * 1. `signal()` with an empty payload parks the run as due. Null means the + * run is not suspended: it is mid-walk (and will read the task itself) + * or already terminal. Nothing more to do. + * 2. The node's stored budget decides whether to go on. Zero returns here, + * which is exactly the behaviour `signal()` had before this existed. + * 3. `N` and `"all"` continue THE STREAM PARKED ON THE NODE, in this + * request, through {@see FlowRunAdvancer::advanceStream()}: the budget + * follows the token (flow-parallel-streams), so siblings are untouched, + * the per-firing oversight gate and the run-wide ceiling still apply, + * and a throw is logged and swallowed. The task is committed and the + * run is due, so the worker's next pass is the unoptimised fallback + * (D-5). A run whose stream rows predate the node (no stream stands + * on it) takes the same fallback. + * + * @param Task $task The task as persisted in its terminal state. + * + * @return FlowRun|null The run after this call, or null when it was not suspended. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + public function continueRun(Task $task): ?FlowRun { + $runUuid = trim((string)$task->getRunUuid()); + if ($runUuid === '') { + return null; + } + + try { + $run = $this->runs->findByUuid(uuid: $runUuid); + } catch (DoesNotExistException) { + $this->logger->warning( + message: '[FlowTaskBridge] Task ' . $task->getUuid() . ' names run ' . $runUuid . ', which no longer exists.', + context: ['file' => __FILE__, 'line' => __LINE__] + ); + + return null; + } + + $runService = $this->container->get(FlowRunService::class); + $woken = $runService->signal(run: $run, payload: []); + if ($woken === null) { + return null; + } + + $nodeId = (string)$task->getNodeId(); + $budget = $this->budgetFor(run: $woken, nodeId: $nodeId); + if ($budget->advancesInRequest() === false) { + return $woken; + } + + $streamId = $this->streamParkedOn(runUuid: $runUuid, nodeId: $nodeId); + if ($streamId === null) { + $this->logger->info( + message: '[FlowTaskBridge] No stream of run ' . $runUuid . ' stands on node ' . $nodeId + . '; the run is due and the worker continues it.', + context: ['file' => __FILE__, 'line' => __LINE__] + ); + + return $woken; + } + + // The node's own re-entry is the first firing of the resumed stream and + // is the completion LANDING, not the run being pushed. `N` transitions + // past the node is therefore N + 1 firings; "all" is passed through. + $firings = $budget->toStored(); + if ($budget->isUnlimited() === false) { + $firings = ((int)$budget->transitions() + 1); + } + + try { + return $this->container->get(FlowRunAdvancer::class)->advanceStream( + run: $woken, + streamId: $streamId, + budget: $firings + ); + } catch (Throwable $failure) { + // The budget is an optimisation. Its failure mode is the default: + // the completion is committed, the run is due, the worker will + // advance it. Nothing is lost except the latency saving. + $this->logger->warning( + message: '[FlowTaskBridge] In-request continuation of run ' . $runUuid + . ' failed; the task stays completed and the worker takes over: ' . $failure->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'task' => $task->getUuid()] + ); + + return $woken; + } + }//end continueRun() + + /** + * The live stream whose token stands on a node's input place, if any. + * + * A node's shared input place IS its id ({@see FlowGraph::inPlace()}), and + * a stream that parked on a user-task node was parked with that place. A + * join's per-edge places start with the id and the join marker. Null when + * no non-terminal stream stands there: a run whose streams predate the + * node, or a marking the stream layer never saw. + * + * @param string $runUuid The run. + * @param string $nodeId The node the task belongs to. + * + * @return string|null The stream id, or null. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + private function streamParkedOn(string $runUuid, string $nodeId): ?string { + if ($nodeId === '') { + return null; + } + + try { + $streams = $this->container->get(FlowStreamMapper::class)->findByRun(runUuid: $runUuid); + } catch (Throwable $unavailable) { + $this->logger->debug( + message: '[FlowTaskBridge] The stream layer is not available: ' . $unavailable->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + + return null; + } + + $joinPrefix = $nodeId . FlowGraph::PLACE_JOIN; + foreach ($streams as $stream) { + if ($stream->isTerminal() === true) { + continue; + } + + $place = (string)$stream->getPlace(); + if ($place === $nodeId || str_starts_with($place, $joinPrefix) === true) { + return (string)$stream->getStreamId(); + } + } + + return null; + }//end streamParkedOn() + + /** + * The budget the node stored when it created the task. + * + * Read from the node's OWN resume slot, which is the record of what that + * node was saved with at the moment it asked. A slot with no budget, or an + * unreadable one, is the default: parking for the worker is the safe + * direction, never running further than the author asked. + * + * @param FlowRun $run The suspended run. + * @param string $nodeId The node whose task ended. + * + * @return FlowAdvanceBudget The budget. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ + private function budgetFor(FlowRun $run, string $nodeId): FlowAdvanceBudget { + $slots = FlowResumeState::fromArray(($run->getContext() ?? [])[FlowResumeState::CONTEXT_KEY] ?? null); + $slot = $slots->read(nodeId: $nodeId); + if (array_key_exists(self::SLOT_ADVANCE, $slot) === false) { + return FlowAdvanceBudget::none(); + } + + try { + return FlowAdvanceBudget::fromValue(value: $slot[self::SLOT_ADVANCE]); + } catch (Throwable $refused) { + $this->logger->warning( + message: '[FlowTaskBridge] Node ' . $nodeId . ' of run ' . $run->getUuid() + . ' stored an unreadable advance budget; parking for the worker: ' . $refused->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + + return FlowAdvanceBudget::none(); + } + }//end budgetFor() + + /** + * The definition version a run is pinned to, or null. + * + * @param string $runUuid The run. + * + * @return integer|null The pinned version; null for a test run of a draft + * or a run that cannot be read. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-user-task-step-creates-exactly-one-task-and-suspends-the-run + */ + private function definitionVersionOf(string $runUuid): ?int { + try { + return $this->runs->findByUuid(uuid: $runUuid)->getFlowVersion(); + } catch (Throwable) { + return null; + } + }//end definitionVersionOf() +}//end class diff --git a/lib/Service/Flow/FlowTaskMootness.php b/lib/Service/Flow/FlowTaskMootness.php new file mode 100644 index 0000000000..0070fbccc5 --- /dev/null +++ b/lib/Service/Flow/FlowTaskMootness.php @@ -0,0 +1,184 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-task-whose-run-or-branch-has-died-is-terminated-not-orphaned + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Service\Task\TaskService; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Branch-mootness propagation onto user tasks. + */ +class FlowTaskMootness { + + /** + * Constructor. + * + * @param ContainerInterface $container Lazily resolves the task service. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * A routing decision withdrew tokens from these places. + * + * Reads the run's resume state out of the context (it travels as an + * object, so the forget below is visible to the walk that continues) and + * terminates the task of every user-task node whose input place, or one + * of whose join places, was just cleared. + * + * @param array $context The run context, carrying the resume state. + * @param array $places The place names the pruning cleared. + * @param string $byTransition The transition whose exit decision did it. + * + * @return int How many tasks were terminated. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-task-whose-run-or-branch-has-died-is-terminated-not-orphaned + */ + public function placesPruned(array $context, array $places, string $byTransition): int { + $state = ($context[FlowResumeState::CONTEXT_KEY] ?? null); + if ($state instanceof FlowResumeState === false || $places === []) { + return 0; + } + + $runUuid = trim((string)($context[FlowRunContext::CONTEXT_RUN] ?? ($context['runUuid'] ?? ''))); + $terminated = 0; + + foreach ($state->all() as $nodeId => $slot) { + $taskUuid = trim((string)($slot[FlowTaskBridge::SLOT_TASK_UUID] ?? '')); + if ($taskUuid === '' || $this->standsOn(nodeId: $nodeId, places: $places) === false) { + continue; + } + + if ($this->terminate(taskUuid: $taskUuid, nodeId: $nodeId, runUuid: $runUuid, byTransition: $byTransition) === true) { + $state->forget(nodeId: $nodeId); + $terminated++; + } + } + + return $terminated; + }//end placesPruned() + + /** + * Whether a node reads from any of the cleared places. + * + * A node's shared input place IS its id ({@see FlowGraph::inPlace()}); a + * join's per-edge places are the id followed by the join marker. + * + * @param string $nodeId The node. + * @param array $places The cleared places. + * + * @return boolean True when one of them feeds this node. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-task-whose-run-or-branch-has-died-is-terminated-not-orphaned + */ + private function standsOn(string $nodeId, array $places): bool { + $joinPrefix = $nodeId . FlowGraph::PLACE_JOIN; + foreach ($places as $place) { + $place = (string)$place; + if ($place === $nodeId || str_starts_with($place, $joinPrefix) === true) { + return true; + } + } + + return false; + }//end standsOn() + + /** + * Terminate one task as moot, never letting the hop fail over it. + * + * Refuses to touch a task that carries no run uuid, or one belonging to a + * different run than the one walking: a slot can only ever have been + * written by this run, so either mismatch means the slot is stale and the + * task is somebody else's. + * + * @param string $taskUuid The task the slot points at. + * @param string $nodeId The node that raised it. + * @param string $runUuid The run walking, when known. + * @param string $byTransition The transition whose decision made it moot. + * + * @return boolean True when the task is now terminal (or already was). + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-task-whose-run-or-branch-has-died-is-terminated-not-orphaned + */ + private function terminate(string $taskUuid, string $nodeId, string $runUuid, string $byTransition): bool { + try { + $tasks = $this->container->get(TaskService::class); + $task = $tasks->get(uuid: $taskUuid); + + $taskRun = trim((string)$task->getRunUuid()); + if ($taskRun === '' || ($runUuid !== '' && $taskRun !== $runUuid)) { + return false; + } + + $tasks->terminateAsMoot( + uuid: $taskUuid, + reason: sprintf( + "Branch decision at '%s' in run '%s' made node '%s' unreachable; its task can no longer be used.", + $byTransition, + $taskRun, + $nodeId + ), + source: FlowTaskBridge::ACTOR_PREFIX . $taskRun + ); + + return true; + } catch (Throwable $failure) { + $this->logger->error( + message: '[FlowTaskMootness] Could not terminate task ' . $taskUuid . ' of node ' . $nodeId + . ' after branch pruning; run-terminality propagation remains the backstop: ' . $failure->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'run' => $runUuid] + ); + + return false; + }//end try + }//end terminate() +}//end class diff --git a/lib/Service/Flow/Nodes/UserTaskConfig.php b/lib/Service/Flow/Nodes/UserTaskConfig.php new file mode 100644 index 0000000000..8ab6dfb398 --- /dev/null +++ b/lib/Service/Flow/Nodes/UserTaskConfig.php @@ -0,0 +1,457 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Flow\FlowAdvanceBudget; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Flow\FlowValueTemplate; +use OCP\IL10N; +use UnexpectedValueException; + +/** + * Reads, validates and templates a user-task step's configuration. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate and FlowAdvanceBudget + * are stateless helpers over values; a factory to call them would add a + * dependency to say the same thing. + */ +final class UserTaskConfig { + + /** + * Minutes between heartbeats when the flow does not choose. + * + * Same reasoning as {@see AwaitSignalNode}: short enough that a lost wake + * is an inconvenience, long enough that a fortnight-long approval costs a + * few thousand no-op wakes. A completion wakes the run at once either way. + * + * @var int + */ + private const DEFAULT_HEARTBEAT_MINUTES = 15; + + /** + * The floor a configured heartbeat is clamped to: the stock cron period. + * + * @var int + */ + private const MIN_HEARTBEAT_MINUTES = 5; + + /** + * The item key the outcome lands under when the flow does not choose. + * + * `task`, not `signal`: a flow holding both wait nodes must not have them + * writing over each other's key by default. + * + * @var string + */ + private const DEFAULT_OUTCOME_KEY = 'task'; + + /** + * Constructor. + * + * @param IL10N $l10n Translations, for refusal messages an author reads. + */ + public function __construct( + private readonly IL10N $l10n, + ) { + + }//end __construct() + + /** + * Refuse a step that asks nothing, asks nobody, or carries an unreadable budget. + * + * The performer check is the one that must not be left to run time: a task + * nobody can be found for is not a task, and failing at run time would bury + * the mistake in a suspended run. The budget check is design D-4: `null` + * is refused by name so an author who asked for unlimited never silently + * gets zero. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When the config is refused. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + */ + public function validate(array $config): void { + if (trim((string)($config['title'] ?? '')) === '') { + throw new UnexpectedValueException( + $this->l10n->t('Say what is being asked, or nobody can do it.') + ); + } + + if ($this->namesAPerformer(config: $config) === false) { + throw new UnexpectedValueException( + $this->l10n->t( + 'No performer can be resolved: name an assignee, candidate users, candidate groups, a candidate role or a routing fallback.' + ) + ); + } + + $this->refuseOutsideVocabulary(config: $config, key: 'performerType', vocabulary: Task::PERFORMER_TYPES); + $this->refuseOutsideVocabulary(config: $config, key: 'priority', vocabulary: Task::PRIORITIES); + $this->refuseOutsideVocabulary(config: $config, key: 'routingStrategy', vocabulary: Task::ROUTING_STRATEGIES); + + if (array_key_exists('outcomes', $config) === true && $this->listOf(value: $config['outcomes']) === []) { + throw new UnexpectedValueException( + $this->l10n->t('Possible outcomes must be a list of names, like "approved, rejected".') + ); + } + + if (array_key_exists('heartbeatMinutes', $config) === true && is_numeric($config['heartbeatMinutes']) === false) { + throw new UnexpectedValueException( + $this->l10n->t('Re-check every (minutes) must be a number.') + ); + } + + // Throws its own message, which names the value and states the spelling. + FlowAdvanceBudget::fromConfig(config: $config); + + }//end validate() + + /** + * The task fields, templated against the representative item. + * + * Everything here is passed THROUGH to the task builder, which validates + * it. The node adds no field of its own; the outcome vocabulary and the + * item key ride under `metadata`, which the task service carries and never + * interprets. The task is anchored to the object the item is about when + * the item says which one that is, so the inbox can show its subject. + * + * @param array $config The step configuration. + * @param array $items The input items; the first is the representative. + * @param string $nodeId The node raising the task. + * @param string $nodeType The node's catalogue id. + * + * @return array The creation payload. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-user-task-step-creates-exactly-one-task-and-suspends-the-run + */ + public function taskData(array $config, array $items, string $nodeId, string $nodeType): array { + $json = $this->representativeJson(items: $items); + $assignee = $this->assignee(config: $config); + + $state = Task::STATE_ENABLED; + if ($assignee !== '') { + $state = Task::STATE_ACTIVE; + } + + $data = [ + 'title' => $this->renderedTitle(config: $config, items: $items), + 'description' => $this->renderedOrNull(value: ($config['description'] ?? null), json: $json), + 'state' => $state, + 'performerType' => trim((string)($config['performerType'] ?? Task::PERFORMER_USER)), + 'priority' => trim((string)($config['priority'] ?? 'normal')), + 'assignee' => $this->nullIfEmpty(value: $assignee), + 'candidateUsers' => $this->nullIfEmptyList(value: $this->listOf(value: ($config['candidateUsers'] ?? null))), + 'candidateGroups' => $this->nullIfEmptyList(value: $this->listOf(value: ($config['candidateGroups'] ?? null))), + 'candidateRole' => $this->nullIfEmpty(value: trim((string)($config['candidateRole'] ?? ''))), + 'routingStrategy' => $this->nullIfEmpty(value: trim((string)($config['routingStrategy'] ?? ''))), + 'routingFallback' => $this->nullIfEmpty(value: trim((string)($config['routingFallback'] ?? ''))), + 'dueAt' => $this->renderedOrNull(value: ($config['dueAt'] ?? null), json: $json), + 'expiresAt' => $this->renderedOrNull(value: ($config['expiresAt'] ?? null), json: $json), + 'metadata' => [ + 'flowNodeType' => $nodeType, + 'flowNode' => $nodeId, + 'outcomes' => $this->listOf(value: ($config['outcomes'] ?? null)), + 'outcomeKey' => $this->outcomeKey(config: $config), + ], + ]; + + return array_merge($data, $this->subjectAnchor(json: $json)); + }//end taskData() + + /** + * What the node remembers in its resume slot once the task exists. + * + * Written ONCE by the node: askedAt records when somebody was first asked, + * not when the run last checked; the budget is the one the node was saved + * with, so the completion listener spends what the author asked for; the + * assignee is read by FlowRunAssignee so a resume POSTed at the run by + * anyone else is refused at the door as well as ignored by the node. + * + * @param array $config The step configuration. + * @param array $items The input items. + * @param string $taskUuid The created task. + * + * @return array The slot values. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-several-user-task-nodes-in-one-flow-keep-independent-state + */ + public function slotValues(array $config, array $items, string $taskUuid): array { + return [ + FlowTaskBridge::SLOT_TASK_UUID => $taskUuid, + FlowTaskBridge::SLOT_ASKED_AT => (new DateTime())->format('c'), + FlowTaskBridge::SLOT_ADVANCE => FlowAdvanceBudget::fromConfig(config: $config)->toStored(), + 'assignee' => $this->assignee(config: $config), + 'title' => $this->renderedTitle(config: $config, items: $items), + ]; + }//end slotValues() + + /** + * The directly configured assignee, trimmed; '' when the task is pooled. + * + * @param array $config The step configuration. + * + * @return string The assignee uid, or ''. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-user-task-step-creates-exactly-one-task-and-suspends-the-run + */ + public function assignee(array $config): string { + return trim((string)($config['assignee'] ?? '')); + }//end assignee() + + /** + * The item key the outcome is written under. + * + * @param array $config The step configuration. + * + * @return string The key. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-outcome-is-written-onto-every-item-not-only-onto-the-run + */ + public function outcomeKey(array $config): string { + $key = trim((string)($config['outcomeKey'] ?? '')); + if ($key === '') { + return self::DEFAULT_OUTCOME_KEY; + } + + return $key; + }//end outcomeKey() + + /** + * The title, templated against the representative item. + * + * @param array $config The step configuration. + * @param array $items The input items. + * + * @return string The rendered title. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-user-task-step-creates-exactly-one-task-and-suspends-the-run + */ + public function renderedTitle(array $config, array $items): string { + return trim((string)FlowValueTemplate::render( + value: (string)($config['title'] ?? ''), + json: $this->representativeJson(items: $items) + )); + }//end renderedTitle() + + /** + * When to wake up and re-ask, absent a wake. Never null. + * + * @param array $config The step configuration. + * + * @return DateTime The next heartbeat. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-run-continues-on-task-terminality-never-on-a-nudge + */ + public function heartbeatAt(array $config): DateTime { + $minutes = (int)($config['heartbeatMinutes'] ?? self::DEFAULT_HEARTBEAT_MINUTES); + if ($minutes < self::MIN_HEARTBEAT_MINUTES) { + $minutes = self::MIN_HEARTBEAT_MINUTES; + } + + return (new DateTime())->modify('+' . $minutes . ' minutes'); + }//end heartbeatAt() + + /** + * Whether the config names anybody who could perform the task. + * + * @param array $config The step configuration. + * + * @return boolean True when at least one performer source is set. + */ + private function namesAPerformer(array $config): bool { + foreach (['assignee', 'candidateRole', 'routingFallback'] as $key) { + if (trim((string)($config[$key] ?? '')) !== '') { + return true; + } + } + + foreach (['candidateUsers', 'candidateGroups'] as $key) { + if ($this->listOf(value: ($config[$key] ?? null)) !== []) { + return true; + } + } + + return false; + }//end namesAPerformer() + + /** + * Refuse a set value outside a published vocabulary, naming both. + * + * @param array $config The step configuration. + * @param string $key The config key. + * @param array $vocabulary The accepted values. + * + * @return void + * + * @throws UnexpectedValueException When the value is set and not in the vocabulary. + */ + private function refuseOutsideVocabulary(array $config, string $key, array $vocabulary): void { + $value = trim((string)($config[$key] ?? '')); + if ($value === '' || in_array($value, $vocabulary, true) === true) { + return; + } + + throw new UnexpectedValueException( + $this->l10n->t('%1$s "%2$s" is not one of %3$s.', [$key, $value, implode(', ', $vocabulary)]) + ); + }//end refuseOutsideVocabulary() + + /** + * The object anchor an item carries, when it carries one. + * + * @param array $json The representative record. + * + * @return array objectUuid, registerId and schemaId as far as known. + */ + private function subjectAnchor(array $json): array { + $self = (array)($json['@self'] ?? []); + $objectUuid = trim((string)($self['uuid'] ?? ($json['uuid'] ?? ($self['id'] ?? '')))); + if ($objectUuid === '') { + return []; + } + + $anchor = ['objectUuid' => $objectUuid]; + if (is_numeric($self['register'] ?? null) === true) { + $anchor['registerId'] = (int)$self['register']; + } + + if (is_numeric($self['schema'] ?? null) === true) { + $anchor['schemaId'] = (int)$self['schema']; + } + + return $anchor; + }//end subjectAnchor() + + /** + * A list from a list or a comma-separated string; empty for anything else. + * + * @param mixed $value The configured value. + * + * @return array Trimmed, non-empty entries. + */ + private function listOf(mixed $value): array { + if (is_string($value) === true) { + $value = explode(',', $value); + } + + if (is_array($value) === false) { + return []; + } + + $list = []; + foreach ($value as $entry) { + if (is_scalar($entry) === false) { + continue; + } + + $entry = trim((string)$entry); + if ($entry !== '') { + $list[] = $entry; + } + } + + return $list; + }//end listOf() + + /** + * The record of the representative item: the first array item's json. + * + * @param array $items The input items. + * + * @return array The record, empty when there is none. + */ + private function representativeJson(array $items): array { + foreach ($items as $item) { + if (is_array($item) === true) { + return (array)($item[FlowItems::JSON] ?? []); + } + } + + return []; + }//end representativeJson() + + /** + * A templated string, or null when it renders to nothing. + * + * @param mixed $value The configured value. + * @param array $json The representative record. + * + * @return string|null The rendered string, or null. + */ + private function renderedOrNull(mixed $value, array $json): ?string { + if ($value === null) { + return null; + } + + $rendered = FlowValueTemplate::render(value: $value, json: $json); + if (is_scalar($rendered) === false) { + return null; + } + + return $this->nullIfEmpty(value: trim((string)$rendered)); + }//end renderedOrNull() + + /** + * Null for an empty string. + * + * @param string $value The value. + * + * @return string|null The value, or null when empty. + */ + private function nullIfEmpty(string $value): ?string { + if ($value === '') { + return null; + } + + return $value; + }//end nullIfEmpty() + + /** + * Null for an empty list. + * + * @param array $value The list. + * + * @return array|null The list, or null when empty. + */ + private function nullIfEmptyList(array $value): ?array { + if ($value === []) { + return null; + } + + return $value; + }//end nullIfEmptyList() +}//end class diff --git a/lib/Service/Flow/Nodes/UserTaskNode.php b/lib/Service/Flow/Nodes/UserTaskNode.php new file mode 100644 index 0000000000..2e9218f2b9 --- /dev/null +++ b/lib/Service/Flow/Nodes/UserTaskNode.php @@ -0,0 +1,571 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowStop; +use OCA\OpenRegister\Service\Flow\FlowSuspension; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\WorkflowEngine\IManager; +use RuntimeException; + +/** + * Creates one task, suspends until it is terminal, and routes on the outcome. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowTaskBridge::outcomeBagFor is a + * stateless helper over a value; a factory to call it would add a dependency + * to say the same thing. + */ +class UserTaskNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm { + + /** + * The configuration boundary: validation and templating. + * + * @var UserTaskConfig + */ + private readonly UserTaskConfig $config; + + /** + * Constructor. + * + * @param FlowTaskBridge $bridge Creates and reads the node's task. + * @param IL10N $l10n Translations. + * @param IURLGenerator $urls For the palette icon. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md + */ + public function __construct( + private readonly FlowTaskBridge $bridge, + private readonly IL10N $l10n, + private readonly IURLGenerator $urls, + ) { + $this->config = new UserTaskConfig(l10n: $l10n); + + }//end __construct() + + /** + * The step type. + * + * @return string The id. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + */ + public function getId(): string { + return 'openregister.user-task'; + }//end getId() + + /** + * Palette name. + * + * @return string The display name. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + */ + public function getDisplayName(): string { + return $this->l10n->t('Ask a person'); + }//end getDisplayName() + + /** + * Palette description, written as the other half of the await-signal pair. + * + * @return string The description. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-signal-node-keeps-machine-to-machine-work + */ + public function getDescription(): string { + return $this->l10n->t( + 'Ask a person or an agent to do something, and wait for their answer. For a system that will call back, use "Wait for an answer" instead.' + ); + }//end getDescription() + + /** + * Palette icon. + * + * @return string The icon URL. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + */ + public function getIcon(): string { + return $this->urls->imagePath('core', 'actions/user.svg'); + }//end getIcon() + + /** + * Asking somebody grants no privilege; the task service authorizes the answer. + * + * @param int $scope The scope constant. + * + * @return boolean Whether it is available. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + */ + public function isAvailableForScope(int $scope): bool { + return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); + }//end isAvailableForScope() + + /** + * The config vocabulary of a user-task step. + * + * @return array The accepted config keys. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + */ + public function configKeys(): array { + return [ + 'title', + 'description', + 'assignee', + 'candidateUsers', + 'candidateGroups', + 'candidateRole', + 'routingStrategy', + 'routingFallback', + 'performerType', + 'priority', + 'dueAt', + 'expiresAt', + 'outcomes', + 'outcomeKey', + 'failOnReject', + 'heartbeatMinutes', + 'advance', + ]; + }//end configKeys() + + /** + * The fields this node is edited through, in the order the spec names them: + * what the task is, who may perform it, how urgent and when, and how the + * flow continues. + * + * @return array> The field descriptions. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + */ + public function configForm(): array { + return array_merge( + $this->whatFields(), + $this->whoFields(), + $this->whenFields(), + $this->continuationFields() + ); + }//end configForm() + + /** + * Validate through the configuration boundary. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws \UnexpectedValueException When the config is refused. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + */ + public function validateConfig(array $config): void { + $this->config->validate(config: $config); + + }//end validateConfig() + + /** + * Create the task on the first pass; suspend until it ends; route on the outcome. + * + * @param array $items The input items. + * @param array $config The step configuration. + * @param array $context Run-level metadata. + * + * @return array The items, each carrying the outcome bag. + * + * @throws FlowSuspension While the task is not terminal. + * @throws FlowStop When rejected and the step asked to fail on rejection. + * @throws RuntimeException When the node has no resume slot, or its task is gone. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-user-task-step-creates-exactly-one-task-and-suspends-the-run + */ + public function execute(array $items, array $config, array $context): array { + if ($items === []) { + // An empty branch reaching this node is the normal case in a + // priority-ordered graph. Nothing to ask about, nobody to ask, and + // suspension is a RUN-level act this branch has no right to. + return $items; + } + + $resume = ($context[FlowNodeResumeState::CONTEXT_KEY] ?? null); + if ($resume instanceof FlowNodeResumeState === false) { + // Without a slot there is nowhere to record that the task exists, + // so every heartbeat would create another. A step that cannot be + // made idempotent must not run at all. + throw new RuntimeException('openregister.user-task needs a node resume slot; without one every heartbeat would create a task.'); + } + + $taskUuid = trim((string)$resume->get(key: FlowTaskBridge::SLOT_TASK_UUID, default: '')); + if ($taskUuid === '') { + $this->createTask(items: $items, config: $config, context: $context, resume: $resume); + + throw $this->suspension(config: $config, items: $items); + } + + $task = $this->bridge->taskOrNull(uuid: $taskUuid); + if ($task === null) { + // The row this run was waiting on is gone. Waiting further would + // wait forever; carrying on would invent an answer. Fail the step + // and let the author's onError policy decide. + throw new RuntimeException(sprintf('Task %s, which this step was waiting on, no longer exists.', $taskUuid)); + } + + if ($task->isInTerminalState() === false) { + // Claimed, reassigned, delegated, nudged: none of those is an + // answer. Suspend again, and do NOT touch the slot: askedAt stays + // what it was. + throw $this->suspension(config: $config, items: $items); + } + + $bag = FlowTaskBridge::outcomeBagFor(task: $task); + + if ($bag['rejected'] === true && ($config['failOnReject'] ?? false) === true) { + throw new FlowStop( + reason: sprintf( + 'Rejected: %s', + trim((string)($bag['comment'] ?? $this->config->renderedTitle(config: $config, items: $items))) + ), + isError: true + ); + } + + return $this->placeOutcome(items: $items, config: $config, bag: $bag); + }//end execute() + + /** + * Create the one task and remember it in this node's slot. + * + * @param array $items The input items; the first is the representative. + * @param array $config The step configuration. + * @param array $context Run-level metadata. + * @param FlowNodeResumeState $resume This node's slot. + * + * @return void + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-user-task-step-creates-exactly-one-task-and-suspends-the-run + */ + private function createTask(array $items, array $config, array $context, FlowNodeResumeState $resume): void { + $runUuid = trim((string)($context[FlowRunContext::CONTEXT_RUN] ?? ($context['runUuid'] ?? ''))); + if ($runUuid === '') { + throw new RuntimeException('openregister.user-task cannot create a task outside a persisted run: the task must carry the run uuid.'); + } + + $task = $this->bridge->createTask( + data: $this->config->taskData(config: $config, items: $items, nodeId: $resume->nodeId(), nodeType: $this->getId()), + runUuid: $runUuid, + nodeId: $resume->nodeId(), + actor: $this->actingIdentity(context: $context) + ); + + // Written ONCE. A heartbeat re-enters execute() and finds the uuid + // held, so it never reaches this line again; askedAt therefore records + // when somebody was first asked, not when the run last checked. + $resume->merge( + values: $this->config->slotValues(config: $config, items: $items, taskUuid: (string)$task->getUuid()) + ); + + }//end createTask() + + /** + * Write the outcome bag onto every item, under the configured key. + * + * Into the item's record (`json`), not beside it: the steps that follow + * route per item and read `json.`, and a Switch cannot branch on + * something only the run holds. Non-array items are left alone rather + * than failing the run. + * + * @param array $items The items to pass on. + * @param array $config The step configuration. + * @param array $bag The outcome bag. + * + * @return array The items, each carrying the bag. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-outcome-is-written-onto-every-item-not-only-onto-the-run + */ + private function placeOutcome(array $items, array $config, array $bag): array { + $key = $this->config->outcomeKey(config: $config); + + foreach ($items as $index => $item) { + if (is_array($item) === false) { + continue; + } + + $json = (array)($item[FlowItems::JSON] ?? []); + $json[$key] = $bag; + $item[FlowItems::JSON] = $json; + $items[$index] = $item; + } + + return $items; + }//end placeOutcome() + + /** + * The suspension this node parks on: a heartbeat, and a reason that names + * what is being waited for so a paused run explains itself. + * + * @param array $config The step configuration. + * @param array $items The input items. + * + * @return FlowSuspension The suspension to throw. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-run-continues-on-task-terminality-never-on-a-nudge + */ + private function suspension(array $config, array $items): FlowSuspension { + $title = $this->config->renderedTitle(config: $config, items: $items); + if ($title === '') { + $title = 'a task'; + } + + return new FlowSuspension( + resumeAt: $this->config->heartbeatAt(config: $config), + reason: sprintf('waiting for a person: %s', $title) + ); + }//end suspension() + + /** + * The run's acting identity: who the task is requested by. + * + * The run's owner (`runAs`) first, the person who triggered it second. + * Null is passed through and refused by the task service by name, which + * is the loud failure an unattributed run should produce. + * + * @param array $context Run-level metadata. + * + * @return string|null The uid, or null when the run has none. + */ + private function actingIdentity(array $context): ?string { + foreach (['runAs', 'triggeredBy'] as $key) { + $uid = trim((string)($context[$key] ?? '')); + if ($uid !== '') { + return $uid; + } + } + + return null; + }//end actingIdentity() + + /** + * What the task is. + * + * @return array> The field descriptions. + */ + private function whatFields(): array { + return [ + [ + 'key' => 'title', + 'label' => $this->l10n->t('What is being asked'), + 'type' => 'text', + 'help' => $this->l10n->t('The task title, shown in the inbox. Fields of the item can be used, like {{ name }}.'), + 'required' => true, + ], + [ + 'key' => 'description', + 'label' => $this->l10n->t('Details'), + 'type' => 'textarea', + 'help' => $this->l10n->t('What the performer needs to know to do it. Templates work here too.'), + ], + [ + 'key' => 'outcomes', + 'label' => $this->l10n->t('Possible outcomes'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'The answers the flow will route on, comma separated, like "approved, rejected". Recorded on the task for the inbox to offer.' + ), + ], + ]; + }//end whatFields() + + /** + * Who may perform it. + * + * @return array> The field descriptions. + */ + private function whoFields(): array { + return [ + [ + 'key' => 'assignee', + 'label' => $this->l10n->t('Assign directly to'), + 'type' => 'text', + 'help' => $this->l10n->t('A user id. The task is created active for this person; leave empty to offer it to a pool instead.'), + ], + [ + 'key' => 'candidateUsers', + 'label' => $this->l10n->t('Candidate users'), + 'type' => 'text', + 'help' => $this->l10n->t('User ids, comma separated. Any of them may claim the task.'), + ], + [ + 'key' => 'candidateGroups', + 'label' => $this->l10n->t('Candidate groups'), + 'type' => 'text', + 'help' => $this->l10n->t('Group ids, comma separated. Any member may claim the task.'), + ], + [ + 'key' => 'candidateRole', + 'label' => $this->l10n->t('Candidate role'), + 'type' => 'text', + 'help' => $this->l10n->t('A role that resolves to a group of performers.'), + ], + [ + 'key' => 'routingStrategy', + 'label' => $this->l10n->t('How to pick a performer'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'One of single-role, or-set, hierarchical, round-robin or least-loaded. Leave empty to let the pool claim.' + ), + ], + [ + 'key' => 'routingFallback', + 'label' => $this->l10n->t('Fallback performer'), + 'type' => 'text', + 'help' => $this->l10n->t('Who gets the task when the strategy finds nobody.'), + ], + [ + 'key' => 'performerType', + 'label' => $this->l10n->t('Kind of performer'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'user, group, agent or worker. Defaults to user. An agent completes a task through the same verbs a person does.' + ), + ], + ]; + }//end whoFields() + + /** + * How urgent it is, and when. + * + * @return array> The field descriptions. + */ + private function whenFields(): array { + return [ + [ + 'key' => 'priority', + 'label' => $this->l10n->t('Priority'), + 'type' => 'text', + 'help' => $this->l10n->t('low, normal, high or urgent. Defaults to normal.'), + ], + [ + 'key' => 'dueAt', + 'label' => $this->l10n->t('Due'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'When the task should be done: a date, a field like {{ deadline }}, or a relative time like "+3 days". Advisory.' + ), + ], + [ + 'key' => 'expiresAt', + 'label' => $this->l10n->t('Expires'), + 'type' => 'text', + 'help' => $this->l10n->t('When the task stops being doable. Same shapes as "Due". Must not lie before it.'), + ], + [ + 'key' => 'heartbeatMinutes', + 'label' => $this->l10n->t('Re-check every (minutes)'), + 'type' => 'number', + 'help' => $this->l10n->t( + 'Safety net for a wake that never arrives. Lower is not faster: a completed task wakes the run immediately either way.' + ), + ], + ]; + }//end whenFields() + + /** + * How the flow continues once answered. + * + * @return array> The field descriptions. + */ + private function continuationFields(): array { + return [ + [ + 'key' => 'outcomeKey', + 'label' => $this->l10n->t('Field to store the answer in'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'The outcome is written onto every item under this field, so later steps can route on it. Defaults to "task".' + ), + ], + [ + 'key' => 'failOnReject', + 'label' => $this->l10n->t('Treat a rejection as a failure'), + 'type' => 'boolean', + 'help' => $this->l10n->t('Off by default: being told "no" is usually the flow working, not breaking. Route on the outcome instead.'), + ], + [ + 'key' => 'advance', + 'label' => $this->l10n->t('Continue after the answer'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'How far the run continues inside the request that completes the task: 0 leaves it to the background worker (default), ' + . 'a number runs that many steps, "all" runs to the next pause or the end.' + ), + ], + ]; + }//end continuationFields() +}//end class diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php index 730a6d7d5b..8f9ed3ab8c 100644 --- a/lib/Service/Task/TaskService.php +++ b/lib/Service/Task/TaskService.php @@ -48,9 +48,11 @@ use OCA\OpenRegister\Db\TaskCandidateMapper; use OCA\OpenRegister\Db\TaskMapper; use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Event\TaskTerminalEvent; use OCA\OpenRegister\Exception\TaskAccessDeniedException; use OCA\OpenRegister\Exception\TaskConflictException; use OCA\OpenRegister\Exception\TaskValidationException; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; use Psr\Log\LoggerInterface; use Throwable; @@ -76,6 +78,11 @@ * @SuppressWarnings(PHPMD.StaticAccess) TaskState is a stateless published * vocabulary (the one status mapping); calling it statically is the point, * an instance would be a second copy of the same table. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) The tenth constructor + * argument is the nullable event dispatcher that announces terminality + * (flow-user-task-node); it is last so the hand-built test services keep + * their order, and folding it into another collaborator would hide that a + * lifecycle verb has an after-commit side effect. * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed */ @@ -94,6 +101,16 @@ class TaskService { * @param LoggerInterface $logger Failure reporting. * @param TaskBuilder $builder Validates and builds a new task from * boundary data (the vocabularies live there). + * @param IEventDispatcher|null $dispatcher Announces a task reaching a + * terminal state + * ({@see TaskTerminalEvent}), AFTER + * the transition committed. Last and + * nullable so the four test suites + * that build this service by hand + * keep their argument order; absent, + * terminality goes unannounced and a + * parked run learns of it on its + * heartbeat instead. */ public function __construct( private readonly TaskMapper $tasks, @@ -105,6 +122,7 @@ public function __construct( private readonly IDBConnection $db, private readonly LoggerInterface $logger, private readonly TaskBuilder $builder, + private readonly ?IEventDispatcher $dispatcher = null, ) { }//end __construct() @@ -473,6 +491,7 @@ public function cancel(string $uuid, ?string $reason, ?string $actor): Task { return $persisted; } ); + }//end cancel() /** @@ -623,6 +642,7 @@ public function terminateAsMoot(string $uuid, string $reason, string $source): T return $persisted; } ); + }//end terminateAsMoot() /** @@ -736,6 +756,7 @@ private function completeInternal( return $persisted; } ); + }//end completeInternal() /** @@ -896,10 +917,12 @@ private function appendAudit(Task $task, string $action, ?string $actor, ?string }//end appendAudit() /** - * Run a mutation and its audit in ONE transaction. + * Run a mutation and its audit in ONE transaction, then announce terminality. * * The rollback is what makes "a completed task without its audit entry" * unreachable: an audit-write failure unwinds the completion with it. + * A mutation that leaves the task terminal is announced as + * {@see TaskTerminalEvent} once the transaction has closed. * * @param callable(): Task $mutation The mutation to run. * @@ -914,12 +937,31 @@ private function transactional(callable $mutation): Task { try { $result = $mutation(); $this->db->commit(); - - return $result; } catch (Throwable $failure) { $this->db->rollBack(); throw $failure; } + + // Terminality is announced HERE, after the commit and from the one + // place every mutation passes, so no verb can forget it (the same + // choke-point argument FlowRunMapper::update() makes for runs). After + // the commit, never inside it: the flow-side listener may continue the + // task's run in-request, and that walk must find a completion that + // already exists on its own. A listener failure is logged and + // swallowed: the caller has a committed task in hand and must be told + // so, whatever the run did with it afterwards. + if ($this->dispatcher !== null && $result->isInTerminalState() === true) { + try { + $this->dispatcher->dispatchTyped(new TaskTerminalEvent(task: $result)); + } catch (Throwable $listenerFailure) { + $this->logger->warning( + '[TaskService] A terminal-task listener failed; the task itself is unaffected: ' . $listenerFailure->getMessage(), + ['task' => $result->getUuid(), 'exception' => $listenerFailure] + ); + } + } + + return $result; }//end transactional() /** diff --git a/openspec/changes/flow-user-task-node/tasks.md b/openspec/changes/flow-user-task-node/tasks.md index 757d2d2dac..3d2918bfe9 100644 --- a/openspec/changes/flow-user-task-node/tasks.md +++ b/openspec/changes/flow-user-task-node/tasks.md @@ -2,41 +2,41 @@ ## 1. The node -- [ ] 1.1 `lib/Service/Flow/Nodes/UserTaskNode.php` implementing `IFlowNode`, +- [x] 1.1 `lib/Service/Flow/Nodes/UserTaskNode.php` implementing `IFlowNode`, `IFlowNodeConfigKeys` and `IFlowNodeConfigForm`. Follow `lib/Service/Flow/Nodes/AwaitSignalNode.php` for shape: EUPL-1.2 header, `@spec` on every method, a file docblock that states the division of labour with `await-signal` and the reason the heartbeat exists. `getId()` returns `openregister.user-task`; `isAvailableForScope()` allows `SCOPE_ADMIN` and `SCOPE_USER`. -- [ ] 1.2 `configForm()` + `configKeys()` covering title/description +- [x] 1.2 `configForm()` + `configKeys()` covering title/description templates, candidate users/groups/role, routing strategy and fallback, priority, `dueAt`/`expiresAt` references, outcome vocabulary, `outcomeKey` (default `task`), `failOnReject`, `heartbeatMinutes`, `advance`. No editor change needed — `FlowNodeRegistry::palette()` already publishes `configForm` (`lib/Service/Flow/FlowNodeRegistry.php:243-250`). -- [ ] 1.3 `validateConfig()` — refuse a config naming no candidate user, +- [x] 1.3 `validateConfig()` — refuse a config naming no candidate user, group, role or fallback; refuse `advance: null` with a message naming the value and stating that unlimited is spelled `"all"`; refuse an `advance` that is neither `0`, a positive integer, nor `"all"`. -- [ ] 1.4 Register the node in `lib/Listener/FlowNodeRegistrationListener.php` +- [x] 1.4 Register the node in `lib/Listener/FlowNodeRegistrationListener.php` alongside the existing built-ins. Do not touch `AwaitSignalNode.php`. ## 2. Suspend and resume -- [ ] 2.1 First-firing path: no items → return items unchanged and do NOT +- [x] 2.1 First-firing path: no items → return items unchanged and do NOT suspend; items present and no task in this node's resume slot → create one task via `flow-tasks`' `TaskService` with `run_uuid` + `node_id`, store its uuid and the creation time in the slot (`FlowNodeResumeState`), then throw `FlowSuspension`. -- [ ] 2.2 Heartbeat: suspend with a non-null `resumeAt`, defaulting to 15 +- [x] 2.2 Heartbeat: suspend with a non-null `resumeAt`, defaulting to 15 minutes and clamped to a 5-minute floor, matching `AwaitSignalNode.php:87` and `:98`. NEVER `resumeAt: null` — that is the only shape `FlowRunMapper::findAbandonedSignals()` (`lib/Db/FlowRunMapper.php:589-605`) reaps, and it would FAIL slow approvals at 14 days (`lib/BackgroundJob/FlowRunWorker.php:94`). -- [ ] 2.3 Continuation path: read terminality from the TASK by uuid, never +- [x] 2.3 Continuation path: read terminality from the TASK by uuid, never from `$context['signal']`. Non-terminal → suspend again without restamping the creation time. Terminal → continue. Idempotence is per node via the resume slot, so two user-task nodes in one flow keep @@ -44,65 +44,71 @@ ## 3. Outcome and rejection -- [ ] 3.1 Write the completion result onto EVERY item under `outcomeKey` — +- [x] 3.1 Write the completion result onto EVERY item under `outcomeKey` — outcome, comment, completing identity, performer type, `on_behalf_of` — and mark a task that went terminal WITHOUT a completion (terminated, expired) distinguishably. Non-array items are skipped, not fatal. Rationale is `AwaitSignalNode.php:294-296`: a Switch cannot branch on something only the run holds. -- [ ] 3.2 Rejection is a BRANCH: continue by default, `failOnReject` opt-in +- [x] 3.2 Rejection is a BRANCH: continue by default, `failOnReject` opt-in raising `FlowStop`, same shape as `AwaitSignalNode.php:277-287`. ## 4. The advance budget -- [ ] 4.1 Completion listener: signal the run with an EMPTY payload so it is +- [x] 4.1 Completion listener: signal the run with an EMPTY payload so it is parked as due (`FlowRunService::signal()`), then honour the node's budget — `0` returns immediately for the worker; `N` and `"all"` call `FlowRunAdvancer::advance(run: $run, rethrow: true)`, the same path `FlowService.php:511` already uses. -- [ ] 4.2 Per-walk transition ceiling carried on the run context and read by +- [x] 4.2 Per-walk transition ceiling carried on the run context and read by the engine's existing loop counter alongside `MAX_TRANSITIONS` (`lib/Service/Flow/FlowEngine.php:103`, `:325`); the lower ceiling wins. No second walk implementation, no second oversight call site — `assertOversightAllows()` (`FlowEngine.php:425`) still gates every hop and still fails closed. -- [ ] 4.3 Degradation: a throw during in-request continuation leaves the task + Landed as `FlowStreamWalk::$budget` through + `FlowRunService::advanceStream()` (flow-parallel-streams, merged + first): the ceiling is a firing budget on the completing STREAM, next + to the run-wide `MAX_TRANSITIONS`, and the per-firing oversight call + is the stream walk's own. `FlowRunAdvancer::advanceStream()` resolves + the run for it exactly as `advance()` does. +- [x] 4.3 Degradation: a throw during in-request continuation leaves the task completed and the run due for the worker, and the completing caller is told the task was accepted. The budget is an optimisation; its failure mode is the unoptimised behaviour. ## 5. Cancellation propagation -- [ ] 5.1 Run-terminality listener: on `completed`, `stopped`, `failed` or +- [x] 5.1 Run-terminality listener: on `completed`, `stopped`, `failed` or `dead_letter` (`lib/Db/FlowRun.php` STATUS constants), terminate every non-terminal task created by any user-task node in that run, reason naming the run and its status, propagation source as actor. Idempotent — terminality is observable twice (completing request and `FlowRunWorker::reapStale()`, `lib/BackgroundJob/FlowRunWorker.php:226-287`). -- [ ] 5.2 Branch-mootness call: when `keepOnlyTakenExits()` +- [x] 5.2 Branch-mootness call: when `keepOnlyTakenExits()` (`FlowEngine.php:410`, `:540`) prunes a place holding a live user-task node's task, terminate it with a reason naming the branch. Never reaches a task with `run_uuid` null. ## 6. Tests -- [ ] 6.1 Node unit tests: one task per node per run across a heartbeat wake; +- [x] 6.1 Node unit tests: one task per node per run across a heartbeat wake; empty firing creates nothing and does not suspend; claim is not completion; terminality read from the task and never from the signal slot; asked-at not restamped; two nodes in one flow requiring two completions. -- [ ] 6.2 Config-validation table: `advance` accepting `0`, `3`, `"all"` and +- [x] 6.2 Config-validation table: `advance` accepting `0`, `3`, `"all"` and REFUSING `null`, `""`, `-1` and `"unlimited"`, each with the value in the message; a config with no resolvable performer refused. -- [ ] 6.3 Budget and oversight tests: `0` leaves the run suspended-and-due; +- [x] 6.3 Budget and oversight tests: `0` leaves the run suspended-and-due; `"all"` finishes the run in-request; a vetoing oversight check stops the in-request walk with the check id; an injected downstream throw leaves the task completed and the run advanceable. -- [ ] 6.4 Propagation tests: stopping a run terminates both its tasks with a +- [x] 6.4 Propagation tests: stopping a run terminates both its tasks with a reason; a losing parallel branch takes its task with it; a second observation of terminality records nothing; a `run_uuid`-null task is untouched. -- [ ] 6.5 Playwright coverage for the eight `@e2e`-marked scenarios in +- [x] 6.5 Playwright coverage for the eight `@e2e`-marked scenarios in `specs/flow-user-task-node/spec.md`, including the negative one: a caller who may run the flow but is not the performer CANNOT answer the task through `POST /api/flow-runs/{uuid}/resume`. diff --git a/tests/Unit/Service/Flow/FlowAdvanceBudgetTest.php b/tests/Unit/Service/Flow/FlowAdvanceBudgetTest.php new file mode 100644 index 0000000000..7f4777edbf --- /dev/null +++ b/tests/Unit/Service/Flow/FlowAdvanceBudgetTest.php @@ -0,0 +1,97 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Service\Flow\FlowAdvanceBudget; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use UnexpectedValueException; + +/** + * The three spellings of the advance budget, and everything that is refused. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ +class FlowAdvanceBudgetTest extends TestCase { + + /** + * @return array + */ + public static function accepted(): array { + return [ + 'zero' => [0, 0, 0], + 'three' => [3, 3, 3], + 'a numeric string' => ['3', 3, 3], + 'all' => ['all', null, 'all'], + 'ALL, trimmed' => [' ALL ', null, 'all'], + ]; + }//end accepted() + + #[DataProvider('accepted')] + public function testTheThreeShapesAreAccepted(mixed $value, ?int $transitions, int|string $stored): void { + $budget = FlowAdvanceBudget::fromValue(value: $value); + + $this->assertSame($transitions, $budget->transitions()); + $this->assertSame($stored, $budget->toStored()); + $this->assertSame($transitions === null, $budget->isUnlimited()); + $this->assertSame($transitions !== 0, $budget->advancesInRequest()); + }//end testTheThreeShapesAreAccepted() + + /** + * @return array + */ + public static function refused(): array { + return [ + 'null' => [null, 'null'], + 'empty string' => ['', "''"], + 'minus one' => [-1, '-1'], + 'unlimited' => ['unlimited', 'unlimited'], + 'a fraction' => [1.5, '1.5'], + 'a boolean' => [true, 'true'], + ]; + }//end refused() + + /** + * Every refusal names the value it refused, so the author sees what they + * wrote rather than a generic complaint. + */ + #[DataProvider('refused')] + public function testEverythingElseIsRefusedNamingTheValue(mixed $value, string $named): void { + try { + FlowAdvanceBudget::fromValue(value: $value); + $this->fail('Expected the budget to be refused.'); + } catch (UnexpectedValueException $refusal) { + $this->assertStringContainsString($named, $refusal->getMessage()); + $this->assertStringContainsString('"all"', $refusal->getMessage(), 'the refusal states the spelling of unlimited'); + } + }//end testEverythingElseIsRefusedNamingTheValue() + + /** + * Design D-4: null is not "unlimited" and not "absent". It is refused, + * and the message says why. + */ + public function testNullIsRefusedAsNullNotReadAsUnlimited(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/advance is null/'); + + FlowAdvanceBudget::fromConfig(config: ['advance' => null]); + }//end testNullIsRefusedAsNullNotReadAsUnlimited() + + /** + * An ABSENT key is the default, and the default is zero. This is the + * distinction `??` cannot make and the reason fromConfig takes the array. + */ + public function testAnAbsentBudgetIsZero(): void { + $budget = FlowAdvanceBudget::fromConfig(config: ['title' => 'x']); + + $this->assertSame(0, $budget->transitions()); + $this->assertFalse($budget->advancesInRequest()); + }//end testAnAbsentBudgetIsZero() +}//end class diff --git a/tests/Unit/Service/Flow/FlowEngineBranchPruningTest.php b/tests/Unit/Service/Flow/FlowEngineBranchPruningTest.php new file mode 100644 index 0000000000..0e984f70dc --- /dev/null +++ b/tests/Unit/Service/Flow/FlowEngineBranchPruningTest.php @@ -0,0 +1,129 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Service\Flow\FlowDefinitionBuilder; +use OCA\OpenRegister\Service\Flow\FlowEngine; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowStepDispatcher; +use OCA\OpenRegister\Service\Flow\FlowTaskMootness; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; + +/** + * Holds the marking for one walk. + */ +class PruningSubject { + public array $marking = []; +}//end class + +/** + * Records which steps ran. + */ +class PruningDispatcher implements FlowStepDispatcher { + public array $ran = []; + + public function dispatch(array $step, array $items, array $context): array { + $this->ran[] = (string)($step['id'] ?? ''); + + return $items; + }//end dispatch() +}//end class + +/** + * The branch-pruning report: a routing decision that clears a place tells the + * mootness collaborator which places, and which transition decided. The + * single-stream walk and the stream walk's firing route through the same + * wrapper, so one hook covers both. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-task-whose-run-or-branch-has-died-is-terminated-not-orphaned + */ +class FlowEngineBranchPruningTest extends TestCase { + + private function linearFlow(): array { + return [ + 'id' => 'linear', + 'nodes' => [ + ['id' => 'a', 'type' => 'openregister.set-fields'], + ['id' => 'b', 'type' => 'openregister.set-fields'], + ], + 'edges' => [['id' => 'ab', 'from' => 'a', 'to' => 'b']], + ]; + }//end linearFlow() + + private function switchFlow(): array { + return [ + 'id' => 'switch', + 'nodes' => [ + [ + 'id' => 's', + 'type' => 'switch', + 'exits' => [ + ['id' => 'high', 'condition' => ['>' => [['var' => 'json.n'], 10]]], + ['id' => 'low'], + ], + ], + ['id' => 'hi', 'type' => 'high'], + ['id' => 'lo', 'type' => 'openregister.user-task'], + ], + 'edges' => [ + ['id' => 'toHigh', 'from' => 's', 'fromExit' => 'high', 'to' => 'hi'], + ['id' => 'toLow', 'from' => 's', 'fromExit' => 'low', 'to' => 'lo'], + ], + ]; + }//end switchFlow() + + private function walk(FlowEngine $engine, PruningDispatcher $dispatcher, array $context, array $flow): array { + return $engine->run( + flow: $flow, + store: new MethodMarkingStore(false, 'marking'), + subject: new PruningSubject(), + dispatcher: $dispatcher, + context: $context, + items: [FlowItems::item(json: ['n' => 42])] + ); + }//end walk() + + /** + * A routing decision that clears a place is REPORTED, with the cleared + * places and the transition that decided, so a user task waiting on one + * of them can be terminated. + */ + public function testPrunedExitsAreReportedToTheMootnessCollaborator(): void { + $mootness = $this->createMock(FlowTaskMootness::class); + $mootness->expects($this->once()) + ->method('placesPruned') + ->with($this->anything(), ['lo'], 's'); + + $engine = new FlowEngine(new FlowDefinitionBuilder(), new NullLogger(), null, null, null, null, $mootness); + + $state = new FlowResumeState(); + $state->forNode(nodeId: 'lo')->set(key: 'taskUuid', value: 't-lo'); + + $dispatcher = new PruningDispatcher(); + $this->walk($engine, $dispatcher, [FlowResumeState::CONTEXT_KEY => $state], $this->switchFlow()); + + $this->assertSame(['s', 'hi'], $dispatcher->ran); + }//end testPrunedExitsAreReportedToTheMootnessCollaborator() + + /** + * A linear flow prunes nothing, so nothing is reported. + */ + public function testALinearFlowReportsNoPruning(): void { + $mootness = $this->createMock(FlowTaskMootness::class); + $mootness->expects($this->never())->method('placesPruned'); + + $engine = new FlowEngine(new FlowDefinitionBuilder(), new NullLogger(), null, null, null, null, $mootness); + + $this->walk($engine, new PruningDispatcher(), [], $this->linearFlow()); + }//end testALinearFlowReportsNoPruning() +}//end class diff --git a/tests/Unit/Service/Flow/FlowTaskBridgeTest.php b/tests/Unit/Service/Flow/FlowTaskBridgeTest.php new file mode 100644 index 0000000000..fda18e879b --- /dev/null +++ b/tests/Unit/Service/Flow/FlowTaskBridgeTest.php @@ -0,0 +1,335 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowStream; +use OCA\OpenRegister\Db\FlowStreamMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunAdvancer; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * The bridge: provenance on creation, and the advance budget on completion. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-advance-budget-says-how-far-a-completion-may-push-the-run + */ +class FlowTaskBridgeTest extends TestCase { + + private TaskService&MockObject $tasks; + + private FlowRunMapper&MockObject $runs; + + private FlowRunService&MockObject $runService; + + private FlowRunAdvancer&MockObject $advancer; + + private FlowStreamMapper&MockObject $streams; + + private FlowTaskBridge $bridge; + + protected function setUp(): void { + $this->tasks = $this->createMock(TaskService::class); + $this->runs = $this->createMock(FlowRunMapper::class); + $this->runService = $this->createMock(FlowRunService::class); + $this->advancer = $this->createMock(FlowRunAdvancer::class); + $this->streams = $this->createMock(FlowStreamMapper::class); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id): object { + return match ($id) { + FlowRunService::class => $this->runService, + FlowRunAdvancer::class => $this->advancer, + FlowStreamMapper::class => $this->streams, + default => throw new RuntimeException('unexpected service ' . $id), + }; + } + ); + + $this->bridge = new FlowTaskBridge($this->tasks, $this->runs, $container, new NullLogger()); + }//end setUp() + + /** + * A suspended run whose node slot holds the given budget. + * + * @param mixed $advance The stored budget, or null for a slot without one. + * + * @return FlowRun The run. + */ + private function suspendedRun(mixed $advance): FlowRun { + $run = new FlowRun(); + $run->setUuid('run-1'); + $run->setStatus(FlowRun::STATUS_SUSPENDED); + $run->setFlowVersion(3); + $slot = ['taskUuid' => 't-1']; + if ($advance !== null) { + $slot[FlowTaskBridge::SLOT_ADVANCE] = $advance; + } + + $run->setContext([FlowResumeState::CONTEXT_KEY => ['ask' => $slot]]); + + return $run; + }//end suspendedRun() + + /** + * A live stream row standing on the given place. + */ + private function stream(string $id, string $place, string $status = FlowRun::STATUS_SUSPENDED): FlowStream { + $stream = new FlowStream(); + $stream->setRunUuid('run-1'); + $stream->setStreamId($id); + $stream->setPlace($place); + $stream->setStatus($status); + + return $stream; + }//end stream() + + private function terminalTask(?string $runUuid = 'run-1'): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setRunUuid($runUuid); + $task->setNodeId('ask'); + $task->setState(Task::STATE_COMPLETED); + $task->setIsTerminal(true); + $task->setOutcome('approved'); + + return $task; + }//end terminalTask() + + /** + * Make signal() behave: park the run as due and hand it back. + */ + private function signalParks(): void { + $this->runService->method('signal')->willReturnCallback( + static function (FlowRun $run): FlowRun { + $run->setResumeAt(new DateTime()); + + return $run; + } + ); + }//end signalParks() + + // ---- Creation --------------------------------------------------------------- + + /** + * The task carries the run, the node, the requester and the run's version + * pin. That provenance is what propagation and the completion find it by. + */ + public function testCreationStampsProvenanceAndTheVersionPin(): void { + $this->runs->method('findByUuid')->with('run-1')->willReturn($this->suspendedRun(advance: null)); + + $this->tasks->expects($this->once()) + ->method('import') + ->with( + $this->callback(function (array $data): bool { + $this->assertSame('run-1', $data['runUuid']); + $this->assertSame('ask', $data['nodeId']); + $this->assertSame('owner', $data['requester']); + $this->assertSame(3, $data['definitionVersion']); + + return true; + }), + 'owner' + ) + ->willReturn($this->terminalTask()); + $this->tasks->expects($this->never())->method('offer'); + + $this->bridge->createTask(data: ['title' => 'x', 'assignee' => 'alice'], runUuid: 'run-1', nodeId: 'ask', actor: 'owner'); + }//end testCreationStampsProvenanceAndTheVersionPin() + + /** + * A routing strategy with no direct assignee is resolved by OFFERING the + * task after creation, so the strategy runs now rather than at first claim. + */ + public function testARoutingStrategyWithoutAnAssigneeOffersTheTask(): void { + $this->runs->method('findByUuid')->willReturn($this->suspendedRun(advance: null)); + $this->tasks->method('import')->willReturn($this->terminalTask()); + $this->tasks->expects($this->once()) + ->method('offer') + ->with('t-1', ['routingStrategy' => 'or-set', 'routingFallback' => 'carol'], 'owner') + ->willReturn($this->terminalTask()); + + $this->bridge->createTask( + data: ['title' => 'x', 'candidateGroups' => ['finance'], 'routingStrategy' => 'or-set', 'routingFallback' => 'carol'], + runUuid: 'run-1', + nodeId: 'ask', + actor: 'owner' + ); + }//end testARoutingStrategyWithoutAnAssigneeOffersTheTask() + + public function testAVanishedTaskReadsAsNull(): void { + $this->tasks->method('get')->willThrowException(new DoesNotExistException('gone')); + + $this->assertNull($this->bridge->taskOrNull(uuid: 't-gone')); + }//end testAVanishedTaskReadsAsNull() + + // ---- Continuation and the budget ------------------------------------------ + + /** + * The default budget: the completion parks the run as due and returns. + * The advancer is never even resolved. + */ + public function testTheDefaultBudgetParksForTheWorker(): void { + $run = $this->suspendedRun(advance: null); + $this->runs->method('findByUuid')->willReturn($run); + $this->signalParks(); + $this->advancer->expects($this->never())->method('advanceStream'); + + $after = $this->bridge->continueRun(task: $this->terminalTask()); + + $this->assertSame($run, $after); + $this->assertSame(FlowRun::STATUS_SUSPENDED, $after->getStatus()); + $this->assertNotNull($after->getResumeAt(), 'the run must be due for the worker'); + }//end testTheDefaultBudgetParksForTheWorker() + + /** + * A budget of N continues THE STREAM parked on the node, for N + 1 + * firings (the node's own re-entry is the completion landing), through + * the one stream-scoped advance path. + */ + public function testABudgetOfNAdvancesTheParkedStreamInRequest(): void { + $run = $this->suspendedRun(advance: 3); + $this->runs->method('findByUuid')->willReturn($run); + $this->signalParks(); + $this->streams->method('findByRun')->with('run-1')->willReturn([ + $this->stream('s-root', 'elsewhere'), + $this->stream('s-ask', 'ask'), + ]); + $this->advancer->expects($this->once()) + ->method('advanceStream') + ->with($run, 's-ask', 4) + ->willReturn($run); + + $this->bridge->continueRun(task: $this->terminalTask()); + }//end testABudgetOfNAdvancesTheParkedStreamInRequest() + + /** + * "all" is passed through as "all": the stream walk's natural stopping + * points (a suspension, another user task, an end) bound it. + */ + public function testABudgetOfAllIsPassedThroughToTheStream(): void { + $run = $this->suspendedRun(advance: 'all'); + $this->runs->method('findByUuid')->willReturn($run); + $this->signalParks(); + $this->streams->method('findByRun')->willReturn([$this->stream('s-ask', 'ask')]); + $this->advancer->expects($this->once()) + ->method('advanceStream') + ->with($run, 's-ask', 'all') + ->willReturn($run); + + $this->bridge->continueRun(task: $this->terminalTask()); + }//end testABudgetOfAllIsPassedThroughToTheStream() + + /** + * A terminal stream is not a branch to continue, and a run whose streams + * predate the node has nothing standing on it: both leave the run due + * for the worker rather than guessing a stream. + */ + public function testWithoutALiveStreamOnTheNodeTheRunIsLeftDueForTheWorker(): void { + $run = $this->suspendedRun(advance: 'all'); + $this->runs->method('findByUuid')->willReturn($run); + $this->signalParks(); + $this->streams->method('findByRun')->willReturn([$this->stream('s-ask', 'ask', FlowRun::STATUS_COMPLETED)]); + $this->advancer->expects($this->never())->method('advanceStream'); + + $after = $this->bridge->continueRun(task: $this->terminalTask()); + + $this->assertNotNull($after); + $this->assertNotNull($after->getResumeAt()); + }//end testWithoutALiveStreamOnTheNodeTheRunIsLeftDueForTheWorker() + + /** + * Design D-5: the budget is an optimisation, so its failure mode is the + * unoptimised behaviour. The task is committed already; the run stays due. + */ + public function testAFailedContinuationLeavesTheRunDueAndDoesNotThrow(): void { + $run = $this->suspendedRun(advance: 'all'); + $this->runs->method('findByUuid')->willReturn($run); + $this->signalParks(); + $this->streams->method('findByRun')->willReturn([$this->stream('s-ask', 'ask')]); + $this->advancer->method('advanceStream')->willThrowException(new RuntimeException('downstream step blew up')); + + $after = $this->bridge->continueRun(task: $this->terminalTask()); + + $this->assertNotNull($after); + $this->assertSame(FlowRun::STATUS_SUSPENDED, $after->getStatus()); + $this->assertNotNull($after->getResumeAt(), 'still due: the worker takes over'); + }//end testAFailedContinuationLeavesTheRunDueAndDoesNotThrow() + + /** + * A stored budget that does not read is the default, never "unlimited": + * parking is the safe direction. + */ + public function testAnUnreadableStoredBudgetParksForTheWorker(): void { + $run = $this->suspendedRun(advance: 'whenever'); + $this->runs->method('findByUuid')->willReturn($run); + $this->signalParks(); + $this->advancer->expects($this->never())->method('advanceStream'); + + $this->bridge->continueRun(task: $this->terminalTask()); + }//end testAnUnreadableStoredBudgetParksForTheWorker() + + /** + * A run that is not suspended (mid-walk, or terminal) cannot be woken; the + * walk itself reads the task, or nothing is owed. + */ + public function testARunThatIsNotSuspendedIsLeftAlone(): void { + $run = $this->suspendedRun(advance: 'all'); + $run->setStatus(FlowRun::STATUS_RUNNING); + $this->runs->method('findByUuid')->willReturn($run); + $this->runService->method('signal')->willReturn(null); + $this->advancer->expects($this->never())->method('advanceStream'); + + $this->assertNull($this->bridge->continueRun(task: $this->terminalTask())); + }//end testARunThatIsNotSuspendedIsLeftAlone() + + public function testATaskWithoutARunIsNobodysBusinessHere(): void { + $this->runs->expects($this->never())->method('findByUuid'); + + $this->assertNull($this->bridge->continueRun(task: $this->terminalTask(runUuid: null))); + }//end testATaskWithoutARunIsNobodysBusinessHere() + + public function testAMissingRunIsLoggedNotThrown(): void { + $this->runs->method('findByUuid')->willThrowException(new DoesNotExistException('gone')); + + $this->assertNull($this->bridge->continueRun(task: $this->terminalTask())); + }//end testAMissingRunIsLoggedNotThrown() + + // ---- The outcome bag ---------------------------------------------------------- + + public function testTheBagSeparatesADecisionFromAnEnding(): void { + $decided = $this->terminalTask(); + $decided->setOutcome('rejected'); + $decided->setComment('no'); + $bag = FlowTaskBridge::outcomeBagFor(task: $decided); + $this->assertTrue($bag['decided']); + $this->assertTrue($bag['rejected']); + + $ended = $this->terminalTask(); + $ended->setState(Task::STATE_TERMINATED); + $ended->setOutcome(null); + $bag = FlowTaskBridge::outcomeBagFor(task: $ended); + $this->assertFalse($bag['decided']); + $this->assertFalse($bag['rejected']); + $this->assertSame(Task::STATE_TERMINATED, $bag['outcome'], 'an ending with no outcome reports its state'); + }//end testTheBagSeparatesADecisionFromAnEnding() +}//end class diff --git a/tests/Unit/Service/Flow/FlowTaskMootnessTest.php b/tests/Unit/Service/Flow/FlowTaskMootnessTest.php new file mode 100644 index 0000000000..416a98fb00 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowTaskMootnessTest.php @@ -0,0 +1,166 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Flow\FlowGraph; +use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowTaskMootness; +use OCA\OpenRegister\Service\Task\TaskService; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * A losing branch takes its task with it; a run-less task is never touched. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-task-whose-run-or-branch-has-died-is-terminated-not-orphaned + */ +class FlowTaskMootnessTest extends TestCase { + + private TaskService&MockObject $tasks; + + private FlowTaskMootness $mootness; + + protected function setUp(): void { + $this->tasks = $this->createMock(TaskService::class); + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->with(TaskService::class)->willReturn($this->tasks); + + $this->mootness = new FlowTaskMootness($container, new NullLogger()); + }//end setUp() + + private function task(?string $runUuid): Task { + $task = new Task(); + $task->setUuid('t-lo'); + $task->setRunUuid($runUuid); + $task->setNodeId('lo'); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + + return $task; + }//end task() + + private function context(FlowResumeState $state): array { + return [ + FlowResumeState::CONTEXT_KEY => $state, + FlowRunContext::CONTEXT_RUN => 'run-1', + ]; + }//end context() + + /** + * The node on the cleared place had asked somebody: its task is + * terminated with a reason naming the branch, and its slot is cleared so a + * later re-entry asks afresh. + */ + public function testALosingBranchTakesItsTaskWithIt(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'lo')->set(key: 'taskUuid', value: 't-lo'); + $state->forNode(nodeId: 'elsewhere')->set(key: 'taskUuid', value: 't-else'); + + $this->tasks->method('get')->with('t-lo')->willReturn($this->task(runUuid: 'run-1')); + $this->tasks->expects($this->once()) + ->method('terminateAsMoot') + ->with( + 't-lo', + $this->callback(function (string $reason): bool { + $this->assertStringContainsString("'s'", $reason, 'names the deciding transition'); + $this->assertStringContainsString("'lo'", $reason, 'names the node'); + $this->assertStringContainsString('run-1', $reason); + + return true; + }), + 'flow-run:run-1' + ) + ->willReturn($this->task(runUuid: 'run-1')); + + $count = $this->mootness->placesPruned(context: $this->context($state), places: ['lo'], byTransition: 's'); + + $this->assertSame(1, $count); + $this->assertSame([], $state->read(nodeId: 'lo'), 'the slot is cleared'); + $this->assertSame('t-else', $state->read(nodeId: 'elsewhere')['taskUuid'], 'other nodes are untouched'); + }//end testALosingBranchTakesItsTaskWithIt() + + /** + * A join's per-edge place is `node#edge`; pruning it is pruning the node. + */ + public function testAJoinPlaceIsRecognisedAsTheNodes(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'lo')->set(key: 'taskUuid', value: 't-lo'); + $this->tasks->method('get')->willReturn($this->task(runUuid: 'run-1')); + $this->tasks->expects($this->once())->method('terminateAsMoot')->willReturn($this->task(runUuid: 'run-1')); + + $this->mootness->placesPruned(context: $this->context($state), places: ['lo' . FlowGraph::PLACE_JOIN . 'e1'], byTransition: 's'); + }//end testAJoinPlaceIsRecognisedAsTheNodes() + + /** + * Propagation SHALL NEVER reach a task that carries no run uuid. + */ + public function testARunlessTaskIsNeverTouched(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'lo')->set(key: 'taskUuid', value: 't-lo'); + $this->tasks->method('get')->willReturn($this->task(runUuid: null)); + $this->tasks->expects($this->never())->method('terminateAsMoot'); + + $count = $this->mootness->placesPruned(context: $this->context($state), places: ['lo'], byTransition: 's'); + + $this->assertSame(0, $count); + $this->assertSame('t-lo', $state->read(nodeId: 'lo')['taskUuid'], 'the slot is kept when nothing was terminated'); + }//end testARunlessTaskIsNeverTouched() + + /** + * A task belonging to a different run than the one walking is stale + * evidence, not ours to terminate. + */ + public function testAnotherRunsTaskIsNeverTouched(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'lo')->set(key: 'taskUuid', value: 't-lo'); + $this->tasks->method('get')->willReturn($this->task(runUuid: 'run-other')); + $this->tasks->expects($this->never())->method('terminateAsMoot'); + + $this->mootness->placesPruned(context: $this->context($state), places: ['lo'], byTransition: 's'); + }//end testAnotherRunsTaskIsNeverTouched() + + /** + * A node whose place was NOT cleared keeps its task. + */ + public function testAPlaceThatWasNotClearedIsLeftAlone(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'lo')->set(key: 'taskUuid', value: 't-lo'); + $this->tasks->expects($this->never())->method('terminateAsMoot'); + + $count = $this->mootness->placesPruned(context: $this->context($state), places: ['hi'], byTransition: 's'); + + $this->assertSame(0, $count); + }//end testAPlaceThatWasNotClearedIsLeftAlone() + + /** + * Task bookkeeping must never fail a hop: a throwing service is logged + * and the walk carries on. + */ + public function testATaskLayerFailureNeverFailsTheHop(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'lo')->set(key: 'taskUuid', value: 't-lo'); + $this->tasks->method('get')->willThrowException(new RuntimeException('database away')); + + $count = $this->mootness->placesPruned(context: $this->context($state), places: ['lo'], byTransition: 's'); + + $this->assertSame(0, $count); + }//end testATaskLayerFailureNeverFailsTheHop() + + public function testWithoutAResumeStateNothingHappens(): void { + $this->tasks->expects($this->never())->method('get'); + + $this->assertSame(0, $this->mootness->placesPruned(context: [], places: ['lo'], byTransition: 's')); + }//end testWithoutAResumeStateNothingHappens() +}//end class diff --git a/tests/Unit/Service/Flow/UserTaskCancellationPropagationTest.php b/tests/Unit/Service/Flow/UserTaskCancellationPropagationTest.php new file mode 100644 index 0000000000..b6eb9c5955 --- /dev/null +++ b/tests/Unit/Service/Flow/UserTaskCancellationPropagationTest.php @@ -0,0 +1,144 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Listener\TaskRunTerminalListener; +use OCA\OpenRegister\Listener\UserTaskTerminalListener; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCA\OpenRegister\Service\Task\TaskPerformerResolver; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * Cancellation propagation from the run onto its tasks, observed twice; + * and the completion listener's contract with the bridge. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-task-whose-run-or-branch-has-died-is-terminated-not-orphaned + */ +class UserTaskCancellationPropagationTest extends TestCase { + + private function openTask(string $uuid, string $assignee): Task { + $task = new Task(); + $task->setId(crc32($uuid)); + $task->setUuid($uuid); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee($assignee); + $task->setRunUuid('run-9'); + $task->setNodeId('ask-' . $assignee); + + return $task; + }//end openTask() + + /** + * Stopping a run with two open tasks for two people terminates both with + * a reason naming the run, and a SECOND observation of the same + * terminality records nothing more. + */ + public function testStoppingARunTerminatesItsTasksOnceAcrossTwoObservations(): void { + $tasks = $this->createMock(TaskMapper::class); + $first = $this->openTask('t-a', 'alice'); + $second = $this->openTask('t-b', 'bob'); + + // First observation finds both open; the second, after they were + // terminated, finds none. That is the mapper predicate doing the + // idempotence, and it is what makes a re-fired event harmless. + $tasks->expects($this->exactly(2)) + ->method('findOpenByRunUuid') + ->with('run-9') + ->willReturnOnConsecutiveCalls([$first, $second], []); + $tasks->method('update')->willReturnArgument(0); + $tasks->method('updateIfOpen')->willReturn(true); + + $audits = $this->createMock(TaskAuditMapper::class); + $entries = []; + $audits->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$entries): TaskAudit { + $entries[] = $entry; + + return $entry; + } + ); + + $service = new TaskService( + tasks: $tasks, + candidates: $this->createMock(TaskCandidateMapper::class), + relations: $this->createMock(TaskRelationMapper::class), + audits: $audits, + authorization: $this->createMock(TaskAuthorizationService::class), + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->createMock(IDBConnection::class), + logger: new NullLogger(), + builder: new TaskBuilder() + ); + $listener = new TaskRunTerminalListener($service, new NullLogger()); + + $listener->handle(new FlowRunTerminalEvent(runUuid: 'run-9', status: 'stopped')); + $listener->handle(new FlowRunTerminalEvent(runUuid: 'run-9', status: 'stopped')); + + $this->assertSame(Task::STATE_TERMINATED, $first->getState()); + $this->assertSame(Task::STATE_TERMINATED, $second->getState()); + $this->assertTrue($first->getIsTerminal()); + $this->assertCount(2, $entries, 'two tasks, two termination entries, and NOT four'); + foreach ($entries as $entry) { + $this->assertSame('terminate', $entry->getAction()); + $this->assertSame('flow-run:run-9', $entry->getActor()); + $this->assertStringContainsString('run-9', (string)$entry->getReason()); + $this->assertStringContainsString('stopped', (string)$entry->getReason()); + } + }//end testStoppingARunTerminatesItsTasksOnceAcrossTwoObservations() + + /** + * The completion listener hands a run-bound terminal task to the bridge, + * ignores a standalone one, and never lets a bridge failure escape: the + * completion is already committed and the caller must be told so. + */ + public function testTheCompletionListenerContinuesRunBoundTasksOnly(): void { + $bridge = $this->createMock(FlowTaskBridge::class); + $listener = new UserTaskTerminalListener($bridge, new NullLogger()); + + $bound = $this->openTask('t-a', 'alice'); + $bound->setState(Task::STATE_COMPLETED); + $bound->setIsTerminal(true); + + $standalone = $this->openTask('t-s', 'carol'); + $standalone->setRunUuid(null); + $standalone->setState(Task::STATE_COMPLETED); + + $bridge->expects($this->once())->method('continueRun')->with($bound); + + $listener->handle(new TaskTerminalEvent(task: $bound)); + $listener->handle(new TaskTerminalEvent(task: $standalone)); + }//end testTheCompletionListenerContinuesRunBoundTasksOnly() + + public function testABridgeFailureIsSwallowedByTheListener(): void { + $bridge = $this->createMock(FlowTaskBridge::class); + $bridge->method('continueRun')->willThrowException(new RuntimeException('engine away')); + $listener = new UserTaskTerminalListener($bridge, new NullLogger()); + + $listener->handle(new TaskTerminalEvent(task: $this->openTask('t-a', 'alice'))); + + $this->addToAssertionCount(1); + }//end testABridgeFailureIsSwallowedByTheListener() +}//end class diff --git a/tests/Unit/Service/Flow/UserTaskNodeTest.php b/tests/Unit/Service/Flow/UserTaskNodeTest.php new file mode 100644 index 0000000000..c10d495cad --- /dev/null +++ b/tests/Unit/Service/Flow/UserTaskNodeTest.php @@ -0,0 +1,587 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; +use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\FlowStop; +use OCA\OpenRegister\Service\Flow\FlowSuspension; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\WorkflowEngine\IManager; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use UnexpectedValueException; + +/** + * The user-task node: one task per node per run, resume on terminality, + * outcome onto every item, rejection as a branch. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md + */ +class UserTaskNodeTest extends TestCase { + + private FlowTaskBridge&MockObject $bridge; + + private UserTaskNode $node; + + protected function setUp(): void { + $this->bridge = $this->createMock(FlowTaskBridge::class); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static function (string $text, array $parameters = []): string { + if ($parameters === []) { + return $text; + } + + return vsprintf($text, $parameters); + } + ); + + $this->node = new UserTaskNode($this->bridge, $l10n, $this->createMock(IURLGenerator::class)); + }//end setUp() + + /** + * A minimal valid configuration. + * + * @param array $overrides Keys to add or replace. + * + * @return array The config. + */ + private function config(array $overrides = []): array { + return array_merge(['title' => 'Approve {{ name }}', 'assignee' => 'alice'], $overrides); + }//end config() + + /** + * A run context with this node scoped to its own slot. + * + * @param FlowResumeState $state The run's resume state. + * @param string $nodeId The node being dispatched. + * @param array $extra Extra context keys. + * + * @return array The context. + */ + private function context(FlowResumeState $state, string $nodeId = 'ask', array $extra = []): array { + return array_merge( + [ + FlowResumeState::CONTEXT_KEY => $state, + FlowNodeResumeState::CONTEXT_KEY => $state->forNode(nodeId: $nodeId), + FlowRunContext::CONTEXT_RUN => 'run-1', + 'runUuid' => 'run-1', + 'runAs' => 'owner', + ], + $extra + ); + }//end context() + + private function items(): array { + return [FlowItems::item(json: ['name' => 'Case 7', '@self' => ['uuid' => 'obj-7', 'register' => 3, 'schema' => 9]])]; + }//end items() + + private function task(string $state, string $uuid = 't-1'): Task { + $task = new Task(); + $task->setUuid($uuid); + $task->setState($state); + $task->setIsTerminal(in_array($state, Task::TERMINAL_STATES, true)); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('alice'); + $task->setRunUuid('run-1'); + $task->setNodeId('ask'); + + return $task; + }//end task() + + // ---- Creation and suspension ------------------------------------------ + + /** + * The first firing creates exactly one task, stamped with run and node, + * and suspends with a heartbeat that is NOT null. + */ + public function testTheFirstFiringCreatesOneTaskAndSuspendsWithAHeartbeat(): void { + $state = new FlowResumeState(); + $created = $this->task(state: Task::STATE_ACTIVE); + + $this->bridge->expects($this->once()) + ->method('createTask') + ->with( + $this->callback(function (array $data): bool { + $this->assertSame('Approve Case 7', $data['title'], 'the title is templated against the item'); + $this->assertSame('alice', $data['assignee']); + $this->assertSame(Task::STATE_ACTIVE, $data['state'], 'a directly assigned task is created active'); + $this->assertSame('obj-7', $data['objectUuid']); + $this->assertSame(3, $data['registerId']); + $this->assertSame('task', $data['metadata']['outcomeKey']); + + return true; + }), + 'run-1', + 'ask', + 'owner' + ) + ->willReturn($created); + + try { + $this->node->execute($this->items(), $this->config(), $this->context($state)); + $this->fail('Expected the node to suspend.'); + } catch (FlowSuspension $suspension) { + $this->assertNotNull($suspension->getResumeAt(), 'a null resumeAt is the one shape the 14-day reaper fails'); + $this->assertGreaterThan(new DateTime(), $suspension->getResumeAt()); + $this->assertStringContainsString('Approve Case 7', $suspension->getMessage()); + } + + $slot = $state->read(nodeId: 'ask'); + $this->assertSame('t-1', $slot[FlowTaskBridge::SLOT_TASK_UUID]); + $this->assertArrayHasKey(FlowTaskBridge::SLOT_ASKED_AT, $slot); + $this->assertSame(0, $slot[FlowTaskBridge::SLOT_ADVANCE], 'the default budget is stored as 0'); + $this->assertSame('alice', $slot['assignee']); + }//end testTheFirstFiringCreatesOneTaskAndSuspendsWithAHeartbeat() + + /** + * A firing with no items creates nothing and does not suspend. + */ + public function testAnEmptyBranchCreatesNothingAndDoesNotSuspend(): void { + $this->bridge->expects($this->never())->method('createTask'); + + $out = $this->node->execute([], $this->config(), $this->context(new FlowResumeState())); + + $this->assertSame([], $out); + }//end testAnEmptyBranchCreatesNothingAndDoesNotSuspend() + + /** + * The heartbeat re-enters the node with the task still open. It must NOT + * create a second task, and it must NOT restamp askedAt. + */ + public function testAHeartbeatWakeCreatesNoSecondTaskAndKeepsAskedAt(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->merge( + values: [ + FlowTaskBridge::SLOT_TASK_UUID => 't-1', + FlowTaskBridge::SLOT_ASKED_AT => '2026-01-01T00:00:00+00:00', + ] + ); + + $this->bridge->expects($this->never())->method('createTask'); + $this->bridge->method('taskOrNull')->with('t-1')->willReturn($this->task(state: Task::STATE_ENABLED)); + + try { + $this->node->execute($this->items(), $this->config(), $this->context($state)); + $this->fail('Expected the node to suspend again.'); + } catch (FlowSuspension $suspension) { + $this->assertNotNull($suspension->getResumeAt()); + } + + $this->assertSame( + '2026-01-01T00:00:00+00:00', + $state->read(nodeId: 'ask')[FlowTaskBridge::SLOT_ASKED_AT], + 'a heartbeat that restamps askedAt makes every long wait read as minutes old' + ); + }//end testAHeartbeatWakeCreatesNoSecondTaskAndKeepsAskedAt() + + /** + * A claim moves the task to active with an assignee. It is not an answer. + */ + public function testAClaimIsNotACompletion(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $claimed = $this->task(state: Task::STATE_ACTIVE); + $claimed->setAssignee('bob'); + $claimed->setLastAction('claim'); + $this->bridge->method('taskOrNull')->willReturn($claimed); + + $this->expectException(FlowSuspension::class); + + $this->node->execute($this->items(), $this->config(), $this->context($state)); + }//end testAClaimIsNotACompletion() + + /** + * The resume endpoint writes a decision into context.signal. This node + * MUST NOT read it: the task is the only source of an answer. + */ + public function testASignalWithADecisionDoesNotAnswerForThePerformer(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $this->bridge->method('taskOrNull')->willReturn($this->task(state: Task::STATE_ACTIVE)); + + $this->expectException(FlowSuspension::class); + + $this->node->execute( + $this->items(), + $this->config(), + $this->context($state, extra: [FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'approve', 'by' => 'stranger']]) + ); + }//end testASignalWithADecisionDoesNotAnswerForThePerformer() + + /** + * Without a resume slot there is nowhere to record the task, so every + * heartbeat would create one. Refusing is the safe direction. + */ + public function testANodeWithoutAResumeSlotRefusesToRun(): void { + $this->bridge->expects($this->never())->method('createTask'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/resume slot/'); + + $this->node->execute($this->items(), $this->config(), ['runUuid' => 'run-1']); + }//end testANodeWithoutAResumeSlotRefusesToRun() + + /** + * A task row that vanished is neither an answer nor something to wait on. + */ + public function testAVanishedTaskFailsTheStepRatherThanInventingAnAnswer(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-gone'); + $this->bridge->method('taskOrNull')->willReturn(null); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/t-gone/'); + + $this->node->execute($this->items(), $this->config(), $this->context($state)); + }//end testAVanishedTaskFailsTheStepRatherThanInventingAnAnswer() + + // ---- Continuation and outcome placement -------------------------------- + + /** + * A completed task lets the items through, each carrying the outcome bag + * under json.task so a Switch can branch on json.task.outcome. + */ + public function testACompletedTaskContinuesWithTheOutcomeOnEveryItem(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $done = $this->task(state: Task::STATE_COMPLETED); + $done->setOutcome('approved'); + $done->setComment('Looks fine'); + $done->setCompletedBy('alice'); + $this->bridge->method('taskOrNull')->willReturn($done); + + $out = $this->node->execute( + [FlowItems::item(json: ['id' => 1]), FlowItems::item(json: ['id' => 2])], + $this->config(), + $this->context($state) + ); + + $this->assertCount(2, $out); + foreach ($out as $item) { + $bag = $item[FlowItems::JSON]['task']; + $this->assertSame('approved', $bag['outcome']); + $this->assertTrue($bag['decided']); + $this->assertFalse($bag['rejected']); + $this->assertSame('Looks fine', $bag['comment']); + $this->assertSame('alice', $bag['completedBy']); + $this->assertSame(Task::PERFORMER_USER, $bag['performerType']); + $this->assertNull($bag['onBehalfOf']); + } + }//end testACompletedTaskContinuesWithTheOutcomeOnEveryItem() + + /** + * A delegated completion names both identities: the deputy who acted and + * the person they acted for. A four-eyes rule cannot be enforced without + * the difference. + */ + public function testADelegatedCompletionNamesBothIdentities(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $done = $this->task(state: Task::STATE_COMPLETED); + $done->setOutcome('approved'); + $done->setAssignee('deputy'); + $done->setOnBehalfOf('manager'); + $done->setMandate('holiday cover'); + $done->setCompletedBy('deputy'); + $this->bridge->method('taskOrNull')->willReturn($done); + + $out = $this->node->execute($this->items(), $this->config(), $this->context($state)); + + $bag = $out[0][FlowItems::JSON]['task']; + $this->assertSame('deputy', $bag['completedBy']); + $this->assertSame('manager', $bag['onBehalfOf']); + $this->assertSame('holiday cover', $bag['mandate']); + }//end testADelegatedCompletionNamesBothIdentities() + + /** + * Where the outcome lands is configurable, and a non-array item is left + * alone rather than failing the run. + */ + public function testTheOutcomeKeyIsConfigurableAndNonArrayItemsAreSkipped(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $done = $this->task(state: Task::STATE_COMPLETED); + $done->setOutcome('done'); + $this->bridge->method('taskOrNull')->willReturn($done); + + $out = $this->node->execute( + [FlowItems::item(json: ['id' => 1]), 'not an item'], + $this->config(['outcomeKey' => 'legalReview']), + $this->context($state) + ); + + $this->assertSame('done', $out[0][FlowItems::JSON]['legalReview']['outcome']); + $this->assertArrayNotHasKey('task', $out[0][FlowItems::JSON]); + $this->assertSame('not an item', $out[1]); + }//end testTheOutcomeKeyIsConfigurableAndNonArrayItemsAreSkipped() + + // ---- Rejection is a branch ---------------------------------------------- + + /** + * By default a rejection continues, marked as rejected, so the author + * routes on it. The run is not failed. + */ + public function testARejectionCarriesOnByDefault(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $done = $this->task(state: Task::STATE_COMPLETED); + $done->setOutcome('rejected'); + $done->setComment('Missing signature'); + $this->bridge->method('taskOrNull')->willReturn($done); + + $out = $this->node->execute($this->items(), $this->config(), $this->context($state)); + + $this->assertTrue($out[0][FlowItems::JSON]['task']['rejected']); + $this->assertTrue($out[0][FlowItems::JSON]['task']['decided']); + }//end testARejectionCarriesOnByDefault() + + /** + * Opting in turns a rejection into a deliberate stop naming the reason. + */ + public function testAFlowThatOptsInStopsOnARejection(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $done = $this->task(state: Task::STATE_COMPLETED); + $done->setOutcome('rejected'); + $done->setComment('Missing signature'); + $this->bridge->method('taskOrNull')->willReturn($done); + + try { + $this->node->execute($this->items(), $this->config(['failOnReject' => true]), $this->context($state)); + $this->fail('Expected a FlowStop.'); + } catch (FlowStop $stop) { + $this->assertTrue($stop->isError()); + $this->assertStringContainsString('Missing signature', $stop->getMessage()); + } + }//end testAFlowThatOptsInStopsOnARejection() + + /** + * A task that was terminated (expiry, cancellation) is terminal but is not + * a decision. The bag says so, and it never counts as a rejection, so + * failOnReject does not fire on it. + */ + public function testATerminatedTaskIsDistinguishableFromARejection(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $ended = $this->task(state: Task::STATE_TERMINATED); + $ended->setOutcome('expired'); + $this->bridge->method('taskOrNull')->willReturn($ended); + + $out = $this->node->execute($this->items(), $this->config(['failOnReject' => true]), $this->context($state)); + + $bag = $out[0][FlowItems::JSON]['task']; + $this->assertFalse($bag['decided']); + $this->assertFalse($bag['rejected']); + $this->assertSame('expired', $bag['outcome']); + $this->assertSame(Task::STATE_TERMINATED, $bag['state']); + }//end testATerminatedTaskIsDistinguishableFromARejection() + + // ---- Independence of several nodes ------------------------------------- + + /** + * Two sequential approvals need two answers: completing the first node's + * task does not answer the second, which creates a task of its own. + */ + public function testTwoNodesInOneFlowKeepIndependentState(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'first')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-first'); + $firstDone = $this->task(state: Task::STATE_COMPLETED, uuid: 't-first'); + $firstDone->setOutcome('approved'); + + $this->bridge->method('taskOrNull')->with('t-first')->willReturn($firstDone); + $this->bridge->expects($this->once()) + ->method('createTask') + ->with($this->anything(), 'run-1', 'second', 'owner') + ->willReturn($this->task(state: Task::STATE_ACTIVE, uuid: 't-second')); + + $afterFirst = $this->node->execute($this->items(), $this->config(), $this->context($state, nodeId: 'first')); + $this->assertSame('approved', $afterFirst[0][FlowItems::JSON]['task']['outcome']); + + try { + $this->node->execute($afterFirst, $this->config(), $this->context($state, nodeId: 'second')); + $this->fail('The second node must ask its own question.'); + } catch (FlowSuspension) { + // Expected. + } + + $this->assertSame('t-second', $state->read(nodeId: 'second')[FlowTaskBridge::SLOT_TASK_UUID]); + $this->assertSame('t-first', $state->read(nodeId: 'first')[FlowTaskBridge::SLOT_TASK_UUID], 'one node cannot overwrite another'); + }//end testTwoNodesInOneFlowKeepIndependentState() + + // ---- Heartbeat ------------------------------------------------------------ + + public function testAHeartbeatBelowTheCronPeriodIsClamped(): void { + $this->bridge->method('createTask')->willReturn($this->task(state: Task::STATE_ACTIVE)); + + try { + $this->node->execute($this->items(), $this->config(['heartbeatMinutes' => 1]), $this->context(new FlowResumeState())); + $this->fail('Expected the node to suspend.'); + } catch (FlowSuspension $suspension) { + $this->assertGreaterThan((new DateTime())->modify('+4 minutes'), $suspension->getResumeAt()); + } + }//end testAHeartbeatBelowTheCronPeriodIsClamped() + + /** + * The budget the node was saved with is what the completion listener will + * read, so it is stored in the slot at creation, normalised. + */ + public function testTheAdvanceBudgetIsStoredInTheSlotAtCreation(): void { + $state = new FlowResumeState(); + $this->bridge->method('createTask')->willReturn($this->task(state: Task::STATE_ACTIVE)); + + try { + $this->node->execute($this->items(), $this->config(['advance' => 'all']), $this->context($state)); + } catch (FlowSuspension) { + // Expected. + } + + $this->assertSame('all', $state->read(nodeId: 'ask')[FlowTaskBridge::SLOT_ADVANCE]); + }//end testTheAdvanceBudgetIsStoredInTheSlotAtCreation() + + // ---- Config validation ---------------------------------------------------- + + /** + * @return array + */ + public static function acceptedBudgets(): array { + return ['zero' => [0], 'three' => [3], 'all' => ['all']]; + }//end acceptedBudgets() + + #[DataProvider('acceptedBudgets')] + public function testAcceptedBudgetsValidate(mixed $advance): void { + $this->node->validateConfig($this->config(['advance' => $advance])); + $this->addToAssertionCount(1); + }//end testAcceptedBudgetsValidate() + + /** + * @return array + */ + public static function refusedBudgets(): array { + return [ + 'null' => [null, 'null'], + 'empty string' => ['', "''"], + 'minus one' => [-1, '-1'], + 'unlimited' => ['unlimited', 'unlimited'], + ]; + }//end refusedBudgets() + + #[DataProvider('refusedBudgets')] + public function testRefusedBudgetsNameTheValue(mixed $advance, string $named): void { + try { + $this->node->validateConfig($this->config(['advance' => $advance])); + $this->fail('Expected the budget to be refused.'); + } catch (UnexpectedValueException $refusal) { + $this->assertStringContainsString($named, $refusal->getMessage()); + } + }//end testRefusedBudgetsNameTheValue() + + public function testAConfigWithNoPossiblePerformerIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/No performer can be resolved/'); + + $this->node->validateConfig(['title' => 'Approve', 'candidateUsers' => '']); + }//end testAConfigWithNoPossiblePerformerIsRefused() + + /** + * @return array}> + */ + public static function performerSources(): array { + return [ + 'assignee' => [['assignee' => 'alice']], + 'candidate users as a string' => [['candidateUsers' => 'alice, bob']], + 'candidate groups as a list' => [['candidateGroups' => ['finance']]], + 'a role' => [['candidateRole' => 'reviewer']], + 'a routing fallback' => [['routingFallback' => 'carol']], + ]; + }//end performerSources() + + #[DataProvider('performerSources')] + public function testAnyPerformerSourceSatisfiesTheCheck(array $source): void { + $this->node->validateConfig(array_merge(['title' => 'Approve'], $source)); + $this->addToAssertionCount(1); + }//end testAnyPerformerSourceSatisfiesTheCheck() + + public function testATitlelessTaskIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + + $this->node->validateConfig(['assignee' => 'alice']); + }//end testATitlelessTaskIsRefused() + + /** + * @return array + */ + public static function outsideVocabulary(): array { + return [ + 'performer type' => ['performerType', 'robot'], + 'priority' => ['priority', 'asap'], + 'routing strategy' => ['routingStrategy', 'random'], + ]; + }//end outsideVocabulary() + + #[DataProvider('outsideVocabulary')] + public function testAValueOutsideAPublishedVocabularyIsRefusedNamingIt(string $key, string $value): void { + try { + $this->node->validateConfig($this->config([$key => $value])); + $this->fail('Expected the value to be refused.'); + } catch (UnexpectedValueException $refusal) { + $this->assertStringContainsString($value, $refusal->getMessage()); + $this->assertStringContainsString($key, $refusal->getMessage()); + } + }//end testAValueOutsideAPublishedVocabularyIsRefusedNamingIt() + + // ---- Palette and form ----------------------------------------------------- + + public function testTheNodeIsOfferedInBothScopes(): void { + $this->assertSame('openregister.user-task', $this->node->getId()); + $this->assertTrue($this->node->isAvailableForScope(IManager::SCOPE_ADMIN)); + $this->assertTrue($this->node->isAvailableForScope(IManager::SCOPE_USER)); + }//end testTheNodeIsOfferedInBothScopes() + + /** + * Every form field writes a key the node reads, and the form covers the + * fields the spec names. + */ + public function testTheFormCoversTheSpecifiedFieldsAndOnlyDeclaredKeys(): void { + $keys = $this->node->configKeys(); + $formKeys = array_map(static fn (array $field): string => (string)$field['key'], $this->node->configForm()); + + foreach ($formKeys as $key) { + $this->assertContains($key, $keys, 'a form field over a key the node ignores looks like it works and changes nothing'); + } + + foreach (['title', 'description', 'candidateUsers', 'candidateGroups', 'candidateRole', 'routingStrategy', 'routingFallback', 'priority', 'dueAt', 'expiresAt', 'outcomes', 'outcomeKey', 'failOnReject', 'heartbeatMinutes', 'advance'] as $required) { + $this->assertContains($required, $formKeys); + } + + $this->assertNotEmpty($this->node->configForm()); + }//end testTheFormCoversTheSpecifiedFieldsAndOnlyDeclaredKeys() + + /** + * The palette description states the division of labour with the signal + * node, so an author picks correctly without reading the source. + */ + public function testTheDescriptionNamesTheOtherHalfOfThePair(): void { + $this->assertStringContainsString('Wait for an answer', $this->node->getDescription()); + $this->assertStringNotContainsString("\u{2014}", $this->node->getDescription(), 'no em-dashes in user-facing copy'); + }//end testTheDescriptionNamesTheOtherHalfOfThePair() +}//end class diff --git a/tests/Unit/Service/Task/TaskServiceTerminalEventTest.php b/tests/Unit/Service/Task/TaskServiceTerminalEventTest.php new file mode 100644 index 0000000000..319b8afb45 --- /dev/null +++ b/tests/Unit/Service/Task/TaskServiceTerminalEventTest.php @@ -0,0 +1,150 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCA\OpenRegister\Service\Task\TaskPerformerResolver; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * Terminality is announced AFTER the transaction commits, once per + * terminal transition, and a listener failure cannot undo the transition. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-run-continues-on-task-terminality-never-on-a-nudge + */ +class TaskServiceTerminalEventTest extends TestCase { + + private TaskMapper&MockObject $tasks; + + private IDBConnection&MockObject $db; + + private IEventDispatcher&MockObject $dispatcher; + + /** + * The order in which commit and dispatch happened. + * + * @var array + */ + private array $sequence = []; + + protected function setUp(): void { + $this->tasks = $this->createMock(TaskMapper::class); + $this->tasks->method('update')->willReturnArgument(0); + $this->tasks->method('updateIfOpen')->willReturn(true); + $this->db = $this->createMock(IDBConnection::class); + $this->db->method('commit')->willReturnCallback(function (): void { + $this->sequence[] = 'commit'; + }); + $this->dispatcher = $this->createMock(IEventDispatcher::class); + }//end setUp() + + private function service(): TaskService { + $audits = $this->createMock(TaskAuditMapper::class); + $audits->method('insert')->willReturnArgument(0); + + return new TaskService( + tasks: $this->tasks, + candidates: $this->createMock(TaskCandidateMapper::class), + relations: $this->createMock(TaskRelationMapper::class), + audits: $audits, + authorization: $this->createMock(TaskAuthorizationService::class), + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->db, + logger: new NullLogger(), + builder: new TaskBuilder(), + dispatcher: $this->dispatcher + ); + }//end service() + + private function openTask(): Task { + $task = new Task(); + $task->setId(7); + $task->setUuid('t-7'); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('alice'); + $task->setRunUuid('run-1'); + + return $task; + }//end openTask() + + /** + * The event fires once, carries the completed task, and fires after the + * commit: a listener that walks the run must find a completion that + * already exists on its own. + */ + public function testACompletionIsAnnouncedOnceAfterTheCommit(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (Event $event): bool { + $this->sequence[] = 'dispatch'; + $this->assertInstanceOf(TaskTerminalEvent::class, $event); + $this->assertSame(Task::STATE_COMPLETED, $event->getTask()->getState()); + $this->assertSame('approved', $event->getTask()->getOutcome()); + + return true; + })); + + $this->service()->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, actor: 'alice'); + + $this->assertSame(['commit', 'dispatch'], $this->sequence); + }//end testACompletionIsAnnouncedOnceAfterTheCommit() + + public function testACancellationIsAnnounced(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->dispatcher->expects($this->once())->method('dispatchTyped')->with($this->isInstanceOf(TaskTerminalEvent::class)); + + $this->service()->cancel(uuid: 't-7', reason: 'moot', actor: 'rita'); + }//end testACancellationIsAnnounced() + + /** + * A refused verb moves nothing, so it announces nothing. + */ + public function testARefusedCompletionAnnouncesNothing(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + + $this->expectException(TaskValidationException::class); + + $this->service()->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: '', actor: 'alice'); + }//end testARefusedCompletionAnnouncesNothing() + + /** + * A listener that throws cannot undo the completion: the task is returned + * completed, and the caller sees no error. + */ + public function testAListenerFailureDoesNotUndoTheCompletion(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->db->expects($this->never())->method('rollBack'); + $this->dispatcher->method('dispatchTyped')->willThrowException(new RuntimeException('listener blew up')); + + $completed = $this->service()->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, actor: 'alice'); + + $this->assertSame(Task::STATE_COMPLETED, $completed->getState()); + $this->assertTrue($completed->getIsTerminal()); + }//end testAListenerFailureDoesNotUndoTheCompletion() +}//end class diff --git a/tests/e2e/api-direct/flow-user-task.spec.ts b/tests/e2e/api-direct/flow-user-task.spec.ts new file mode 100644 index 0000000000..b93fd9a5ca --- /dev/null +++ b/tests/e2e/api-direct/flow-user-task.spec.ts @@ -0,0 +1,636 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The user-task node, end to end over the live HTTP API: the eight + * @e2e-marked scenarios of the flow-user-task-node spec. + * + * A flow is authored through `POST /api/flows` and run through the + * synchronous test endpoint (`POST /api/flow-runs/test`), which walks it + * until the user-task node suspends. The task is then driven through the + * flow-tasks verbs, and the run is read back through `GET /api/flow-runs`. + * + * Two scenarios need the background worker: the DEFAULT budget parks the + * run for the worker, and "stopping a run" has no HTTP verb on this + * surface (there is no run-stop endpoint), so the operator's stop is used: + * the instance kill switch vetoes the next hop, which ends the run as + * `stopped` and lets cancellation propagation empty the inbox. Both drive + * the worker with `occ background-job:execute` through the dev container + * and SKIP, loudly, where occ is not reachable. + * + * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md + */ +import type { APIRequestContext } from '@playwright/test' + +import { request as apiRequest, expect, test } from '@playwright/test' +import { execSync } from 'node:child_process' +import { resolveContainer } from '../base-url.ts' + +const API = '/index.php/apps/openregister/api' +const JSON_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json', +} +const RUN_ID = `e2e-usertask-${Date.now().toString(36)}` +const ADMIN = process.env.NEXTCLOUD_ADMIN_USER || process.env.OR_USER || 'admin' +const ADMIN_PASS = + process.env.NEXTCLOUD_ADMIN_PASSWORD || process.env.OR_PASS || 'admin' +const STRANGER = `${RUN_ID}-perf` +const STRANGER_PASS = `Perf0rmer!${Date.now().toString(36)}A` + +// Same reasoning as flow-engine.spec.ts: Basic auth, no session cookie, so no +// CSRF token is demanded and `OCS-APIRequest` marks the calls as API traffic. +const NO_SESSION = { cookies: [], origins: [] } +const ADMIN_HEADERS = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${ADMIN}:${ADMIN_PASS}`).toString('base64')}`, +} +const STRANGER_HEADERS = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${STRANGER}:${STRANGER_PASS}`).toString('base64')}`, +} + +test.use({ storageState: NO_SESSION, extraHTTPHeaders: ADMIN_HEADERS }) +test.describe.configure({ mode: 'serial' }) + +const CONTAINER = resolveContainer() + +function occ(args: string): string { + if (CONTAINER === null) { + throw new Error('NC_CONTAINER is not set; refusing to guess a container.') + } + return execSync(`docker exec -u www-data ${CONTAINER} php occ ${args}`, { + encoding: 'utf8', + }) +} + +/** + * The FlowRunWorker's background-job id, by class BASENAME (a namespace move + * must not turn this into a silent skip). Null when occ is unreachable. + */ +function runWorkerJobId(): string | null { + try { + const line = occ('background-job:list') + .split('\n') + .find((l) => l.includes('FlowRunWorker')) + return line ? (line.match(/\|\s*(\d+)\s*\|/)?.[1] ?? null) : null + } catch { + return null + } +} + +type Node = Record + +/** Author a flow through the flows API and hand back its uuid. */ +async function createFlow( + request: APIRequestContext, + label: string, + nodes: Node[], + edges: Array>, +): Promise { + const resp = await request.post(`${API}/flows`, { + headers: JSON_HEADERS, + data: { + name: `${RUN_ID} ${label}`, + description: 'Created by the flow-user-task e2e suite.', + trigger: 'manual', + enabled: true, + nodes, + edges, + }, + }) + expect(resp.status(), await resp.text()).toBe(201) + const body = await resp.json() + const uuid = body.uuid ?? body.id + expect(uuid, 'flow uuid').toBeTruthy() + return uuid as string +} + +/** A user-task node with the given config, assigned to the admin unless told otherwise. */ +function userTask(id: string, config: Record = {}): Node { + return { + id, + type: 'openregister.user-task', + config: { + title: `${RUN_ID} ${id}: approve {{ name }}`, + assignee: ADMIN, + outcomes: 'approved, rejected', + ...config, + }, + position: { x: 0, y: 0 }, + } +} + +function setFields(id: string, set: Record): Node { + return { + id, + type: 'openregister.set-fields', + config: { set }, + position: { x: 0, y: 0 }, + } +} + +/** Run a flow synchronously through the test endpoint; returns the run row. */ +async function testRun(request: APIRequestContext, flowId: string) { + const resp = await request.post(`${API}/flow-runs/test`, { + headers: JSON_HEADERS, + data: { flowId, seedItems: [{ json: { name: 'Case 7' } }] }, + }) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() +} + +async function readRun(request: APIRequestContext, uuid: string) { + const resp = await request.get(`${API}/flow-runs/${uuid}`) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() +} + +/** The open task a run's node created, found through the assignee's inbox. */ +async function inboxTaskFor( + request: APIRequestContext, + runUuid: string, + nodeId: string, +) { + const resp = await request.get( + `${API}/flow-tasks?scope=assigned&isTerminal=false&limit=100&sort=created&direction=desc`, + ) + expect(resp.status(), await resp.text()).toBe(200) + const rows = ((await resp.json()).results ?? []) as Array< + Record + > + return ( + rows.find((row) => row.runUuid === runUuid && row.nodeId === nodeId) ?? null + ) +} + +async function readTask(request: APIRequestContext, uuid: string) { + const resp = await request.get(`${API}/flow-tasks/${uuid}`) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() +} + +async function complete( + request: APIRequestContext, + uuid: string, + outcome: string, + comment: string | null = null, +) { + const resp = await request.post(`${API}/flow-tasks/${uuid}/complete`, { + headers: JSON_HEADERS, + data: { outcome, comment }, + }) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() +} + +test.describe('flow-user-task-node: a person in the graph', () => { + const flows: string[] = [] + + test.afterAll(async ({ request }) => { + // Deleting the flow cascades its runs. Tasks are terminated by the + // scenarios that create them, or left terminal; never deleted. + for (const uuid of flows) { + await request.delete(`${API}/flows/${uuid}`).catch(() => {}) + } + }) + + // @e2e flow-user-task-node::the-catalog-serves-the-nodes-form + test('the node catalog offers the user-task node with its form', async ({ + request, + }) => { + const resp = await request.get(`${API}/flow/node-catalog`) + expect(resp.status(), await resp.text()).toBe(200) + const entry = ((await resp.json()).results ?? []).find( + (node: { id?: string }) => node.id === 'openregister.user-task', + ) + expect(entry, 'the palette carries openregister.user-task').toBeTruthy() + expect(Array.isArray(entry.configForm)).toBe(true) + expect(entry.configForm.length).toBeGreaterThan(0) + const formKeys = entry.configForm.map((field: { key: string }) => field.key) + for (const key of [ + 'title', + 'candidateUsers', + 'priority', + 'dueAt', + 'outcomes', + 'advance', + ]) { + expect(formKeys, `form field ${key}`).toContain(key) + } + expect(entry.configKeys).toContain('advance') + // The division of labour is stated where the author picks. + expect(String(entry.description)).toContain('Wait for an answer') + }) + + // @e2e flow-user-task-node::the-first-firing-produces-a-task-and-a-suspended-run + test('a flow with a user task suspends and the task appears in the inbox', async ({ + request, + }) => { + const flowId = await createFlow( + request, + 'suspends', + [ + setFields('start', { step: 1 }), + userTask('ask'), + setFields('done', { step: 2 }), + ], + [ + { id: 'e1', from: 'start', to: 'ask' }, + { id: 'e2', from: 'ask', to: 'done' }, + ], + ) + flows.push(flowId) + + const run = await testRun(request, flowId) + expect(run.status).toBe('suspended') + // The heartbeat: a suspension a clock can reach. Null is the one shape + // the 14-day abandoned-signal reaper would FAIL. + expect( + run.resumeAt, + 'a user task never parks on a null resumeAt', + ).toBeTruthy() + + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task, 'exactly this run and node raised a task').toBeTruthy() + expect(task!.assignee).toBe(ADMIN) + expect(task!.state).toBe('active') + expect(String(task!.title)).toContain('approve Case 7') + + // One task for this node, not one per heartbeat: a re-run of the same + // suspended run (the worker's wake, done here through the resume nudge + // and a second read) must not add a second row. + const nudge = await request.post(`${API}/flow-runs/${run.uuid}/resume`, { + headers: JSON_HEADERS, + data: {}, + }) + expect([200, 403]).toContain(nudge.status()) + const listed = await request.get( + `${API}/flow-tasks?scope=assigned&isTerminal=false&limit=100`, + ) + const mine = ( + ((await listed.json()).results ?? []) as Array> + ).filter((row) => row.runUuid === run.uuid) + expect(mine).toHaveLength(1) + + await complete(request, task!.uuid as string, 'approved') + }) + + // @e2e flow-user-task-node::completing-the-task-advances-the-run + test('completing a task from the inbox advances its flow run', async ({ + request, + }) => { + const flowId = await createFlow( + request, + 'default budget', + [userTask('ask'), setFields('done', { finished: true })], + [{ id: 'e1', from: 'ask', to: 'done' }], + ) + flows.push(flowId) + + const run = await testRun(request, flowId) + expect(run.status).toBe('suspended') + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task).toBeTruthy() + + const before = Date.now() + const completed = await complete(request, task!.uuid as string, 'approved') + expect(completed.state).toBe('completed') + + // The DEFAULT budget: the completing request parks the run as due and + // returns. Still suspended, and the resume time is now, not the + // heartbeat fifteen minutes out. + const parked = await readRun(request, run.uuid) + expect(parked.status).toBe('suspended') + expect(new Date(parked.resumeAt as string).getTime()).toBeLessThanOrEqual( + before + 60_000, + ) + + // On the next advance the node MUST NOT suspend again. That advance is + // the worker's; drive it when occ is reachable, and say so when not. + const job = runWorkerJobId() + test.skip( + job === null, + 'FlowRunWorker not reachable via occ; the worker half of this scenario cannot run here', + ) + occ(`background-job:execute ${job} --force-execute`) + + const after = await readRun(request, run.uuid) + expect(after.status).toBe('completed') + const item = (after.items ?? [])[0]?.json ?? {} + expect(item.task?.outcome).toBe('approved') + expect(item.task?.decided).toBe(true) + expect(item.task?.completedBy).toBe(ADMIN) + expect(item.finished).toBe(true) + }) + + // @e2e flow-user-task-node::the-resume-endpoint-cannot-answer-for-a-performer + test('a flow-runner who is not the performer cannot answer a user task', async ({ + request, + }) => { + // A second real account is the task's performer. The ADMIN owns the + // flow and may run it, and is exactly the caller the spec names: may + // run the FLOW, is not the performer. + const provisioned = await request.post('/ocs/v2.php/cloud/users', { + data: { userid: STRANGER, password: STRANGER_PASS }, + }) + test.skip( + provisioned.status() !== 200, + `cannot provision a performer account (HTTP ${provisioned.status()})`, + ) + + const performer = await apiRequest.newContext({ + baseURL: + process.env.PLAYWRIGHT_BASE_URL + || process.env.NEXTCLOUD_URL + || process.env.BASE_URL, + extraHTTPHeaders: STRANGER_HEADERS, + }) + + try { + const flowId = await createFlow( + request, + 'not the performer', + [ + userTask('ask', { assignee: STRANGER }), + setFields('done', { finished: true }), + ], + [{ id: 'e1', from: 'ask', to: 'done' }], + ) + flows.push(flowId) + + const run = await testRun(request, flowId) + expect(run.status).toBe('suspended') + + // The performer sees it in THEIR inbox. + const theirs = await performer.get( + `${API}/flow-tasks?scope=assigned&isTerminal=false&limit=100`, + ) + expect(theirs.status(), await theirs.text()).toBe(200) + const task = ( + ((await theirs.json()).results ?? []) as Array< + Record + > + ).find((row) => row.runUuid === run.uuid) + expect(task, 'the performer is asked').toBeTruthy() + + // The flow-runner posts a decision at the RUN. Whatever the door + // says (the assignee guard refuses, or a nudge is accepted), the + // task must not move and the run must still be waiting. + const answered = await request.post( + `${API}/flow-runs/${run.uuid}/resume`, + { + headers: JSON_HEADERS, + data: { decision: 'approve', outcome: 'approved' }, + }, + ) + expect([200, 403]).toContain(answered.status()) + + const taskAfter = await readTask(request, task!.uuid as string) + expect(taskAfter.state).toBe('active') + expect(taskAfter.isTerminal).toBe(false) + expect(taskAfter.completedBy).toBeNull() + + const runAfter = await readRun(request, run.uuid) + expect(runAfter.status).toBe('suspended') + + // Positive control: the performer CAN answer, through the task verb. + const done = await performer.post( + `${API}/flow-tasks/${task!.uuid}/complete`, + { + headers: JSON_HEADERS, + data: { outcome: 'approved' }, + }, + ) + expect(done.status(), await done.text()).toBe(200) + } finally { + await performer.dispose() + await request + .delete(`/ocs/v2.php/cloud/users/${STRANGER}`) + .catch((error) => + console.warn( + '[flow-user-task] performer cleanup failed:', + error, + ), + ) + } + }) + + // @e2e flow-user-task-node::a-downstream-switch-branches-on-the-outcome + test('a rejected task routes the flow down its rejection branch', async ({ + request, + }) => { + const flowId = await createFlow( + request, + 'rejection branch', + [ + userTask('ask', { advance: 'all' }), + { + id: 'route', + type: 'openregister.route', + config: { + rules: [ + { + condition: { + '==': [{ var: 'json.task.outcome' }, 'rejected'], + }, + output: 'no', + }, + ], + default: 'yes', + }, + exits: [{ id: 'no' }, { id: 'yes' }], + position: { x: 0, y: 0 }, + }, + setFields('onRejected', { branch: 'rejected' }), + setFields('onApproved', { branch: 'approved' }), + ], + [ + { id: 'e1', from: 'ask', to: 'route' }, + { id: 'e2', from: 'route', fromExit: 'no', to: 'onRejected' }, + { id: 'e3', from: 'route', fromExit: 'yes', to: 'onApproved' }, + ], + ) + flows.push(flowId) + + const run = await testRun(request, flowId) + expect(run.status).toBe('suspended') + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task).toBeTruthy() + + // A rejecting outcome requires a comment; the run is NOT failed by it. + await complete( + request, + task!.uuid as string, + 'rejected', + 'Missing signature', + ) + + const after = await readRun(request, run.uuid) + expect(after.status, 'a rejection is a branch, not a failure').toBe( + 'completed', + ) + const transitions = (after.log ?? []).map( + (entry: { transition?: string }) => entry.transition, + ) + expect(transitions).toContain('onRejected') + expect(transitions).not.toContain('onApproved') + const item = (after.items ?? [])[0]?.json ?? {} + expect(item.branch).toBe('rejected') + expect(item.task?.rejected).toBe(true) + expect(item.task?.comment).toBe('Missing signature') + }) + + // @e2e flow-user-task-node::two-approvals-require-two-answers + test('a two-approval flow requires both approvals', async ({ request }) => { + const flowId = await createFlow( + request, + 'two approvals', + [ + userTask('first', { advance: 'all', outcomeKey: 'first' }), + userTask('second', { advance: 'all', outcomeKey: 'second' }), + setFields('done', { finished: true }), + ], + [ + { id: 'e1', from: 'first', to: 'second' }, + { id: 'e2', from: 'second', to: 'done' }, + ], + ) + flows.push(flowId) + + const run = await testRun(request, flowId) + expect(run.status).toBe('suspended') + const first = await inboxTaskFor(request, run.uuid, 'first') + expect(first).toBeTruthy() + expect( + await inboxTaskFor(request, run.uuid, 'second'), + 'the second question is not asked yet', + ).toBeNull() + + await complete(request, first!.uuid as string, 'approved') + + // The run continued past the first node and suspended AGAIN, on the + // second, which raised a task of its own. + const between = await readRun(request, run.uuid) + expect(between.status).toBe('suspended') + const second = await inboxTaskFor(request, run.uuid, 'second') + expect(second, 'a second, distinct task').toBeTruthy() + expect(second!.uuid).not.toBe(first!.uuid) + + await complete(request, second!.uuid as string, 'approved') + + const after = await readRun(request, run.uuid) + expect(after.status).toBe('completed') + const item = (after.items ?? [])[0]?.json ?? {} + expect(item.first?.outcome).toBe('approved') + expect(item.second?.outcome).toBe('approved') + expect(item.finished).toBe(true) + }) + + // @e2e flow-user-task-node::a-budget-of-all-runs-to-the-next-stopping-point + test('completing a task with an "all" budget finishes the run in one request', async ({ + request, + }) => { + const flowId = await createFlow( + request, + 'all budget', + [ + userTask('ask', { advance: 'all' }), + setFields('one', { one: true }), + setFields('two', { two: true }), + { + id: 'end', + type: 'openregister.end', + config: { message: 'done' }, + position: { x: 0, y: 0 }, + }, + ], + [ + { id: 'e1', from: 'ask', to: 'one' }, + { id: 'e2', from: 'one', to: 'two' }, + { id: 'e3', from: 'two', to: 'end' }, + ], + ) + flows.push(flowId) + + const run = await testRun(request, flowId) + expect(run.status).toBe('suspended') + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task).toBeTruthy() + + await complete(request, task!.uuid as string, 'approved') + + // No worker pass happened between the completion and this read: the + // completing request itself walked the two steps and the end. + const after = await readRun(request, run.uuid) + expect(['completed', 'stopped'], 'ended in-request').toContain(after.status) + const transitions = (after.log ?? []).map( + (entry: { transition?: string }) => entry.transition, + ) + expect(transitions).toContain('one') + expect(transitions).toContain('two') + }) + + // @e2e flow-user-task-node::stopping-a-run-empties-its-inboxes + test("stopping a run removes its tasks from the assignees' inboxes", async ({ + request, + }) => { + const job = runWorkerJobId() + test.skip( + job === null, + 'FlowRunWorker not reachable via occ; there is no run-stop HTTP verb, so the operator stop needs the worker', + ) + + const flowId = await createFlow( + request, + 'stopped', + [userTask('ask'), setFields('done', { finished: true })], + [{ id: 'e1', from: 'ask', to: 'done' }], + ) + flows.push(flowId) + + const run = await testRun(request, flowId) + expect(run.status).toBe('suspended') + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task).toBeTruthy() + + try { + // The operator's stop: the instance kill switch vetoes the next hop, + // so the worker's walk ends the run as `stopped` (terminal), and + // cancellation propagation terminates the run's open tasks. + occ('config:app:set openregister flow_kill_switch --value=1') + const nudged = await request.post( + `${API}/flow-runs/${run.uuid}/resume`, + { + headers: JSON_HEADERS, + data: {}, + }, + ) + expect(nudged.status(), await nudged.text()).toBe(200) + occ(`background-job:execute ${job} --force-execute`) + + const after = await readRun(request, run.uuid) + expect(after.status).toBe('stopped') + + const gone = await inboxTaskFor(request, run.uuid, 'ask') + expect(gone, 'the task is no longer actionable in the inbox').toBeNull() + + // Terminated, not deleted: the record and its reason survive. + const terminated = await readTask(request, task!.uuid as string) + expect(terminated.state).toBe('terminated') + expect(terminated.isTerminal).toBe(true) + const audit = await request.get(`${API}/flow-tasks/${task!.uuid}/audit`) + expect(audit.status()).toBe(200) + const entries = ((await audit.json()).results ?? []) as Array< + Record + > + const termination = entries.find((e) => e.action === 'terminate') + expect(termination, 'the termination is audited').toBeTruthy() + expect(String(termination!.reason)).toContain(run.uuid) + } finally { + occ('config:app:delete openregister flow_kill_switch') + } + }) +}) From cfad0daeef58ed0a4dcd6bce767f50f0a863ad45 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 14:57:15 +0200 Subject: [PATCH 30/68] feat(router): move openregister off hash routing, and stop the catch-all eating the API (#3270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(router): move openregister off hash routing, and stop the catch-all eating the API Seventh and last app off `#` routing, after stackiq (softwarecatalog#899), zaakafhandelapp (#609), opencatalogi (#1341), keepiq (#582), larpinq (#651) and pipelinq (#1684). Three source changes: 1. DashboardController::catchAll(), delegating to page(). THIS is the actual fix for #133. The app registered exactly one frontend route -- `dashboard#page` at `/` -- and nothing serving the SPA shell for a deep sub-path, so a bookmark or full-page load to /registers never reached the SPA and the grouped index pages rendered empty (no Add button, no list). Hash mode was adopted to work around that; this removes the cause instead. 2. The catch-all route in appinfo/routes.php. 3. createWebHashHistory -> createWebHistory with routerBase(), derived from the URL actually being served. Nextcloud serves the app under BOTH /apps/openregister/... and /index.php/apps/openregister/..., but generateUrl() returns only one; arriving on the other leaves the path outside the router base and the deep link is silently swallowed. ⚠️ The catch-all needs `(?!api/)`, and getting this wrong is not theoretical -- the same defect shipped in zaakafhandelapp#615 and ate all seventeen of its ZGW resource routes until #619. Nextcloud's RouteParser processes the `routes` array BEFORE the `resources` array (RouteParser::parseDefaultRoutes) and Symfony matches in insertion order, so the catch-all registers ahead of every one of this app's NINE `api/...` resources no matter that it is written last. `.+` also matches slashes. Without the lookahead, GET api/registers answers the SPA shell at HTTP 200: a JSON caller receives HTML and nothing errors loudly. \OCA\OpenRegister\AppHost\Routes::catchAllRoute() gets the same lookahead, so no future adopter of Routes::standard() inherits it, and RoutesTest now asserts the requirement as a REGEX against real paths rather than as a literal string, so the guard survives a rewrite of the spelling. Deep-link URL GENERATION is de-hashed (CalendarEventTransformer), but every PARSER keeps its hash pattern: ObjectReferenceProvider, UrnService and ObjectPreviewFormatter already accepted three URL shapes including the path form, and links shared into Talk, Mail and calendar entries before today still carry `#`. Dropping the pattern would break previews of already-shared links, so those tests keep asserting it on purpose. Verified on the dev instance, with the catch-all live: api/registers, api/schemas, api/sources, api/configurations, api/applications, api/agents, api/endpoints, api/mappings, api/consumers -> all 200 application/json (unshadowed) /registers, /schemas, /features-roadmap, /zzz-nonsense -> 200, SPA shell (404 before) and in the browser against the built bundle: /index.php/apps/openregister/registers -> Registers, 48 rows, "Add Register", 0 hash links /apps/openregister/schemas -> Schemas, 95 rows, "Add Schema" Both were full page loads, which is the reload case hash mode existed to avoid. Nav hrefs carry whichever base the page was loaded under, which is routerBase() working -- generateUrl() alone would have broken one of the two forms. 43 files; the bulk is the e2e suite moving off `${APP}/#${route}`. eslint 0 errors, prettier --check on the FULL glob clean, webpack compiles, php -l passes. * test(dashboard): cover catchAll, the statement the coverage guard flagged Two CI failures, one cause. catchAll() adds a single statement and no test touched it, so: coverage guard base 272/299 -> head 272/300 statements, a 0.3% drop on code this change KEEPS or ADDS gate-25 contract-coverage FAIL, 1 new public endpoint missing a contract test `dashboard#catchAll` is a public, network-facing GET /{path}, so it is covered rather than annotated `@contract exclude`. The test asserts equality with page() rather than merely "returns a TemplateResponse": catchAll() exists only to delegate, and a delegation that quietly rendered the wrong template would hand every deep link a blank page while still answering 200. 21 DashboardController tests pass, and RoutesTest still passes with the `(?!api/)` requirement it now asserts as a regex against real paths. --------- Co-authored-by: Conduction Release Bot --- appinfo/routes.php | 19 ++++++++ lib/AppHost/Routes.php | 13 +++++- lib/Calendar/CalendarEventTransformer.php | 2 +- lib/Controller/DashboardController.php | 26 +++++++++++ src/main.js | 45 ++++++++++++++----- src/views/quality/RegisterSchemaSelector.vue | 4 +- tests/Unit/AppHost/RoutesTest.php | 28 +++++++++++- .../Calendar/CalendarEventTransformerTest.php | 2 +- .../Controller/DashboardControllerTest.php | 17 +++++++ tests/e2e/_page-routes.ts | 2 +- tests/e2e/ci/flow-controls.spec.ts | 2 +- tests/e2e/ci/object-shares-tab.spec.ts | 2 +- tests/e2e/ci/smoke-boot.spec.ts | 2 +- tests/e2e/core-crud.spec.ts | 10 ++--- tests/e2e/crud/object-crud.spec.ts | 4 +- tests/e2e/crud/register-crud.spec.ts | 2 +- tests/e2e/crud/schema-crud.spec.ts | 2 +- tests/e2e/docs-screenshots.spec.ts | 2 +- tests/e2e/flow-engine.spec.ts | 16 +++---- tests/e2e/integration-mount.spec.ts | 2 +- tests/e2e/leaf-screenshots.spec.ts | 2 +- tests/e2e/manifest-shell.spec.ts | 14 +++--- tests/e2e/smoke-boot.spec.ts | 2 +- .../admin-settings-pages.spec.ts | 2 +- .../e2e/spec-coverage/core-list-pages.spec.ts | 2 +- .../spec-coverage/data-import-export.spec.ts | 4 +- tests/e2e/spec-coverage/detail-pages.spec.ts | 2 +- .../entity-management-modals.spec.ts | 4 +- tests/e2e/spec-coverage/feature-pages.spec.ts | 8 ++-- .../features-roadmap-surface.spec.ts | 2 +- .../spec-coverage/files-sidebar-tabs.spec.ts | 4 +- tests/e2e/spec-coverage/mdm-frontend.spec.ts | 4 +- tests/e2e/spec-coverage/mdm-merge-ui.spec.ts | 2 +- .../mdm-survivorship-override.spec.ts | 2 +- tests/e2e/spec-coverage/register-i18n.spec.ts | 8 ++-- .../spec-coverage/saved-search-views.spec.ts | 2 +- tests/e2e/ui-navigation.spec.ts | 8 ++-- tests/e2e/visual/dsar-cases.visual.spec.ts | 2 +- tests/e2e/visual/mdm-frontend.visual.spec.ts | 14 +++--- tests/e2e/visual/mdm-merge-ui.visual.spec.ts | 4 +- .../mdm-survivorship-override.visual.spec.ts | 2 +- tests/e2e/visual/openregister.visual.spec.ts | 2 +- tests/e2e/workflows/dsar-cases.spec.ts | 2 +- .../object-lifecycle-workflows.spec.ts | 4 +- 44 files changed, 212 insertions(+), 92 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 7739102ec3..c82a4f3ec0 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1392,5 +1392,24 @@ ['name' => 'federatedConfig#publicKey', 'url' => '/api/federated-config/public-key', 'verb' => 'GET'], ['name' => 'federatedConfig#trust', 'url' => '/api/federated-config/trust', 'verb' => 'GET'], ['name' => 'federatedConfig#setTrust', 'url' => '/api/federated-config/trust', 'verb' => 'PUT'], + + // SPA catch-all — MUST stay last so every explicit route above keeps + // priority over the /{path} fallback. Without it only `/` served the + // shell, so any deep link (/registers, /schemas, a detail route) never + // reached the SPA at all — the #133 regression that forced this app back + // onto hash routing. Spelled inline rather than via + // \OCA\OpenRegister\AppHost\Routes::standard() because this file also + // declares a `resources` block the builder does not carry, and because + // this IS openregister — guarding a call to its own class would be odd. + // ⚠️ `(?!api/)` is load-bearing. Nextcloud's RouteParser processes the + // `routes` array BEFORE the `resources` array + // (RouteParser::parseDefaultRoutes), and Symfony matches in insertion + // order — so even as the LAST entry here this route still registers + // ahead of all nine `api/...` resource routes below. Without the + // lookahead `.+` (which matches slashes) would swallow + // GET /api/registers, /api/schemas and the rest, answering the SPA + // shell instead of JSON. The SPA never needs an `api/` path. + ['name' => 'dashboard#catchAll', 'url' => '/{path}', 'verb' => 'GET', + 'requirements' => ['path' => '(?!api/).+'], 'defaults' => ['path' => '']], ], ]; diff --git a/lib/AppHost/Routes.php b/lib/AppHost/Routes.php index 8a8b56f4eb..3dbc0d7a18 100644 --- a/lib/AppHost/Routes.php +++ b/lib/AppHost/Routes.php @@ -165,7 +165,18 @@ private static function catchAllRoute(): array { 'name' => 'dashboard#catchAll', 'url' => '/{path}', 'verb' => 'GET', - 'requirements' => ['path' => '.+'], + // ⚠️ `(?!api/)` is load-bearing for any adopter whose routes.php + // also declares a `resources` block. Nextcloud's RouteParser + // processes the `routes` array BEFORE the `resources` array + // (RouteParser::parseDefaultRoutes) and Symfony matches in + // insertion order, so this route registers ahead of every + // resource-generated route no matter that it is appended LAST + // here. `.+` matches slashes, so without the lookahead it + // swallows GET api/ and answers the SPA shell with + // HTTP 200 — a JSON caller receives HTML and nothing errors + // loudly. zaakafhandelapp lost all seventeen of its ZGW resource + // routes that way. The SPA never needs an `api/` path. + 'requirements' => ['path' => '(?!api/).+'], 'defaults' => ['path' => ''], ]; }//end catchAllRoute() diff --git a/lib/Calendar/CalendarEventTransformer.php b/lib/Calendar/CalendarEventTransformer.php index 91cc7d8559..b9ff0b62f3 100644 --- a/lib/Calendar/CalendarEventTransformer.php +++ b/lib/Calendar/CalendarEventTransformer.php @@ -139,7 +139,7 @@ public function transform( // URL to OpenRegister object. $register = $object->getRegister(); - $url = '/apps/openregister/#/objects/' . $register . '/' . $schemaId . '/' . $objectUuid; + $url = '/apps/openregister/objects/' . $register . '/' . $schemaId . '/' . $objectUuid; $veventProperties['URL'] = [$url, []]; return [ diff --git a/lib/Controller/DashboardController.php b/lib/Controller/DashboardController.php index cdca0b78cc..a8244db3b9 100644 --- a/lib/Controller/DashboardController.php +++ b/lib/Controller/DashboardController.php @@ -173,6 +173,32 @@ public function page(): TemplateResponse { }//end try }//end page() + /** + * Serve the SPA for deep links (Vue history mode). Delegates to {@see page()}. + * + * THIS IS THE FIX FOR #133. This app registered exactly one frontend route — + * `dashboard#page` at `/` — and nothing that served the SPA shell for a deep + * sub-path like /registers or /schemas. A bookmark or full-page load to one + * of those therefore never reached the SPA, the router resolved the base + * path instead, and the grouped index pages rendered empty (no Add button, + * no list). The app was moved back to hash routing to work around it; with + * this route the workaround is no longer needed. + * + * ⚠️ Probing an enumerated path does NOT prove a catch-all exists — probe a + * nonsense one. /apps/openregister/zzz-nonsense returned 404 before this and + * 401 after. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return TemplateResponse + * + * @spec exclude Vue history-mode fallback — delegates to page(); pure framework plumbing, no domain logic. + */ + public function catchAll(): TemplateResponse { + return $this->page(); + }//end catchAll() + /** * Retrieves dashboard data including registers with their schemas * diff --git a/src/main.js b/src/main.js index a429b76c0c..0f0f691c82 100644 --- a/src/main.js +++ b/src/main.js @@ -6,9 +6,10 @@ import { registerIcons, } from '@conduction/nextcloud-vue' import { translatePlural as n, translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' import { createApp, h } from 'vue' // eslint-disable-next-line n/no-unpublished-import -import { createRouter, createWebHashHistory } from 'vue-router' +import { createRouter, createWebHistory } from 'vue-router' import App from './App.vue' import appIcons from './icons.js' import { ensureIntegrationRegistry } from './integrations/bootstrap.js' @@ -173,18 +174,38 @@ function routesFromManifest(manifest) { return routes } -// Hash mode (not history): the PHP backend registers exactly one frontend -// route — `dashboard#page` at `/` (appinfo/routes.php) — and no catch-all that -// serves the SPA shell for deep sub-paths like `/registers` or `/schemas`. In -// history mode a full-page load or bookmark to `#/registers` drops the fragment -// and resolves the base path `/` → the Dashboard surface, so the relocated / -// grouped index pages render empty (no Add button, no list) on deep-link — the -// #133 regression. Hash mode keeps every route under the single `/` server -// route, so `#/registers` etc. resolve client-side to their correct index -// surface. This also matches the e2e harness contract (tests deep-link via -// `/index.php/apps/openregister/#/`). +/** + * The router base for THIS page load. + * + * ⚠️ `generateUrl('/apps/openregister')` alone is not enough. Nextcloud serves + * the app under BOTH `/apps/openregister/...` and + * `/index.php/apps/openregister/...`, but `generateUrl()` returns only the form + * the instance is configured for. A visitor arriving on the other form falls + * outside the router base, vue-router cannot resolve the path, and the + * catch-all above redirects to `/`: they land on the Dashboard with no error + * and the deep link is silently swallowed. This app's e2e harness deep-links + * via the `/index.php` form, so pinning the other one would break every one of + * those. + * + * @return {string} The base path vue-router should strip from the URL. + */ +function routerBase() { + const match = window.location.pathname.match(/^(.*\/apps\/openregister)(?:\/|$)/) + return match ? match[1] : generateUrl('/apps/openregister') +} + +// History mode. This app ran on HASH mode as a workaround for #133: the PHP +// backend registered exactly one frontend route — `dashboard#page` at `/` — and +// no catch-all serving the SPA shell for deep sub-paths like `/registers` or +// `/schemas`, so a bookmark or full-page load never reached the SPA and the +// grouped index pages rendered empty (no Add button, no list). +// +// `dashboard#catchAll` in appinfo/routes.php now serves the shell on any +// sub-path, which removes the cause rather than working around it. Verified by +// requesting a NONSENSE path: /apps/openregister/zzz-nonsense answered 404 +// before and 401 after — an enumerated path would have proved nothing. const router = createRouter({ - history: createWebHashHistory(), + history: createWebHistory(routerBase()), routes: routesFromManifest(mergedManifest), }) diff --git a/src/views/quality/RegisterSchemaSelector.vue b/src/views/quality/RegisterSchemaSelector.vue index 944ca2e2d0..b11e4a76e1 100644 --- a/src/views/quality/RegisterSchemaSelector.vue +++ b/src/views/quality/RegisterSchemaSelector.vue @@ -34,9 +34,9 @@ import { qualityStore } from '../../store/store.js' * committed to the `quality` Pinia store so switching between MDM views * preserves the selection (design.md D3). * - * The selection is also mirrored into the hash-mode route query + * The selection is also mirrored into the route query * (`?register=&schema=`): a deep-link such as - * `#/quality?register=16&schema=1207` pre-selects and loads with no clicks, + * `/quality?register=16&schema=1207` pre-selects and loads with no clicks, * and every in-UI selection change reflects back into the URL so the view is * bookmarkable and shareable (mdm-views-route-scoping). The store remains the * source of truth; the route is a mirror + entry point. diff --git a/tests/Unit/AppHost/RoutesTest.php b/tests/Unit/AppHost/RoutesTest.php index dc9de7a846..53bbc9c310 100644 --- a/tests/Unit/AppHost/RoutesTest.php +++ b/tests/Unit/AppHost/RoutesTest.php @@ -72,9 +72,35 @@ public function testCatchAllIsLastAndHasPathRequirement(): void { $this->assertSame('dashboard#catchAll', $last['name']); $this->assertSame('/{path}', $last['url']); - $this->assertSame('.+', $last['requirements']['path']); + $this->assertSame('(?!api/).+', $last['requirements']['path']); }//end testCatchAllIsLastAndHasPathRequirement() + /** + * Being LAST in the `routes` array is not enough to keep the catch-all off + * the API, which is the trap this guards. Nextcloud's RouteParser processes + * `routes` BEFORE `resources` (RouteParser::parseDefaultRoutes) and Symfony + * matches in insertion order, so the catch-all still registers ahead of + * every route generated from a `resources` block. `.+` matches slashes, so + * without the lookahead it answers the SPA shell for GET api/ — + * with HTTP 200, so a JSON caller silently receives HTML. + * + * Asserts the requirement as a REGEX against real paths rather than as a + * string, so it keeps holding if the spelling is ever rewritten. + */ + public function testCatchAllRequirementExcludesApiPaths(): void { + $routes = Routes::standard()['routes']; + $last = end($routes); + $pattern = '#^' . $last['requirements']['path'] . '$#'; + + foreach (['api/taken', 'api/klanten', 'api/zrc/zaken', 'api/registers/1'] as $apiPath) { + $this->assertSame(0, preg_match($pattern, $apiPath), "catch-all must NOT match $apiPath"); + } + + foreach (['registers', 'schemas/12', 'features-roadmap', 'zaken/abc-123'] as $spaPath) { + $this->assertSame(1, preg_match($pattern, $spaPath), "catch-all MUST match $spaPath"); + } + }//end testCatchAllRequirementExcludesApiPaths() + public function testIndexRouteIsGetSlash(): void { $routes = Routes::standard()['routes']; $this->assertSame('/', $routes[0]['url']); diff --git a/tests/Unit/Calendar/CalendarEventTransformerTest.php b/tests/Unit/Calendar/CalendarEventTransformerTest.php index ce7c371ce3..b95a22b3ae 100644 --- a/tests/Unit/Calendar/CalendarEventTransformerTest.php +++ b/tests/Unit/Calendar/CalendarEventTransformerTest.php @@ -228,7 +228,7 @@ public function testUrlGeneration(): void { $result = $this->transformer->transform($object, $this->schema, $config); - $this->assertSame('/apps/openregister/#/objects/5/12/abc-123', $result['objects'][0]['URL'][0]); + $this->assertSame('/apps/openregister/objects/5/12/abc-123', $result['objects'][0]['URL'][0]); } public function testCategoriesIncludeOpenRegisterAndSchemaName(): void { diff --git a/tests/Unit/Controller/DashboardControllerTest.php b/tests/Unit/Controller/DashboardControllerTest.php index 2176973892..2d48b94b53 100644 --- a/tests/Unit/Controller/DashboardControllerTest.php +++ b/tests/Unit/Controller/DashboardControllerTest.php @@ -85,6 +85,23 @@ public function testPage(): void { $this->assertInstanceOf(TemplateResponse::class, $result); } + /** + * The SPA catch-all serves the same shell as page(). + * + * `dashboard#catchAll` at GET /{path} is what lets a deep link such as + * /registers reach the SPA at all; before it, only `/` was routed and every + * sub-path 404'd at the server, which is why this app ran on hash routing. + * Asserting equality with page() rather than merely "returns a response" + * pins the delegation, so a rewrite that stops delegating has to say so. + */ + public function testCatchAllServesTheSameShellAsPage(): void { + $result = $this->controller->catchAll(); + + $this->assertInstanceOf(TemplateResponse::class, $result); + $this->assertEquals($this->controller->page()->getTemplateName(), $result->getTemplateName()); + $this->assertEquals($this->controller->page()->getRenderAs(), $result->getRenderAs()); + } + public function testIndexSuccess(): void { $this->request->method('getParams')->willReturn([]); $this->dashboardService->method('getRegistersWithSchemas')->willReturn([]); diff --git a/tests/e2e/_page-routes.ts b/tests/e2e/_page-routes.ts index ff36a56c38..36cd92716e 100644 --- a/tests/e2e/_page-routes.ts +++ b/tests/e2e/_page-routes.ts @@ -39,7 +39,7 @@ * ⚠️ THE ROUTER RUNS IN HASH MODE (`src/main.js`). These are hash routes: a * path-form deep link is rewritten by the hash router and renders the * DASHBOARD instead of the target page. Callers must compose them as - * `…/apps/openregister/#${route}` — which every `gotoPage`/`gotoApp`/`go` + * `…/apps/openregister${route}` — which every `gotoPage`/`gotoApp`/`go` * helper in this suite already does. */ diff --git a/tests/e2e/ci/flow-controls.spec.ts b/tests/e2e/ci/flow-controls.spec.ts index 72ad381130..48e900e3b4 100644 --- a/tests/e2e/ci/flow-controls.spec.ts +++ b/tests/e2e/ci/flow-controls.spec.ts @@ -110,7 +110,7 @@ import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') -const FLOWS_ROUTE = '/index.php/apps/openregister/#/flows' +const FLOWS_ROUTE = '/index.php/apps/openregister/flows' test.use(fs.existsSync(STORAGE_STATE) ? { storageState: STORAGE_STATE } : {}) diff --git a/tests/e2e/ci/object-shares-tab.spec.ts b/tests/e2e/ci/object-shares-tab.spec.ts index 55b98cddf1..ab7be9c462 100644 --- a/tests/e2e/ci/object-shares-tab.spec.ts +++ b/tests/e2e/ci/object-shares-tab.spec.ts @@ -115,7 +115,7 @@ async function openSharesTab( uuid: string, ) { await page.goto( - `/index.php/apps/openregister/#/objects/${register}/${schema}/${uuid}`, + `/index.php/apps/openregister/objects/${register}/${schema}/${uuid}`, { waitUntil: 'domcontentloaded' }, ) diff --git a/tests/e2e/ci/smoke-boot.spec.ts b/tests/e2e/ci/smoke-boot.spec.ts index 9087c372cd..c9618abda3 100644 --- a/tests/e2e/ci/smoke-boot.spec.ts +++ b/tests/e2e/ci/smoke-boot.spec.ts @@ -44,7 +44,7 @@ const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') // shows up as a MIME refusal here and nowhere else. const ROUTES = [ { name: 'app root', url: '/index.php/apps/openregister/' }, - { name: 'registers', url: '/index.php/apps/openregister/#/registers' }, + { name: 'registers', url: '/index.php/apps/openregister/registers' }, ] /** Console messages that mean the bundle never booted. */ diff --git a/tests/e2e/core-crud.spec.ts b/tests/e2e/core-crud.spec.ts index 3aa78135da..6a39b24301 100644 --- a/tests/e2e/core-crud.spec.ts +++ b/tests/e2e/core-crud.spec.ts @@ -111,7 +111,7 @@ test.describe('frontend-app-bootstrap — app mount and data load', () => { test('navigating to /registers renders the register list view', async ({ page, }) => { - await page.goto('/index.php/apps/openregister/#/registers', { + await page.goto('/index.php/apps/openregister/registers', { waitUntil: 'domcontentloaded', }) // Don't wait for networkidle — NC SPA keeps background XHR alive indefinitely. @@ -141,7 +141,7 @@ test.describe('deep-link-registry — hash routes render correct views', () => { test.use({ storageState: STORAGE_STATE }) test('#/registers route renders register list', async ({ page }) => { - await page.goto('/index.php/apps/openregister/#/registers', { + await page.goto('/index.php/apps/openregister/registers', { waitUntil: 'domcontentloaded', }) // Should NOT redirect to a different page or show a 404. @@ -152,7 +152,7 @@ test.describe('deep-link-registry — hash routes render correct views', () => { }) test('#/schemas route renders schema list', async ({ page }) => { - await page.goto('/index.php/apps/openregister/#/schemas', { + await page.goto('/index.php/apps/openregister/schemas', { waitUntil: 'domcontentloaded', }) expect(page.url()).toContain('/openregister/') @@ -162,7 +162,7 @@ test.describe('deep-link-registry — hash routes render correct views', () => { }) test('#/objects route renders object list', async ({ page }) => { - await page.goto('/index.php/apps/openregister/#/objects', { + await page.goto('/index.php/apps/openregister/objects', { waitUntil: 'domcontentloaded', }) expect(page.url()).toContain('/openregister/') @@ -179,7 +179,7 @@ test.describe('deep-link-registry — hash routes render correct views', () => { test.skip(objectId === null, 'no live object found for deep-link test') await page.goto( - `/index.php/apps/openregister/#/objects/${REGISTER_ID}/${SCHEMA_ID}/${objectId}`, + `/index.php/apps/openregister/objects/${REGISTER_ID}/${SCHEMA_ID}/${objectId}`, { waitUntil: 'domcontentloaded' }, ) expect(page.url()).toContain('/openregister/') diff --git a/tests/e2e/crud/object-crud.spec.ts b/tests/e2e/crud/object-crud.spec.ts index 2a5a8a1a94..1f622ba7c5 100644 --- a/tests/e2e/crud/object-crud.spec.ts +++ b/tests/e2e/crud/object-crud.spec.ts @@ -126,7 +126,7 @@ test.describe('object-crud — create→read→update→delete with field-value test.skip(objectId === null, 'no object created') // Deep-link to the object detail (path routing in the manifest-v2 shell). - await page.goto(`${APP}/#/objects/${register.id}/${schema.id}/${objectId}`, { + await page.goto(`${APP}/objects/${register.id}/${schema.id}/${objectId}`, { waitUntil: 'domcontentloaded', }) await expect( @@ -172,7 +172,7 @@ test.describe('object-crud — create→read→update→delete with field-value expect(Number(fresh.count)).toBe(COUNT_UPDATED) // The detail surface still resolves the (now-updated) object by uuid. - await page.goto(`${APP}/#/objects/${register.id}/${schema.id}/${objectId}`, { + await page.goto(`${APP}/objects/${register.id}/${schema.id}/${objectId}`, { waitUntil: 'domcontentloaded', }) await expect( diff --git a/tests/e2e/crud/register-crud.spec.ts b/tests/e2e/crud/register-crud.spec.ts index 2a5c8c5b20..1b928ff3f4 100644 --- a/tests/e2e/crud/register-crud.spec.ts +++ b/tests/e2e/crud/register-crud.spec.ts @@ -35,7 +35,7 @@ import { makeRunId } from '../_fixtures.ts' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') // HASH form — the router runs in hash mode (src/main.js); the path-form URL // renders the dashboard instead of the registers page. -const REGISTERS_ROUTE = '/index.php/apps/openregister/#/registers' +const REGISTERS_ROUTE = '/index.php/apps/openregister/registers' const API = '/index.php/apps/openregister/api' const RUN_ID = makeRunId() diff --git a/tests/e2e/crud/schema-crud.spec.ts b/tests/e2e/crud/schema-crud.spec.ts index 42f2bd3df4..b1c87aa380 100644 --- a/tests/e2e/crud/schema-crud.spec.ts +++ b/tests/e2e/crud/schema-crud.spec.ts @@ -29,7 +29,7 @@ import { makeRunId, twoPropertySchema } from '../_fixtures.ts' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') // HASH form — the router runs in hash mode (src/main.js); the path-form URL // renders the dashboard instead of the schemas page. -const SCHEMAS_ROUTE = '/index.php/apps/openregister/#/schemas' +const SCHEMAS_ROUTE = '/index.php/apps/openregister/schemas' const API = '/index.php/apps/openregister/api' const RUN_ID = makeRunId() diff --git a/tests/e2e/docs-screenshots.spec.ts b/tests/e2e/docs-screenshots.spec.ts index 8082d4536f..22b0ba14dc 100644 --- a/tests/e2e/docs-screenshots.spec.ts +++ b/tests/e2e/docs-screenshots.spec.ts @@ -117,7 +117,7 @@ async function go(page: Page, route: string): Promise { const url = route.startsWith('/apps/') || route.startsWith('/settings/') ? `/index.php${route}` - : `/index.php${APP}/#${route}` + : `/index.php${APP}${route}` // `networkidle` NEVER settles on Nextcloud (ADR-074 rule 4): the // notification long-poll keeps a request in flight for the life of the // page, so this wait always ran to its timeout and the `.catch()` hid diff --git a/tests/e2e/flow-engine.spec.ts b/tests/e2e/flow-engine.spec.ts index cbd8024d75..8c0a43e203 100644 --- a/tests/e2e/flow-engine.spec.ts +++ b/tests/e2e/flow-engine.spec.ts @@ -656,7 +656,7 @@ test.describe('the Flows page', () => { // `networkidle` never settles on Nextcloud (ADR-074 rule 4) — the // readiness signal is the row/name assertion that follows each goto. - await page.goto(`/apps/openregister/#${FlowsIndex}`, { + await page.goto(`/apps/openregister${FlowsIndex}`, { waitUntil: 'domcontentloaded', }) @@ -664,7 +664,7 @@ test.describe('the Flows page', () => { timeout: 15000, }) - await page.goto(`/apps/openregister/#${FlowDetailPage(flow.id)}`, { + await page.goto(`/apps/openregister${FlowDetailPage(flow.id)}`, { waitUntil: 'domcontentloaded', }) @@ -692,7 +692,7 @@ test.describe('the Flows page', () => { nodes: [{ id: 'end1', type: 'openregister.end', config: {} }], }) - await page.goto(`/apps/openregister/#${FlowDetailPage(flow.id)}`, { + await page.goto(`/apps/openregister${FlowDetailPage(flow.id)}`, { waitUntil: 'domcontentloaded', }) @@ -742,7 +742,7 @@ test.describe('the Flows page', () => { { publish: false }, ) - await page.goto(`/apps/openregister/#${FlowDetailPage(flow.id)}`, { + await page.goto(`/apps/openregister${FlowDetailPage(flow.id)}`, { waitUntil: 'domcontentloaded', }) @@ -772,7 +772,7 @@ test.describe('the Flows page', () => { test('the list is an ordinary index page with a New flow action (ADR-096)', async ({ page, }) => { - await page.goto(`/apps/openregister/#${FlowsIndex}`, { + await page.goto(`/apps/openregister${FlowsIndex}`, { waitUntil: 'domcontentloaded', }) @@ -789,7 +789,7 @@ test.describe('the Flows page', () => { test('a new flow is the SAME editor holding only a starting point', async ({ page, }) => { - await page.goto(`/apps/openregister/#${FlowDetailPage('new')}`, { + await page.goto(`/apps/openregister${FlowDetailPage('new')}`, { waitUntil: 'domcontentloaded', }) @@ -823,7 +823,7 @@ test.describe('the Flows page', () => { page, request, }) => { - await page.goto(`/apps/openregister/#${FlowDetailPage('new')}`, { + await page.goto(`/apps/openregister${FlowDetailPage('new')}`, { waitUntil: 'domcontentloaded', }) @@ -868,7 +868,7 @@ test.describe('the Flows page', () => { // `networkidle` never settles on Nextcloud (ADR-074 rule 4); the row // assertion below is the real wait. - await page.goto(`/apps/openregister/#${FlowsIndex}`, { + await page.goto(`/apps/openregister${FlowsIndex}`, { waitUntil: 'domcontentloaded', }) diff --git a/tests/e2e/integration-mount.spec.ts b/tests/e2e/integration-mount.spec.ts index f41b39fb02..a38828a006 100644 --- a/tests/e2e/integration-mount.spec.ts +++ b/tests/e2e/integration-mount.spec.ts @@ -162,7 +162,7 @@ async function openObjectDetail( // URL dispatches `objectDetail` (route name in the manifest) to // ObjectsIndex and its param-watch primes the object store. await page.goto( - `${baseURL}/index.php/apps/openregister/#/objects/${triple.register}/${triple.schema}/${triple.objectId}`, + `${baseURL}/index.php/apps/openregister/objects/${triple.register}/${triple.schema}/${triple.objectId}`, // `networkidle` never settles on Nextcloud (ADR-074 rule 4): the // long-poll / notification channels keep a request in flight for the // life of the page, so it always burns its timeout. The readiness diff --git a/tests/e2e/leaf-screenshots.spec.ts b/tests/e2e/leaf-screenshots.spec.ts index f7602cb1c3..69052a7182 100644 --- a/tests/e2e/leaf-screenshots.spec.ts +++ b/tests/e2e/leaf-screenshots.spec.ts @@ -49,7 +49,7 @@ test.describe('Per-leaf screenshot harness', () => { await page.goto( // HASH form — the router runs in hash mode (src/main.js); the // path-form URL renders the dashboard instead of the integrations view. - `${baseURL}/index.php/apps/openregister/#${IntegrationsView( + `${baseURL}/index.php/apps/openregister${IntegrationsView( REGISTER, SCHEMA, OBJECT_ID, diff --git a/tests/e2e/manifest-shell.spec.ts b/tests/e2e/manifest-shell.spec.ts index 5c4253d8f7..733dbc8bf8 100644 --- a/tests/e2e/manifest-shell.spec.ts +++ b/tests/e2e/manifest-shell.spec.ts @@ -34,7 +34,7 @@ import * as fs from 'fs' import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '.auth/admin.json') -const APP_BASE = '/index.php/apps/openregister/#' +const APP_BASE = '/index.php/apps/openregister' function requireAuth() { if (!fs.existsSync(STORAGE_STATE)) { @@ -42,8 +42,8 @@ function requireAuth() { } } -async function gotoRoute(page: Page, hash: string) { - await page.goto(`${APP_BASE}${hash}`, { waitUntil: 'domcontentloaded' }) +async function gotoRoute(page: Page, route: string) { + await page.goto(`${APP_BASE}${route}`, { waitUntil: 'domcontentloaded' }) // App content shell must render for every manifest-driven route. await expect(page.locator('main, .app-content').first()).toBeVisible({ timeout: 25_000, @@ -84,7 +84,7 @@ test.describe('openregister-app-manifest — CnAppRoot shell mounts', () => { // A non-root manifest route must resolve via the manifest-built router // rather than redirecting away (catch-all only fires for unknown paths). await gotoRoute(page, '/schemas') - await expect(page).toHaveURL(/#\/schemas$/) + await expect(page).toHaveURL(/\/apps\/openregister\/schemas$/) await expect(page.locator('main, .app-content').first()).toBeVisible() }) }) @@ -155,7 +155,7 @@ test.describe('openregister-app-manifest — registry dispatch', () => { }) => { requireAuth() await gotoRoute(page, '/registers') - await expect(page).toHaveURL(/#\/registers$/) + await expect(page).toHaveURL(/\/apps\/openregister\/registers$/) await expect(page.locator('main, .app-content').first()).toBeVisible() }) @@ -171,7 +171,9 @@ test.describe('openregister-app-manifest — registry dispatch', () => { // router into a registry component and the app-content shell renders — the // scenario asserts the dispatch + shell, not that an unknown id stays put. await gotoRoute(page, '/registers/e2e-probe-id') - await expect(page).toHaveURL(/#\/registers(\/e2e-probe-id)?$/) + await expect(page).toHaveURL( + /\/apps\/openregister\/registers(\/e2e-probe-id)?$/, + ) await expect(page.locator('main, .app-content').first()).toBeVisible() }) diff --git a/tests/e2e/smoke-boot.spec.ts b/tests/e2e/smoke-boot.spec.ts index 99f1cd7078..1650d0a79b 100644 --- a/tests/e2e/smoke-boot.spec.ts +++ b/tests/e2e/smoke-boot.spec.ts @@ -44,7 +44,7 @@ const STORAGE_STATE = path.resolve(__dirname, '.auth/admin.json') // shows up as a MIME refusal here and nowhere else. const ROUTES = [ { name: 'app root', url: '/index.php/apps/openregister/' }, - { name: 'registers', url: '/index.php/apps/openregister/#/registers' }, + { name: 'registers', url: '/index.php/apps/openregister/registers' }, ] /** Console messages that mean the bundle never booted. */ diff --git a/tests/e2e/spec-coverage/admin-settings-pages.spec.ts b/tests/e2e/spec-coverage/admin-settings-pages.spec.ts index c00249f2ac..48a2fe57d7 100644 --- a/tests/e2e/spec-coverage/admin-settings-pages.spec.ts +++ b/tests/e2e/spec-coverage/admin-settings-pages.spec.ts @@ -83,7 +83,7 @@ function trackErrors(page: Page): { console: string[]; http: string[] } { async function gotoPage(page: Page, route: string): Promise { // HASH form — the router runs in hash mode (src/main.js); path-form // deep-links render the dashboard instead of the target page. - await page.goto(`/index.php/apps/openregister/#${route}`, { + await page.goto(`/index.php/apps/openregister${route}`, { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { diff --git a/tests/e2e/spec-coverage/core-list-pages.spec.ts b/tests/e2e/spec-coverage/core-list-pages.spec.ts index ed467b315a..bf419f533b 100644 --- a/tests/e2e/spec-coverage/core-list-pages.spec.ts +++ b/tests/e2e/spec-coverage/core-list-pages.spec.ts @@ -106,7 +106,7 @@ async function gotoPage(page: Page, route: string): Promise { // deep-link (`/apps/openregister/registers`) is rewritten by the hash // router to `/registers#/` and renders the DASHBOARD, not the target page // (verified empirically 2026-07-27). - await page.goto(`/index.php/apps/openregister/#${route}`, { + await page.goto(`/index.php/apps/openregister${route}`, { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { diff --git a/tests/e2e/spec-coverage/data-import-export.spec.ts b/tests/e2e/spec-coverage/data-import-export.spec.ts index 84d168f137..94f9046359 100644 --- a/tests/e2e/spec-coverage/data-import-export.spec.ts +++ b/tests/e2e/spec-coverage/data-import-export.spec.ts @@ -64,7 +64,7 @@ test.describe('data-import-export — import dialog reachability', () => { // The OR SPA uses Vue Router history mode with base /index.php/apps/openregister/ // The objects route is reached via the hash-anchor pattern used by the existing test suite - await page.goto('/index.php/apps/openregister/#/objects', { + await page.goto('/index.php/apps/openregister/objects', { waitUntil: 'domcontentloaded', }) // Nextcloud header must be visible @@ -90,7 +90,7 @@ test.describe('data-import-export — import dialog reachability', () => { if (!fs.existsSync(STORAGE_STATE)) test.skip(true, 'no auth state') // Navigate to the registers view where the import action is available - await page.goto('/index.php/apps/openregister/#/registers', { + await page.goto('/index.php/apps/openregister/registers', { waitUntil: 'domcontentloaded', }) await expect( diff --git a/tests/e2e/spec-coverage/detail-pages.spec.ts b/tests/e2e/spec-coverage/detail-pages.spec.ts index 184f655f89..8809337dca 100644 --- a/tests/e2e/spec-coverage/detail-pages.spec.ts +++ b/tests/e2e/spec-coverage/detail-pages.spec.ts @@ -165,7 +165,7 @@ async function gotoPage(page: Page, route: string): Promise { // HASH form — the router runs in hash mode (src/main.js). A path-form // deep-link (`/apps/openregister/applications/12`) is rewritten by the hash // router and renders the DASHBOARD, not the target page. - await page.goto(`/index.php/apps/openregister/#${route}`, { + await page.goto(`/index.php/apps/openregister${route}`, { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { diff --git a/tests/e2e/spec-coverage/entity-management-modals.spec.ts b/tests/e2e/spec-coverage/entity-management-modals.spec.ts index d03727100f..0795cb16e3 100644 --- a/tests/e2e/spec-coverage/entity-management-modals.spec.ts +++ b/tests/e2e/spec-coverage/entity-management-modals.spec.ts @@ -83,7 +83,7 @@ async function deleteTestObject( async function gotoApp(page: Page, subpath: string): Promise { // HASH form — the router runs in hash mode (src/main.js); path-form // deep-links render the dashboard instead of the target page. - await page.goto(`/index.php/apps/openregister/#${subpath}`, { + await page.goto(`/index.php/apps/openregister${subpath}`, { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { @@ -151,7 +151,7 @@ test.describe('entity-management-modals — copy-single-object-names-the-duplica // register+schema pre-selected so the route watcher triggers // applyQueryParamsFromRoute → performSearchWithFacets immediately. await page.goto( - `/index.php/apps/openregister/#/tables?register=${REGISTER_ID}&schema=${SCHEMA_ID}`, + `/index.php/apps/openregister/tables?register=${REGISTER_ID}&schema=${SCHEMA_ID}`, { waitUntil: 'domcontentloaded' }, ) await page.waitForSelector('#header, header.header-appcontainer', { diff --git a/tests/e2e/spec-coverage/feature-pages.spec.ts b/tests/e2e/spec-coverage/feature-pages.spec.ts index b40e597c2f..a53518af76 100644 --- a/tests/e2e/spec-coverage/feature-pages.spec.ts +++ b/tests/e2e/spec-coverage/feature-pages.spec.ts @@ -89,7 +89,7 @@ function trackErrors( async function gotoPage(page: Page, route: string): Promise { // HASH form — the router runs in hash mode (src/main.js); path-form // deep-links render the dashboard instead of the target page. - await page.goto(`/index.php/apps/openregister/#${route}`, { + await page.goto(`/index.php/apps/openregister${route}`, { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { @@ -243,7 +243,7 @@ test.describe('feature-pages — real UI render + actions', () => { page, }) => { await gotoPage(page, '/registers') - await expect(page).toHaveURL(/#\/registers$/) + await expect(page).toHaveURL(/\/apps\/openregister\/registers$/) // The manifest declares this entry in the `footer` section; CnAppNav // renders footer entries as NcAppNavigationItem router-links. @@ -253,7 +253,9 @@ test.describe('feature-pages — real UI render + actions', () => { await expect(navEntry).toHaveCount(1) await navEntry.first().click() - await expect(page).toHaveURL(/#\/features-roadmap$/, { timeout: 15_000 }) + await expect(page).toHaveURL(/\/apps\/openregister\/features-roadmap$/, { + timeout: 15_000, + }) const view = page.locator('.cn-features-and-roadmap-view') await expect(view).toBeVisible({ timeout: 15_000 }) diff --git a/tests/e2e/spec-coverage/features-roadmap-surface.spec.ts b/tests/e2e/spec-coverage/features-roadmap-surface.spec.ts index 6405235c6c..fc793ee780 100644 --- a/tests/e2e/spec-coverage/features-roadmap-surface.spec.ts +++ b/tests/e2e/spec-coverage/features-roadmap-surface.spec.ts @@ -58,7 +58,7 @@ const FLAG_PATH = * deep-link renders the dashboard instead of the target page. */ async function gotoRoadmapRoute(page: Page): Promise { - await page.goto('/index.php/apps/openregister/#/features-roadmap', { + await page.goto('/index.php/apps/openregister/features-roadmap', { waitUntil: 'domcontentloaded', }) await page diff --git a/tests/e2e/spec-coverage/files-sidebar-tabs.spec.ts b/tests/e2e/spec-coverage/files-sidebar-tabs.spec.ts index dd6e7fc0c3..7e0363168d 100644 --- a/tests/e2e/spec-coverage/files-sidebar-tabs.spec.ts +++ b/tests/e2e/spec-coverage/files-sidebar-tabs.spec.ts @@ -45,7 +45,7 @@ const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') async function gotoApp(page: Page, subpath: string): Promise { // HASH form — the router runs in hash mode (src/main.js); path-form // deep-links render the dashboard instead of the target page. - await page.goto(`/index.php/apps/openregister/#${subpath}`, { + await page.goto(`/index.php/apps/openregister${subpath}`, { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { @@ -440,7 +440,7 @@ test.describe('files-sidebar-tabs — applyfilters-writes-filter-state-to-the-ur page, }) => { // Start from clean /deleted (hash form — router runs in hash mode). - await page.goto('/index.php/apps/openregister/#/deleted', { + await page.goto('/index.php/apps/openregister/deleted', { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { diff --git a/tests/e2e/spec-coverage/mdm-frontend.spec.ts b/tests/e2e/spec-coverage/mdm-frontend.spec.ts index 0906e25a1b..ce2ee2ff3b 100644 --- a/tests/e2e/spec-coverage/mdm-frontend.spec.ts +++ b/tests/e2e/spec-coverage/mdm-frontend.spec.ts @@ -8,7 +8,7 @@ * @e2e openspec/changes/mdm-frontend/specs/mdm-frontend/spec.md# * * Methodology: drive the real UI. The five MDM views live under the hash-mode - * router (`/index.php/apps/openregister/#/`); the shared + * router (`/index.php/apps/openregister/`); the shared * RegisterSchemaSelector is now route-scoped, so when the self-seeding MDM * fixture (tests/e2e/mdm-seed.ts, run in globalSetup) has planted data, these * tests DEEP-LINK straight to the seeded register/schema @@ -33,7 +33,7 @@ function scopedQuery(): string { /** Navigate to a hash-mode OR route and wait for NC header + app content. */ async function gotoApp(page: Page, route: string): Promise { - await page.goto(`/index.php/apps/openregister/#${route}`, { + await page.goto(`/index.php/apps/openregister${route}`, { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { diff --git a/tests/e2e/spec-coverage/mdm-merge-ui.spec.ts b/tests/e2e/spec-coverage/mdm-merge-ui.spec.ts index f981b3dde7..8e6a472597 100644 --- a/tests/e2e/spec-coverage/mdm-merge-ui.spec.ts +++ b/tests/e2e/spec-coverage/mdm-merge-ui.spec.ts @@ -33,7 +33,7 @@ function scopedQuery(): string { /** Navigate to a hash-mode OR route and wait for NC header + app content. */ async function gotoApp(page: Page, route: string): Promise { - await page.goto(`/index.php/apps/openregister/#${route}`, { + await page.goto(`/index.php/apps/openregister${route}`, { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { diff --git a/tests/e2e/spec-coverage/mdm-survivorship-override.spec.ts b/tests/e2e/spec-coverage/mdm-survivorship-override.spec.ts index ba67e1e243..ebb64c2735 100644 --- a/tests/e2e/spec-coverage/mdm-survivorship-override.spec.ts +++ b/tests/e2e/spec-coverage/mdm-survivorship-override.spec.ts @@ -33,7 +33,7 @@ function scopedQuery(): string { /** Navigate to a hash-mode OR route and wait for NC header + app content. */ async function gotoApp(page: Page, route: string): Promise { - await page.goto(`/index.php/apps/openregister/#${route}`, { + await page.goto(`/index.php/apps/openregister${route}`, { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { diff --git a/tests/e2e/spec-coverage/register-i18n.spec.ts b/tests/e2e/spec-coverage/register-i18n.spec.ts index 861bc2c586..abcef59783 100644 --- a/tests/e2e/spec-coverage/register-i18n.spec.ts +++ b/tests/e2e/spec-coverage/register-i18n.spec.ts @@ -175,7 +175,7 @@ test.describe('register-i18n — register language management UI', () => { if (!fs.existsSync(STORAGE_STATE)) test.skip(true, 'no auth state') if (!registerId) test.skip(true, 'no test register') - await page.goto(`/index.php/apps/openregister/#/registers`, { + await page.goto(`/index.php/apps/openregister/registers`, { waitUntil: 'domcontentloaded', }) // Wait for the app to mount @@ -311,7 +311,7 @@ test.describe('register-i18n — register language management UI', () => { }) await page - .goto(`/index.php/apps/openregister/#/registers`, { + .goto(`/index.php/apps/openregister/registers`, { waitUntil: 'domcontentloaded', }) @@ -340,7 +340,7 @@ test.describe('register-i18n — register language management UI', () => { if (!fs.existsSync(STORAGE_STATE)) test.skip(true, 'no auth state') // Navigate to registers - await page.goto(`/index.php/apps/openregister/#/registers`, { + await page.goto(`/index.php/apps/openregister/registers`, { waitUntil: 'domcontentloaded', }) await expect(page.locator('main, .app-content').first()).toBeVisible({ @@ -444,7 +444,7 @@ test.describe('register-i18n — register language management UI', () => { }) => { if (!fs.existsSync(STORAGE_STATE)) test.skip(true, 'no auth state') - await page.goto(`/index.php/apps/openregister/#/registers`, { + await page.goto(`/index.php/apps/openregister/registers`, { waitUntil: 'domcontentloaded', }) await expect(page.locator('main, .app-content').first()).toBeVisible({ diff --git a/tests/e2e/spec-coverage/saved-search-views.spec.ts b/tests/e2e/spec-coverage/saved-search-views.spec.ts index c9f686fe3a..e9f9811f50 100644 --- a/tests/e2e/spec-coverage/saved-search-views.spec.ts +++ b/tests/e2e/spec-coverage/saved-search-views.spec.ts @@ -54,7 +54,7 @@ async function deleteView(request: APIRequestContext, id: number): Promise async function gotoTablesPage(page: Page): Promise { // HASH form — the router runs in hash mode (src/main.js); the path-form // URL renders the dashboard instead of the tables page. - await page.goto('/index.php/apps/openregister/#/tables', { + await page.goto('/index.php/apps/openregister/tables', { waitUntil: 'domcontentloaded', }) await page.waitForSelector('#header, header.header-appcontainer', { diff --git a/tests/e2e/ui-navigation.spec.ts b/tests/e2e/ui-navigation.spec.ts index 1dc771aeac..9aaeb1c727 100644 --- a/tests/e2e/ui-navigation.spec.ts +++ b/tests/e2e/ui-navigation.spec.ts @@ -45,7 +45,7 @@ test.describe('frontend-app-bootstrap — navigation routes load', () => { ) } // Use domcontentloaded — the NC SPA keeps background XHR alive so networkidle never fires. - await page.goto(`/index.php/apps/openregister/#${route.hash}`, { + await page.goto(`/index.php/apps/openregister${route.hash}`, { waitUntil: 'domcontentloaded', }) @@ -75,7 +75,7 @@ test.describe('built-in-dashboards — dashboard renders', () => { test.skip(true, 'storageState not present') } - await page.goto('/index.php/apps/openregister/#/', { + await page.goto('/index.php/apps/openregister/', { waitUntil: 'domcontentloaded', }) @@ -106,7 +106,7 @@ test.describe('features-roadmap — roadmap view renders', () => { test.skip(true, 'storageState not present') } - await page.goto('/index.php/apps/openregister/#/features-roadmap', { + await page.goto('/index.php/apps/openregister/features-roadmap', { waitUntil: 'domcontentloaded', }) @@ -127,7 +127,7 @@ test.describe('no-code-app-builder — applications view', () => { test.skip(true, 'storageState not present') } - await page.goto('/index.php/apps/openregister/#/applications', { + await page.goto('/index.php/apps/openregister/applications', { waitUntil: 'domcontentloaded', }) diff --git a/tests/e2e/visual/dsar-cases.visual.spec.ts b/tests/e2e/visual/dsar-cases.visual.spec.ts index f4d49faeb6..ad428562d4 100644 --- a/tests/e2e/visual/dsar-cases.visual.spec.ts +++ b/tests/e2e/visual/dsar-cases.visual.spec.ts @@ -34,6 +34,6 @@ test.describe('Open Register — DSAR cases visual baseline', () => { ) } // The AVG view opens on the Activities tab; the Cases tab is client-side. - await shootSurface(page, `${APP}/#/avg`, 'avg-cases.png') + await shootSurface(page, `${APP}/avg`, 'avg-cases.png') }) }) diff --git a/tests/e2e/visual/mdm-frontend.visual.spec.ts b/tests/e2e/visual/mdm-frontend.visual.spec.ts index 1fbcae0336..4a3eab1b73 100644 --- a/tests/e2e/visual/mdm-frontend.visual.spec.ts +++ b/tests/e2e/visual/mdm-frontend.visual.spec.ts @@ -62,27 +62,23 @@ async function selectFirstRegisterAndSchema(page: Page): Promise { test.describe('mdm-frontend — visual baselines', () => { // QualityIndex (Data Quality dashboard). test('QualityIndex', async ({ page }) => { - await shootSurface(page, `${APP}/#/quality`, 'QualityIndex.png') + await shootSurface(page, `${APP}/quality`, 'QualityIndex.png') }) // DuplicatesIndex (Duplicate Candidates, read-only). test('DuplicatesIndex', async ({ page }) => { - await shootSurface(page, `${APP}/#/duplicates`, 'DuplicatesIndex.png') + await shootSurface(page, `${APP}/duplicates`, 'DuplicatesIndex.png') }) // MasterEntitiesIndex (master-entity list). test('MasterEntitiesIndex', async ({ page }) => { - await shootSurface( - page, - `${APP}/#/master-entities`, - 'MasterEntitiesIndex.png', - ) + await shootSurface(page, `${APP}/master-entities`, 'MasterEntitiesIndex.png') }) // GoldenRecordDetail (opened from MasterEntitiesIndex — not a standalone // route, so this baseline drives the panel open before shooting). test('GoldenRecordDetail', async ({ page }) => { - await page.goto(`${APP}/#/master-entities`, { + await page.goto(`${APP}/master-entities`, { waitUntil: 'domcontentloaded', }) await dismissSupportDialog(page) @@ -115,6 +111,6 @@ test.describe('mdm-frontend — visual baselines', () => { // QueueHealthIndex (Queue / sync health). test('QueueHealthIndex', async ({ page }) => { - await shootSurface(page, `${APP}/#/queue-health`, 'QueueHealthIndex.png') + await shootSurface(page, `${APP}/queue-health`, 'QueueHealthIndex.png') }) }) diff --git a/tests/e2e/visual/mdm-merge-ui.visual.spec.ts b/tests/e2e/visual/mdm-merge-ui.visual.spec.ts index 8f346f47ca..0f9c048075 100644 --- a/tests/e2e/visual/mdm-merge-ui.visual.spec.ts +++ b/tests/e2e/visual/mdm-merge-ui.visual.spec.ts @@ -64,14 +64,14 @@ test.describe('mdm-merge-ui — visual baselines', () => { test('MergeOperationsIndex', async ({ page }) => { await shootSurface( page, - `${APP}/#/mergeOperations`, + `${APP}/mergeOperations`, 'MergeOperationsIndex.png', ) }) // MdmMergeWizardModal — opened from a candidate pair on DuplicatesIndex. test('MdmMergeWizardModal', async ({ page }) => { - await page.goto(`${APP}/#/duplicates`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP}/duplicates`, { waitUntil: 'domcontentloaded' }) await dismissSupportDialog(page) await waitForContentReady(page) diff --git a/tests/e2e/visual/mdm-survivorship-override.visual.spec.ts b/tests/e2e/visual/mdm-survivorship-override.visual.spec.ts index c40a5ffab4..e5c661c40c 100644 --- a/tests/e2e/visual/mdm-survivorship-override.visual.spec.ts +++ b/tests/e2e/visual/mdm-survivorship-override.visual.spec.ts @@ -62,7 +62,7 @@ test.describe('mdm-survivorship-override — visual baselines', () => { test('MdmConflictResolutionModal', async ({ page }) => { // Manifest route is kebab-case '/master-entities' (src/manifest.json); // '#/masterEntities' hits the catch-all and redirects to the dashboard. - await page.goto(`${APP}/#/master-entities`, { + await page.goto(`${APP}/master-entities`, { waitUntil: 'domcontentloaded', }) await dismissSupportDialog(page) diff --git a/tests/e2e/visual/openregister.visual.spec.ts b/tests/e2e/visual/openregister.visual.spec.ts index 8bb27dc215..4be8f2003b 100644 --- a/tests/e2e/visual/openregister.visual.spec.ts +++ b/tests/e2e/visual/openregister.visual.spec.ts @@ -17,7 +17,7 @@ const APP = '/index.php/apps/openregister' test.describe('Open Register — visual baselines', () => { test('dashboard', async ({ page }) => { - await shootSurface(page, `${APP}/#/`, 'dashboard.png') + await shootSurface(page, `${APP}/`, 'dashboard.png') }) // NOTE: OpenRegister's in-app sidebar routes do not switch the rendered diff --git a/tests/e2e/workflows/dsar-cases.spec.ts b/tests/e2e/workflows/dsar-cases.spec.ts index 494cffd784..8059d0dd67 100644 --- a/tests/e2e/workflows/dsar-cases.spec.ts +++ b/tests/e2e/workflows/dsar-cases.spec.ts @@ -24,7 +24,7 @@ import * as fs from 'fs' import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '..', '.auth', 'admin.json') -const AVG_URL = '/index.php/apps/openregister/#/avg' +const AVG_URL = '/index.php/apps/openregister/avg' // Safe placeholders only (nil UUID / literal token) — never real values. const NIL_UUID = '00000000-0000-0000-0000-000000000000' diff --git a/tests/e2e/workflows/object-lifecycle-workflows.spec.ts b/tests/e2e/workflows/object-lifecycle-workflows.spec.ts index 242025019d..b60983fb82 100644 --- a/tests/e2e/workflows/object-lifecycle-workflows.spec.ts +++ b/tests/e2e/workflows/object-lifecycle-workflows.spec.ts @@ -161,7 +161,7 @@ test.describe('workflow: audit-trail records create and update', () => { const trails = await getTrails(request) test.skip(trails.length === 0, 'no audit trail entries to render') - await page.goto(`${APP}/#/audit-trails`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP}/audit-trails`, { waitUntil: 'domcontentloaded' }) await expect(page.locator('main, .app-content').first()).toBeVisible({ timeout: 30_000, }) @@ -299,7 +299,7 @@ test.describe('workflow: soft-delete then restore', () => { }) test('UI: the Deleted view renders', async ({ page }) => { - await page.goto(`${APP}/#/deleted`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP}/deleted`, { waitUntil: 'domcontentloaded' }) // The Deleted view must at least mount. (BUG-1 is now fixed, so the list // is populated by GET /api/deleted — asserted at the API level above.) await expect(page.locator('main, .app-content').first()).toBeVisible({ From 985d3514ece5b32d628a049f22140ce4bd90e04f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 15:20:39 +0200 Subject: [PATCH 31/68] feat(flow): task inbox projections, notifications you can answer, a VTODO that writes back through the gate (#3271) * feat(tasks): the task store - four tables, entities and mappers The flow-task-entity storage layer (openspec/changes/flow-task-entity): openregister_tasks with the resolved 23-shape union (no overdue column, no app-named column, performer_type as an open string vocabulary so the coming external type is an append, definition_version shipped now), openregister_task_candidates as the pooled-inbox index half of the candidate pool, openregister_task_relations as the typed anchor table, and openregister_task_audit with an append-only mapper whose update and delete refuse. claim() is a conditional update so the database decides the race, and the inbox page and total share one predicate builder. Ticks tasks 1.1 and 1.2. * feat(tasks): the vocabularies, the authorization and the routing Normalisation at the boundary: TaskState publishes the one legacy-status mapping onto the six CMMN states (collapsed distinctions survive on outcome, an unmapped value is refused naming itself, and a migration may declare its source vocabulary so procest's status 'open' fails loudly); TaskPriority lands all four fleet scales on low|normal|high|urgent and refuses pipelinq's 'normaal'; TaskTemporalProjection is the ONE overdue derivation, computed and never stored. TaskAuthorizationService decides every verb fail-closed BEFORE mutation: no group backend, an unresolvable role or an unknown performer type each DENY rather than skip, and no nullable service-unavailable return exists. TaskPerformerResolver carries the five routing strategies; a strategy that finds nobody assigns nobody. Ticks tasks 2.1, 2.2, 3.1 and 3.2. * feat(tasks): the lifecycle service, cancellation propagation and the inbox TaskService: create/offer/claim/unclaim/assign/reassign/delegate/ resolve/complete/cancel, each authorized fail-closed before any mutation, each audited in the SAME transaction (an audit-write failure unwinds the completion), denials audited too. One write path maintains the candidate JSON and the candidate index rows together; state and is_terminal move in one statement; claim is the mapper's conditional update so the race's loser gets a conflict; a rejecting outcome without a comment is refused before anything moves; the template snapshot is frozen at creation. Cancellation propagation (design D-8): FlowRunMapper::update() - the one choke point every terminal run write passes - dispatches FlowRunTerminalEvent, and TaskRunTerminalListener terminates that run's open tasks with the reason recorded. Idempotent, and a task with run_uuid null is structurally out of reach. TaskInboxService answers assigned/pooled/watched/by-object in the datastore over one shared predicate set, with the total from the same predicates, subject context batched per page, a display title synthesized on read for titleless tasks (never persisted), and the derived temporal projection attached to rows only. Ticks tasks 4.1-4.5, 5.1, 6.1 and 6.2. * feat(tasks): the REST surface and the seed fixtures TaskController + /api/flow-tasks routes for the inbox, the detail and audit reads (both visibility-checked) and every lifecycle verb. The family is named for the flow-tasks CAPABILITY, not a flow requirement - /api/tasks itself already belongs to the CalDAV VTODO leaf. Every method declares its auth posture attribute AND delegates its actual authorization to TaskAuthorizationService inside the service; refusals translate uniformly (400 validation, 403 denial, 409 conflict naming the current state, 404 absence). SeedTaskFixtures installs the five seed groups from design.md (pooled municipal check with no run, delegated approval with enforcing expiry on a run, agent task with a typed checklist, one completed 'approved' and one terminated by propagation, plus audit fixtures including one DENIED entry), idempotent on uuid - and is REGISTERED in appinfo/info.xml in this same commit, because an unregistered repair step is a known fleet defect. Ticks tasks 7.1 and 8.1. * test(tasks): the guarantees, pinned - 93 tests over the task layer Table-driven legacy-status and priority tests including the live fleet defects: procest's status 'open' against a source vocabulary that does not define it is refused naming the value, and pipelinq's 'normaal' is refused on every scale. Authorization: a stranger who knows the uuid is denied on every verb, an unresolvable role denies naming the role, an absent group backend denies, an unknown performer type denies, watchers read and never act. Concurrency and transactionality: the two-claim race yields one assignee and one conflict, a rejection without a comment moves nothing, and an injected audit-write failure rolls the completion back. Derivation: overdue flips with an injected clock while the stored row stays byte-identical. Inbox: the page and the total run over the SAME criteria object (25 of 120 reported honestly), a titleless task presents a synthesized display title while its stored title stays null, and a runless task completes identically to a runful one. Mutation-checked: disabling assertMay fails 7 tests; removing the transaction rollback fails 2; both restored byte-identical (sha256-verified). Ticks tasks 9.1, 9.2 and 9.3. * test(tasks): playwright coverage for the two e2e-marked scenarios 'a stranger is refused on the task detail route': a provisioned second account gets 403 on the detail read AND on complete, and the task provably did not move - the positive control for the hole where knowing a uuid was the whole check. 'the inbox route returns tasks with subject context': one request lists the assignee's work with a display title, the subject slot, the derived overdue flag and a datastore total, and when the dev seed offers a live object the anchored subject context rides along. Both specs clean up after themselves and skip loudly when the instance cannot provision a stranger. Ticks task 9.4. * style(tasks): named parameters on every internal call, no inline ifs The repo's PHPCS standard requires named arguments on calls into OCA code and forbids ternaries. Exception constructors now name message:, the transactional/stringOrNull/arrayOrNull/respondWith calls name their parameter, the nine isset-ternaries collapse into one intOrNull helper, and the two remaining conditionals become explicit if blocks. No behaviour change: the 93 task tests are unchanged and green. * refactor(tasks): the quality pass - intake extracted, complexity earned down TaskBuilder now owns intake: boundary data in, a validated unsaved Task out, with every published vocabulary applied there once. That returns TaskService to lifecycle only and under the complexity budget. The authorization switch becomes a verb-to-rule table with static dispatch; the inbox predicate builder splits into scope, visibility and filters; the migration's indexes and the seed fixtures get methods of their own; sort and direction travel as one parameter (-dueAt) so the inbox route keeps a single-digit parameter list. The few remaining suppressions each carry their justification inline. Behaviour unchanged: the 93 task tests pass untouched except for injecting the builder. * fix(tasks): the PHPStan pass - typed mapper overrides and a DBAL constant The mapper overrides now guard their entity type with a throwing instanceof check, which is what lets the generic QBMapper contract type-check (and turns a wrong entity into a named error instead of a silent write). candidateMembershipPredicate declares the IQueryFunction it returns. The migration uses Types::DATETIME_MUTABLE, the constant the current DBAL stubs actually define. * style(tasks): import InvalidArgumentException in the three mapper guards PHPMD MissingImport on the type guards added in the PHPStan pass. No behaviour change. * fix(tasks): the review pass - offer is the requester's, and the inbox works on PostgreSQL Two blocking defects from the independent review of #3258: - offer bypassed per-task authorization and rewrote the assignee. Any authenticated user could POST offer {"routingFallback": "mallory"} on an assigned active task, become its assignee, and then complete it. offer now needs the requester (or an administrator), refuses a task that already has an assignee (unclaim or reassign instead), and is in the stranger-denied test's verb list, whose omission is how the suite stayed green. - The watchers LIKE ran against a json column, which PostgreSQL refuses (operator does not exist: json ~~ unknown): every non-admin inbox request 500ed there. The column is now cast per platform (AS TEXT on PostgreSQL, AS CHAR elsewhere), every identifier in the mapper's raw SQL goes through the platform's own quoter (no backticks), and TaskMapperTest pins the cast on a PostgreSQL platform mock. The should-fix items: authorization runs before the terminality check and a task the caller may not see answers 404 on every route, verbs included (no existence or state oracle); over HTTP the requester is pinned to the actor and a terminal creation state is refused, with import() as the trusted path for migrations and the user-task node; every state-changing write is a conditional update (WHERE is_terminal = false) so a second completion conflicts instead of overwriting; delegate refuses an empty delegate and keeps naming the original performer on re-delegation; an unexpected failure is a generic 500 whose detail goes to the log; propagation continues past a failing task; role pools are matched by the inbox EXISTS; the seed fixtures run only when app config openregister/seed_demo_tasks is true; the CSRF posture is recorded in the controller docblock; the e2e stranger scenario fails loudly instead of skipping and now covers the non-admin inbox and a watcher. Class-level @spec tags on every new class. TaskControllerTest is the contract test for all fourteen /api/flow-tasks endpoints (gate-25 PASS). Checks on this tree: PHPCS 0, PHPMD 0 (touched files), PHPStan 0, Psalm 0, 120 task tests green. * style(tasks): format the task inbox e2e spec with the Nextcloud prettier config CI's Frontend Check (format) named tests/e2e/task-inbox.spec.ts. Formatted with @nextcloud/prettier-config's options (tabs, no semicolons, single quotes, trailing commas, print width 85, operators leading). * test(tasks): cover the entities, the listener, the seed step and the builder CI's pgsql cell did not fail in PHPUnit (17860 tests, 0 failures, and app:enable succeeded); it failed the coverage guard: the changed files measured 3.85% (69/1791 statements) against a 7.32% base, because the suite runs with strict coverage metadata and collaborators exercised through TaskServiceTest were never credited. This adds the tests that were genuinely missing: the four entities' typing, hydration and serialisation (no overdue anywhere in the stored row); the terminal-run listener and event; the seed step off by default, seeding the five groups when on, idempotent on uuid, and continuing past a failing fixture; and TaskBuilder credited where TaskServiceTest exercises it. 133 task tests green. * feat(flow): task inbox projections, notifications, VTODO and the write-back gate Delivers the engine task where people already look (flow-task-inbox- projections, ADR-098 D2): a committed transition is announced after its transaction and becomes a declarative notification through the existing dispatcher (TaskNotificationRules, TaskObjectAdapter, task-verb action kind rendered as POST) and a VTODO in the assignee's own calendar with DUE, URL and X-OPENREGISTER-TASK. Write-back is one gate carrying (task_uuid, verb, actor): a Sabre plugin refuses in-band, a calendar event listener reverts committed bypasses, both audit and notify the actor. GET /api/tasks answers from the inbox; the CalDAV leaf becomes a projection writer and the assignee prose is gone. * test(tasks): walk the mapper queries and credit every exercised class The pgsql cell's coverage guard credits only statements in classes a test names with @covers (strict coverage metadata), and the changed files measured 20.16% against a 23.46% base. Two moves: every task test class now names each class it actually exercises (TaskBuilder, TaskState and TaskPriority behind TaskServiceTest, TaskInboxCriteria behind the inbox and controller tests, the entities and exceptions everywhere they are built), and the largest untested code is now walked: TaskMapper's whole query vocabulary, the candidate, relation and audit mappers, and FlowRunMapper's terminality announcement, over a fluent query-builder double that records which predicate and column each query sets, so a conditional update without its openness guard or a pooled scope without its EXISTS would fail here. Plus the untested TaskService verbs (unclaim, assign, reassign, cancel, resolve, terminateAsMoot, relations, intake refusals), TaskInboxService's batched subject context, and the controller's filter, flag and failing-backend paths. 168 task tests. * wip: checkpoint before rate-limit pause (2026-09-01 09:45) * fix(flow): drop the node_modules symlink from the index, fix a spec anchor, exempt the redirect route * fix(flow): analyzer cleanups, terminal-event listener, sweep clock from ITimeFactory PHPMD per subdirectory, psalm and phpstan clean: the pool resolver and the calendar locator split by concern, the task-verb validation extracted, the CalDavBackend baseline entries retired by the one class-level suppression. The scheduled sweep takes its clock from ITimeFactory so the derived-overdue candidate query and the rule filter read one instant. TaskTerminalProjection- Listener subscribes by name to flow-user-task-node's TaskTerminalEvent for dismiss-on-terminality, idempotent beside the transition listeners; the dispatcher parameter is named as that branch names it so the two merge on one hunk. * style(tasks): a top-level type import and sorted specifiers in the inbox e2e spec Vue Quality (eslint) on #3258 named four errors in tests/e2e/task-inbox.spec.ts: perfectionist/sort-named-imports (the specifiers now read apiRequest, expect, test) and import-extensions/ban-inline-type-imports (APIRequestContext is a top-level import type). eslint --fix plus a plain run: 0 errors; the sibling flow-engine.spec.ts as control also 0; prettier clean. * wip: checkpoint after host restart (2026-09-01 14:08) * style(tasks): one ExcessiveParameterList tag, grouped, after the merge union --------- Co-authored-by: Conduction Release Bot --- appinfo/info.xml | 11 + appinfo/routes.php | 4 + lib/AppInfo/Application.php | 51 +- .../TaskScheduledNotificationJob.php | 348 +++++++++++ lib/Controller/TaskController.php | 39 ++ lib/Controller/TasksController.php | 188 +++++- lib/Dav/TaskVtodoWriteBackPlugin.php | 322 ++++++++++ lib/Db/TaskProjectionState.php | 146 +++++ lib/Db/TaskProjectionStateMapper.php | 91 +++ lib/Event/TaskTransitionedEvent.php | 130 ++++ .../TaskCalendarProjectionListener.php | 69 +++ lib/Listener/TaskNotificationListener.php | 148 +++++ .../TaskTerminalProjectionListener.php | 119 ++++ lib/Listener/TaskVtodoWriteBackListener.php | 197 ++++++ lib/Migration/Version1Date20260901130000.php | 88 +++ lib/Notification/AnnotationNotifier.php | 19 +- .../AnnotationNotificationDispatcher.php | 108 +++- .../NotificationAnnotationValidator.php | 57 +- .../Notification/TaskNotificationRules.php | 321 ++++++++++ .../Notification/TaskObjectAdapter.php | 148 +++++ .../TaskPoolRecipientResolver.php | 140 +++++ lib/Service/Task/TaskCalendarProjector.php | 575 ++++++++++++++++++ lib/Service/Task/TaskInboxService.php | 18 + lib/Service/Task/TaskProjectionService.php | 149 +++++ lib/Service/Task/TaskService.php | 108 +++- lib/Service/Task/TaskVtodoStatusMapping.php | 168 +++++ lib/Service/Task/TaskVtodoWriteBackGate.php | 353 +++++++++++ lib/Service/Task/VtodoCalendarLocator.php | 126 ++++ lib/Service/TaskService.php | 259 +++++--- .../flow-task-inbox-projections/tasks.md | 61 +- phpstan.neon | 12 + psalm-baseline.xml | 43 -- psalm.xml | 8 + .../TaskScheduledNotificationJobTest.php | 183 ++++++ tests/Unit/Controller/TasksControllerTest.php | 213 ++++--- .../Unit/Dav/TaskVtodoWriteBackPluginTest.php | 215 +++++++ .../Listener/TaskNotificationListenerTest.php | 166 +++++ .../TaskTerminalProjectionListenerTest.php | 107 ++++ .../TaskVtodoWriteBackListenerTest.php | 127 ++++ .../TaskNotificationRulesTest.php | 144 +++++ .../Notification/TaskObjectAdapterTest.php | 102 ++++ .../TaskPoolRecipientResolverTest.php | 106 ++++ .../Notification/TaskVerbActionTest.php | 216 +++++++ .../Task/TaskCalendarProjectorTest.php | 368 +++++++++++ .../Task/TaskProjectionServiceTest.php | 93 +++ .../Task/TaskServiceAnnouncementTest.php | 160 +++++ .../Task/TaskServiceTerminalEventTest.php | 28 +- .../Task/TaskVtodoStatusMappingTest.php | 87 +++ .../Task/TaskVtodoWriteBackGateTest.php | 263 ++++++++ .../Service/Task/VtodoCalendarLocatorTest.php | 69 +++ .../Service/TaskServiceProjectionTest.php | 158 +++++ tests/e2e/task-projections.spec.ts | 394 ++++++++++++ tests/stubs/NextcloudInternalStubs.php | 74 +++ 53 files changed, 7621 insertions(+), 276 deletions(-) create mode 100644 lib/BackgroundJob/TaskScheduledNotificationJob.php create mode 100644 lib/Dav/TaskVtodoWriteBackPlugin.php create mode 100644 lib/Db/TaskProjectionState.php create mode 100644 lib/Db/TaskProjectionStateMapper.php create mode 100644 lib/Event/TaskTransitionedEvent.php create mode 100644 lib/Listener/TaskCalendarProjectionListener.php create mode 100644 lib/Listener/TaskNotificationListener.php create mode 100644 lib/Listener/TaskTerminalProjectionListener.php create mode 100644 lib/Listener/TaskVtodoWriteBackListener.php create mode 100644 lib/Migration/Version1Date20260901130000.php create mode 100644 lib/Service/Notification/TaskNotificationRules.php create mode 100644 lib/Service/Notification/TaskObjectAdapter.php create mode 100644 lib/Service/Notification/TaskPoolRecipientResolver.php create mode 100644 lib/Service/Task/TaskCalendarProjector.php create mode 100644 lib/Service/Task/TaskProjectionService.php create mode 100644 lib/Service/Task/TaskVtodoStatusMapping.php create mode 100644 lib/Service/Task/TaskVtodoWriteBackGate.php create mode 100644 lib/Service/Task/VtodoCalendarLocator.php create mode 100644 tests/Unit/BackgroundJob/TaskScheduledNotificationJobTest.php create mode 100644 tests/Unit/Dav/TaskVtodoWriteBackPluginTest.php create mode 100644 tests/Unit/Listener/TaskNotificationListenerTest.php create mode 100644 tests/Unit/Listener/TaskTerminalProjectionListenerTest.php create mode 100644 tests/Unit/Listener/TaskVtodoWriteBackListenerTest.php create mode 100644 tests/Unit/Service/Notification/TaskNotificationRulesTest.php create mode 100644 tests/Unit/Service/Notification/TaskObjectAdapterTest.php create mode 100644 tests/Unit/Service/Notification/TaskPoolRecipientResolverTest.php create mode 100644 tests/Unit/Service/Notification/TaskVerbActionTest.php create mode 100644 tests/Unit/Service/Task/TaskCalendarProjectorTest.php create mode 100644 tests/Unit/Service/Task/TaskProjectionServiceTest.php create mode 100644 tests/Unit/Service/Task/TaskServiceAnnouncementTest.php create mode 100644 tests/Unit/Service/Task/TaskVtodoStatusMappingTest.php create mode 100644 tests/Unit/Service/Task/TaskVtodoWriteBackGateTest.php create mode 100644 tests/Unit/Service/Task/VtodoCalendarLocatorTest.php create mode 100644 tests/Unit/Service/TaskServiceProjectionTest.php create mode 100644 tests/e2e/task-projections.spec.ts diff --git a/appinfo/info.xml b/appinfo/info.xml index 29cd40200a..8d3311070b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -142,6 +142,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\BackgroundJob\DsarRetentionSweepJob OCA\OpenRegister\BackgroundJob\ReportRenderJob OCA\OpenRegister\BackgroundJob\NotificationQueueFlushJob + OCA\OpenRegister\BackgroundJob\TaskScheduledNotificationJob OCA\OpenRegister\BackgroundJob\ArchivalRetentionTask OCA\OpenRegister\BackgroundJob\FlowRunWorker OCA\OpenRegister\BackgroundJob\FlowScheduleWorker @@ -372,4 +373,14 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Contacts\ContactsMenuProvider + + + + OCA\OpenRegister\Dav\TaskVtodoWriteBackPlugin + + diff --git a/appinfo/routes.php b/appinfo/routes.php index c82a4f3ec0..0b20292f61 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1356,6 +1356,10 @@ // VTODO leaf (tasks#allUserTasks above), which is a different thing. // Every verb's real authorization is TaskAuthorizationService inside // the service; the route attribute is never the whole check. + // The one stable "open this task" address (flow-task-inbox-projections): + // the VTODO URL, the notification buttons and the rule actions all + // resolve here, and it redirects into the app's task route. + ['name' => 'task#open', 'url' => '/flow-tasks/{uuid}', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'task#index', 'url' => '/api/flow-tasks', 'verb' => 'GET'], ['name' => 'task#create', 'url' => '/api/flow-tasks', 'verb' => 'POST'], ['name' => 'task#show', 'url' => '/api/flow-tasks/{uuid}', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 38461e9682..4279a376e4 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -1180,11 +1180,24 @@ private function registerObjectInteractionServices(IRegistrationContext $context $context->registerService( TaskService::class, function (ContainerInterface $container) { + // The write-back gate is resolved lazily and best-effort: without + // it a PROJECTED VTODO's update is refused (fail closed), and + // standalone VTODOs are unaffected either way. + $gate = null; + try { + $gate = $container->get(\OCA\OpenRegister\Service\Task\TaskVtodoWriteBackGate::class); + } catch (\Throwable $e) { + $container->get('Psr\Log\LoggerInterface')->debug( + '[Application] TaskVtodoWriteBackGate unavailable for TaskService: ' . $e->getMessage() + ); + } + return new TaskService( calDavBackend: $container->get('OCA\DAV\CalDAV\CalDavBackend'), userSession: $container->get('OCP\IUserSession'), logger: $container->get('Psr\Log\LoggerInterface'), - urlGenerator: $container->get('OCP\IURLGenerator') + urlGenerator: $container->get('OCP\IURLGenerator'), + gate: $gate ); } ); @@ -2541,6 +2554,42 @@ private function registerEventListeners(IRegistrationContext $context): void { \OCA\OpenRegister\Listener\UserTaskTerminalListener::class ); + // Task projections (flow-task-inbox-projections): a committed + // transition becomes a declarative notification and a VTODO in the + // assignee's calendar. Both run AFTER the commit and neither can fail + // the transition (design D-8). Withdrawal runs before delivery, so the + // notification listener is registered first. + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTransitionedEvent::class, + \OCA\OpenRegister\Listener\TaskNotificationListener::class + ); + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTransitionedEvent::class, + \OCA\OpenRegister\Listener\TaskCalendarProjectionListener::class + ); + + // Dismiss-on-terminality, on the same terminal-task event the + // user-task node listens to: no approve button and no open VTODO + // survives a terminal task, however it became terminal. Idempotent + // beside the transition listeners above: a second withdrawal finds + // nothing to withdraw and a second render finds nothing changed. + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTerminalEvent::class, + \OCA\OpenRegister\Listener\TaskTerminalProjectionListener::class + ); + + // The safety-net write-back hook (design D-6): calendar writes that + // reached the backend without traversing the Sabre plugin are + // reverted to the engine's truth, and the actor is told why. + $context->registerEventListener( + \OCP\Calendar\Events\CalendarObjectUpdatedEvent::class, + \OCA\OpenRegister\Listener\TaskVtodoWriteBackListener::class + ); + $context->registerEventListener( + \OCP\Calendar\Events\CalendarObjectDeletedEvent::class, + \OCA\OpenRegister\Listener\TaskVtodoWriteBackListener::class + ); + // Lifecycle annotation listeners — see x-openregister-lifecycle. // Order matters: initial state runs on creating; validation runs on updating. $context->registerEventListener(ObjectCreatingEvent::class, LifecycleInitialStateListener::class); diff --git a/lib/BackgroundJob/TaskScheduledNotificationJob.php b/lib/BackgroundJob/TaskScheduledNotificationJob.php new file mode 100644 index 0000000000..44e8104284 --- /dev/null +++ b/lib/BackgroundJob/TaskScheduledNotificationJob.php @@ -0,0 +1,348 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category BackgroundJob + * @package OCA\OpenRegister\BackgroundJob + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-deadline-notifications-filter-on-the-derived-predicate + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\BackgroundJob; + +use DateTime; +use DateTimeImmutable; +use OCA\OpenRegister\Db\NotificationDedupeStateMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; +use OCA\OpenRegister\Service\Notification\ScheduledFilterEvaluator; +use OCA\OpenRegister\Service\Notification\TaskNotificationRules; +use OCA\OpenRegister\Service\Notification\TaskObjectAdapter; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The task-side scheduled sweep. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The sweep joins the rule + * registry, the inbox query, the filter evaluator, the dedupe state and the + * dispatcher; each is the platform's one implementation of that concern. + * @SuppressWarnings(PHPMD.StaticAccess) DateTime conversions between the + * mutable clock the inbox takes and the immutable one the evaluator takes. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-deadline-notifications-filter-on-the-derived-predicate + */ +class TaskScheduledNotificationJob extends TimedJob { + + /** + * Page size of the candidate sweep. + */ + private const PAGE = 200; + + /** + * Upper bound on tasks inspected per run. + */ + private const MAX_PER_RUN = 5000; + + /** + * The synthetic schema id the dedupe rows are keyed under. Tasks have no + * stored schema; this constant is the registry's stand-in. + */ + public const DEDUPE_SCHEMA_ID = -1; + + /** + * The uid the sweep queries as: an administrator's view, which the inbox + * does not narrow. It is never a recipient. + */ + private const SWEEP_UID = '__openregister_task_sweep__'; + + /** + * Constructor. + * + * @param ITimeFactory $time The clock. + * @param TaskNotificationRules $rules The task rule registry. + * @param TaskMapper $tasks The candidate query. + * @param TaskInboxService $inbox The row the adapter is built from. + * @param ScheduledFilterEvaluator $filters The operator-object filter evaluator. + * @param NotificationDedupeStateMapper $dedupe Per-(rule, task) fire state. + * @param AnnotationNotificationDispatcher $dispatcher The one dispatcher. + * @param IAppConfig $appConfig Last-fire timestamps per rule. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + ITimeFactory $time, + private readonly TaskNotificationRules $rules, + private readonly TaskMapper $tasks, + private readonly TaskInboxService $inbox, + private readonly ScheduledFilterEvaluator $filters, + private readonly NotificationDedupeStateMapper $dedupe, + private readonly AnnotationNotificationDispatcher $dispatcher, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 3600); + + }//end __construct() + + /** + * Fire every due scheduled task rule. + * + * @param mixed $argument Unused job argument. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The signature is Nextcloud's. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-deadline-notifications-filter-on-the-derived-predicate + */ + protected function run($argument): void { + $now = $this->time->getTime(); + + foreach ($this->rules->getRules() as $name => $rule) { + $trigger = ($rule['trigger'] ?? null); + if (is_array($trigger) === false || (string)($trigger['type'] ?? '') !== 'scheduled') { + continue; + } + + if (($rule['enabled'] ?? true) === false) { + continue; + } + + $interval = max(60, (int)($trigger['intervalSec'] ?? 86400)); + if ($this->isDue(name: (string)$name, intervalSec: $interval, now: $now) === false) { + continue; + } + + $this->fire(name: (string)$name, rule: $rule, trigger: $trigger); + $this->appConfig->setValueString('openregister', $this->stateKey(name: (string)$name), (string)$now); + }//end foreach + }//end run() + + /** + * Evaluate one scheduled rule over the open, past-due tasks. + * + * @param string $name The rule name. + * @param array $rule The rule. + * @param array $trigger Its trigger block. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-deadline-notifications-filter-on-the-derived-predicate + */ + private function fire(string $name, array $rule, array $trigger): void { + $filter = (array)($trigger['filter'] ?? []); + // One clock instant for the whole sweep: the candidate query's + // derived-overdue filter and the rule's declared filter read the same + // "now", so the two derivations cannot disagree within a run. + $nowMutable = $this->time->getDateTime(); + $nowDt = DateTimeImmutable::createFromMutable($nowMutable); + $schema = $this->schemaFor(name: $name, rule: $rule); + $watched = $this->watchedFields(trigger: $trigger); + + $matched = 0; + $dispatched = 0; + $offset = 0; + while ($offset < self::MAX_PER_RUN) { + $page = $this->candidates(now: $nowMutable, offset: $offset); + if ($page === []) { + break; + } + + $offset += count($page); + + foreach ($page as $task) { + $row = $this->inbox->enrich(task: $task); + $adapter = new TaskObjectAdapter(task: $task, row: $row); + $payload = ($adapter->getObject() ?? []); + + if ($this->filters->matches(objectData: $payload, filter: $filter, now: $nowDt) === false) { + continue; + } + + $matched++; + $uuid = (string)$task->getUuid(); + $fingerprint = $this->fingerprint(payload: $payload, watched: $watched); + $existing = $this->dedupe->findOne(schemaId: self::DEDUPE_SCHEMA_ID, ruleKey: $name, objectUuid: $uuid); + if ($existing !== null && (string)$existing->getFingerprint() === $fingerprint) { + continue; + } + + try { + $this->dispatcher->dispatchWithSchema( + object: $adapter, + trigger: 'scheduled', + context: ['notificationName' => $name], + schema: $schema + ); + $this->dedupe->upsert( + schemaId: self::DEDUPE_SCHEMA_ID, + ruleKey: $name, + objectUuid: $uuid, + fingerprint: $fingerprint, + now: $nowMutable, + dispatched: true + ); + $dispatched++; + } catch (Throwable $failure) { + $this->logger->warning( + sprintf('[TaskScheduledNotificationJob] Rule "%s" failed for task %s: %s', $name, $uuid, $failure->getMessage()) + ); + } + }//end foreach + + if (count($page) < self::PAGE) { + break; + } + }//end while + + $this->logger->info( + sprintf('[TaskScheduledNotificationJob] Rule "%s": matched=%d dispatched=%d', $name, $matched, $dispatched) + ); + }//end fire() + + /** + * The candidate page: open tasks whose deadline lies before now, through + * the inbox query's derived-overdue filter. + * + * @param DateTime $now The clock instant. + * @param int $offset Page offset. + * + * @return array The page. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-deadline-notifications-filter-on-the-derived-predicate + */ + private function candidates(DateTime $now, int $offset): array { + $criteria = new TaskInboxCriteria( + uid: self::SWEEP_UID, + isAdmin: true, + scope: TaskInboxCriteria::SCOPE_ALL, + isTerminal: false, + overdueAt: $now, + sort: TaskInboxCriteria::SORT_CREATED + ); + + try { + return $this->tasks->findInbox(criteria: $criteria, limit: self::PAGE, offset: $offset); + } catch (Throwable $failure) { + $this->logger->warning('[TaskScheduledNotificationJob] Candidate query failed: ' . $failure->getMessage()); + + return []; + } + }//end candidates() + + /** + * A synthetic schema carrying ONLY the named rule, so the dispatcher's + * `scheduled` trigger fires exactly that rule. + * + * @param string $name The rule name. + * @param array $rule The rule. + * + * @return Schema The one-rule schema. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-deadline-notifications-filter-on-the-derived-predicate + */ + private function schemaFor(string $name, array $rule): Schema { + $schema = $this->rules->buildSchema(); + $schema->setConfiguration(['x-openregister-notifications' => [$name => $rule]]); + + return $schema; + }//end schemaFor() + + /** + * Whether a rule's interval has elapsed since it last fired. + * + * @param string $name The rule name. + * @param int $intervalSec The declared interval. + * @param int $now The clock. + * + * @return bool True when due. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-deadline-notifications-filter-on-the-derived-predicate + */ + private function isDue(string $name, int $intervalSec, int $now): bool { + $last = (int)$this->appConfig->getValueString('openregister', $this->stateKey(name: $name), '0'); + + return ($now - $last) >= $intervalSec; + }//end isDue() + + /** + * The app-config key holding a rule's last fire time. + * + * @param string $name The rule name. + * + * @return string The key. + */ + private function stateKey(string $name): string { + return 'sched_task:' . $name; + }//end stateKey() + + /** + * The payload fields whose values re-arm the rule when they change. + * + * @param array $trigger The trigger block. + * + * @return array Sorted field names; empty means fire once per task. + */ + private function watchedFields(array $trigger): array { + $fields = []; + foreach ((array)($trigger['dedupeFields'] ?? []) as $field) { + if (is_string($field) === true && $field !== '') { + $fields[] = $field; + } + } + + $fields = array_values(array_unique($fields)); + sort($fields); + + return $fields; + }//end watchedFields() + + /** + * The dedupe fingerprint over the watched fields. + * + * @param array $payload The adapter payload. + * @param array $watched The watched field names. + * + * @return string SHA-1 of the watched values. + */ + private function fingerprint(array $payload, array $watched): string { + $values = []; + foreach ($watched as $field) { + $values[$field] = ($payload[$field] ?? null); + } + + return sha1((string)json_encode($values)); + }//end fingerprint() +}//end class diff --git a/lib/Controller/TaskController.php b/lib/Controller/TaskController.php index 7246e40d48..9dc81fe1b4 100644 --- a/lib/Controller/TaskController.php +++ b/lib/Controller/TaskController.php @@ -57,6 +57,8 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\IURLGenerator; use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -69,6 +71,8 @@ * @SuppressWarnings(PHPMD.TooManyPublicMethods) One route method per * lifecycle verb the spec names, plus the three reads. Folding verbs into a * mode parameter is how per-verb authorization rules get lost. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) One collaborator per + * concern, injected; the tenth builds the deep link the projections carry. * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The controller mediates * between HTTP and the task services plus their three exception shapes; * that is the whole of its job. @@ -96,6 +100,8 @@ class TaskController extends Controller { * bare; absent means no groups * and not admin, which SCOPES * rather than widens. + * @param IURLGenerator|null $urlGenerator Builds the app link the open + * route redirects into. */ public function __construct( string $appName, @@ -107,11 +113,44 @@ public function __construct( private readonly IUserSession $userSession, private readonly ?LoggerInterface $logger = null, private readonly ?IGroupManager $groupManager = null, + private readonly ?IURLGenerator $urlGenerator = null, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() + /** + * The deep link every projection carries: a page route that lands a + * person on the task's own surface. + * + * The notification actions, the VTODO `URL` and the route action in the + * task rules all resolve to THIS route, so there is one stable address + * for "open this task" however the task reached the person. It redirects + * into the OpenRegister app's task route; the form itself is + * `flow-task-forms`' surface, and lands under the same hash. + * + * @param string $uuid The task uuid. + * + * @return RedirectResponse Into the app. + * + * @no-admin-idor-exempt Reads nothing: the uuid is only interpolated into + * a redirect to the app's task route, where the SPA performs the + * visibility-checked read (TaskController::show, 404 for the invisible). + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function open(string $uuid): RedirectResponse { + $safeUuid = rawurlencode($uuid); + $base = '/index.php/apps/openregister/'; + if ($this->urlGenerator !== null) { + $base = $this->urlGenerator->linkToRoute('openregister.dashboard.page'); + } + + return new RedirectResponse(rtrim($base, '/') . '/#/flow-tasks/' . $safeUuid); + }//end open() + /** * The inbox: what is waiting for me, with subject context and a total. * diff --git a/lib/Controller/TasksController.php b/lib/Controller/TasksController.php index 386a31510a..be12bd21a1 100644 --- a/lib/Controller/TasksController.php +++ b/lib/Controller/TasksController.php @@ -25,13 +25,22 @@ namespace OCA\OpenRegister\Controller; use Exception; +use OCA\OpenRegister\Db\TaskInboxCriteria; use OCA\OpenRegister\Exception\NoVtodoCalendarException; +use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskState; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; use OCA\OpenRegister\Service\TaskService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; use OCP\IRequest; +use OCP\IUserSession; +use Throwable; /** * TasksController handles task operations for objects in registers. @@ -41,6 +50,12 @@ * * @category Controller * @package OCA\OpenRegister\Controller + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The controller now serves + * two stores: the CalDAV leaf for standalone VTODOs and the engine inbox for + * the aggregate, plus the three refusal shapes each can produce. + * @SuppressWarnings(PHPMD.StaticAccess) TaskState is the stateless published + * status mapping. */ class TasksController extends Controller { @@ -65,6 +80,14 @@ class TasksController extends Controller { * @param IRequest $request HTTP request object * @param TaskService $taskService Task service for VTODO operations * @param ObjectService $objectService Object service for object validation + * @param TaskInboxService|null $inbox The engine inbox the aggregate answers from; + * without it the aggregate refuses (401), never + * falls back to walking calendars + * @param IUserSession|null $userSession Names the caller for the aggregate + * @param TaskTemporalProjection|null $temporal The one overdue derivation's clock + * @param IGroupManager|null $groupManager Resolves the caller's groups and admin + * status; absent means no groups and not + * admin, which scopes rather than widens * * @return void */ @@ -73,6 +96,10 @@ public function __construct( IRequest $request, TaskService $taskService, ObjectService $objectService, + private readonly ?TaskInboxService $inbox = null, + private readonly ?IUserSession $userSession = null, + private readonly ?TaskTemporalProjection $temporal = null, + private readonly ?IGroupManager $groupManager = null, ) { parent::__construct(appName: $appName, request: $request); @@ -81,53 +108,154 @@ public function __construct( }//end __construct() /** - * Get all tasks for the current user across all calendars. + * The user-wide task aggregate: what the session user owes, from the + * engine inbox. + * + * Answered by TaskInboxService, never by walking calendars: visibility, + * filter, sort, page and total are the query's, so the total cannot + * reveal a task the caller may not see and a page cannot silently drop + * rows. The caller is resolved from the session, never from a request + * parameter, so no parameter can widen the read to another user. * - * Returns all CalDAV VTODOs from the user's VTODO-supporting calendars, - * optionally filtered by status or assignee. + * `assignee` is NO LONGER accepted as a filter: the aggregate is already + * scoped to the caller, and the free-text description prose it used to + * match no longer exists. `status` (legacy) and `state` both name a + * lifecycle state through the published TaskState mapping; an unmapped + * value is refused with 400 rather than silently ignored. * - * Authorization: this endpoint is anchored to the current session user. - * TaskService::getAllUserTasks() resolves the calendar set from - * IUserSession::getUser()->getUID() (principals/users/); the request - * never controls which user's calendars are read. The optional `assignee` - * request parameter is a free-text filter applied to each task's - * description ATTENDEE field within the caller's own task list — it is - * NOT an identity claim and cannot be used to read another user's tasks. - * Per ADR-005 Rule 3, no per-object authorization anchor is needed beyond - * the session-user binding already enforced in the service. + * Authorization: session-scoped (ADR-005 Rule 3), enforced in the inbox + * query's WHERE clause. * - * @return JSONResponse JSON response with all user tasks + * @return JSONResponse The page: results, total, limit, offset * * @NoAdminRequired * @NoCSRFRequired - * @no-admin-idor-exempt Session-scoped list: TaskService::getAllUserTasks resolves the calendar set from the IUserSession UID; - * the assignee param filters within the caller's own tasks, not an identity claim. * - * @spec openspec/changes/retrofit-2026-05-24-b-ctrl-misc/tasks.md#task-2 + * @no-admin-idor-exempt Session-scoped list: TaskInboxService::inbox() narrows visibility to the + * IUserSession UID inside the query; no request parameter names another user. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-user-wide-task-aggregate-endpoint */ public function allUserTasks(): JSONResponse { + $uid = $this->userSession?->getUser()?->getUID(); + if ($this->inbox === null || $uid === null || trim($uid) === '') { + return new JSONResponse(data: ['error' => 'No session'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + $limit = min((int)($this->request->getParam('_limit') ?? $this->request->getParam('limit') ?? 50), 200); + $offset = (int)($this->request->getParam('_offset') ?? $this->request->getParam('offset') ?? 0); + $scope = (string)($this->request->getParam('scope') ?? TaskInboxCriteria::SCOPE_ALL); + + try { + $states = $this->requestedStates(); + } catch (TaskValidationException $refused) { + return new JSONResponse(data: ['error' => $refused->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + $overdueAt = null; + $overdue = $this->request->getParam('overdue'); + if ($overdue !== null && filter_var($overdue, FILTER_VALIDATE_BOOLEAN) === true) { + $overdueAt = ($this->temporal ?? new TaskTemporalProjection())->now(); + } + + $sort = (string)($this->request->getParam('sort') ?? TaskInboxCriteria::SORT_DUE); + try { - $status = $this->request->getParam('status'); - $limit = min((int)($this->request->getParam('_limit') ?? $this->request->getParam('limit') ?? 50), 200); - $offset = (int)($this->request->getParam('_offset') ?? $this->request->getParam('offset') ?? 0); - $assignee = $this->request->getParam('assignee'); - - $result = $this->taskService->getAllUserTasks( - status: $status, - limit: $limit, - offset: $offset, - assignee: $assignee + $criteria = new TaskInboxCriteria( + uid: $uid, + groupIds: $this->groupIds(), + isAdmin: $this->isAdmin(uid: $uid), + scope: $scope, + states: $states, + isTerminal: $this->requestedTerminal(), + overdueAt: $overdueAt, + sort: ltrim($sort, '-'), + sortDescending: str_starts_with($sort, '-'), ); - return new JSONResponse(data: $result); - } catch (Exception $e) { + return new JSONResponse(data: $this->inbox->inbox(criteria: $criteria, limit: $limit, offset: $offset)); + } catch (Throwable $failure) { return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: 500 + data: ['error' => $failure->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR ); }//end try }//end allUserTasks() + /** + * The lifecycle states the request filters on, through the published mapping. + * + * @return array Canonical CMMN states; empty means no state filter + * + * @throws TaskValidationException When a value is in no known vocabulary + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-user-wide-task-aggregate-endpoint + */ + private function requestedStates(): array { + $raw = ($this->request->getParam('state') ?? $this->request->getParam('status')); + if (is_string($raw) === false || trim($raw) === '') { + return []; + } + + $states = []; + foreach (array_filter(array_map('trim', explode(',', $raw))) as $value) { + $states[] = TaskState::normalise(value: $value)['state']; + } + + return array_values(array_unique($states)); + }//end requestedStates() + + /** + * The terminality filter, when the request names one. + * + * @return bool|null True/false to restrict; null for both + */ + private function requestedTerminal(): ?bool { + $raw = $this->request->getParam('isTerminal'); + if ($raw === null) { + return null; + } + + return filter_var($raw, FILTER_VALIDATE_BOOLEAN); + }//end requestedTerminal() + + /** + * The caller's group ids, or none without a backend (which scopes). + * + * @return array The group ids + */ + private function groupIds(): array { + $user = $this->userSession?->getUser(); + if ($this->groupManager === null || $user === null) { + return []; + } + + try { + return $this->groupManager->getUserGroupIds($user); + } catch (Throwable) { + return []; + } + }//end groupIds() + + /** + * Whether the caller is an administrator; false without a backend. + * + * @param string $uid The caller + * + * @return bool True only when the backend affirms it + */ + private function isAdmin(string $uid): bool { + if ($this->groupManager === null) { + return false; + } + + try { + return $this->groupManager->isAdmin($uid); + } catch (Throwable) { + return false; + } + }//end isAdmin() + /** * List all tasks linked to a specific object. * diff --git a/lib/Dav/TaskVtodoWriteBackPlugin.php b/lib/Dav/TaskVtodoWriteBackPlugin.php new file mode 100644 index 0000000000..941b972b02 --- /dev/null +++ b/lib/Dav/TaskVtodoWriteBackPlugin.php @@ -0,0 +1,322 @@ +` and loaded + * by apps/dav's PluginManager. It acts only on VTODOs carrying + * `X-OPENREGISTER-TASK`; every other calendar object passes untouched. What + * it does with a projected VTODO is hand the document to the ONE gate: an + * accepted verb advances the engine and the stored document becomes the + * engine's fresh rendering; a refusal becomes a DAV 403, so there is no + * window in which the user's client believes the task is done + * (flow-task-inbox-projections, design D-6). + * + * Creating a VTODO that carries a task identity through DAV is refused: only + * the projector creates projected VTODOs, so a VTODO carrying an engine task + * identity always corresponds to a task the engine authorized into + * existence. Deleting one is refused too, with the reason: a task is not + * cancelled by removing the reminder of it. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Dav + * @package OCA\OpenRegister\Dav + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Dav; + +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Task\TaskVtodoWriteBackGate; +use OCP\AppFramework\Db\DoesNotExistException; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ICollection; +use Sabre\DAV\INode; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Throwable; + +/** + * Sabre plugin: refuse in-band, or store the engine's rendering. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ +class TaskVtodoWriteBackPlugin extends ServerPlugin { + + /** + * The DAV path prefix calendar objects live under. + */ + private const CALENDAR_PREFIX = 'calendars/'; + + /** + * The principal prefix a user's DAV identity carries. + */ + private const PRINCIPAL_PREFIX = 'principals/users/'; + + /** + * The Sabre server, once initialised. + * + * @var Server|null + */ + private ?Server $server = null; + + /** + * Constructor. + * + * @param TaskVtodoWriteBackGate $gate The one gate. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly TaskVtodoWriteBackGate $gate, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Subscribe to the write hooks. + * + * Priority 90: after Sabre's own CalDAV validation (which runs at 100 + * and normalises the document), before the write reaches the backend. + * + * @param Server $server The Sabre server. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + public function initialize(Server $server): void { + $this->server = $server; + $server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 90); + $server->on('beforeCreateFile', [$this, 'beforeCreateFile'], 90); + $server->on('beforeUnbind', [$this, 'beforeUnbind'], 90); + }//end initialize() + + /** + * The plugin's name, for Sabre's plugin registry. + * + * @return string The name. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + public function getPluginName(): string { + return 'openregister-task-write-back'; + }//end getPluginName() + + /** + * An existing calendar object is being overwritten. + * + * @param string $path The DAV path. + * @param INode $node The node being written. + * @param resource|string|null $data The incoming body, by reference. + * @param bool $modified Set to true when the body is replaced, by reference. + * + * @return void + * + * @throws Forbidden When the gate refuses. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The node is Sabre's hook signature. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + public function beforeWriteContent(string $path, INode $node, &$data, &$modified): void { + if ($this->isCalendarPath(path: $path) === false) { + return; + } + + $body = $this->bodyOf(data: $data); + if ($body === null || $this->gate->isProjected(calendarData: $body) === false) { + return; + } + + $replacement = $this->guard( + call: fn (): ?string => $this->gate->handleWrite(calendarData: $body, actor: $this->actor()) + ); + + if ($replacement !== null) { + $data = $replacement; + $modified = true; + } + }//end beforeWriteContent() + + /** + * A new calendar object is being created. + * + * @param string $path The DAV path. + * @param resource|string|null $data The incoming body, by reference. + * @param ICollection $parent The calendar. + * @param bool $modified Unused: a refusal replaces nothing. + * + * @return void + * + * @throws Forbidden When the body forges a task identity. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The parent and flag are Sabre's hook signature. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-tasks-on-objects-via-caldav-vtodo + */ + public function beforeCreateFile(string $path, &$data, ICollection $parent, &$modified): void { + if ($this->isCalendarPath(path: $path) === false) { + return; + } + + $body = $this->bodyOf(data: $data); + if ($body === null || $this->gate->isProjected(calendarData: $body) === false) { + return; + } + + $this->logger->warning( + '[TaskVtodoWriteBackPlugin] Refused a calendar object that forges an engine task identity.', + ['path' => $path, 'actor' => $this->actor()] + ); + + throw new Forbidden('A calendar entry cannot create an engine task. Only OpenRegister writes task entries into a calendar.'); + }//end beforeCreateFile() + + /** + * A calendar object is being deleted. + * + * @param string $path The DAV path. + * + * @return void + * + * @throws Forbidden When the object is a projected VTODO. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-tasks-on-objects-via-caldav-vtodo + */ + public function beforeUnbind(string $path): void { + if ($this->isCalendarPath(path: $path) === false || $this->server === null) { + return; + } + + try { + $node = $this->server->tree->getNodeForPath($path); + } catch (Throwable) { + return; + } + + if (method_exists($node, 'get') === false) { + return; + } + + $body = $this->bodyOf(data: $node->get()); + if ($body === null || $this->gate->isProjected(calendarData: $body) === false) { + return; + } + + throw new Forbidden('This entry is an OpenRegister task. Complete or cancel the task instead of deleting its calendar entry.'); + }//end beforeUnbind() + + /** + * Run the gate, translating every refusal into a DAV 403 that names the reason. + * + * @param callable(): ?string $call The gate call. + * + * @return string|null The gate's replacement document, when any. + * + * @throws Forbidden On every refusal, including an unknown task and an unexpected failure (fail closed). + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + private function guard(callable $call): ?string { + try { + return $call(); + } catch (TaskAccessDeniedException | TaskConflictException | TaskValidationException $refused) { + throw new Forbidden('OpenRegister refused this change: ' . $refused->getMessage(), 0, $refused); + } catch (DoesNotExistException $missing) { + throw new Forbidden('OpenRegister refused this change: the entry names a task that does not exist.', 0, $missing); + } catch (Throwable $failure) { + $this->logger->error( + '[TaskVtodoWriteBackPlugin] Gate failure; the write is refused rather than applied unchecked: ' . $failure->getMessage(), + ['exception' => $failure] + ); + throw new Forbidden('OpenRegister could not verify this change, so it was not applied.', 0, $failure); + }//end try + }//end guard() + + /** + * The acting uid, from the authenticated DAV principal. + * + * @return string|null The uid, or null when there is none (which the gate denies). + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + */ + private function actor(): ?string { + if ($this->server === null) { + return null; + } + + try { + $auth = $this->server->getPlugin('auth'); + if ($auth === null || method_exists($auth, 'getCurrentPrincipal') === false) { + return null; + } + + $principal = (string)$auth->getCurrentPrincipal(); + } catch (Throwable) { + return null; + } + + if (str_starts_with($principal, self::PRINCIPAL_PREFIX) === false) { + return null; + } + + $uid = substr($principal, strlen(self::PRINCIPAL_PREFIX)); + if ($uid === '') { + return null; + } + + return $uid; + }//end actor() + + /** + * The body as a string, whatever Sabre handed over. + * + * @param mixed $data A string, a stream, or null. + * + * @return string|null The body, or null when unreadable. + */ + private function bodyOf(mixed $data): ?string { + if (is_string($data) === true) { + return $data; + } + + if (is_resource($data) === true) { + $contents = stream_get_contents($data); + rewind($data); + if ($contents === false) { + return null; + } + + return $contents; + } + + return null; + }//end bodyOf() + + /** + * Whether a DAV path addresses a calendar object. + * + * @param string $path The DAV path. + * + * @return bool True under `calendars/`. + */ + private function isCalendarPath(string $path): bool { + return str_starts_with(ltrim($path, '/'), self::CALENDAR_PREFIX); + }//end isCalendarPath() +}//end class diff --git a/lib/Db/TaskProjectionState.php b/lib/Db/TaskProjectionState.php new file mode 100644 index 0000000000..6c0fe2a8aa --- /dev/null +++ b/lib/Db/TaskProjectionState.php @@ -0,0 +1,146 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * One projection surface's rendered state for one task. + * + * @method string|null getTaskUuid() + * @method void setTaskUuid(?string $taskUuid) + * @method string|null getSurface() + * @method void setSurface(?string $surface) + * @method string|null getAssignee() + * @method void setAssignee(?string $assignee) + * @method int|null getCalendarId() + * @method void setCalendarId(?int $calendarId) + * @method string|null getObjectUri() + * @method void setObjectUri(?string $objectUri) + * @method string|null getRenderedHash() + * @method void setRenderedHash(?string $renderedHash) + * @method DateTime|null getRenderedAt() + * @method void setRenderedAt(?DateTime $renderedAt) + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ +class TaskProjectionState extends Entity implements JsonSerializable { + + /** + * The CalDAV VTODO surface. + */ + public const SURFACE_CALDAV = 'caldav'; + + /** + * The task this row projects. + * + * @var string|null + */ + protected ?string $taskUuid = null; + + /** + * Which surface (SURFACE_*). + * + * @var string|null + */ + protected ?string $surface = null; + + /** + * Whose calendar the VTODO was rendered into. + * + * @var string|null + */ + protected ?string $assignee = null; + + /** + * The CalDAV calendar id holding the VTODO. + * + * @var int|null + */ + protected ?int $calendarId = null; + + /** + * The VTODO object uri inside that calendar. + * + * @var string|null + */ + protected ?string $objectUri = null; + + /** + * Hash of the rendered, engine-owned content. + * + * @var string|null + */ + protected ?string $renderedHash = null; + + /** + * When it was rendered. + * + * @var DateTime|null + */ + protected ?DateTime $renderedAt = null; + + /** + * Constructor: declare field types so the mapper hydrates them correctly. + */ + public function __construct() { + $this->addType(fieldName: 'taskUuid', type: 'string'); + $this->addType(fieldName: 'surface', type: 'string'); + $this->addType(fieldName: 'assignee', type: 'string'); + $this->addType(fieldName: 'calendarId', type: 'integer'); + $this->addType(fieldName: 'objectUri', type: 'string'); + $this->addType(fieldName: 'renderedHash', type: 'string'); + $this->addType(fieldName: 'renderedAt', type: 'datetime'); + + }//end __construct() + + /** + * Serialise for diagnostics. + * + * @return array The row as plain data. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'taskUuid' => $this->taskUuid, + 'surface' => $this->surface, + 'assignee' => $this->assignee, + 'calendarId' => $this->calendarId, + 'objectUri' => $this->objectUri, + 'renderedHash' => $this->renderedHash, + 'renderedAt' => $this->renderedAt?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/TaskProjectionStateMapper.php b/lib/Db/TaskProjectionStateMapper.php new file mode 100644 index 0000000000..2993de3669 --- /dev/null +++ b/lib/Db/TaskProjectionStateMapper.php @@ -0,0 +1,91 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Reads and writes `openregister_task_projections`. + * + * @template-extends QBMapper + */ +class TaskProjectionStateMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_task_projections', entityClass: TaskProjectionState::class); + + }//end __construct() + + /** + * The state row for one task on one surface, or null when never rendered. + * + * @param string $taskUuid The task uuid. + * @param string $surface One of TaskProjectionState::SURFACE_*. + * + * @return TaskProjectionState|null The row, when any. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + public function findForTask(string $taskUuid, string $surface = TaskProjectionState::SURFACE_CALDAV): ?TaskProjectionState { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('task_uuid', $qb->createNamedParameter($taskUuid))) + ->andWhere($qb->expr()->eq('surface', $qb->createNamedParameter($surface))) + ->setMaxResults(1); + + try { + return $this->findEntity(query: $qb); + } catch (DoesNotExistException) { + return null; + } + }//end findForTask() + + /** + * Insert or update a state row. + * + * @param TaskProjectionState $state The row to persist. + * + * @return TaskProjectionState The persisted row. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + public function save(TaskProjectionState $state): TaskProjectionState { + if ($state->getId() === null) { + return $this->insert(entity: $state); + } + + return $this->update(entity: $state); + }//end save() +}//end class diff --git a/lib/Event/TaskTransitionedEvent.php b/lib/Event/TaskTransitionedEvent.php new file mode 100644 index 0000000000..333a21a9e1 --- /dev/null +++ b/lib/Event/TaskTransitionedEvent.php @@ -0,0 +1,130 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Event + * @package OCA\OpenRegister\Event + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCA\OpenRegister\Db\Task; +use OCP\EventDispatcher\Event; + +/** + * Carries the committed task, its previous holder and the acting identity. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ +class TaskTransitionedEvent extends Event { + + /** + * Constructor. + * + * @param Task $task The task as committed. + * @param string|null $previousAssignee Who held it before this transition, when anyone. + * @param string|null $previousState The CMMN state before this transition, when known. + * @param string|null $actor The acting identity, when any. + */ + public function __construct( + private readonly Task $task, + private readonly ?string $previousAssignee = null, + private readonly ?string $previousState = null, + private readonly ?string $actor = null, + ) { + parent::__construct(); + + }//end __construct() + + /** + * The committed task. + * + * @return Task The task. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ + public function getTask(): Task { + return $this->task; + }//end getTask() + + /** + * The NAMED transition action, as the row recorded it. + * + * Rules address this, not the resulting state, so a completion by + * approval and one by rejection stay separately addressable. + * + * @return string The action, or an empty string when the row carries none. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function getAction(): string { + return (string)$this->task->getLastAction(); + }//end getAction() + + /** + * The assignee before the transition, when there was one. + * + * @return string|null The previous assignee uid. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + public function getPreviousAssignee(): ?string { + return $this->previousAssignee; + }//end getPreviousAssignee() + + /** + * The state before the transition, when known. + * + * @return string|null The previous CMMN state. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function getPreviousState(): ?string { + return $this->previousState; + }//end getPreviousState() + + /** + * The acting identity, when any. + * + * @return string|null The actor uid. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function getActor(): ?string { + return $this->actor; + }//end getActor() + + /** + * Whether the assignee changed hands in this transition. + * + * @return bool True when the previous and current assignee differ. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-notification-for-an-answered-task-is-withdrawn + */ + public function assigneeChanged(): bool { + return (string)$this->previousAssignee !== (string)$this->task->getAssignee(); + }//end assigneeChanged() +}//end class diff --git a/lib/Listener/TaskCalendarProjectionListener.php b/lib/Listener/TaskCalendarProjectionListener.php new file mode 100644 index 0000000000..c12a1e91b4 --- /dev/null +++ b/lib/Listener/TaskCalendarProjectionListener.php @@ -0,0 +1,69 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Event\TaskTransitionedEvent; +use OCA\OpenRegister\Service\Task\TaskProjectionService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** + * Task transitions become calendar entries. + * + * @template-implements IEventListener + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ +class TaskCalendarProjectionListener implements IEventListener { + + /** + * Constructor. + * + * @param TaskProjectionService $projections The failure-isolated fan-out. + */ + public function __construct( + private readonly TaskProjectionService $projections, + ) { + + }//end __construct() + + /** + * Handle a committed transition. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ + public function handle(Event $event): void { + if (($event instanceof TaskTransitionedEvent) === false) { + return; + } + + $this->projections->afterTransition(event: $event); + }//end handle() +}//end class diff --git a/lib/Listener/TaskNotificationListener.php b/lib/Listener/TaskNotificationListener.php new file mode 100644 index 0000000000..52ff55328e --- /dev/null +++ b/lib/Listener/TaskNotificationListener.php @@ -0,0 +1,148 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Event\TaskTransitionedEvent; +use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; +use OCA\OpenRegister\Service\Notification\TaskNotificationRules; +use OCA\OpenRegister\Service\Notification\TaskObjectAdapter; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Notification\IManager as INotificationManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Task transitions become declarative notifications. + * + * @template-implements IEventListener + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ +class TaskNotificationListener implements IEventListener { + + /** + * Constructor. + * + * @param AnnotationNotificationDispatcher $dispatcher The one dispatcher. + * @param TaskNotificationRules $rules The task rule registry. + * @param TaskInboxService $inbox The row the adapter is built from. + * @param INotificationManager $notifications Withdrawal only, never delivery. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly AnnotationNotificationDispatcher $dispatcher, + private readonly TaskNotificationRules $rules, + private readonly TaskInboxService $inbox, + private readonly INotificationManager $notifications, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle a committed transition. + * + * A failure here is logged naming the task and never rethrown: the + * transition has committed, and delivery is not a condition of it. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function handle(Event $event): void { + if (($event instanceof TaskTransitionedEvent) === false) { + return; + } + + $task = $event->getTask(); + + try { + if ($task->isInTerminalState() === true || $event->assigneeChanged() === true) { + $this->withdraw(task: $task); + } + + $row = $this->inbox->enrich(task: $task); + $adapter = new TaskObjectAdapter( + task: $task, + row: $row, + extra: ['previousAssignee' => $event->getPreviousAssignee()] + ); + + $this->dispatcher->dispatchWithSchema( + object: $adapter, + trigger: 'transition', + context: [ + 'action' => $event->getAction(), + 'from' => $event->getPreviousState(), + 'to' => $task->getState(), + 'actor' => $event->getActor(), + ], + schema: $this->rules->buildSchema() + ); + } catch (Throwable $failure) { + $this->logger->warning( + '[TaskNotificationListener] Task notification failed; the transition is unaffected: ' . $failure->getMessage(), + ['task' => $task->getUuid(), 'action' => $event->getAction()] + ); + }//end try + }//end handle() + + /** + * Withdraw every outstanding notification about a task, for every recipient. + * + * @param Task $task The task. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-notification-for-an-answered-task-is-withdrawn + */ + private function withdraw(Task $task): void { + $uuid = (string)$task->getUuid(); + if ($uuid === '') { + return; + } + + $notification = $this->notifications->createNotification(); + $notification->setApp('openregister')->setObject('object', $uuid); + $this->notifications->markProcessed($notification); + }//end withdraw() +}//end class diff --git a/lib/Listener/TaskTerminalProjectionListener.php b/lib/Listener/TaskTerminalProjectionListener.php new file mode 100644 index 0000000000..9294bd3533 --- /dev/null +++ b/lib/Listener/TaskTerminalProjectionListener.php @@ -0,0 +1,119 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-notification-for-an-answered-task-is-withdrawn + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Task\TaskProjectionService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Notification\IManager as INotificationManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * A terminal task leaves no approve button standing and no open VTODO. + * + * @template-implements IEventListener + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-notification-for-an-answered-task-is-withdrawn + */ +class TaskTerminalProjectionListener implements IEventListener { + + /** + * Constructor. + * + * @param INotificationManager $notifications Withdrawal only, never delivery. + * @param TaskProjectionService $projections Failure-isolated re-render. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly INotificationManager $notifications, + private readonly TaskProjectionService $projections, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle a terminal-task announcement. + * + * @param Event $event The dispatched event; only one carrying a terminal Task is acted on. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-notification-for-an-answered-task-is-withdrawn + */ + public function handle(Event $event): void { + $task = $this->terminalTaskOf(event: $event); + if ($task === null) { + return; + } + + try { + $notification = $this->notifications->createNotification(); + $notification->setApp('openregister')->setObject('object', (string)$task->getUuid()); + $this->notifications->markProcessed($notification); + } catch (Throwable $failure) { + $this->logger->warning( + '[TaskTerminalProjectionListener] Could not withdraw the notifications of a terminal task: ' . $failure->getMessage(), + ['task' => $task->getUuid()] + ); + } + + $this->projections->reconcileTask(task: $task); + }//end handle() + + /** + * The terminal task an event carries, or null when it carries none. + * + * @param Event $event The event. + * + * @return Task|null The task, only when terminal. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-notification-for-an-answered-task-is-withdrawn + */ + private function terminalTaskOf(Event $event): ?Task { + if (method_exists($event, 'getTask') === false) { + return null; + } + + $task = $event->getTask(); + if (($task instanceof Task) === false || $task->isInTerminalState() === false || trim((string)$task->getUuid()) === '') { + return null; + } + + return $task; + }//end terminalTaskOf() +}//end class diff --git a/lib/Listener/TaskVtodoWriteBackListener.php b/lib/Listener/TaskVtodoWriteBackListener.php new file mode 100644 index 0000000000..9df0959e48 --- /dev/null +++ b/lib/Listener/TaskVtodoWriteBackListener.php @@ -0,0 +1,197 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Service\Task\TaskCalendarProjector; +use OCA\OpenRegister\Service\Task\TaskProjectionService; +use OCA\OpenRegister\Service\Task\TaskVtodoWriteBackGate; +use OCP\Calendar\Events\CalendarObjectDeletedEvent; +use OCP\Calendar\Events\CalendarObjectUpdatedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Committed calendar writes on projected VTODOs are reverted or applied + * through the gate. + * + * @template-implements IEventListener + * + * @SuppressWarnings(PHPMD.StaticAccess) The identity reader is a stateless + * parser published by the projector. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ +class TaskVtodoWriteBackListener implements IEventListener { + + /** + * Constructor. + * + * @param TaskVtodoWriteBackGate $gate The one gate. + * @param TaskProjectionService $projections Failure-isolated reconciliation. + * @param IUserSession $userSession Names the acting identity, when there is one. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly TaskVtodoWriteBackGate $gate, + private readonly TaskProjectionService $projections, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle a committed calendar write. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + public function handle(Event $event): void { + if ($event instanceof CalendarObjectUpdatedEvent) { + $this->onUpdated(event: $event); + + return; + } + + if ($event instanceof CalendarObjectDeletedEvent) { + $this->onDeleted(event: $event); + } + }//end handle() + + /** + * A projected VTODO was overwritten in the backend. + * + * @param CalendarObjectUpdatedEvent $event The event. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + private function onUpdated(CalendarObjectUpdatedEvent $event): void { + $body = $this->bodyOf(event: $event); + if ($body === null) { + return; + } + + $taskUuid = TaskCalendarProjector::taskUuidOf(calendarData: $body); + if ($taskUuid === null) { + return; + } + + try { + $replacement = $this->gate->handleWrite(calendarData: $body, actor: $this->actor()); + } catch (Throwable $refused) { + // The gate has already audited, reverted and notified; the write + // that committed is now overwritten by the engine's truth. + $this->logger->info( + '[TaskVtodoWriteBackListener] A committed calendar edit was refused and reverted: ' . $refused->getMessage(), + ['task' => $taskUuid] + ); + + return; + } + + if ($replacement !== null) { + // Accepted verb or projection-owned edit: the stored document is + // the user's; make it the engine's. + $this->projections->reconcile(taskUuid: $taskUuid); + } + }//end onUpdated() + + /** + * A projected VTODO was deleted in the backend: rebuild it, task untouched. + * + * @param CalendarObjectDeletedEvent $event The event. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-truth-flows-one-way-and-the-one-path-back-is-a-gate + */ + private function onDeleted(CalendarObjectDeletedEvent $event): void { + $body = $this->bodyOf(event: $event); + if ($body === null) { + return; + } + + $taskUuid = TaskCalendarProjector::taskUuidOf(calendarData: $body); + if ($taskUuid === null) { + return; + } + + $this->logger->info( + '[TaskVtodoWriteBackListener] A projected calendar entry was deleted; rebuilding it, the task is unchanged.', + ['task' => $taskUuid] + ); + $this->projections->reconcile(taskUuid: $taskUuid); + }//end onDeleted() + + /** + * The calendar document the event carries. + * + * @param CalendarObjectUpdatedEvent|CalendarObjectDeletedEvent $event The event. + * + * @return string|null The document, or null when absent. + */ + private function bodyOf(CalendarObjectUpdatedEvent|CalendarObjectDeletedEvent $event): ?string { + $objectData = $event->getObjectData(); + $data = ($objectData['calendardata'] ?? null); + if (is_resource($data) === true) { + $data = stream_get_contents($data); + } + + if (is_string($data) === false || $data === '') { + return null; + } + + return $data; + }//end bodyOf() + + /** + * The acting identity: the session user, or null (which the gate denies). + * + * @return string|null The uid. + */ + private function actor(): ?string { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + + return $user->getUID(); + }//end actor() +}//end class diff --git a/lib/Migration/Version1Date20260901130000.php b/lib/Migration/Version1Date20260901130000.php new file mode 100644 index 0000000000..3dd7f048b0 --- /dev/null +++ b/lib/Migration/Version1Date20260901130000.php @@ -0,0 +1,88 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The per-task projection state (flow-task-inbox-projections, design D-2 + * rules 6 and 8): one row per (task, surface) recording what the projector + * rendered, where, and when. Written by the projector only. Nothing in the + * lifecycle or authorization reads it, so a wrong row costs one redundant + * re-render and nothing else. + * + * Deliberately NOT columns on `openregister_tasks`: the task row is the + * truth and this is bookkeeping about a copy of it. Keeping them apart is + * what keeps "reconcile the projection" from ever looking like "update the + * task". + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Creates the task projection state table. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ +class Version1Date20260901130000 extends SimpleMigrationStep { + + /** + * The projection state table. + */ + private const TABLE = 'openregister_task_projections'; + + /** + * Create the table when absent. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the schema wrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The signature is Nextcloud's. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + + if ($schema->hasTable(self::TABLE) === true) { + return null; + } + + $table = $schema->createTable(self::TABLE); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('task_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('surface', Types::STRING, ['notnull' => true, 'length' => 32]); + $table->addColumn('assignee', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('calendar_id', Types::BIGINT, ['notnull' => false]); + $table->addColumn('object_uri', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('rendered_hash', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('rendered_at', Types::DATETIME_MUTABLE, ['notnull' => false]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['task_uuid', 'surface'], 'or_task_proj_task_surface'); + $table->addIndex(['calendar_id', 'object_uri'], 'or_task_proj_calendar_uri'); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Notification/AnnotationNotifier.php b/lib/Notification/AnnotationNotifier.php index c9f3c80d60..7a9c37a51d 100644 --- a/lib/Notification/AnnotationNotifier.php +++ b/lib/Notification/AnnotationNotifier.php @@ -198,10 +198,15 @@ public function prepare(INotification $notification, string $languageCode): INot /** * Render the schema-declared action buttons via addAction(). * - * Each action carries a per-locale `label` map, a `primary` flag, and a + * Each action carries a per-locale `label` map, a `primary` flag, a * pre-resolved absolute `url` (resolved server-side by the dispatcher - * through OR RBAC). The recipient's locale label wins, falling back to - * `en` then the first available locale. + * through OR RBAC) and an optional `method`. The recipient's locale + * label wins, falling back to `en` then the first available locale. + * + * A `task-verb` target arrives with `method: POST` and is rendered as a + * state-changing action; everything else renders GET, as before. The + * method is whitelisted here so a resolved action can never smuggle an + * arbitrary verb into the client. * * @param INotification $notification Notification to attach actions to. * @param array $actions Resolved actions (each element validated at runtime). @@ -210,6 +215,7 @@ public function prepare(INotification $notification, string $languageCode): INot * @return int The number of action buttons actually rendered. * * @spec openspec/changes/openregister-web-push-engine/specs/notificatie-engine/spec.md + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-binary-decision-is-decidable-from-the-notification */ private function addDeclaredActions(INotification $notification, array $actions, string $languageCode): int { $rendered = 0; @@ -236,10 +242,15 @@ private function addDeclaredActions(INotification $notification, array $actions, $label = (string)($labelMap[$languageCode] ?? ($labelMap['en'] ?? $fallbackLabel)); + $method = strtoupper((string)($action['method'] ?? 'GET')); + if (in_array($method, ['GET', 'POST', 'PUT', 'DELETE'], true) === false) { + $method = 'GET'; + } + $actionObject = $notification->createAction(); $actionObject->setLabel($label) ->setPrimary((bool)($action['primary'] ?? false)) - ->setLink($url, 'GET'); + ->setLink($url, $method); $notification->addAction($actionObject); $rendered++; }//end foreach diff --git a/lib/Service/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php index 791a874810..a67e8fd1f5 100644 --- a/lib/Service/Notification/AnnotationNotificationDispatcher.php +++ b/lib/Service/Notification/AnnotationNotificationDispatcher.php @@ -2300,9 +2300,10 @@ private function resolveOriginApp(array $spec, ObjectEntity $object): string { * @param array $data The triggering object's data. * @param string $originApp The resolved origin app id. * - * @return array, primary: bool, url: string}> + * @return array, primary: bool, url: string, method: string}> * * @spec openspec/changes/openregister-web-push-engine/specs/notificatie-engine/spec.md + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-binary-decision-is-decidable-from-the-notification */ private function resolveActions(array $spec, ObjectEntity $object, array $data, string $originApp): array { $declared = ($spec['actions'] ?? null); @@ -2337,12 +2338,109 @@ private function resolveActions(array $spec, ObjectEntity $object, array $data, 'label' => $label, 'primary' => (bool)($action['primary'] ?? false), 'url' => $url, + // A `task-verb` target is a state-changing REQUEST and renders + // POST; every navigation kind keeps rendering GET. + 'method' => $this->resolveActionMethod(target: ($action['target'] ?? [])), ]; }//end foreach return $resolved; }//end resolveActions() + /** + * The HTTP method a resolved action is delivered with. + * + * Only a `task-verb` target is a state change. Even then, a verb whose + * outcome requires a mandatory comment (a rejecting outcome) resolves to + * the task form instead, which is a navigation and therefore GET. + * + * @param mixed $target The raw target spec. + * + * @return string `POST` or `GET`. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-binary-decision-is-decidable-from-the-notification + */ + private function resolveActionMethod(mixed $target): string { + if (is_array($target) === false || (string)($target['kind'] ?? '') !== 'task-verb') { + return 'GET'; + } + + if ($this->taskVerbNeedsForm(target: $target) === true) { + return 'GET'; + } + + return 'POST'; + }//end resolveActionMethod() + + /** + * Whether a `task-verb` action must open the form rather than act directly. + * + * `flow-tasks` refuses a rejecting or returning outcome without a + * comment, so a button for such an outcome cannot complete the task by + * itself; it opens the surface that collects the comment. + * + * @param array $target The task-verb target. + * + * @return bool True when the form must be opened. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-binary-decision-is-decidable-from-the-notification + */ + private function taskVerbNeedsForm(array $target): bool { + $outcome = ($target['outcome'] ?? null); + if (is_string($outcome) === false) { + return false; + } + + return \OCA\OpenRegister\Service\Task\TaskState::isRejectingOutcome(outcome: $outcome); + }//end taskVerbNeedsForm() + + /** + * Resolve a `task-verb` target to the verb route, or to the task form + * when the outcome needs a comment. + * + * The uuid comes from the adapter payload (`taskUuid`) or the entity + * uuid; the verb is validated against the closed list by the validator, + * and the route it lands on is authorized by TaskAuthorizationService + * identically to any other caller. The notification is a transport, + * never a bypass. + * + * @param array $target The task-verb target. + * @param ObjectEntity $object The triggering object. + * @param array $data The triggering object's data. + * + * @return string|null The absolute URL, or null when unresolvable (no url generator, no uuid). + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-binary-decision-is-decidable-from-the-notification + */ + private function resolveTaskVerbTarget(array $target, ObjectEntity $object, array $data): ?string { + if ($this->urlGenerator === null) { + return null; + } + + $uuid = (string)($data['taskUuid'] ?? ($object->getUuid() ?? '')); + $verb = (string)($target['verb'] ?? ''); + if ($uuid === '' || $verb === '' || preg_match('/^[a-z]+$/', $verb) !== 1) { + return null; + } + + try { + if ($this->taskVerbNeedsForm(target: $target) === true) { + return $this->urlGenerator->linkToRouteAbsolute('openregister.task.open', ['uuid' => $uuid]); + } + + $url = $this->urlGenerator->linkToRouteAbsolute('openregister.task.' . $verb, ['uuid' => $uuid]); + } catch (\Throwable $e) { + return null; + } + + $outcome = ($target['outcome'] ?? null); + if (is_string($outcome) === true && $outcome !== '') { + $url .= '?outcome=' . rawurlencode($outcome); + } + + return $url; + }//end resolveTaskVerbTarget() + /** * Resolve a single action `target` to an absolute deeplink. * @@ -2354,6 +2452,8 @@ private function resolveActions(array $spec, ObjectEntity $object, array $data, * id is never trusted from the wire. * - route: an originApp frontend route with {{prop}} interpolation (HTML-escaped). * - url: an absolute URL, passed through verbatim. + * - task-verb: the task lifecycle verb route (POST), or the task form + * when the outcome requires a comment. * * @param mixed $target The raw target spec. * @param ObjectEntity $object The triggering object. @@ -2363,6 +2463,8 @@ private function resolveActions(array $spec, ObjectEntity $object, array $data, * @return string|null The resolved absolute URL, or null when unresolvable. * * @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per target kind. + * @SuppressWarnings(PHPMD.NPathComplexity) The fourth kind (task-verb) + * adds one branch; each kind's resolution is a distinct contract. * * @spec openspec/changes/openregister-web-push-engine/specs/notificatie-engine/spec.md */ @@ -2373,6 +2475,10 @@ private function resolveActionTarget(mixed $target, ObjectEntity $object, array $kind = (string)($target['kind'] ?? ''); + if ($kind === 'task-verb') { + return $this->resolveTaskVerbTarget(target: $target, object: $object, data: $data); + } + if ($kind === 'url') { $href = (string)($target['href'] ?? ''); if (filter_var($href, FILTER_VALIDATE_URL) === false) { diff --git a/lib/Service/Notification/NotificationAnnotationValidator.php b/lib/Service/Notification/NotificationAnnotationValidator.php index 1457206dfd..b6b6905062 100644 --- a/lib/Service/Notification/NotificationAnnotationValidator.php +++ b/lib/Service/Notification/NotificationAnnotationValidator.php @@ -56,7 +56,17 @@ final class NotificationAnnotationValidator { * * @var array */ - private const VALID_ACTION_TARGET_KINDS = ['object-detail', 'route', 'url']; + private const VALID_ACTION_TARGET_KINDS = ['object-detail', 'route', 'url', 'task-verb']; + + /** + * The lifecycle verbs a `task-verb` action target may name. A target + * names a VERB, never an author-composed URL, so a rule cannot aim a + * notification button anywhere it likes (flow-task-inbox-projections, + * design D-1). + * + * @var array + */ + private const VALID_TASK_VERBS = ['claim', 'unclaim', 'complete', 'resolve', 'cancel']; /** * Hard cap on declared action buttons — the Web Notification API renders @@ -718,8 +728,10 @@ public function validate(array $schema): array { * - `actions` MUST be an array; more than 2 entries → notification-too-many-actions. * - each action's `label` MUST be a per-locale map with at least one * non-empty locale value → otherwise notification-action-bad-label. - * - each action's `target.kind` MUST be one of object-detail | route | url + * - each action's `target.kind` MUST be one of object-detail | route | url | task-verb * → otherwise notification-action-bad-target. + * - a `task-verb` target MUST name a known lifecycle verb in `verb` + * → otherwise notification-action-bad-target, naming the value. * * @param mixed $actions Raw value of the `actions` key. * @param string $name Notification name (for diagnostics). @@ -814,12 +826,53 @@ private function validateActions(mixed $actions, string $name): array { implode(', ', self::VALID_ACTION_TARGET_KINDS) ), ]; + continue; + } + + if ($targetKind === 'task-verb') { + $errors = array_merge($errors, $this->validateTaskVerbTarget(target: $target, name: $name, idx: (string)$idx)); } }//end foreach return $errors; }//end validateActions() + /** + * Validate a `task-verb` action target: it MUST name a lifecycle verb from + * the closed list, never an author-composed URL. + * + * @param mixed $target The raw target (already known to be kind task-verb). + * @param string $name Notification name (for diagnostics). + * @param string $idx Action index (for diagnostics). + * + * @return array + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-binary-decision-is-decidable-from-the-notification + */ + private function validateTaskVerbTarget(mixed $target, string $name, string $idx): array { + $verb = ''; + if (is_array($target) === true) { + $verb = (string)($target['verb'] ?? ''); + } + + if (in_array($verb, self::VALID_TASK_VERBS, true) === true) { + return []; + } + + return [ + [ + 'code' => 'notification-action-bad-target', + 'message' => sprintf( + 'Notification "%s" action[%s] target.verb "%s" is not a task lifecycle verb in [%s].', + $name, + $idx, + $verb, + implode(', ', self::VALID_TASK_VERBS) + ), + ], + ]; + }//end validateTaskVerbTarget() + /** * Validate the optional `critical` bypass flag and the optional * fixed-time `digest` schedule block. diff --git a/lib/Service/Notification/TaskNotificationRules.php b/lib/Service/Notification/TaskNotificationRules.php new file mode 100644 index 0000000000..73bf96ca95 --- /dev/null +++ b/lib/Service/Notification/TaskNotificationRules.php @@ -0,0 +1,321 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use OCA\OpenRegister\Db\Schema; + +/** + * Task rule registry: the seed data of this change (ADR-001). + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ +class TaskNotificationRules { + + /** + * The synthetic schema slug tasks are evaluated under. + */ + public const SLUG = 'openregister_task'; + + /** + * The transition action the write-back gate records on a refusal, so the + * refusal notice is a rule like every other task notification. + */ + public const ACTION_WRITE_BACK_REFUSED = 'write-back-refused'; + + /** + * The route the navigation actions and the VTODO `URL` resolve to. + */ + private const OPEN_ROUTE = 'flow-tasks/{{taskUuid}}'; + + /** + * The payload fields TaskObjectAdapter publishes, typed for the dialect + * validator so a `kind: field` recipient can only name a real field. + * + * @var array> + */ + private const PAYLOAD_PROPERTIES = [ + 'taskUuid' => ['type' => 'string'], + 'title' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'state' => ['type' => 'string'], + 'isTerminal' => ['type' => 'boolean'], + 'lastAction' => ['type' => 'string'], + 'outcome' => ['type' => 'string'], + 'priority' => ['type' => 'string'], + 'performerType' => ['type' => 'string'], + 'assignee' => ['type' => 'string'], + 'previousAssignee' => ['type' => 'string'], + 'candidateUsers' => ['type' => 'array'], + 'candidateGroups' => ['type' => 'array'], + 'candidateRole' => ['type' => 'string'], + 'requester' => ['type' => 'string'], + 'watchers' => ['type' => 'array'], + 'startAt' => ['type' => 'string'], + 'dueAt' => ['type' => 'string'], + 'expiresAt' => ['type' => 'string'], + 'overdue' => ['type' => 'boolean'], + 'daysUntilDue' => ['type' => 'integer'], + 'daysOverdue' => ['type' => 'integer'], + 'objectUuid' => ['type' => 'string'], + 'registerId' => ['type' => 'integer'], + 'schemaId' => ['type' => 'integer'], + 'subjectTitle' => ['type' => 'string'], + 'appId' => ['type' => 'string'], + 'runUuid' => ['type' => 'string'], + 'completedBy' => ['type' => 'string'], + 'writeBackActor' => ['type' => 'string'], + 'writeBackReason' => ['type' => 'string'], + ]; + + /** + * The rules. Text is per locale, sentence case, English primary. + * + * @var array> + */ + private const RULES = [ + // 1. Assigned to you: actionable, binary. The reject action routes to + // the form because a rejecting outcome needs a comment; the rule does + // not have to know that, the target resolver does. + 'taskAssignedToYou' => [ + 'trigger' => [ + 'type' => 'transition', + 'action' => ['create', 'assign', 'reassign', 'claim', 'delegate'], + ], + 'enabled' => true, + 'channels' => ['nc-notification', 'web-push'], + 'recipients' => [['kind' => 'field', 'field' => 'assignee']], + 'subject' => [ + 'en' => 'Assigned to you: {{title}}', + 'nl' => 'Aan jou toegewezen: {{title}}', + ], + 'message' => [ + 'en' => 'You can answer this task from here.', + 'nl' => 'Je kunt deze taak vanaf hier afhandelen.', + ], + 'actions' => [ + [ + 'label' => ['en' => 'Approve', 'nl' => 'Goedkeuren'], + 'primary' => true, + 'target' => ['kind' => 'task-verb', 'verb' => 'complete', 'outcome' => 'approved'], + ], + [ + 'label' => ['en' => 'Reject', 'nl' => 'Afwijzen'], + 'target' => ['kind' => 'task-verb', 'verb' => 'complete', 'outcome' => 'rejected'], + ], + ], + ], + // 2. Offered to your pool: no assignee to address, so the recipients + // are the pool's members, resolved from the task's OWN candidate + // groups and users, and nobody else. + 'taskOfferedToPool' => [ + 'trigger' => ['type' => 'transition', 'action' => 'offer'], + 'enabled' => true, + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'expression', 'resolver' => TaskPoolRecipientResolver::class]], + 'subject' => [ + 'en' => 'New task for your group: {{title}}', + 'nl' => 'Nieuwe taak voor je groep: {{title}}', + ], + 'actions' => [ + [ + 'label' => ['en' => 'Open', 'nl' => 'Openen'], + 'primary' => true, + 'target' => ['kind' => 'route', 'app' => 'openregister', 'route' => self::OPEN_ROUTE], + ], + ], + ], + // 3. Reassigned away from you. + 'taskReassignedAway' => [ + 'trigger' => ['type' => 'transition', 'action' => ['reassign', 'delegate']], + 'enabled' => true, + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'field', 'field' => 'previousAssignee']], + 'subject' => [ + 'en' => 'No longer yours: {{title}}', + 'nl' => 'Niet meer van jou: {{title}}', + ], + ], + // 4. Due soon: the EVENT is computed by flow-business-timers and + // recorded as an action; this rule only delivers it. + 'taskDueSoon' => [ + 'trigger' => ['type' => 'transition', 'action' => 'due-soon'], + 'enabled' => true, + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'field', 'field' => 'assignee']], + 'subject' => [ + 'en' => 'Due soon: {{title}}', + 'nl' => 'Bijna over tijd: {{title}}', + ], + 'actions' => [ + [ + 'label' => ['en' => 'Open', 'nl' => 'Openen'], + 'primary' => true, + 'target' => ['kind' => 'route', 'app' => 'openregister', 'route' => self::OPEN_ROUTE], + ], + ], + ], + // 5. Escalated: assignee and requester both hear it. + 'taskEscalated' => [ + 'trigger' => ['type' => 'transition', 'action' => 'escalate'], + 'enabled' => true, + 'channels' => ['nc-notification'], + 'recipients' => [ + ['kind' => 'field', 'field' => 'assignee'], + ['kind' => 'field', 'field' => 'requester'], + ], + 'subject' => [ + 'en' => 'Escalated: {{title}}', + 'nl' => 'Geescaleerd: {{title}}', + ], + ], + // 6. Overdue: the derived predicate, in the verified operator-object + // grammar. No `overdue` field appears anywhere in it. + 'taskOverdue' => [ + 'trigger' => [ + 'type' => 'scheduled', + 'intervalSec' => 86400, + 'filter' => [ + 'all' => [ + ['field' => 'isTerminal', 'operator' => 'notIn', 'values' => [true]], + ['field' => 'dueAt', 'operator' => 'before', 'value' => 'now'], + ], + ], + 'dedupeFields' => ['taskUuid'], + ], + 'enabled' => true, + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'field', 'field' => 'assignee']], + 'subject' => [ + 'en' => 'Task overdue: {{title}}', + 'nl' => 'Taak over tijd: {{title}}', + ], + 'actions' => [ + [ + 'label' => ['en' => 'Open', 'nl' => 'Openen'], + 'primary' => true, + 'target' => ['kind' => 'route', 'app' => 'openregister', 'route' => self::OPEN_ROUTE], + ], + ], + ], + // 7. Cancelled, by a person or by propagation from a stopped run. + 'taskCancelled' => [ + 'trigger' => ['type' => 'transition', 'action' => ['cancel', 'terminate']], + 'enabled' => true, + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'field', 'field' => 'assignee']], + 'subject' => [ + 'en' => 'Task withdrawn: {{title}}', + 'nl' => 'Taak ingetrokken: {{title}}', + ], + ], + // 8. A refused write-back, explained to the person who tried it. No + // silent revert anywhere: this rule is how the calendar user learns + // why their tick did not take. + 'taskWriteBackRefused' => [ + 'trigger' => ['type' => 'transition', 'action' => self::ACTION_WRITE_BACK_REFUSED], + 'enabled' => true, + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'field', 'field' => 'writeBackActor']], + 'subject' => [ + 'en' => 'Change not applied: {{title}}', + 'nl' => 'Wijziging niet doorgevoerd: {{title}}', + ], + 'message' => [ + 'en' => '{{writeBackReason}}', + 'nl' => '{{writeBackReason}}', + ], + 'actions' => [ + [ + 'label' => ['en' => 'Open task', 'nl' => 'Taak openen'], + 'primary' => true, + 'target' => ['kind' => 'route', 'app' => 'openregister', 'route' => self::OPEN_ROUTE], + ], + ], + ], + ]; + + /** + * The declared rules. + * + * @return array> Rules keyed by name. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function getRules(): array { + return self::RULES; + }//end getRules() + + /** + * The payload fields a rule may address, as schema properties. + * + * @return array> Property name => JSON-schema fragment. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function payloadProperties(): array { + return self::PAYLOAD_PROPERTIES; + }//end payloadProperties() + + /** + * The rule set as the schema array the dialect validator consumes. + * + * @return array `properties` plus `x-openregister-notifications`. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function asSchemaArray(): array { + return [ + 'properties' => self::PAYLOAD_PROPERTIES, + 'x-openregister-notifications' => self::RULES, + ]; + }//end asSchemaArray() + + /** + * A synthetic Schema carrying the rules, for dispatchWithSchema(). + * + * @return Schema The synthetic schema. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function buildSchema(): Schema { + $schema = new Schema(); + $schema->setSlug(self::SLUG); + $schema->setTitle('Task'); + $schema->setProperties(self::PAYLOAD_PROPERTIES); + $schema->setConfiguration(['x-openregister-notifications' => self::RULES]); + + return $schema; + }//end buildSchema() +}//end class diff --git a/lib/Service/Notification/TaskObjectAdapter.php b/lib/Service/Notification/TaskObjectAdapter.php new file mode 100644 index 0000000000..9d529e1255 --- /dev/null +++ b/lib/Service/Notification/TaskObjectAdapter.php @@ -0,0 +1,148 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Task; + +/** + * Virtual ObjectEntity over a task row plus its derived read projection. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ +class TaskObjectAdapter extends ObjectEntity { + + /** + * Build the adapter. + * + * @param Task|null $task The task row; null only for the bare construction + * the Entity base class performs. + * @param array $row The task's inbox row: the row carries + * `displayTitle`, `subject` and the + * DERIVED temporal fields, so no + * derivation happens here. + * @param array $extra Event-scoped payload fields a rule + * may address (previousAssignee, the + * write-back actor and reason). + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function __construct(?Task $task = null, array $row = [], array $extra = []) { + parent::__construct(); + + $this->schema = TaskNotificationRules::SLUG; + $this->register = null; + if ($task === null) { + // Entity::fromRow() constructs bare; nothing hydrates an adapter + // from a row, so a bare adapter carries an empty payload. + return; + } + + $this->uuid = (string)$task->getUuid(); + + $payload = self::payload(task: $task, row: $row, extra: $extra); + $this->name = (string)$payload['title']; + $this->object = $payload; + }//end __construct() + + /** + * The flat payload a rule's recipients, filters and templates read. + * + * @param Task $task The task row. + * @param array $row The inbox row (display title, subject, derived fields). + * @param array $extra Event-scoped fields. + * + * @return array The payload, keyed by TaskNotificationRules::payloadProperties(). + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public static function payload(Task $task, array $row = [], array $extra = []): array { + $title = trim((string)($row['displayTitle'] ?? '')); + if ($title === '') { + $title = trim((string)$task->getTitle()); + } + + if ($title === '') { + $title = (string)$task->getUuid(); + } + + $subject = ($row['subject'] ?? null); + $subjectTitle = null; + if (is_array($subject) === true) { + $subjectTitle = ($subject['title'] ?? null); + } + + $payload = [ + 'taskUuid' => (string)$task->getUuid(), + 'title' => $title, + 'description' => $task->getDescription(), + 'state' => $task->getState(), + 'isTerminal' => (bool)$task->getIsTerminal(), + 'lastAction' => $task->getLastAction(), + 'outcome' => $task->getOutcome(), + 'priority' => $task->getPriority(), + 'performerType' => $task->getPerformerType(), + 'assignee' => $task->getAssignee(), + 'previousAssignee' => null, + 'candidateUsers' => ($task->getCandidateUsers() ?? []), + 'candidateGroups' => ($task->getCandidateGroups() ?? []), + 'candidateRole' => $task->getCandidateRole(), + 'requester' => $task->getRequester(), + 'watchers' => ($task->getWatchers() ?? []), + 'startAt' => $task->getStartAt()?->format('c'), + 'dueAt' => $task->getDueAt()?->format('c'), + 'expiresAt' => $task->getExpiresAt()?->format('c'), + 'overdue' => (bool)($row['overdue'] ?? false), + 'daysUntilDue' => ($row['daysUntilDue'] ?? null), + 'daysOverdue' => ($row['daysOverdue'] ?? null), + 'objectUuid' => $task->getObjectUuid(), + 'registerId' => $task->getRegisterId(), + 'schemaId' => $task->getSchemaId(), + 'subjectTitle' => $subjectTitle, + 'appId' => $task->getAppId(), + 'runUuid' => $task->getRunUuid(), + 'completedBy' => $task->getCompletedBy(), + 'writeBackActor' => null, + 'writeBackReason' => null, + ]; + + foreach ($extra as $key => $value) { + if (is_string($key) === true && array_key_exists($key, $payload) === true) { + $payload[$key] = $value; + } + } + + return $payload; + }//end payload() +}//end class diff --git a/lib/Service/Notification/TaskPoolRecipientResolver.php b/lib/Service/Notification/TaskPoolRecipientResolver.php new file mode 100644 index 0000000000..23b3e946d9 --- /dev/null +++ b/lib/Service/Notification/TaskPoolRecipientResolver.php @@ -0,0 +1,140 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCP\IGroupManager; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * `kind: expression` resolver for the candidate pool. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ +class TaskPoolRecipientResolver implements RecipientResolverInterface { + + /** + * Constructor. + * + * @param IGroupManager $groupManager Resolves group membership. + * @param IUserManager $userManager Confirms a candidate user exists. + * @param LoggerInterface $logger Names groups that could not be resolved. + */ + public function __construct( + private readonly IGroupManager $groupManager, + private readonly IUserManager $userManager, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The pool's members. + * + * @param ObjectEntity $object The task adapter. + * @param array $context Trigger context (unused: the pool is on the payload). + * + * @return array Distinct uids. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The interface passes context; the pool is payload. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + public function resolve(ObjectEntity $object, array $context): array { + $payload = ($object->getObject() ?? []); + + $uids = array_merge( + $this->groupMembers(groupIds: (array)($payload['candidateGroups'] ?? [])), + $this->existingUsers(uids: (array)($payload['candidateUsers'] ?? [])) + ); + + return array_values(array_unique($uids)); + }//end resolve() + + /** + * The members of the named groups; an unresolvable group adds nobody. + * + * @param array $groupIds The candidate group ids. + * + * @return array Member uids. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + private function groupMembers(array $groupIds): array { + $uids = []; + foreach ($groupIds as $groupId) { + if (is_string($groupId) === false || trim($groupId) === '') { + continue; + } + + try { + $group = $this->groupManager->get($groupId); + } catch (Throwable $failure) { + $this->logger->warning( + sprintf('[TaskPoolRecipientResolver] Group "%s" could not be resolved; nobody is notified for it: %s', $groupId, $failure->getMessage()) + ); + continue; + } + + if ($group === null) { + $this->logger->warning( + sprintf('[TaskPoolRecipientResolver] Group "%s" does not exist; nobody is notified for it.', $groupId) + ); + continue; + } + + foreach ($group->getUsers() as $user) { + $uids[] = $user->getUID(); + } + }//end foreach + + return $uids; + }//end groupMembers() + + /** + * The candidate users that exist; an unknown uid is dropped. + * + * @param array $uids The candidate uids. + * + * @return array Existing uids. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + private function existingUsers(array $uids): array { + $existing = []; + foreach ($uids as $uid) { + if (is_string($uid) === true && trim($uid) !== '' && $this->userManager->userExists($uid) === true) { + $existing[] = $uid; + } + } + + return $existing; + }//end existingUsers() +}//end class diff --git a/lib/Service/Task/TaskCalendarProjector.php b/lib/Service/Task/TaskCalendarProjector.php new file mode 100644 index 0000000000..e8fdaed988 --- /dev/null +++ b/lib/Service/Task/TaskCalendarProjector.php @@ -0,0 +1,575 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use DateTime; +use DateTimeInterface; +use DateTimeZone; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskProjectionState; +use OCA\OpenRegister\Db\TaskProjectionStateMapper; +use OCA\OpenRegister\Exception\NoVtodoCalendarException; +use OCP\IURLGenerator; +use Psr\Log\LoggerInterface; +use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Reader; +use Throwable; + +/** + * The CalDAV projection writer. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The projector bridges the + * task store, the projection state, the calendar backend and iCalendar; that + * is the whole of its job. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of the sync + * contract's rules (design D-2), each a small branch; splitting the class + * would put half the contract out of sight of the other half. + * @SuppressWarnings(PHPMD.StaticAccess) TaskVtodoStatusMapping is the + * stateless published mapping; Reader is VObject's parser entry point. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ +class TaskCalendarProjector { + + /** + * The property carrying the task identity: what makes a VTODO projected. + */ + public const PROP_TASK = 'X-OPENREGISTER-TASK'; + + /** + * The property carrying the assignee as a uid. + */ + public const PROP_ASSIGNEE = 'X-OPENREGISTER-TASK-ASSIGNEE'; + + /** + * The object uri prefix; the task uuid follows. + */ + private const URI_PREFIX = 'openregister-task-'; + + /** + * The route a projected VTODO's `URL` resolves to. + */ + public const OPEN_ROUTE = 'openregister.task.open'; + + /** + * Constructor. + * + * @param CalDavBackend $calDavBackend The calendar store. + * @param VtodoCalendarLocator $calendars Calendar selection by uid. + * @param TaskProjectionStateMapper $states The per-task projection state. + * @param TaskInboxService $inbox Display titles, synthesized where needed. + * @param IURLGenerator $urlGenerator Builds the deep link. + * @param LoggerInterface $logger Names skipped and failed tasks. + */ + public function __construct( + private readonly CalDavBackend $calDavBackend, + private readonly VtodoCalendarLocator $calendars, + private readonly TaskProjectionStateMapper $states, + private readonly TaskInboxService $inbox, + private readonly IURLGenerator $urlGenerator, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Project a task after a transition. + * + * Removes the previous assignee's copy when the task changed hands, + * skips (logging, naming the task) when there is no individual assignee + * or the assignee has no VTODO-capable calendar, and otherwise renders + * into the assignee's calendar, writing nothing when the rendered content + * is unchanged. + * + * @param Task $task The committed task. + * @param string|null $previousAssignee Who held it before, when anyone. + * + * @return void + * + * @throws Throwable A calendar backend failure, for the caller to isolate. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + public function project(Task $task, ?string $previousAssignee = null): void { + $this->sync(task: $task, previousAssignee: $previousAssignee, verifyStore: false); + }//end project() + + /** + * Make the calendar match the task, reading what is actually there. + * + * Unlike {@see project()}, this trusts nothing: a VTODO deleted outright + * is recreated with the same content, and one edited is overwritten + * with the engine's rendering. + * + * @param Task $task The task to reconcile. + * + * @return void + * + * @throws Throwable A calendar backend failure, for the caller to isolate. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-truth-flows-one-way-and-the-one-path-back-is-a-gate + */ + public function reconcile(Task $task): void { + $state = $this->states->findForTask(taskUuid: (string)$task->getUuid()); + $this->sync(task: $task, previousAssignee: $state?->getAssignee(), verifyStore: true); + }//end reconcile() + + /** + * Render the VTODO for a task. + * + * @param Task $task The task. + * + * @return string The VCALENDAR document. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + public function render(Task $task): string { + $uuid = (string)$task->getUuid(); + $utc = new DateTimeZone('UTC'); + $title = $this->inbox->displayTitle(task: $task, subject: null); + + $vcalendar = new VCalendar(); + $vcalendar->remove('PRODID'); + $vcalendar->add('PRODID', '-//OpenRegister//Tasks//EN'); + + // No defaults: VObject would otherwise stamp its own UID and DTSTAMP + // beside ours, and the UID is the task uuid by contract. + $vtodo = $vcalendar->createComponent('VTODO', [], false); + $vcalendar->add($vtodo); + $vtodo->add('UID', $uuid); + $vtodo->add('DTSTAMP', (new DateTime('now', $utc))); + $vtodo->add('SUMMARY', $title); + + $description = trim((string)$task->getDescription()); + if ($description !== '') { + $vtodo->add('DESCRIPTION', $description); + } + + $vtodo->add('STATUS', TaskVtodoStatusMapping::render(state: (string)$task->getState())); + $vtodo->add('PRIORITY', (string)TaskVtodoStatusMapping::priority(priority: $task->getPriority())); + + $due = $task->getDueAt(); + if ($due instanceof DateTimeInterface) { + $vtodo->add('DUE', DateTime::createFromInterface($due)->setTimezone($utc)); + } + + if ((string)$task->getState() === Task::STATE_COMPLETED) { + $completedAt = ($task->getCompletedAt() ?? new DateTime('now', $utc)); + $vtodo->add('COMPLETED', DateTime::createFromInterface($completedAt)->setTimezone($utc)); + $vtodo->add('PERCENT-COMPLETE', '100'); + } + + $vtodo->add('URL', $this->deepLink(uuid: $uuid)); + $vtodo->add(self::PROP_TASK, $uuid); + $vtodo->add(self::PROP_ASSIGNEE, (string)$task->getAssignee()); + + $objectUuid = trim((string)$task->getObjectUuid()); + if ($objectUuid !== '' && $task->getRegisterId() !== null && $task->getSchemaId() !== null) { + $vtodo->add('X-OPENREGISTER-REGISTER', (string)$task->getRegisterId()); + $vtodo->add('X-OPENREGISTER-SCHEMA', (string)$task->getSchemaId()); + $vtodo->add('X-OPENREGISTER-OBJECT', $objectUuid); + $vtodo->add( + 'LINK', + sprintf('/apps/openregister/api/objects/%d/%d/%s', $task->getRegisterId(), $task->getSchemaId(), $objectUuid), + [ + 'LINKREL' => 'related', + 'LABEL' => $title, + 'VALUE' => 'URI', + ] + ); + } + + return $vcalendar->serialize(); + }//end render() + + /** + * The task uuid a VTODO document carries, or null for a standalone one. + * + * Identity is carried, never inferred: no heuristic on summary, due date + * or calendar (design D-2 rule 5). + * + * @param string $calendarData The VCALENDAR document. + * + * @return string|null The task uuid, or null when the VTODO is not a projection. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + */ + public static function taskUuidOf(string $calendarData): ?string { + $fields = self::engineFields(calendarData: $calendarData); + if ($fields === null) { + return null; + } + + $uuid = trim((string)($fields['task'] ?? '')); + if ($uuid === '') { + return null; + } + + return $uuid; + }//end taskUuidOf() + + /** + * The engine-owned fields of a VTODO document, or null when it is no VTODO. + * + * @param string $calendarData The VCALENDAR document. + * + * @return array{task: string, status: string, summary: string, description: string, + * due: string, priority: string, assignee: string}|null The fields. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + public static function engineFields(string $calendarData): ?array { + try { + $document = Reader::read($calendarData); + } catch (Throwable) { + return null; + } + + if (($document instanceof VCalendar) === false) { + return null; + } + + $vtodo = ($document->select('VTODO')[0] ?? null); + if (($vtodo instanceof Component) === false) { + return null; + } + + $due = ''; + $dueProperty = ($vtodo->select('DUE')[0] ?? null); + if ($dueProperty !== null) { + try { + $due = (string)$dueProperty->getDateTime()->getTimestamp(); + } catch (Throwable) { + $due = (string)$dueProperty; + } + } + + return [ + 'task' => self::value(component: $vtodo, name: self::PROP_TASK), + 'status' => strtoupper(trim(self::value(component: $vtodo, name: 'STATUS'))), + 'summary' => self::value(component: $vtodo, name: 'SUMMARY'), + 'description' => self::value(component: $vtodo, name: 'DESCRIPTION'), + 'due' => $due, + 'priority' => self::value(component: $vtodo, name: 'PRIORITY'), + 'assignee' => self::value(component: $vtodo, name: self::PROP_ASSIGNEE), + ]; + }//end engineFields() + + /** + * One property's string value on a component, or '' when absent. + * + * @param Component $component The component. + * @param string $name The property name. + * + * @return string The value. + */ + private static function value(Component $component, string $name): string { + $property = ($component->select($name)[0] ?? null); + if ($property === null) { + return ''; + } + + return (string)$property; + }//end value() + + /** + * The hash of a document's engine-owned content. + * + * DTSTAMP, COMPLETED and client-added properties are deliberately not + * part of it: two renders of one unchanged task hash equal, and a + * client's re-serialisation of an untouched projection hashes equal too. + * + * @param string $calendarData The VCALENDAR document. + * + * @return string|null The hash, or null when the document is no VTODO. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + public static function contentHash(string $calendarData): ?string { + $fields = self::engineFields(calendarData: $calendarData); + if ($fields === null) { + return null; + } + + return hash('sha256', implode("\x1f", $fields)); + }//end contentHash() + + /** + * Whether a calendar write is this projector's own echo. + * + * @param string $taskUuid The task the write names. + * @param string $calendarData The written document. + * + * @return bool True when its engine-owned content is exactly what was last rendered. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + */ + public function isEcho(string $taskUuid, string $calendarData): bool { + $state = $this->states->findForTask(taskUuid: $taskUuid); + if ($state === null || (string)$state->getRenderedHash() === '') { + return false; + } + + return self::contentHash(calendarData: $calendarData) === $state->getRenderedHash(); + }//end isEcho() + + /** + * Remove a task's projection, wherever it is. + * + * @param string $taskUuid The task uuid. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + public function remove(string $taskUuid): void { + $state = $this->states->findForTask(taskUuid: $taskUuid); + if ($state === null) { + return; + } + + $this->removeState(state: $state); + }//end remove() + + /** + * The shared body of project() and reconcile(). + * + * @param Task $task The task. + * @param string|null $previousAssignee Whose calendar may hold a stale copy. + * @param bool $verifyStore Whether to read the calendar rather than trust the state row. + * + * @return void + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The flag IS the difference + * between the two public entry points; both are documented on their own. + * @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per rule of + * the sync contract (design D-2); merging them would hide which rule fired. + * @SuppressWarnings(PHPMD.NPathComplexity) Same reason: the rules are + * independent, so their paths multiply, and each is one line. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + private function sync(Task $task, ?string $previousAssignee, bool $verifyStore): void { + $uuid = (string)$task->getUuid(); + $state = $this->states->findForTask(taskUuid: $uuid); + $assignee = $this->projectableAssignee(task: $task); + + // Reassignment: the previous holder's copy goes first, whoever it was. + if ($state !== null && ((string)$state->getAssignee() !== (string)$assignee || $assignee === null)) { + $this->removeState(state: $state); + $state = null; + } + + if ($assignee === null) { + if ($previousAssignee !== null || $task->isInTerminalState() === false) { + $this->logger->info( + '[TaskCalendarProjector] Task has no individual assignee; not projected into any calendar.', + ['task' => $uuid, 'state' => $task->getState()] + ); + } + + return; + } + + // A terminal task that was never projected does not become a + // calendar entry now: there is nothing outstanding to show. + if ($state === null && $task->isInTerminalState() === true) { + return; + } + + try { + $calendar = $this->calendars->forUser(uid: $assignee); + } catch (NoVtodoCalendarException $missing) { + $this->logger->info( + '[TaskCalendarProjector] Assignee has no VTODO-capable calendar; projection skipped, task unaffected: ' . $missing->getMessage(), + ['task' => $uuid, 'assignee' => $assignee, 'surface' => TaskProjectionState::SURFACE_CALDAV] + ); + + return; + } + + $rendered = $this->render(task: $task); + $hash = (string)self::contentHash(calendarData: $rendered); + + if ($state !== null && (int)$state->getCalendarId() !== $calendar['id']) { + $this->removeState(state: $state); + $state = null; + } + + $existing = null; + if ($state !== null) { + if ($verifyStore === false && $state->getRenderedHash() === $hash) { + // Idempotent: nothing the engine owns changed, so nothing is written. + return; + } + + $existing = $this->calDavBackend->getCalendarObject($calendar['id'], (string)$state->getObjectUri()); + if ($verifyStore === true + && $existing !== null + && $state->getRenderedHash() === $hash + && self::contentHash(calendarData: (string)($existing['calendardata'] ?? '')) === $hash + ) { + return; + } + } + + $uri = ($state?->getObjectUri() ?? (self::URI_PREFIX . $uuid . '.ics')); + $state ??= new TaskProjectionState(); + $state->setTaskUuid($uuid); + $state->setSurface(TaskProjectionState::SURFACE_CALDAV); + $state->setAssignee($assignee); + $state->setCalendarId($calendar['id']); + $state->setObjectUri($uri); + $state->setRenderedHash($hash); + $state->setRenderedAt(new DateTime()); + // State BEFORE the write, so the write's own event reads as an echo. + $state = $this->states->save(state: $state); + + if ($existing !== null) { + $this->calDavBackend->updateCalendarObject($calendar['id'], $uri, $rendered); + + return; + } + + $this->createObject(state: $state, calendarId: $calendar['id'], rendered: $rendered); + }//end sync() + + /** + * Create the VTODO, falling back to a fresh uri when the first is taken + * (a client-deleted projection lingers in the calendar trash under its + * old uri). + * + * @param TaskProjectionState $state The saved state row (updated when the uri changes). + * @param int $calendarId The target calendar. + * @param string $rendered The document. + * + * @return void + * + * @throws Throwable When the second attempt fails too. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-truth-flows-one-way-and-the-one-path-back-is-a-gate + */ + private function createObject(TaskProjectionState $state, int $calendarId, string $rendered): void { + $uri = (string)$state->getObjectUri(); + try { + $this->calDavBackend->createCalendarObject($calendarId, $uri, $rendered); + + return; + } catch (Throwable $firstAttempt) { + $this->logger->debug( + '[TaskCalendarProjector] Create under the stable uri failed, retrying under a fresh one: ' . $firstAttempt->getMessage(), + ['task' => $state->getTaskUuid(), 'uri' => $uri] + ); + } + + $fresh = self::URI_PREFIX . (string)$state->getTaskUuid() . '-' . time() . '.ics'; + $this->calDavBackend->createCalendarObject($calendarId, $fresh, $rendered); + $state->setObjectUri($fresh); + $this->states->save(state: $state); + }//end createObject() + + /** + * Delete the VTODO a state row points at, then the row. + * + * A VTODO already gone is not an error: the goal is its absence. + * + * @param TaskProjectionState $state The row. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + private function removeState(TaskProjectionState $state): void { + $calendarId = $state->getCalendarId(); + $uri = (string)$state->getObjectUri(); + if ($calendarId !== null && $uri !== '') { + try { + if ($this->calDavBackend->getCalendarObject($calendarId, $uri) !== null) { + $this->calDavBackend->deleteCalendarObject($calendarId, $uri); + } + } catch (Throwable $failure) { + $this->logger->warning( + '[TaskCalendarProjector] Could not remove a stale projection: ' . $failure->getMessage(), + ['task' => $state->getTaskUuid(), 'calendar' => $calendarId, 'uri' => $uri] + ); + } + } + + $this->states->delete(entity: $state); + }//end removeState() + + /** + * The uid a task is projected for: an individual assignee, or nobody. + * + * A pooled task has no such person yet; projecting it into an arbitrary + * member's calendar would assert an assignment the engine has not made. + * + * @param Task $task The task. + * + * @return string|null The assignee uid, or null. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + private function projectableAssignee(Task $task): ?string { + if ((string)$task->getPerformerType() !== Task::PERFORMER_USER) { + return null; + } + + $assignee = trim((string)$task->getAssignee()); + if ($assignee === '') { + return null; + } + + return $assignee; + }//end projectableAssignee() + + /** + * The deep link a projected VTODO carries as `URL`: a surface a person + * can act on, never the API. + * + * @param string $uuid The task uuid. + * + * @return string The absolute URL. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + private function deepLink(string $uuid): string { + return $this->urlGenerator->linkToRouteAbsolute(self::OPEN_ROUTE, ['uuid' => $uuid]); + }//end deepLink() +}//end class diff --git a/lib/Service/Task/TaskInboxService.php b/lib/Service/Task/TaskInboxService.php index 46b7b51ada..4110c48aed 100644 --- a/lib/Service/Task/TaskInboxService.php +++ b/lib/Service/Task/TaskInboxService.php @@ -155,6 +155,24 @@ public function row(Task $task, array $subjects, \DateTimeInterface $now): array return $row; }//end row() + /** + * One task as an API row, with its subject context resolved. + * + * The single-task form of {@see inbox()}: the projections (notification + * payload, calendar VTODO) read the SAME row the inbox serves, so a + * display title or a derived overdue flag cannot differ between the + * inbox and the notification about it. + * + * @param Task $task The task. + * + * @return array The row. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-truth-flows-one-way-and-the-one-path-back-is-a-gate + */ + public function enrich(Task $task): array { + return $this->row(task: $task, subjects: $this->subjectContexts(tasks: [$task]), now: $this->temporal->now()); + }//end enrich() + /** * Synthesize a display title for a titleless task — on read, never persisted. * diff --git a/lib/Service/Task/TaskProjectionService.php b/lib/Service/Task/TaskProjectionService.php new file mode 100644 index 0000000000..bdbd287e76 --- /dev/null +++ b/lib/Service/Task/TaskProjectionService.php @@ -0,0 +1,149 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskProjectionState; +use OCA\OpenRegister\Event\TaskTransitionedEvent; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Failure-isolated fan-out to the projection surfaces. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ +class TaskProjectionService { + + /** + * Constructor. + * + * @param TaskCalendarProjector $calendar The CalDAV projection writer. + * @param TaskMapper $tasks Resolves a task by uuid for reconciliation. + * @param LoggerInterface $logger Names failed tasks and surfaces. + */ + public function __construct( + private readonly TaskCalendarProjector $calendar, + private readonly TaskMapper $tasks, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Project a committed transition. + * + * @param TaskTransitionedEvent $event The committed transition. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ + public function afterTransition(TaskTransitionedEvent $event): void { + $task = $event->getTask(); + try { + $this->calendar->project(task: $task, previousAssignee: $event->getPreviousAssignee()); + } catch (Throwable $failure) { + $this->logFailure(task: $task, failure: $failure); + } + }//end afterTransition() + + /** + * Make a task's projections match the task, by uuid. + * + * @param string $taskUuid The task uuid. + * + * @return bool True when reconciled; false when the task is unknown or the surface failed. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-truth-flows-one-way-and-the-one-path-back-is-a-gate + */ + public function reconcile(string $taskUuid): bool { + try { + $task = $this->tasks->findByUuid(uuid: $taskUuid); + } catch (Throwable $missing) { + $this->logger->info( + '[TaskProjectionService] Nothing to reconcile: ' . $missing->getMessage(), + ['task' => $taskUuid] + ); + + return false; + } + + return $this->reconcileTask(task: $task); + }//end reconcile() + + /** + * Make a task's projections match the task. + * + * @param Task $task The task. + * + * @return bool True when reconciled; false when the surface failed. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-truth-flows-one-way-and-the-one-path-back-is-a-gate + */ + public function reconcileTask(Task $task): bool { + try { + $this->calendar->reconcile(task: $task); + + return true; + } catch (Throwable $failure) { + $this->logFailure(task: $task, failure: $failure); + + return false; + } + }//end reconcileTask() + + /** + * Log a surface failure naming the task and the surface. + * + * @param Task $task The task. + * @param Throwable $failure What went wrong. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ + private function logFailure(Task $task, Throwable $failure): void { + $this->logger->warning( + sprintf( + '[TaskProjectionService] Projection of task %s onto %s failed and is left to reconciliation; the task itself is unaffected: %s', + (string)$task->getUuid(), + TaskProjectionState::SURFACE_CALDAV, + $failure->getMessage() + ), + [ + 'task' => $task->getUuid(), + 'surface' => TaskProjectionState::SURFACE_CALDAV, + 'exception' => $failure, + ] + ); + }//end logFailure() +}//end class diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php index 8f9ed3ab8c..ac2817ff2f 100644 --- a/lib/Service/Task/TaskService.php +++ b/lib/Service/Task/TaskService.php @@ -52,6 +52,7 @@ use OCA\OpenRegister\Exception\TaskAccessDeniedException; use OCA\OpenRegister\Exception\TaskConflictException; use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Event\TaskTransitionedEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; use Psr\Log\LoggerInterface; @@ -75,14 +76,18 @@ * rules (authorize, then terminality, then the verb's precondition, then * the conditional write); folding verbs together to lower the number would * hide exactly the per-verb rules the spec enumerates. + * @SuppressWarnings(PHPMD.TooManyMethods) One private helper per concern the + * verbs share (open, authorize, audit, persist, announce); merging them would + * hide which rule a verb relies on. * @SuppressWarnings(PHPMD.StaticAccess) TaskState is a stateless published * vocabulary (the one status mapping); calling it statically is the point, * an instance would be a second copy of the same table. * @SuppressWarnings(PHPMD.ExcessiveParameterList) The tenth constructor - * argument is the nullable event dispatcher that announces terminality - * (flow-user-task-node); it is last so the hand-built test services keep - * their order, and folding it into another collaborator would hide that a - * lifecycle verb has an after-commit side effect. + * argument is the nullable event dispatcher that announces the transition + * to the projections and terminality to the flow side; it is last so the + * hand-built test services keep their order, and folding it into another + * collaborator would hide that a lifecycle verb has an after-commit side + * effect. * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed */ @@ -101,16 +106,20 @@ class TaskService { * @param LoggerInterface $logger Failure reporting. * @param TaskBuilder $builder Validates and builds a new task from * boundary data (the vocabularies live there). - * @param IEventDispatcher|null $dispatcher Announces a task reaching a - * terminal state - * ({@see TaskTerminalEvent}), AFTER - * the transition committed. Last and - * nullable so the four test suites - * that build this service by hand - * keep their argument order; absent, - * terminality goes unannounced and a - * parked run learns of it on its - * heartbeat instead. + * @param IEventDispatcher|null $dispatcher Announces the committed + * transition to the projections + * ({@see TaskTransitionedEvent}) + * and, when the task came out + * terminal, its terminality to the + * flow side + * ({@see TaskTerminalEvent}) — both + * AFTER the commit. Last and + * nullable so the suites that build + * this service by hand keep their + * argument order; absent, nothing + * is projected, terminality goes + * unannounced, and the lifecycle is + * unchanged. */ public function __construct( private readonly TaskMapper $tasks, @@ -127,6 +136,19 @@ public function __construct( }//end __construct() + /** + * What the task looked like BEFORE the verb in flight, for the + * post-commit announcement: previous assignee, previous state, actor. + * + * Set by {@see openTaskFor()} and {@see create()}, consumed once by + * {@see transactional()}. Verbs that load their task another way + * (termination by propagation) announce with no previous snapshot, + * which is correct: termination changes no assignee. + * + * @var array{assignee: string|null, state: string|null, actor: string|null}|null + */ + private ?array $pending = null; + /** * Create a task. * @@ -191,6 +213,11 @@ public function create(array $data, ?string $actor): Task { public function import(array $data, ?string $actor): Task { $task = $this->builder->fromData(data: $data, actor: $actor); $this->authorizeOrRecord(verb: 'create', task: $task, actor: $actor); + $this->pending = [ + 'assignee' => null, + 'state' => null, + 'actor' => $actor, + ]; return $this->transactional( mutation: function () use ($task, $data, $actor): Task { @@ -789,6 +816,14 @@ private function openTaskFor(string $verb, string $uuid, ?string $actor): Task { ); } + // Snapshot BEFORE any mutation: the closures mutate this very object, + // so this is the last moment the previous holder is observable. + $this->pending = [ + 'assignee' => $task->getAssignee(), + 'state' => $task->getState(), + 'actor' => $actor, + ]; + return $task; }//end openTaskFor() @@ -938,10 +973,16 @@ private function transactional(callable $mutation): Task { $result = $mutation(); $this->db->commit(); } catch (Throwable $failure) { + $this->pending = null; $this->db->rollBack(); throw $failure; } + // The projections first: the notification and the calendar entry of + // THIS task reflect its committed state before the flow side walks on + // (that walk can complete further tasks re-entrantly). + $this->announce(task: $result); + // Terminality is announced HERE, after the commit and from the one // place every mutation passes, so no verb can forget it (the same // choke-point argument FlowRunMapper::update() makes for runs). After @@ -964,6 +1005,45 @@ private function transactional(callable $mutation): Task { return $result; }//end transactional() + /** + * Announce a COMMITTED transition to the projections. + * + * Runs after the commit and outside the transaction, so nothing a + * listener does can unwind the verb (flow-task-inbox-projections, + * design D-8). A listener that throws is logged naming the task; the + * verb has already succeeded and its caller is told so. + * + * @param Task $task The committed task. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ + private function announce(Task $task): void { + $pending = $this->pending; + $this->pending = null; + + if ($this->dispatcher === null) { + return; + } + + try { + $this->dispatcher->dispatchTyped( + new TaskTransitionedEvent( + task: $task, + previousAssignee: ($pending['assignee'] ?? $task->getAssignee()), + previousState: ($pending['state'] ?? null), + actor: ($pending['actor'] ?? null) + ) + ); + } catch (Throwable $failure) { + $this->logger->warning( + '[TaskService] A projection failed after the transition committed; the task is unchanged by it: ' . $failure->getMessage(), + ['task' => $task->getUuid(), 'action' => $task->getLastAction()] + ); + } + }//end announce() + /** * Rewrite the candidate INDEX rows from the task's JSON record. * diff --git a/lib/Service/Task/TaskVtodoStatusMapping.php b/lib/Service/Task/TaskVtodoStatusMapping.php new file mode 100644 index 0000000000..e4b0768cbb --- /dev/null +++ b/lib/Service/Task/TaskVtodoStatusMapping.php @@ -0,0 +1,168 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-status-mapping + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\TaskConflictException; + +/** + * State to VTODO status, VTODO status to requested verb, priority both ways. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-status-mapping + */ +final class TaskVtodoStatusMapping { + + /** + * The four VTODO status values. + */ + public const NEEDS_ACTION = 'NEEDS-ACTION'; + + public const IN_PROCESS = 'IN-PROCESS'; + + public const COMPLETED = 'COMPLETED'; + + public const CANCELLED = 'CANCELLED'; + + /** + * State to VTODO status: the render direction. + * + * @var array + */ + private const RENDER = [ + Task::STATE_AVAILABLE => self::NEEDS_ACTION, + Task::STATE_ENABLED => self::NEEDS_ACTION, + Task::STATE_ACTIVE => self::IN_PROCESS, + Task::STATE_COMPLETED => self::COMPLETED, + Task::STATE_TERMINATED => self::CANCELLED, + Task::STATE_DISABLED => self::CANCELLED, + ]; + + /** + * Normalised priority to iCal 0-9: the inverse of TaskPriority's import + * mapping, chosen so every value round-trips (1 urgent, 3 high, 5 normal, + * 7 low). + * + * @var array + */ + private const PRIORITY = [ + 'urgent' => 1, + 'high' => 3, + 'normal' => 5, + 'low' => 7, + ]; + + /** + * The VTODO status a task state renders as. + * + * @param string $state One of the six CMMN states. + * + * @return string One of the four VTODO status values. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-status-mapping + */ + public static function render(string $state): string { + return self::RENDER[$state] ?? self::NEEDS_ACTION; + }//end render() + + /** + * The lifecycle verb an incoming VTODO status REQUESTS, given the task's + * current state. + * + * Only completion and cancellation name verbs: the trust boundary is + * one field wide. A status that merely restates the task's rendered + * status is not a request (null). A status that would reopen a terminal + * task is an illegal transition and is refused here, before any verb. + * + * @param string $vtodoStatus The incoming STATUS value, any case. + * @param Task $task The task the VTODO projects. + * + * @return string|null `complete`, `cancel`, or null for "no lifecycle request". + * + * @throws TaskConflictException When the status names no legal transition. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-status-mapping + */ + public static function requestedVerb(string $vtodoStatus, Task $task): ?string { + $incoming = strtoupper(trim($vtodoStatus)); + $current = self::render(state: (string)$task->getState()); + + if ($incoming === $current) { + return null; + } + + if ($incoming === self::COMPLETED) { + return 'complete'; + } + + if ($incoming === self::CANCELLED) { + return 'cancel'; + } + + if ($task->isInTerminalState() === true) { + throw new TaskConflictException( + message: sprintf( + "Status '%s' refused: task '%s' is in terminal state '%s' and cannot be reopened from a calendar.", + $incoming, + (string)$task->getUuid(), + (string)$task->getState() + ) + ); + } + + // NEEDS-ACTION or IN-PROCESS on a non-terminal task: a progress note, + // not a lifecycle verb. The next render restores the engine's value. + return null; + }//end requestedVerb() + + /** + * The iCal priority a normalised priority renders as. + * + * @param string|null $priority One of low|normal|high|urgent. + * + * @return int The iCal 0-9 value. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ + public static function priority(?string $priority): int { + return self::PRIORITY[strtolower(trim((string)$priority))] ?? self::PRIORITY['normal']; + }//end priority() + + /** + * The published render mapping, for documentation surfaces and tests. + * + * @return array state => VTODO status. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-status-mapping + */ + public static function mapping(): array { + return self::RENDER; + }//end mapping() +}//end class diff --git a/lib/Service/Task/TaskVtodoWriteBackGate.php b/lib/Service/Task/TaskVtodoWriteBackGate.php new file mode 100644 index 0000000000..4960991d2b --- /dev/null +++ b/lib/Service/Task/TaskVtodoWriteBackGate.php @@ -0,0 +1,353 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; +use OCA\OpenRegister\Service\Notification\TaskNotificationRules; +use OCA\OpenRegister\Service\Notification\TaskObjectAdapter; +use OCP\AppFramework\Db\DoesNotExistException; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The write-back gate. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The gate is the one + * place the calendar, the lifecycle, the audit, the projection and the + * refusal notice meet; splitting it would create a second path back. + * @SuppressWarnings(PHPMD.StaticAccess) The identity and status readers are + * stateless parsers published by the projector and the mapping. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + */ +class TaskVtodoWriteBackGate { + + /** + * The verbs a VTODO status may request. Nothing else crosses. + * + * @var array + */ + public const VERBS = ['complete', 'cancel']; + + /** + * Constructor. + * + * @param TaskService $tasks The authorized lifecycle: the ONLY thing that mutates a task. + * @param TaskMapper $mapper Resolves the task the VTODO names. + * @param TaskAuditMapper $audits Records denials the lifecycle did not already record. + * @param TaskCalendarProjector $projector Renders the engine's truth back over a refused edit. + * @param TaskProjectionService $projections Failure-isolated reconciliation. + * @param TaskInboxService $inbox The row the refusal notice is built from. + * @param AnnotationNotificationDispatcher $dispatcher The one dispatcher, for the refusal notice. + * @param TaskNotificationRules $rules The task rule registry. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly TaskService $tasks, + private readonly TaskMapper $mapper, + private readonly TaskAuditMapper $audits, + private readonly TaskCalendarProjector $projector, + private readonly TaskProjectionService $projections, + private readonly TaskInboxService $inbox, + private readonly AnnotationNotificationDispatcher $dispatcher, + private readonly TaskNotificationRules $rules, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Process a VTODO write arriving from a calendar. + * + * Returns the document the calendar SHOULD hold afterwards, or null when + * the write is not this capability's business (no task identity) or is + * the projector's own echo. A returned document is always the engine's + * rendering: after an accepted verb it reflects the new state; after a + * non-verb edit (SUMMARY, DUE, ...) it restores the projected values. + * + * @param string $calendarData The incoming VCALENDAR document. + * @param string|null $actor The acting identity, or null when unknown (denied). + * + * @return string|null The document to store, or null to leave the write alone. + * + * @throws TaskAccessDeniedException When the actor may not perform the requested verb. + * @throws TaskConflictException When the status names no legal transition. + * @throws TaskValidationException When the verb refuses its arguments. + * @throws DoesNotExistException When the identity names no task. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + */ + public function handleWrite(string $calendarData, ?string $actor): ?string { + $taskUuid = TaskCalendarProjector::taskUuidOf(calendarData: $calendarData); + if ($taskUuid === null) { + // An ordinary calendar task: not the engine's business, at any + // point in its life. + return null; + } + + if ($this->projector->isEcho(taskUuid: $taskUuid, calendarData: $calendarData) === true) { + return null; + } + + $fields = TaskCalendarProjector::engineFields(calendarData: $calendarData); + $task = $this->resolve(taskUuid: $taskUuid, actor: $actor, reason: 'The calendar entry names a task that does not exist.'); + + try { + $verb = TaskVtodoStatusMapping::requestedVerb(vtodoStatus: (string)($fields['status'] ?? ''), task: $task); + } catch (TaskConflictException $illegal) { + $this->refuse(task: $task, verb: 'status', actor: $actor, reason: $illegal->getMessage(), audited: false); + throw $illegal; + } + + if ($verb === null) { + // SUMMARY, DESCRIPTION, DUE, PRIORITY or a status restating the + // engine's own: projection-owned, so the render overwrites it. + return $this->projector->render(task: $task); + } + + $updated = $this->request(taskUuid: $taskUuid, verb: $verb, actor: $actor); + + return $this->projector->render(task: $updated); + }//end handleWrite() + + /** + * Request a lifecycle verb on behalf of a calendar actor. + * + * The triple, and nothing else, reaches the lifecycle. Authorization + * denials are audited by the lifecycle itself; every other refusal is + * audited here, so no refused write-back goes unrecorded. + * + * @param string $taskUuid The task. + * @param string $verb One of VERBS. + * @param string|null $actor The acting identity. + * + * @return Task The task after the verb. + * + * @throws TaskAccessDeniedException When denied, including for a verb outside VERBS. + * @throws TaskConflictException When the task is already terminal. + * @throws TaskValidationException When the verb refuses its arguments. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + */ + public function request(string $taskUuid, string $verb, ?string $actor): Task { + $task = $this->resolve(taskUuid: $taskUuid, actor: $actor, reason: 'The calendar entry names a task that does not exist.'); + + if (in_array($verb, self::VERBS, true) === false) { + $denied = new TaskAccessDeniedException( + message: sprintf("Verb '%s' cannot be requested from a calendar; only completion and cancellation can.", $verb) + ); + $this->refuse(task: $task, verb: $verb, actor: $actor, reason: $denied->getMessage(), audited: false); + throw $denied; + } + + try { + if ($verb === 'cancel') { + return $this->tasks->cancel(uuid: $taskUuid, reason: 'Cancelled from the calendar.', actor: $actor); + } + + return $this->tasks->complete(uuid: $taskUuid, outcome: 'done', resultText: null, comment: null, actor: $actor); + } catch (TaskAccessDeniedException $denied) { + // The lifecycle audited this denial already (authorizeOrRecord). + $this->refuse(task: $task, verb: $verb, actor: $actor, reason: $denied->getMessage(), audited: true); + throw $denied; + } catch (TaskConflictException | TaskValidationException $refused) { + $this->refuse(task: $task, verb: $verb, actor: $actor, reason: $refused->getMessage(), audited: false); + throw $refused; + }//end try + }//end request() + + /** + * Whether a document is a projected VTODO. + * + * @param string $calendarData The VCALENDAR document. + * + * @return bool True when it carries a task identity. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + */ + public function isProjected(string $calendarData): bool { + return TaskCalendarProjector::taskUuidOf(calendarData: $calendarData) !== null; + }//end isProjected() + + /** + * Resolve the task a write names, refusing (and telling the actor) when it does not exist. + * + * @param string $taskUuid The task uuid. + * @param string|null $actor The acting identity. + * @param string $reason The refusal reason when unresolvable. + * + * @return Task The task. + * + * @throws DoesNotExistException When no such task exists. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + */ + private function resolve(string $taskUuid, ?string $actor, string $reason): Task { + try { + return $this->mapper->findByUuid(uuid: $taskUuid); + } catch (DoesNotExistException $missing) { + $this->logger->warning( + '[TaskVtodoWriteBackGate] Refused: ' . $reason, + ['task' => $taskUuid, 'actor' => $actor] + ); + throw $missing; + } + }//end resolve() + + /** + * Make a refusal visible: audit it, restore the projection, tell the actor. + * + * @param Task $task The task the write named. + * @param string $verb The verb attempted (or `status` for an illegal status edit). + * @param string|null $actor The acting identity. + * @param string $reason Why it was refused. + * @param bool $audited Whether the lifecycle already recorded the denial. + * + * @return void + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Whether the denial is + * already on the audit is a fact about the caller, not a behaviour switch. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + private function refuse(Task $task, string $verb, ?string $actor, string $reason, bool $audited): void { + if ($audited === false) { + $this->recordDenial(task: $task, verb: $verb, actor: $actor, reason: $reason); + } + + $this->projections->reconcileTask(task: $task); + $this->notifyRefusal(task: $task, actor: $actor, reason: $reason); + + $this->logger->info( + '[TaskVtodoWriteBackGate] Refused a calendar write-back: ' . $reason, + ['task' => $task->getUuid(), 'verb' => $verb, 'actor' => $actor] + ); + }//end refuse() + + /** + * Append an unauthorized audit entry for a refusal the lifecycle did not see. + * + * @param Task $task The task. + * @param string $verb The verb attempted. + * @param string|null $actor The acting identity. + * @param string $reason The denial reason. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + private function recordDenial(Task $task, string $verb, ?string $actor, string $reason): void { + if ($task->getId() === null) { + return; + } + + try { + $entry = new TaskAudit(); + $entry->setTaskId((int)$task->getId()); + $entry->setAction($verb); + $entry->setStateAfter($task->getState()); + $entry->setActor($actor); + $entry->setPerformerType($task->getPerformerType()); + $entry->setOnBehalfOf($task->getOnBehalfOf()); + $entry->setMandate($task->getMandate()); + $entry->setReason('Calendar write-back refused: ' . $reason); + $entry->setAuthorized(false); + $this->audits->insert($entry); + } catch (Throwable $failure) { + $this->logger->warning( + '[TaskVtodoWriteBackGate] Could not record a write-back denial: ' . $failure->getMessage(), + ['task' => $task->getUuid(), 'verb' => $verb] + ); + } + }//end recordDenial() + + /** + * Tell the actor which task was affected and why the change did not take, + * through the declarative rule set (no imperative notification call). + * + * An actor who cannot be named cannot be told; that is logged, not + * widened to anyone else. + * + * @param Task $task The task. + * @param string|null $actor The acting identity. + * @param string $reason The refusal reason. + * + * @return void + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + */ + private function notifyRefusal(Task $task, ?string $actor, string $reason): void { + if ($actor === null || trim($actor) === '') { + $this->logger->info( + '[TaskVtodoWriteBackGate] Refusal has no nameable actor to notify.', + ['task' => $task->getUuid()] + ); + + return; + } + + try { + $adapter = new TaskObjectAdapter( + task: $task, + row: $this->inbox->enrich(task: $task), + extra: [ + 'writeBackActor' => $actor, + 'writeBackReason' => $reason, + ] + ); + $this->dispatcher->dispatchWithSchema( + object: $adapter, + trigger: 'transition', + context: ['action' => TaskNotificationRules::ACTION_WRITE_BACK_REFUSED, 'actor' => $actor], + schema: $this->rules->buildSchema() + ); + } catch (Throwable $failure) { + $this->logger->warning( + '[TaskVtodoWriteBackGate] Could not notify the actor of a refusal: ' . $failure->getMessage(), + ['task' => $task->getUuid(), 'actor' => $actor] + ); + }//end try + }//end notifyRefusal() +}//end class diff --git a/lib/Service/Task/VtodoCalendarLocator.php b/lib/Service/Task/VtodoCalendarLocator.php new file mode 100644 index 0000000000..2951b9de1b --- /dev/null +++ b/lib/Service/Task/VtodoCalendarLocator.php @@ -0,0 +1,126 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-calendar-selection-for-tasks + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\OpenRegister\Exception\NoVtodoCalendarException; + +/** + * Calendar selection by uid. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-calendar-selection-for-tasks + */ +class VtodoCalendarLocator { + + /** + * The CalDAV property naming a calendar's supported component set. + */ + private const COMPONENT_SET = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; + + /** + * Constructor. + * + * @param CalDavBackend $calDavBackend The calendar store. + */ + public function __construct( + private readonly CalDavBackend $calDavBackend, + ) { + + }//end __construct() + + /** + * The user's first VTODO-supporting calendar. + * + * @param string $uid The calendar owner. + * + * @return array{id: int, uri: string} The calendar id and uri. + * + * @throws NoVtodoCalendarException When the user has no calendar accepting tasks. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-calendar-selection-for-tasks + */ + public function forUser(string $uid): array { + $calendars = $this->calDavBackend->getCalendarsForUser('principals/users/' . $uid); + + foreach ($calendars as $calendar) { + if ($this->supportsVtodo(components: ($calendar[self::COMPONENT_SET] ?? null)) === true) { + return [ + 'id' => (int)$calendar['id'], + 'uri' => (string)$calendar['uri'], + ]; + } + } + + throw new NoVtodoCalendarException(userId: $uid); + }//end forUser() + + /** + * Whether a supported-calendar-component-set value names VTODO. + * + * Handles the object, string and iterable shapes the backend has been + * observed to return. + * + * @param mixed $components The property value. + * + * @return bool True when VTODO is supported. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-calendar-selection-for-tasks + */ + public function supportsVtodo(mixed $components): bool { + if (is_string($components) === true) { + return stripos($components, 'VTODO') !== false; + } + + if (is_object($components) === true && method_exists($components, 'getValue') === true) { + $components = (array)$components->getValue(); + } + + if (is_iterable($components) === false) { + return false; + } + + return $this->namesVtodo(components: $components); + }//end supportsVtodo() + + /** + * Whether an iterable of component names contains VTODO. + * + * @param iterable $components The component names. + * + * @return bool True when one is VTODO, any case. + */ + private function namesVtodo(iterable $components): bool { + foreach ($components as $component) { + if (is_scalar($component) === true && strtoupper((string)$component) === 'VTODO') { + return true; + } + } + + return false; + }//end namesVtodo() +}//end class diff --git a/lib/Service/TaskService.php b/lib/Service/TaskService.php index 63ae8f17e8..a3e3a4b64a 100644 --- a/lib/Service/TaskService.php +++ b/lib/Service/TaskService.php @@ -7,6 +7,17 @@ * Tasks are stored as standard VTODO items in the user's Nextcloud calendar with * X-OPENREGISTER-* properties for linking and an RFC 9253 LINK property. * + * Two classes of VTODO pass through here, keyed on ONE property + * (flow-task-inbox-projections, design D-7): + * + * - STANDALONE VTODOs carry no `X-OPENREGISTER-TASK`. The VTODO is their + * store and every method below behaves as it always did. + * - PROJECTED VTODOs carry `X-OPENREGISTER-TASK`, the engine task uuid. The + * engine task row is their store; this service is a projection writer. A + * projected VTODO cannot be created here, a status change on one is a + * REQUEST through the write-back gate, and deleting one does not cancel + * the task. + * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. * @@ -19,6 +30,7 @@ * @link https://OpenRegister.app * * @spec openspec/specs/object-interactions/spec.md + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-tasks-on-objects-via-caldav-vtodo */ declare(strict_types=1); @@ -29,6 +41,10 @@ use Exception; use OCA\DAV\CalDAV\CalDavBackend; use OCA\OpenRegister\Exception\NoVtodoCalendarException; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Service\Task\TaskCalendarProjector; +use OCA\OpenRegister\Service\Task\TaskVtodoWriteBackGate; +use OCA\OpenRegister\Service\Task\VtodoCalendarLocator; use OCP\IURLGenerator; use OCP\IUserSession; use Psr\Log\LoggerInterface; @@ -47,6 +63,8 @@ * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The leaf now routes projected + * VTODOs through the write-back gate beside its own CalDAV collaborators. */ class TaskService { @@ -78,6 +96,22 @@ class TaskService { */ private readonly IURLGenerator $urlGenerator; + /** + * Calendar selection by uid, shared with the projector. + * + * @var VtodoCalendarLocator + */ + private readonly VtodoCalendarLocator $calendars; + + /** + * The write-back gate a PROJECTED VTODO's status change goes through. + * Nullable so the service stays constructible bare; without it a + * projected update is refused, never applied unchecked (fail closed). + * + * @var TaskVtodoWriteBackGate|null + */ + private readonly ?TaskVtodoWriteBackGate $gate; + /** * Constructor. * @@ -85,6 +119,7 @@ class TaskService { * @param IUserSession $userSession User session for current user context * @param LoggerInterface $logger Logger for error reporting * @param IURLGenerator $urlGenerator URL generator for deep links + * @param TaskVtodoWriteBackGate|null $gate The one path from a projected VTODO into the engine * * @return void */ @@ -93,11 +128,14 @@ public function __construct( IUserSession $userSession, LoggerInterface $logger, IURLGenerator $urlGenerator, + ?TaskVtodoWriteBackGate $gate = null, ) { $this->calDavBackend = $calDavBackend; $this->userSession = $userSession; $this->logger = $logger; $this->urlGenerator = $urlGenerator; + $this->calendars = new VtodoCalendarLocator(calDavBackend: $calDavBackend); + $this->gate = $gate; }//end __construct() /** @@ -106,22 +144,28 @@ public function __construct( * Returns all VTODOs (optionally filtered by status) from the user's calendars. * Tasks with X-OPENREGISTER-* properties include linking metadata. * + * This walks every calendar and filters in PHP, which is why it no longer + * backs `GET /api/tasks` (that aggregate answers from the engine inbox). + * It remains the read behind the `nc-task` virtual schema and the + * caldav-vtodo object source, both of which project the acting user's own + * calendar read-only. No assignee filter exists: an assignee was never + * carried on a VTODO as anything but description prose, and the prose is + * gone. + * * @param string|null $status Optional status filter (e.g. 'needs-action', 'completed') * @param int $limit Maximum number of tasks to return * @param int $offset Number of tasks to skip - * @param string|null $assignee Optional assignee filter (matches ATTENDEE or description) * * @return array{results: array, total: int} Task results with total count * * @throws Exception If no user is logged in * - * @spec openspec/changes/retrofit-2026-05-24-b-svc-report-import-link/tasks.md#task-10 + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-the-projection-carries-a-real-assignee-not-prose */ public function getAllUserTasks( ?string $status = null, int $limit = 50, int $offset = 0, - ?string $assignee = null, ): array { $user = $this->userSession->getUser(); if ($user === null) { @@ -172,16 +216,6 @@ public function getAllUserTasks( continue; } - // Apply assignee filter. - if ($assignee !== null) { - $taskAssignee = $this->extractAssigneeFromDescription( - description: $taskArray['description'] ?? '' - ); - if ($taskAssignee !== $assignee) { - continue; - } - } - $allTasks[] = $taskArray; } catch (Exception $e) { $this->logger->warning( @@ -219,50 +253,9 @@ public function getAllUserTasks( * @return bool True if the calendar supports VTODO. */ private function calendarSupportsVtodo(mixed $components): bool { - if ($components === null) { - return false; - } - - if (is_object($components) === true && method_exists($components, 'getValue') === true) { - $componentValues = $components->getValue(); - foreach ($componentValues as $comp) { - if (strtoupper($comp) === 'VTODO') { - return true; - } - } - } elseif (is_string($components) === true) { - return stripos($components, 'VTODO') !== false; - } elseif (is_iterable($components) === true) { - foreach ($components as $comp) { - $compName = (string)$comp; - if (is_string($comp) === true) { - $compName = $comp; - } - - if (strtoupper($compName) === 'VTODO') { - return true; - } - } - }//end if - - return false; + return $this->calendars->supportsVtodo(components: $components); }//end calendarSupportsVtodo() - /** - * Extract assignee from the description field. - * - * @param string $description The task description. - * - * @return string|null The assignee name or null. - */ - private function extractAssigneeFromDescription(string $description): ?string { - if (str_starts_with($description, 'Assigned to: ') === true) { - return substr($description, strlen('Assigned to: ')); - } - - return null; - }//end extractAssigneeFromDescription() - /** * Get all tasks linked to a specific OpenRegister object. * @@ -375,11 +368,18 @@ private function buildTaskDeepLink(?string $calendarUri, ?string $taskUri): ?str * @param string $objectTitle The object title for the LINK label * @param array $data Task data: summary, description, priority, due, status * + * A payload that carries an engine task identity is REFUSED: a projected + * VTODO is created only by the projection, so a VTODO carrying + * `X-OPENREGISTER-TASK` always corresponds to a task the engine + * authorized into existence. + * * @return array|null The created task in JSON-friendly format, or null if the calendar data was not a VTODO * * @throws Exception If no user is logged in or no calendar found + * @throws TaskAccessDeniedException If the payload attempts to set an engine task identity * * @spec openspec/specs/object-interactions/spec.md + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-tasks-on-objects-via-caldav-vtodo */ public function createTask( int $registerId, @@ -388,6 +388,12 @@ public function createTask( string $objectTitle, array $data, ): ?array { + if ($this->carriesEngineIdentity(data: $data) === true) { + throw new TaskAccessDeniedException( + message: 'An engine task cannot be created through the object task endpoint; only OpenRegister projects engine tasks into a calendar.' + ); + } + $calendar = $this->findUserCalendar(); $calendarId = $calendar['id']; @@ -452,6 +458,12 @@ public function createTask( * * Loads the existing VTODO, applies changes, and saves it back. * + * For a PROJECTED VTODO (one carrying `X-OPENREGISTER-TASK`) the VTODO + * is not the store: the update is handed to the write-back gate as a + * REQUEST against the engine task, authorized like any other caller, and + * what is stored afterwards is the engine's own rendering. Without a + * gate the update is refused, never applied unchecked. + * * @param string $calendarId The calendar ID containing the task * @param string $taskUri The URI of the task to update * @param array $data Fields to update: summary, description, priority, due, status @@ -459,8 +471,10 @@ public function createTask( * @return array|null The updated task in JSON-friendly format, or null if calendar data was not a VTODO * * @throws Exception If the task is not found or update fails + * @throws TaskAccessDeniedException When a projected VTODO's change is refused * * @spec openspec/changes/retrofit-2026-05-24-b-svc-report-import-link/tasks.md#task-10 + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-compatibility-with-nextcloud-tasks-app */ public function updateTask(string $calendarId, string $taskUri, array $data): ?array { $calendarIdInt = (int)$calendarId; @@ -470,6 +484,15 @@ public function updateTask(string $calendarId, string $taskUri, array $data): ?a throw new Exception('Task not found'); } + if (TaskCalendarProjector::taskUuidOf(calendarData: (string)$existing['calendardata']) !== null) { + return $this->updateProjectedTask( + calendarId: $calendarIdInt, + taskUri: $taskUri, + existing: (string)$existing['calendardata'], + data: $data + ); + } + $vcalendar = Reader::read($existing['calendardata']); $vtodo = $vcalendar->VTODO; @@ -528,6 +551,11 @@ public function updateTask(string $calendarId, string $taskUri, array $data): ?a /** * Delete a CalDAV task. * + * Deleting a PROJECTED VTODO deletes the calendar entry and nothing else: + * the engine task keeps its state, and the projection is restored on the + * next reconciliation, because a task is not cancelled by removing the + * reminder of it. Nothing here reaches the engine. + * * @param string $calendarId The calendar ID containing the task * @param string $taskUri The URI of the task to delete * @@ -536,6 +564,7 @@ public function updateTask(string $calendarId, string $taskUri, array $data): ?a * @throws Exception If the task is not found or deletion fails * * @spec openspec/changes/retrofit-2026-05-24-b-svc-report-import-link/tasks.md#task-10 + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-tasks-on-objects-via-caldav-vtodo */ public function deleteTask(string $calendarId, string $taskUri): void { $calendarIdInt = (int)$calendarId; @@ -549,36 +578,114 @@ public function deleteTask(string $calendarId, string $taskUri): void { }//end deleteTask() /** - * Find the user's first VTODO-supporting calendar. + * Find a user's first VTODO-supporting calendar. + * + * Standalone tasks resolve the SESSION user (the default); a projection + * passes the ASSIGNEE's uid, because the reminder belongs in the calendar + * of whoever owes the work, not of whoever triggered the transition. * - * Checks the user's calendars and returns the first one that - * supports VTODO components. + * @param string|null $uid The calendar owner; null means the session user * - * @return array Calendar data with 'id' and 'uri' keys + * @return array{id: int, uri: string} Calendar data with 'id' and 'uri' keys * - * @throws Exception If no user is logged in or no suitable calendar found + * @throws Exception If no user is logged in and none was named + * @throws NoVtodoCalendarException If the user has no suitable calendar + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-calendar-selection-for-tasks */ - private function findUserCalendar(): array { - $user = $this->userSession->getUser(); - if ($user === null) { - throw new Exception('No user logged in'); + public function findUserCalendar(?string $uid = null): array { + if ($uid === null || trim($uid) === '') { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new Exception('No user logged in'); + } + + $uid = $user->getUID(); } - $principal = 'principals/users/' . $user->getUID(); - $calendars = $this->calDavBackend->getCalendarsForUser($principal); + return $this->calendars->forUser(uid: $uid); + }//end findUserCalendar() - foreach ($calendars as $calendar) { - $components = $calendar['{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set']; - if ($this->calendarSupportsVtodo(components: $components) === true) { - return [ - 'id' => $calendar['id'], - 'uri' => $calendar['uri'], - ]; + /** + * Whether a create payload attempts to set an engine task identity. + * + * @param array $data The create payload + * + * @return bool True when `X-OPENREGISTER-TASK` (or its camel-cased spelling) appears at any level the writer reads + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-tasks-on-objects-via-caldav-vtodo + */ + private function carriesEngineIdentity(array $data): bool { + $markers = [TaskCalendarProjector::PROP_TASK, 'taskUuid', 'engineTask']; + foreach ($markers as $marker) { + if (isset($data[$marker]) === true && trim((string)$data[$marker]) !== '') { + return true; } - }//end foreach + } - throw new NoVtodoCalendarException(userId: $user->getUID()); - }//end findUserCalendar() + $fields = ($data['fields'] ?? null); + if (is_array($fields) === false) { + return false; + } + + foreach ($markers as $marker) { + if (isset($fields[$marker]) === true && trim((string)$fields[$marker]) !== '') { + return true; + } + } + + return false; + }//end carriesEngineIdentity() + + /** + * Route a projected VTODO's update through the write-back gate. + * + * Only `status` can name a verb; every other field is projection-owned + * and the stored document is the engine's rendering regardless. + * + * @param int $calendarId The calendar holding the VTODO + * @param string $taskUri The VTODO uri + * @param string $existing The stored document + * @param array $data The requested changes + * + * @return array|null The projected task as JSON-friendly array + * + * @throws TaskAccessDeniedException When no gate is available (fail closed) or the gate refuses + * @throws Exception When the gate refuses for any other reason + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-status-mapping + */ + private function updateProjectedTask(int $calendarId, string $taskUri, string $existing, array $data): ?array { + if ($this->gate === null) { + throw new TaskAccessDeniedException( + message: 'This calendar entry projects an engine task and the write-back gate is unavailable, so the change was not applied.' + ); + } + + $requested = $existing; + if (isset($data['status']) === true) { + $vcalendar = Reader::read($existing); + $vtodo = ($vcalendar->select('VTODO')[0] ?? null); + if ($vtodo === null) { + throw new Exception('Calendar object is not a VTODO'); + } + + $vtodo->remove('STATUS'); + $vtodo->add('STATUS', strtoupper((string)$data['status'])); + $requested = $vcalendar->serialize(); + } + + $actor = $this->userSession->getUser()?->getUID(); + $rendered = $this->gate->handleWrite(calendarData: $requested, actor: $actor); + if ($rendered === null) { + // An echo or a document that is not the engine's business: stored as is. + return $this->vtodoToArray(calendarData: $existing, calendarId: (string)$calendarId, uri: $taskUri); + } + + $this->calDavBackend->updateCalendarObject($calendarId, $taskUri, $rendered); + + return $this->vtodoToArray(calendarData: $rendered, calendarId: (string)$calendarId, uri: $taskUri); + }//end updateProjectedTask() /** * Parse a VTODO iCalendar string into a JSON-friendly array. diff --git a/openspec/changes/flow-task-inbox-projections/tasks.md b/openspec/changes/flow-task-inbox-projections/tasks.md index 9d030ba17d..4687b75415 100644 --- a/openspec/changes/flow-task-inbox-projections/tasks.md +++ b/openspec/changes/flow-task-inbox-projections/tasks.md @@ -2,14 +2,14 @@ ## 1. Dialect: a notification that can decide -- [ ] 1.1 Add the `task-verb` action target kind to +- [x] 1.1 Add the `task-verb` action target kind to `lib/Service/Notification/NotificationAnnotationValidator.php` beside the three at `:60` (`object-detail|route|url`): it names a lifecycle verb and an optional outcome, never an author-composed URL. Keep `MAX_ACTIONS = 2` (`:68`) — approve and reject is exactly two. Reject an unknown verb naming the value. Existing rules keep validating unchanged. -- [ ] 1.2 Render it as a state-changing action: +- [x] 1.2 Render it as a state-changing action: `lib/Notification/AnnotationNotifier.php:235` hardcodes `->setLink($url, 'GET')` for every declared action; a `task-verb` target renders POST against the `TaskController` verb route, and the @@ -21,7 +21,7 @@ ## 2. Task notifications, declaratively -- [ ] 2.1 `lib/Service/Notification/TaskObjectAdapter.php` extending +- [x] 2.1 `lib/Service/Notification/TaskObjectAdapter.php` extending `ObjectEntity`, modelled on `SystemEntityObjectAdapter.php:46`. Entity uuid = TASK uuid (so `NotificationDedupeState`, which is keyed per object, dedupes per task). Flattens assignee, candidate users/groups, @@ -29,7 +29,7 @@ overdue fields and the subject object's context into payload fields recipients and filters can read. NO notification logic in it — design D-1's fence. -- [ ] 2.2 `lib/Service/Notification/TaskNotificationRules.php` modelled on +- [x] 2.2 `lib/Service/Notification/TaskNotificationRules.php` modelled on `SystemSchemaRules.php:58`: the rule set from design.md — Seed Data, addressed at `trigger.action` (matched at `AnnotationNotificationDispatcher.php:1523-1539`), covering offered, @@ -37,13 +37,13 @@ overdue rule uses the operator-object filter grammar verified at `shillinq/lib/Settings/register.d/contract-lifecycle-management.json:383-401` over DERIVED fields — no rule anywhere filters a stored `overdue`. -- [ ] 2.3 `lib/Listener/TaskNotificationListener.php` on the task lifecycle +- [x] 2.3 `lib/Listener/TaskNotificationListener.php` on the task lifecycle events, calling `AnnotationNotificationDispatcher::dispatchWithSchema()` (`:211`) with `context['action']` set to the recorded transition action — the same seam `SystemEntityNotificationListener.php:94-127` uses. No second notification pipeline. -- [ ] 2.4 Withdrawal via `IManager::markProcessed()` (the call already used +- [x] 2.4 Withdrawal via `IManager::markProcessed()` (the call already used at `lib/Service/NotificationService.php:228`) on every terminal transition and on assignee change: a claimed pool task clears the other members' notifications; a task terminated by propagation leaves @@ -51,11 +51,11 @@ ## 3. The calendar projection -- [ ] 3.1 Projection state per task (rendered-content hash + timestamp, +- [x] 3.1 Projection state per task (rendered-content hash + timestamp, written by the projector only, read by no lifecycle or authorization rule) so idempotency, echo suppression and drift detection are comparisons rather than guesses — design D-2 rules 6 and 8. -- [ ] 3.2 `lib/Service/Task/TaskCalendarProjector.php` rendering the VTODO +- [x] 3.2 `lib/Service/Task/TaskCalendarProjector.php` rendering the VTODO from the task per the property table in design.md — D-5, including the new `URL` (a form deep link; `lib/Service/TaskService.php:400-440` emits no `URL` at all today), `X-OPENREGISTER-TASK` and @@ -64,7 +64,7 @@ must be parameterised by uid. A pooled task with no assignee is NOT projected. Reassignment removes and recreates; a terminal task is rendered terminal. -- [ ] 3.3 Invert `lib/Service/TaskService.php` (753L) from store to +- [x] 3.3 Invert `lib/Service/TaskService.php` (753L) from store to projection writer per the method table in design.md — D-7: two classes keyed on `X-OPENREGISTER-TASK`; `createTask()` refuses an engine-identity payload from the sub-resource endpoint; @@ -74,7 +74,11 @@ `nextcloud-vue/src/components/CnObjectSidebar/CnTasksTab.vue:304`, and fix the docblock at `:112` that claims ATTENDEE matching no code performs. -- [ ] 3.4 Run projection and notification AFTER the lifecycle transaction + > OpenRegister side done. The `'Assigned to: '` WRITER at + > `CnTasksTab.vue:304` lives in nextcloud-vue and is tracked with 5.2; + > until it lands, that prose arrives as an ordinary DESCRIPTION on a + > STANDALONE VTODO and nothing on the server reads it any more. +- [x] 3.4 Run projection and notification AFTER the lifecycle transaction commits, never inside it (design D-8): a calendar outage or an assignee with no VTODO-capable calendar logs and skips naming the task, and the transition still succeeds. `findUserCalendar()`'s @@ -84,20 +88,20 @@ ## 4. The write-back gate -- [ ] 4.1 One gate implementation taking `(task_uuid, requested_verb, +- [x] 4.1 One gate implementation taking `(task_uuid, requested_verb, actor)` and nothing else — never a state, never a field value (design D-2 rule 3). It calls the entity `TaskService`, which authorizes. Fail-closed on unresolvable task, illegal transition, unknown property shape or unavailable authorization. Only `STATUS` and deletion name verbs; `SUMMARY`/`DESCRIPTION`/`DUE`/`PRIORITY` edits reach the engine never and are overwritten by the next render. -- [ ] 4.2 `lib/Dav/TaskVtodoWriteBackPlugin.php` — a Sabre `ServerPlugin` +- [x] 4.2 `lib/Dav/TaskVtodoWriteBackPlugin.php` — a Sabre `ServerPlugin` declared in `appinfo/info.xml` under `` (the mechanism `apps/dav/lib/AppInfo/PluginManager.php:155-160` loads) on `beforeWriteContent`/`beforeUnbind`, acting only on VTODOs carrying `X-OPENREGISTER-TASK`. A refusal throws a DAV forbidden exception so the client never records the change. -- [ ] 4.3 `lib/Listener/TaskVtodoWriteBackListener.php` on the `apps/dav` +- [x] 4.3 `lib/Listener/TaskVtodoWriteBackListener.php` on the `apps/dav` `CalendarObjectUpdatedEvent`/`CalendarObjectDeletedEvent` for writes that bypass the plugin: here the write has committed, so REVERT the projection to the engine's truth and notify the actor naming the task @@ -111,6 +115,17 @@ the `flow-tasks` inbox API. Filter, sort, page and total come from the query; the badge reads the TOTAL, never the row count; no client-side filter is applied over a returned page. + > **Blocked (nextcloud-vue, 2026-09-01).** The endpoint side is done: + > `GET /api/flow-tasks` and `GET /api/tasks` both answer with + > server-side filter, sort, page and a datastore `total`. The surface + > is a manifest page of `type: "index"` over a NAMED entity source, + > not a bespoke Vue page, and the registry that resolves named sources + > (`nextcloud-vue/src/composables/indexSources.js`, version 2.0.5) + > knows exactly one source, `flows`. A `tasks` source must be added + > there first; declaring `entitySource: "tasks"` in a manifest before + > that lands renders an empty index with a console error, which is + > worse than no page. Ships with the nextcloud-vue change that adds + > the source and `CnTasksWidget`. - [ ] 5.2 Repoint the leaf: `src/integrations/builtin/tasks.js` (64L), `CnObjectSidebar/CnTasksTab.vue` (500L), `CnTasksCard/CnTasksCard.vue` (387L) and `src/types/task.d.ts` (31L) @@ -121,7 +136,17 @@ no assignee and offers claim; and only verbs the caller is authorized to invoke are offered. Coordinate with decidesk, the only mounter (`decidesk/src/manifest.json:594`). -- [ ] 5.3 `GET /api/tasks` (`appinfo/routes.php:903`) answers from the inbox + > **Blocked (nextcloud-vue, 2026-09-01).** Same repo, same + > coordination as 5.1: the leaf files named here are nextcloud-vue's, + > and decidesk is the one mounter to coordinate with. The server + > contract they repoint onto is in place (`GET /api/flow-tasks` + > with `objectUuid=`, the six CMMN states, server-derived `overdue`, + > the assignee as a uid, and verbs refused with 403 so a surface can + > learn what to offer). The two `@e2e` scenarios this leaves + > uncovered, the watcher who sees no action buttons and the widget's + > page-of-rows with the full count, are UI scenarios and travel with + > this task. +- [x] 5.3 `GET /api/tasks` (`appinfo/routes.php:903`) answers from the inbox instead of `TaskService::getAllUserTasks()` (`:120-197`, which walks every calendar and filters and paginates in PHP). Visibility, filter, sort, page and total are the query's; `assignee` is no longer accepted @@ -129,25 +154,25 @@ ## 6. Seed data and tests -- [ ] 6.1 Install the rule set and projection fixtures from design.md — +- [x] 6.1 Install the rule set and projection fixtures from design.md — Seed Data (assigned-to-you with two `task-verb` actions; offered-to- pool via `kind: groups` from the task's own candidate groups; overdue in the verified filter grammar; cancelled-by-propagation; one projected task, one pooled task with no projection, one assignee with no VTODO-capable calendar) through the existing seeding path, idempotent. -- [ ] 6.2 Gate tests: a stranger's completion refused and audited; a shared- +- [x] 6.2 Gate tests: a stranger's completion refused and audited; a shared- calendar tick reverted and the actor notified; an illegal transition from a terminal task refused; a VTODO with no `X-OPENREGISTER-TASK` untouched through create/edit/complete; a `SUMMARY` edit not reaching the engine; both hooks proven to reach the one gate. -- [ ] 6.3 Contract tests: rendering twice produces one VTODO and no second +- [x] 6.3 Contract tests: rendering twice produces one VTODO and no second notification; a gate-driven completion records exactly one audit entry (no echo); a deleted VTODO is rebuilt with identical content and the task untouched; a clock-controlled overdue rule fires with the row byte-identical before and after; a failing calendar backend leaves the assignment committed and inboxed. -- [ ] 6.4 Playwright coverage for the six `@e2e`-marked scenarios across +- [x] 6.4 Playwright coverage for the six `@e2e`-marked scenarios across `specs/flow-task-projections/spec.md` and `specs/object-interactions/spec.md`: approve from the notification; the assigned task in the calendar with a link that resolves to the diff --git a/phpstan.neon b/phpstan.neon index 88c5e38657..8dc3043608 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -34,6 +34,13 @@ parameters: # hiding a finding. - lib/ContextChat/ContentProvider.php + # TaskVtodoWriteBackPlugin extends Sabre\DAV\ServerPlugin, which ships + # with the server's 3rdparty tree (apps/dav loads it), not with the + # sabre/vobject this app requires. Same shape as ContentProvider above: + # a class-level "extends unknown class" cannot be ignored or baselined, + # so the file is excluded; its behaviour is unit-tested against stubs. + - lib/Dav/TaskVtodoWriteBackPlugin.php + # `vendor-bin` USED to be excluded by the shared base. It left the base in # conduction/hydra-gates v1.7.3, because a conditional path has no spelling # that is safe on both PHPStan majors: plain aborts 2.x (it validates the @@ -45,6 +52,11 @@ parameters: - vendor-bin ignoreErrors: + # The shared base already ignores `unknown class OCA\DAV\...` (server- + # internal, not in nextcloud/ocp). A constructor-promoted parameter of + # that type is reported with a different spelling, `has invalid type`, + # which the base does not cover. Same class of finding, same reason. + - '#has invalid type OCA\\DAV\\#' # FilesSidebarListener guards on an OPTIONAL app's event class. # # The listener is registered for 'OCA\Files\Event\LoadAdditionalScriptsEvent' diff --git a/psalm-baseline.xml b/psalm-baseline.xml index fcecd3fba3..0184eeaf31 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -130,21 +130,6 @@ - - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - - - @@ -155,19 +140,6 @@ {'X-OPENREGISTER-SCHEMA'}]]> - - - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - - - @@ -309,21 +281,6 @@ - - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - calDavBackend]]> - - - COMPLETED]]> DESCRIPTION]]> diff --git a/psalm.xml b/psalm.xml index 1f20afdb1d..2b91c881c0 100644 --- a/psalm.xml +++ b/psalm.xml @@ -62,6 +62,14 @@ + + + + + + + diff --git a/tests/Unit/BackgroundJob/TaskScheduledNotificationJobTest.php b/tests/Unit/BackgroundJob/TaskScheduledNotificationJobTest.php new file mode 100644 index 0000000000..f1bf544c0d --- /dev/null +++ b/tests/Unit/BackgroundJob/TaskScheduledNotificationJobTest.php @@ -0,0 +1,183 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Clock-controlled overdue: a task becomes notifiable without anything + * writing to it, the candidate set comes from the inbox's derived-overdue + * filter, a terminal task is not chased, and a second sweep with the row + * byte-identical dedupes. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-deadline-notifications-filter-on-the-derived-predicate + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\BackgroundJob; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use DateTime; +use OCA\OpenRegister\BackgroundJob\TaskScheduledNotificationJob; +use OCA\OpenRegister\Db\NotificationDedupeState; +use OCA\OpenRegister\Db\NotificationDedupeStateMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; +use OCA\OpenRegister\Service\Notification\ScheduledFilterEvaluator; +use OCA\OpenRegister\Service\Notification\TaskNotificationRules; +use OCA\OpenRegister\Service\Notification\TaskObjectAdapter; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class TaskScheduledNotificationJobTest extends TestCase { + private TaskMapper&MockObject $tasks; + + private NotificationDedupeStateMapper&MockObject $dedupe; + + private AnnotationNotificationDispatcher&MockObject $dispatcher; + + private IAppConfig&MockObject $appConfig; + + /** @var array */ + private array $config = []; + + protected function setUp(): void { + parent::setUp(); + $this->tasks = $this->createMock(TaskMapper::class); + $this->dedupe = $this->createMock(NotificationDedupeStateMapper::class); + $this->dispatcher = $this->createMock(AnnotationNotificationDispatcher::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->config = []; + $this->appConfig->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = ''): string => $this->config[$key] ?? $default + ); + $this->appConfig->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value): bool { + $this->config[$key] = $value; + return true; + } + ); + } + + private function job(DateTime $now): TaskScheduledNotificationJob { + $time = $this->createMock(ITimeFactory::class); + $time->method('getTime')->willReturn($now->getTimestamp()); + $time->method('getDateTime')->willReturnCallback(static fn (): DateTime => clone $now); + $inbox = $this->createMock(TaskInboxService::class); + $inbox->method('enrich')->willReturnCallback( + static fn (Task $task): array => ['displayTitle' => (string)$task->getTitle(), 'overdue' => true, 'subject' => null] + ); + + return new TaskScheduledNotificationJob( + $time, + new TaskNotificationRules(), + $this->tasks, + $inbox, + new ScheduledFilterEvaluator(), + $this->dedupe, + $this->dispatcher, + $this->appConfig, + new NullLogger() + ); + } + + private function runJob(TaskScheduledNotificationJob $job): void { + $method = new \ReflectionMethod($job, 'run'); + $method->setAccessible(true); + $method->invoke($job, null); + } + + private function overdueTask(): Task { + $task = new Task(); + $task->setId(7); + $task->setUuid('t-late'); + $task->setTitle('Late review'); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setLastAction('assign'); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('approver'); + $task->setDueAt(new DateTime('2026-09-01T09:00:00+00:00')); + + return $task; + } + + public function testATaskBecomesNotifiableByTheClockAloneAndTheRowIsNeverWritten(): void { + $task = $this->overdueTask(); + $before = $task->jsonSerialize(); + $captured = null; + $this->tasks->method('findInbox')->willReturnCallback( + static function (TaskInboxCriteria $criteria) use (&$captured, $task): array { + $captured = $criteria; + return [$task]; + } + ); + $this->tasks->expects($this->never())->method('update'); + $this->tasks->expects($this->never())->method('updateIfOpen'); + $this->dedupe->method('findOne')->willReturn(null); + $this->dedupe->expects($this->once())->method('upsert') + ->with(TaskScheduledNotificationJob::DEDUPE_SCHEMA_ID, 'taskOverdue', 't-late', $this->anything(), $this->anything(), true); + $this->dispatcher->expects($this->once())->method('dispatchWithSchema') + ->with( + $this->callback(static fn (TaskObjectAdapter $adapter): bool => $adapter->getUuid() === 't-late'), + 'scheduled', + ['notificationName' => 'taskOverdue'], + $this->callback(static fn ($schema): bool => array_keys($schema->getConfiguration()['x-openregister-notifications']) === ['taskOverdue']) + ); + + $this->runJob($this->job(new DateTime('2026-09-02T09:00:00+00:00'))); + + // The candidate query is the inbox's derived-overdue filter, over open tasks, as an admin sweep. + $this->assertTrue($captured->isAdmin); + $this->assertSame(TaskInboxCriteria::SCOPE_ALL, $captured->scope); + $this->assertFalse($captured->isTerminal); + $this->assertSame('2026-09-02T09:00:00+00:00', $captured->overdueAt->format('c')); + $this->assertSame($before, $task->jsonSerialize(), 'the stored row is byte-identical after the evaluation'); + } + + public function testATaskNotYetDueIsNotNotified(): void { + $task = $this->overdueTask(); + $this->tasks->method('findInbox')->willReturn([$task]); + $this->dispatcher->expects($this->never())->method('dispatchWithSchema'); + + // The clock is BEFORE the deadline: the declared filter (dueAt before now) does not match. + $this->runJob($this->job(new DateTime('2026-08-31T09:00:00+00:00'))); + } + + public function testATerminalTaskIsNotChased(): void { + $task = $this->overdueTask(); + $task->setState(Task::STATE_COMPLETED); + $task->setIsTerminal(true); + $this->tasks->method('findInbox')->willReturn([$task]); + $this->dispatcher->expects($this->never())->method('dispatchWithSchema'); + + $this->runJob($this->job(new DateTime('2026-09-02T09:00:00+00:00'))); + } + + public function testASecondSweepWithTheSameRowDedupes(): void { + $task = $this->overdueTask(); + $this->tasks->method('findInbox')->willReturn([$task]); + $state = new NotificationDedupeState(); + $state->setFingerprint(sha1((string)json_encode(['taskUuid' => 't-late']))); + $this->dedupe->method('findOne')->willReturn($state); + $this->dispatcher->expects($this->never())->method('dispatchWithSchema'); + + $this->runJob($this->job(new DateTime('2026-09-02T09:00:00+00:00'))); + } + + public function testTheIntervalIsHonoured(): void { + $this->config['sched_task:taskOverdue'] = (string)(new DateTime('2026-09-02T08:00:00+00:00'))->getTimestamp(); + $this->tasks->expects($this->never())->method('findInbox'); + + // One hour later, a daily rule is not due. + $this->runJob($this->job(new DateTime('2026-09-02T09:00:00+00:00'))); + } +} diff --git a/tests/Unit/Controller/TasksControllerTest.php b/tests/Unit/Controller/TasksControllerTest.php index ec60b2b991..1f7bb8cf53 100644 --- a/tests/Unit/Controller/TasksControllerTest.php +++ b/tests/Unit/Controller/TasksControllerTest.php @@ -9,12 +9,18 @@ use OCA\OpenRegister\Controller\TasksController; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\TaskInboxCriteria; use OCA\OpenRegister\Exception\NoVtodoCalendarException; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; use OCA\OpenRegister\Service\TaskService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -262,54 +268,24 @@ public function testDestroyTaskNotFound(): void { // ── GET /api/tasks — the caller's own tasks across all calendars ─────── /** - * Record the arguments TaskService::getAllUserTasks() is called with. + * Build a controller wired to the engine inbox for the aggregate tests. * - * @param array $capture Receives [status, limit, offset, assignee]. - * @param array $result The payload the service returns. + * @param string|null $uid The session user, or null for no session. + * @param array $params The request parameters. * - * @return void + * @return array{0: TasksController, 1: TaskInboxService&MockObject} */ - private function expectAllUserTasksCall(array &$capture, array $result): void { - $this->taskService - ->expects($this->once()) - ->method('getAllUserTasks') - ->willReturnCallback( - static function (?string $status, int $limit, int $offset, ?string $assignee) use (&$capture, $result) { - $capture = [$status, $limit, $offset, $assignee]; - return $result; - } - ); - } - - public function testAllUserTasksReturnsTheServicePayload(): void { - $payload = [ - 'results' => [ - ['id' => 'task-1', 'summary' => 'Review permit application', 'status' => 'NEEDS-ACTION'], - ], - 'total' => 1, - ]; + private function inboxController(?string $uid, array $params = []): array { + $inbox = $this->createMock(TaskInboxService::class); + $session = $this->createMock(IUserSession::class); + if ($uid === null) { + $session->method('getUser')->willReturn(null); + } else { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $session->method('getUser')->willReturn($user); + } - $this->request->method('getParam')->willReturn(null); - - $capture = []; - $this->expectAllUserTasksCall($capture, $payload); - - $result = $this->controller->allUserTasks(); - - $this->assertInstanceOf(JSONResponse::class, $result); - $this->assertEquals(200, $result->getStatus()); - $this->assertEquals($payload, $result->getData()); - // Defaults when the caller sends no query parameters at all. - $this->assertEquals([null, 50, 0, null], $capture); - } - - public function testAllUserTasksForwardsStatusPagingAndAssigneeFilters(): void { - $params = [ - 'status' => 'NEEDS-ACTION', - '_limit' => '25', - '_offset' => '10', - 'assignee' => 'alice', - ]; $this->request->method('getParam') ->willReturnCallback( static function (string $key, $default = null) use ($params) { @@ -317,73 +293,144 @@ static function (string $key, $default = null) use ($params) { } ); - $capture = []; - $this->expectAllUserTasksCall($capture, ['results' => [], 'total' => 0]); - - $result = $this->controller->allUserTasks(); + $controller = new TasksController( + 'openregister', + $this->request, + $this->taskService, + $this->objectService, + $inbox, + $session, + new TaskTemporalProjection(), + null + ); - $this->assertEquals(200, $result->getStatus()); - $this->assertEquals(['NEEDS-ACTION', 25, 10, 'alice'], $capture); + return [$controller, $inbox]; } /** - * `limit`/`offset` are the legacy spellings of `_limit`/`_offset`; both - * must reach the service or a client written against either one silently - * gets the default page. + * The aggregate answers from the engine inbox, scoped to the session + * user, and no calendar is enumerated to produce it. * * @return void */ - public function testAllUserTasksAcceptsTheLegacyLimitAndOffsetSpellings(): void { - $params = ['limit' => '15', 'offset' => '5']; - $this->request->method('getParam') + public function testAllUserTasksAnswersFromTheInboxForTheSessionUser(): void { + [$controller, $inbox] = $this->inboxController('alice'); + $payload = ['results' => [['uuid' => 't-1']], 'total' => 1, 'limit' => 50, 'offset' => 0]; + + $captured = null; + $inbox->expects($this->once())->method('inbox') ->willReturnCallback( - static function (string $key, $default = null) use ($params) { - return ($params[$key] ?? $default); + static function (TaskInboxCriteria $criteria, int $limit, int $offset) use (&$captured, $payload) { + $captured = [$criteria, $limit, $offset]; + return $payload; } ); + $this->taskService->expects($this->never())->method('getAllUserTasks'); - $capture = []; - $this->expectAllUserTasksCall($capture, ['results' => [], 'total' => 0]); + $result = $controller->allUserTasks(); - $this->controller->allUserTasks(); - - $this->assertEquals([null, 15, 5, null], $capture); + $this->assertSame(200, $result->getStatus()); + $this->assertSame($payload, $result->getData()); + $this->assertSame('alice', $captured[0]->uid); + $this->assertSame(TaskInboxCriteria::SCOPE_ALL, $captured[0]->scope); + $this->assertSame([50, 0], [$captured[1], $captured[2]]); } /** - * The page size is capped server-side at 200 — an uncapped `_limit` would - * let one request walk every VTODO in every calendar. + * `assignee` is no longer a filter: the aggregate is already scoped to + * the caller, and no parameter names another user's tasks. * * @return void */ - public function testAllUserTasksCapsThePageSizeAtTwoHundred(): void { - $params = ['_limit' => '100000']; - $this->request->method('getParam') + public function testAllUserTasksIgnoresTheAssigneeParameterAndNormalisesStatus(): void { + [$controller, $inbox] = $this->inboxController('alice', [ + 'status' => 'done', + 'assignee' => 'bob', + '_limit' => '25', + '_offset' => '10', + ]); + + $captured = null; + $inbox->method('inbox') ->willReturnCallback( - static function (string $key, $default = null) use ($params) { - return ($params[$key] ?? $default); + static function (TaskInboxCriteria $criteria, int $limit, int $offset) use (&$captured) { + $captured = [$criteria, $limit, $offset]; + return ['results' => [], 'total' => 0, 'limit' => $limit, 'offset' => $offset]; } ); - $capture = []; - $this->expectAllUserTasksCall($capture, ['results' => [], 'total' => 0]); + $controller->allUserTasks(); + + $this->assertSame('alice', $captured[0]->uid, 'the caller, never the assignee parameter'); + $this->assertSame(['completed'], $captured[0]->states, 'legacy status resolves through TaskState'); + $this->assertSame([25, 10], [$captured[1], $captured[2]]); + } + + public function testAllUserTasksAcceptsTheLegacyLimitAndOffsetSpellings(): void { + [$controller, $inbox] = $this->inboxController('alice', ['limit' => '15', 'offset' => '5']); - $this->controller->allUserTasks(); + $inbox->expects($this->once())->method('inbox') + ->with($this->anything(), 15, 5) + ->willReturn(['results' => [], 'total' => 0, 'limit' => 15, 'offset' => 5]); - $this->assertEquals(200, $capture[1]); + $controller->allUserTasks(); } - public function testAllUserTasksReturns500WhenTheCalendarBackendFails(): void { - $this->request->method('getParam')->willReturn(null); + public function testAllUserTasksCapsThePageSizeAtTwoHundred(): void { + [$controller, $inbox] = $this->inboxController('alice', ['_limit' => '100000']); - $this->taskService - ->method('getAllUserTasks') - ->willThrowException(new \Exception('No user logged in')); + $inbox->expects($this->once())->method('inbox') + ->with($this->anything(), 200, 0) + ->willReturn(['results' => [], 'total' => 0, 'limit' => 200, 'offset' => 0]); - $result = $this->controller->allUserTasks(); + $controller->allUserTasks(); + } - $this->assertInstanceOf(JSONResponse::class, $result); - $this->assertEquals(500, $result->getStatus()); - $this->assertEquals('No user logged in', $result->getData()['error']); + public function testAllUserTasksRefusesAnUnmappedStatusRatherThanIgnoringIt(): void { + [$controller, $inbox] = $this->inboxController('alice', ['status' => 'whatever']); + $inbox->expects($this->never())->method('inbox'); + + $result = $controller->allUserTasks(); + + $this->assertSame(400, $result->getStatus()); + $this->assertStringContainsString("'whatever'", $result->getData()['error']); + } + + public function testAllUserTasksWithoutASessionIsUnauthorized(): void { + [$controller, $inbox] = $this->inboxController(null); + $inbox->expects($this->never())->method('inbox'); + + $result = $controller->allUserTasks(); + + $this->assertSame(401, $result->getStatus()); + } + + public function testAllUserTasksReturns500WhenTheInboxFails(): void { + [$controller, $inbox] = $this->inboxController('alice'); + $inbox->method('inbox')->willThrowException(new \RuntimeException('database down')); + + $result = $controller->allUserTasks(); + + $this->assertSame(500, $result->getStatus()); + $this->assertSame('database down', $result->getData()['error']); + } + + /** + * A create payload attempting to set an engine task identity is refused + * by the service; the controller reports it as a 400, and no VTODO is + * created. + * + * @return void + */ + public function testCreateRefusesAnEngineTaskIdentity(): void { + $this->setupObjectValidation($this->createObjectEntity()); + $this->request->method('getParams')->willReturn(['summary' => 'Forged', 'X-OPENREGISTER-TASK' => 'some-uuid']); + $this->taskService->method('createTask') + ->willThrowException(new TaskAccessDeniedException('An engine task cannot be created through the object task endpoint.')); + + $result = $this->controller->create('1', '2', 'test-uuid'); + + $this->assertSame(400, $result->getStatus()); + $this->assertStringContainsString('engine task', $result->getData()['error']); } } diff --git a/tests/Unit/Dav/TaskVtodoWriteBackPluginTest.php b/tests/Unit/Dav/TaskVtodoWriteBackPluginTest.php new file mode 100644 index 0000000000..a9f8e01cf2 --- /dev/null +++ b/tests/Unit/Dav/TaskVtodoWriteBackPluginTest.php @@ -0,0 +1,215 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The in-band hook: the plugin subscribes to the three write hooks, hands a + * projected VTODO to the same gate the event listener uses, replaces the + * body with the engine's rendering when the gate accepts, and turns every + * refusal into a DAV 403 so the client never records the change. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-tasks-on-objects-via-caldav-vtodo + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Dav; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Dav\TaskVtodoWriteBackPlugin; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Service\Task\TaskVtodoWriteBackGate; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ICollection; +use Sabre\DAV\IFile; +use Sabre\DAV\INode; +use Sabre\DAV\Server; +use Sabre\DAV\Tree; + +class TaskVtodoWriteBackPluginTest extends TestCase { + private const UUID = '00000000-0000-0000-0000-000000000002'; + + private TaskVtodoWriteBackGate&MockObject $gate; + + /** + * The Sabre server, mocked: the real class needs a tree and an HTTP + * sapi, and the suite runs both against the test stubs and, in the CI + * container, against the server's real Sabre. + */ + private Server&MockObject $server; + + /** @var array Hooks the plugin subscribed. */ + private array $subscriptions = []; + + protected function setUp(): void { + parent::setUp(); + $this->gate = $this->createMock(TaskVtodoWriteBackGate::class); + $this->gate->method('isProjected')->willReturnCallback( + static fn (string $ics): bool => str_contains($ics, 'X-OPENREGISTER-TASK:') + ); + $this->subscriptions = []; + $this->server = $this->createMock(Server::class); + $this->server->method('on')->willReturnCallback( + function (string $event, callable $callback, int $priority = 100): void { + $this->subscriptions[$event] = [$callback, $priority]; + } + ); + } + + private function plugin(?string $principal = 'principals/users/EXAMPLE_APPROVER_USER'): TaskVtodoWriteBackPlugin { + $plugin = new TaskVtodoWriteBackPlugin($this->gate, new NullLogger()); + $auth = null; + if ($principal !== null) { + $auth = new class($principal) { + public function __construct(private string $principal) { + } + + public function getCurrentPrincipal(): string { + return $this->principal; + } + }; + } + + $this->server->method('getPlugin')->willReturnCallback( + static fn (string $name): ?object => $name === 'auth' ? $auth : null + ); + + $plugin->initialize($this->server); + + return $plugin; + } + + private function projected(string $status): string { + return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" . self::UUID . "\r\nSUMMARY:Approve\r\nSTATUS:{$status}\r\n" + . 'X-OPENREGISTER-TASK:' . self::UUID . "\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + } + + public function testItSubscribesToTheThreeWriteHooks(): void { + $this->plugin(); + + $this->assertArrayHasKey('beforeWriteContent', $this->subscriptions); + $this->assertArrayHasKey('beforeCreateFile', $this->subscriptions); + $this->assertArrayHasKey('beforeUnbind', $this->subscriptions); + $this->assertSame(90, $this->subscriptions['beforeWriteContent'][1]); + } + + public function testAnAcceptedTickStoresTheEnginesRendering(): void { + $this->gate->expects($this->once())->method('handleWrite') + ->with($this->projected('COMPLETED'), 'EXAMPLE_APPROVER_USER') + ->willReturn('RENDERED-BY-ENGINE'); + $data = $this->projected('COMPLETED'); + $modified = false; + + $this->plugin()->beforeWriteContent('calendars/approver/personal/x.ics', $this->createMock(INode::class), $data, $modified); + + $this->assertSame('RENDERED-BY-ENGINE', $data); + $this->assertTrue($modified); + } + + public function testARefusalIsADavForbiddenNamingTheReason(): void { + $this->gate->method('handleWrite')->willThrowException(new TaskAccessDeniedException("Verb 'complete' denied: 'stranger' is not the assignee.")); + $data = $this->projected('COMPLETED'); + $modified = false; + + try { + $this->plugin('principals/users/stranger')->beforeWriteContent('calendars/approver/personal/x.ics', $this->createMock(INode::class), $data, $modified); + $this->fail('expected a Forbidden'); + } catch (Forbidden $forbidden) { + $this->assertStringContainsString('not the assignee', $forbidden->getMessage()); + } + + $this->assertFalse($modified, 'a refused write replaces nothing'); + } + + public function testAConflictAndAnUnknownTaskAreForbiddenToo(): void { + $data = $this->projected('NEEDS-ACTION'); + $modified = false; + $this->gate->method('handleWrite')->willReturnOnConsecutiveCalls( + $this->throwException(new TaskConflictException('terminal')), + $this->throwException(new DoesNotExistException('gone')), + $this->throwException(new \RuntimeException('database down')) + ); + $plugin = $this->plugin(); + + foreach (['terminal', 'does not exist', 'could not verify'] as $fragment) { + try { + $plugin->beforeWriteContent('calendars/a/p/x.ics', $this->createMock(INode::class), $data, $modified); + $this->fail('expected a Forbidden'); + } catch (Forbidden $forbidden) { + $this->assertStringContainsString($fragment, $forbidden->getMessage()); + } + } + } + + public function testAStreamBodyIsReadAndAStandaloneVtodoPassesUntouched(): void { + $standalone = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:x\r\nSUMMARY:Buy milk\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $standalone); + rewind($stream); + $this->gate->expects($this->never())->method('handleWrite'); + $modified = false; + + $this->plugin()->beforeWriteContent('calendars/a/p/x.ics', $this->createMock(INode::class), $stream, $modified); + + $this->assertIsResource($stream); + $this->assertFalse($modified); + $this->assertSame($standalone, stream_get_contents($stream), 'the stream is rewound for the next handler'); + } + + public function testANonCalendarPathIsNotInspected(): void { + $this->gate->expects($this->never())->method('isProjected'); + $data = $this->projected('COMPLETED'); + $modified = false; + + $this->plugin()->beforeWriteContent('files/admin/notes.ics', $this->createMock(INode::class), $data, $modified); + } + + public function testCreatingAVtodoThatForgesATaskIdentityIsForbidden(): void { + $data = $this->projected('NEEDS-ACTION'); + $modified = false; + + $this->expectException(Forbidden::class); + $this->expectExceptionMessage('cannot create an engine task'); + $this->plugin()->beforeCreateFile('calendars/stranger/personal/forged.ics', $data, $this->createMock(ICollection::class), $modified); + } + + public function testCreatingAnOrdinaryVtodoIsAllowed(): void { + $data = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:x\r\nSUMMARY:Buy milk\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + $modified = false; + + $this->plugin()->beforeCreateFile('calendars/a/personal/new.ics', $data, $this->createMock(ICollection::class), $modified); + $this->assertFalse($modified); + } + + public function testDeletingAProjectedVtodoIsRefusedWithTheReason(): void { + $node = $this->createMock(IFile::class); + $node->method('get')->willReturn($this->projected('IN-PROCESS')); + $tree = $this->createMock(Tree::class); + $tree->method('getNodeForPath')->with('calendars/approver/personal/x.ics')->willReturn($node); + $this->server->tree = $tree; + + $this->expectException(Forbidden::class); + $this->expectExceptionMessage('Complete or cancel the task'); + $this->plugin()->beforeUnbind('calendars/approver/personal/x.ics'); + } + + public function testWithoutAnAuthPluginTheActorIsNullAndTheGateDecides(): void { + $this->gate->expects($this->once())->method('handleWrite')->with($this->anything(), null) + ->willThrowException(new TaskAccessDeniedException('no acting identity')); + $data = $this->projected('COMPLETED'); + $modified = false; + + $this->expectException(Forbidden::class); + $this->plugin(null)->beforeWriteContent('calendars/a/p/x.ics', $this->createMock(INode::class), $data, $modified); + } +} diff --git a/tests/Unit/Listener/TaskNotificationListenerTest.php b/tests/Unit/Listener/TaskNotificationListenerTest.php new file mode 100644 index 0000000000..2273fbf503 --- /dev/null +++ b/tests/Unit/Listener/TaskNotificationListenerTest.php @@ -0,0 +1,166 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Task transitions reach the ONE dispatcher through dispatchWithSchema() + * with the recorded action; terminal transitions and assignee changes + * withdraw first; a dispatcher failure never propagates. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-notification-for-an-answered-task-is-withdrawn + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Event\TaskTransitionedEvent; +use OCA\OpenRegister\Listener\TaskNotificationListener; +use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; +use OCA\OpenRegister\Service\Notification\TaskNotificationRules; +use OCA\OpenRegister\Service\Notification\TaskObjectAdapter; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCP\Notification\IManager; +use OCP\Notification\INotification; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class TaskNotificationListenerTest extends TestCase { + private AnnotationNotificationDispatcher&MockObject $dispatcher; + + private IManager&MockObject $notifications; + + protected function setUp(): void { + parent::setUp(); + $this->dispatcher = $this->createMock(AnnotationNotificationDispatcher::class); + $this->notifications = $this->createMock(IManager::class); + } + + private function listener(): TaskNotificationListener { + $inbox = $this->createMock(TaskInboxService::class); + $inbox->method('enrich')->willReturnCallback( + static fn (Task $task): array => ['displayTitle' => 'Assign: Permit 42', 'overdue' => false, 'subject' => null] + ); + + return new TaskNotificationListener($this->dispatcher, new TaskNotificationRules(), $inbox, $this->notifications, new NullLogger()); + } + + private function task(string $state, ?string $assignee, string $action): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setState($state); + $task->setIsTerminal(in_array($state, Task::TERMINAL_STATES, true)); + $task->setLastAction($action); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee($assignee); + + return $task; + } + + private function expectWithdrawal(int $times): void { + $notification = $this->createMock(INotification::class); + $notification->method('setApp')->willReturnSelf(); + $notification->expects($this->exactly($times))->method('setObject')->with('object', 't-1')->willReturnSelf(); + $this->notifications->method('createNotification')->willReturn($notification); + $this->notifications->expects($this->exactly($times))->method('markProcessed'); + } + + public function testAnAssignmentIsDispatchedUnderTheTaskSlugWithTheRecordedAction(): void { + $this->expectWithdrawal(1); + $this->dispatcher->expects($this->once())->method('dispatchWithSchema') + ->with( + $this->callback( + static function (TaskObjectAdapter $adapter): bool { + $payload = $adapter->getObject(); + return $adapter->getUuid() === 't-1' + && $adapter->getSchema() === TaskNotificationRules::SLUG + && $payload['assignee'] === 'approver' + && $payload['previousAssignee'] === null + && $payload['title'] === 'Assign: Permit 42'; + } + ), + 'transition', + $this->callback(static fn (array $ctx): bool => $ctx['action'] === 'assign' && $ctx['actor'] === 'clerk' && $ctx['to'] === 'active'), + $this->callback(static fn ($schema): bool => $schema->getSlug() === TaskNotificationRules::SLUG) + ); + + $this->listener()->handle(new TaskTransitionedEvent($this->task(Task::STATE_ACTIVE, 'approver', 'assign'), null, 'enabled', 'clerk')); + } + + public function testATerminalTransitionWithdrawsBeforeItDispatches(): void { + $order = []; + $notification = $this->createMock(INotification::class); + $notification->method('setApp')->willReturnSelf(); + $notification->method('setObject')->willReturnSelf(); + $this->notifications->method('createNotification')->willReturn($notification); + $this->notifications->method('markProcessed')->willReturnCallback( + static function () use (&$order): void { + $order[] = 'withdraw'; + } + ); + $this->dispatcher->method('dispatchWithSchema')->willReturnCallback( + static function () use (&$order): void { + $order[] = 'dispatch'; + } + ); + + $this->listener()->handle(new TaskTransitionedEvent($this->task(Task::STATE_TERMINATED, 'approver', 'terminate'), 'approver', 'active', null)); + + $this->assertSame(['withdraw', 'dispatch'], $order); + } + + public function testAnUnchangedAssigneeOnAnOpenTaskWithdrawsNothing(): void { + $this->notifications->expects($this->never())->method('markProcessed'); + $this->dispatcher->expects($this->once())->method('dispatchWithSchema'); + + $this->listener()->handle(new TaskTransitionedEvent($this->task(Task::STATE_ACTIVE, 'approver', 'due-soon'), 'approver', 'active', null)); + } + + public function testAClaimWithdrawsThePoolsNotificationsAndCarriesThePreviousHolder(): void { + $this->expectWithdrawal(1); + $this->dispatcher->expects($this->once())->method('dispatchWithSchema') + ->with( + $this->callback(static fn (TaskObjectAdapter $adapter): bool => $adapter->getObject()['previousAssignee'] === null), + 'transition', + $this->callback(static fn (array $ctx): bool => $ctx['action'] === 'claim'), + $this->anything() + ); + + $this->listener()->handle(new TaskTransitionedEvent($this->task(Task::STATE_ACTIVE, 'member-two', 'claim'), null, 'enabled', 'member-two')); + } + + public function testAReassignmentCarriesThePreviousAssigneeForTheAwayRule(): void { + $this->expectWithdrawal(1); + $this->dispatcher->expects($this->once())->method('dispatchWithSchema') + ->with( + $this->callback(static fn (TaskObjectAdapter $adapter): bool => $adapter->getObject()['previousAssignee'] === 'former'), + 'transition', + $this->callback(static fn (array $ctx): bool => $ctx['action'] === 'reassign'), + $this->anything() + ); + + $this->listener()->handle(new TaskTransitionedEvent($this->task(Task::STATE_ACTIVE, 'next', 'reassign'), 'former', 'active', 'manager')); + } + + public function testADispatcherFailureNeverPropagates(): void { + $this->dispatcher->method('dispatchWithSchema')->willThrowException(new \RuntimeException('notification backend down')); + + $this->listener()->handle(new TaskTransitionedEvent($this->task(Task::STATE_ACTIVE, 'approver', 'assign'), 'approver', 'active', null)); + $this->addToAssertionCount(1); + } + + public function testOtherEventsAreIgnored(): void { + $this->dispatcher->expects($this->never())->method('dispatchWithSchema'); + + $this->listener()->handle(new FlowRunTerminalEvent('run-1', 'failed')); + } +} diff --git a/tests/Unit/Listener/TaskTerminalProjectionListenerTest.php b/tests/Unit/Listener/TaskTerminalProjectionListenerTest.php new file mode 100644 index 0000000000..4e57849206 --- /dev/null +++ b/tests/Unit/Listener/TaskTerminalProjectionListenerTest.php @@ -0,0 +1,107 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Dismiss-on-terminality from the user-task node's terminal event: withdraw + * every notification about the task and re-render its calendar entry; a + * non-terminal or task-less event does nothing. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-notification-for-an-answered-task-is-withdrawn + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Listener\TaskTerminalProjectionListener; +use OCA\OpenRegister\Service\Task\TaskProjectionService; +use OCP\EventDispatcher\Event; +use OCP\Notification\IManager; +use OCP\Notification\INotification; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class TaskTerminalProjectionListenerTest extends TestCase { + private IManager&MockObject $notifications; + + private TaskProjectionService&MockObject $projections; + + protected function setUp(): void { + parent::setUp(); + $this->notifications = $this->createMock(IManager::class); + $this->projections = $this->createMock(TaskProjectionService::class); + } + + private function listener(): TaskTerminalProjectionListener { + return new TaskTerminalProjectionListener($this->notifications, $this->projections, new NullLogger()); + } + + /** + * A stand-in for flow-user-task-node's TaskTerminalEvent: any event + * carrying getTask() qualifies, which is exactly the contract. + */ + private function terminalEvent(Task $task): Event { + return new class($task) extends Event { + public function __construct(private Task $task) { + parent::__construct(); + } + + public function getTask(): Task { + return $this->task; + } + }; + } + + private function task(string $state): Task { + $task = new Task(); + $task->setUuid('t-9'); + $task->setState($state); + $task->setIsTerminal(in_array($state, Task::TERMINAL_STATES, true)); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('approver'); + + return $task; + } + + public function testATerminalTaskIsWithdrawnEverywhereAndReRendered(): void { + $task = $this->task(Task::STATE_COMPLETED); + $notification = $this->createMock(INotification::class); + $notification->method('setApp')->willReturnSelf(); + $notification->expects($this->once())->method('setObject')->with('object', 't-9')->willReturnSelf(); + $this->notifications->method('createNotification')->willReturn($notification); + $this->notifications->expects($this->once())->method('markProcessed'); + $this->projections->expects($this->once())->method('reconcileTask')->with($task); + + $this->listener()->handle($this->terminalEvent($task)); + } + + public function testANonTerminalTaskIsLeftAlone(): void { + $this->notifications->expects($this->never())->method('markProcessed'); + $this->projections->expects($this->never())->method('reconcileTask'); + + $this->listener()->handle($this->terminalEvent($this->task(Task::STATE_ACTIVE))); + } + + public function testAnEventWithoutATaskIsIgnored(): void { + $this->notifications->expects($this->never())->method('markProcessed'); + $this->projections->expects($this->never())->method('reconcileTask'); + + $this->listener()->handle(new FlowRunTerminalEvent('run-1', 'failed')); + } + + public function testAWithdrawalFailureStillReRendersTheCalendar(): void { + $task = $this->task(Task::STATE_TERMINATED); + $this->notifications->method('createNotification')->willThrowException(new \RuntimeException('notifications down')); + $this->projections->expects($this->once())->method('reconcileTask')->with($task); + + $this->listener()->handle($this->terminalEvent($task)); + } +} diff --git a/tests/Unit/Listener/TaskVtodoWriteBackListenerTest.php b/tests/Unit/Listener/TaskVtodoWriteBackListenerTest.php new file mode 100644 index 0000000000..a6a2e42026 --- /dev/null +++ b/tests/Unit/Listener/TaskVtodoWriteBackListenerTest.php @@ -0,0 +1,127 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The safety-net hook: a committed write on a projected VTODO reaches the + * same gate the Sabre plugin uses (both hooks, one gate); a refusal is a + * revert, not a failure; a deleted projection is rebuilt; a standalone VTODO + * is ignored. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-truth-flows-one-way-and-the-one-path-back-is-a-gate + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Listener\TaskVtodoWriteBackListener; +use OCA\OpenRegister\Service\Task\TaskProjectionService; +use OCA\OpenRegister\Service\Task\TaskVtodoWriteBackGate; +use OCP\Calendar\Events\CalendarObjectDeletedEvent; +use OCP\Calendar\Events\CalendarObjectUpdatedEvent; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class TaskVtodoWriteBackListenerTest extends TestCase { + private const UUID = '00000000-0000-0000-0000-000000000002'; + + private TaskVtodoWriteBackGate&MockObject $gate; + + private TaskProjectionService&MockObject $projections; + + protected function setUp(): void { + parent::setUp(); + $this->gate = $this->createMock(TaskVtodoWriteBackGate::class); + $this->projections = $this->createMock(TaskProjectionService::class); + } + + private function listener(?string $uid = 'EXAMPLE_APPROVER_USER'): TaskVtodoWriteBackListener { + $session = $this->createMock(IUserSession::class); + if ($uid === null) { + $session->method('getUser')->willReturn(null); + } else { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $session->method('getUser')->willReturn($user); + } + + return new TaskVtodoWriteBackListener($this->gate, $this->projections, $session, new NullLogger()); + } + + private function projected(string $status): string { + return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" . self::UUID . "\r\nSUMMARY:Approve\r\nSTATUS:{$status}\r\n" + . 'X-OPENREGISTER-TASK:' . self::UUID . "\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + } + + private function updated(string $ics): CalendarObjectUpdatedEvent { + return new CalendarObjectUpdatedEvent(9, ['id' => 9, 'principaluri' => 'principals/users/EXAMPLE_APPROVER_USER'], [], ['uri' => 'x.ics', 'calendardata' => $ics]); + } + + public function testACommittedTickReachesTheGateWithTheSessionActor(): void { + $this->gate->expects($this->once())->method('handleWrite') + ->with($this->projected('COMPLETED'), 'EXAMPLE_APPROVER_USER') + ->willReturn('RENDERED'); + // The stored document is the user's; the reconcile makes it the engine's. + $this->projections->expects($this->once())->method('reconcile')->with(self::UUID); + + $this->listener()->handle($this->updated($this->projected('COMPLETED'))); + } + + public function testARefusedCommittedWriteIsARevertNotAFailure(): void { + // The gate has reverted, audited and notified; the listener only reports. + $this->gate->method('handleWrite')->willThrowException(new TaskAccessDeniedException('denied')); + $this->projections->expects($this->never())->method('reconcile'); + + $this->listener('stranger')->handle($this->updated($this->projected('COMPLETED'))); + $this->addToAssertionCount(1); + } + + public function testALostRaceSurfacesAsARevertNotAFiveHundred(): void { + $this->gate->method('handleWrite')->willThrowException(new TaskConflictException('lost a race')); + + $this->listener()->handle($this->updated($this->projected('COMPLETED'))); + $this->addToAssertionCount(1); + } + + public function testAnEchoLeavesTheCalendarAlone(): void { + $this->gate->method('handleWrite')->willReturn(null); + $this->projections->expects($this->never())->method('reconcile'); + + $this->listener()->handle($this->updated($this->projected('IN-PROCESS'))); + } + + public function testAStandaloneVtodoIsIgnored(): void { + $standalone = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:x\r\nSUMMARY:Buy milk\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + $this->gate->expects($this->never())->method('handleWrite'); + $this->projections->expects($this->never())->method('reconcile'); + + $this->listener()->handle($this->updated($standalone)); + $this->listener()->handle(new CalendarObjectDeletedEvent(9, [], [], ['uri' => 'x.ics', 'calendardata' => $standalone])); + } + + public function testADeletedProjectionIsRebuilt(): void { + $this->gate->expects($this->never())->method('handleWrite'); + $this->projections->expects($this->once())->method('reconcile')->with(self::UUID); + + $this->listener()->handle(new CalendarObjectDeletedEvent(9, [], [], ['uri' => 'x.ics', 'calendardata' => $this->projected('IN-PROCESS')])); + } + + public function testWithoutASessionTheActorIsNullAndTheGateDecides(): void { + $this->gate->expects($this->once())->method('handleWrite')->with($this->anything(), null) + ->willThrowException(new TaskAccessDeniedException('no acting identity')); + + $this->listener(null)->handle($this->updated($this->projected('COMPLETED'))); + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/Service/Notification/TaskNotificationRulesTest.php b/tests/Unit/Service/Notification/TaskNotificationRulesTest.php new file mode 100644 index 0000000000..48407efee1 --- /dev/null +++ b/tests/Unit/Service/Notification/TaskNotificationRulesTest.php @@ -0,0 +1,144 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The task rule set is the seed data of flow-task-inbox-projections. These + * tests prove it is dialect-valid (including the new `task-verb` kind), + * that it addresses named transition actions rather than states, and that + * no rule filters a stored `overdue`. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-deadline-notifications-filter-on-the-derived-predicate + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Notification; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Service\Notification\NotificationAnnotationValidator; +use OCA\OpenRegister\Service\Notification\TaskNotificationRules; +use PHPUnit\Framework\TestCase; + +class TaskNotificationRulesTest extends TestCase { + private TaskNotificationRules $rules; + + protected function setUp(): void { + parent::setUp(); + $this->rules = new TaskNotificationRules(); + } + + public function testTheRuleSetIsDialectValid(): void { + $errors = (new NotificationAnnotationValidator())->validate($this->rules->asSchemaArray()); + + $this->assertSame([], $errors, json_encode($errors, JSON_PRETTY_PRINT)); + } + + public function testEveryFieldRecipientNamesAPayloadField(): void { + $fields = array_keys($this->rules->payloadProperties()); + foreach ($this->rules->getRules() as $name => $rule) { + foreach ($rule['recipients'] as $recipient) { + if ($recipient['kind'] !== 'field') { + continue; + } + + $this->assertContains($recipient['field'], $fields, sprintf('%s addresses an unknown payload field', $name)); + } + } + } + + public function testTheSixDeliveriesAreAddressedByAction(): void { + $actions = []; + foreach ($this->rules->getRules() as $rule) { + $trigger = $rule['trigger']; + if ($trigger['type'] !== 'transition') { + continue; + } + + foreach ((array)$trigger['action'] as $action) { + $actions[] = $action; + } + } + + // Offered, assigned, reassigned away, due soon, escalated, cancelled by propagation. + foreach (['offer', 'assign', 'claim', 'reassign', 'due-soon', 'escalate', 'cancel', 'terminate'] as $required) { + $this->assertContains($required, $actions, sprintf('no rule addresses the %s action', $required)); + } + + // No rule addresses a STATE: two actions landing on one state stay distinct. + foreach (['completed', 'terminated', 'active', 'enabled'] as $state) { + $this->assertNotContains($state, $actions); + } + } + + public function testTheAssignedRuleOffersExactlyApproveAndReject(): void { + $rule = $this->rules->getRules()['taskAssignedToYou']; + + $this->assertCount(2, $rule['actions']); + $this->assertSame('task-verb', $rule['actions'][0]['target']['kind']); + $this->assertSame('complete', $rule['actions'][0]['target']['verb']); + $this->assertSame('approved', $rule['actions'][0]['target']['outcome']); + $this->assertSame('rejected', $rule['actions'][1]['target']['outcome']); + $this->assertSame([['kind' => 'field', 'field' => 'assignee']], $rule['recipients']); + } + + public function testTheOverdueRuleFiltersTheDerivedPredicateAndNoStoredFlag(): void { + $rule = $this->rules->getRules()['taskOverdue']; + + $this->assertSame('scheduled', $rule['trigger']['type']); + $fields = array_column($rule['trigger']['filter']['all'], 'field'); + $this->assertSame(['isTerminal', 'dueAt'], $fields); + $this->assertStringNotContainsString('"overdue"', json_encode($rule)); + } + + public function testNoRuleAnywhereFiltersOnAnOverdueField(): void { + foreach ($this->rules->getRules() as $name => $rule) { + $filter = json_encode($rule['trigger']); + $this->assertStringNotContainsString('"field":"overdue"', (string)$filter, $name); + } + } + + public function testThePoolRuleResolvesThroughTheTaskPoolResolverOnly(): void { + $rule = $this->rules->getRules()['taskOfferedToPool']; + + $this->assertCount(1, $rule['recipients']); + $this->assertSame('expression', $rule['recipients'][0]['kind']); + $this->assertSame(\OCA\OpenRegister\Service\Notification\TaskPoolRecipientResolver::class, $rule['recipients'][0]['resolver']); + } + + public function testTheRefusalRuleAddressesTheActorAndCarriesTheReason(): void { + $rule = $this->rules->getRules()['taskWriteBackRefused']; + + $this->assertSame(TaskNotificationRules::ACTION_WRITE_BACK_REFUSED, $rule['trigger']['action']); + $this->assertSame([['kind' => 'field', 'field' => 'writeBackActor']], $rule['recipients']); + $this->assertStringContainsString('{{writeBackReason}}', $rule['message']['en']); + } + + public function testTheTextCarriesNoEmDashAndReadsInSentenceCase(): void { + foreach ($this->rules->getRules() as $name => $rule) { + foreach (['subject', 'message'] as $key) { + foreach (($rule[$key] ?? []) as $locale => $text) { + $this->assertStringNotContainsString("\u{2014}", $text, sprintf('%s.%s.%s', $name, $key, $locale)); + } + } + + foreach (($rule['actions'] ?? []) as $action) { + foreach ($action['label'] as $label) { + $this->assertStringNotContainsString("\u{2014}", $label); + } + } + } + } + + public function testBuildSchemaCarriesTheRulesUnderTheSlug(): void { + $schema = $this->rules->buildSchema(); + + $this->assertSame(TaskNotificationRules::SLUG, $schema->getSlug()); + $this->assertSame($this->rules->getRules(), $schema->getConfiguration()['x-openregister-notifications']); + $this->assertArrayHasKey('assignee', $schema->getProperties()); + } +} diff --git a/tests/Unit/Service/Notification/TaskObjectAdapterTest.php b/tests/Unit/Service/Notification/TaskObjectAdapterTest.php new file mode 100644 index 0000000000..d81d2322e6 --- /dev/null +++ b/tests/Unit/Service/Notification/TaskObjectAdapterTest.php @@ -0,0 +1,102 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The adapter maps a task onto a flat payload and stops: no notification + * logic, the task uuid as the entity uuid (per-task dedupe), and every + * payload key a rule may address present. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Notification; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Notification\TaskNotificationRules; +use OCA\OpenRegister\Service\Notification\TaskObjectAdapter; +use PHPUnit\Framework\TestCase; + +class TaskObjectAdapterTest extends TestCase { + private function task(): Task { + $task = new Task(); + $task->setUuid('00000000-0000-0000-0000-000000000002'); + $task->setTitle('Approve the permit'); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setLastAction('assign'); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('approver'); + $task->setCandidateGroups(['permits']); + $task->setRequester('clerk'); + $task->setPriority('high'); + $task->setDueAt(new DateTime('2026-09-10T12:00:00+00:00')); + + return $task; + } + + public function testTheEntityUuidIsTheTaskUuidUnderTheTaskSlug(): void { + $adapter = new TaskObjectAdapter($this->task()); + + $this->assertSame('00000000-0000-0000-0000-000000000002', $adapter->getUuid()); + $this->assertSame(TaskNotificationRules::SLUG, $adapter->getSchema()); + $this->assertNull($adapter->getRegister()); + $this->assertSame('Approve the permit', $adapter->getName()); + } + + public function testThePayloadCoversEveryDeclaredProperty(): void { + $payload = TaskObjectAdapter::payload($this->task()); + $declared = array_keys((new TaskNotificationRules())->payloadProperties()); + + sort($declared); + $keys = array_keys($payload); + sort($keys); + $this->assertSame($declared, $keys); + } + + public function testDerivedFieldsComeFromTheRowNotFromTheAdapter(): void { + $row = ['displayTitle' => 'Assign: Permit 42', 'overdue' => true, 'daysOverdue' => 3, 'subject' => ['title' => 'Permit 42']]; + $payload = (new TaskObjectAdapter($this->task(), $row))->getObject(); + + $this->assertSame('Assign: Permit 42', $payload['title']); + $this->assertTrue($payload['overdue']); + $this->assertSame(3, $payload['daysOverdue']); + $this->assertSame('Permit 42', $payload['subjectTitle']); + $this->assertSame('2026-09-10T12:00:00+00:00', $payload['dueAt']); + } + + public function testExtraOnlyFillsDeclaredKeys(): void { + $payload = (new TaskObjectAdapter($this->task(), [], [ + 'previousAssignee' => 'former', + 'writeBackActor' => 'stranger', + 'somethingElse' => 'ignored', + ]))->getObject(); + + $this->assertSame('former', $payload['previousAssignee']); + $this->assertSame('stranger', $payload['writeBackActor']); + $this->assertArrayNotHasKey('somethingElse', $payload); + } + + public function testAPooledTaskCarriesNoAssignee(): void { + $task = $this->task(); + $task->setAssignee(null); + $payload = (new TaskObjectAdapter($task))->getObject(); + + $this->assertNull($payload['assignee']); + $this->assertSame(['permits'], $payload['candidateGroups']); + } + + public function testABareAdapterIsConstructibleForTheEntityBaseClass(): void { + $adapter = new TaskObjectAdapter(); + + $this->assertSame(TaskNotificationRules::SLUG, $adapter->getSchema()); + $this->assertNull($adapter->getUuid()); + } +} diff --git a/tests/Unit/Service/Notification/TaskPoolRecipientResolverTest.php b/tests/Unit/Service/Notification/TaskPoolRecipientResolverTest.php new file mode 100644 index 0000000000..3374dffda3 --- /dev/null +++ b/tests/Unit/Service/Notification/TaskPoolRecipientResolverTest.php @@ -0,0 +1,106 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Offering to a pool notifies the pool: every member of the candidate + * groups plus the candidate users, nobody outside it, and nobody at all + * when a group cannot be resolved (fail closed, never "everyone"). + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-task-lifecycle-delivery-is-automatic-and-declarative + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Notification; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Notification\TaskObjectAdapter; +use OCA\OpenRegister\Service\Notification\TaskPoolRecipientResolver; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class TaskPoolRecipientResolverTest extends TestCase { + private IGroupManager&MockObject $groups; + + private IUserManager&MockObject $users; + + protected function setUp(): void { + parent::setUp(); + $this->groups = $this->createMock(IGroupManager::class); + $this->users = $this->createMock(IUserManager::class); + } + + private function group(array $uids): IGroup { + $members = []; + foreach ($uids as $uid) { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $members[] = $user; + } + + $group = $this->createMock(IGroup::class); + $group->method('getUsers')->willReturn($members); + + return $group; + } + + private function pooled(array $groups, array $users = []): TaskObjectAdapter { + $task = new Task(); + $task->setUuid('t-pool'); + $task->setState(Task::STATE_ENABLED); + $task->setPerformerType(Task::PERFORMER_GROUP); + $task->setCandidateGroups($groups); + $task->setCandidateUsers($users); + + return new TaskObjectAdapter($task); + } + + public function testAllFourMembersAndNobodyElse(): void { + $this->groups->method('get')->with('permits')->willReturn($this->group(['a', 'b', 'c', 'd'])); + $resolver = new TaskPoolRecipientResolver($this->groups, $this->users, $this->createMock(LoggerInterface::class)); + + $this->assertSame(['a', 'b', 'c', 'd'], $resolver->resolve($this->pooled(['permits']), ['action' => 'offer'])); + } + + public function testANonMemberIsNotNotified(): void { + $this->groups->method('get')->willReturn($this->group(['member'])); + $resolver = new TaskPoolRecipientResolver($this->groups, $this->users, $this->createMock(LoggerInterface::class)); + + $this->assertNotContains('outsider', $resolver->resolve($this->pooled(['permits']), [])); + } + + public function testAnUnknownGroupAddsNobodyAndIsLogged(): void { + $this->groups->method('get')->willReturn(null); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning')->with($this->stringContains('does not exist')); + $resolver = new TaskPoolRecipientResolver($this->groups, $this->users, $logger); + + $this->assertSame([], $resolver->resolve($this->pooled(['ghosts']), [])); + } + + public function testCandidateUsersAreIncludedOnlyWhenTheyExist(): void { + $this->users->method('userExists')->willReturnCallback(static fn (string $uid): bool => $uid === 'real'); + $resolver = new TaskPoolRecipientResolver($this->groups, $this->users, $this->createMock(LoggerInterface::class)); + + $this->assertSame(['real'], $resolver->resolve($this->pooled([], ['real', 'phantom']), [])); + } + + public function testMembersAreDistinctAcrossGroups(): void { + $this->groups->method('get')->willReturnCallback( + fn (string $gid): IGroup => $gid === 'a' ? $this->group(['x', 'y']) : $this->group(['y', 'z']) + ); + $resolver = new TaskPoolRecipientResolver($this->groups, $this->users, $this->createMock(LoggerInterface::class)); + + $this->assertSame(['x', 'y', 'z'], $resolver->resolve($this->pooled(['a', 'b']), [])); + } +} diff --git a/tests/Unit/Service/Notification/TaskVerbActionTest.php b/tests/Unit/Service/Notification/TaskVerbActionTest.php new file mode 100644 index 0000000000..24d65bbdb5 --- /dev/null +++ b/tests/Unit/Service/Notification/TaskVerbActionTest.php @@ -0,0 +1,216 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The one new dialect surface: a `task-verb` action target is validated + * against the closed verb list, resolved to the verb route as POST, and + * routed to the form as GET when its outcome needs a comment. The three + * existing kinds keep rendering GET. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-binary-decision-is-decidable-from-the-notification + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Notification; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; +use OCA\OpenRegister\Service\Notification\NotificationAnnotationValidator; +use OCA\OpenRegister\Service\Notification\TaskNotificationRules; +use OCA\OpenRegister\Service\Notification\TaskObjectAdapter; +use OCP\Activity\IManager as IActivityManager; +use OCP\Http\Client\IClientService; +use OCP\IGroupManager; +use OCP\IServerContainer; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Mail\IMailer; +use OCP\Notification\IAction; +use OCP\Notification\IManager as INotificationManager; +use OCP\Notification\INotification; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class TaskVerbActionTest extends TestCase { + private function schemaWithActions(array $actions): array { + return [ + 'properties' => ['assignee' => ['type' => 'string']], + 'x-openregister-notifications' => [ + 'x' => [ + 'trigger' => ['type' => 'transition'], + 'recipients' => [['kind' => 'field', 'field' => 'assignee']], + 'channels' => ['nc-notification'], + 'subject' => ['en' => 'Task: {{title}}'], + 'actions' => $actions, + ], + ], + ]; + } + + public function testTheValidatorAcceptsAKnownVerbAndNamesAnUnknownOne(): void { + $validator = new NotificationAnnotationValidator(); + + $ok = $validator->validate($this->schemaWithActions([ + ['label' => ['en' => 'Approve'], 'target' => ['kind' => 'task-verb', 'verb' => 'complete', 'outcome' => 'approved']], + ])); + $this->assertSame([], $ok); + + $bad = $validator->validate($this->schemaWithActions([ + ['label' => ['en' => 'Nuke'], 'target' => ['kind' => 'task-verb', 'verb' => 'delete']], + ])); + $this->assertCount(1, $bad); + $this->assertSame('notification-action-bad-target', $bad[0]['code']); + $this->assertStringContainsString('"delete"', $bad[0]['message']); + } + + public function testTheValidatorStillCapsAtTwoActions(): void { + $action = ['label' => ['en' => 'x'], 'target' => ['kind' => 'task-verb', 'verb' => 'complete']]; + $errors = (new NotificationAnnotationValidator())->validate($this->schemaWithActions([$action, $action, $action])); + + $this->assertContains('notification-too-many-actions', array_column($errors, 'code')); + } + + /** + * Drive the real dispatcher over the real task rule set and capture the + * actions it hands the notifier for the assignee. + * + * @return array> The resolved `_actions`. + */ + private function resolvedActionsForAssignment(): array { + $captured = null; + $notification = $this->createMock(INotification::class); + foreach (['setApp', 'setUser', 'setDateTime', 'setObject'] as $method) { + $notification->method($method)->willReturnSelf(); + } + + $notification->method('setSubject')->willReturnCallback( + static function (string $subject, array $params) use (&$captured, $notification): INotification { + $captured = $params; + return $notification; + } + ); + $notifications = $this->createMock(INotificationManager::class); + $notifications->method('createNotification')->willReturn($notification); + $notifications->method('notify'); + + $users = $this->createMock(IUserManager::class); + $users->method('userExists')->willReturn(true); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('approver'); + $users->method('get')->willReturn($user); + + $urls = $this->createMock(IURLGenerator::class); + $urls->method('linkToRouteAbsolute')->willReturnCallback( + static fn (string $route, array $params = []): string => 'https://cloud.example/' . $route . '/' . ($params['uuid'] ?? '') + ); + + $dispatcher = new AnnotationNotificationDispatcher( + $this->createMock(SchemaMapper::class), + $notifications, + new NullLogger(), + $this->createMock(IGroupManager::class), + $users, + $this->createMock(IMailer::class), + $this->createMock(IActivityManager::class), + $this->createMock(IClientService::class), + $this->createMock(IServerContainer::class), + null, + null, + null, + null, + null, + null, + null, + null, + $urls + ); + + $task = new Task(); + $task->setUuid('t-approve'); + $task->setTitle('Approve the permit'); + $task->setState(Task::STATE_ACTIVE); + $task->setLastAction('assign'); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('approver'); + + $rules = new TaskNotificationRules(); + // Only the assignment rule, so one notification is emitted. + $schema = $rules->buildSchema(); + $schema->setConfiguration(['x-openregister-notifications' => ['taskAssignedToYou' => $rules->getRules()['taskAssignedToYou']]]); + + $dispatcher->dispatchWithSchema(new TaskObjectAdapter($task), 'transition', ['action' => 'assign'], $schema); + + $this->assertIsArray($captured, 'the assignee received the notification'); + $this->assertStringContainsString('Approve the permit', $captured['_text']); + + return $captured['_actions']; + } + + public function testApproveIsAPostToTheVerbRouteAndRejectAGetToTheForm(): void { + $actions = $this->resolvedActionsForAssignment(); + + $this->assertCount(2, $actions); + $this->assertSame('POST', $actions[0]['method']); + $this->assertSame('https://cloud.example/openregister.task.complete/t-approve?outcome=approved', $actions[0]['url']); + $this->assertTrue($actions[0]['primary']); + + // A rejecting outcome needs a comment: the button opens the form. + $this->assertSame('GET', $actions[1]['method']); + $this->assertSame('https://cloud.example/openregister.task.open/t-approve', $actions[1]['url']); + } + + public function testTheNotifierRendersTheMethodItWasHanded(): void { + $l10n = $this->createMock(\OCP\IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $factory = $this->createMock(\OCP\L10N\IFactory::class); + $factory->method('get')->willReturn($l10n); + $urls = $this->createMock(IURLGenerator::class); + $urls->method('imagePath')->willReturn('/apps/openregister/img/app.svg'); + $urls->method('getAbsoluteURL')->willReturn('https://cloud.example/apps/openregister/img/app.svg'); + + $links = []; + $action = $this->createMock(IAction::class); + $action->method('setLabel')->willReturnSelf(); + $action->method('setPrimary')->willReturnSelf(); + $action->method('setLink')->willReturnCallback( + static function (string $url, string $method) use (&$links, $action): IAction { + $links[] = [$url, $method]; + return $action; + } + ); + + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('openregister'); + $notification->method('getSubject')->willReturn('object_transitioned'); + $notification->method('getSubjectParameters')->willReturn([ + '_text' => 'Assigned to you: Approve the permit', + '_actions' => [ + ['label' => ['en' => 'Approve', 'nl' => 'Goedkeuren'], 'primary' => true, 'url' => 'https://x/complete?outcome=approved', 'method' => 'POST'], + ['label' => ['en' => 'Reject'], 'url' => 'https://x/open', 'method' => 'GET'], + ['label' => ['en' => 'Legacy'], 'url' => 'https://x/legacy'], + ['label' => ['en' => 'Smuggled'], 'url' => 'https://x/evil', 'method' => 'TRACE'], + ], + ]); + $notification->method('createAction')->willReturn($action); + $notification->method('setIcon')->willReturnSelf(); + $notification->method('setParsedSubject')->willReturnSelf(); + $notification->method('addAction')->willReturnSelf(); + + (new \OCA\OpenRegister\Notification\AnnotationNotifier($factory, $urls))->prepare($notification, 'nl'); + + $this->assertSame([ + ['https://x/complete?outcome=approved', 'POST'], + ['https://x/open', 'GET'], + ['https://x/legacy', 'GET'], + ['https://x/evil', 'GET'], + ], $links); + } +} diff --git a/tests/Unit/Service/Task/TaskCalendarProjectorTest.php b/tests/Unit/Service/Task/TaskCalendarProjectorTest.php new file mode 100644 index 0000000000..f6dfc1fc2e --- /dev/null +++ b/tests/Unit/Service/Task/TaskCalendarProjectorTest.php @@ -0,0 +1,368 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The calendar projection: into the ASSIGNEE's calendar, with the property + * table from design D-5; a pooled task in nobody's calendar; reassignment + * moving the entry; idempotent re-renders; a destroyed projection rebuilt; + * a missing calendar skipped and logged, never thrown. + * + * Fixtures (design, Seed Data): one assigned task with a projection + * (assignee EXAMPLE_APPROVER_USER, uuid ...0002), one pooled task with no + * projection, one assignee with no VTODO-capable calendar. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-projection-is-idempotent-and-does-not-feed-itself + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-truth-flows-one-way-and-the-one-path-back-is-a-gate + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-the-projection-carries-a-real-assignee-not-prose + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-calendar-selection-for-tasks + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskProjectionState; +use OCA\OpenRegister\Db\TaskProjectionStateMapper; +use OCA\OpenRegister\Exception\NoVtodoCalendarException; +use OCA\OpenRegister\Service\Task\TaskCalendarProjector; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\VtodoCalendarLocator; +use OCP\IURLGenerator; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class TaskCalendarProjectorTest extends TestCase { + public const APPROVER = 'EXAMPLE_APPROVER_USER'; + + public const PROJECTED_UUID = '00000000-0000-0000-0000-000000000002'; + + public const POOLED_UUID = '00000000-0000-0000-0000-000000000003'; + + private \OCA\DAV\CalDAV\CalDavBackend&MockObject $backend; + + private VtodoCalendarLocator&MockObject $calendars; + + private TaskProjectionStateMapper&MockObject $states; + + private LoggerInterface&MockObject $logger; + + /** @var array */ + private array $stateRows = []; + + protected function setUp(): void { + parent::setUp(); + $this->backend = $this->createMock(\OCA\DAV\CalDAV\CalDavBackend::class); + $this->calendars = $this->createMock(VtodoCalendarLocator::class); + $this->states = $this->createMock(TaskProjectionStateMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + + // An in-memory state store: save() persists, findForTask() reads back. + $this->stateRows = []; + $this->states->method('findForTask')->willReturnCallback( + fn (string $uuid): ?TaskProjectionState => $this->stateRows[$uuid] ?? null + ); + $this->states->method('save')->willReturnCallback( + function (TaskProjectionState $state): TaskProjectionState { + if ($state->getId() === null) { + $state->setId(count($this->stateRows) + 1); + } + + $this->stateRows[(string)$state->getTaskUuid()] = $state; + return $state; + } + ); + $this->states->method('delete')->willReturnCallback( + function (TaskProjectionState $state): TaskProjectionState { + unset($this->stateRows[(string)$state->getTaskUuid()]); + return $state; + } + ); + } + + private function projector(): TaskCalendarProjector { + $inbox = $this->createMock(TaskInboxService::class); + $inbox->method('displayTitle')->willReturnCallback( + static fn (Task $task): string => (string)($task->getTitle() ?? 'Task') + ); + $urls = $this->createMock(IURLGenerator::class); + $urls->method('linkToRouteAbsolute')->willReturnCallback( + static fn (string $route, array $params): string => 'https://cloud.example/index.php/apps/openregister/flow-tasks/' . $params['uuid'] + ); + + return new TaskCalendarProjector($this->backend, $this->calendars, $this->states, $inbox, $urls, $this->logger); + } + + /** + * Fixture 1: the assigned task with a projection. + */ + public static function assignedTask(): Task { + $task = new Task(); + $task->setId(2); + $task->setUuid(self::PROJECTED_UUID); + $task->setTitle('Approve the permit'); + $task->setDescription('Check the drawings against the zoning plan.'); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setLastAction('assign'); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee(self::APPROVER); + $task->setPriority('high'); + $task->setDueAt(new DateTime('2026-09-10T12:00:00+00:00')); + $task->setObjectUuid('obj-42'); + $task->setRegisterId(8); + $task->setSchemaId(18); + + return $task; + } + + /** + * Fixture 2: the pooled task with no projection. + */ + public static function pooledTask(): Task { + $task = new Task(); + $task->setId(3); + $task->setUuid(self::POOLED_UUID); + $task->setTitle('Second signature'); + $task->setState(Task::STATE_ENABLED); + $task->setIsTerminal(false); + $task->setLastAction('offer'); + $task->setPerformerType(Task::PERFORMER_GROUP); + $task->setCandidateGroups(['permits']); + + return $task; + } + + private function calendarFor(string $uid, int $id): void { + $this->calendars->method('forUser')->with($uid)->willReturn(['id' => $id, 'uri' => 'personal']); + } + + public function testRenderCarriesThePropertyTable(): void { + $folded = $this->projector()->render(self::assignedTask()); + // Unfold RFC 5545 continuation lines so the assertions read whole properties. + $ics = str_replace("\r\n ", '', $folded); + + $this->assertStringContainsString('BEGIN:VTODO', $ics); + $this->assertSame(1, substr_count($ics, 'UID:'), 'exactly one UID, the task uuid'); + $this->assertSame(1, substr_count($ics, 'DTSTAMP:')); + $this->assertStringContainsString('UID:' . self::PROJECTED_UUID, $ics); + $this->assertStringContainsString('SUMMARY:Approve the permit', $ics); + $this->assertStringContainsString('DUE:20260910T120000Z', $ics); + $this->assertStringContainsString('PRIORITY:3', $ics); + $this->assertStringContainsString('STATUS:IN-PROCESS', $ics); + $this->assertMatchesRegularExpression( + '#URL(;VALUE=URI)?:https://cloud\.example/index\.php/apps/openregister/flow-tasks/' . self::PROJECTED_UUID . '#', + $ics + ); + $this->assertStringContainsString('X-OPENREGISTER-TASK:' . self::PROJECTED_UUID, $ics); + $this->assertStringContainsString('X-OPENREGISTER-TASK-ASSIGNEE:' . self::APPROVER, $ics); + $this->assertStringContainsString('X-OPENREGISTER-REGISTER:8', $ics); + $this->assertStringContainsString('X-OPENREGISTER-OBJECT:obj-42', $ics); + $this->assertMatchesRegularExpression( + '#LINK;LINKREL=related;LABEL="?Approve the permit"?;VALUE=URI:/apps/openregister/api/objects/8/18/obj-42#', + $ics + ); + // The assignee is an identity, never prose. + $this->assertStringNotContainsString('Assigned to', $ics); + // The deep link is a page, not the API. + $this->assertDoesNotMatchRegularExpression('#URL(;VALUE=URI)?:https://cloud\.example/index\.php/apps/openregister/api#', $ics); + } + + public function testATerminalTaskRendersTerminal(): void { + $task = self::assignedTask(); + $task->setState(Task::STATE_COMPLETED); + $task->setIsTerminal(true); + $task->setCompletedAt(new DateTime('2026-09-01T09:00:00+00:00')); + + $ics = $this->projector()->render($task); + + $this->assertStringContainsString('STATUS:COMPLETED', $ics); + $this->assertStringContainsString('COMPLETED:20260901T090000Z', $ics); + $this->assertStringContainsString('PERCENT-COMPLETE:100', $ics); + } + + public function testATaskLandsInTheAssigneesCalendarNotTheActors(): void { + $this->calendarFor(self::APPROVER, 9); + $this->backend->expects($this->once())->method('createCalendarObject') + ->with(9, 'openregister-task-' . self::PROJECTED_UUID . '.ics', $this->stringContains('X-OPENREGISTER-TASK:' . self::PROJECTED_UUID)); + $this->backend->expects($this->never())->method('updateCalendarObject'); + + $this->projector()->project(self::assignedTask(), null); + + $state = $this->stateRows[self::PROJECTED_UUID]; + $this->assertSame(self::APPROVER, $state->getAssignee()); + $this->assertSame(9, $state->getCalendarId()); + $this->assertNotEmpty($state->getRenderedHash()); + } + + public function testAnUnclaimedPooledTaskIsInNobodysCalendar(): void { + $this->calendars->expects($this->never())->method('forUser'); + $this->backend->expects($this->never())->method('createCalendarObject'); + + $this->projector()->project(self::pooledTask(), null); + + $this->assertArrayNotHasKey(self::POOLED_UUID, $this->stateRows); + } + + public function testAnAssigneeWithoutACalendarIsSkippedAndLoggedNamingTheTask(): void { + $this->calendars->method('forUser')->willThrowException(new NoVtodoCalendarException('nocal')); + $this->backend->expects($this->never())->method('createCalendarObject'); + $this->logger->expects($this->once())->method('info') + ->with( + $this->stringContains('no VTODO-capable calendar'), + $this->callback(static fn (array $context): bool => $context['task'] === self::PROJECTED_UUID && $context['surface'] === 'caldav') + ); + + $task = self::assignedTask(); + $task->setAssignee('nocal'); + + // No exception: the transition this projection follows is unaffected. + $this->projector()->project($task, null); + $this->assertArrayNotHasKey(self::PROJECTED_UUID, $this->stateRows); + } + + public function testRenderingTwiceWritesOnce(): void { + $this->calendarFor(self::APPROVER, 9); + $this->backend->expects($this->once())->method('createCalendarObject'); + $this->backend->expects($this->never())->method('updateCalendarObject'); + + $projector = $this->projector(); + $projector->project(self::assignedTask(), null); + $projector->project(self::assignedTask(), self::APPROVER); + } + + public function testAChangedTaskUpdatesTheExistingEntry(): void { + $this->calendarFor(self::APPROVER, 9); + $this->backend->expects($this->once())->method('createCalendarObject'); + $this->backend->method('getCalendarObject')->willReturn(['calendardata' => 'BEGIN:VCALENDAR', 'uri' => 'x']); + $this->backend->expects($this->once())->method('updateCalendarObject') + ->with(9, 'openregister-task-' . self::PROJECTED_UUID . '.ics', $this->stringContains('STATUS:COMPLETED')); + + $projector = $this->projector(); + $projector->project(self::assignedTask(), null); + + $completed = self::assignedTask(); + $completed->setState(Task::STATE_COMPLETED); + $completed->setIsTerminal(true); + $projector->project($completed, self::APPROVER); + } + + public function testReassignmentMovesTheEntry(): void { + $this->calendars->method('forUser')->willReturnCallback( + static fn (string $uid): array => $uid === self::APPROVER ? ['id' => 9, 'uri' => 'personal'] : ['id' => 21, 'uri' => 'personal'] + ); + $this->backend->method('getCalendarObject')->willReturn(['calendardata' => 'BEGIN:VCALENDAR', 'uri' => 'x']); + $this->backend->expects($this->once())->method('deleteCalendarObject') + ->with(9, 'openregister-task-' . self::PROJECTED_UUID . '.ics'); + $created = []; + $this->backend->method('createCalendarObject')->willReturnCallback( + static function (int $calendarId) use (&$created): string { + $created[] = $calendarId; + return 'etag'; + } + ); + + $projector = $this->projector(); + $projector->project(self::assignedTask(), null); + + $reassigned = self::assignedTask(); + $reassigned->setAssignee('second-approver'); + $reassigned->setLastAction('reassign'); + $projector->project($reassigned, self::APPROVER); + + $this->assertSame([9, 21], $created); + $this->assertSame('second-approver', $this->stateRows[self::PROJECTED_UUID]->getAssignee()); + } + + public function testADestroyedProjectionIsRebuiltWithTheSameContent(): void { + $this->calendarFor(self::APPROVER, 9); + $rendered = []; + $this->backend->method('createCalendarObject')->willReturnCallback( + static function (int $calendarId, string $uri, string $data) use (&$rendered): string { + $rendered[] = $data; + return 'etag'; + } + ); + // The VTODO is gone from the calendar. + $this->backend->method('getCalendarObject')->willReturn(null); + + $projector = $this->projector(); + $projector->project(self::assignedTask(), null); + $projector->reconcile(self::assignedTask()); + + $this->assertCount(2, $rendered); + $this->assertSame(TaskCalendarProjector::contentHash($rendered[0]), TaskCalendarProjector::contentHash($rendered[1])); + } + + public function testAnEditedProjectionIsOverwrittenOnReconcile(): void { + $this->calendarFor(self::APPROVER, 9); + $this->backend->method('createCalendarObject')->willReturn('etag'); + $edited = str_replace('SUMMARY:Approve the permit', 'SUMMARY:Something else', $this->projector()->render(self::assignedTask())); + $this->backend->method('getCalendarObject')->willReturn(['calendardata' => $edited, 'uri' => 'x']); + $this->backend->expects($this->once())->method('updateCalendarObject') + ->with(9, $this->anything(), $this->stringContains('SUMMARY:Approve the permit')); + + $projector = $this->projector(); + $projector->project(self::assignedTask(), null); + $projector->reconcile(self::assignedTask()); + } + + public function testATerminalTaskNeverProjectedGetsNoEntry(): void { + $task = self::assignedTask(); + $task->setState(Task::STATE_TERMINATED); + $task->setIsTerminal(true); + $this->backend->expects($this->never())->method('createCalendarObject'); + + $this->projector()->project($task, null); + } + + public function testTheEchoIsRecognisedByContentNotBytes(): void { + $this->calendarFor(self::APPROVER, 9); + $this->backend->method('createCalendarObject')->willReturn('etag'); + $projector = $this->projector(); + $projector->project(self::assignedTask(), null); + + // A client re-serialises with a different DTSTAMP and folding: still an echo. + $reserialised = preg_replace('/DTSTAMP:\d{8}T\d{6}Z/', 'DTSTAMP:20300101T000000Z', $projector->render(self::assignedTask())); + $this->assertTrue($projector->isEcho(self::PROJECTED_UUID, (string)$reserialised)); + + // A status edit is not. + $ticked = str_replace('STATUS:IN-PROCESS', 'STATUS:COMPLETED', $projector->render(self::assignedTask())); + $this->assertFalse($projector->isEcho(self::PROJECTED_UUID, $ticked)); + $this->assertFalse($projector->isEcho('unknown-task', $ticked)); + } + + public function testTwoUsersWithTheSameDisplayNameStayDistinct(): void { + // The projection carries the uid, so the display name never enters it. + $ics = $this->projector()->render(self::assignedTask()); + $fields = TaskCalendarProjector::engineFields($ics); + + $this->assertSame(self::APPROVER, $fields['assignee']); + $this->assertSame(self::PROJECTED_UUID, TaskCalendarProjector::taskUuidOf($ics)); + } + + public function testAStandaloneVtodoHasNoTaskIdentity(): void { + $standalone = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:abc\r\nSUMMARY:Buy milk\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + + $this->assertNull(TaskCalendarProjector::taskUuidOf($standalone)); + $this->assertNull(TaskCalendarProjector::taskUuidOf('not a calendar')); + } + + public function testACalendarBackendThatFailsEveryWriteThrowsForTheCallerToIsolate(): void { + $this->calendarFor(self::APPROVER, 9); + $this->backend->method('createCalendarObject')->willThrowException(new \RuntimeException('calendar down')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('calendar down'); + + $this->projector()->project(self::assignedTask(), null); + } +} diff --git a/tests/Unit/Service/Task/TaskProjectionServiceTest.php b/tests/Unit/Service/Task/TaskProjectionServiceTest.php new file mode 100644 index 0000000000..4cddf8c40c --- /dev/null +++ b/tests/Unit/Service/Task/TaskProjectionServiceTest.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Delivery failure is isolated: a calendar backend that fails every write + * leaves the assignment committed, is logged naming the task and the + * surface, and never propagates. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-truth-flows-one-way-and-the-one-path-back-is-a-gate + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Event\TaskTransitionedEvent; +use OCA\OpenRegister\Listener\TaskCalendarProjectionListener; +use OCA\OpenRegister\Service\Task\TaskCalendarProjector; +use OCA\OpenRegister\Service\Task\TaskProjectionService; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class TaskProjectionServiceTest extends TestCase { + private TaskCalendarProjector&MockObject $projector; + + private TaskMapper&MockObject $tasks; + + private LoggerInterface&MockObject $logger; + + protected function setUp(): void { + parent::setUp(); + $this->projector = $this->createMock(TaskCalendarProjector::class); + $this->tasks = $this->createMock(TaskMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + private function service(): TaskProjectionService { + return new TaskProjectionService($this->projector, $this->tasks, $this->logger); + } + + public function testACalendarOutageIsLoggedNamingTheTaskAndSurfaceAndNeverThrown(): void { + $task = TaskCalendarProjectorTest::assignedTask(); + $this->projector->method('project')->willThrowException(new \RuntimeException('calendar backend fails every write')); + $this->logger->expects($this->once())->method('warning') + ->with( + $this->logicalAnd($this->stringContains(TaskCalendarProjectorTest::PROJECTED_UUID), $this->stringContains('caldav')), + $this->callback(static fn (array $ctx): bool => $ctx['task'] === TaskCalendarProjectorTest::PROJECTED_UUID && $ctx['surface'] === 'caldav') + ); + + (new TaskCalendarProjectionListener($this->service()))->handle(new TaskTransitionedEvent($task, null, 'enabled', 'clerk')); + $this->addToAssertionCount(1); + } + + public function testATransitionHandsThePreviousAssigneeToTheProjector(): void { + $task = TaskCalendarProjectorTest::assignedTask(); + $this->projector->expects($this->once())->method('project')->with($task, 'former'); + + $this->service()->afterTransition(new TaskTransitionedEvent($task, 'former', 'active', 'manager')); + } + + public function testReconcileResolvesTheTaskAndReportsTheOutcome(): void { + $task = TaskCalendarProjectorTest::assignedTask(); + $this->tasks->method('findByUuid')->with(TaskCalendarProjectorTest::PROJECTED_UUID)->willReturn($task); + $this->projector->expects($this->once())->method('reconcile')->with($task); + + $this->assertTrue($this->service()->reconcile(TaskCalendarProjectorTest::PROJECTED_UUID)); + } + + public function testReconcilingAnUnknownTaskIsFalseNotAnError(): void { + $this->tasks->method('findByUuid')->willThrowException(new DoesNotExistException('gone')); + $this->projector->expects($this->never())->method('reconcile'); + + $this->assertFalse($this->service()->reconcile('nope')); + } + + public function testAFailedReconcileIsFalseAndLogged(): void { + $task = TaskCalendarProjectorTest::assignedTask(); + $this->projector->method('reconcile')->willThrowException(new \RuntimeException('down')); + $this->logger->expects($this->once())->method('warning'); + + $this->assertFalse($this->service()->reconcileTask($task)); + } +} diff --git a/tests/Unit/Service/Task/TaskServiceAnnouncementTest.php b/tests/Unit/Service/Task/TaskServiceAnnouncementTest.php new file mode 100644 index 0000000000..14fba55218 --- /dev/null +++ b/tests/Unit/Service/Task/TaskServiceAnnouncementTest.php @@ -0,0 +1,160 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The post-commit seam: a verb announces its committed transition with the + * previous holder and the actor, AFTER the commit, and a listener that + * throws cannot fail the verb. A rolled-back verb announces nothing. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-delivery-failure-never-fails-the-task + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Event\TaskTransitionedEvent; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCA\OpenRegister\Service\Task\TaskPerformerResolver; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class TaskServiceAnnouncementTest extends TestCase { + private TaskMapper&MockObject $tasks; + + private IDBConnection&MockObject $db; + + private IEventDispatcher&MockObject $events; + + /** @var array */ + private array $order = []; + + protected function setUp(): void { + parent::setUp(); + $this->tasks = $this->createMock(TaskMapper::class); + $this->db = $this->createMock(IDBConnection::class); + $this->events = $this->createMock(IEventDispatcher::class); + $this->order = []; + $this->db->method('commit')->willReturnCallback( + function (): void { + $this->order[] = 'commit'; + } + ); + $this->db->method('rollBack')->willReturnCallback( + function (): void { + $this->order[] = 'rollback'; + } + ); + } + + private function service(): TaskService { + return new TaskService( + tasks: $this->tasks, + candidates: $this->createMock(TaskCandidateMapper::class), + relations: $this->createMock(TaskRelationMapper::class), + audits: $this->createMock(TaskAuditMapper::class), + authorization: $this->createMock(TaskAuthorizationService::class), + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->db, + logger: new NullLogger(), + builder: new TaskBuilder(), + dispatcher: $this->events + ); + } + + private function openTask(): Task { + $task = new Task(); + $task->setId(1); + $task->setUuid('t-1'); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('former'); + + return $task; + } + + public function testAReassignmentAnnouncesThePreviousHolderAfterTheCommit(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->tasks->method('updateIfOpen')->willReturn(true); + $this->tasks->method('update')->willReturnArgument(0); + + $announced = null; + $this->events->expects($this->once())->method('dispatchTyped')->willReturnCallback( + function (TaskTransitionedEvent $event) use (&$announced): void { + $this->order[] = 'announce'; + $announced = $event; + } + ); + + $this->service()->reassign(uuid: 't-1', assignee: 'next', actor: 'manager'); + + $this->assertSame(['commit', 'announce'], $this->order); + $this->assertSame('former', $announced->getPreviousAssignee()); + $this->assertSame('next', $announced->getTask()->getAssignee()); + $this->assertSame('active', $announced->getPreviousState()); + $this->assertSame('manager', $announced->getActor()); + $this->assertSame('reassign', $announced->getAction()); + $this->assertTrue($announced->assigneeChanged()); + } + + public function testAListenerThatThrowsCannotFailTheVerb(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->tasks->method('updateIfOpen')->willReturn(true); + $this->tasks->method('update')->willReturnArgument(0); + $this->events->method('dispatchTyped')->willThrowException(new \RuntimeException('calendar backend fails every write')); + + $task = $this->service()->complete(uuid: 't-1', outcome: 'approved', resultText: null, comment: null, actor: 'former'); + + $this->assertSame(Task::STATE_COMPLETED, $task->getState()); + $this->assertSame(['commit'], $this->order, 'committed, and never rolled back by the listener'); + } + + public function testARolledBackVerbAnnouncesNothing(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->tasks->method('updateIfOpen')->willThrowException(new \RuntimeException('write failed')); + $this->events->expects($this->never())->method('dispatchTyped'); + + try { + $this->service()->complete(uuid: 't-1', outcome: 'approved', resultText: null, comment: null, actor: 'former'); + $this->fail('expected the write failure to surface'); + } catch (\RuntimeException $failure) { + $this->assertSame(['rollback'], $this->order); + } + } + + public function testWithoutADispatcherTheLifecycleIsUnchanged(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask()); + $this->tasks->method('updateIfOpen')->willReturn(true); + $this->tasks->method('update')->willReturnArgument(0); + + $service = new TaskService( + tasks: $this->tasks, + candidates: $this->createMock(TaskCandidateMapper::class), + relations: $this->createMock(TaskRelationMapper::class), + audits: $this->createMock(TaskAuditMapper::class), + authorization: $this->createMock(TaskAuthorizationService::class), + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->db, + logger: new NullLogger(), + builder: new TaskBuilder() + ); + + $this->assertSame(Task::STATE_TERMINATED, $service->cancel(uuid: 't-1', reason: null, actor: 'former')->getState()); + } +} diff --git a/tests/Unit/Service/Task/TaskServiceTerminalEventTest.php b/tests/Unit/Service/Task/TaskServiceTerminalEventTest.php index 319b8afb45..938ed41963 100644 --- a/tests/Unit/Service/Task/TaskServiceTerminalEventTest.php +++ b/tests/Unit/Service/Task/TaskServiceTerminalEventTest.php @@ -15,6 +15,7 @@ use OCA\OpenRegister\Db\TaskMapper; use OCA\OpenRegister\Db\TaskRelationMapper; use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Event\TaskTransitionedEvent; use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\Task\TaskAuthorizationService; use OCA\OpenRegister\Service\Task\TaskBuilder; @@ -98,10 +99,21 @@ private function openTask(): Task { */ public function testACompletionIsAnnouncedOnceAfterTheCommit(): void { $this->tasks->method('findByUuid')->willReturn($this->openTask()); - $this->dispatcher->expects($this->once()) + // Two announcements, both after the commit, in a fixed order: the + // transition to the projections first (flow-task-inbox-projections), + // then terminality to the flow side — the run-walk that continues on + // terminality must find the projections already told. + $this->dispatcher->expects($this->exactly(2)) ->method('dispatchTyped') ->with($this->callback(function (Event $event): bool { - $this->sequence[] = 'dispatch'; + if ($event instanceof TaskTransitionedEvent) { + $this->sequence[] = 'transition'; + $this->assertSame('alice', $event->getActor()); + + return true; + } + + $this->sequence[] = 'terminal'; $this->assertInstanceOf(TaskTerminalEvent::class, $event); $this->assertSame(Task::STATE_COMPLETED, $event->getTask()->getState()); $this->assertSame('approved', $event->getTask()->getOutcome()); @@ -111,14 +123,22 @@ public function testACompletionIsAnnouncedOnceAfterTheCommit(): void { $this->service()->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, actor: 'alice'); - $this->assertSame(['commit', 'dispatch'], $this->sequence); + $this->assertSame(['commit', 'transition', 'terminal'], $this->sequence); }//end testACompletionIsAnnouncedOnceAfterTheCommit() public function testACancellationIsAnnounced(): void { $this->tasks->method('findByUuid')->willReturn($this->openTask()); - $this->dispatcher->expects($this->once())->method('dispatchTyped')->with($this->isInstanceOf(TaskTerminalEvent::class)); + $seen = []; + $this->dispatcher->expects($this->exactly(2))->method('dispatchTyped') + ->with($this->callback(static function (Event $event) use (&$seen): bool { + $seen[] = $event::class; + + return true; + })); $this->service()->cancel(uuid: 't-7', reason: 'moot', actor: 'rita'); + + $this->assertSame([TaskTransitionedEvent::class, TaskTerminalEvent::class], $seen); }//end testACancellationIsAnnounced() /** diff --git a/tests/Unit/Service/Task/TaskVtodoStatusMappingTest.php b/tests/Unit/Service/Task/TaskVtodoStatusMappingTest.php new file mode 100644 index 0000000000..085e174899 --- /dev/null +++ b/tests/Unit/Service/Task/TaskVtodoStatusMappingTest.php @@ -0,0 +1,87 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The published state/status mapping: lossy on render, a REQUESTED + * TRANSITION on interpret, and a refusal for a status that names no legal + * transition. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-status-mapping + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Service\Task\TaskPriority; +use OCA\OpenRegister\Service\Task\TaskVtodoStatusMapping; +use PHPUnit\Framework\TestCase; + +class TaskVtodoStatusMappingTest extends TestCase { + private function taskIn(string $state): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setState($state); + $task->setIsTerminal(in_array($state, Task::TERMINAL_STATES, true)); + + return $task; + } + + public function testEverySateRendersOntoOneOfFourValues(): void { + $expected = [ + Task::STATE_AVAILABLE => 'NEEDS-ACTION', + Task::STATE_ENABLED => 'NEEDS-ACTION', + Task::STATE_ACTIVE => 'IN-PROCESS', + Task::STATE_COMPLETED => 'COMPLETED', + Task::STATE_TERMINATED => 'CANCELLED', + Task::STATE_DISABLED => 'CANCELLED', + ]; + + $this->assertSame($expected, TaskVtodoStatusMapping::mapping()); + foreach (Task::STATES as $state) { + $this->assertSame($expected[$state], TaskVtodoStatusMapping::render($state)); + } + } + + public function testCompletedRequestsTheCompleteVerbNeverAState(): void { + $this->assertSame('complete', TaskVtodoStatusMapping::requestedVerb('COMPLETED', $this->taskIn(Task::STATE_ACTIVE))); + $this->assertSame('complete', TaskVtodoStatusMapping::requestedVerb('completed', $this->taskIn(Task::STATE_ENABLED))); + } + + public function testCancelledRequestsTheCancelVerb(): void { + $this->assertSame('cancel', TaskVtodoStatusMapping::requestedVerb('CANCELLED', $this->taskIn(Task::STATE_ACTIVE))); + } + + public function testAStatusRestatingTheRenderedOneIsNoRequest(): void { + $this->assertNull(TaskVtodoStatusMapping::requestedVerb('IN-PROCESS', $this->taskIn(Task::STATE_ACTIVE))); + $this->assertNull(TaskVtodoStatusMapping::requestedVerb('COMPLETED', $this->taskIn(Task::STATE_COMPLETED))); + } + + public function testAProgressNoteOnAnOpenTaskIsNoRequest(): void { + // NEEDS-ACTION on an active task: projection-owned, overwritten by the next render. + $this->assertNull(TaskVtodoStatusMapping::requestedVerb('NEEDS-ACTION', $this->taskIn(Task::STATE_ACTIVE))); + } + + public function testReopeningATerminalTaskIsRefusedNamingTheState(): void { + $this->expectException(TaskConflictException::class); + $this->expectExceptionMessage("terminal state 'completed'"); + + TaskVtodoStatusMapping::requestedVerb('NEEDS-ACTION', $this->taskIn(Task::STATE_COMPLETED)); + } + + public function testPriorityRoundTripsThroughTheImportMapping(): void { + foreach (['urgent', 'high', 'normal', 'low'] as $priority) { + $ical = TaskVtodoStatusMapping::priority($priority); + $this->assertSame($priority, TaskPriority::normalise($ical), sprintf('%s -> %d does not round-trip', $priority, $ical)); + } + + $this->assertSame(5, TaskVtodoStatusMapping::priority(null)); + } +} diff --git a/tests/Unit/Service/Task/TaskVtodoWriteBackGateTest.php b/tests/Unit/Service/Task/TaskVtodoWriteBackGateTest.php new file mode 100644 index 0000000000..6f2f463573 --- /dev/null +++ b/tests/Unit/Service/Task/TaskVtodoWriteBackGateTest.php @@ -0,0 +1,263 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The one path back: a stranger's completion refused and audited; an + * illegal transition from a terminal task refused; a VTODO with no task + * identity untouched; a SUMMARY edit never reaching the engine; an echo + * ignored; a refusal always reverting AND notifying the actor. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-notification-for-an-answered-task-is-withdrawn + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-the-projection-carries-a-real-assignee-not-prose + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-status-mapping + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; +use OCA\OpenRegister\Service\Notification\TaskNotificationRules; +use OCA\OpenRegister\Service\Task\TaskCalendarProjector; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskProjectionService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCA\OpenRegister\Service\Task\TaskVtodoWriteBackGate; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class TaskVtodoWriteBackGateTest extends TestCase { + private const UUID = TaskCalendarProjectorTest::PROJECTED_UUID; + + private TaskService&MockObject $tasks; + + private TaskMapper&MockObject $mapper; + + private TaskAuditMapper&MockObject $audits; + + private TaskCalendarProjector&MockObject $projector; + + private TaskProjectionService&MockObject $projections; + + private AnnotationNotificationDispatcher&MockObject $dispatcher; + + protected function setUp(): void { + parent::setUp(); + $this->tasks = $this->createMock(TaskService::class); + $this->mapper = $this->createMock(TaskMapper::class); + $this->audits = $this->createMock(TaskAuditMapper::class); + $this->projector = $this->createMock(TaskCalendarProjector::class); + $this->projections = $this->createMock(TaskProjectionService::class); + $this->dispatcher = $this->createMock(AnnotationNotificationDispatcher::class); + $this->projector->method('render')->willReturnCallback( + static fn (Task $task): string => 'RENDERED:' . $task->getState() + ); + } + + private function gate(): TaskVtodoWriteBackGate { + $inbox = $this->createMock(TaskInboxService::class); + $inbox->method('enrich')->willReturn(['displayTitle' => 'Approve the permit']); + + return new TaskVtodoWriteBackGate( + $this->tasks, + $this->mapper, + $this->audits, + $this->projector, + $this->projections, + $inbox, + $this->dispatcher, + new TaskNotificationRules(), + new NullLogger() + ); + } + + private function vtodo(string $status, string $summary = 'Approve the permit', string $uuid = self::UUID): string { + return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:{$uuid}\r\nSUMMARY:{$summary}\r\nSTATUS:{$status}\r\n" + . "X-OPENREGISTER-TASK:{$uuid}\r\nX-OPENREGISTER-TASK-ASSIGNEE:EXAMPLE_APPROVER_USER\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + } + + private function taskExists(Task $task): void { + $this->mapper->method('findByUuid')->with(self::UUID)->willReturn($task); + } + + /** + * Every refusal is VISIBLE: the projection is re-rendered and the actor + * is told, through the refusal rule, naming the reason. + */ + private function expectRevertAndNotice(string $actor, string $reasonFragment): void { + $this->projections->expects($this->once())->method('reconcileTask'); + $this->dispatcher->expects($this->once())->method('dispatchWithSchema') + ->with( + $this->callback( + static function ($adapter) use ($actor, $reasonFragment): bool { + $payload = $adapter->getObject(); + return $payload['writeBackActor'] === $actor + && str_contains((string)$payload['writeBackReason'], $reasonFragment); + } + ), + 'transition', + $this->callback(static fn (array $ctx): bool => $ctx['action'] === TaskNotificationRules::ACTION_WRITE_BACK_REFUSED), + $this->anything() + ); + } + + public function testAHandMadeCalendarTaskIsNotTouched(): void { + $standalone = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:x\r\nSUMMARY:Buy milk\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + $this->mapper->expects($this->never())->method('findByUuid'); + $this->tasks->expects($this->never())->method('complete'); + + $this->assertNull($this->gate()->handleWrite($standalone, 'anyone')); + $this->assertFalse($this->gate()->isProjected($standalone)); + } + + public function testCompletingInTheTasksAppRequestsTheCompleteVerbWithTheActor(): void { + $task = TaskCalendarProjectorTest::assignedTask(); + $this->taskExists($task); + $this->projector->method('isEcho')->willReturn(false); + $completed = TaskCalendarProjectorTest::assignedTask(); + $completed->setState(Task::STATE_COMPLETED); + $this->tasks->expects($this->once())->method('complete') + ->with(self::UUID, 'done', null, null, 'EXAMPLE_APPROVER_USER') + ->willReturn($completed); + $this->tasks->expects($this->never())->method('cancel'); + $this->projections->expects($this->never())->method('reconcileTask'); + + $stored = $this->gate()->handleWrite($this->vtodo('COMPLETED'), 'EXAMPLE_APPROVER_USER'); + + // What the calendar holds afterwards is the engine's rendering. + $this->assertSame('RENDERED:completed', $stored); + } + + public function testAStrangersTickIsRefusedRevertedAndTheStrangerTold(): void { + $this->taskExists(TaskCalendarProjectorTest::assignedTask()); + $this->projector->method('isEcho')->willReturn(false); + // The lifecycle denies (and audits) exactly as it would for an API call. + $this->tasks->method('complete') + ->willThrowException(new TaskAccessDeniedException("Verb 'complete' denied: 'stranger' is not the assignee.")); + // The lifecycle already recorded the denial; the gate must not double it. + $this->audits->expects($this->never())->method('insert'); + $this->expectRevertAndNotice('stranger', 'not the assignee'); + + try { + $this->gate()->handleWrite($this->vtodo('COMPLETED'), 'stranger'); + $this->fail('expected the denial to surface'); + } catch (TaskAccessDeniedException $denied) { + $this->assertStringContainsString('stranger', $denied->getMessage()); + } + } + + public function testAnIllegalTransitionFromATerminalTaskIsRefusedAndAudited(): void { + $task = TaskCalendarProjectorTest::assignedTask(); + $task->setState(Task::STATE_COMPLETED); + $task->setIsTerminal(true); + $this->taskExists($task); + $this->projector->method('isEcho')->willReturn(false); + $this->tasks->expects($this->never())->method('complete'); + $this->tasks->expects($this->never())->method('cancel'); + $this->audits->expects($this->once())->method('insert') + ->with( + $this->callback( + static fn (TaskAudit $entry): bool => $entry->getAuthorized() === false + && $entry->getActor() === 'EXAMPLE_APPROVER_USER' + && $entry->getAction() === 'status' + && str_contains((string)$entry->getReason(), 'terminal state') + ) + ); + $this->expectRevertAndNotice('EXAMPLE_APPROVER_USER', 'terminal state'); + + $this->expectException(TaskConflictException::class); + $this->gate()->handleWrite($this->vtodo('NEEDS-ACTION'), 'EXAMPLE_APPROVER_USER'); + } + + public function testALostRaceIsAConflictThatRevertsNotAFailure(): void { + $this->taskExists(TaskCalendarProjectorTest::assignedTask()); + $this->projector->method('isEcho')->willReturn(false); + $this->tasks->method('complete') + ->willThrowException(new TaskConflictException("Verb 'complete' lost a race: task is no longer open.")); + $this->audits->expects($this->once())->method('insert'); + $this->expectRevertAndNotice('EXAMPLE_APPROVER_USER', 'lost a race'); + + $this->expectException(TaskConflictException::class); + $this->gate()->handleWrite($this->vtodo('COMPLETED'), 'EXAMPLE_APPROVER_USER'); + } + + public function testASummaryEditNeverReachesTheEngineAndIsRestored(): void { + $this->taskExists(TaskCalendarProjectorTest::assignedTask()); + $this->projector->method('isEcho')->willReturn(false); + $this->tasks->expects($this->never())->method('complete'); + $this->tasks->expects($this->never())->method('cancel'); + $this->audits->expects($this->never())->method('insert'); + $this->dispatcher->expects($this->never())->method('dispatchWithSchema'); + + $stored = $this->gate()->handleWrite($this->vtodo('IN-PROCESS', 'Assigned to: somebody else'), 'EXAMPLE_APPROVER_USER'); + + $this->assertSame('RENDERED:active', $stored, 'the next render restores the projected values'); + } + + public function testTheProjectorsOwnEchoIsIgnored(): void { + $this->projector->method('isEcho')->with(self::UUID, $this->anything())->willReturn(true); + $this->mapper->expects($this->never())->method('findByUuid'); + $this->tasks->expects($this->never())->method('complete'); + + $this->assertNull($this->gate()->handleWrite($this->vtodo('COMPLETED'), 'EXAMPLE_APPROVER_USER')); + } + + public function testAnUnknownTaskIdentityIsRefusedNotInvented(): void { + $this->projector->method('isEcho')->willReturn(false); + $this->mapper->method('findByUuid')->willThrowException(new DoesNotExistException('no such task')); + $this->tasks->expects($this->never())->method('complete'); + + $this->expectException(DoesNotExistException::class); + $this->gate()->handleWrite($this->vtodo('COMPLETED'), 'EXAMPLE_APPROVER_USER'); + } + + public function testOnlyCompletionAndCancellationCanBeRequested(): void { + $this->taskExists(TaskCalendarProjectorTest::assignedTask()); + $this->tasks->expects($this->never())->method('assign'); + $this->audits->expects($this->once())->method('insert'); + $this->expectRevertAndNotice('EXAMPLE_APPROVER_USER', 'cannot be requested from a calendar'); + + $this->expectException(TaskAccessDeniedException::class); + $this->gate()->request(self::UUID, 'assign', 'EXAMPLE_APPROVER_USER'); + } + + public function testANullActorIsPassedToTheLifecycleWhichDeniesIt(): void { + $this->taskExists(TaskCalendarProjectorTest::assignedTask()); + $this->projector->method('isEcho')->willReturn(false); + $this->tasks->method('complete')->with(self::UUID, 'done', null, null, null) + ->willThrowException(new TaskAccessDeniedException("Verb 'complete' denied: no acting identity.")); + // Nobody to notify: the refusal is logged, never widened to anyone else. + $this->dispatcher->expects($this->never())->method('dispatchWithSchema'); + $this->projections->expects($this->once())->method('reconcileTask'); + + $this->expectException(TaskAccessDeniedException::class); + $this->gate()->handleWrite($this->vtodo('COMPLETED'), null); + } + + public function testCancelledRequestsTheCancelVerb(): void { + $this->taskExists(TaskCalendarProjectorTest::assignedTask()); + $this->projector->method('isEcho')->willReturn(false); + $cancelled = TaskCalendarProjectorTest::assignedTask(); + $cancelled->setState(Task::STATE_TERMINATED); + $this->tasks->expects($this->once())->method('cancel') + ->with(self::UUID, $this->anything(), 'EXAMPLE_APPROVER_USER') + ->willReturn($cancelled); + + $this->assertSame('RENDERED:terminated', $this->gate()->handleWrite($this->vtodo('CANCELLED'), 'EXAMPLE_APPROVER_USER')); + } +} diff --git a/tests/Unit/Service/Task/VtodoCalendarLocatorTest.php b/tests/Unit/Service/Task/VtodoCalendarLocatorTest.php new file mode 100644 index 0000000000..18dcb87df8 --- /dev/null +++ b/tests/Unit/Service/Task/VtodoCalendarLocatorTest.php @@ -0,0 +1,69 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Calendar selection by uid: the first VTODO-capable calendar of the NAMED + * user, and a named exception when there is none. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-calendar-selection-for-tasks + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Exception\NoVtodoCalendarException; +use OCA\OpenRegister\Service\Task\VtodoCalendarLocator; +use PHPUnit\Framework\TestCase; + +class VtodoCalendarLocatorTest extends TestCase { + private const COMPONENTS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; + + public function testItPicksTheFirstVtodoCalendarOfTheNamedUser(): void { + $backend = $this->createMock(\OCA\DAV\CalDAV\CalDavBackend::class); + $backend->expects($this->once())->method('getCalendarsForUser') + ->with('principals/users/approver') + ->willReturn([ + ['id' => 7, 'uri' => 'birthdays', self::COMPONENTS => 'VEVENT'], + ['id' => 9, 'uri' => 'personal', self::COMPONENTS => 'VEVENT,VTODO'], + ['id' => 11, 'uri' => 'tasks', self::COMPONENTS => 'VTODO'], + ]); + + $calendar = (new VtodoCalendarLocator($backend))->forUser('approver'); + + $this->assertSame(['id' => 9, 'uri' => 'personal'], $calendar); + } + + public function testNoVtodoCalendarIsANamedException(): void { + $backend = $this->createMock(\OCA\DAV\CalDAV\CalDavBackend::class); + $backend->method('getCalendarsForUser')->willReturn([ + ['id' => 7, 'uri' => 'birthdays', self::COMPONENTS => 'VEVENT'], + ]); + + $this->expectException(NoVtodoCalendarException::class); + $this->expectExceptionMessage('No VTODO-supporting calendar found for user nocal'); + + (new VtodoCalendarLocator($backend))->forUser('nocal'); + } + + public function testItReadsEveryComponentSetShape(): void { + $locator = new VtodoCalendarLocator($this->createMock(\OCA\DAV\CalDAV\CalDavBackend::class)); + + $this->assertTrue($locator->supportsVtodo('VEVENT,VTODO')); + $this->assertTrue($locator->supportsVtodo(['VEVENT', 'vtodo'])); + $this->assertFalse($locator->supportsVtodo(['VEVENT'])); + $this->assertFalse($locator->supportsVtodo(null)); + + $object = new class { + public function getValue(): array { + return ['VTODO']; + } + }; + $this->assertTrue($locator->supportsVtodo($object)); + } +} diff --git a/tests/Unit/Service/TaskServiceProjectionTest.php b/tests/Unit/Service/TaskServiceProjectionTest.php new file mode 100644 index 0000000000..2371c3089c --- /dev/null +++ b/tests/Unit/Service/TaskServiceProjectionTest.php @@ -0,0 +1,158 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The CalDAV leaf as a projection writer: an engine identity cannot be + * forged through createTask(); a projected VTODO's update goes through the + * gate and stores the engine's rendering; without a gate it is refused; + * findUserCalendar() resolves the NAMED user; and standalone VTODOs behave + * exactly as before. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-tasks-on-objects-via-caldav-vtodo + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-compatibility-with-nextcloud-tasks-app + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-calendar-selection-for-tasks + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- PHPUnit arrange/act/assert conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit positional assertions. + +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Service\Task\TaskVtodoWriteBackGate; +use OCA\OpenRegister\Service\TaskService; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class TaskServiceProjectionTest extends TestCase { + private const UUID = '00000000-0000-0000-0000-000000000002'; + + private const COMPONENTS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; + + private \OCA\DAV\CalDAV\CalDavBackend&MockObject $backend; + + private IUserSession&MockObject $session; + + protected function setUp(): void { + parent::setUp(); + $this->backend = $this->createMock(\OCA\DAV\CalDAV\CalDavBackend::class); + $this->session = $this->createMock(IUserSession::class); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('session-user'); + $this->session->method('getUser')->willReturn($user); + } + + private function service(?TaskVtodoWriteBackGate $gate = null): TaskService { + return new TaskService($this->backend, $this->session, new NullLogger(), $this->createMock(IURLGenerator::class), $gate); + } + + private function projected(string $status): string { + return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" . self::UUID . "\r\nSUMMARY:Approve\r\nSTATUS:{$status}\r\n" + . 'X-OPENREGISTER-TASK:' . self::UUID . "\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + } + + public function testAnEngineIdentityCannotBeForgedThroughCreateTask(): void { + $this->backend->expects($this->never())->method('createCalendarObject'); + $this->backend->expects($this->never())->method('getCalendarsForUser'); + + foreach ([['X-OPENREGISTER-TASK' => self::UUID], ['taskUuid' => self::UUID], ['fields' => ['X-OPENREGISTER-TASK' => self::UUID]]] as $forged) { + try { + $this->service()->createTask(1, 2, 'obj', 'Object', array_merge(['summary' => 'Forged'], $forged)); + $this->fail('expected a refusal'); + } catch (TaskAccessDeniedException $refused) { + $this->assertStringContainsString('engine task', $refused->getMessage()); + } + } + } + + public function testAStandaloneCreateStillWorksAndCarriesNoTaskIdentity(): void { + $this->backend->method('getCalendarsForUser')->with('principals/users/session-user') + ->willReturn([['id' => 5, 'uri' => 'personal', self::COMPONENTS => 'VTODO']]); + $written = null; + $this->backend->expects($this->once())->method('createCalendarObject')->willReturnCallback( + static function (int $calendarId, string $uri, string $data) use (&$written): string { + $written = $data; + return 'etag'; + } + ); + + $result = $this->service()->createTask(1, 2, 'obj', 'Object', ['summary' => 'Buy milk']); + + $this->assertSame('Buy milk', $result['summary']); + $this->assertStringNotContainsString('X-OPENREGISTER-TASK:', (string)$written); + $this->assertStringContainsString('X-OPENREGISTER-OBJECT:obj', (string)$written); + } + + public function testFindUserCalendarResolvesTheNamedUserNotTheSession(): void { + $this->backend->method('getCalendarsForUser')->willReturnCallback( + static fn (string $principal): array => $principal === 'principals/users/approver' + ? [['id' => 9, 'uri' => 'personal', self::COMPONENTS => 'VTODO']] + : [['id' => 5, 'uri' => 'personal', self::COMPONENTS => 'VTODO']] + ); + + $this->assertSame(['id' => 9, 'uri' => 'personal'], $this->service()->findUserCalendar('approver')); + $this->assertSame(['id' => 5, 'uri' => 'personal'], $this->service()->findUserCalendar()); + } + + public function testAProjectedUpdateGoesThroughTheGateAndStoresTheEnginesRendering(): void { + $this->backend->method('getCalendarObject')->with(9, 'x.ics')->willReturn(['calendardata' => $this->projected('IN-PROCESS')]); + $gate = $this->createMock(TaskVtodoWriteBackGate::class); + $gate->expects($this->once())->method('handleWrite') + ->with($this->stringContains('STATUS:COMPLETED'), 'session-user') + ->willReturn($this->projected('COMPLETED')); + $this->backend->expects($this->once())->method('updateCalendarObject')->with(9, 'x.ics', $this->projected('COMPLETED')); + + $result = $this->service($gate)->updateTask('9', 'x.ics', ['status' => 'completed', 'summary' => 'ignored by the engine']); + + $this->assertSame('completed', $result['status']); + $this->assertSame('Approve', $result['summary'], 'the stored document is the rendering, not the request'); + } + + public function testAProjectedUpdateWithoutAGateIsRefusedNotApplied(): void { + $this->backend->method('getCalendarObject')->willReturn(['calendardata' => $this->projected('IN-PROCESS')]); + $this->backend->expects($this->never())->method('updateCalendarObject'); + + $this->expectException(TaskAccessDeniedException::class); + $this->service()->updateTask('9', 'x.ics', ['status' => 'completed']); + } + + public function testAGateRefusalSurfacesAndNothingIsWritten(): void { + $this->backend->method('getCalendarObject')->willReturn(['calendardata' => $this->projected('IN-PROCESS')]); + $gate = $this->createMock(TaskVtodoWriteBackGate::class); + $gate->method('handleWrite')->willThrowException(new TaskAccessDeniedException('not the assignee')); + $this->backend->expects($this->never())->method('updateCalendarObject'); + + $this->expectException(TaskAccessDeniedException::class); + $this->service($gate)->updateTask('9', 'x.ics', ['status' => 'completed']); + } + + public function testAStandaloneUpdateNeverTouchesTheGate(): void { + $standalone = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:x\r\nSUMMARY:Buy milk\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + $this->backend->method('getCalendarObject')->willReturn(['calendardata' => $standalone]); + $gate = $this->createMock(TaskVtodoWriteBackGate::class); + $gate->expects($this->never())->method('handleWrite'); + $this->backend->expects($this->once())->method('updateCalendarObject')->with(9, 'x.ics', $this->stringContains('STATUS:COMPLETED')); + + $result = $this->service($gate)->updateTask('9', 'x.ics', ['status' => 'completed']); + + $this->assertSame('completed', $result['status']); + } + + public function testDeletingAProjectedVtodoDeletesTheEntryAndReachesNoEngine(): void { + $this->backend->method('getCalendarObject')->willReturn(['calendardata' => $this->projected('IN-PROCESS')]); + $gate = $this->createMock(TaskVtodoWriteBackGate::class); + $gate->expects($this->never())->method('handleWrite'); + $gate->expects($this->never())->method('request'); + $this->backend->expects($this->once())->method('deleteCalendarObject')->with(9, 'x.ics'); + + $this->service($gate)->deleteTask('9', 'x.ics'); + } +} diff --git a/tests/e2e/task-projections.spec.ts b/tests/e2e/task-projections.spec.ts new file mode 100644 index 0000000000..bff64b70a7 --- /dev/null +++ b/tests/e2e/task-projections.spec.ts @@ -0,0 +1,394 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Task projections e2e: the @e2e-marked scenarios of flow-task-projections + * and the modified object-interactions requirements, driven over the REST + * API and CalDAV with Basic auth (no session, so no CSRF token is demanded). + * + * 1. "an assigned task appears in the assignee's calendar and links back": + * assigning a task writes a VTODO into the assignee's calendar carrying + * DUE, PRIORITY, X-OPENREGISTER-TASK and a URL that resolves (redirects) + * to the task's own surface, not to the API. + * 2. "completing the projected VTODO completes the engine task": a PUT of + * the VTODO with STATUS:COMPLETED, as the assignee, completes the task + * and audits the assignee as actor. + * 3. "an unauthorized calendar completion is reverted and reported": a + * stranger who can reach the VTODO (the calendar is shared read-write) + * is refused in-band (403), the task stays open, and the calendar entry + * still shows the engine's state. + * 4. "the user-wide task aggregate lists engine tasks": GET /api/tasks lists + * the engine task with a datastore total and no calendar enumeration. + * 5. "a watcher sees the task and no action buttons" and "the task widget + * shows a page of rows and the full count" are recorded as blocked in + * tasks.md: the watcher's verb set and the widget live in nextcloud-vue, + * whose named index source registry does not yet express a task source. + * 6. "an assignee approves a task from the notification": the approve + * action is a POST to the complete verb route; exercised here as the + * exact request the notification button issues. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-ticking-off-the-vtodo-completes-the-engine-task-through-authorization + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-unauthorized-write-back-is-undone-and-explained + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-a-binary-decision-is-decidable-from-the-notification + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-user-wide-task-aggregate-endpoint + * @spec openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md#requirement-task-compatibility-with-nextcloud-tasks-app + */ +import type { APIRequestContext } from '@playwright/test' + +import { request as apiRequest, expect, test } from '@playwright/test' + +const RUN_ID = `e2e-proj-${Date.now().toString(36)}` +const ADMIN = process.env.OR_USER || 'admin' +const STRANGER = `${RUN_ID}-stranger` +const STRANGER_PASS = `Str4nger!${Date.now().toString(36)}A` + +const NO_SESSION = { cookies: [], origins: [] } +function basic(user: string, pass: string) { + return { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`, + } +} +const ADMIN_HEADERS = basic(ADMIN, process.env.OR_PASS || 'admin') + +const TASKS = '/index.php/apps/openregister/api/flow-tasks' +const AGGREGATE = '/index.php/apps/openregister/api/tasks' +const OPEN = '/index.php/apps/openregister/flow-tasks' +const CALENDAR = `/remote.php/dav/calendars/${ADMIN}/personal` + +test.use({ storageState: NO_SESSION, extraHTTPHeaders: ADMIN_HEADERS }) + +/** + * Create a task assigned to the admin (who is also its requester) and hand + * back its row. + * + * @param request The admin API context. + * @param overrides Fields to set on the task. + */ +async function createTask( + request: APIRequestContext, + overrides: Record = {}, +) { + const response = await request.post(TASKS, { + data: { + title: `${RUN_ID} approval`, + state: 'active', + performerType: 'user', + assignee: ADMIN, + requester: ADMIN, + priority: 'high', + dueAt: '2026-12-01T12:00:00+00:00', + ...overrides, + }, + }) + expect(response.status(), await response.text()).toBe(201) + return response.json() +} + +/** + * The projected VTODO for a task, read straight from the assignee's + * calendar over CalDAV. The projector writes under a stable uri, so the + * read is one GET, and the response is the raw iCalendar document. + * + * @param request The calendar owner's API context. + * @param uuid The task uuid. + */ +async function readVtodo(request: APIRequestContext, uuid: string) { + const response = await request.get(`${CALENDAR}/openregister-task-${uuid}.ics`, { + headers: { Accept: 'text/calendar' }, + }) + return response +} + +/** + * Unfold RFC 5545 continuation lines so assertions read whole properties. + * + * @param ics The iCalendar document. + */ +function unfold(ics: string): string { + return ics.replace(/\r?\n[ \t]/g, '') +} + +/** + * Cancel this run's task so terminal litter is at least closed litter. + * + * @param request The admin API context. + * @param uuid The task to cancel. + */ +async function cancelQuietly(request: APIRequestContext, uuid: string) { + try { + await request.post(`${TASKS}/${uuid}/cancel`, { + data: { reason: `${RUN_ID} cleanup` }, + }) + } catch (error) { + console.warn('[task-projections] cleanup failed:', error) + } +} + +test.describe('flow-task-projections: the calendar projection', () => { + test("an assigned task appears in the assignee's calendar and links back", async ({ + request, + }) => { + const task = await createTask(request) + try { + const vtodo = await readVtodo(request, task.uuid) + test.skip( + vtodo.status() === 404, + 'the assignee has no VTODO-capable calendar on this instance; the projection is skipped by design and logged naming the task', + ) + expect(vtodo.status(), await vtodo.text()).toBe(200) + const ics = unfold(await vtodo.text()) + + expect(ics).toContain('BEGIN:VTODO') + expect(ics).toContain(`X-OPENREGISTER-TASK:${task.uuid}`) + expect(ics).toContain(`X-OPENREGISTER-TASK-ASSIGNEE:${ADMIN}`) + expect(ics).toContain(`SUMMARY:${RUN_ID} approval`) + expect(ics).toContain('DUE:20261201T120000Z') + expect(ics).toContain('PRIORITY:3') + expect(ics).toContain('STATUS:IN-PROCESS') + // The assignee is an identity, never prose. + expect(ics).not.toContain('Assigned to') + + // The URL deep-links to a surface a person can act on, not to the API. + const url = /URL(?:;VALUE=URI)?:(\S+)/.exec(ics)?.[1] + expect(url, 'the VTODO carries a URL').toBeTruthy() + expect(url).toContain(`${OPEN}/${task.uuid}`) + expect(url).not.toContain('/api/') + + // Following it lands in the app (a redirect into the task route), not a 404. + const followed = await request.get(url!, { maxRedirects: 0 }) + expect([302, 303]).toContain(followed.status()) + expect(followed.headers().location).toContain( + `#/flow-tasks/${task.uuid}`, + ) + } finally { + await cancelQuietly(request, task.uuid) + } + }) + + test('completing the projected VTODO completes the engine task', async ({ + request, + }) => { + const task = await createTask(request) + try { + const vtodo = await readVtodo(request, task.uuid) + test.skip( + vtodo.status() === 404, + 'no VTODO-capable calendar for the assignee on this instance', + ) + const ticked = (await vtodo.text()).replace( + 'STATUS:IN-PROCESS', + 'STATUS:COMPLETED', + ) + + // The assignee ticks it off in a calendar client: a PUT of the document. + const put = await request.put( + `${CALENDAR}/openregister-task-${task.uuid}.ics`, + { + headers: { 'Content-Type': 'text/calendar; charset=utf-8' }, + data: ticked, + }, + ) + expect([200, 201, 204], await put.text()).toContain(put.status()) + + // The engine task reached its completed state, with the assignee as actor. + const after = await request.get(`${TASKS}/${task.uuid}`) + expect(after.status()).toBe(200) + const row = await after.json() + expect(row.state).toBe('completed') + expect(row.completedBy).toBe(ADMIN) + + const audit = await request.get(`${TASKS}/${task.uuid}/audit`) + expect(audit.status()).toBe(200) + const entries = (await audit.json()).results ?? (await audit.json()) + const completion = (Array.isArray(entries) ? entries : []).find( + (entry: { action?: string; authorized?: boolean }) => + entry.action === 'complete' && entry.authorized !== false, + ) + expect( + completion, + 'exactly one authorized completion audit entry', + ).toBeTruthy() + expect(completion.actor).toBe(ADMIN) + expect( + (Array.isArray(entries) ? entries : []).filter( + (entry: { action?: string; authorized?: boolean }) => + entry.action === 'complete' && entry.authorized !== false, + ), + ).toHaveLength(1) + + // The calendar entry shows the engine's state: rendered COMPLETED, not echoed back. + const rendered = unfold( + await (await readVtodo(request, task.uuid)).text(), + ) + expect(rendered).toContain('STATUS:COMPLETED') + } finally { + await cancelQuietly(request, task.uuid) + } + }) + + test('an unauthorized calendar completion is reverted and reported', async ({ + request, + }) => { + const provisioned = await request.post('/ocs/v2.php/cloud/users', { + data: { userid: STRANGER, password: STRANGER_PASS }, + }) + test.skip( + provisioned.status() !== 200, + `cannot provision a stranger account (HTTP ${provisioned.status()})`, + ) + + const task = await createTask(request) + const stranger = await apiRequest.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL, + extraHTTPHeaders: basic(STRANGER, STRANGER_PASS), + }) + + try { + const vtodo = await readVtodo(request, task.uuid) + test.skip( + vtodo.status() === 404, + 'no VTODO-capable calendar for the assignee on this instance', + ) + + // Share the calendar read-write with the stranger: the single most + // likely real-world unauthorized path. + const share = await request.post(CALENDAR, { + headers: { 'Content-Type': 'application/xml; charset=utf-8' }, + data: ` + + + principal:principals/users/${STRANGER} + + +`, + }) + test.skip( + ![200, 204].includes(share.status()), + `calendar sharing not available (HTTP ${share.status()})`, + ) + + const ticked = (await vtodo.text()).replace( + 'STATUS:IN-PROCESS', + 'STATUS:COMPLETED', + ) + const strangerPut = await stranger.put( + `/remote.php/dav/calendars/${STRANGER}/personal_shared_by_${ADMIN}/openregister-task-${task.uuid}.ics`, + { + headers: { 'Content-Type': 'text/calendar; charset=utf-8' }, + data: ticked, + }, + ) + // Refused in-band: the client never records the change. + expect([403, 404], await strangerPut.text()).toContain( + strangerPut.status(), + ) + + // The engine task did not move. + const after = await request.get(`${TASKS}/${task.uuid}`) + expect((await after.json()).state).toBe('active') + + // The calendar shows the engine's state, not the stranger's edit. + const rendered = unfold( + await (await readVtodo(request, task.uuid)).text(), + ) + expect(rendered).toContain('STATUS:IN-PROCESS') + + if (strangerPut.status() === 403) { + // The refusal is auditable: a denial entry naming the stranger. + const audit = await request.get(`${TASKS}/${task.uuid}/audit`) + const entries = (await audit.json()).results ?? (await audit.json()) + const denial = (Array.isArray(entries) ? entries : []).find( + (entry: { actor?: string; authorized?: boolean }) => + entry.actor === STRANGER && entry.authorized === false, + ) + expect( + denial, + 'the refusal is recorded in the task audit', + ).toBeTruthy() + } + } finally { + await stranger.dispose() + await cancelQuietly(request, task.uuid) + await request + .delete(`/ocs/v2.php/cloud/users/${STRANGER}`) + .catch((error) => + console.warn( + '[task-projections] stranger cleanup failed:', + error, + ), + ) + } + }) +}) + +test.describe('flow-task-projections: the decision from the notification', () => { + test('an assignee approves a task from the notification', async ({ + request, + }) => { + const task = await createTask(request) + try { + // The approve button is a POST to the complete verb route with the + // approving outcome as a query parameter: this is that request. + const approve = await request.post( + `${TASKS}/${task.uuid}/complete?outcome=approved`, + ) + expect(approve.status(), await approve.text()).toBe(200) + const row = await approve.json() + expect(row.state).toBe('completed') + expect(row.outcome).toBe('approved') + expect(row.completedBy).toBe(ADMIN) + + // A stale reject button loses to the recorded outcome: conflict, unchanged. + const stale = await request.post( + `${TASKS}/${task.uuid}/complete?outcome=rejected&comment=too+late`, + ) + expect(stale.status()).toBe(409) + const again = await request.get(`${TASKS}/${task.uuid}`) + expect((await again.json()).outcome).toBe('approved') + } finally { + await cancelQuietly(request, task.uuid) + } + }) +}) + +test.describe('object-interactions: the user-wide aggregate', () => { + test('the user-wide task aggregate lists engine tasks', async ({ request }) => { + const task = await createTask(request) + try { + const listed = await request.get(`${AGGREGATE}?_limit=100&sort=-created`) + expect(listed.status(), await listed.text()).toBe(200) + const page = await listed.json() + + // The total is the query's, and the rows are engine tasks, not VTODOs. + expect(typeof page.total).toBe('number') + expect(page.total).toBeGreaterThanOrEqual(1) + const mine = (page.results ?? []).find( + (row: { uuid?: string }) => row.uuid === task.uuid, + ) + expect(mine, 'the engine task is listed').toBeTruthy() + expect(mine.state).toBe('active') + expect('overdue' in mine).toBe(true) + expect('displayTitle' in mine).toBe(true) + + // `assignee` is not honoured as a filter: still the caller's own tasks. + const other = await request.get( + `${AGGREGATE}?assignee=somebody-else&_limit=100&sort=-created`, + ) + expect(other.status()).toBe(200) + expect( + (await other.json()).results.some( + (row: { uuid?: string }) => row.uuid === task.uuid, + ), + ).toBe(true) + + // The limit cap stays. + const capped = await request.get(`${AGGREGATE}?_limit=500`) + expect((await capped.json()).limit).toBe(200) + } finally { + await cancelQuietly(request, task.uuid) + } + }) +}) diff --git a/tests/stubs/NextcloudInternalStubs.php b/tests/stubs/NextcloudInternalStubs.php index 12fb584a3e..33c3eee1f9 100644 --- a/tests/stubs/NextcloudInternalStubs.php +++ b/tests/stubs/NextcloudInternalStubs.php @@ -562,3 +562,77 @@ public function getAddressBookById(int $addressBookId): ?array { return null; } public function getAddressBooksForPrincipal(string $principal): array { return []; } }'); }//end if + +// ----------------------------------------------------------------- +// Sabre\DAV server classes (ship with the server's 3rdparty tree, loaded by +// apps/dav; absent in the bare CI container). Enough surface for +// TaskVtodoWriteBackPlugin to be constructed and its hooks invoked. +// ----------------------------------------------------------------- +if (class_exists(\Sabre\DAV\ServerPlugin::class) === false) { + eval('namespace Sabre\DAV; + abstract class ServerPlugin { + abstract public function initialize(Server $server); + public function getPluginName() { return get_class($this); } + }'); +}//end if + +if (class_exists(\Sabre\DAV\Server::class) === false) { + eval('namespace Sabre\DAV; + class Server { + public $tree; + private array $plugins = []; + public function __construct($treeOrNode = null, $sapi = null) {} + public function on(string $eventName, callable $callBack, int $priority = 100) {} + public function addPlugin(ServerPlugin $plugin) { $this->plugins[$plugin->getPluginName()] = $plugin; } + public function getPlugin($name) { return $this->plugins[$name] ?? null; } + }'); +}//end if + +if (class_exists(\Sabre\DAV\Tree::class) === false) { + eval('namespace Sabre\DAV; + class Tree { + public function __construct($rootNode = null) {} + public function getNodeForPath($path) { return null; } + }'); +}//end if + +// The interfaces carry the REAL method sets, so a test double that satisfies +// the stub also satisfies the server\'s Sabre (the in-container suite runs +// against the real classes; an anonymous class that fit a hollow stub +// fataled there). +if (interface_exists(\Sabre\DAV\INode::class) === false) { + eval('namespace Sabre\DAV; + interface INode { + public function delete(); + public function getName(); + public function setName($name); + public function getLastModified(); + }'); +}//end if + +if (interface_exists(\Sabre\DAV\IFile::class) === false) { + eval('namespace Sabre\DAV; + interface IFile extends INode { + public function put($data); + public function get(); + public function getContentType(); + public function getETag(); + public function getSize(); + }'); +}//end if + +if (interface_exists(\Sabre\DAV\ICollection::class) === false) { + eval('namespace Sabre\DAV; + interface ICollection extends INode { + public function createFile($name, $data = null); + public function createDirectory($name); + public function getChild($name); + public function getChildren(); + public function childExists($name); + }'); +}//end if + +if (class_exists(\Sabre\DAV\Exception\Forbidden::class) === false) { + eval('namespace Sabre\DAV\Exception; + class Forbidden extends \Exception {}'); +}//end if From a099d56361d88d46050736870ccd566bf1dc4625 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 15:43:18 +0200 Subject: [PATCH 32/68] =?UTF-8?q?feat(flow):=20the=20case=20layer=20?= =?UTF-8?q?=E2=80=94=20CMMN=20semantics=20as=20rows=20on=20the=20object=20?= =?UTF-8?q?(flow-cmmn-case-semantics)=20(#3286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tasks): the task store - four tables, entities and mappers The flow-task-entity storage layer (openspec/changes/flow-task-entity): openregister_tasks with the resolved 23-shape union (no overdue column, no app-named column, performer_type as an open string vocabulary so the coming external type is an append, definition_version shipped now), openregister_task_candidates as the pooled-inbox index half of the candidate pool, openregister_task_relations as the typed anchor table, and openregister_task_audit with an append-only mapper whose update and delete refuse. claim() is a conditional update so the database decides the race, and the inbox page and total share one predicate builder. Ticks tasks 1.1 and 1.2. * feat(tasks): the vocabularies, the authorization and the routing Normalisation at the boundary: TaskState publishes the one legacy-status mapping onto the six CMMN states (collapsed distinctions survive on outcome, an unmapped value is refused naming itself, and a migration may declare its source vocabulary so procest's status 'open' fails loudly); TaskPriority lands all four fleet scales on low|normal|high|urgent and refuses pipelinq's 'normaal'; TaskTemporalProjection is the ONE overdue derivation, computed and never stored. TaskAuthorizationService decides every verb fail-closed BEFORE mutation: no group backend, an unresolvable role or an unknown performer type each DENY rather than skip, and no nullable service-unavailable return exists. TaskPerformerResolver carries the five routing strategies; a strategy that finds nobody assigns nobody. Ticks tasks 2.1, 2.2, 3.1 and 3.2. * feat(tasks): the lifecycle service, cancellation propagation and the inbox TaskService: create/offer/claim/unclaim/assign/reassign/delegate/ resolve/complete/cancel, each authorized fail-closed before any mutation, each audited in the SAME transaction (an audit-write failure unwinds the completion), denials audited too. One write path maintains the candidate JSON and the candidate index rows together; state and is_terminal move in one statement; claim is the mapper's conditional update so the race's loser gets a conflict; a rejecting outcome without a comment is refused before anything moves; the template snapshot is frozen at creation. Cancellation propagation (design D-8): FlowRunMapper::update() - the one choke point every terminal run write passes - dispatches FlowRunTerminalEvent, and TaskRunTerminalListener terminates that run's open tasks with the reason recorded. Idempotent, and a task with run_uuid null is structurally out of reach. TaskInboxService answers assigned/pooled/watched/by-object in the datastore over one shared predicate set, with the total from the same predicates, subject context batched per page, a display title synthesized on read for titleless tasks (never persisted), and the derived temporal projection attached to rows only. Ticks tasks 4.1-4.5, 5.1, 6.1 and 6.2. * feat(tasks): the REST surface and the seed fixtures TaskController + /api/flow-tasks routes for the inbox, the detail and audit reads (both visibility-checked) and every lifecycle verb. The family is named for the flow-tasks CAPABILITY, not a flow requirement - /api/tasks itself already belongs to the CalDAV VTODO leaf. Every method declares its auth posture attribute AND delegates its actual authorization to TaskAuthorizationService inside the service; refusals translate uniformly (400 validation, 403 denial, 409 conflict naming the current state, 404 absence). SeedTaskFixtures installs the five seed groups from design.md (pooled municipal check with no run, delegated approval with enforcing expiry on a run, agent task with a typed checklist, one completed 'approved' and one terminated by propagation, plus audit fixtures including one DENIED entry), idempotent on uuid - and is REGISTERED in appinfo/info.xml in this same commit, because an unregistered repair step is a known fleet defect. Ticks tasks 7.1 and 8.1. * test(tasks): the guarantees, pinned - 93 tests over the task layer Table-driven legacy-status and priority tests including the live fleet defects: procest's status 'open' against a source vocabulary that does not define it is refused naming the value, and pipelinq's 'normaal' is refused on every scale. Authorization: a stranger who knows the uuid is denied on every verb, an unresolvable role denies naming the role, an absent group backend denies, an unknown performer type denies, watchers read and never act. Concurrency and transactionality: the two-claim race yields one assignee and one conflict, a rejection without a comment moves nothing, and an injected audit-write failure rolls the completion back. Derivation: overdue flips with an injected clock while the stored row stays byte-identical. Inbox: the page and the total run over the SAME criteria object (25 of 120 reported honestly), a titleless task presents a synthesized display title while its stored title stays null, and a runless task completes identically to a runful one. Mutation-checked: disabling assertMay fails 7 tests; removing the transaction rollback fails 2; both restored byte-identical (sha256-verified). Ticks tasks 9.1, 9.2 and 9.3. * test(tasks): playwright coverage for the two e2e-marked scenarios 'a stranger is refused on the task detail route': a provisioned second account gets 403 on the detail read AND on complete, and the task provably did not move - the positive control for the hole where knowing a uuid was the whole check. 'the inbox route returns tasks with subject context': one request lists the assignee's work with a display title, the subject slot, the derived overdue flag and a datastore total, and when the dev seed offers a live object the anchored subject context rides along. Both specs clean up after themselves and skip loudly when the instance cannot provision a stranger. Ticks task 9.4. * style(tasks): named parameters on every internal call, no inline ifs The repo's PHPCS standard requires named arguments on calls into OCA code and forbids ternaries. Exception constructors now name message:, the transactional/stringOrNull/arrayOrNull/respondWith calls name their parameter, the nine isset-ternaries collapse into one intOrNull helper, and the two remaining conditionals become explicit if blocks. No behaviour change: the 93 task tests are unchanged and green. * refactor(tasks): the quality pass - intake extracted, complexity earned down TaskBuilder now owns intake: boundary data in, a validated unsaved Task out, with every published vocabulary applied there once. That returns TaskService to lifecycle only and under the complexity budget. The authorization switch becomes a verb-to-rule table with static dispatch; the inbox predicate builder splits into scope, visibility and filters; the migration's indexes and the seed fixtures get methods of their own; sort and direction travel as one parameter (-dueAt) so the inbox route keeps a single-digit parameter list. The few remaining suppressions each carry their justification inline. Behaviour unchanged: the 93 task tests pass untouched except for injecting the builder. * fix(tasks): the PHPStan pass - typed mapper overrides and a DBAL constant The mapper overrides now guard their entity type with a throwing instanceof check, which is what lets the generic QBMapper contract type-check (and turns a wrong entity into a named error instead of a silent write). candidateMembershipPredicate declares the IQueryFunction it returns. The migration uses Types::DATETIME_MUTABLE, the constant the current DBAL stubs actually define. * style(tasks): import InvalidArgumentException in the three mapper guards PHPMD MissingImport on the type guards added in the PHPStan pass. No behaviour change. * fix(tasks): the review pass - offer is the requester's, and the inbox works on PostgreSQL Two blocking defects from the independent review of #3258: - offer bypassed per-task authorization and rewrote the assignee. Any authenticated user could POST offer {"routingFallback": "mallory"} on an assigned active task, become its assignee, and then complete it. offer now needs the requester (or an administrator), refuses a task that already has an assignee (unclaim or reassign instead), and is in the stranger-denied test's verb list, whose omission is how the suite stayed green. - The watchers LIKE ran against a json column, which PostgreSQL refuses (operator does not exist: json ~~ unknown): every non-admin inbox request 500ed there. The column is now cast per platform (AS TEXT on PostgreSQL, AS CHAR elsewhere), every identifier in the mapper's raw SQL goes through the platform's own quoter (no backticks), and TaskMapperTest pins the cast on a PostgreSQL platform mock. The should-fix items: authorization runs before the terminality check and a task the caller may not see answers 404 on every route, verbs included (no existence or state oracle); over HTTP the requester is pinned to the actor and a terminal creation state is refused, with import() as the trusted path for migrations and the user-task node; every state-changing write is a conditional update (WHERE is_terminal = false) so a second completion conflicts instead of overwriting; delegate refuses an empty delegate and keeps naming the original performer on re-delegation; an unexpected failure is a generic 500 whose detail goes to the log; propagation continues past a failing task; role pools are matched by the inbox EXISTS; the seed fixtures run only when app config openregister/seed_demo_tasks is true; the CSRF posture is recorded in the controller docblock; the e2e stranger scenario fails loudly instead of skipping and now covers the non-admin inbox and a watcher. Class-level @spec tags on every new class. TaskControllerTest is the contract test for all fourteen /api/flow-tasks endpoints (gate-25 PASS). Checks on this tree: PHPCS 0, PHPMD 0 (touched files), PHPStan 0, Psalm 0, 120 task tests green. * style(tasks): format the task inbox e2e spec with the Nextcloud prettier config CI's Frontend Check (format) named tests/e2e/task-inbox.spec.ts. Formatted with @nextcloud/prettier-config's options (tabs, no semicolons, single quotes, trailing commas, print width 85, operators leading). * test(tasks): cover the entities, the listener, the seed step and the builder CI's pgsql cell did not fail in PHPUnit (17860 tests, 0 failures, and app:enable succeeded); it failed the coverage guard: the changed files measured 3.85% (69/1791 statements) against a 7.32% base, because the suite runs with strict coverage metadata and collaborators exercised through TaskServiceTest were never credited. This adds the tests that were genuinely missing: the four entities' typing, hydration and serialisation (no overdue anywhere in the stored row); the terminal-run listener and event; the seed step off by default, seeding the five groups when on, idempotent on uuid, and continuing past a failing fixture; and TaskBuilder credited where TaskServiceTest exercises it. 133 task tests green. * test(tasks): walk the mapper queries and credit every exercised class The pgsql cell's coverage guard credits only statements in classes a test names with @covers (strict coverage metadata), and the changed files measured 20.16% against a 23.46% base. Two moves: every task test class now names each class it actually exercises (TaskBuilder, TaskState and TaskPriority behind TaskServiceTest, TaskInboxCriteria behind the inbox and controller tests, the entities and exceptions everywhere they are built), and the largest untested code is now walked: TaskMapper's whole query vocabulary, the candidate, relation and audit mappers, and FlowRunMapper's terminality announcement, over a fluent query-builder double that records which predicate and column each query sets, so a conditional update without its openness guard or a pooled scope without its EXISTS would fail here. Plus the untested TaskService verbs (unclaim, assign, reassign, cancel, resolve, terminateAsMoot, relations, intake refusals), TaskInboxService's batched subject context, and the controller's filter, flag and failing-backend paths. 168 task tests. * wip: checkpoint before rate-limit pause (2026-09-01 09:45) * feat(flow): the case layer — stages, sentries, milestones and discretionary work as rows on the object Implements openspec change flow-cmmn-case-semantics (tasks 1.1 to 10.3): two additive tables, the per-type lifecycle table, one transition path with the audit in the same transaction and the stage-exit cascade, sentries over FlowExpression and the event catalog (three case.item.* entries, dispatched as CaseItemTransitionedEvent), a bounded fixpoint (MAX_CASCADE_DEPTH 50) that rolls back at the bound, realisation through TaskService::import() and FlowRunService::queue(), fail-closed authorization for enable and attach, write-through of status and result via patchObject(), the zaaktype skeleton mapper with its report, the REST surface, six seed groups, and the tests. * wip: checkpoint after host restart (2026-09-01 14:08) * fix(merge): resolve the checkpointed conflict markers, keeping the case-layer registrations * style(cases): lint the e2e spec, tag the catalog listener, trim two analyzer findings eslint/prettier on the case-plan spec (top-level type import, sorted named imports, function declaration), the @spec tag gate-16 asked of EventCatalogListener::handle, the eager sentry data document (PHPMD class complexity at the threshold), and psalm's nullable getObject() read. --------- Co-authored-by: Conduction Release Bot --- appinfo/info.xml | 1 + appinfo/routes.php | 19 + lib/AppInfo/Application.php | 24 + lib/Controller/CaseController.php | 425 ++++++++++ lib/Db/CaseItem.php | 638 ++++++++++++++ lib/Db/CaseItemAudit.php | 175 ++++ lib/Db/CaseItemAuditMapper.php | 157 ++++ lib/Db/CaseItemMapper.php | 330 ++++++++ lib/Event/CaseItemTransitionedEvent.php | 111 +++ lib/Exception/CaseAccessDeniedException.php | 33 + lib/Exception/CaseCascadeBoundException.php | 33 + lib/Exception/CaseTransitionException.php | 33 + lib/Exception/CaseValidationException.php | 33 + lib/Listener/CaseObjectEventListener.php | 96 +++ lib/Listener/CaseRunTerminalListener.php | 71 ++ lib/Listener/CaseTaskTerminalListener.php | 79 ++ lib/Listener/EventCatalogListener.php | 20 + lib/Migration/Version1Date20260901150000.php | 223 +++++ lib/Repair/SeedCaseFixtures.php | 336 ++++++++ lib/Service/Case/CaseAnchorReader.php | 124 +++ lib/Service/Case/CaseBusinessStateWriter.php | 149 ++++ .../Case/CasePlanAuthorizationService.php | 306 +++++++ lib/Service/Case/CasePlanCascade.php | 496 +++++++++++ lib/Service/Case/CasePlanDefinition.php | 444 ++++++++++ lib/Service/Case/CasePlanService.php | 775 ++++++++++++++++++ lib/Service/Case/CasePlanStateMachine.php | 446 ++++++++++ lib/Service/Case/CasePlanTransitions.php | 174 ++++ lib/Service/Case/CasePlanTree.php | 372 +++++++++ lib/Service/Case/CaseRealisationService.php | 348 ++++++++ lib/Service/Case/CaseSentryEvaluator.php | 407 +++++++++ .../Case/ZaaktypeCaseSkeletonMapper.php | 550 +++++++++++++ lib/Service/Flow/EventCatalogService.php | 7 + .../changes/flow-cmmn-case-semantics/tasks.md | 40 +- tests/Unit/Controller/CaseControllerTest.php | 311 +++++++ tests/Unit/Db/CaseItemMapperQueriesTest.php | 208 +++++ tests/Unit/Listener/CaseListenersTest.php | 103 +++ tests/Unit/Repair/SeedCaseFixturesTest.php | 154 ++++ .../Service/Case/CaseAnchorAndWriterTest.php | 122 +++ tests/Unit/Service/Case/CaseFixtures.php | 79 ++ .../Case/CasePlanAuthorizationServiceTest.php | 215 +++++ .../Unit/Service/Case/CasePlanCascadeTest.php | 381 +++++++++ .../Service/Case/CasePlanDefinitionTest.php | 238 ++++++ .../Unit/Service/Case/CasePlanServiceTest.php | 531 ++++++++++++ .../Service/Case/CasePlanStateMachineTest.php | 364 ++++++++ .../Service/Case/CasePlanTransitionsTest.php | 196 +++++ tests/Unit/Service/Case/CasePlanTreeTest.php | 125 +++ .../Case/CaseRealisationServiceTest.php | 280 +++++++ .../Service/Case/CaseSentryEvaluatorTest.php | 227 +++++ .../Unit/Service/Case/FakeCaseItemMapper.php | 277 +++++++ .../Service/Case/RecordingAuditMapper.php | 114 +++ .../Case/ZaaktypeCaseSkeletonMapperTest.php | 144 ++++ tests/e2e/case-plan.spec.ts | 416 ++++++++++ 52 files changed, 11940 insertions(+), 20 deletions(-) create mode 100644 lib/Controller/CaseController.php create mode 100644 lib/Db/CaseItem.php create mode 100644 lib/Db/CaseItemAudit.php create mode 100644 lib/Db/CaseItemAuditMapper.php create mode 100644 lib/Db/CaseItemMapper.php create mode 100644 lib/Event/CaseItemTransitionedEvent.php create mode 100644 lib/Exception/CaseAccessDeniedException.php create mode 100644 lib/Exception/CaseCascadeBoundException.php create mode 100644 lib/Exception/CaseTransitionException.php create mode 100644 lib/Exception/CaseValidationException.php create mode 100644 lib/Listener/CaseObjectEventListener.php create mode 100644 lib/Listener/CaseRunTerminalListener.php create mode 100644 lib/Listener/CaseTaskTerminalListener.php create mode 100644 lib/Migration/Version1Date20260901150000.php create mode 100644 lib/Repair/SeedCaseFixtures.php create mode 100644 lib/Service/Case/CaseAnchorReader.php create mode 100644 lib/Service/Case/CaseBusinessStateWriter.php create mode 100644 lib/Service/Case/CasePlanAuthorizationService.php create mode 100644 lib/Service/Case/CasePlanCascade.php create mode 100644 lib/Service/Case/CasePlanDefinition.php create mode 100644 lib/Service/Case/CasePlanService.php create mode 100644 lib/Service/Case/CasePlanStateMachine.php create mode 100644 lib/Service/Case/CasePlanTransitions.php create mode 100644 lib/Service/Case/CasePlanTree.php create mode 100644 lib/Service/Case/CaseRealisationService.php create mode 100644 lib/Service/Case/CaseSentryEvaluator.php create mode 100644 lib/Service/Case/ZaaktypeCaseSkeletonMapper.php create mode 100644 tests/Unit/Controller/CaseControllerTest.php create mode 100644 tests/Unit/Db/CaseItemMapperQueriesTest.php create mode 100644 tests/Unit/Listener/CaseListenersTest.php create mode 100644 tests/Unit/Repair/SeedCaseFixturesTest.php create mode 100644 tests/Unit/Service/Case/CaseAnchorAndWriterTest.php create mode 100644 tests/Unit/Service/Case/CaseFixtures.php create mode 100644 tests/Unit/Service/Case/CasePlanAuthorizationServiceTest.php create mode 100644 tests/Unit/Service/Case/CasePlanCascadeTest.php create mode 100644 tests/Unit/Service/Case/CasePlanDefinitionTest.php create mode 100644 tests/Unit/Service/Case/CasePlanServiceTest.php create mode 100644 tests/Unit/Service/Case/CasePlanStateMachineTest.php create mode 100644 tests/Unit/Service/Case/CasePlanTransitionsTest.php create mode 100644 tests/Unit/Service/Case/CasePlanTreeTest.php create mode 100644 tests/Unit/Service/Case/CaseRealisationServiceTest.php create mode 100644 tests/Unit/Service/Case/CaseSentryEvaluatorTest.php create mode 100644 tests/Unit/Service/Case/FakeCaseItemMapper.php create mode 100644 tests/Unit/Service/Case/RecordingAuditMapper.php create mode 100644 tests/Unit/Service/Case/ZaaktypeCaseSkeletonMapperTest.php create mode 100644 tests/e2e/case-plan.spec.ts diff --git a/appinfo/info.xml b/appinfo/info.xml index 8d3311070b..d128d24d78 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -249,6 +249,7 @@ Vrij en open source onder de EUPL-licentie. Post-migration because the step needs the openregister_tasks tables its own change's migration creates. --> OCA\OpenRegister\Repair\SeedTaskFixtures + OCA\OpenRegister\Repair\SeedCaseFixtures OCA\OpenRegister\Repair\ReconcileDeclaredBackgroundJobs diff --git a/appinfo/routes.php b/appinfo/routes.php index 0b20292f61..0ac23fac03 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1374,6 +1374,25 @@ ['name' => 'task#complete', 'url' => '/api/flow-tasks/{uuid}/complete', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'task#cancel', 'url' => '/api/flow-tasks/{uuid}/cancel', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'task#checkItem', 'url' => '/api/flow-tasks/{uuid}/checklist/{itemId}', 'verb' => 'PATCH', 'requirements' => ['uuid' => '[^/]+', 'itemId' => '[^/]+']], + // The case layer (flow-cmmn-case-semantics): a plan of stages, human + // items and milestones anchored to an OpenRegister OBJECT. There is no + // case id: every plan route is keyed by the anchoring object's uuid, and + // the item verbs by the plan item's uuid. No CMMN XML route exists; the + // zaaktype import takes a document that is already in a register. + // Every verb's real authorization is CasePlanAuthorizationService + // inside the service; the route attribute is never the whole check. + // The two literal routes stay ABOVE `{objectUuid}` or they are swallowed. + ['name' => 'case#items', 'url' => '/api/cases/items', 'verb' => 'GET'], + ['name' => 'case#skeletonFromZaaktype', 'url' => '/api/cases/skeleton-from-zaaktype', 'verb' => 'POST'], + ['name' => 'case#transition', 'url' => '/api/cases/items/{uuid}/transition', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'case#enable', 'url' => '/api/cases/items/{uuid}/enable', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'case#show', 'url' => '/api/cases/{objectUuid}', 'verb' => 'GET', 'requirements' => ['objectUuid' => '[^/]+']], + ['name' => 'case#create', 'url' => '/api/cases/{objectUuid}', 'verb' => 'POST', 'requirements' => ['objectUuid' => '[^/]+']], + ['name' => 'case#destroy', 'url' => '/api/cases/{objectUuid}', 'verb' => 'DELETE', 'requirements' => ['objectUuid' => '[^/]+']], + ['name' => 'case#evaluate', 'url' => '/api/cases/{objectUuid}/evaluate', 'verb' => 'POST', 'requirements' => ['objectUuid' => '[^/]+']], + ['name' => 'case#enableable', 'url' => '/api/cases/{objectUuid}/enableable', 'verb' => 'GET', 'requirements' => ['objectUuid' => '[^/]+']], + ['name' => 'case#attach', 'url' => '/api/cases/{objectUuid}/items', 'verb' => 'POST', 'requirements' => ['objectUuid' => '[^/]+']], + ['name' => 'case#complete', 'url' => '/api/cases/{objectUuid}/complete', 'verb' => 'POST', 'requirements' => ['objectUuid' => '[^/]+']], // Delegation grants (or-delegation-grants): the consent surface. A grant // store with no way to answer is a store that only ever says no, so these // are what make every delegation refusal recoverable. diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4279a376e4..91cd0f593f 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -2590,6 +2590,30 @@ private function registerEventListeners(IRegistrationContext $context): void { \OCA\OpenRegister\Listener\TaskVtodoWriteBackListener::class ); + // The case layer (flow-cmmn-case-semantics). A realisation ending (a task + // or a run) drives its plan item; an object change may satisfy a sentry; + // and a plan item reaching a terminal state is a catalog event fired + // against the anchoring object. Nothing under Service\Flow depends on + // Service\Case: these listeners are the only coupling, and it points one + // way. TaskTerminalEvent is dispatched by TaskService after the + // terminal transition commits (flow-user-task-node); the same + // reconciliation also runs on every case-plan evaluation, so a missed + // event costs latency, never correctness. + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTerminalEvent::class, + \OCA\OpenRegister\Listener\CaseTaskTerminalListener::class + ); + $context->registerEventListener( + \OCA\OpenRegister\Event\FlowRunTerminalEvent::class, + \OCA\OpenRegister\Listener\CaseRunTerminalListener::class + ); + $context->registerEventListener(ObjectUpdatedEvent::class, \OCA\OpenRegister\Listener\CaseObjectEventListener::class); + $context->registerEventListener(ObjectTransitionedEvent::class, \OCA\OpenRegister\Listener\CaseObjectEventListener::class); + $context->registerEventListener( + \OCA\OpenRegister\Event\CaseItemTransitionedEvent::class, + \OCA\OpenRegister\Listener\EventCatalogListener::class + ); + // Lifecycle annotation listeners — see x-openregister-lifecycle. // Order matters: initial state runs on creating; validation runs on updating. $context->registerEventListener(ObjectCreatingEvent::class, LifecycleInitialStateListener::class); diff --git a/lib/Controller/CaseController.php b/lib/Controller/CaseController.php new file mode 100644 index 0000000000..05428832f9 --- /dev/null +++ b/lib/Controller/CaseController.php @@ -0,0 +1,425 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Controller + * @package OCA\OpenRegister\Controller + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\Exception\CaseAccessDeniedException; +use OCA\OpenRegister\Exception\CaseCascadeBoundException; +use OCA\OpenRegister\Exception\CaseTransitionException; +use OCA\OpenRegister\Exception\CaseValidationException; +use OCA\OpenRegister\Service\Case\CasePlanAuthorizationService; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCA\OpenRegister\Service\Case\ZaaktypeCaseSkeletonMapper; +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\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * REST surface for case plans. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One route method per verb + * the spec names plus the reads. Folding verbs into a mode parameter is how + * per-verb authorization rules get lost. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The controller mediates + * between HTTP, the service, the mapper and four exception shapes; that is + * the whole of its job. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ +class CaseController extends Controller { + + /** + * Constructor. + * + * @param string $appName The app id. + * @param IRequest $request The request. + * @param CasePlanService $plans The authorized case layer. + * @param ZaaktypeCaseSkeletonMapper $zaaktypes The pure zaaktype mapping. + * @param CasePlanAuthorizationService $authorization Identity checks for the anchorless route. + * @param IUserSession $userSession Names the acting identity. + * @param LoggerInterface|null $logger Where an unexpected failure's detail goes, INSTEAD of the response. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly CasePlanService $plans, + private readonly ZaaktypeCaseSkeletonMapper $zaaktypes, + private readonly CasePlanAuthorizationService $authorization, + private readonly IUserSession $userSession, + private readonly ?LoggerInterface $logger = null, + ) { + parent::__construct(appName: $appName, request: $request); + + }//end __construct() + + /** + * Items across cases by type and state: "which cases are stuck where". + * + * @param string|null $type Plan-item type filter. + * @param string|null $state State filter. + * @param int $limit Page size. + * @param int $offset Page offset. + * + * @return JSONResponse results, total, limit, offset. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function items(?string $type = null, ?string $state = null, int $limit = 25, int $offset = 0): JSONResponse { + return $this->guard(action: fn (): array => $this->plans->findStuck(type: $type, state: $state, limit: $limit, offset: $offset, uid: $this->uid())); + }//end items() + + /** + * A zaaktype document becomes a draft skeleton plus a report. Pure; writes nothing. + * + * @param array|null $zaaktype The zaaktype document. + * + * @return JSONResponse draft, definition, report. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function skeletonFromZaaktype(?array $zaaktype = null): JSONResponse { + return $this->guard( + action: function () use ($zaaktype): array { + $this->authorization->assertIdentified(uid: $this->uid(), verb: 'skeleton-from-zaaktype'); + if (is_array($zaaktype) === false || $zaaktype === []) { + throw new CaseValidationException(message: 'A `zaaktype` document is required.'); + } + + return $this->zaaktypes->map(zaaktype: $zaaktype); + } + ); + }//end skeletonFromZaaktype() + + /** + * The plan of one object. + * + * @param string $objectUuid The anchoring object. + * + * @return JSONResponse The plan; 404 when absent OR invisible. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function show(string $objectUuid): JSONResponse { + return $this->guard(action: fn (): array => $this->plans->getPlan(objectUuid: $objectUuid, uid: $this->uid())); + }//end show() + + /** + * Create a plan on an object from a definition. + * + * @param string $objectUuid The anchoring object. + * @param int|null $register Its register id. + * @param int|null $schema Its schema id. + * @param array|null $definition The definition (`settings`, `items`). + * @param string|null $flowUuid Definition provenance, when any. + * @param int|null $flowVersion Definition provenance, when any. + * + * @return JSONResponse The plan, 201. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function create( + string $objectUuid, + ?int $register = null, + ?int $schema = null, + ?array $definition = null, + ?string $flowUuid = null, + ?int $flowVersion = null, + ): JSONResponse { + return $this->guard( + action: fn (): array => $this->plans->createPlan( + objectUuid: $objectUuid, + registerId: $register, + schemaId: $schema, + definition: ($definition ?? []), + uid: $this->uid(), + flowUuid: $flowUuid, + flowVersion: $flowVersion + ), + status: Http::STATUS_CREATED + ); + }//end create() + + /** + * Re-evaluate a plan. + * + * @param string $objectUuid The anchoring object. + * + * @return JSONResponse passes, transitions. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function evaluate(string $objectUuid): JSONResponse { + return $this->guard(action: fn (): array => $this->plans->evaluate(objectUuid: $objectUuid, uid: $this->uid())); + }//end evaluate() + + /** + * Which discretionary items may be enabled now. + * + * @param string $objectUuid The anchoring object. + * + * @return JSONResponse results. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function enableable(string $objectUuid): JSONResponse { + return $this->guard(action: fn (): array => ['results' => $this->plans->enableableItems(objectUuid: $objectUuid, uid: $this->uid())]); + }//end enableable() + + /** + * Attach an ad-hoc item. + * + * @param string $objectUuid The anchoring object. + * @param string|null $key The item key. + * @param string|null $type The plan-item type. + * @param string|null $name Human name. + * @param string|null $description Description. + * @param string|null $parent Parent stage uuid or key, or null for the root. + * @param array|null $entryCriteria Entry sentries. + * @param array|null $exitCriteria Exit sentries. + * @param array|null $candidateUsers Candidate uids. + * @param array|null $candidateGroups Candidate group ids. + * @param string|null $candidateRole Candidate role. + * @param string|null $dueAt Advisory deadline. + * @param string|null $expiresAt Enforcing deadline. + * @param bool $required Whether the parent waits for it. + * + * @return JSONResponse The item, 201. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) One parameter per + * field the route accepts; Nextcloud binds them by name from the body. + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) `required` is a stored + * boolean field of the item, bound from the body like the others, not a + * behaviour switch of this method. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function attach( + string $objectUuid, + ?string $key = null, + ?string $type = null, + ?string $name = null, + ?string $description = null, + ?string $parent = null, + ?array $entryCriteria = null, + ?array $exitCriteria = null, + ?array $candidateUsers = null, + ?array $candidateGroups = null, + ?string $candidateRole = null, + ?string $dueAt = null, + ?string $expiresAt = null, + bool $required = true, + ): JSONResponse { + $data = array_filter( + [ + 'key' => $key, + 'type' => $type, + 'name' => $name, + 'description' => $description, + 'parent' => $parent, + 'entryCriteria' => $entryCriteria, + 'exitCriteria' => $exitCriteria, + 'candidateUsers' => $candidateUsers, + 'candidateGroups' => $candidateGroups, + 'candidateRole' => $candidateRole, + 'dueAt' => $dueAt, + 'expiresAt' => $expiresAt, + ], + static fn (mixed $value): bool => $value !== null + ); + $data['required'] = $required; + // An ad-hoc item may not declare its own authorization; the body key, + // if sent, is refused inside the service, so it is forwarded as-is. + $body = $this->request->getParams(); + if (array_key_exists('authorization', $body) === true) { + $data['authorization'] = $body['authorization']; + } + + return $this->guard( + action: fn (): array => $this->plans->attachAdHoc(objectUuid: $objectUuid, data: $data, uid: $this->uid())->jsonSerialize(), + status: Http::STATUS_CREATED + ); + }//end attach() + + /** + * Finish the case with a result. + * + * @param string $objectUuid The anchoring object. + * @param string|null $result The result. + * + * @return JSONResponse The plan plus `result`. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function complete(string $objectUuid, ?string $result = null): JSONResponse { + return $this->guard( + action: function () use ($objectUuid, $result): array { + if ($result === null || trim($result) === '') { + throw new CaseValidationException(message: 'A `result` is required to finish a case.'); + } + + return $this->plans->completeCase(objectUuid: $objectUuid, result: $result, uid: $this->uid()); + } + ); + }//end complete() + + /** + * Delete a plan's items. The audit and the mirrored business state stay. + * + * @param string $objectUuid The anchoring object. + * + * @return JSONResponse deleted. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function destroy(string $objectUuid): JSONResponse { + return $this->guard(action: fn (): array => ['deleted' => $this->plans->deletePlan(objectUuid: $objectUuid, uid: $this->uid())]); + }//end destroy() + + /** + * Transition one item by hand. + * + * @param string $uuid The item. + * @param string|null $to The target state. + * @param string|null $reason Free text. + * + * @return JSONResponse The item. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function transition(string $uuid, ?string $to = null, ?string $reason = null): JSONResponse { + return $this->guard( + action: function () use ($uuid, $to, $reason): array { + if ($to === null || trim($to) === '') { + throw new CaseValidationException(message: 'A target state `to` is required.'); + } + + return $this->plans->transition(itemUuid: $uuid, to: $to, uid: $this->uid(), reason: $reason)->jsonSerialize(); + } + ); + }//end transition() + + /** + * Enable a discretionary item. + * + * @param string $uuid The item. + * + * @return JSONResponse The item. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function enable(string $uuid): JSONResponse { + return $this->guard(action: fn (): array => $this->plans->enableDiscretionary(itemUuid: $uuid, uid: $this->uid())->jsonSerialize()); + }//end enable() + + /** + * The acting identity, or null without a session. + * + * @return string|null The uid. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function uid(): ?string { + return $this->userSession->getUser()?->getUID(); + }//end uid() + + /** + * Translate the service's exceptions to HTTP, once. + * + * Absent and invisible are the same 404; a denial is 403 only when the + * plan itself was visible (the service throws DoesNotExist otherwise); + * a refused value is 400; an illegal transition or a lost race is 409; + * the cascade bound is 422; anything else is 500 with the detail logged + * and NOT echoed. + * + * @param callable(): array $action The work. + * @param int $status The success status. + * + * @return JSONResponse The response. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function guard(callable $action, int $status = Http::STATUS_OK): JSONResponse { + try { + return new JSONResponse($action(), $status); + } catch (DoesNotExistException) { + return new JSONResponse(['error' => 'Not found'], Http::STATUS_NOT_FOUND); + } catch (CaseAccessDeniedException $denied) { + return new JSONResponse(['error' => $denied->getMessage()], Http::STATUS_FORBIDDEN); + } catch (CaseValidationException $refused) { + return new JSONResponse(['error' => $refused->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (CaseTransitionException $conflict) { + return new JSONResponse(['error' => $conflict->getMessage()], Http::STATUS_CONFLICT); + } catch (CaseCascadeBoundException $bound) { + return new JSONResponse(['error' => $bound->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); + } catch (Throwable $failure) { + $this->logger?->error('[CaseController] ' . $failure->getMessage(), ['exception' => $failure]); + + return new JSONResponse(['error' => 'Internal error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + }//end guard() +}//end class diff --git a/lib/Db/CaseItem.php b/lib/Db/CaseItem.php new file mode 100644 index 0000000000..d51cd3fd17 --- /dev/null +++ b/lib/Db/CaseItem.php @@ -0,0 +1,638 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * Class CaseItem + * + * @method string|null getUuid() + * @method void setUuid(?string $uuid) + * @method string|null getItemKey() + * @method void setItemKey(?string $itemKey) + * @method string|null getName() + * @method void setName(?string $name) + * @method string|null getDescription() + * @method void setDescription(?string $description) + * @method string|null getObjectUuid() + * @method void setObjectUuid(?string $objectUuid) + * @method integer|null getRegisterId() + * @method void setRegisterId(?int $registerId) + * @method integer|null getSchemaId() + * @method void setSchemaId(?int $schemaId) + * @method string|null getFlowUuid() + * @method void setFlowUuid(?string $flowUuid) + * @method integer|null getFlowVersion() + * @method void setFlowVersion(?int $flowVersion) + * @method string|null getDefinitionItemKey() + * @method void setDefinitionItemKey(?string $definitionItemKey) + * @method string|null getOrigin() + * @method void setOrigin(?string $origin) + * @method integer|null getParentItemId() + * @method void setParentItemId(?int $parentItemId) + * @method string|null getPlanItemType() + * @method void setPlanItemType(?string $planItemType) + * @method integer|null getPosition() + * @method void setPosition(?int $position) + * @method string|null getState() + * @method void setState(?string $state) + * @method bool|null getIsTerminal() + * @method void setIsTerminal(?bool $isTerminal) + * @method DateTime|null getEnteredAt() + * @method void setEnteredAt(?DateTime $enteredAt) + * @method string|null getTerminatedReason() + * @method void setTerminatedReason(?string $terminatedReason) + * @method array|null getEntryCriteria() + * @method void setEntryCriteria(?array $entryCriteria) + * @method array|null getExitCriteria() + * @method void setExitCriteria(?array $exitCriteria) + * @method bool|null getRequired() + * @method void setRequired(?bool $required) + * @method bool|null getDiscretionary() + * @method void setDiscretionary(?bool $discretionary) + * @method array|null getRepetition() + * @method void setRepetition(?array $repetition) + * @method string|null getRealisationKind() + * @method void setRealisationKind(?string $realisationKind) + * @method string|null getRealisationUuid() + * @method void setRealisationUuid(?string $realisationUuid) + * @method integer|null getRealisationCount() + * @method void setRealisationCount(?int $realisationCount) + * @method array|null getAuthorizationRules() + * @method void setAuthorizationRules(?array $authorizationRules) + * @method array|null getCandidateUsers() + * @method void setCandidateUsers(?array $candidateUsers) + * @method array|null getCandidateGroups() + * @method void setCandidateGroups(?array $candidateGroups) + * @method string|null getCandidateRole() + * @method void setCandidateRole(?string $candidateRole) + * @method DateTime|null getDueAt() + * @method void setDueAt(?DateTime $dueAt) + * @method DateTime|null getExpiresAt() + * @method void setExpiresAt(?DateTime $expiresAt) + * @method string|null getDoorlooptijd() + * @method void setDoorlooptijd(?string $doorlooptijd) + * @method string|null getServicenorm() + * @method void setServicenorm(?string $servicenorm) + * @method array|null getPlanSettings() + * @method void setPlanSettings(?array $planSettings) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * @method DateTime|null getUpdated() + * @method void setUpdated(?DateTime $updated) + * @method string|null getCreatedBy() + * @method void setCreatedBy(?string $createdBy) + * + * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class + * + * @SuppressWarnings(PHPMD.TooManyFields) One property per column of + * `openregister_case_items` (design.md, Data model). + * @SuppressWarnings(PHPMD.ExcessiveClassLength) The @method block, the typed + * properties and the field-by-field constructor scale linearly with the + * column count, same as {@see Task} and {@see FlowRun}. + * @SuppressWarnings(PHPMD.ExcessivePublicCount) Entity getters/setters are + * the column surface, not an API design choice. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ +class CaseItem extends Entity implements JsonSerializable { + + /** + * The six CMMN plan-item states: the same six a task carries. + */ + public const STATE_AVAILABLE = 'available'; + + public const STATE_ENABLED = 'enabled'; + + public const STATE_ACTIVE = 'active'; + + public const STATE_COMPLETED = 'completed'; + + public const STATE_TERMINATED = 'terminated'; + + public const STATE_DISABLED = 'disabled'; + + /** + * Every persistable state. + * + * @var array + */ + public const STATES = [ + self::STATE_AVAILABLE, + self::STATE_ENABLED, + self::STATE_ACTIVE, + self::STATE_COMPLETED, + self::STATE_TERMINATED, + self::STATE_DISABLED, + ]; + + /** + * States out of which no plan item of any type ever moves. + * + * @var array + */ + public const TERMINAL_STATES = [ + self::STATE_COMPLETED, + self::STATE_TERMINATED, + self::STATE_DISABLED, + ]; + + /** + * The three plan-item types. + */ + public const TYPE_STAGE = 'stage'; + + public const TYPE_HUMAN_TASK = 'humanTask'; + + public const TYPE_MILESTONE = 'milestone'; + + /** + * Every plan-item type. + * + * @var array + */ + public const TYPES = [ + self::TYPE_STAGE, + self::TYPE_HUMAN_TASK, + self::TYPE_MILESTONE, + ]; + + /** + * Where a plan item came from. + */ + public const ORIGIN_DEFINED = 'defined'; + + public const ORIGIN_DISCRETIONARY = 'discretionary'; + + public const ORIGIN_ADHOC = 'adhoc'; + + /** + * Every origin. + * + * @var array + */ + public const ORIGINS = [ + self::ORIGIN_DEFINED, + self::ORIGIN_DISCRETIONARY, + self::ORIGIN_ADHOC, + ]; + + /** + * What realises an active item. + */ + public const REALISATION_TASK = 'task'; + + public const REALISATION_RUN = 'run'; + + public const REALISATION_NONE = 'none'; + + /** + * Public identifier. + * + * @var string|null + */ + protected ?string $uuid = null; + + /** + * Stable id within the plan; what a sentry's on-part names. Shared by + * every realisation of a repeating item. + * + * @var string|null + */ + protected ?string $itemKey = null; + + /** + * Human name. + * + * @var string|null + */ + protected ?string $name = null; + + /** + * Longer description. + * + * @var string|null + */ + protected ?string $description = null; + + /** + * The anchor: the object that IS the case. + * + * @var string|null + */ + protected ?string $objectUuid = null; + + /** + * The anchor's register. + * + * @var integer|null + */ + protected ?int $registerId = null; + + /** + * The anchor's schema. + * + * @var integer|null + */ + protected ?int $schemaId = null; + + /** + * Definition provenance: the flow whose case definition produced this + * item. Null for an ad-hoc item. + * + * @var string|null + */ + protected ?string $flowUuid = null; + + /** + * The pinned definition version. + * + * @var integer|null + */ + protected ?int $flowVersion = null; + + /** + * The key in the definition this row was created from. Null for ad-hoc. + * + * @var string|null + */ + protected ?string $definitionItemKey = null; + + /** + * defined | discretionary | adhoc. + * + * @var string|null + */ + protected ?string $origin = null; + + /** + * The containing stage, or null at the plan root. + * + * @var integer|null + */ + protected ?int $parentItemId = null; + + /** + * stage | humanTask | milestone. + * + * @var string|null + */ + protected ?string $planItemType = null; + + /** + * Display order among siblings. + * + * @var integer|null + */ + protected ?int $position = 0; + + /** + * One of the six states. + * + * @var string|null + */ + protected ?string $state = null; + + /** + * Materialised terminality, written in the SAME statement as `state`. + * + * @var boolean|null + */ + protected ?bool $isTerminal = false; + + /** + * When the item first became active (or completed, for a milestone). + * + * @var DateTime|null + */ + protected ?DateTime $enteredAt = null; + + /** + * Why the item was terminated, when it was. + * + * @var string|null + */ + protected ?string $terminatedReason = null; + + /** + * Entry sentries. Empty means: satisfied as soon as the parent is active. + * + * @var array|null + */ + protected ?array $entryCriteria = null; + + /** + * Exit sentries. Empty means: never satisfied. + * + * @var array|null + */ + protected ?array $exitCriteria = null; + + /** + * Whether the parent's completion rule waits for this item. + * + * @var boolean|null + */ + protected ?bool $required = true; + + /** + * Whether entering requires an explicit act. + * + * @var boolean|null + */ + protected ?bool $discretionary = false; + + /** + * The repetition rule, when any: `{"max": N}`. + * + * @var array|null + */ + protected ?array $repetition = null; + + /** + * task | run | none. + * + * @var string|null + */ + protected ?string $realisationKind = null; + + /** + * The task or run uuid realising this row. + * + * @var string|null + */ + protected ?string $realisationUuid = null; + + /** + * Which realisation of the item this row is (1 for a non-repeating item). + * + * @var integer|null + */ + protected ?int $realisationCount = 1; + + /** + * Who may enable or attach here: a list of group ids, `user:` and + * `role:` entries. Serialised as `authorization`. + * + * @var array|null + */ + protected ?array $authorizationRules = null; + + /** + * Performer hint passed to the task on realisation. + * + * @var array|null + */ + protected ?array $candidateUsers = null; + + /** + * Performer hint passed to the task on realisation. + * + * @var array|null + */ + protected ?array $candidateGroups = null; + + /** + * Performer hint passed to the task on realisation. + * + * @var string|null + */ + protected ?string $candidateRole = null; + + /** + * Advisory deadline, carried onto the task. + * + * @var DateTime|null + */ + protected ?DateTime $dueAt = null; + + /** + * Enforcing deadline, carried onto the task. + * + * @var DateTime|null + */ + protected ?DateTime $expiresAt = null; + + /** + * The zaaktype's doorlooptijd, carried for flow-business-timers. + * + * @var string|null + */ + protected ?string $doorlooptijd = null; + + /** + * The zaaktype's servicenorm, carried for flow-business-timers. + * + * @var string|null + */ + protected ?string $servicenorm = null; + + /** + * Plan-level settings, frozen at creation: `authorization` (root), + * `results` (the allowed end states), `writeThrough` (field mapping). + * + * @var array|null + */ + protected ?array $planSettings = null; + + /** + * Creation timestamp. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Last-modified timestamp. + * + * @var DateTime|null + */ + protected ?DateTime $updated = null; + + /** + * Who created the item. + * + * @var string|null + */ + protected ?string $createdBy = null; + + /** + * Constructor: declare field types so the mapper hydrates them correctly. + */ + public function __construct() { + $this->addType(fieldName: 'uuid', type: 'string'); + $this->addType(fieldName: 'itemKey', type: 'string'); + $this->addType(fieldName: 'name', type: 'string'); + $this->addType(fieldName: 'description', type: 'string'); + $this->addType(fieldName: 'objectUuid', type: 'string'); + $this->addType(fieldName: 'registerId', type: 'integer'); + $this->addType(fieldName: 'schemaId', type: 'integer'); + $this->addType(fieldName: 'flowUuid', type: 'string'); + $this->addType(fieldName: 'flowVersion', type: 'integer'); + $this->addType(fieldName: 'definitionItemKey', type: 'string'); + $this->addType(fieldName: 'origin', type: 'string'); + $this->addType(fieldName: 'parentItemId', type: 'integer'); + $this->addType(fieldName: 'planItemType', type: 'string'); + $this->addType(fieldName: 'position', type: 'integer'); + $this->addType(fieldName: 'state', type: 'string'); + $this->addType(fieldName: 'isTerminal', type: 'boolean'); + $this->addType(fieldName: 'enteredAt', type: 'datetime'); + $this->addType(fieldName: 'terminatedReason', type: 'string'); + $this->addType(fieldName: 'entryCriteria', type: 'json'); + $this->addType(fieldName: 'exitCriteria', type: 'json'); + $this->addType(fieldName: 'required', type: 'boolean'); + $this->addType(fieldName: 'discretionary', type: 'boolean'); + $this->addType(fieldName: 'repetition', type: 'json'); + $this->addType(fieldName: 'realisationKind', type: 'string'); + $this->addType(fieldName: 'realisationUuid', type: 'string'); + $this->addType(fieldName: 'realisationCount', type: 'integer'); + $this->addType(fieldName: 'authorizationRules', type: 'json'); + $this->addType(fieldName: 'candidateUsers', type: 'json'); + $this->addType(fieldName: 'candidateGroups', type: 'json'); + $this->addType(fieldName: 'candidateRole', type: 'string'); + $this->addType(fieldName: 'dueAt', type: 'datetime'); + $this->addType(fieldName: 'expiresAt', type: 'datetime'); + $this->addType(fieldName: 'doorlooptijd', type: 'string'); + $this->addType(fieldName: 'servicenorm', type: 'string'); + $this->addType(fieldName: 'planSettings', type: 'json'); + $this->addType(fieldName: 'created', type: 'datetime'); + $this->addType(fieldName: 'updated', type: 'datetime'); + $this->addType(fieldName: 'createdBy', type: 'string'); + + }//end __construct() + + /** + * Whether this row will never move again. + * + * Reads the state set, NOT the materialised column: the column exists for + * indexed queries, and a test asserts the two agree. + * + * @return boolean True when the state is terminal. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + public function isInTerminalState(): bool { + return in_array($this->state, self::TERMINAL_STATES, true); + }//end isInTerminalState() + + /** + * Whether this row has been entered (is or was past `available`). + * + * @return boolean True for enabled, active or a terminal state other than disabled. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function isEntered(): bool { + return $this->state !== null + && $this->state !== self::STATE_AVAILABLE + && $this->state !== self::STATE_DISABLED; + }//end isEntered() + + /** + * Hydrate entity from array. + * + * @param array $object Data to hydrate from. + * + * @return self This item. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function hydrate(array $object): self { + foreach ($object as $fieldName => $value) { + if (array_key_exists($fieldName, $this->getFieldTypes()) === false || $fieldName === 'id') { + continue; + } + + $setter = 'set' . ucfirst($fieldName); + $this->$setter($value); + } + + return $this; + }//end hydrate() + + /** + * Serialise for the API. + * + * @return array The plan item as plain data. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'key' => $this->itemKey, + 'name' => $this->name, + 'description' => $this->description, + 'objectUuid' => $this->objectUuid, + 'registerId' => $this->registerId, + 'schemaId' => $this->schemaId, + 'flowUuid' => $this->flowUuid, + 'flowVersion' => $this->flowVersion, + 'definitionItemKey' => $this->definitionItemKey, + 'origin' => $this->origin, + 'parentItemId' => $this->parentItemId, + 'type' => $this->planItemType, + 'position' => $this->position, + 'state' => $this->state, + 'isTerminal' => $this->isTerminal, + 'enteredAt' => $this->enteredAt?->format('c'), + 'terminatedReason' => $this->terminatedReason, + 'entryCriteria' => ($this->entryCriteria ?? []), + 'exitCriteria' => ($this->exitCriteria ?? []), + 'required' => $this->required, + 'discretionary' => $this->discretionary, + 'repetition' => $this->repetition, + 'realisationKind' => $this->realisationKind, + 'realisationUuid' => $this->realisationUuid, + 'realisationCount' => $this->realisationCount, + 'authorization' => $this->authorizationRules, + 'candidateUsers' => $this->candidateUsers, + 'candidateGroups' => $this->candidateGroups, + 'candidateRole' => $this->candidateRole, + 'dueAt' => $this->dueAt?->format('c'), + 'expiresAt' => $this->expiresAt?->format('c'), + 'doorlooptijd' => $this->doorlooptijd, + 'servicenorm' => $this->servicenorm, + 'planSettings' => $this->planSettings, + 'created' => $this->created?->format('c'), + 'updated' => $this->updated?->format('c'), + 'createdBy' => $this->createdBy, + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/CaseItemAudit.php b/lib/Db/CaseItemAudit.php new file mode 100644 index 0000000000..79bb71e10a --- /dev/null +++ b/lib/Db/CaseItemAudit.php @@ -0,0 +1,175 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * Class CaseItemAudit + * + * @method integer|null getCaseItemId() + * @method void setCaseItemId(?int $caseItemId) + * @method string|null getFromState() + * @method void setFromState(?string $fromState) + * @method string|null getToState() + * @method void setToState(?string $toState) + * @method string|null getCause() + * @method void setCause(?string $cause) + * @method string|null getCauseRef() + * @method void setCauseRef(?string $causeRef) + * @method string|null getActor() + * @method void setActor(?string $actor) + * @method string|null getReason() + * @method void setReason(?string $reason) + * @method bool|null getAuthorized() + * @method void setAuthorized(?bool $authorized) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * + * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ +class CaseItemAudit extends Entity implements JsonSerializable { + + /** + * The five causes a transition can have. + */ + public const CAUSE_SENTRY = 'sentry'; + + public const CAUSE_USER = 'user'; + + public const CAUSE_REALISATION = 'realisation'; + + public const CAUSE_CASCADE = 'cascade'; + + public const CAUSE_IMPORT = 'import'; + + /** + * The plan item this entry is about. + * + * @var integer|null + */ + protected ?int $caseItemId = null; + + /** + * The state before. + * + * @var string|null + */ + protected ?string $fromState = null; + + /** + * The state after (or the state that was requested, on a denial). + * + * @var string|null + */ + protected ?string $toState = null; + + /** + * sentry | user | realisation | cascade | import. + * + * @var string|null + */ + protected ?string $cause = null; + + /** + * The sentry id, the task or run uuid, or the parent item uuid. + * + * @var string|null + */ + protected ?string $causeRef = null; + + /** + * The acting identity. + * + * @var string|null + */ + protected ?string $actor = null; + + /** + * Free-text reason, or the denial message. + * + * @var string|null + */ + protected ?string $reason = null; + + /** + * False on a recorded denial. + * + * @var boolean|null + */ + protected ?bool $authorized = true; + + /** + * When it happened. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Constructor: declare field types. + */ + public function __construct() { + $this->addType(fieldName: 'caseItemId', type: 'integer'); + $this->addType(fieldName: 'fromState', type: 'string'); + $this->addType(fieldName: 'toState', type: 'string'); + $this->addType(fieldName: 'cause', type: 'string'); + $this->addType(fieldName: 'causeRef', type: 'string'); + $this->addType(fieldName: 'actor', type: 'string'); + $this->addType(fieldName: 'reason', type: 'string'); + $this->addType(fieldName: 'authorized', type: 'boolean'); + $this->addType(fieldName: 'created', type: 'datetime'); + + }//end __construct() + + /** + * Serialise for the API. + * + * @return array The entry as plain data. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'caseItemId' => $this->caseItemId, + 'fromState' => $this->fromState, + 'toState' => $this->toState, + 'cause' => $this->cause, + 'causeRef' => $this->causeRef, + 'actor' => $this->actor, + 'reason' => $this->reason, + 'authorized' => $this->authorized, + 'created' => $this->created?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/CaseItemAuditMapper.php b/lib/Db/CaseItemAuditMapper.php new file mode 100644 index 0000000000..b15bcc6439 --- /dev/null +++ b/lib/Db/CaseItemAuditMapper.php @@ -0,0 +1,157 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use InvalidArgumentException; +use LogicException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Appends and reads plan-item audit entries. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ +class CaseItemAuditMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_case_item_audit', entityClass: CaseItemAudit::class); + + }//end __construct() + + /** + * Append an entry, stamping `created`. + * + * @param Entity $entity The entry to append. + * + * @return CaseItemAudit The appended entry. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function insert(Entity $entity): CaseItemAudit { + if ($entity instanceof CaseItemAudit === false) { + throw new InvalidArgumentException('CaseItemAuditMapper appends CaseItemAudit entries only.'); + } + + if ($entity->getCreated() === null) { + $entity->setCreated(new DateTime()); + } + + return parent::insert(entity: $entity); + }//end insert() + + /** + * Refused: the audit is append-only. + * + * @param Entity $entity Ignored. + * + * @return CaseItemAudit Never returns. + * + * @throws LogicException Always. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The parameter is the + * inherited signature; refusing it unread is the whole method. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function update(Entity $entity): CaseItemAudit { + throw new LogicException('The plan-item audit is append-only: entries are never updated.'); + }//end update() + + /** + * Refused: the audit is append-only. + * + * @param Entity $entity Ignored. + * + * @return CaseItemAudit Never returns. + * + * @throws LogicException Always. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The parameter is the + * inherited signature; refusing it unread is the whole method. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function delete(Entity $entity): CaseItemAudit { + throw new LogicException('The plan-item audit is append-only: entries are never deleted.'); + }//end delete() + + /** + * The audit trail of one plan item, oldest first. + * + * @param int $caseItemId The item's row id. + * + * @return array The entries. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function findForItem(int $caseItemId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('case_item_id', $qb->createNamedParameter($caseItemId, IQueryBuilder::PARAM_INT))) + ->orderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findForItem() + + /** + * The audit trail of a whole plan, oldest first. + * + * @param array $caseItemIds The plan's row ids. + * + * @return array The entries. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function findForItems(array $caseItemIds): array { + if ($caseItemIds === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->in('case_item_id', $qb->createNamedParameter(array_values($caseItemIds), IQueryBuilder::PARAM_INT_ARRAY))) + ->orderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findForItems() +}//end class diff --git a/lib/Db/CaseItemMapper.php b/lib/Db/CaseItemMapper.php new file mode 100644 index 0000000000..48768ded04 --- /dev/null +++ b/lib/Db/CaseItemMapper.php @@ -0,0 +1,330 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use InvalidArgumentException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Symfony\Component\Uid\Uuid; + +/** + * Reads and writes plan items. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) A mapper's public methods + * are its query vocabulary, one per distinct question the case layer asks of + * the table (same reasoning as TaskMapper and FlowRunMapper). + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ +class CaseItemMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_case_items', entityClass: CaseItem::class); + + }//end __construct() + + /** + * Insert, stamping `uuid` and `created` when absent. + * + * @param Entity $entity The plan item to insert. + * + * @return CaseItem The inserted item, with its id. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function insert(Entity $entity): CaseItem { + if ($entity instanceof CaseItem === false) { + throw new InvalidArgumentException('CaseItemMapper persists CaseItem entities only.'); + } + + if (trim((string)$entity->getUuid()) === '') { + $entity->setUuid(Uuid::v4()->toRfc4122()); + } + + if ($entity->getCreated() === null) { + $entity->setCreated(new DateTime()); + } + + return parent::insert(entity: $entity); + }//end insert() + + /** + * Update, stamping `updated`. + * + * @param Entity $entity The plan item to update. + * + * @return CaseItem The updated item. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function update(Entity $entity): CaseItem { + if ($entity instanceof CaseItem === false) { + throw new InvalidArgumentException('CaseItemMapper persists CaseItem entities only.'); + } + + $entity->setUpdated(new DateTime()); + + return parent::update(entity: $entity); + }//end update() + + /** + * Persist a transition ONLY if the row is still in the state it was read in. + * + * Mirrors QBMapper::update() field by field (updated fields only) with the + * extra predicate. Two callers can both pass the in-memory legality check; + * exactly one of them changes the row, and the other learns it here. + * + * @param CaseItem $item The item, with its setters already applied. + * @param string $expectedState The state the caller read before mutating. + * + * @return boolean True when the row moved; false when somebody else moved it first. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function updateIfState(CaseItem $item, string $expectedState): bool { + $id = $item->getId(); + if ($id === null) { + throw new InvalidArgumentException('A plan item must be persisted before it can be transitioned.'); + } + + $item->setUpdated(new DateTime()); + $properties = $item->getUpdatedFields(); + unset($properties['id']); + + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()); + foreach (array_keys($properties) as $property) { + $getter = 'get' . ucfirst($property); + $qb->set( + $item->propertyToColumn(property: $property), + $qb->createNamedParameter($item->$getter(), $this->getParameterTypeForProperty(entity: $item, property: $property)) + ); + } + + $qb->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter($expectedState))); + + return $qb->executeStatement() === 1; + }//end updateIfState() + + /** + * Find a plan item by its public uuid. + * + * @param string $uuid The item uuid. + * + * @return CaseItem The item. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException When no such item exists. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + public function findByUuid(string $uuid): CaseItem { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))); + + return $this->findEntity(query: $qb); + }//end findByUuid() + + /** + * The whole plan of one object: every row, tree order. + * + * ONE indexed read by the anchor. No run uuid is involved, so a plan on + * an object that never had a run reads identically. + * + * @param string $objectUuid The anchoring object's uuid. + * + * @return array The rows, parents before children by id. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + public function findByObject(string $objectUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid))) + ->orderBy('position', 'ASC') + ->addOrderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findByObject() + + /** + * How many non-terminal rows an object has: the cheap "is there a live + * plan here at all" question an object-event listener asks first. + * + * @param string $objectUuid The anchoring object's uuid. + * + * @return int The open row count. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function countOpenByObject(string $objectUuid): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'total')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid))) + ->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))); + + $result = $qb->executeQuery(); + $total = (int)$result->fetchOne(); + $result->closeCursor(); + + return $total; + }//end countOpenByObject() + + /** + * The plan items realised by a task or a run: the reverse lookup. + * + * @param string $realisationUuid The task or run uuid. + * + * @return array The rows (normally one). + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + public function findByRealisation(string $realisationUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('realisation_uuid', $qb->createNamedParameter($realisationUuid))) + ->orderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findByRealisation() + + /** + * "Which cases are stuck where": rows of one type in one state, paged. + * + * Filtering, ordering and paging happen in the datastore over the + * `(plan_item_type, state)` index. The total is {@see countByTypeAndState()} + * over the same predicate, so page and total cannot disagree. + * + * @param string|null $type Restrict to one plan-item type, or null for all. + * @param string|null $state Restrict to one state, or null for all. + * @param int $limit Page size. + * @param int $offset Page offset. + * + * @return array The page, oldest first. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function findByTypeAndState(?string $type, ?string $state, int $limit = 25, int $offset = 0): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()); + $this->applyTypeAndState(qb: $qb, type: $type, state: $state); + $qb->orderBy('id', 'ASC') + ->setMaxResults(max(1, $limit)) + ->setFirstResult(max(0, $offset)); + + return $this->findEntities(query: $qb); + }//end findByTypeAndState() + + /** + * The total behind {@see findByTypeAndState()}. + * + * @param string|null $type Restrict to one plan-item type, or null for all. + * @param string|null $state Restrict to one state, or null for all. + * + * @return int The count, computed in the datastore. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function countByTypeAndState(?string $type, ?string $state): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'total')) + ->from($this->getTableName()); + $this->applyTypeAndState(qb: $qb, type: $type, state: $state); + + $result = $qb->executeQuery(); + $total = (int)$result->fetchOne(); + $result->closeCursor(); + + return $total; + }//end countByTypeAndState() + + /** + * Delete every plan item of one object. The audit is NOT touched: it is + * append-only and outlives the rows it describes. + * + * @param string $objectUuid The anchoring object's uuid. + * + * @return int How many rows were deleted. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + public function deleteByObject(string $objectUuid): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid))); + + return $qb->executeStatement(); + }//end deleteByObject() + + /** + * The shared predicate of the stuck-where page and its total. + * + * @param IQueryBuilder $qb The query under construction. + * @param string|null $type The type filter, or null. + * @param string|null $state The state filter, or null. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + private function applyTypeAndState(IQueryBuilder $qb, ?string $type, ?string $state): void { + if ($type !== null && trim($type) !== '') { + $qb->andWhere($qb->expr()->eq('plan_item_type', $qb->createNamedParameter($type))); + } + + if ($state !== null && trim($state) !== '') { + $qb->andWhere($qb->expr()->eq('state', $qb->createNamedParameter($state))); + } + }//end applyTypeAndState() +}//end class diff --git a/lib/Event/CaseItemTransitionedEvent.php b/lib/Event/CaseItemTransitionedEvent.php new file mode 100644 index 0000000000..3c2f8313a7 --- /dev/null +++ b/lib/Event/CaseItemTransitionedEvent.php @@ -0,0 +1,111 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Event + * @package OCA\OpenRegister\Event + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCA\OpenRegister\Db\CaseItem; +use OCP\EventDispatcher\Event; + +/** + * Carries the plan item as persisted, and the state it left. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ +class CaseItemTransitionedEvent extends Event { + + /** + * Constructor. + * + * @param CaseItem $item The item, already persisted in its new state. + * @param string $fromState The state it left. + */ + public function __construct( + private readonly CaseItem $item, + private readonly string $fromState, + ) { + parent::__construct(); + + }//end __construct() + + /** + * The transitioned item. + * + * @return CaseItem The item as persisted. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function getItem(): CaseItem { + return $this->item; + }//end getItem() + + /** + * The state the item left. + * + * @return string The from-state. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function getFromState(): string { + return $this->fromState; + }//end getFromState() + + /** + * The catalog trigger this transition corresponds to, or null when the + * new state is not one of the three terminal ones the catalog names. + * + * @return string|null `case.item.completed` | `case.item.terminated` | `case.item.disabled` | null. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function getCatalogTrigger(): ?string { + $state = (string)$this->item->getState(); + if (in_array($state, CaseItem::TERMINAL_STATES, true) === false) { + return null; + } + + return 'case.item.' . $state; + }//end getCatalogTrigger() + + /** + * The anchoring object as a trigger subject: the same shape + * `EventCatalogListener::dispatch()` builds from an ObjectEntity. + * + * @return array{uuid: string, register: string, schema: string} The subject. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + public function getSubject(): array { + return [ + 'uuid' => (string)$this->item->getObjectUuid(), + 'register' => (string)$this->item->getRegisterId(), + 'schema' => (string)$this->item->getSchemaId(), + ]; + }//end getSubject() +}//end class diff --git a/lib/Exception/CaseAccessDeniedException.php b/lib/Exception/CaseAccessDeniedException.php new file mode 100644 index 0000000000..71fa8fa49d --- /dev/null +++ b/lib/Exception/CaseAccessDeniedException.php @@ -0,0 +1,33 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * An authorization decision on a plan item denied, or could not be determined (which is a denial). + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ +class CaseAccessDeniedException extends RuntimeException { +}//end class diff --git a/lib/Exception/CaseCascadeBoundException.php b/lib/Exception/CaseCascadeBoundException.php new file mode 100644 index 0000000000..fbf502dbac --- /dev/null +++ b/lib/Exception/CaseCascadeBoundException.php @@ -0,0 +1,33 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * A case-plan evaluation reached its cascade bound without settling. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ +class CaseCascadeBoundException extends RuntimeException { +}//end class diff --git a/lib/Exception/CaseTransitionException.php b/lib/Exception/CaseTransitionException.php new file mode 100644 index 0000000000..b2d3c87b0a --- /dev/null +++ b/lib/Exception/CaseTransitionException.php @@ -0,0 +1,33 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * A plan-item transition absent from the lifecycle table was requested. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ +class CaseTransitionException extends RuntimeException { +}//end class diff --git a/lib/Exception/CaseValidationException.php b/lib/Exception/CaseValidationException.php new file mode 100644 index 0000000000..bfda6bf55f --- /dev/null +++ b/lib/Exception/CaseValidationException.php @@ -0,0 +1,33 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use InvalidArgumentException; + +/** + * A case-plan definition or verb payload was refused at the boundary. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ +class CaseValidationException extends InvalidArgumentException { +}//end class diff --git a/lib/Listener/CaseObjectEventListener.php b/lib/Listener/CaseObjectEventListener.php new file mode 100644 index 0000000000..6461c27386 --- /dev/null +++ b/lib/Listener/CaseObjectEventListener.php @@ -0,0 +1,96 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Event\ObjectTransitionedEvent; +use OCA\OpenRegister\Event\ObjectUpdatedEvent; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** + * Feeds object events to case-plan evaluation. + * + * @template-implements IEventListener + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ +class CaseObjectEventListener implements IEventListener { + + /** + * Constructor. + * + * @param CasePlanService $plans The case layer. + */ + public function __construct( + private readonly CasePlanService $plans, + ) { + + }//end __construct() + + /** + * Handle the event. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function handle(Event $event): void { + if ($event instanceof ObjectTransitionedEvent) { + $this->forward(object: $event->getObject(), trigger: 'object.transitioned'); + return; + } + + if ($event instanceof ObjectUpdatedEvent) { + $this->forward(object: $event->getNewObject(), trigger: 'object.updated'); + } + }//end handle() + + /** + * Forward one object event to the case layer. + * + * @param ObjectEntity|null $object The object after the change. + * @param string $trigger The catalog event id. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function forward(?ObjectEntity $object, string $trigger): void { + $uuid = trim((string)$object?->getUuid()); + if ($uuid === '') { + return; + } + + $this->plans->onObjectEvent(objectUuid: $uuid, event: $trigger, payload: $object->getObject()); + }//end forward() +}//end class diff --git a/lib/Listener/CaseRunTerminalListener.php b/lib/Listener/CaseRunTerminalListener.php new file mode 100644 index 0000000000..f216dad33d --- /dev/null +++ b/lib/Listener/CaseRunTerminalListener.php @@ -0,0 +1,71 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** + * Drives stage items from run terminality. + * + * @template-implements IEventListener + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ +class CaseRunTerminalListener implements IEventListener { + + /** + * Constructor. + * + * @param CasePlanService $plans The case layer. + */ + public function __construct( + private readonly CasePlanService $plans, + ) { + + }//end __construct() + + /** + * Handle the event. Failures are logged inside the service, never rethrown. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + public function handle(Event $event): void { + if ($event instanceof FlowRunTerminalEvent === false) { + return; + } + + $this->plans->onRealisationTerminal(taskUuid: $event->getRunUuid()); + }//end handle() +}//end class diff --git a/lib/Listener/CaseTaskTerminalListener.php b/lib/Listener/CaseTaskTerminalListener.php new file mode 100644 index 0000000000..bd21ad89d8 --- /dev/null +++ b/lib/Listener/CaseTaskTerminalListener.php @@ -0,0 +1,79 @@ + completed, anything else -> + * terminated) inside that evaluation. Until the dispatching side lands, the + * same reconciliation runs on every evaluation, so nothing here is the only + * path. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** + * Drives plan items from task terminality. + * + * @template-implements IEventListener + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ +class CaseTaskTerminalListener implements IEventListener { + + /** + * Constructor. + * + * @param CasePlanService $plans The case layer. + */ + public function __construct( + private readonly CasePlanService $plans, + ) { + + }//end __construct() + + /** + * Handle the event. Failures are logged inside the service, never rethrown. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + public function handle(Event $event): void { + if ($event instanceof TaskTerminalEvent === false) { + return; + } + + $uuid = trim((string)$event->getTask()->getUuid()); + if ($uuid === '') { + return; + } + + $this->plans->onRealisationTerminal(taskUuid: $uuid); + }//end handle() +}//end class diff --git a/lib/Listener/EventCatalogListener.php b/lib/Listener/EventCatalogListener.php index 67983fa00c..68fc804449 100644 --- a/lib/Listener/EventCatalogListener.php +++ b/lib/Listener/EventCatalogListener.php @@ -34,6 +34,7 @@ namespace OCA\OpenRegister\Listener; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Event\CaseItemTransitionedEvent; use OCA\OpenRegister\Event\ObjectCreatedEvent; use OCA\OpenRegister\Event\ObjectDeletedEvent; use OCA\OpenRegister\Event\ObjectLockedEvent; @@ -72,8 +73,27 @@ public function __construct( * @param Event $event The dispatched event. * * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) A dispatch table: one + * instanceof branch per catalog event, each a one-liner; splitting it + * would spread the catalog over several methods. + * @SuppressWarnings(PHPMD.NPathComplexity) Same cause. */ public function handle(Event $event): void { + // A plan item reaching a terminal state (flow-cmmn-case-semantics) fires + // its catalog trigger against the ANCHORING object, the same shape as + // every object event below; no separate subject type is introduced. + if ($event instanceof CaseItemTransitionedEvent) { + $trigger = $event->getCatalogTrigger(); + if ($trigger !== null) { + $this->triggers->fire(event: $trigger, subject: $event->getSubject()); + } + + return; + } + if ($event instanceof ObjectCreatedEvent) { $this->dispatch(object: $event->getObject(), trigger: 'object.created'); return; diff --git a/lib/Migration/Version1Date20260901150000.php b/lib/Migration/Version1Date20260901150000.php new file mode 100644 index 0000000000..0fae60b78a --- /dev/null +++ b/lib/Migration/Version1Date20260901150000.php @@ -0,0 +1,223 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * The case layer's store: two tables, additive only. + * + * `openregister_case_items` holds one row per plan-item INSTANCE, anchored to + * an OpenRegister object by the same triple a flow run and a task already + * carry (`object_uuid`, `register_id`, `schema_id`). This is the replacement + * for the reference implementation's single `casePlanState` string: a plan + * item is a row with its own state column, so "which cases have an active + * item of type X" is an indexed query and two caseworkers completing two + * different items never overwrite each other. + * + * Three deliberate absences, same rule as `openregister_tasks`: + * + * - NO `overdue`, `days_until_due` or `days_overdue` column. Deadlines are + * CARRIED (`due_at`, `expires_at`, `doorlooptijd`, `servicenorm`) and + * never computed on here; that is flow-business-timers' work. + * - NO case entity and NO case id. The anchoring object IS the case. + * - NO column on `openregister_tasks`: the link runs plan item -> task via + * `realisation_uuid`, never the other way. + * + * `openregister_case_item_audit` is append-only. Its mapper exposes no update + * and no delete, and deleting a plan item does not cascade into it. + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Creates the plan-item and plan-item-audit tables. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ +class Version1Date20260901150000 extends SimpleMigrationStep { + + /** + * The plan-item table. + */ + private const TABLE_ITEMS = 'openregister_case_items'; + + /** + * The append-only audit table. + */ + private const TABLE_AUDIT = 'openregister_case_item_audit'; + + /** + * Create both tables and their indexes. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the ISchemaWrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + $schema = $schemaClosure(); + + $changed = false; + + if ($schema->hasTable(self::TABLE_ITEMS) === false) { + $this->createItemsTable(schema: $schema); + $changed = true; + } + + if ($schema->hasTable(self::TABLE_AUDIT) === false) { + $this->createAuditTable(schema: $schema); + $changed = true; + } + + if ($changed === false) { + return null; + } + + $output->info('Created the case-plan tables: ' . self::TABLE_ITEMS . ', ' . self::TABLE_AUDIT . '.'); + + return $schema; + }//end changeSchema() + + /** + * The plan-item table: design.md, Data model. + * + * @param ISchemaWrapper $schema The schema to add to. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + private function createItemsTable(ISchemaWrapper $schema): void { + $table = $schema->createTable(self::TABLE_ITEMS); + + // Identity. + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('item_key', Types::STRING, ['notnull' => true, 'length' => 128]); + $table->addColumn('name', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('description', Types::TEXT, ['notnull' => false]); + + // Anchor: the same triple FlowRun::subject_* and Task::object_* carry. + $table->addColumn('object_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('register_id', Types::BIGINT, ['notnull' => false]); + $table->addColumn('schema_id', Types::BIGINT, ['notnull' => false]); + + // Definition provenance. All nullable: an ad-hoc item has none. + $table->addColumn('flow_uuid', Types::STRING, ['notnull' => false, 'length' => 36]); + $table->addColumn('flow_version', Types::INTEGER, ['notnull' => false]); + $table->addColumn('definition_item_key', Types::STRING, ['notnull' => false, 'length' => 128]); + $table->addColumn('origin', Types::STRING, ['notnull' => true, 'length' => 16]); + + // Structure. + $table->addColumn('parent_item_id', Types::BIGINT, ['notnull' => false]); + $table->addColumn('plan_item_type', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('position', Types::INTEGER, ['notnull' => true, 'default' => 0]); + + // Lifecycle. `is_terminal` is written in the same statement as `state`. + $table->addColumn('state', Types::STRING, ['notnull' => true, 'length' => 20]); + $table->addColumn('is_terminal', Types::BOOLEAN, ['notnull' => true, 'default' => false]); + $table->addColumn('entered_at', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('terminated_reason', Types::STRING, ['notnull' => false, 'length' => 512]); + + // Criteria. + $table->addColumn('entry_criteria', Types::JSON, ['notnull' => false]); + $table->addColumn('exit_criteria', Types::JSON, ['notnull' => false]); + $table->addColumn('required', Types::BOOLEAN, ['notnull' => true, 'default' => true]); + $table->addColumn('discretionary', Types::BOOLEAN, ['notnull' => true, 'default' => false]); + $table->addColumn('repetition', Types::JSON, ['notnull' => false]); + + // Realisation: the link runs FROM the plan item TO the task or run. + $table->addColumn('realisation_kind', Types::STRING, ['notnull' => false, 'length' => 8]); + $table->addColumn('realisation_uuid', Types::STRING, ['notnull' => false, 'length' => 36]); + $table->addColumn('realisation_count', Types::INTEGER, ['notnull' => true, 'default' => 1]); + + // Authorization. Named `authorization_rules` rather than the design's + // `authorization` because AUTHORIZATION is a reserved word in the SQL + // standard; the API serialises it as `authorization`. + $table->addColumn('authorization_rules', Types::JSON, ['notnull' => false]); + + // Performer hints: passed to the task on realisation, never evaluated here. + $table->addColumn('candidate_users', Types::JSON, ['notnull' => false]); + $table->addColumn('candidate_groups', Types::JSON, ['notnull' => false]); + $table->addColumn('candidate_role', Types::STRING, ['notnull' => false, 'length' => 128]); + + // Deadlines: carried, not computed. + $table->addColumn('due_at', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('expires_at', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('doorlooptijd', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('servicenorm', Types::STRING, ['notnull' => false, 'length' => 64]); + + // Plan-level settings (root authorization, allowed results, the + // write-through field mapping), frozen at plan creation and carried on + // every row of the plan so the plan needs no record of its own. + $table->addColumn('plan_settings', Types::JSON, ['notnull' => false]); + + // Stamps. + $table->addColumn('created', Types::DATETIME_MUTABLE, ['notnull' => true]); + $table->addColumn('updated', Types::DATETIME_MUTABLE, ['notnull' => false]); + $table->addColumn('created_by', Types::STRING, ['notnull' => false, 'length' => 64]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['uuid'], 'or_caseitem_uuid'); + // "What is open on this case." + $table->addIndex(['object_uuid', 'is_terminal'], 'or_caseitem_obj_term'); + // The tree read. + $table->addIndex(['object_uuid', 'parent_item_id'], 'or_caseitem_obj_parent'); + // "Which cases are stuck where." + $table->addIndex(['plan_item_type', 'state'], 'or_caseitem_type_state'); + // The reverse lookup from a task or a run. + $table->addIndex(['realisation_uuid'], 'or_caseitem_realisation'); + // A repetition cannot collide with itself. + $table->addUniqueIndex(['object_uuid', 'item_key', 'realisation_count'], 'or_caseitem_obj_key_rep'); + }//end createItemsTable() + + /** + * The append-only audit table. + * + * @param ISchemaWrapper $schema The schema to add to. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + private function createAuditTable(ISchemaWrapper $schema): void { + $table = $schema->createTable(self::TABLE_AUDIT); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('case_item_id', Types::BIGINT, ['notnull' => true]); + $table->addColumn('from_state', Types::STRING, ['notnull' => false, 'length' => 20]); + $table->addColumn('to_state', Types::STRING, ['notnull' => false, 'length' => 20]); + $table->addColumn('cause', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('cause_ref', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('actor', Types::STRING, ['notnull' => false, 'length' => 128]); + $table->addColumn('reason', Types::TEXT, ['notnull' => false]); + $table->addColumn('authorized', Types::BOOLEAN, ['notnull' => true, 'default' => true]); + $table->addColumn('created', Types::DATETIME_MUTABLE, ['notnull' => true]); + $table->setPrimaryKey(['id']); + $table->addIndex(['case_item_id'], 'or_caseaudit_item'); + }//end createAuditTable() +}//end class diff --git a/lib/Repair/SeedCaseFixtures.php b/lib/Repair/SeedCaseFixtures.php new file mode 100644 index 0000000000..7cbbd96593 --- /dev/null +++ b/lib/Repair/SeedCaseFixtures.php @@ -0,0 +1,336 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Repair; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Db\CaseItemAuditMapper; +use OCA\OpenRegister\Db\CaseItemMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IAppConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Seeds the case-plan fixture groups, idempotent on uuid. + * + * @psalm-suppress UnusedClass Instantiated by the NC repair framework (appinfo/info.xml). + * + * @SuppressWarnings(PHPMD.ExcessiveClassLength) The fixtures ARE the class; + * six groups of literal rows, same as SeedTaskFixtures. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ +class SeedCaseFixtures implements IRepairStep { + + /** + * The app config key that switches the demo seed on. + */ + public const FLAG = 'seed_demo_cases'; + + /** + * The demo permit object, shared with SeedTaskFixtures' first task. + */ + public const PERMIT_OBJECT = '00000000-0000-0000-0000-0000000000aa'; + + /** + * The demo object carrying the terminated stage. + */ + public const TERMINATED_OBJECT = '00000000-0000-0000-0000-0000000000ab'; + + /** + * The demo object carrying the repeating item. + */ + public const REPEATING_OBJECT = '00000000-0000-0000-0000-0000000000ac'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig Holds the opt-in flag. + * @param CaseItemMapper $items The plan-item table. + * @param CaseItemAuditMapper $audits The append-only audit. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly IAppConfig $appConfig, + private readonly CaseItemMapper $items, + private readonly CaseItemAuditMapper $audits, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The step's name in the repair log. + * + * @return string The name. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + public function getName(): string { + return 'Seed the case-plan fixtures (flow-cmmn-case-semantics)'; + }//end getName() + + /** + * Install every fixture row that does not exist yet. + * + * @param IOutput $output The repair output. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + public function run(IOutput $output): void { + if ($this->appConfig->getValueBool('openregister', self::FLAG, false) === false) { + $output->info('Case-plan fixtures: skipped (openregister/' . self::FLAG . ' is not enabled).'); + return; + } + + $seeded = 0; + $present = 0; + foreach ($this->fixtures() as $fixture) { + try { + $result = $this->seedTree(fixture: $fixture, parentId: null); + $seeded += $result['seeded']; + $present += $result['present']; + } catch (Throwable $failure) { + // A fixture must never fail an upgrade, and silence would be the + // repair-step defect this fleet already paid for once. + $output->warning(sprintf('Case-plan fixture %s failed: %s', (string)($fixture['uuid'] ?? '?'), $failure->getMessage())); + $this->logger->warning('[SeedCaseFixtures] Fixture failed: ' . $failure->getMessage(), ['uuid' => ($fixture['uuid'] ?? null)]); + } + } + + $output->info(sprintf('Case-plan fixtures: %d rows seeded, %d already present.', $seeded, $present)); + }//end run() + + /** + * The zaaktype fixture for the mapper: four statustypen with sequence + * numbers deliberately out of document order, three roltypen, two + * resultaattypen, a doorlooptijd, a servicenorm, and two elements the + * mapping does not cover. + * + * @return array The zaaktype document. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + public static function zaaktypeFixture(): array { + return [ + 'url' => 'https://ztc.demo.invalid/api/v1/zaaktypen/00000000-0000-0000-0000-0000000000d1', + 'identificatie' => 'DEMO-OMGEVINGSVERGUNNING', + 'omschrijving' => 'Omgevingsvergunning (demo)', + 'doorlooptijd' => 'P8W', + 'servicenorm' => 'P6W', + 'statustypen' => [ + ['volgnummer' => 3, 'omschrijving' => 'In behandeling'], + ['volgnummer' => 1, 'omschrijving' => 'Ontvangen'], + ['volgnummer' => 4, 'omschrijving' => 'Afgehandeld'], + ['volgnummer' => 2, 'omschrijving' => 'Volledig'], + ], + 'roltypen' => [ + ['omschrijving' => 'Aanvrager', 'omschrijvingGeneriek' => 'initiator'], + ['omschrijving' => 'Vergunningverlener', 'omschrijvingGeneriek' => 'behandelaar'], + ['omschrijving' => 'Welstandscommissie', 'omschrijvingGeneriek' => 'adviseur'], + ], + 'resultaattypen' => [ + ['omschrijving' => 'Verleend', 'archiefnominatie' => 'blijvend_bewaren'], + ['omschrijving' => 'Geweigerd', 'archiefnominatie' => 'vernietigen', 'archiefactietermijn' => 'P10Y'], + ], + // Two elements the mapping does not cover. + 'publicatieIndicatie' => true, + 'verlengingMogelijk' => true, + ]; + }//end zaaktypeFixture() + + /** + * Seed one fixture node and its children; returns counts. + * + * @param array $fixture The node: `uuid`, `item`, optional `audit`, `children`. + * @param int|null $parentId The parent's row id. + * + * @return array{seeded: int, present: int} Counts. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + private function seedTree(array $fixture, ?int $parentId): array { + $counts = ['seeded' => 0, 'present' => 0]; + try { + $row = $this->items->findByUuid(uuid: (string)$fixture['uuid']); + $counts['present']++; + } catch (DoesNotExistException) { + $row = new CaseItem(); + $row->hydrate($fixture['item']); + $row->setUuid((string)$fixture['uuid']); + $row->setParentItemId($parentId); + $row = $this->items->insert($row); + foreach (($fixture['audit'] ?? []) as $auditFixture) { + $entry = new CaseItemAudit(); + $entry->setCaseItemId((int)$row->getId()); + $entry->setFromState($auditFixture['from'] ?? null); + $entry->setToState($auditFixture['to'] ?? null); + $entry->setCause((string)$auditFixture['cause']); + $entry->setCauseRef($auditFixture['causeRef'] ?? null); + $entry->setActor($auditFixture['actor'] ?? null); + $entry->setReason($auditFixture['reason'] ?? null); + $entry->setAuthorized((bool)($auditFixture['authorized'] ?? true)); + $this->audits->insert($entry); + } + + $counts['seeded']++; + }//end try + + foreach (($fixture['children'] ?? []) as $child) { + $childCounts = $this->seedTree(fixture: $child, parentId: (int)$row->getId()); + $counts['seeded'] += $childCounts['seeded']; + $counts['present'] += $childCounts['present']; + } + + return $counts; + }//end seedTree() + + /** + * The fixture trees. + * + * @return array> The root fixtures. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + private function fixtures(): array { + // phpcs:disable Generic.Files.LineLength.MaxExceeded -- one fixture row per line keeps each seed readable as one record. + $permitSettings = [ + 'authorization' => ['demo-behandelaars'], + 'results' => ['verleend', 'geweigerd'], + 'writeThrough' => ['statusField' => 'status', 'statusAtField' => 'statusReachedAt', 'resultField' => 'resultaat', 'resultAtField' => 'resultaatReachedAt'], + ]; + $permit = static fn (array $fields): array => array_merge( + [ + 'objectUuid' => self::PERMIT_OBJECT, + 'registerId' => 1, + 'schemaId' => 1, + 'origin' => CaseItem::ORIGIN_DEFINED, + 'required' => true, + 'discretionary' => false, + 'realisationCount' => 1, + 'planSettings' => $permitSettings, + 'createdBy' => 'demo-seed', + ], + $fields + ); + $created = static fn (string $to): array => ['from' => '', 'to' => $to, 'cause' => CaseItemAudit::CAUSE_IMPORT, 'actor' => 'demo-seed']; + + return [ + // 1 + 2 + 3: the two-stage permit case with its discretionary and ad-hoc items. + [ + 'uuid' => '00000000-0000-0000-0000-0000000000c1', + 'item' => $permit(['itemKey' => 'intake', 'name' => 'Intake', 'planItemType' => CaseItem::TYPE_STAGE, 'position' => 0, 'state' => CaseItem::STATE_ACTIVE, 'isTerminal' => false, 'definitionItemKey' => 'intake', 'realisationKind' => CaseItem::REALISATION_NONE]), + 'audit' => [$created(CaseItem::STATE_AVAILABLE), ['from' => CaseItem::STATE_AVAILABLE, 'to' => CaseItem::STATE_ACTIVE, 'cause' => CaseItemAudit::CAUSE_SENTRY, 'causeRef' => 'entry:default', 'actor' => 'case-plan']], + 'children' => [ + [ + 'uuid' => '00000000-0000-0000-0000-0000000000c2', + 'item' => $permit(['itemKey' => 'completeness-check', 'name' => 'Controleer volledigheid', 'planItemType' => CaseItem::TYPE_HUMAN_TASK, 'position' => 0, 'state' => CaseItem::STATE_ACTIVE, 'isTerminal' => false, 'definitionItemKey' => 'completeness-check', 'candidateGroups' => ['demo-behandelaars'], 'realisationKind' => CaseItem::REALISATION_TASK, 'realisationUuid' => '00000000-0000-0000-0000-000000000001']), + 'audit' => [$created(CaseItem::STATE_AVAILABLE), ['from' => CaseItem::STATE_AVAILABLE, 'to' => CaseItem::STATE_ACTIVE, 'cause' => CaseItemAudit::CAUSE_SENTRY, 'causeRef' => 'entry:default', 'actor' => 'case-plan']], + ], + [ + 'uuid' => '00000000-0000-0000-0000-0000000000c3', + 'item' => $permit(['itemKey' => 'application-complete', 'name' => 'Aanvraag volledig', 'planItemType' => CaseItem::TYPE_MILESTONE, 'position' => 1, 'state' => CaseItem::STATE_AVAILABLE, 'isTerminal' => false, 'definitionItemKey' => 'application-complete', 'entryCriteria' => [['id' => 'complete:entry', 'on' => ['event' => 'case.item.completed', 'item' => 'completeness-check']]]]), + 'audit' => [$created(CaseItem::STATE_AVAILABLE)], + ], + [ + // 3: ad-hoc, in no definition, realised by a task with run_uuid null. + 'uuid' => '00000000-0000-0000-0000-0000000000c6', + 'item' => $permit(['itemKey' => 'adhoc-site-visit', 'name' => 'Locatiebezoek (ad hoc)', 'planItemType' => CaseItem::TYPE_HUMAN_TASK, 'position' => 2, 'state' => CaseItem::STATE_ACTIVE, 'isTerminal' => false, 'origin' => CaseItem::ORIGIN_ADHOC, 'required' => false, 'candidateUsers' => ['demo-behandelaar-1'], 'realisationKind' => CaseItem::REALISATION_TASK, 'realisationUuid' => '00000000-0000-0000-0000-0000000000c7']), + 'audit' => [['from' => '', 'to' => CaseItem::STATE_AVAILABLE, 'cause' => CaseItemAudit::CAUSE_USER, 'causeRef' => 'attach', 'actor' => 'demo-behandelaar-1'], ['from' => CaseItem::STATE_AVAILABLE, 'to' => CaseItem::STATE_ACTIVE, 'cause' => CaseItemAudit::CAUSE_SENTRY, 'causeRef' => 'entry:default', 'actor' => 'case-plan']], + ], + ], + ], + [ + 'uuid' => '00000000-0000-0000-0000-0000000000c4', + 'item' => $permit(['itemKey' => 'assessment', 'name' => 'Beoordeling', 'planItemType' => CaseItem::TYPE_STAGE, 'position' => 1, 'state' => CaseItem::STATE_AVAILABLE, 'isTerminal' => false, 'definitionItemKey' => 'assessment', 'entryCriteria' => [['id' => 'assessment:entry', 'on' => ['event' => 'case.item.completed', 'item' => 'application-complete']]]]), + 'audit' => [$created(CaseItem::STATE_AVAILABLE)], + 'children' => [ + [ + // 2: the discretionary advice item. + 'uuid' => '00000000-0000-0000-0000-0000000000c5', + 'item' => $permit(['itemKey' => 'external-advice', 'name' => 'Extern advies', 'planItemType' => CaseItem::TYPE_HUMAN_TASK, 'position' => 0, 'state' => CaseItem::STATE_AVAILABLE, 'isTerminal' => false, 'definitionItemKey' => 'external-advice', 'origin' => CaseItem::ORIGIN_DISCRETIONARY, 'discretionary' => true, 'required' => false, 'authorizationRules' => ['demo-beslissers'], 'candidateRole' => 'demo-adviseurs']), + 'audit' => [$created(CaseItem::STATE_AVAILABLE), ['from' => CaseItem::STATE_AVAILABLE, 'to' => CaseItem::STATE_ENABLED, 'cause' => CaseItemAudit::CAUSE_USER, 'actor' => 'demo-stranger', 'reason' => "Verb 'enable' denied: the caller holds none of the item's authorizations.", 'authorized' => false]], + ], + ], + ], + // 4: a terminated stage with its cascade. + [ + 'uuid' => '00000000-0000-0000-0000-0000000000c8', + 'item' => ['objectUuid' => self::TERMINATED_OBJECT, 'registerId' => 1, 'schemaId' => 1, 'itemKey' => 'hearing', 'name' => 'Hoorzitting', 'planItemType' => CaseItem::TYPE_STAGE, 'origin' => CaseItem::ORIGIN_DEFINED, 'definitionItemKey' => 'hearing', 'position' => 0, 'state' => CaseItem::STATE_TERMINATED, 'isTerminal' => true, 'required' => true, 'discretionary' => false, 'realisationCount' => 1, 'terminatedReason' => 'Bezwaar ingetrokken.', 'planSettings' => ['authorization' => ['demo-behandelaars']], 'createdBy' => 'demo-seed'], + 'audit' => [$created(CaseItem::STATE_AVAILABLE), ['from' => CaseItem::STATE_AVAILABLE, 'to' => CaseItem::STATE_ACTIVE, 'cause' => CaseItemAudit::CAUSE_SENTRY, 'causeRef' => 'entry:default', 'actor' => 'case-plan'], ['from' => CaseItem::STATE_ACTIVE, 'to' => CaseItem::STATE_TERMINATED, 'cause' => CaseItemAudit::CAUSE_USER, 'actor' => 'demo-behandelaar-1', 'reason' => 'Bezwaar ingetrokken.']], + 'children' => [ + [ + 'uuid' => '00000000-0000-0000-0000-0000000000c9', + 'item' => ['objectUuid' => self::TERMINATED_OBJECT, 'registerId' => 1, 'schemaId' => 1, 'itemKey' => 'invite-parties', 'name' => 'Partijen uitnodigen', 'planItemType' => CaseItem::TYPE_HUMAN_TASK, 'origin' => CaseItem::ORIGIN_DEFINED, 'definitionItemKey' => 'invite-parties', 'position' => 0, 'state' => CaseItem::STATE_TERMINATED, 'isTerminal' => true, 'required' => true, 'discretionary' => false, 'realisationCount' => 1, 'terminatedReason' => "Stage 'hearing' exited to 'terminated'.", 'planSettings' => ['authorization' => ['demo-behandelaars']], 'createdBy' => 'demo-seed'], + 'audit' => [$created(CaseItem::STATE_AVAILABLE), ['from' => CaseItem::STATE_ACTIVE, 'to' => CaseItem::STATE_TERMINATED, 'cause' => CaseItemAudit::CAUSE_CASCADE, 'causeRef' => '00000000-0000-0000-0000-0000000000c8', 'actor' => 'case-plan', 'reason' => "Stage 'hearing' exited to 'terminated'."]], + ], + [ + 'uuid' => '00000000-0000-0000-0000-0000000000ca', + 'item' => ['objectUuid' => self::TERMINATED_OBJECT, 'registerId' => 1, 'schemaId' => 1, 'itemKey' => 'hearing-report', 'name' => 'Verslag hoorzitting', 'planItemType' => CaseItem::TYPE_HUMAN_TASK, 'origin' => CaseItem::ORIGIN_DEFINED, 'definitionItemKey' => 'hearing-report', 'position' => 1, 'state' => CaseItem::STATE_DISABLED, 'isTerminal' => true, 'required' => true, 'discretionary' => false, 'realisationCount' => 1, 'planSettings' => ['authorization' => ['demo-behandelaars']], 'createdBy' => 'demo-seed'], + 'audit' => [$created(CaseItem::STATE_AVAILABLE), ['from' => CaseItem::STATE_AVAILABLE, 'to' => CaseItem::STATE_DISABLED, 'cause' => CaseItemAudit::CAUSE_CASCADE, 'causeRef' => '00000000-0000-0000-0000-0000000000c8', 'actor' => 'case-plan', 'reason' => "Stage 'hearing' exited to 'terminated'."]], + ], + ], + ], + // 5: a repeating item, two realisations: one completed, one active. + [ + 'uuid' => '00000000-0000-0000-0000-0000000000cb', + 'item' => ['objectUuid' => self::REPEATING_OBJECT, 'registerId' => 1, 'schemaId' => 1, 'itemKey' => 'request-documents', 'name' => 'Stukken opvragen', 'planItemType' => CaseItem::TYPE_HUMAN_TASK, 'origin' => CaseItem::ORIGIN_DEFINED, 'definitionItemKey' => 'request-documents', 'position' => 0, 'state' => CaseItem::STATE_COMPLETED, 'isTerminal' => true, 'required' => true, 'discretionary' => false, 'repetition' => ['max' => 3], 'realisationCount' => 1, 'realisationKind' => CaseItem::REALISATION_TASK, 'realisationUuid' => '00000000-0000-0000-0000-0000000000cd', 'planSettings' => ['authorization' => ['demo-behandelaars']], 'createdBy' => 'demo-seed'], + 'audit' => [$created(CaseItem::STATE_AVAILABLE), ['from' => CaseItem::STATE_ACTIVE, 'to' => CaseItem::STATE_COMPLETED, 'cause' => CaseItemAudit::CAUSE_REALISATION, 'causeRef' => '00000000-0000-0000-0000-0000000000cd', 'actor' => 'case-plan']], + ], + [ + 'uuid' => '00000000-0000-0000-0000-0000000000cc', + 'item' => ['objectUuid' => self::REPEATING_OBJECT, 'registerId' => 1, 'schemaId' => 1, 'itemKey' => 'request-documents', 'name' => 'Stukken opvragen', 'planItemType' => CaseItem::TYPE_HUMAN_TASK, 'origin' => CaseItem::ORIGIN_DEFINED, 'definitionItemKey' => 'request-documents', 'position' => 0, 'state' => CaseItem::STATE_ACTIVE, 'isTerminal' => false, 'required' => true, 'discretionary' => false, 'repetition' => ['max' => 3], 'realisationCount' => 2, 'realisationKind' => CaseItem::REALISATION_TASK, 'realisationUuid' => '00000000-0000-0000-0000-0000000000ce', 'planSettings' => ['authorization' => ['demo-behandelaars']], 'createdBy' => 'demo-seed'], + 'audit' => [['from' => '', 'to' => CaseItem::STATE_AVAILABLE, 'cause' => CaseItemAudit::CAUSE_REALISATION, 'causeRef' => '00000000-0000-0000-0000-0000000000cb', 'actor' => 'case-plan'], ['from' => CaseItem::STATE_AVAILABLE, 'to' => CaseItem::STATE_ACTIVE, 'cause' => CaseItemAudit::CAUSE_SENTRY, 'causeRef' => 'entry:default', 'actor' => 'case-plan']], + ], + ]; + // phpcs:enable Generic.Files.LineLength.MaxExceeded + }//end fixtures() +}//end class diff --git a/lib/Service/Case/CaseAnchorReader.php b/lib/Service/Case/CaseAnchorReader.php new file mode 100644 index 0000000000..705258ee9a --- /dev/null +++ b/lib/Service/Case/CaseAnchorReader.php @@ -0,0 +1,124 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use OCA\OpenRegister\Service\ObjectService; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The case layer's view of its anchor. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ +class CaseAnchorReader { + + /** + * Constructor. + * + * @param ObjectService $objects The ordinary object read path. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly ObjectService $objects, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The anchor's data for sentry evaluation. Empty when unreadable. + * + * @param string $objectUuid The anchor uuid. + * @param int|null $registerId Its register, when known. + * @param int|null $schemaId Its schema, when known. + * + * @return array The object's data, or [] when it cannot be read. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function read(string $objectUuid, ?int $registerId, ?int $schemaId): array { + try { + $entity = $this->objects->find( + id: $objectUuid, + register: $registerId, + schema: $schemaId, + _rbac: false, + _multitenancy: false, + _render: false, + _audit: false + ); + } catch (Throwable $failure) { + $this->logger->warning( + '[CaseAnchorReader] The anchoring object could not be read; every if-part over it is false: ' . $failure->getMessage(), + ['object' => $objectUuid] + ); + + return []; + } + + if ($entity === null) { + return []; + } + + $data = $entity->getObject(); + if (is_array($data) === false) { + return []; + } + + return $data; + }//end read() + + /** + * Whether a caller may read the anchor (and therefore its plan). + * + * @param string $objectUuid The anchor uuid. + * @param int|null $registerId Its register, when known. + * @param int|null $schemaId Its schema, when known. + * + * @return boolean True only when the RBAC-checked read succeeds. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function mayRead(string $objectUuid, ?int $registerId, ?int $schemaId): bool { + try { + return $this->objects->find( + id: $objectUuid, + register: $registerId, + schema: $schemaId, + _render: false, + _audit: false + ) !== null; + } catch (Throwable) { + return false; + } + }//end mayRead() +}//end class diff --git a/lib/Service/Case/CaseBusinessStateWriter.php b/lib/Service/Case/CaseBusinessStateWriter.php new file mode 100644 index 0000000000..13301ec97f --- /dev/null +++ b/lib/Service/Case/CaseBusinessStateWriter.php @@ -0,0 +1,149 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use DateTime; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Service\ObjectService; + +/** + * Mirrors status and result onto the anchoring object. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ +class CaseBusinessStateWriter { + + /** + * Constructor. + * + * @param ObjectService $objects The ordinary object write path. + */ + public function __construct( + private readonly ObjectService $objects, + ) { + + }//end __construct() + + /** + * A milestone was reached: mirror it as the object's status. + * + * @param CaseItem $milestone The completed milestone. + * + * @return boolean True when a write happened; false when the plan maps no status field. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + public function mirrorStatus(CaseItem $milestone): bool { + $mapping = $this->mapping(settings: ($milestone->getPlanSettings() ?? [])); + $statusField = trim((string)($mapping['statusField'] ?? '')); + if ($statusField === '') { + return false; + } + + $data = [$statusField => (string)($milestone->getName() ?? $milestone->getItemKey())]; + $atField = trim((string)($mapping['statusAtField'] ?? '')); + if ($atField !== '') { + $data[$atField] = (new DateTime())->format('c'); + } + + $this->patch(item: $milestone, data: $data); + + return true; + }//end mirrorStatus() + + /** + * The case finished: mirror its result. + * + * @param CaseItem $anyRow Any row of the plan (for the anchor and the settings). + * @param string $result The result, already checked against the allowed set. + * + * @return boolean True when a write happened; false when the plan maps no result field. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + public function mirrorResult(CaseItem $anyRow, string $result): bool { + $mapping = $this->mapping(settings: ($anyRow->getPlanSettings() ?? [])); + $resultField = trim((string)($mapping['resultField'] ?? '')); + if ($resultField === '') { + return false; + } + + $data = [$resultField => $result]; + $atField = trim((string)($mapping['resultAtField'] ?? '')); + if ($atField !== '') { + $data[$atField] = (new DateTime())->format('c'); + } + + $this->patch(item: $anyRow, data: $data); + + return true; + }//end mirrorResult() + + /** + * The `writeThrough` mapping of a plan's settings. + * + * @param array $settings The plan settings. + * + * @return array The mapping, or [] when none. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + private function mapping(array $settings): array { + $mapping = ($settings['writeThrough'] ?? null); + if (is_array($mapping) === false) { + return []; + } + + return $mapping; + }//end mapping() + + /** + * One partial write through the ordinary path. + * + * @param CaseItem $item The row naming the anchor. + * @param array $data The fields to write. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + private function patch(CaseItem $item, array $data): void { + $this->objects->patchObject( + objectId: (string)$item->getObjectUuid(), + data: $data, + register: $item->getRegisterId(), + schema: $item->getSchemaId(), + _rbac: false + ); + }//end patch() +}//end class diff --git a/lib/Service/Case/CasePlanAuthorizationService.php b/lib/Service/Case/CasePlanAuthorizationService.php new file mode 100644 index 0000000000..87d35468be --- /dev/null +++ b/lib/Service/Case/CasePlanAuthorizationService.php @@ -0,0 +1,306 @@ +` or `role:`. A + * role resolves to the group of the same name and MUST exist, or the + * decision is indeterminate and denies naming the role. An item without + * rules of its own derives them from its nearest ancestor that has any, + * then from the plan root (`settings.authorization`). A plan that declares + * none anywhere is administrable by administrators only: fail-closed, and + * an ad-hoc item cannot declare itself unguarded because it may not declare + * rules at all. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseAccessDeniedException; +use OCP\IGroupManager; +use Throwable; + +/** + * The case layer's authorization decisions. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ +class CasePlanAuthorizationService { + + /** + * Constructor. + * + * @param IGroupManager|null $groupManager Resolves membership, roles and + * administrators. Nullable so the + * service stays constructible + * bare; ABSENT, every + * membership-dependent decision + * DENIES. + */ + public function __construct( + private readonly ?IGroupManager $groupManager = null, + ) { + + }//end __construct() + + /** + * Assert an acting identity exists at all. No verb is anonymous. + * + * @param string|null $uid The acting identity. + * @param string $verb The verb, for the message. + * + * @return string The trimmed identity. + * + * @throws CaseAccessDeniedException Without one. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function assertIdentified(?string $uid, string $verb): string { + $identity = trim((string)$uid); + if ($identity === '') { + throw new CaseAccessDeniedException(message: sprintf("Verb '%s' denied: no acting identity.", $verb)); + } + + return $identity; + }//end assertIdentified() + + /** + * Whether a uid is an administrator. Fail-closed: no backend, no admin. + * + * @param string|null $uid The acting identity. + * + * @return boolean True only when the group backend affirms it. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function isAdministrator(?string $uid): bool { + if ($uid === null || trim($uid) === '' || $this->groupManager === null) { + return false; + } + + try { + return $this->groupManager->isAdmin($uid); + } catch (Throwable) { + return false; + } + }//end isAdministrator() + + /** + * Assert a caller may act on an item (enable, transition, attach under it). + * + * @param string $verb The verb attempted. + * @param CaseItem|null $item The item acted on, or null for the plan root. + * @param CasePlanTree $tree The plan (for ancestors and root settings). + * @param string|null $uid The acting identity. + * + * @return void + * + * @throws CaseAccessDeniedException When denied or indeterminate. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function assertMayAct(string $verb, ?CaseItem $item, CasePlanTree $tree, ?string $uid): void { + $identity = $this->assertIdentified(uid: $uid, verb: $verb); + if ($this->isAdministrator(uid: $identity) === true) { + return; + } + + $this->assertHolds(verb: $verb, rules: $this->effectiveRules(item: $item, tree: $tree), uid: $identity); + }//end assertMayAct() + + /** + * Assert a caller may administer a plan: create it, delete it, complete + * the case. Judged against the ROOT rules alone. + * + * @param string $verb The verb attempted. + * @param array $settings The plan settings (root rules under `authorization`). + * @param string|null $uid The acting identity. + * + * @return void + * + * @throws CaseAccessDeniedException When denied or indeterminate. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function assertMayAdminister(string $verb, array $settings, ?string $uid): void { + $identity = $this->assertIdentified(uid: $uid, verb: $verb); + if ($this->isAdministrator(uid: $identity) === true) { + return; + } + + $this->assertHolds(verb: $verb, rules: ($settings['authorization'] ?? null), uid: $identity); + }//end assertMayAdminister() + + /** + * The rules that govern an item: its own, else its nearest ancestor's, + * else the plan root's. Null when nowhere declares any. + * + * @param CaseItem|null $item The item, or null for the root. + * @param CasePlanTree $tree The plan. + * + * @return mixed The rule list, or null. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function effectiveRules(?CaseItem $item, CasePlanTree $tree): mixed { + if ($item !== null) { + $own = $item->getAuthorizationRules(); + if (is_array($own) === true && $own !== []) { + return $own; + } + + foreach ($tree->ancestors(item: $item) as $ancestor) { + $rules = $ancestor->getAuthorizationRules(); + if (is_array($rules) === true && $rules !== []) { + return $rules; + } + } + } + + return ($tree->settings()['authorization'] ?? null); + }//end effectiveRules() + + /** + * The caller must satisfy at least one rule; anything indeterminate denies. + * + * @param string $verb The verb, for the message. + * @param mixed $rules The rule list. + * @param string $uid The acting identity. + * + * @return void + * + * @throws CaseAccessDeniedException When no rule admits the caller. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function assertHolds(string $verb, mixed $rules, string $uid): void { + if (is_array($rules) === false || $rules === []) { + throw new CaseAccessDeniedException( + message: sprintf("Verb '%s' denied: no authorization is declared for this item or its plan, so only an administrator may perform it.", $verb) + ); + } + + foreach ($rules as $rule) { + if ($this->ruleAdmits(verb: $verb, rule: $rule, uid: $uid) === true) { + return; + } + } + + throw new CaseAccessDeniedException( + message: sprintf("Verb '%s' denied: the caller holds none of the item's authorizations.", $verb) + ); + }//end assertHolds() + + /** + * Whether one rule admits the caller. A role that does not resolve is + * indeterminate and DENIES naming the role, never "no check applicable". + * + * @param string $verb The verb, for the message. + * @param mixed $rule One rule. + * @param string $uid The acting identity. + * + * @return boolean True only when the rule affirmatively admits. + * + * @throws CaseAccessDeniedException When a role cannot be resolved. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function ruleAdmits(string $verb, mixed $rule, string $uid): bool { + if (is_string($rule) === false || trim($rule) === '') { + return false; + } + + $rule = trim($rule); + if (str_starts_with($rule, 'user:') === true) { + return substr($rule, 5) === $uid; + } + + if (str_starts_with($rule, 'role:') === true) { + $role = substr($rule, 5); + $this->assertRoleResolvable(verb: $verb, role: $role); + + return $this->isInGroup(uid: $uid, groupId: $role); + } + + return $this->isInGroup(uid: $uid, groupId: $rule); + }//end ruleAdmits() + + /** + * Membership through the group backend, denying when it is absent. + * + * @param string $uid The acting identity. + * @param string $groupId The group to test. + * + * @return boolean True only when the backend affirms membership. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function isInGroup(string $uid, string $groupId): bool { + if ($this->groupManager === null || $groupId === '') { + return false; + } + + try { + return $this->groupManager->isInGroup($uid, $groupId); + } catch (Throwable) { + return false; + } + }//end isInGroup() + + /** + * A role must resolve to the group of the same name. + * + * @param string $verb The verb, for the message. + * @param string $role The role name. + * + * @return void + * + * @throws CaseAccessDeniedException When it does not, or cannot be checked. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function assertRoleResolvable(string $verb, string $role): void { + if ($this->groupManager === null) { + throw new CaseAccessDeniedException( + message: sprintf("Verb '%s' denied: role '%s' cannot be resolved because no group backend is available.", $verb, $role) + ); + } + + $exists = false; + try { + $exists = $this->groupManager->groupExists($role); + } catch (Throwable) { + $exists = false; + } + + if ($exists === false) { + throw new CaseAccessDeniedException( + message: sprintf("Verb '%s' denied: role '%s' does not resolve to any group.", $verb, $role) + ); + } + }//end assertRoleResolvable() +}//end class diff --git a/lib/Service/Case/CasePlanCascade.php b/lib/Service/Case/CasePlanCascade.php new file mode 100644 index 0000000000..8ab7597323 --- /dev/null +++ b/lib/Service/Case/CasePlanCascade.php @@ -0,0 +1,496 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Db\CaseItemMapper; +use OCA\OpenRegister\Exception\CaseCascadeBoundException; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Bounded fixpoint evaluation of one plan. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The loop coordinates the + * mapper, the machine, the evaluator, the realiser and the anchor reader + * inside one transaction; that is the whole of its job. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ +class CasePlanCascade { + + /** + * The named bound. A definition whose sentries admit each other in a + * cycle hits it and fails; a real plan settles in a handful of passes. + * + * @var integer + */ + public const MAX_CASCADE_DEPTH = 50; + + /** + * Objects currently being evaluated, so a write-through's own object + * event cannot re-enter the loop it came from. + * + * @var array + */ + private array $evaluating = []; + + /** + * Constructor. + * + * @param CaseItemMapper $items The plan-item table. + * @param CasePlanStateMachine $machine The one transition path. + * @param CaseSentryEvaluator $sentries Entry and exit criteria. + * @param CaseRealisationService $realiser Reads how realisations ended. + * @param CaseAnchorReader $anchor Reads the anchoring object. + * @param IDBConnection $db Holds the evaluation's transaction. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly CaseItemMapper $items, + private readonly CasePlanStateMachine $machine, + private readonly CaseSentryEvaluator $sentries, + private readonly CaseRealisationService $realiser, + private readonly CaseAnchorReader $anchor, + private readonly IDBConnection $db, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Evaluate a plan to its fixpoint, in one transaction. + * + * @param string $objectUuid The anchoring object. + * @param string|null $event The event being handled (an object event), or null. + * @param array $payload The event's payload. + * @param string|null $actor The identity whose act triggered this, or null for the system. + * + * @return array{passes: int, transitions: int, skipped: bool} What happened. + * + * @throws CaseCascadeBoundException At the bound, after rolling back. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function evaluate(string $objectUuid, ?string $event = null, array $payload = [], ?string $actor = null): array { + if (isset($this->evaluating[$objectUuid]) === true) { + // Re-entered from inside our own transaction (a write-through's + // object event): the outer loop's next pass sees the change. + return ['passes' => 0, 'transitions' => 0, 'skipped' => true]; + } + + $this->evaluating[$objectUuid] = true; + $outermost = $this->db->inTransaction() === false; + $this->db->beginTransaction(); + $passes = 0; + $transitions = 0; + try { + while (true) { + $passes++; + $changed = $this->pass(objectUuid: $objectUuid, event: $event, payload: $payload, actor: $actor); + $transitions += $changed; + if ($changed === 0) { + break; + } + + // Only the event being handled admits an object-event on-part, + // and only in the pass it arrived in. + $event = null; + $payload = []; + + if ($passes >= self::MAX_CASCADE_DEPTH) { + throw new CaseCascadeBoundException( + message: sprintf( + 'Case-plan evaluation of object %s did not settle within %d passes (MAX_CASCADE_DEPTH); ' + . 'the plan was rolled back to its state before evaluation.', + $objectUuid, + self::MAX_CASCADE_DEPTH + ) + ); + } + } + + $this->db->commit(); + } catch (Throwable $failure) { + $this->db->rollBack(); + $this->machine->discardEvents(); + unset($this->evaluating[$objectUuid]); + $this->logger->warning( + '[CasePlanCascade] Evaluation rolled back: ' . $failure->getMessage(), + ['object' => $objectUuid, 'passes' => $passes] + ); + throw $failure; + } + + unset($this->evaluating[$objectUuid]); + if ($outermost === true) { + $this->machine->flushEvents(); + } + + return ['passes' => $passes, 'transitions' => $transitions, 'skipped' => false]; + }//end evaluate() + + /** + * One pass over a fresh read of the plan. + * + * @param string $objectUuid The anchoring object. + * @param string|null $event The event being handled. + * @param array $payload Its payload. + * @param string|null $actor The triggering identity. + * + * @return int How many transitions (or new rows) this pass produced. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + private function pass(string $objectUuid, ?string $event, array $payload, ?string $actor): int { + $rows = $this->items->findByObject(objectUuid: $objectUuid); + if ($rows === []) { + return 0; + } + + $tree = new CasePlanTree(items: $rows); + $first = $rows[0]; + $object = $this->anchor->read( + objectUuid: $objectUuid, + registerId: $first->getRegisterId(), + schemaId: $first->getSchemaId() + ); + + $changed = 0; + $changed += $this->syncRealisations(tree: $tree); + $changed += $this->applyExits(tree: $tree, object: $object, event: $event, payload: $payload); + $changed += $this->applyEntries(tree: $tree, object: $object, event: $event, payload: $payload); + $changed += $this->startEnabled(tree: $tree, actor: $actor); + $changed += $this->completeStages(tree: $tree); + $changed += $this->repeat(tree: $tree); + + return $changed; + }//end pass() + + /** + * A realisation that ended drives its item: completed -> completed, + * anything else -> terminated, cause `realisation`, ref the realisation. + * + * @param CasePlanTree $tree The plan. + * + * @return int Transitions made. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + private function syncRealisations(CasePlanTree $tree): int { + $count = 0; + foreach ($tree->all() as $row) { + if ($row->getState() !== CaseItem::STATE_ACTIVE || $row->getRealisationKind() === CaseItem::REALISATION_NONE) { + continue; + } + + $outcome = $this->realiser->terminalOutcome(item: $row); + if ($outcome === null) { + continue; + } + + $this->machine->transition( + item: $row, + to: $outcome, + cause: CaseItemAudit::CAUSE_REALISATION, + causeRef: $row->getRealisationUuid(), + actor: null, + reason: sprintf("Realisation %s '%s' ended as '%s'.", (string)$row->getRealisationKind(), (string)$row->getRealisationUuid(), $outcome), + tree: $tree + ); + $count++; + } + + return $count; + }//end syncRealisations() + + /** + * Exit sentries terminate entered, non-terminal items. + * + * @param CasePlanTree $tree The plan. + * @param array $object The anchor's data. + * @param string|null $event The event being handled. + * @param array $payload Its payload. + * + * @return int Transitions made. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function applyExits(CasePlanTree $tree, array $object, ?string $event, array $payload): int { + $count = 0; + foreach ($tree->all() as $row) { + if ($row->isEntered() === false || $row->isInTerminalState() === true) { + continue; + } + + $sentry = $this->sentries->exitSentry(item: $row, tree: $tree, object: $object, event: $event, payload: $payload); + if ($sentry === null) { + continue; + } + + $this->machine->transition( + item: $row, + to: CaseItem::STATE_TERMINATED, + cause: CaseItemAudit::CAUSE_SENTRY, + causeRef: $sentry, + actor: null, + reason: sprintf("Exit criterion '%s' fired.", $sentry), + tree: $tree + ); + $count++; + } + + return $count; + }//end applyExits() + + /** + * Entry sentries admit available, non-discretionary items whose parent + * is active. A milestone completes at once; a work item becomes active. + * A discretionary item waits for its explicit enable. + * + * @param CasePlanTree $tree The plan. + * @param array $object The anchor's data. + * @param string|null $event The event being handled. + * @param array $payload Its payload. + * + * @return int Transitions made. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function applyEntries(CasePlanTree $tree, array $object, ?string $event, array $payload): int { + $count = 0; + foreach ($tree->all() as $row) { + if ($row->getState() !== CaseItem::STATE_AVAILABLE || $row->getDiscretionary() === true) { + continue; + } + + if ($tree->isParentActive(item: $row) === false) { + continue; + } + + $sentry = $this->sentries->entrySentry(item: $row, tree: $tree, object: $object, event: $event, payload: $payload); + if ($sentry === null) { + continue; + } + + $target = CaseItem::STATE_ACTIVE; + if ($row->getPlanItemType() === CaseItem::TYPE_MILESTONE) { + $target = CaseItem::STATE_COMPLETED; + } + + $this->machine->transition( + item: $row, + to: $target, + cause: CaseItemAudit::CAUSE_SENTRY, + causeRef: $sentry, + actor: null, + reason: null, + tree: $tree + ); + $count++; + } + + return $count; + }//end applyEntries() + + /** + * An enabled item under an active parent starts. + * + * @param CasePlanTree $tree The plan. + * @param string|null $actor The identity that enabled it, when known. + * + * @return int Transitions made. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function startEnabled(CasePlanTree $tree, ?string $actor): int { + $count = 0; + foreach ($tree->all() as $row) { + if ($row->getState() !== CaseItem::STATE_ENABLED || $tree->isParentActive(item: $row) === false) { + continue; + } + + $this->machine->transition( + item: $row, + to: CaseItem::STATE_ACTIVE, + cause: CaseItemAudit::CAUSE_USER, + causeRef: 'enable', + actor: $actor, + reason: null, + tree: $tree + ); + $count++; + } + + return $count; + }//end startEnabled() + + /** + * Active stages whose completion rule holds complete. A stage realised by + * a flow run is driven by that run instead (syncRealisations). + * + * @param CasePlanTree $tree The plan. + * + * @return int Transitions made. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + private function completeStages(CasePlanTree $tree): int { + $count = 0; + foreach ($tree->all() as $row) { + if ($row->getPlanItemType() !== CaseItem::TYPE_STAGE || $row->getState() !== CaseItem::STATE_ACTIVE) { + continue; + } + + if ($row->getRealisationKind() === CaseItem::REALISATION_RUN || $tree->stageMayComplete(stage: $row) === false) { + continue; + } + + $this->machine->transition( + item: $row, + to: CaseItem::STATE_COMPLETED, + cause: CaseItemAudit::CAUSE_CASCADE, + causeRef: 'children', + actor: null, + reason: 'Every required child is terminal and no child is active.', + tree: $tree + ); + $count++; + } + + return $count; + }//end completeStages() + + /** + * A completed repeating row whose rule is not exhausted grows its next + * realisation: a new row of the same key, `realisation_count` + 1, in + * `available`. ONE plan item, N realisations, each its own row. + * + * @param CasePlanTree $tree The plan. + * + * @return int Rows created. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + private function repeat(CasePlanTree $tree): int { + $count = 0; + foreach ($tree->all() as $row) { + if ($row->getState() !== CaseItem::STATE_COMPLETED || $tree->repetitionExhausted(item: $row) === true) { + continue; + } + + $next = (int)$row->getRealisationCount() + 1; + $exists = false; + foreach ($tree->rowsForKey(key: (string)$row->getItemKey()) as $sibling) { + if ((int)$sibling->getRealisationCount() === $next) { + $exists = true; + break; + } + } + + if ($exists === true) { + continue; + } + + $clone = $this->nextRealisation(previous: $row, count: $next); + $inserted = $this->items->insert($clone); + $this->machine->recordCreation( + item: $inserted, + cause: CaseItemAudit::CAUSE_REALISATION, + causeRef: (string)$row->getUuid(), + actor: null + ); + $count++; + } + + return $count; + }//end repeat() + + /** + * The next realisation row of a repeating item: same definition, fresh + * lifecycle. + * + * @param CaseItem $previous The completed row. + * @param int $count The next realisation number. + * + * @return CaseItem The unsaved row. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + private function nextRealisation(CaseItem $previous, int $count): CaseItem { + $clone = new CaseItem(); + $clone->setItemKey($previous->getItemKey()); + $clone->setName($previous->getName()); + $clone->setDescription($previous->getDescription()); + $clone->setObjectUuid($previous->getObjectUuid()); + $clone->setRegisterId($previous->getRegisterId()); + $clone->setSchemaId($previous->getSchemaId()); + $clone->setFlowUuid($previous->getFlowUuid()); + $clone->setFlowVersion($previous->getFlowVersion()); + $clone->setDefinitionItemKey($previous->getDefinitionItemKey()); + $clone->setOrigin($previous->getOrigin()); + $clone->setParentItemId($previous->getParentItemId()); + $clone->setPlanItemType($previous->getPlanItemType()); + $clone->setPosition($previous->getPosition()); + $clone->setState(CaseItem::STATE_AVAILABLE); + $clone->setIsTerminal(false); + $clone->setEntryCriteria($previous->getEntryCriteria()); + $clone->setExitCriteria($previous->getExitCriteria()); + $clone->setRequired($previous->getRequired()); + $clone->setDiscretionary($previous->getDiscretionary()); + $clone->setRepetition($previous->getRepetition()); + $clone->setRealisationCount($count); + $clone->setAuthorizationRules($previous->getAuthorizationRules()); + $clone->setCandidateUsers($previous->getCandidateUsers()); + $clone->setCandidateGroups($previous->getCandidateGroups()); + $clone->setCandidateRole($previous->getCandidateRole()); + $clone->setDueAt($previous->getDueAt()); + $clone->setExpiresAt($previous->getExpiresAt()); + $clone->setDoorlooptijd($previous->getDoorlooptijd()); + $clone->setServicenorm($previous->getServicenorm()); + $clone->setPlanSettings($previous->getPlanSettings()); + $clone->setCreatedBy($previous->getCreatedBy()); + + return $clone; + }//end nextRealisation() +}//end class diff --git a/lib/Service/Case/CasePlanDefinition.php b/lib/Service/Case/CasePlanDefinition.php new file mode 100644 index 0000000000..05150d8557 --- /dev/null +++ b/lib/Service/Case/CasePlanDefinition.php @@ -0,0 +1,444 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-cmmn-notation-is-not-adopted-and-bpmn-remains-a-format + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use DateTime; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseValidationException; +use Throwable; + +/** + * Validates and compiles case-plan definitions. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) `rowFrom()` is one setter + * per optional definition field; each nullable field is one branch. + * @SuppressWarnings(PHPMD.NPathComplexity) Same cause. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of every shape + * the boundary refuses; each refusal is one `if` with one message, and the + * spec asks for all of them to fail at save time. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) `rowFrom()` takes the + * anchor triple, the tree position, the settings and the provenance + * separately because they come from different callers (definition import, + * ad-hoc attach, repetition); a parameter object would be built and unpacked + * at every one of them. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-cmmn-notation-is-not-adopted-and-bpmn-remains-a-format + */ +class CasePlanDefinition { + + /** + * What an item key may look like: something a sentry can name and a URL + * can carry. + */ + private const KEY_PATTERN = '/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/'; + + /** + * Constructor. + * + * @param CaseSentryEvaluator $sentries Validates criteria at save time. + */ + public function __construct( + private readonly CaseSentryEvaluator $sentries, + ) { + + }//end __construct() + + /** + * Validate a whole definition; return it normalised with `settings.flows` + * collected from the stages' `flow` bindings. + * + * @param array $definition The definition as submitted. + * + * @return array{settings: array, items: array>} The normalised definition. + * + * @throws CaseValidationException On the first refused element. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function validate(array $definition): array { + $items = ($definition['items'] ?? null); + if (is_array($items) === false || $items === []) { + throw new CaseValidationException(message: 'A case-plan definition needs a non-empty `items` list.'); + } + + $settings = ($definition['settings'] ?? []); + if (is_array($settings) === false) { + throw new CaseValidationException(message: '`settings` must be an object.'); + } + + $this->validateRuleList(rules: ($settings['authorization'] ?? null), where: 'settings.authorization'); + if (isset($settings['results']) === true && $this->isStringList(value: $settings['results']) === false) { + throw new CaseValidationException(message: '`settings.results` must be a list of result names.'); + } + + if (isset($settings['writeThrough']) === true && is_array($settings['writeThrough']) === false) { + throw new CaseValidationException(message: '`settings.writeThrough` must be an object of field names.'); + } + + $keys = []; + $flows = []; + $normalised = []; + foreach ($items as $index => $node) { + $normalised[] = $this->validateNode(node: $node, path: 'items[' . (int)$index . ']', keys: $keys, flows: $flows); + } + + if ($flows !== []) { + $settings['flows'] = $flows; + } + + return ['settings' => $settings, 'items' => $normalised]; + }//end validate() + + /** + * Validate ONE ad-hoc item as submitted at runtime. + * + * An ad-hoc item may not declare its own authorization (it derives from + * its parent or the plan root), may not be discretionary (it is attached + * by an act, there is nothing to enable), and may not carry children. + * + * @param array $node The item as submitted. + * + * @return array The normalised node. + * + * @throws CaseValidationException On any refused element. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function validateAdHoc(array $node): array { + if (array_key_exists('authorization', $node) === true) { + throw new CaseValidationException( + message: 'An ad-hoc item cannot declare its own authorization; it derives it from its parent stage or the plan root.' + ); + } + + if (($node['discretionary'] ?? false) === true) { + throw new CaseValidationException(message: 'An ad-hoc item cannot be discretionary; it is entered by the act of attaching it.'); + } + + if (isset($node['children']) === true || isset($node['flow']) === true) { + throw new CaseValidationException(message: 'An ad-hoc item cannot carry children or a flow binding.'); + } + + $keys = []; + $flows = []; + + return $this->validateNode(node: $node, path: 'item', keys: $keys, flows: $flows); + }//end validateAdHoc() + + /** + * Build the unsaved row for one normalised node. + * + * @param array $node The normalised node. + * @param string $objectUuid The anchor. + * @param int|null $registerId The anchor's register. + * @param int|null $schemaId The anchor's schema. + * @param int|null $parentId The containing stage's row id, or null. + * @param int $position Order among siblings. + * @param array $settings The plan settings, carried on every row. + * @param string $origin defined | discretionary | adhoc. + * @param string|null $actor The creating identity. + * @param string|null $flowUuid Definition provenance, when any. + * @param int|null $flowVersion Definition provenance, when any. + * + * @return CaseItem The unsaved row in `available`. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function rowFrom( + array $node, + string $objectUuid, + ?int $registerId, + ?int $schemaId, + ?int $parentId, + int $position, + array $settings, + string $origin, + ?string $actor, + ?string $flowUuid = null, + ?int $flowVersion = null, + ): CaseItem { + $row = new CaseItem(); + $row->setItemKey((string)$node['key']); + $row->setName($this->stringOrNull(value: ($node['name'] ?? null))); + $row->setDescription($this->stringOrNull(value: ($node['description'] ?? null))); + $row->setObjectUuid($objectUuid); + $row->setRegisterId($registerId); + $row->setSchemaId($schemaId); + $row->setFlowUuid($flowUuid); + $row->setFlowVersion($flowVersion); + if ($origin !== CaseItem::ORIGIN_ADHOC) { + $row->setDefinitionItemKey((string)$node['key']); + } + + $row->setOrigin($origin); + $row->setParentItemId($parentId); + $row->setPlanItemType((string)$node['type']); + $row->setPosition($position); + $row->setState(CaseItem::STATE_AVAILABLE); + $row->setIsTerminal(false); + $row->setEntryCriteria($this->listOrNull(value: ($node['entryCriteria'] ?? null))); + $row->setExitCriteria($this->listOrNull(value: ($node['exitCriteria'] ?? null))); + $row->setRequired(($node['required'] ?? true) !== false); + $row->setDiscretionary(($node['discretionary'] ?? false) === true); + $row->setRepetition($this->listOrNull(value: ($node['repetition'] ?? null))); + $row->setRealisationCount(1); + $row->setAuthorizationRules($this->listOrNull(value: ($node['authorization'] ?? null))); + $row->setCandidateUsers($this->listOrNull(value: ($node['candidateUsers'] ?? null))); + $row->setCandidateGroups($this->listOrNull(value: ($node['candidateGroups'] ?? null))); + $row->setCandidateRole($this->stringOrNull(value: ($node['candidateRole'] ?? null))); + $row->setDueAt($this->dateOrNull(value: ($node['dueAt'] ?? null), field: 'dueAt')); + $row->setExpiresAt($this->dateOrNull(value: ($node['expiresAt'] ?? null), field: 'expiresAt')); + $row->setDoorlooptijd($this->stringOrNull(value: ($node['doorlooptijd'] ?? null))); + $row->setServicenorm($this->stringOrNull(value: ($node['servicenorm'] ?? null))); + $row->setPlanSettings($settings); + $row->setCreatedBy($actor); + + return $row; + }//end rowFrom() + + /** + * Validate one node and, recursively, its children. + * + * @param mixed $node The node as submitted. + * @param string $path Where it is, for messages. + * @param array $keys Keys seen so far (by reference). + * @param array $flows Stage flow bindings collected (by reference). + * + * @return array The normalised node. + * + * @throws CaseValidationException On the first refused element. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-cmmn-notation-is-not-adopted-and-bpmn-remains-a-format + */ + private function validateNode(mixed $node, string $path, array &$keys, array &$flows): array { + if (is_array($node) === false) { + throw new CaseValidationException(message: sprintf('%s must be an object.', $path)); + } + + $key = trim((string)($node['key'] ?? '')); + if ($key === '' || preg_match(self::KEY_PATTERN, $key) !== 1) { + throw new CaseValidationException(message: sprintf('%s needs a `key` of letters, digits, `_`, `.` or `-` (max 128).', $path)); + } + + if (isset($keys[$key]) === true) { + throw new CaseValidationException(message: sprintf("%s repeats item key '%s'; keys are unique within a plan.", $path, $key)); + } + + $keys[$key] = true; + $type = (string)($node['type'] ?? ''); + if (in_array($type, CaseItem::TYPES, true) === false) { + throw new CaseValidationException( + message: sprintf("%s ('%s') has type '%s'; expected one of %s.", $path, $key, $type, implode(', ', CaseItem::TYPES)) + ); + } + + $this->sentries->validateCriteria(criteria: ($node['entryCriteria'] ?? null), where: sprintf("'%s' entry", $key)); + $this->sentries->validateCriteria(criteria: ($node['exitCriteria'] ?? null), where: sprintf("'%s' exit", $key)); + $this->validateRuleList(rules: ($node['authorization'] ?? null), where: sprintf("'%s'.authorization", $key)); + + $repetition = ($node['repetition'] ?? null); + if ($repetition !== null && (is_array($repetition) === false || (int)($repetition['max'] ?? 0) < 1)) { + throw new CaseValidationException(message: sprintf("'%s'.repetition must be {\"max\": N} with N >= 1.", $key)); + } + + if ($type === CaseItem::TYPE_MILESTONE && ($node['discretionary'] ?? false) === true) { + throw new CaseValidationException(message: sprintf("'%s' is a milestone and cannot be discretionary: a milestone is never enabled.", $key)); + } + + if ($type !== CaseItem::TYPE_STAGE) { + if (isset($node['children']) === true) { + throw new CaseValidationException(message: sprintf("'%s' is a %s and cannot contain children; only a stage nests.", $key, $type)); + } + + if (isset($node['flow']) === true) { + throw new CaseValidationException(message: sprintf("'%s' is a %s and cannot bind a flow; only a stage is realised by a run.", $key, $type)); + } + } + + $node['key'] = $key; + $children = ($node['children'] ?? []); + $node['children'] = []; + if ($type === CaseItem::TYPE_STAGE) { + $flow = trim((string)($node['flow'] ?? '')); + if ($flow !== '') { + $flows[$key] = $flow; + } + + if (is_array($children) === false) { + throw new CaseValidationException(message: sprintf("'%s'.children must be a list.", $key)); + } + + // A stage bound to a flow is driven by its run; children under it + // would have two masters. + if ($flow !== '' && $children !== []) { + throw new CaseValidationException(message: sprintf("'%s' binds a flow and cannot also contain children.", $key)); + } + + $normalisedChildren = []; + foreach ($children as $index => $child) { + $normalisedChildren[] = $this->validateNode(node: $child, path: sprintf('%s.children[%d]', $path, (int)$index), keys: $keys, flows: $flows); + } + + $node['children'] = $normalisedChildren; + } + + return $node; + }//end validateNode() + + /** + * An authorization rule list is a list of non-empty strings, or absent. + * + * @param mixed $rules The list. + * @param string $where For the message. + * + * @return void + * + * @throws CaseValidationException When malformed. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function validateRuleList(mixed $rules, string $where): void { + if ($rules === null) { + return; + } + + if ($this->isStringList(value: $rules) === false) { + throw new CaseValidationException( + message: sprintf('%s must be a list of group ids, `user:` or `role:` entries.', $where) + ); + } + }//end validateRuleList() + + /** + * Whether a value is a list of non-empty strings. + * + * @param mixed $value The value. + * + * @return boolean True for a (possibly empty) list of non-empty strings. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-cmmn-notation-is-not-adopted-and-bpmn-remains-a-format + */ + private function isStringList(mixed $value): bool { + if (is_array($value) === false) { + return false; + } + + foreach ($value as $entry) { + if (is_string($entry) === false || trim($entry) === '') { + return false; + } + } + + return true; + }//end isStringList() + + /** + * A trimmed string, or null for empty. + * + * @param mixed $value The value. + * + * @return string|null The string or null. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-cmmn-notation-is-not-adopted-and-bpmn-remains-a-format + */ + private function stringOrNull(mixed $value): ?string { + if ($value === null || is_scalar($value) === false) { + return null; + } + + $text = trim((string)$value); + if ($text === '') { + return null; + } + + return $text; + }//end stringOrNull() + + /** + * An array, or null for empty. + * + * @param mixed $value The value. + * + * @return array|null The array or null. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-cmmn-notation-is-not-adopted-and-bpmn-remains-a-format + */ + private function listOrNull(mixed $value): ?array { + if (is_array($value) === false || $value === []) { + return null; + } + + return $value; + }//end listOrNull() + + /** + * A date, or null; an unparseable one is refused naming the field. + * + * @param mixed $value The value. + * @param string $field The field, for the message. + * + * @return DateTime|null The date or null. + * + * @throws CaseValidationException When unparseable. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function dateOrNull(mixed $value, string $field): ?DateTime { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof DateTime) { + return $value; + } + + try { + return new DateTime((string)$value); + } catch (Throwable) { + throw new CaseValidationException(message: sprintf("'%s' is not a date: %s", $field, (string)$value)); + } + }//end dateOrNull() +}//end class diff --git a/lib/Service/Case/CasePlanService.php b/lib/Service/Case/CasePlanService.php new file mode 100644 index 0000000000..c908d43dd5 --- /dev/null +++ b/lib/Service/Case/CasePlanService.php @@ -0,0 +1,775 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Db\CaseItemAuditMapper; +use OCA\OpenRegister\Db\CaseItemMapper; +use OCA\OpenRegister\Exception\CaseAccessDeniedException; +use OCA\OpenRegister\Exception\CaseValidationException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Reads, creates, transitions, enables, attaches, completes and deletes. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One method per verb the + * spec names plus the three reads and the three event entry points. Folding + * verbs into a mode parameter is how per-verb authorization gets lost. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The service owns the + * transaction across the mapper, the machine, the cascade, the evaluator, + * the authorization, the anchor reader, the writer and the compiler; that + * IS its job description. + * @SuppressWarnings(PHPMD.ExcessiveClassLength) Scales with the verb count; + * each verb is short and single-purpose. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of the verbs' + * guards: authorize, then precondition, then the conditional write. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) The constructor names its + * collaborators one by one; hiding them behind a locator would hide the + * dependency direction this change exists to enforce. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ +class CasePlanService { + + /** + * Constructor. + * + * @param CaseItemMapper $items The plan-item table. + * @param CaseItemAuditMapper $audits The append-only audit. + * @param CasePlanStateMachine $machine The one transition path. + * @param CasePlanCascade $cascade The bounded fixpoint. + * @param CaseSentryEvaluator $sentries Entry criteria for the enableable query. + * @param CasePlanAuthorizationService $authorization Fail-closed decisions. + * @param CaseAnchorReader $anchor Read visibility and the anchor's data. + * @param CaseBusinessStateWriter $writer Result write-through. + * @param CasePlanDefinition $definitions Validates and compiles definitions. + * @param IDBConnection $db Holds the creation transactions. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly CaseItemMapper $items, + private readonly CaseItemAuditMapper $audits, + private readonly CasePlanStateMachine $machine, + private readonly CasePlanCascade $cascade, + private readonly CaseSentryEvaluator $sentries, + private readonly CasePlanAuthorizationService $authorization, + private readonly CaseAnchorReader $anchor, + private readonly CaseBusinessStateWriter $writer, + private readonly CasePlanDefinition $definitions, + private readonly IDBConnection $db, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The plan of one object: every item with its state, type and parent, + * plus the audit trail. Needs no run uuid. + * + * @param string $objectUuid The anchoring object. + * @param string|null $uid The reading identity. + * + * @return array objectUuid, items, audit, settings. + * + * @throws DoesNotExistException When there is no plan, OR the caller may not see the object. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + public function getPlan(string $objectUuid, ?string $uid): array { + $rows = $this->visibleRows(objectUuid: $objectUuid, uid: $uid); + $ids = []; + foreach ($rows as $row) { + $ids[] = (int)$row->getId(); + } + + return [ + 'objectUuid' => $objectUuid, + 'settings' => (new CasePlanTree(items: $rows))->settings(), + 'items' => array_map(static fn (CaseItem $row): array => $row->jsonSerialize(), $rows), + 'audit' => array_map(static fn (CaseItemAudit $entry): array => $entry->jsonSerialize(), $this->audits->findForItems(caseItemIds: $ids)), + ]; + }//end getPlan() + + /** + * Create a plan on an object from a definition, then evaluate it. + * + * Validation is at THIS boundary (unknown event, invalid if-part, unknown + * type, duplicate key), before anything is written. The caller must hold + * the plan's root authorization: nobody may create a plan they could not + * administer. + * + * @param string $objectUuid The anchoring object. + * @param int|null $registerId Its register. + * @param int|null $schemaId Its schema. + * @param array $definition The definition (`settings`, `items`). + * @param string|null $uid The creating identity. + * @param string|null $flowUuid Definition provenance, when any. + * @param int|null $flowVersion Definition provenance, when any. + * + * @return array The plan as {@see getPlan()} returns it. + * + * @throws CaseValidationException When the definition is refused, or the object already has a plan. + * @throws CaseAccessDeniedException When the caller may not administer it. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function createPlan( + string $objectUuid, + ?int $registerId, + ?int $schemaId, + array $definition, + ?string $uid, + ?string $flowUuid = null, + ?int $flowVersion = null, + ): array { + $normalised = $this->definitions->validate(definition: $definition); + $this->authorization->assertMayAdminister(verb: 'create-plan', settings: $normalised['settings'], uid: $uid); + + if ($this->items->findByObject(objectUuid: $objectUuid) !== []) { + throw new CaseValidationException(message: sprintf('Object %s already has a case plan; delete it before creating another.', $objectUuid)); + } + + $this->transactional( + mutation: function () use ($normalised, $objectUuid, $registerId, $schemaId, $uid, $flowUuid, $flowVersion): void { + $this->insertNodes( + nodes: $normalised['items'], + objectUuid: $objectUuid, + registerId: $registerId, + schemaId: $schemaId, + parentId: null, + settings: $normalised['settings'], + actor: $uid, + flowUuid: $flowUuid, + flowVersion: $flowVersion + ); + } + ); + + $this->cascade->evaluate(objectUuid: $objectUuid, actor: $uid); + + return $this->getPlan(objectUuid: $objectUuid, uid: $uid); + }//end createPlan() + + /** + * A user-driven transition of one item (terminate a stage, reach a + * milestone by hand, complete a work item). + * + * @param string $itemUuid The item. + * @param string $to The target state. + * @param string|null $uid The acting identity. + * @param string|null $reason Free text. + * + * @return CaseItem The item as persisted. + * + * @throws CaseAccessDeniedException When denied (audited). + * @throws \OCA\OpenRegister\Exception\CaseTransitionException When illegal. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + public function transition(string $itemUuid, string $to, ?string $uid, ?string $reason = null): CaseItem { + $item = $this->items->findByUuid(uuid: $itemUuid); + $tree = $this->treeFor(item: $item); + $this->authorizeOrRecord(verb: 'transition', item: $item, tree: $tree, uid: $uid, requested: $to); + + if (in_array($to, CaseItem::STATES, true) === false) { + throw new CaseValidationException(message: sprintf("'%s' is not a plan-item state.", $to)); + } + + $persisted = $this->machine->transition( + item: $item, + to: $to, + cause: CaseItemAudit::CAUSE_USER, + causeRef: null, + actor: $uid, + reason: $reason, + tree: $tree + ); + $this->cascade->evaluate(objectUuid: (string)$item->getObjectUuid(), actor: $uid); + + return $this->items->findByUuid(uuid: (string)$persisted->getUuid()); + }//end transition() + + /** + * Enable a discretionary item: the explicit act it waits for. + * + * Refused unless the item is discretionary, `available`, under an active + * parent, and its entry criteria are satisfied. Authorized against the + * item's effective rules before anything is written; the denial is audited. + * + * @param string $itemUuid The item. + * @param string|null $uid The acting identity. + * + * @return CaseItem The item after enabling and evaluation (normally `active`). + * + * @throws CaseAccessDeniedException When denied (audited). + * @throws CaseValidationException When not enableable. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function enableDiscretionary(string $itemUuid, ?string $uid): CaseItem { + $item = $this->items->findByUuid(uuid: $itemUuid); + $tree = $this->treeFor(item: $item); + $this->authorizeOrRecord(verb: 'enable', item: $item, tree: $tree, uid: $uid, requested: CaseItem::STATE_ENABLED); + + if ($this->isEnableable(item: $item, tree: $tree) === false) { + throw new CaseValidationException( + message: sprintf( + "Plan item '%s' is not enableable: it must be discretionary, available, under an active parent, with its entry criteria satisfied.", + (string)$item->getItemKey() + ) + ); + } + + $this->machine->transition( + item: $item, + to: CaseItem::STATE_ENABLED, + cause: CaseItemAudit::CAUSE_USER, + causeRef: 'enable', + actor: $uid, + reason: null, + tree: $tree + ); + $this->cascade->evaluate(objectUuid: (string)$item->getObjectUuid(), actor: $uid); + + return $this->items->findByUuid(uuid: $itemUuid); + }//end enableDiscretionary() + + /** + * Attach an ad-hoc item to a live case: work that appears in no definition. + * + * Authorization derives from the parent stage (or the plan root); the item + * cannot declare its own. Creating one modifies no flow definition and + * creates no definition version: it is a row. + * + * @param string $objectUuid The anchoring object. + * @param array $data The item (`key`, `type`, `name`, `parent` uuid, criteria, candidates, deadlines). + * @param string|null $uid The acting identity. + * + * @return CaseItem The item after attachment and evaluation. + * + * @throws CaseAccessDeniedException When denied (audited on the parent, when any). + * @throws CaseValidationException When the item is refused. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function attachAdHoc(string $objectUuid, array $data, ?string $uid): CaseItem { + $rows = $this->items->findByObject(objectUuid: $objectUuid); + if ($rows === []) { + throw new DoesNotExistException(sprintf('Object %s has no case plan to attach to.', $objectUuid)); + } + + $tree = new CasePlanTree(items: $rows); + $parent = $this->parentFor(data: $data, tree: $tree); + $this->authorizeOrRecord(verb: 'attach', item: $parent, tree: $tree, uid: $uid, requested: CaseItem::STATE_AVAILABLE); + + $node = $this->definitions->validateAdHoc(node: $data); + foreach ($tree->rowsForKey(key: (string)$node['key']) as $existing) { + if ($existing !== null) { + throw new CaseValidationException(message: sprintf("Item key '%s' already exists in this plan.", (string)$node['key'])); + } + } + + if ($parent !== null && $parent->getState() !== CaseItem::STATE_ACTIVE) { + throw new CaseValidationException( + message: sprintf("Stage '%s' is not active; an ad-hoc item is attached to a live stage.", (string)$parent->getItemKey()) + ); + } + + $first = $rows[0]; + $inserted = $this->transactional( + mutation: function () use ($node, $objectUuid, $first, $parent, $tree, $uid): CaseItem { + $row = $this->definitions->rowFrom( + node: $node, + objectUuid: $objectUuid, + registerId: $first->getRegisterId(), + schemaId: $first->getSchemaId(), + parentId: $parent?->getId(), + position: count($tree->children(parentId: $parent?->getId())), + settings: $tree->settings(), + origin: CaseItem::ORIGIN_ADHOC, + actor: $uid + ); + $persisted = $this->items->insert($row); + $this->machine->recordCreation(item: $persisted, cause: CaseItemAudit::CAUSE_USER, causeRef: 'attach', actor: $uid); + + return $persisted; + } + ); + + $this->cascade->evaluate(objectUuid: $objectUuid, actor: $uid); + + return $this->items->findByUuid(uuid: (string)$inserted->getUuid()); + }//end attachAdHoc() + + /** + * Which discretionary items may be enabled right now: discretionary, + * `available`, parent active, entry criteria satisfied. + * + * @param string $objectUuid The anchoring object. + * @param string|null $uid The reading identity. + * + * @return array> The enableable items. + * + * @throws DoesNotExistException When there is no plan or the caller may not see it. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function enableableItems(string $objectUuid, ?string $uid): array { + $rows = $this->visibleRows(objectUuid: $objectUuid, uid: $uid); + $tree = new CasePlanTree(items: $rows); + $enableable = []; + foreach ($rows as $row) { + if ($this->isEnableable(item: $row, tree: $tree) === true) { + $enableable[] = $row->jsonSerialize(); + } + } + + return $enableable; + }//end enableableItems() + + /** + * "Which cases are stuck where": items by type and state, paged, with the + * total computed in the datastore. Administrators only: it spans cases. + * + * @param string|null $type The plan-item type filter. + * @param string|null $state The state filter. + * @param int $limit Page size. + * @param int $offset Page offset. + * @param string|null $uid The reading identity. + * + * @return array results, total, limit, offset. + * + * @throws CaseAccessDeniedException For a non-administrator. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function findStuck(?string $type, ?string $state, int $limit, int $offset, ?string $uid): array { + $identity = $this->authorization->assertIdentified(uid: $uid, verb: 'list-items'); + if ($this->authorization->isAdministrator(uid: $identity) === false) { + throw new CaseAccessDeniedException(message: "Verb 'list-items' denied: listing plan items across cases is an administrator's read."); + } + + return [ + 'results' => array_map( + static fn (CaseItem $row): array => $row->jsonSerialize(), + $this->items->findByTypeAndState(type: $type, state: $state, limit: $limit, offset: $offset) + ), + 'total' => $this->items->countByTypeAndState(type: $type, state: $state), + 'limit' => $limit, + 'offset' => $offset, + ]; + }//end findStuck() + + /** + * Re-evaluate a plan: idempotent, derives only from facts already + * recorded. Readable-by-the-caller is the check, since the verb chooses + * nothing. + * + * @param string $objectUuid The anchoring object. + * @param string|null $uid The acting identity. + * + * @return array{passes: int, transitions: int, skipped: bool} What happened. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function evaluate(string $objectUuid, ?string $uid): array { + $this->visibleRows(objectUuid: $objectUuid, uid: $uid); + + return $this->cascade->evaluate(objectUuid: $objectUuid, actor: $uid); + }//end evaluate() + + /** + * Finish the case with a result: refused outside the plan's constrained + * end-state set (naming the set) and while a required root item is open; + * otherwise mirrored onto the object through the ordinary write path. + * + * @param string $objectUuid The anchoring object. + * @param string $result The result. + * @param string|null $uid The acting identity. + * + * @return array The plan, plus `result`. + * + * @throws CaseValidationException When the result is outside the set, or the plan is not finished. + * @throws CaseAccessDeniedException When the caller may not administer the plan. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + public function completeCase(string $objectUuid, string $result, ?string $uid): array { + $rows = $this->items->findByObject(objectUuid: $objectUuid); + if ($rows === []) { + throw new DoesNotExistException(sprintf('Object %s has no case plan.', $objectUuid)); + } + + $tree = new CasePlanTree(items: $rows); + $settings = $tree->settings(); + $this->authorization->assertMayAdminister(verb: 'complete-case', settings: $settings, uid: $uid); + + $allowed = ($settings['results'] ?? []); + if (is_array($allowed) === false) { + $allowed = []; + } + + if (in_array($result, $allowed, true) === false) { + throw new CaseValidationException( + message: sprintf("Result '%s' is not in the case's allowed set [%s].", $result, implode(', ', $allowed)) + ); + } + + foreach ($tree->children(parentId: null) as $root) { + if ($root->getRequired() === true && $tree->isItemTerminal(item: $root) === false) { + throw new CaseValidationException( + message: sprintf("The case cannot finish while required item '%s' is '%s'.", (string)$root->getItemKey(), (string)$root->getState()) + ); + } + } + + $this->writer->mirrorResult(anyRow: $rows[0], result: $result); + $plan = $this->getPlan(objectUuid: $objectUuid, uid: $uid); + $plan['result'] = $result; + + return $plan; + }//end completeCase() + + /** + * Delete a plan's items. The audit stays; the mirrored business state on + * the object is not touched. + * + * @param string $objectUuid The anchoring object. + * @param string|null $uid The acting identity. + * + * @return int Rows deleted. + * + * @throws CaseAccessDeniedException When the caller may not administer the plan. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + public function deletePlan(string $objectUuid, ?string $uid): int { + $rows = $this->items->findByObject(objectUuid: $objectUuid); + if ($rows === []) { + throw new DoesNotExistException(sprintf('Object %s has no case plan.', $objectUuid)); + } + + $this->authorization->assertMayAdminister(verb: 'delete-plan', settings: (new CasePlanTree(items: $rows))->settings(), uid: $uid); + + return $this->items->deleteByObject(objectUuid: $objectUuid); + }//end deletePlan() + + /** + * A task reached a terminal state: evaluate the plan of the item it realised. + * + * @param string $taskUuid The task. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + public function onRealisationTerminal(string $taskUuid): void { + $objects = []; + foreach ($this->items->findByRealisation(realisationUuid: $taskUuid) as $row) { + $objects[(string)$row->getObjectUuid()] = true; + } + + foreach (array_keys($objects) as $objectUuid) { + $this->evaluateQuietly(objectUuid: (string)$objectUuid, event: null, payload: []); + } + }//end onRealisationTerminal() + + /** + * An object changed: evaluate its plan, if it has a live one, with the + * event in hand so object on-parts can fire. + * + * @param string $objectUuid The object. + * @param string $event The catalog event id. + * @param array $payload The object's data after the change. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ + public function onObjectEvent(string $objectUuid, string $event, array $payload): void { + if ($this->items->countOpenByObject(objectUuid: $objectUuid) === 0) { + return; + } + + $this->evaluateQuietly(objectUuid: $objectUuid, event: $event, payload: $payload); + }//end onObjectEvent() + + /** + * Whether an item is enableable now. + * + * @param CaseItem $item The item. + * @param CasePlanTree $tree The plan. + * + * @return boolean True when discretionary, available, parent active, entry satisfied. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function isEnableable(CaseItem $item, CasePlanTree $tree): bool { + if ($item->getDiscretionary() !== true || $item->getState() !== CaseItem::STATE_AVAILABLE) { + return false; + } + + if ($tree->isParentActive(item: $item) === false) { + return false; + } + + $object = $this->anchor->read( + objectUuid: (string)$item->getObjectUuid(), + registerId: $item->getRegisterId(), + schemaId: $item->getSchemaId() + ); + + return $this->sentries->entrySentry(item: $item, tree: $tree, object: $object) !== null; + }//end isEnableable() + + /** + * The plan's rows, only if the caller may read the anchoring object. + * + * @param string $objectUuid The object. + * @param string|null $uid The reading identity. + * + * @return array The rows. + * + * @throws DoesNotExistException When there is no plan, or it is invisible (same answer, deliberately). + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + private function visibleRows(string $objectUuid, ?string $uid): array { + $rows = $this->items->findByObject(objectUuid: $objectUuid); + if ($rows === []) { + throw new DoesNotExistException(sprintf('Object %s has no case plan.', $objectUuid)); + } + + $first = $rows[0]; + $visible = $this->authorization->isAdministrator(uid: $uid) + || ($uid !== null && $this->anchor->mayRead( + objectUuid: $objectUuid, + registerId: $first->getRegisterId(), + schemaId: $first->getSchemaId() + ) === true); + if ($visible === false) { + throw new DoesNotExistException(sprintf('Object %s has no case plan.', $objectUuid)); + } + + return $rows; + }//end visibleRows() + + /** + * The plan an item belongs to. + * + * @param CaseItem $item The item. + * + * @return CasePlanTree The tree. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + private function treeFor(CaseItem $item): CasePlanTree { + return new CasePlanTree(items: $this->items->findByObject(objectUuid: (string)$item->getObjectUuid())); + }//end treeFor() + + /** + * The parent stage an ad-hoc item names, or null for the plan root. + * + * @param array $data The submitted item. + * @param CasePlanTree $tree The plan. + * + * @return CaseItem|null The parent stage. + * + * @throws CaseValidationException When the named parent is not a stage of this plan. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function parentFor(array $data, CasePlanTree $tree): ?CaseItem { + $ref = trim((string)($data['parent'] ?? '')); + if ($ref === '') { + return null; + } + + foreach ($tree->all() as $row) { + if ($row->getUuid() === $ref || $row->getItemKey() === $ref) { + if ($row->getPlanItemType() !== CaseItem::TYPE_STAGE) { + throw new CaseValidationException(message: sprintf("'%s' is not a stage; an ad-hoc item is attached to a stage or to the plan root.", $ref)); + } + + return $row; + } + } + + throw new CaseValidationException(message: sprintf("No stage '%s' exists in this plan.", $ref)); + }//end parentFor() + + /** + * Authorize a verb on an item (or the root), and AUDIT a denial before + * rethrowing it. + * + * @param string $verb The verb. + * @param CaseItem|null $item The item, or null for the root. + * @param CasePlanTree $tree The plan. + * @param string|null $uid The acting identity. + * @param string $requested The requested state, for the audit row. + * + * @return void + * + * @throws CaseAccessDeniedException The original denial, always rethrown. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + private function authorizeOrRecord(string $verb, ?CaseItem $item, CasePlanTree $tree, ?string $uid, string $requested): void { + try { + $this->authorization->assertMayAct(verb: $verb, item: $item, tree: $tree, uid: $uid); + } catch (CaseAccessDeniedException $denial) { + if ($item !== null) { + $this->machine->recordDenial(item: $item, to: $requested, actor: $uid, reason: $denial->getMessage()); + } + + throw $denial; + } + }//end authorizeOrRecord() + + /** + * Insert a level of normalised nodes and recurse into stages' children. + * + * @param array> $nodes The nodes. + * @param string $objectUuid The anchor. + * @param int|null $registerId Its register. + * @param int|null $schemaId Its schema. + * @param int|null $parentId The containing stage's row id. + * @param array $settings The plan settings. + * @param string|null $actor The creating identity. + * @param string|null $flowUuid Provenance. + * @param int|null $flowVersion Provenance. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + private function insertNodes( + array $nodes, + string $objectUuid, + ?int $registerId, + ?int $schemaId, + ?int $parentId, + array $settings, + ?string $actor, + ?string $flowUuid, + ?int $flowVersion, + ): void { + foreach ($nodes as $position => $node) { + $origin = CaseItem::ORIGIN_DEFINED; + if (($node['discretionary'] ?? false) === true) { + $origin = CaseItem::ORIGIN_DISCRETIONARY; + } + + $row = $this->definitions->rowFrom( + node: $node, + objectUuid: $objectUuid, + registerId: $registerId, + schemaId: $schemaId, + parentId: $parentId, + position: (int)$position, + settings: $settings, + origin: $origin, + actor: $actor, + flowUuid: $flowUuid, + flowVersion: $flowVersion + ); + $persisted = $this->items->insert($row); + $this->machine->recordCreation(item: $persisted, cause: CaseItemAudit::CAUSE_IMPORT, causeRef: $flowUuid, actor: $actor); + + $this->insertNodes( + nodes: ($node['children'] ?? []), + objectUuid: $objectUuid, + registerId: $registerId, + schemaId: $schemaId, + parentId: (int)$persisted->getId(), + settings: $settings, + actor: $actor, + flowUuid: $flowUuid, + flowVersion: $flowVersion + ); + } + }//end insertNodes() + + /** + * Evaluate from a listener: a failure is logged, never rethrown into the + * event that caused it. + * + * @param string $objectUuid The object. + * @param string|null $event The event, when any. + * @param array $payload Its payload. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + private function evaluateQuietly(string $objectUuid, ?string $event, array $payload): void { + try { + $this->cascade->evaluate(objectUuid: $objectUuid, event: $event, payload: $payload); + } catch (Throwable $failure) { + $this->logger->error( + '[CasePlanService] Event-driven evaluation failed: ' . $failure->getMessage(), + ['object' => $objectUuid, 'event' => $event, 'exception' => $failure] + ); + } + }//end evaluateQuietly() + + /** + * Run a mutation in one transaction. + * + * @param callable $mutation The mutation. + * + * @return mixed The mutation's result. + * + * @throws Throwable Whatever the mutation threw, after rollback. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + private function transactional(callable $mutation): mixed { + $this->db->beginTransaction(); + try { + $result = $mutation(); + $this->db->commit(); + + return $result; + } catch (Throwable $failure) { + $this->db->rollBack(); + throw $failure; + } + }//end transactional() +}//end class diff --git a/lib/Service/Case/CasePlanStateMachine.php b/lib/Service/Case/CasePlanStateMachine.php new file mode 100644 index 0000000000..44780074d5 --- /dev/null +++ b/lib/Service/Case/CasePlanStateMachine.php @@ -0,0 +1,446 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use DateTime; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Db\CaseItemAuditMapper; +use OCA\OpenRegister\Db\CaseItemMapper; +use OCA\OpenRegister\Event\CaseItemTransitionedEvent; +use OCA\OpenRegister\Exception\CaseTransitionException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The one write path for plan-item state. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The machine owns the + * transaction across two mappers, the table, the realiser, the writer and + * the dispatcher; that is its job description. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ +class CasePlanStateMachine { + + /** + * The actor recorded for transitions nobody asked for by hand. + */ + public const SYSTEM_ACTOR = 'case-plan'; + + /** + * Events waiting for the outermost transaction to commit. + * + * @var array + */ + private array $pending = []; + + /** + * Constructor. + * + * @param CaseItemMapper $items The plan-item table. + * @param CaseItemAuditMapper $audits The append-only audit. + * @param CasePlanTransitions $table The lifecycle table. + * @param CaseRealisationService $realiser Creates and closes realisations. + * @param CaseBusinessStateWriter $writer Mirrors a milestone onto the object. + * @param IDBConnection $db Holds the one transaction per transition. + * @param LoggerInterface $logger Failure reporting. + * @param IEventDispatcher|null $dispatcher Announces committed transitions. + */ + public function __construct( + private readonly CaseItemMapper $items, + private readonly CaseItemAuditMapper $audits, + private readonly CasePlanTransitions $table, + private readonly CaseRealisationService $realiser, + private readonly CaseBusinessStateWriter $writer, + private readonly IDBConnection $db, + private readonly LoggerInterface $logger, + private readonly ?IEventDispatcher $dispatcher = null, + ) { + + }//end __construct() + + /** + * Move one item, with everything that entails, in one transaction. + * + * @param CaseItem $item The item, as read. + * @param string $to The target state. + * @param string $cause sentry | user | realisation | cascade | import. + * @param string|null $causeRef The sentry id, task/run uuid or parent uuid. + * @param string|null $actor The acting identity, or null for the system. + * @param string|null $reason Free text; becomes `terminated_reason` on a termination. + * @param CasePlanTree|null $tree The plan, when the caller has it (for the cascade). + * + * @return CaseItem The item as persisted. + * + * @throws CaseTransitionException When illegal, or when another mover won. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + public function transition( + CaseItem $item, + string $to, + string $cause, + ?string $causeRef, + ?string $actor, + ?string $reason = null, + ?CasePlanTree $tree = null, + ): CaseItem { + $this->table->assertLegal(item: $item, to: $to); + + $outermost = $this->db->inTransaction() === false; + $this->db->beginTransaction(); + try { + $persisted = $this->apply(item: $item, to: $to, cause: $cause, causeRef: $causeRef, actor: $actor, reason: $reason, tree: $tree); + $this->db->commit(); + } catch (Throwable $failure) { + $this->db->rollBack(); + if ($outermost === true) { + $this->pending = []; + } + + throw $failure; + } + + if ($outermost === true) { + $this->flushEvents(); + } + + return $persisted; + }//end transition() + + /** + * Record a refused attempt. Mutates nothing; appended outside any verb + * transaction, and a failure to record it is logged, not converted. + * + * @param CaseItem $item The item acted on. + * @param string $to The requested state (or the verb, for enable/attach). + * @param string|null $actor The acting identity. + * @param string $reason The denial message. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function recordDenial(CaseItem $item, string $to, ?string $actor, string $reason): void { + if ($item->getId() === null) { + return; + } + + try { + $this->appendAudit( + item: $item, + fromState: (string)$item->getState(), + toState: $to, + cause: CaseItemAudit::CAUSE_USER, + causeRef: null, + actor: $actor, + reason: $reason, + authorized: false + ); + } catch (Throwable $failure) { + $this->logger->warning( + '[CasePlanStateMachine] Could not record an authorization denial: ' . $failure->getMessage(), + ['item' => $item->getUuid()] + ); + } + }//end recordDenial() + + /** + * Audit a row's creation: `from_state` null, `to_state` its initial state. + * Called inside the creator's transaction, right after the insert. + * + * @param CaseItem $item The inserted item. + * @param string $cause import | user | realisation (a repetition). + * @param string|null $causeRef The definition, the actor's verb, or the previous realisation. + * @param string|null $actor The acting identity. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + public function recordCreation(CaseItem $item, string $cause, ?string $causeRef, ?string $actor): void { + $this->appendAudit( + item: $item, + fromState: '', + toState: (string)$item->getState(), + cause: $cause, + causeRef: $causeRef, + actor: ($actor ?? self::SYSTEM_ACTOR), + reason: null, + authorized: true + ); + }//end recordCreation() + + /** + * Dispatch every committed transition's event. Called by the outermost + * committer; a nested caller's events wait here for it. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function flushEvents(): void { + $events = $this->pending; + $this->pending = []; + if ($this->dispatcher === null) { + return; + } + + foreach ($events as $event) { + try { + $this->dispatcher->dispatchTyped($event); + } catch (Throwable $failure) { + $this->logger->warning( + '[CasePlanStateMachine] A plan-item event listener failed; the transition itself is unaffected: ' . $failure->getMessage(), + ['item' => $event->getItem()->getUuid(), 'exception' => $failure] + ); + } + } + }//end flushEvents() + + /** + * Forget queued events after a rolled-back outer transaction. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function discardEvents(): void { + $this->pending = []; + }//end discardEvents() + + /** + * The body of a transition, inside the transaction. + * + * @param CaseItem $item The item, as read. + * @param string $to The target state. + * @param string $cause The cause. + * @param string|null $causeRef The cause reference. + * @param string|null $actor The acting identity. + * @param string|null $reason Free text. + * @param CasePlanTree|null $tree The plan, when known. + * + * @return CaseItem The persisted item. + * + * @throws CaseTransitionException When another mover won the row. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + private function apply( + CaseItem $item, + string $to, + string $cause, + ?string $causeRef, + ?string $actor, + ?string $reason, + ?CasePlanTree $tree, + ): CaseItem { + $from = (string)$item->getState(); + $identity = ($actor ?? self::SYSTEM_ACTOR); + $this->stamp(item: $item, to: $to, reason: $reason, actor: $identity); + + // THE one place `state` and `is_terminal` change, in the same statement. + $item->setState($to); + $item->setIsTerminal($this->table->isTerminal(state: $to)); + + if ($this->items->updateIfState(item: $item, expectedState: $from) === false) { + throw new CaseTransitionException( + message: sprintf("Plan item '%s' was moved concurrently out of '%s'; this transition was not applied.", (string)$item->getUuid(), $from) + ); + } + + $this->appendAudit( + item: $item, + fromState: $from, + toState: $to, + cause: $cause, + causeRef: $causeRef, + actor: $identity, + reason: $reason, + authorized: true + ); + + $this->propagate(item: $item, cause: $cause, reason: $reason); + $this->pending[] = new CaseItemTransitionedEvent(item: $item, fromState: $from); + + if ($item->getPlanItemType() === CaseItem::TYPE_STAGE && $item->isInTerminalState() === true) { + $this->cascade(stage: $item, tree: $tree); + } + + return $item; + }//end apply() + + /** + * The side stamps of entering a state: realise and stamp `entered_at` on + * activation, stamp `entered_at` on a milestone's completion, record the + * reason on a termination. + * + * @param CaseItem $item The item. + * @param string $to The target state. + * @param string|null $reason Free text. + * @param string $actor The acting identity the realisation is created by. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + private function stamp(CaseItem $item, string $to, ?string $reason, string $actor): void { + if ($to === CaseItem::STATE_ACTIVE) { + $this->realiser->realise(item: $item, actor: $actor); + $item->setEnteredAt(new DateTime()); + } + + if ($to === CaseItem::STATE_COMPLETED && $item->getEnteredAt() === null) { + $item->setEnteredAt(new DateTime()); + } + + if ($to === CaseItem::STATE_TERMINATED) { + $item->setTerminatedReason($reason); + } + }//end stamp() + + /** + * What a committed transition tells the world outside the row: an exited + * work item closes its realisation (UNLESS the realisation is what ended + * it: it is already closed, and terminating a completed task would be the + * drift the one-directional rule forbids), and a reached milestone is + * mirrored onto the object. + * + * @param CaseItem $item The item, already in its new state. + * @param string $cause The cause. + * @param string|null $reason Free text. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + private function propagate(CaseItem $item, string $cause, ?string $reason): void { + $hasRealisation = trim((string)$item->getRealisationUuid()) !== ''; + if ($item->isInTerminalState() === true && $cause !== CaseItemAudit::CAUSE_REALISATION && $hasRealisation === true) { + $this->realiser->terminate( + item: $item, + reason: ($reason ?? sprintf("Plan item '%s' reached '%s'.", (string)$item->getItemKey(), (string)$item->getState())) + ); + } + + if ($item->getState() === CaseItem::STATE_COMPLETED && $item->getPlanItemType() === CaseItem::TYPE_MILESTONE) { + $this->writer->mirrorStatus(milestone: $item); + } + }//end propagate() + + /** + * Stage exit: non-terminal entered children are terminated, unentered + * children disabled, each individually audited with the stage as cause. + * Nested stages cascade in turn through the same method. + * + * @param CaseItem $stage The exited stage. + * @param CasePlanTree|null $tree The plan, or null to read the children now. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + private function cascade(CaseItem $stage, ?CasePlanTree $tree): void { + if ($tree === null) { + $tree = new CasePlanTree(items: $this->items->findByObject(objectUuid: (string)$stage->getObjectUuid())); + } + + $reason = sprintf("Stage '%s' exited to '%s'.", (string)$stage->getItemKey(), (string)$stage->getState()); + foreach ($tree->children(parentId: (int)$stage->getId()) as $child) { + if ($child->isInTerminalState() === true) { + continue; + } + + $target = CaseItem::STATE_DISABLED; + if ($child->isEntered() === true) { + $target = CaseItem::STATE_TERMINATED; + } + + if ($child->getPlanItemType() === CaseItem::TYPE_MILESTONE) { + // A milestone has no `disabled` edge: an unreached one is terminated. + $target = CaseItem::STATE_TERMINATED; + } + + $this->apply( + item: $child, + to: $target, + cause: CaseItemAudit::CAUSE_CASCADE, + causeRef: (string)$stage->getUuid(), + actor: self::SYSTEM_ACTOR, + reason: $reason, + tree: $tree + ); + } + }//end cascade() + + /** + * Append one audit row. + * + * @param CaseItem $item The item. + * @param string $fromState The state before. + * @param string $toState The state after (or requested). + * @param string $cause The cause. + * @param string|null $causeRef The cause reference. + * @param string|null $actor The acting identity. + * @param string|null $reason Free text. + * @param bool $authorized False on a denial. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + private function appendAudit( + CaseItem $item, + string $fromState, + string $toState, + string $cause, + ?string $causeRef, + ?string $actor, + ?string $reason, + bool $authorized, + ): void { + $entry = new CaseItemAudit(); + $entry->setCaseItemId((int)$item->getId()); + $entry->setFromState($fromState); + $entry->setToState($toState); + $entry->setCause($cause); + $entry->setCauseRef($causeRef); + $entry->setActor($actor); + $entry->setReason($reason); + $entry->setAuthorized($authorized); + $this->audits->insert($entry); + }//end appendAudit() +}//end class diff --git a/lib/Service/Case/CasePlanTransitions.php b/lib/Service/Case/CasePlanTransitions.php new file mode 100644 index 0000000000..8c9be0de2d --- /dev/null +++ b/lib/Service/Case/CasePlanTransitions.php @@ -0,0 +1,174 @@ + completed` + * and `available -> terminated`, because a milestone performs no work and so + * has nothing to be `enabled` or `active` during. Presence in this table is + * the ONLY definition of legality; a transition absent from it is refused + * naming all four facts (item, type, from, to) and is never coerced to a + * legal neighbour. + * + * Pure and stateless: injected as a collaborator so the state machine and + * its tests share one table rather than two copies that drift. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseTransitionException; + +/** + * The exhaustive per-type edge table and the terminal set. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ +class CasePlanTransitions { + + /** + * The edges a stage or a human task may take. No edge leaves a terminal + * state, and no edge is a self-loop. + * + * @var array> + */ + private const WORK_ITEM_EDGES = [ + CaseItem::STATE_AVAILABLE => [ + CaseItem::STATE_ENABLED, + CaseItem::STATE_ACTIVE, + CaseItem::STATE_DISABLED, + CaseItem::STATE_TERMINATED, + ], + CaseItem::STATE_ENABLED => [ + CaseItem::STATE_ACTIVE, + CaseItem::STATE_DISABLED, + CaseItem::STATE_TERMINATED, + ], + CaseItem::STATE_ACTIVE => [ + CaseItem::STATE_COMPLETED, + CaseItem::STATE_TERMINATED, + ], + CaseItem::STATE_COMPLETED => [], + CaseItem::STATE_TERMINATED => [], + CaseItem::STATE_DISABLED => [], + ]; + + /** + * A milestone's two edges: the asymmetry the reference keeps at + * `PlanItemTransitions.php:80-83`. + * + * @var array> + */ + private const MILESTONE_EDGES = [ + CaseItem::STATE_AVAILABLE => [ + CaseItem::STATE_COMPLETED, + CaseItem::STATE_TERMINATED, + ], + CaseItem::STATE_ENABLED => [], + CaseItem::STATE_ACTIVE => [], + CaseItem::STATE_COMPLETED => [], + CaseItem::STATE_TERMINATED => [], + CaseItem::STATE_DISABLED => [], + ]; + + /** + * The table, keyed by plan-item type. + * + * @var array>> + */ + private const TABLE = [ + CaseItem::TYPE_STAGE => self::WORK_ITEM_EDGES, + CaseItem::TYPE_HUMAN_TASK => self::WORK_ITEM_EDGES, + CaseItem::TYPE_MILESTONE => self::MILESTONE_EDGES, + ]; + + /** + * The legal target states of a type from a state. + * + * @param string $type The plan-item type. + * @param string $from The current state. + * + * @return array The legal targets; empty for a terminal state or an unknown type. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + public function targetsFor(string $type, string $from): array { + return (self::TABLE[$type][$from] ?? []); + }//end targetsFor() + + /** + * Whether an edge is in the table. + * + * @param string $type The plan-item type. + * @param string $from The current state. + * @param string $to The requested state. + * + * @return boolean True only for an edge present in the table. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + public function isLegal(string $type, string $from, string $to): bool { + return in_array($to, $this->targetsFor(type: $type, from: $from), true); + }//end isLegal() + + /** + * Refuse an edge absent from the table, naming all four facts. + * + * @param CaseItem $item The item being moved. + * @param string $to The requested state. + * + * @return void + * + * @throws CaseTransitionException Naming item, type, from-state and to-state. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + public function assertLegal(CaseItem $item, string $to): void { + $type = (string)$item->getPlanItemType(); + $from = (string)$item->getState(); + if ($this->isLegal(type: $type, from: $from, to: $to) === true) { + return; + } + + throw new CaseTransitionException( + message: sprintf( + "Plan item '%s' (%s) cannot move from '%s' to '%s': no such transition exists in the lifecycle table.", + (string)$item->getUuid(), + $type, + $from, + $to + ) + ); + }//end assertLegal() + + /** + * Whether a state is terminal for every type. + * + * @param string $state The state. + * + * @return boolean True for completed, terminated and disabled. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-one-lifecycle-table-governs-every-plan-item + */ + public function isTerminal(string $state): bool { + return in_array($state, CaseItem::TERMINAL_STATES, true); + }//end isTerminal() +}//end class diff --git a/lib/Service/Case/CasePlanTree.php b/lib/Service/Case/CasePlanTree.php new file mode 100644 index 0000000000..1f7bfdaa5a --- /dev/null +++ b/lib/Service/Case/CasePlanTree.php @@ -0,0 +1,372 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; + +/** + * Structural reads over one plan's rows. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One method per structural + * question the evaluator, the completion rule and the authorization ask; + * the tree is a read-only query vocabulary, like a mapper. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ +class CasePlanTree { + + /** + * Rows by id. + * + * @var array + */ + private array $byId = []; + + /** + * Row ids by parent id (0 for the root). + * + * @var array> + */ + private array $childIds = []; + + /** + * Row ids by item key, in realisation order. + * + * @var array> + */ + private array $keyRows = []; + + /** + * Constructor. + * + * @param array $items The plan's rows. + */ + public function __construct(array $items) { + foreach ($items as $item) { + $id = (int)$item->getId(); + $this->byId[$id] = $item; + $this->childIds[(int)($item->getParentItemId() ?? 0)][] = $id; + $this->keyRows[(string)$item->getItemKey()][] = $id; + } + + }//end __construct() + + /** + * Every row. + * + * @return array The rows, keyed by id. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + public function all(): array { + return $this->byId; + }//end all() + + /** + * A row by id. + * + * @param int|null $id The row id. + * + * @return CaseItem|null The row, or null. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function byId(?int $id): ?CaseItem { + if ($id === null) { + return null; + } + + return ($this->byId[$id] ?? null); + }//end byId() + + /** + * The direct children of a stage (or the roots for null). + * + * @param int|null $parentId The stage's row id, or null for the plan root. + * + * @return array The children. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function children(?int $parentId): array { + $rows = []; + foreach (($this->childIds[(int)($parentId ?? 0)] ?? []) as $id) { + $rows[] = $this->byId[$id]; + } + + return $rows; + }//end children() + + /** + * Every descendant of a stage, depth first. + * + * @param int $parentId The stage's row id. + * + * @return array The descendants. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function descendants(int $parentId): array { + $rows = []; + foreach ($this->children(parentId: $parentId) as $child) { + $rows[] = $child; + foreach ($this->descendants(parentId: (int)$child->getId()) as $grandchild) { + $rows[] = $grandchild; + } + } + + return $rows; + }//end descendants() + + /** + * The parent of a row, or null at the root. + * + * @param CaseItem $item The row. + * + * @return CaseItem|null The parent stage. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function parentOf(CaseItem $item): ?CaseItem { + return $this->byId(id: $item->getParentItemId()); + }//end parentOf() + + /** + * A row's ancestors, nearest first. + * + * @param CaseItem $item The row. + * + * @return array The ancestors. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function ancestors(CaseItem $item): array { + $chain = []; + $current = $this->parentOf(item: $item); + $guard = 0; + while ($current !== null && $guard < 1000) { + $chain[] = $current; + $current = $this->parentOf(item: $current); + $guard++; + } + + return $chain; + }//end ancestors() + + /** + * Whether a child may become actionable: its parent is `active`, or it + * has no parent. + * + * @param CaseItem $item The row. + * + * @return boolean True when the containing stage is active or absent. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function isParentActive(CaseItem $item): bool { + $parent = $this->parentOf(item: $item); + if ($parent === null) { + return $item->getParentItemId() === null; + } + + return $parent->getState() === CaseItem::STATE_ACTIVE; + }//end isParentActive() + + /** + * Every realisation row of one item key. + * + * @param string $key The item key. + * + * @return array The rows, oldest first. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function rowsForKey(string $key): array { + $rows = []; + foreach (($this->keyRows[$key] ?? []) as $id) { + $rows[] = $this->byId[$id]; + } + + return $rows; + }//end rowsForKey() + + /** + * Whether any realisation of a key is in a state. Terminal states are + * monotonic, so for those "has occurred" and "is currently so" coincide. + * + * @param string $key The item key. + * @param string $state The state. + * + * @return boolean True when some row of that key is in that state. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function keyHasState(string $key, string $state): bool { + foreach ($this->rowsForKey(key: $key) as $row) { + if ($row->getState() === $state) { + return true; + } + } + + return false; + }//end keyHasState() + + /** + * Whether an ITEM (all realisations of a key) is terminal: every row is + * terminal AND the repetition rule is exhausted. + * + * @param CaseItem $item Any row of the item. + * + * @return boolean True when nothing of that key can still move. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function isItemTerminal(CaseItem $item): bool { + $rows = $this->rowsForKey(key: (string)$item->getItemKey()); + $latest = $item; + foreach ($rows as $row) { + if ($row->isInTerminalState() === false) { + return false; + } + + if ((int)$row->getRealisationCount() >= (int)$latest->getRealisationCount()) { + $latest = $row; + } + } + + return $this->repetitionExhausted(item: $latest); + }//end isItemTerminal() + + /** + * Whether a row's repetition rule allows no further realisation. + * + * A row without a rule is exhausted after its one realisation. A row + * that ended other than `completed` is exhausted too: a terminated or + * disabled item does not repeat. + * + * @param CaseItem $item The latest row of the item. + * + * @return boolean True when no further realisation may be created. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function repetitionExhausted(CaseItem $item): bool { + $rule = $item->getRepetition(); + if (is_array($rule) === false || isset($rule['max']) === false) { + return true; + } + + if ($item->getState() !== CaseItem::STATE_COMPLETED) { + return true; + } + + return (int)$item->getRealisationCount() >= (int)$rule['max']; + }//end repetitionExhausted() + + /** + * The stage-completion rule: every REQUIRED child item is terminal AND no + * child row is `active` AND at least one required child exists. + * + * The last clause is the `$mandatoryFound` guard of the reference + * (`PlanItemTree.php:98-117`): a stage with only optional children must + * NOT auto-complete on activation, and the only thing separating "all + * required children are terminal" from "there are no required children" + * is that flag. + * + * @param CaseItem $stage The stage. + * + * @return boolean True when the stage may auto-complete. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + */ + public function stageMayComplete(CaseItem $stage): bool { + $mandatoryFound = false; + foreach ($this->children(parentId: (int)$stage->getId()) as $child) { + if ($child->getState() === CaseItem::STATE_ACTIVE) { + return false; + } + + if ($child->getRequired() !== true) { + continue; + } + + $mandatoryFound = true; + if ($this->isItemTerminal(item: $child) === false) { + return false; + } + } + + return $mandatoryFound; + }//end stageMayComplete() + + /** + * The state of every key: the sentry document's `case.items` map. For a + * repeating item the latest realisation's state is reported. + * + * @return array key => state. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function stateMap(): array { + $map = []; + foreach ($this->keyRows as $key => $ids) { + $latest = null; + foreach ($ids as $id) { + $row = $this->byId[$id]; + if ($latest === null || (int)$row->getRealisationCount() >= (int)$latest->getRealisationCount()) { + $latest = $row; + } + } + + if ($latest !== null) { + $map[(string)$key] = (string)$latest->getState(); + } + } + + return $map; + }//end stateMap() + + /** + * The plan-level settings, carried on every row and frozen at creation. + * + * @return array The settings; empty for a plan with none. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + */ + public function settings(): array { + foreach ($this->byId as $row) { + $settings = $row->getPlanSettings(); + if (is_array($settings) === true && $settings !== []) { + return $settings; + } + } + + return []; + }//end settings() +}//end class diff --git a/lib/Service/Case/CaseRealisationService.php b/lib/Service/Case/CaseRealisationService.php new file mode 100644 index 0000000000..4cef792d74 --- /dev/null +++ b/lib/Service/Case/CaseRealisationService.php @@ -0,0 +1,348 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\AppFramework\Db\DoesNotExistException; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Creates, reads and closes realisations. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ +class CaseRealisationService { + + /** + * The trigger name a stage-queued run carries. + */ + public const RUN_TRIGGER = 'case'; + + /** + * Constructor. + * + * @param TaskService $tasks The task lifecycle (its trusted creation path). + * @param TaskMapper $taskRows The task table, for the terminal-outcome read. + * @param FlowRunService $runs The one queue funnel. + * @param FlowRunMapper $runRows The run table, for the terminal-outcome read. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly TaskService $tasks, + private readonly TaskMapper $taskRows, + private readonly FlowRunService $runs, + private readonly FlowRunMapper $runRows, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Realise an item that is entering `active`: set `realisation_kind` and + * `realisation_uuid` on the (unsaved) row. + * + * @param CaseItem $item The item entering active. + * @param string $actor The identity the realisation is created by. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + public function realise(CaseItem $item, string $actor): void { + if ($item->getPlanItemType() === CaseItem::TYPE_HUMAN_TASK) { + $task = $this->tasks->import(data: $this->taskDataFor(item: $item, actor: $actor), actor: $actor); + $item->setRealisationKind(CaseItem::REALISATION_TASK); + $item->setRealisationUuid($task->getUuid()); + + return; + } + + $flow = trim((string)($item->getPlanSettings()['flows'][(string)$item->getItemKey()] ?? '')); + if ($item->getPlanItemType() === CaseItem::TYPE_STAGE && $flow !== '') { + $run = $this->runs->queue( + flowId: $flow, + subject: [ + 'uuid' => (string)$item->getObjectUuid(), + 'register' => (string)$item->getRegisterId(), + 'schema' => (string)$item->getSchemaId(), + ], + trigger: self::RUN_TRIGGER, + context: ['caseItem' => (string)$item->getUuid()], + user: $actor + ); + $item->setRealisationKind(CaseItem::REALISATION_RUN); + $item->setRealisationUuid($run->getUuid()); + + return; + } + + $item->setRealisationKind(CaseItem::REALISATION_NONE); + $item->setRealisationUuid(null); + }//end realise() + + /** + * How a realisation ended, if it has: the outcome that drives the item. + * + * @param CaseItem $item An active item with a realisation. + * + * @return string|null `completed` | `terminated` when the realisation is + * terminal; null while it is open, absent, or unreadable. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + public function terminalOutcome(CaseItem $item): ?string { + $uuid = trim((string)$item->getRealisationUuid()); + if ($uuid === '') { + return null; + } + + try { + if ($item->getRealisationKind() === CaseItem::REALISATION_TASK) { + $task = $this->taskRows->findByUuid(uuid: $uuid); + + return $this->outcomeOfTask(task: $task); + } + + if ($item->getRealisationKind() === CaseItem::REALISATION_RUN) { + $run = $this->runRows->findByUuid(uuid: $uuid); + + return $this->outcomeOfRun(run: $run); + } + } catch (DoesNotExistException) { + // A realisation that no longer exists cannot complete anything; + // it is terminated work. + return CaseItem::STATE_TERMINATED; + } + + return null; + }//end terminalOutcome() + + /** + * Close an open realisation because its item was exited or cascaded. + * + * The ONLY write the plan item ever makes to its realisation. A task is + * terminated as moot with the reason; a run has no public stop verb, so + * its terminal status stays the engine's decision and the omission is + * logged rather than faked. + * + * @param CaseItem $item The exited item. + * @param string $reason Why, recorded on the task and its audit. + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + public function terminate(CaseItem $item, string $reason): void { + $uuid = trim((string)$item->getRealisationUuid()); + if ($uuid === '') { + return; + } + + $source = sprintf('case-item:%s', (string)$item->getUuid()); + if ($item->getRealisationKind() === CaseItem::REALISATION_TASK) { + try { + $this->tasks->terminateAsMoot(uuid: $uuid, reason: $reason, source: $source); + } catch (DoesNotExistException) { + // Nothing left to terminate. + return; + } catch (Throwable $failure) { + $this->logger->warning( + '[CaseRealisationService] Could not terminate a task realisation: ' . $failure->getMessage(), + ['item' => $item->getUuid(), 'task' => $uuid] + ); + throw $failure; + } + + return; + } + + if ($item->getRealisationKind() === CaseItem::REALISATION_RUN) { + $this->logger->info( + sprintf('[CaseRealisationService] Plan item %s exited while run %s is open; the run keeps the engine\'s status.', (string)$item->getUuid(), $uuid) + ); + } + }//end terminate() + + /** + * The task a human plan item becomes, in TaskBuilder's vocabulary. + * + * Carries the anchor triple, the candidates, the deadline values and the + * carried zaaktype terms (as metadata, for flow-business-timers). The + * task starts `enabled` (pooled) when it has candidates and `available` + * otherwise; it is never born assigned by the case layer. + * + * @param CaseItem $item The human plan item. + * @param string $actor The creating identity. + * + * @return array The task data. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + public function taskDataFor(CaseItem $item, string $actor): array { + $users = ($item->getCandidateUsers() ?? []); + $groups = ($item->getCandidateGroups() ?? []); + $role = trim((string)$item->getCandidateRole()); + + $data = [ + 'title' => (string)($item->getName() ?? $item->getItemKey()), + 'description' => $item->getDescription(), + 'state' => $this->initialState(users: $users, groups: $groups, role: $role), + 'performerType' => $this->performerType(users: $users, groups: $groups), + 'requester' => (string)($item->getCreatedBy() ?? $actor), + 'objectUuid' => $item->getObjectUuid(), + 'registerId' => $item->getRegisterId(), + 'schemaId' => $item->getSchemaId(), + 'appId' => 'openregister', + 'metadata' => [ + 'caseItem' => $item->getUuid(), + 'caseItemKey' => $item->getItemKey(), + 'realisationCount' => $item->getRealisationCount(), + 'doorlooptijd' => $item->getDoorlooptijd(), + 'servicenorm' => $item->getServicenorm(), + ], + ]; + + if ($users !== []) { + $data['candidateUsers'] = array_values($users); + } + + if ($groups !== []) { + $data['candidateGroups'] = array_values($groups); + } + + if ($role !== '') { + $data['candidateRole'] = $role; + } + + if ($item->getDueAt() !== null) { + $data['dueAt'] = $item->getDueAt()->format('c'); + } + + if ($item->getExpiresAt() !== null) { + $data['expiresAt'] = $item->getExpiresAt()->format('c'); + } + + return $data; + }//end taskDataFor() + + /** + * A task with candidates starts pooled (`enabled`); one without starts + * `available`. The case layer never assigns. + * + * @param array $users Candidate uids. + * @param array $groups Candidate group ids. + * @param string $role Candidate role. + * + * @return string The initial task state. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + private function initialState(array $users, array $groups, string $role): string { + if ($users !== [] || $groups !== [] || $role !== '') { + return Task::STATE_ENABLED; + } + + return Task::STATE_AVAILABLE; + }//end initialState() + + /** + * Groups-only candidates make a group task; anything else a user task. + * + * @param array $users Candidate uids. + * @param array $groups Candidate group ids. + * + * @return string The performer type. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + private function performerType(array $users, array $groups): string { + if ($users === [] && $groups !== []) { + return Task::PERFORMER_GROUP; + } + + return Task::PERFORMER_USER; + }//end performerType() + + /** + * A task's terminal outcome as a plan-item state. + * + * @param Task $task The task. + * + * @return string|null `completed` | `terminated` | null while open. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + private function outcomeOfTask(Task $task): ?string { + if ($task->isInTerminalState() === false) { + return null; + } + + if ($task->getState() === Task::STATE_COMPLETED) { + return CaseItem::STATE_COMPLETED; + } + + return CaseItem::STATE_TERMINATED; + }//end outcomeOfTask() + + /** + * A run's terminal outcome as a plan-item state. + * + * @param FlowRun $run The run. + * + * @return string|null `completed` | `terminated` | null while active. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + */ + private function outcomeOfRun(FlowRun $run): ?string { + $status = (string)$run->getStatus(); + if (in_array($status, FlowRun::TERMINAL, true) === false) { + return null; + } + + if ($status === FlowRun::STATUS_COMPLETED) { + return CaseItem::STATE_COMPLETED; + } + + return CaseItem::STATE_TERMINATED; + }//end outcomeOfRun() +}//end class diff --git a/lib/Service/Case/CaseSentryEvaluator.php b/lib/Service/Case/CaseSentryEvaluator.php new file mode 100644 index 0000000000..e876e1f508 --- /dev/null +++ b/lib/Service/Case/CaseSentryEvaluator.php @@ -0,0 +1,407 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseValidationException; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use OCA\OpenRegister\Service\Flow\FlowExpression; +use OCA\OpenRegister\Service\Flow\FlowItems; + +/** + * Evaluates and validates entry and exit criteria. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowExpression is the engine's + * stateless expression facade; calling it statically IS the reuse. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ +class CaseSentryEvaluator { + + /** + * The cause_ref recorded when an item enters on empty entry criteria. + */ + public const DEFAULT_ENTRY = 'entry:default'; + + /** + * The three plan-item events; the catalog carries the same three. + * + * @var array event id => plan-item state. + */ + public const ITEM_EVENTS = [ + 'case.item.completed' => CaseItem::STATE_COMPLETED, + 'case.item.terminated' => CaseItem::STATE_TERMINATED, + 'case.item.disabled' => CaseItem::STATE_DISABLED, + ]; + + /** + * Constructor. + * + * @param EventCatalogService $catalog The closed event catalog. + */ + public function __construct( + private readonly EventCatalogService $catalog, + ) { + + }//end __construct() + + /** + * Refuse a criteria array the editor should not have accepted. + * + * A sentry naming an event outside `knownTriggerIds()` is refused naming + * the event; an if-part that {@see FlowExpression::isValid()} rejects is + * refused naming the sentry; a plan-item event without an `item` is + * refused. Save time, not run time: the editor fails, not the case. + * + * @param mixed $criteria The stored criteria (a list of sentries). + * @param string $where Which criteria, for the message (`entry`/`exit` of a key). + * + * @return void + * + * @throws CaseValidationException On the first refused sentry. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function validateCriteria(mixed $criteria, string $where): void { + if ($criteria === null) { + return; + } + + if (is_array($criteria) === false) { + throw new CaseValidationException(message: sprintf('%s criteria must be a list of sentries.', $where)); + } + + $known = $this->catalog->knownTriggerIds(); + foreach ($criteria as $index => $sentry) { + $this->validateSentry(sentry: $sentry, label: sprintf('%s sentry #%d', $where, (int)$index + 1), known: $known); + } + }//end validateCriteria() + + /** + * Save-time check of one sentry. + * + * @param mixed $sentry The sentry as stored. + * @param string $label Which sentry, for the message. + * @param array $known The catalog's known trigger ids. + * + * @return void + * + * @throws CaseValidationException On the first refused part. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function validateSentry(mixed $sentry, string $label, array $known): void { + if (is_array($sentry) === false) { + throw new CaseValidationException(message: sprintf('%s must be an object with an on-part and/or an if-part.', $label)); + } + + if (array_key_exists('on', $sentry) === false && array_key_exists('if', $sentry) === false) { + throw new CaseValidationException(message: sprintf('%s has neither an on-part nor an if-part.', $label)); + } + + if (array_key_exists('on', $sentry) === true) { + $this->validateOnPart(onPart: $sentry['on'], label: $label, known: $known); + } + + if (array_key_exists('if', $sentry) === true && FlowExpression::isValid(logic: $sentry['if']) === false) { + throw new CaseValidationException(message: sprintf('%s has an if-part that is not a valid expression.', $label)); + } + }//end validateSentry() + + /** + * Which entry sentry admits an item now, if any. + * + * Empty entry criteria mean "satisfied as soon as the parent is active"; + * the parent check is the CALLER's (the tree knows the parent), this + * method answers the criteria alone. + * + * @param CaseItem $item The item. + * @param CasePlanTree $tree The plan. + * @param array $object The anchoring object's data. + * @param string|null $event The event being handled, or null. + * @param array $payload The event's payload. + * + * @return string|null The admitting sentry's id, {@see DEFAULT_ENTRY}, or null when none fires. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function entrySentry(CaseItem $item, CasePlanTree $tree, array $object, ?string $event = null, array $payload = []): ?string { + $criteria = ($item->getEntryCriteria() ?? []); + if ($criteria === []) { + return self::DEFAULT_ENTRY; + } + + return $this->firingSentry(criteria: $criteria, tree: $tree, object: $object, event: $event, payload: $payload); + }//end entrySentry() + + /** + * Which exit sentry exits an item now, if any. Empty exit criteria never + * fire. + * + * @param CaseItem $item The item. + * @param CasePlanTree $tree The plan. + * @param array $object The anchoring object's data. + * @param string|null $event The event being handled, or null. + * @param array $payload The event's payload. + * + * @return string|null The firing sentry's id, or null. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function exitSentry(CaseItem $item, CasePlanTree $tree, array $object, ?string $event = null, array $payload = []): ?string { + $criteria = ($item->getExitCriteria() ?? []); + if ($criteria === []) { + return null; + } + + return $this->firingSentry(criteria: $criteria, tree: $tree, object: $object, event: $event, payload: $payload); + }//end exitSentry() + + /** + * The document an if-part is evaluated against: `dataFor()`'s shape + * extended with a `case` key. Additive, so a flow expression author + * already knows every other key. + * + * @param CasePlanTree $tree The plan. + * @param array $object The anchoring object's data. + * @param string|null $event The event being handled. + * @param array $payload The event's payload. + * + * @return array The data document. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + public function dataFor(CasePlanTree $tree, array $object, ?string $event, array $payload): array { + $data = FlowExpression::dataFor( + item: [FlowItems::JSON => $object], + context: [ + 'event' => $event, + 'payload' => $payload, + ], + subject: $object + ); + $data['case'] = [ + 'items' => $tree->stateMap(), + 'object' => $object, + 'event' => $event, + 'payload' => $payload, + ]; + + return $data; + }//end dataFor() + + /** + * OR across the array: the first sentry that fires names itself. + * + * @param array $criteria The sentries. + * @param CasePlanTree $tree The plan. + * @param array $object The anchoring object's data. + * @param string|null $event The event being handled. + * @param array $payload The event's payload. + * + * @return string|null The firing sentry's id, or null. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function firingSentry(array $criteria, CasePlanTree $tree, array $object, ?string $event, array $payload): ?string { + $data = $this->dataFor(tree: $tree, object: $object, event: $event, payload: $payload); + foreach ($criteria as $index => $sentry) { + if (is_array($sentry) === false) { + continue; + } + + if ($this->fires(sentry: $sentry, tree: $tree, data: $data, event: $event) === true) { + return (string)($sentry['id'] ?? ('sentry:' . ((int)$index + 1))); + } + } + + return null; + }//end firingSentry() + + /** + * AND within a sentry. A malformed sentry (neither part, or an if-part + * naming no field) never fires. + * + * @param array $sentry The sentry. + * @param CasePlanTree $tree The plan. + * @param array $data The evaluation document. + * @param string|null $event The event being handled. + * + * @return boolean True when both present parts hold. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function fires(array $sentry, CasePlanTree $tree, array $data, ?string $event): bool { + $hasOn = array_key_exists('on', $sentry); + $hasIf = array_key_exists('if', $sentry); + if ($hasOn === false && $hasIf === false) { + return false; + } + + if ($hasOn === true && $this->onPartOccurred(onPart: $sentry['on'], tree: $tree, event: $event) === false) { + return false; + } + + if ($hasIf === true) { + if ($this->namesField(logic: $sentry['if']) === false) { + return false; + } + + return FlowExpression::isTrue(logic: $sentry['if'], data: $data); + } + + return true; + }//end fires() + + /** + * Whether an on-part has occurred: a plan-item event against current + * state (monotonic), any other event against the one being handled. + * + * @param mixed $onPart The on-part: `{event, item?}`. + * @param CasePlanTree $tree The plan. + * @param string|null $event The event being handled. + * + * @return boolean True when it has. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function onPartOccurred(mixed $onPart, CasePlanTree $tree, ?string $event): bool { + if (is_array($onPart) === false) { + return false; + } + + $name = trim((string)($onPart['event'] ?? '')); + if ($name === '') { + return false; + } + + if (array_key_exists($name, self::ITEM_EVENTS) === true) { + $key = trim((string)($onPart['item'] ?? '')); + if ($key === '') { + return false; + } + + return $tree->keyHasState(key: $key, state: self::ITEM_EVENTS[$name]); + } + + return $event !== null && in_array($event, $this->catalog->aliasesFor(dispatched: $name), true); + }//end onPartOccurred() + + /** + * Save-time check of one on-part. + * + * @param mixed $onPart The on-part. + * @param string $label The sentry, for the message. + * @param array $known The catalog's known trigger ids. + * + * @return void + * + * @throws CaseValidationException Naming the unknown event or the missing item. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function validateOnPart(mixed $onPart, string $label, array $known): void { + if (is_array($onPart) === false || trim((string)($onPart['event'] ?? '')) === '') { + throw new CaseValidationException(message: sprintf('%s has an on-part without an event.', $label)); + } + + $name = trim((string)$onPart['event']); + if (in_array($name, $known, true) === false) { + throw new CaseValidationException( + message: sprintf("%s names event '%s', which is not in the event catalog.", $label, $name) + ); + } + + if (array_key_exists($name, self::ITEM_EVENTS) === true && trim((string)($onPart['item'] ?? '')) === '') { + throw new CaseValidationException( + message: sprintf("%s names plan-item event '%s' without naming the item.", $label, $name) + ); + } + }//end validateOnPart() + + /** + * Whether an if-part is a rule that reads at least one field: an operator + * object (not a list literal, which JSONLogic evaluates to itself and + * would fire on being non-empty) with `{"var": ...}` somewhere inside. + * A literal `true` names no field and is malformed by the spec's rule. + * + * @param mixed $logic The if-part. + * + * @return boolean True when it is a rule object containing a `var`. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function namesField(mixed $logic): bool { + if (is_array($logic) === false || $logic === [] || array_is_list($logic) === true) { + return false; + } + + return $this->containsVar(logic: $logic); + }//end namesField() + + /** + * Whether a `var` operator occurs anywhere in an expression tree. + * + * @param mixed $logic Any node of the expression. + * + * @return boolean True when a `var` key occurs at any depth. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + */ + private function containsVar(mixed $logic): bool { + if (is_array($logic) === false) { + return false; + } + + if (array_key_exists('var', $logic) === true) { + return true; + } + + foreach ($logic as $child) { + if ($this->containsVar(logic: $child) === true) { + return true; + } + } + + return false; + }//end containsVar() +}//end class diff --git a/lib/Service/Case/ZaaktypeCaseSkeletonMapper.php b/lib/Service/Case/ZaaktypeCaseSkeletonMapper.php new file mode 100644 index 0000000000..1957d257a6 --- /dev/null +++ b/lib/Service/Case/ZaaktypeCaseSkeletonMapper.php @@ -0,0 +1,550 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; + +/** + * zaaktype -> draft skeleton + report. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ +class ZaaktypeCaseSkeletonMapper { + + /** + * Report statuses. + */ + public const MAPPED = 'mapped'; + + public const APPROXIMATE = 'approximate'; + + public const CARRIED = 'carried'; + + public const UNMAPPED = 'unmapped'; + + /** + * Zaaktype elements the mapping consumes. + * + * @var array + */ + private const HANDLED = ['statustypen', 'roltypen', 'resultaattypen', 'doorlooptijd', 'servicenorm']; + + /** + * Identity elements that describe the zaaktype itself and are carried as + * the plan's name, not reported as unmapped. + * + * @var array + */ + private const IDENTITY = [ + 'url', + 'uuid', + 'identificatie', + 'omschrijving', + 'omschrijvingGeneriek', + 'catalogus', + 'versiedatum', + 'beginGeldigheid', + 'eindeGeldigheid', + 'concept', + ]; + + /** + * Produce the draft skeleton and the report. + * + * @param array $zaaktype The zaaktype document as stored. + * + * @return array{draft: bool, definition: array, report: array>} The result. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + public function map(array $zaaktype): array { + $report = []; + $settings = [ + 'name' => (string)($zaaktype['omschrijving'] ?? $zaaktype['identificatie'] ?? 'zaaktype'), + 'source' => ['zaaktype' => (string)($zaaktype['url'] ?? $zaaktype['identificatie'] ?? '')], + 'writeThrough' => [ + 'statusField' => 'status', + 'statusAtField' => 'statusReachedAt', + 'resultField' => 'resultaat', + 'resultAtField' => 'resultaatReachedAt', + ], + ]; + $report[] = $this->entry( + element: 'authorization', + status: self::UNMAPPED, + reason: 'No zaaktype element says who may administer the plan.', + action: 'Declare `settings.authorization` (groups, user:, role:) before publishing; until then only administrators may act.' + ); + $report[] = $this->entry( + element: 'writeThrough', + status: self::APPROXIMATE, + reason: 'Field names `status`, `statusReachedAt`, `resultaat`, `resultaatReachedAt` were assumed for the write-through.', + action: 'Check them against the zaak schema; rename or remove the mapping.' + ); + + $behandelaar = $this->mapRoles(zaaktype: $zaaktype, settings: $settings, report: $report); + $this->mapResults(zaaktype: $zaaktype, settings: $settings, report: $report); + $terms = $this->mapTerms(zaaktype: $zaaktype, settings: $settings, report: $report); + $items = $this->mapStatuses(zaaktype: $zaaktype, behandelaar: $behandelaar, terms: $terms, report: $report); + $this->reportRest(zaaktype: $zaaktype, report: $report); + + return [ + 'draft' => true, + 'definition' => ['settings' => $settings, 'items' => $items], + 'report' => $report, + ]; + }//end map() + + /** + * `statustypen` in `volgnummer` order -> milestones, each wrapped in a + * stage with one human item, chained in sequence. + * + * @param array $zaaktype The document. + * @param string|null $behandelaar The behandelaar role name, when any. + * @param array $terms doorlooptijd / servicenorm. + * @param array> $report The report (by reference). + * + * @return array> The items. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function mapStatuses(array $zaaktype, ?string $behandelaar, array $terms, array &$report): array { + $statuses = ($zaaktype['statustypen'] ?? []); + if (is_array($statuses) === false || $statuses === []) { + $report[] = $this->entry( + element: 'statustypen', + status: self::UNMAPPED, + reason: 'The zaaktype carries no statustypen, so no milestones could be produced.', + action: 'Add milestones by hand.' + ); + + return []; + } + + $items = []; + $previousMilestone = null; + foreach ($this->orderStatuses(statuses: $statuses, report: $report) as $position => $status) { + $stage = $this->stageFor(status: $status, number: $position + 1, behandelaar: $behandelaar, terms: $terms, previousMilestone: $previousMilestone); + $items[] = $stage; + $previousMilestone = $stage['children'][1]['key']; + $this->reportStatus(status: $status, stage: $stage, report: $report); + } + + return $items; + }//end mapStatuses() + + /** + * Numbered statuses in `volgnummer` order, unnumbered ones after them + * (reported), non-objects dropped (reported). + * + * @param array $statuses The statustypen as stored. + * @param array> $report The report (by reference). + * + * @return array> The ordered statuses. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function orderStatuses(array $statuses, array &$report): array { + $ordered = []; + $unnumbered = []; + foreach ($statuses as $index => $status) { + if (is_array($status) === false) { + $report[] = $this->entry( + element: 'statustypen[' . (int)$index . ']', + status: self::UNMAPPED, + reason: 'Not an object.', + action: 'Correct the source document.' + ); + continue; + } + + if (isset($status['volgnummer']) === false || is_numeric($status['volgnummer']) === false) { + $unnumbered[] = $status; + $report[] = $this->entry( + element: 'statustypen[' . (int)$index . ']', + status: self::APPROXIMATE, + reason: 'No `volgnummer`; the status was placed after every numbered one.', + action: 'Move the milestone to its place in the sequence.' + ); + continue; + } + + $ordered[] = $status; + } + + usort($ordered, static fn (array $a, array $b): int => (int)$a['volgnummer'] <=> (int)$b['volgnummer']); + + return array_merge($ordered, $unnumbered); + }//end orderStatuses() + + /** + * One status as a stage holding a placeholder human item and the milestone. + * + * @param array $status The statustype. + * @param int $number Its position in the sequence, from 1. + * @param string|null $behandelaar The behandelaar role, when any. + * @param array $terms doorlooptijd / servicenorm. + * @param string|null $previousMilestone The milestone key this stage waits for. + * + * @return array The stage node. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function stageFor(array $status, int $number, ?string $behandelaar, array $terms, ?string $previousMilestone): array { + $label = (string)($status['omschrijving'] ?? ('status ' . $number)); + $slug = $this->slug(text: $label); + $stageKey = sprintf('fase-%d-%s', $number, $slug); + $taskKey = sprintf('behandel-%d-%s', $number, $slug); + $milestoneKey = sprintf('status-%d-%s', $number, $slug); + + $task = [ + 'key' => $taskKey, + 'type' => CaseItem::TYPE_HUMAN_TASK, + 'name' => sprintf('Behandelen: %s', $label), + 'required' => true, + 'doorlooptijd' => $terms['doorlooptijd'], + 'servicenorm' => $terms['servicenorm'], + ]; + if ($behandelaar !== null) { + $task['candidateRole'] = $behandelaar; + } + + $entry = []; + if ($previousMilestone !== null) { + $entry = [['id' => $stageKey . ':entry', 'on' => ['event' => 'case.item.completed', 'item' => $previousMilestone]]]; + } + + return [ + 'key' => $stageKey, + 'type' => CaseItem::TYPE_STAGE, + 'name' => $label, + 'required' => true, + 'entryCriteria' => $entry, + 'children' => [ + $task, + [ + 'key' => $milestoneKey, + 'type' => CaseItem::TYPE_MILESTONE, + 'name' => $label, + 'required' => true, + 'entryCriteria' => [ + ['id' => $milestoneKey . ':entry', 'on' => ['event' => 'case.item.completed', 'item' => $taskKey]], + ], + ], + ], + ]; + }//end stageFor() + + /** + * Report the approximate wrapping of one status, and every attribute of + * it that has no counterpart. + * + * @param array $status The statustype. + * @param array $stage The stage it became. + * @param array> $report The report (by reference). + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function reportStatus(array $status, array $stage, array &$report): void { + $number = (string)($status['volgnummer'] ?? '-'); + $report[] = $this->entry( + element: sprintf('statustypen (volgnummer %s)', $number), + status: self::APPROXIMATE, + reason: sprintf( + "Milestone '%s' was wrapped in stage '%s' with one required human item '%s' so the sequence does not complete itself on import.", + (string)$stage['children'][1]['key'], + (string)$stage['key'], + (string)$stage['children'][0]['key'] + ), + action: 'Replace the placeholder human item with the real work of this phase, or drop the stage and give the milestone a real entry criterion.' + ); + + foreach (['statustekst', 'informeren', 'toelichting', 'doorlooptijd', 'checklistitemStatustype'] as $extra) { + if (isset($status[$extra]) === true && $status[$extra] !== '') { + $report[] = $this->entry( + element: sprintf('statustypen (volgnummer %s).%s', $number, $extra), + status: self::UNMAPPED, + reason: 'A statustype attribute with no counterpart on a milestone.', + action: 'Carry it in the milestone description if it matters to the caseworker.' + ); + } + } + }//end reportStatus() + + /** + * `roltypen` -> candidate roles; the behandelaar goes on the human items. + * + * @param array $zaaktype The document. + * @param array $settings The settings (by reference). + * @param array> $report The report (by reference). + * + * @return string|null The behandelaar role name, when one is declared. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function mapRoles(array $zaaktype, array &$settings, array &$report): ?string { + $roles = ($zaaktype['roltypen'] ?? []); + if (is_array($roles) === false || $roles === []) { + return null; + } + + $behandelaar = null; + $settings['candidateRoles'] = []; + foreach ($roles as $index => $role) { + if (is_array($role) === false) { + $report[] = $this->entry( + element: 'roltypen[' . (int)$index . ']', + status: self::UNMAPPED, + reason: 'Not an object.', + action: 'Correct the source document.', + ); + continue; + } + + $name = trim((string)($role['omschrijving'] ?? '')); + $generic = trim((string)($role['omschrijvingGeneriek'] ?? '')); + if ($name === '') { + $report[] = $this->entry( + element: 'roltypen[' . (int)$index . ']', + status: self::UNMAPPED, + reason: 'No `omschrijving`.', + action: 'Name the role.', + ); + continue; + } + + $settings['candidateRoles'][] = ['role' => $name, 'generic' => $generic]; + if ($generic === 'behandelaar' && $behandelaar === null) { + $behandelaar = $name; + $report[] = $this->entry( + element: sprintf("roltypen '%s'", $name), + status: self::MAPPED, + reason: 'The behandelaar role became the candidate role of every human item.', + action: sprintf("Make sure a group named '%s' exists, or the role will not resolve.", $name) + ); + continue; + } + + $report[] = $this->entry( + element: sprintf("roltypen '%s'", $name), + status: self::CARRIED, + reason: sprintf("Carried in settings.candidateRoles with generic designation '%s'; not placed on any item.", $generic), + action: 'Assign it as candidateRole to the items this role performs.' + ); + }//end foreach + + return $behandelaar; + }//end mapRoles() + + /** + * `resultaattypen` -> the constrained end-state set; archival terms carried. + * + * @param array $zaaktype The document. + * @param array $settings The settings (by reference). + * @param array> $report The report (by reference). + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function mapResults(array $zaaktype, array &$settings, array &$report): void { + $results = ($zaaktype['resultaattypen'] ?? []); + if (is_array($results) === false || $results === []) { + $report[] = $this->entry( + element: 'resultaattypen', + status: self::UNMAPPED, + reason: 'No resultaattypen, so the case has no constrained end-state set.', + action: 'Declare `settings.results` by hand, or any result will be refused.' + ); + + return; + } + + $settings['results'] = []; + $settings['resultMetadata'] = []; + foreach ($results as $index => $result) { + $name = ''; + if (is_array($result) === true) { + $name = trim((string)($result['omschrijving'] ?? '')); + } + + if ($name === '') { + $report[] = $this->entry( + element: 'resultaattypen[' . (int)$index . ']', + status: self::UNMAPPED, + reason: 'No `omschrijving`.', + action: 'Name the result.', + ); + continue; + } + + $settings['results'][] = $name; + $metadata = []; + foreach (['archiefnominatie', 'archiefactietermijn', 'brondatumArchiefprocedure', 'selectielijstklasse'] as $term) { + if (isset($result[$term]) === true) { + $metadata[$term] = $result[$term]; + } + } + + if ($metadata !== []) { + $settings['resultMetadata'][$name] = $metadata; + $report[] = $this->entry( + element: sprintf("resultaattypen '%s' archival terms", $name), + status: self::CARRIED, + reason: 'Archival terms are carried as metadata; the archival capabilities act on them, not the case layer.', + action: 'Nothing here; configure archival separately.' + ); + } + }//end foreach + + $report[] = $this->entry( + element: 'resultaattypen', + status: self::MAPPED, + reason: sprintf('%d result(s) became the constrained end-state set.', count($settings['results'])), + action: 'Completing the case with any other result is refused.' + ); + }//end mapResults() + + /** + * `doorlooptijd` / `servicenorm`: carried, not computed on. + * + * @param array $zaaktype The document. + * @param array $settings The settings (by reference). + * @param array> $report The report (by reference). + * + * @return array The two terms. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function mapTerms(array $zaaktype, array &$settings, array &$report): array { + $terms = []; + foreach (['doorlooptijd', 'servicenorm'] as $term) { + $value = trim((string)($zaaktype[$term] ?? '')); + $terms[$term] = null; + if ($value === '') { + continue; + } + + $terms[$term] = $value; + $settings[$term] = $value; + $report[] = $this->entry( + element: $term, + status: self::CARRIED, + reason: sprintf("'%s' is carried onto every human item and the settings; nothing computes on it here.", $value), + action: 'flow-business-timers acts on it; nothing to do in the case plan.' + ); + } + + return $terms; + }//end mapTerms() + + /** + * Everything not handled is reported, never dropped silently. + * + * @param array $zaaktype The document. + * @param array> $report The report (by reference). + * + * @return void + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function reportRest(array $zaaktype, array &$report): void { + foreach ($zaaktype as $element => $value) { + $name = (string)$element; + if (in_array($name, self::HANDLED, true) === true || in_array($name, self::IDENTITY, true) === true) { + continue; + } + + if ($value === null || $value === '' || $value === []) { + continue; + } + + $report[] = $this->entry( + element: $name, + status: self::UNMAPPED, + reason: 'The case-plan mapping has no counterpart for this zaaktype element.', + action: 'Decide whether it belongs on the zaak schema, on a plan item, or nowhere; it was not carried.' + ); + } + }//end reportRest() + + /** + * One report line. + * + * @param string $element What. + * @param string $status mapped | approximate | carried | unmapped. + * @param string $reason Why. + * @param string $action What the author should do. + * + * @return array The line. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function entry(string $element, string $status, string $reason, string $action): array { + return ['element' => $element, 'status' => $status, 'reason' => $reason, 'action' => $action]; + }//end entry() + + /** + * A key-safe slug of a label. + * + * @param string $text The label. + * + * @return string Lower-case letters, digits and dashes. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-zaaktype-maps-to-a-case-skeleton-and-reports-what-it-could-not-map + */ + private function slug(string $text): string { + $slug = strtolower(trim((string)preg_replace('/[^A-Za-z0-9]+/', '-', $text), '-')); + if ($slug === '') { + return 'status'; + } + + return substr($slug, 0, 60); + }//end slug() +}//end class diff --git a/lib/Service/Flow/EventCatalogService.php b/lib/Service/Flow/EventCatalogService.php index c768de62c6..fa251e50aa 100644 --- a/lib/Service/Flow/EventCatalogService.php +++ b/lib/Service/Flow/EventCatalogService.php @@ -66,6 +66,13 @@ class EventCatalogService { ['id' => 'share.deleted', 'label' => 'A share is deleted', 'group' => 'Share'], ['id' => 'tag.assigned', 'label' => 'A tag is assigned', 'group' => 'Tag'], ['id' => 'tag.unassigned', 'label' => 'A tag is removed', 'group' => 'Tag'], + // Plan-item lifecycle (flow-cmmn-case-semantics). Dispatched by the case + // layer as CaseItemTransitionedEvent against the anchoring object, so one + // plan item's outcome can satisfy another item's sentry or start a flow. + // Additive: aliasesFor() is unchanged and no row enters the trigger index. + ['id' => 'case.item.completed', 'label' => 'A plan item is completed', 'group' => 'Case'], + ['id' => 'case.item.terminated', 'label' => 'A plan item is terminated', 'group' => 'Case'], + ['id' => 'case.item.disabled', 'label' => 'A plan item is disabled', 'group' => 'Case'], ]; /** diff --git a/openspec/changes/flow-cmmn-case-semantics/tasks.md b/openspec/changes/flow-cmmn-case-semantics/tasks.md index 66611575c3..79a8870f72 100644 --- a/openspec/changes/flow-cmmn-case-semantics/tasks.md +++ b/openspec/changes/flow-cmmn-case-semantics/tasks.md @@ -2,13 +2,13 @@ ## 1. Storage -- [ ] 1.1 Migration creating `openregister_case_items` and +- [x] 1.1 Migration creating `openregister_case_items` and `openregister_case_item_audit` with the columns and indexes in design.md — Data model. Additive only: no existing table altered, no data backfilled, and `openregister_tasks` gains NO column. Verify neither table has an `overdue`, `days_until_due` or `days_overdue` column. -- [ ] 1.2 Entities + mappers under `lib/Db/`: `CaseItem`/`CaseItemMapper`, +- [x] 1.2 Entities + mappers under `lib/Db/`: `CaseItem`/`CaseItemMapper`, `CaseItemAudit`/`CaseItemAuditMapper`, following `lib/Db/FlowRun.php` conventions (docblock `@method` block, `@spec` tag, `@license EUPL-1.2` + `@copyright 2026 Conduction B.V.`, `jsonSerialize()`). @@ -18,14 +18,14 @@ ## 2. Plan-item lifecycle -- [ ] 2.1 `lib/Service/Case/CasePlanTransitions.php` — pure, stateless, +- [x] 2.1 `lib/Service/Case/CasePlanTransitions.php` — pure, stateless, injected as a collaborator: the six states, the three types, the exhaustive per-type edge table and the explicit terminal set, ported from `procest/lib/Service/Cmmn/PlanItemTransitions.php:41-97` including the milestone asymmetry (two edges only). `assertLegal()` throws naming item, type, from-state and to-state; a same-state "transition" is illegal. -- [ ] 2.2 `lib/Service/Case/CasePlanStateMachine.php` — one transition, +- [x] 2.2 `lib/Service/Case/CasePlanStateMachine.php` — one transition, `is_terminal` written in the same statement as `state`, audit row appended in the SAME transaction as the mutation (successes AND denials, `authorized: false`), and stage-exit cascade: non-terminal @@ -35,12 +35,12 @@ ## 3. Sentries -- [ ] 3.1 Append `case.item.completed`, `case.item.terminated`, +- [x] 3.1 Append `case.item.completed`, `case.item.terminated`, `case.item.disabled` to `EventCatalogService::CATALOG` (`lib/Service/Flow/EventCatalogService.php:52-69`) and emit them from the state machine. Purely additive: `aliasesFor()` behaviour unchanged and no row enters `openregister_flow_triggers`. -- [ ] 3.2 `lib/Service/Case/CaseSentryEvaluator.php` — AND within a sentry, +- [x] 3.2 `lib/Service/Case/CaseSentryEvaluator.php` — AND within a sentry, OR across the criteria array; on-part resolved against current plan-item state for the monotonic terminal events and against the event being handled otherwise; if-part evaluated ONLY through @@ -54,11 +54,11 @@ ## 4. Item kinds and completion rules -- [ ] 4.1 `lib/Service/Case/CasePlanCascade.php` — the fixpoint evaluation +- [x] 4.1 `lib/Service/Case/CasePlanCascade.php` — the fixpoint evaluation loop with a named bound (reference: `PlanItemCascade.php:64`, `MAX_CASCADE_DEPTH = 50`), failing loudly at the bound and rolling back rather than leaving a half-cascaded plan. -- [ ] 4.2 Stage semantics: nesting to arbitrary depth, a child never +- [x] 4.2 Stage semantics: nesting to arbitrary depth, a child never actionable while its parent is not `active`, and auto-completion when every REQUIRED child is terminal AND no child is `active` — including the `$mandatoryFound` guard so a stage with only optional children @@ -66,19 +66,19 @@ (`PlanItemTree.php:98-117`). Milestones land here too: no work performed, completion immediate on entry, and completion emits the catalog event so another item's sentry can consume it. -- [ ] 4.3 Repetition: a repeating plan item produces a new realisation per +- [x] 4.3 Repetition: a repeating plan item produces a new realisation per repetition while remaining ONE plan item, each realisation individually addressable via `realisation_count`; the item is terminal only when the rule is exhausted AND every realisation is terminal. ## 5. Realisation -- [ ] 5.1 A `humanTask` item entering `active` creates a task through the +- [x] 5.1 A `humanTask` item entering `active` creates a task through the task capability, carrying the case anchor triple, the candidate users/groups/role and the deadline values. The task's terminal outcome drives the item's terminal state; the item writes to the task ONLY to terminate it on exit or cascade. Nothing else in either direction. -- [ ] 5.2 A `stage` bound to a flow queues a run through +- [x] 5.2 A `stage` bound to a flow queues a run through `FlowRunService::queue()` (`lib/Service/Flow/FlowRunService.php:321`) against the flow's pinned published version; the run's terminal status drives the item. Assert by dependency direction that nothing under @@ -86,13 +86,13 @@ ## 6. Discretionary, ad-hoc and authorization -- [ ] 6.1 `lib/Service/Case/CasePlanAuthorizationService.php` — DECIDES +- [x] 6.1 `lib/Service/Case/CasePlanAuthorizationService.php` — DECIDES fail-closed before any write, denying on indeterminate (unresolvable role, unavailable group backend). No nullable "could not determine" return a caller can read as "check skipped". Contrast the reference, which returns a list for the REST layer to compare (`CaseModelEngine.php:244-268`). -- [ ] 6.2 Enable-a-discretionary-item and attach-an-ad-hoc-item verbs, plus +- [x] 6.2 Enable-a-discretionary-item and attach-an-ad-hoc-item verbs, plus the enableable-items query (discretionary, entry-satisfied, parent `active` — `CaseModelEngine.php:269-276`). An ad-hoc item derives its authorization from its parent stage or the plan root and CANNOT @@ -101,13 +101,13 @@ ## 7. Write-through and API -- [ ] 7.1 Business-state write-through: the anchoring object's status and +- [x] 7.1 Business-state write-through: the anchoring object's status and result are mirrored via the ordinary object-write path so `x-openregister-lifecycle` governs them and `object.transitioned` fires as usual. One-directional — an object write is never read back as a plan-item transition, though it may satisfy a sentry. Deleting plan items leaves mirrored state intact. -- [ ] 7.2 `lib/Controller/CaseController.php` + `appinfo/routes.php`: read +- [x] 7.2 `lib/Controller/CaseController.php` + `appinfo/routes.php`: read the plan by object uuid, transition an item, enable a discretionary item, attach an ad-hoc item, list enableable items, list cases by item type and state. Every method declares its auth posture attribute AND @@ -116,7 +116,7 @@ ## 8. Zaaktype import -- [ ] 8.1 `lib/Service/Case/ZaaktypeCaseSkeletonMapper.php` — a PURE +- [x] 8.1 `lib/Service/Case/ZaaktypeCaseSkeletonMapper.php` — a PURE transformation over a supplied zaaktype document, no HTTP: ordered `statustypen` → milestones in sequence order; `roltypen` → candidate roles preserving the generic designation; `resultaattypen` → the @@ -130,7 +130,7 @@ ## 9. Seed data -- [ ] 9.1 Install the six seed fixtures from design.md — Seed Data (the +- [x] 9.1 Install the six seed fixtures from design.md — Seed Data (the two-stage permit case, the discretionary advice item, the ad-hoc item on a run-less task, the terminated stage with its cascade audit rows, the repeating item with two realisations, and the zaaktype fixture @@ -140,13 +140,13 @@ ## 10. Tests -- [ ] 10.1 Table-driven unit tests for the transition table (every legal +- [x] 10.1 Table-driven unit tests for the transition table (every legal edge, and the illegal ones naming all four facts — including milestone → `active` and any transition out of a terminal state) and for sentry evaluation (AND-within / OR-across, an unevaluable if-part being FALSE, an unknown event refused at save time, a malformed sentry never firing). -- [ ] 10.2 Structural and authorization tests: a stage with only optional +- [x] 10.2 Structural and authorization tests: a stage with only optional children staying open; the cascade bound failing loudly and rolling back; a non-member denied on enable and on attach with the denial audited; an unresolvable role denying; two overlapping transitions on @@ -158,7 +158,7 @@ unmappable elements; and opencatalogi + softwarecatalog suites green (additive-migration-only consumers — the check is that no shared service signature changed). -- [ ] 10.3 Playwright coverage for the six `@e2e`-marked scenarios in +- [x] 10.3 Playwright coverage for the six `@e2e`-marked scenarios in `specs/flow-cases/spec.md`: reading a case plan by object uuid, a milestone satisfying another item's sentry, terminating a stage cascading to its children, completing a task completing its plan item, diff --git a/tests/Unit/Controller/CaseControllerTest.php b/tests/Unit/Controller/CaseControllerTest.php new file mode 100644 index 0000000000..2e7408e85f --- /dev/null +++ b/tests/Unit/Controller/CaseControllerTest.php @@ -0,0 +1,311 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use OCA\OpenRegister\Controller\CaseController; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseAccessDeniedException; +use OCA\OpenRegister\Exception\CaseCascadeBoundException; +use OCA\OpenRegister\Exception\CaseTransitionException; +use OCA\OpenRegister\Exception\CaseValidationException; +use OCA\OpenRegister\Service\Case\CasePlanAuthorizationService; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCA\OpenRegister\Service\Case\ZaaktypeCaseSkeletonMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionClass; +use ReflectionMethod; +use RuntimeException; + +/** + * HTTP translation, route contract and structural absences. + * + * @covers \OCA\OpenRegister\Controller\CaseController + */ +class CaseControllerTest extends TestCase { + + /** + * The service, mocked. + * + * @var CasePlanService&MockObject + */ + private CasePlanService&MockObject $plans; + + /** + * The request, mocked. + * + * @var IRequest&MockObject + */ + private IRequest&MockObject $request; + + /** + * Fresh mocks. + * + * @return void + */ + protected function setUp(): void { + $this->plans = $this->createMock(CasePlanService::class); + $this->request = $this->createMock(IRequest::class); + $this->request->method('getParams')->willReturn([]); + }//end setUp() + + /** + * A controller with a session for `alice` (or none). + * + * @param string|null $uid The session user. + * @param LoggerInterface|null $logger The logger. + * + * @return CaseController The controller. + */ + private function controller(?string $uid = 'alice', ?LoggerInterface $logger = null): CaseController { + $session = $this->createMock(IUserSession::class); + $user = null; + if ($uid !== null) { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + } + + $session->method('getUser')->willReturn($user); + $authorization = $this->createMock(CasePlanAuthorizationService::class); + $authorization->method('assertIdentified')->willReturnCallback( + static function (?string $uid, string $verb): string { + if ($uid === null) { + throw new CaseAccessDeniedException("Verb '$verb' denied: no acting identity."); + } + + return $uid; + } + ); + + return new CaseController( + appName: 'openregister', + request: $this->request, + plans: $this->plans, + zaaktypes: new ZaaktypeCaseSkeletonMapper(), + authorization: $authorization, + userSession: $session, + logger: $logger + ); + }//end controller() + + /** + * The routes this controller serves, from appinfo/routes.php. + * + * @return array> The case# routes. + */ + private static function caseRoutes(): array { + $routes = include __DIR__ . '/../../../appinfo/routes.php'; + $all = ($routes['routes'] ?? []); + + return array_values(array_filter($all, static fn (array $route): bool => str_starts_with((string)($route['name'] ?? ''), 'case#'))); + }//end caseRoutes() + + /** + * gate-25: every case route resolves to a public method carrying both attributes. + * + * @return void + */ + public function testEveryCaseRouteResolvesToAnAttributedPublicMethod(): void { + $routes = self::caseRoutes(); + $this->assertCount(11, $routes); + $reflection = new ReflectionClass(CaseController::class); + foreach ($routes as $route) { + $method = substr((string)$route['name'], strlen('case#')); + $this->assertTrue($reflection->hasMethod($method), "route {$route['name']} names a method"); + $target = $reflection->getMethod($method); + $this->assertTrue($target->isPublic()); + $this->assertNotEmpty($target->getAttributes(NoAdminRequired::class), "$method declares its posture"); + $this->assertNotEmpty($target->getAttributes(NoCSRFRequired::class), "$method decides CSRF explicitly"); + $this->assertStringStartsWith('/api/cases', (string)$route['url']); + } + + // The literal routes precede the {objectUuid} route, or they are swallowed. + $urls = array_column($routes, 'url'); + $this->assertLessThan(array_search('/api/cases/{objectUuid}', $urls, true), array_search('/api/cases/items', $urls, true)); + $this->assertLessThan(array_search('/api/cases/{objectUuid}', $urls, true), array_search('/api/cases/skeleton-from-zaaktype', $urls, true)); + }//end testEveryCaseRouteResolvesToAnAttributedPublicMethod() + + /** + * No CMMN endpoint exists anywhere in the route table, and no case-layer + * file mentions CMMN XML as something it parses or emits. + * + * @return void + */ + public function testNoCmmnEndpointExists(): void { + $routes = include __DIR__ . '/../../../appinfo/routes.php'; + foreach (($routes['routes'] ?? []) as $route) { + $this->assertStringNotContainsStringIgnoringCase('cmmn', (string)($route['url'] ?? '')); + $this->assertStringNotContainsStringIgnoringCase('cmmn', (string)($route['name'] ?? '')); + } + + foreach (glob(__DIR__ . '/../../../lib/Service/Case/*.php') as $file) { + $source = (string)file_get_contents($file); + $this->assertDoesNotMatchRegularExpression('/simplexml|DOMDocument|xml_parse|assertNotEmpty($files); + foreach ($files as $file) { + $this->assertStringNotContainsString('Service\\Case\\', (string)file_get_contents($file), basename($file)); + } + + // And the case layer never touches marking-writing surfaces. + foreach (glob(__DIR__ . '/../../../lib/Service/Case/*.php') as $file) { + $source = (string)file_get_contents($file); + $this->assertDoesNotMatchRegularExpression('/setMarking|setStatus\(|setLog\(|FlowRunAdvancer|FlowEngine\b/', $source, basename($file)); + } + }//end testTheEngineNeverReferencesTheCaseLayer() + + /** + * Reads and the success statuses. + * + * @return void + */ + public function testSuccessfulRoutesTranslateTheServiceResult(): void { + $item = new CaseItem(); + $item->setUuid('i-1'); + $item->setState(CaseItem::STATE_ACTIVE); + $this->plans->method('getPlan')->willReturn(['objectUuid' => 'o', 'items' => []]); + $this->plans->method('createPlan')->willReturn(['objectUuid' => 'o', 'items' => [1]]); + $this->plans->method('evaluate')->willReturn(['passes' => 2, 'transitions' => 3, 'skipped' => false]); + $this->plans->method('enableableItems')->willReturn([['key' => 'advice']]); + $this->plans->method('attachAdHoc')->willReturn($item); + $this->plans->method('completeCase')->willReturn(['result' => 'verleend']); + $this->plans->method('deletePlan')->willReturn(4); + $this->plans->method('transition')->willReturn($item); + $this->plans->method('enableDiscretionary')->willReturn($item); + $this->plans->method('findStuck')->willReturn(['results' => [], 'total' => 0, 'limit' => 25, 'offset' => 0]); + $controller = $this->controller(); + + $this->assertSame(Http::STATUS_OK, $controller->show('o')->getStatus()); + $this->assertSame(Http::STATUS_CREATED, $controller->create('o', 1, 1, ['items' => []])->getStatus()); + $this->assertSame(3, $controller->evaluate('o')->getData()['transitions']); + $this->assertSame([['key' => 'advice']], $controller->enableable('o')->getData()['results']); + $this->assertSame(Http::STATUS_CREATED, $controller->attach('o', 'k', 'humanTask')->getStatus()); + $this->assertSame('verleend', $controller->complete('o', 'verleend')->getData()['result']); + $this->assertSame(4, $controller->destroy('o')->getData()['deleted']); + $this->assertSame('i-1', $controller->transition('i-1', 'completed')->getData()['uuid']); + $this->assertSame('active', $controller->enable('i-1')->getData()['state']); + $this->assertSame(0, $controller->items()->getData()['total']); + + $skeleton = $controller->skeletonFromZaaktype(['statustypen' => [['volgnummer' => 1, 'omschrijving' => 'A']]]); + $this->assertSame(Http::STATUS_OK, $skeleton->getStatus()); + $this->assertTrue($skeleton->getData()['draft']); + }//end testSuccessfulRoutesTranslateTheServiceResult() + + /** + * Missing required body fields are 400 before the service is asked. + * + * @return void + */ + public function testMissingFieldsAreRefusedBeforeTheService(): void { + $this->plans->expects($this->never())->method($this->anything()); + $controller = $this->controller(); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $controller->transition('i-1', null)->getStatus()); + $this->assertSame(Http::STATUS_BAD_REQUEST, $controller->transition('i-1', ' ')->getStatus()); + $this->assertSame(Http::STATUS_BAD_REQUEST, $controller->complete('o', null)->getStatus()); + $this->assertSame(Http::STATUS_BAD_REQUEST, $controller->skeletonFromZaaktype(null)->getStatus()); + $this->assertSame(Http::STATUS_BAD_REQUEST, $controller->skeletonFromZaaktype([])->getStatus()); + $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller(uid: null)->skeletonFromZaaktype(['a' => 1])->getStatus()); + }//end testMissingFieldsAreRefusedBeforeTheService() + + /** + * The exception-to-status table, and the 500 that logs instead of echoing. + * + * @return void + */ + public function testServiceExceptionsMapToStatuses(): void { + $map = [ + [new DoesNotExistException('x'), Http::STATUS_NOT_FOUND, 'Not found'], + [new CaseAccessDeniedException('denied'), Http::STATUS_FORBIDDEN, 'denied'], + [new CaseValidationException('refused'), Http::STATUS_BAD_REQUEST, 'refused'], + [new CaseTransitionException('illegal'), Http::STATUS_CONFLICT, 'illegal'], + [new CaseCascadeBoundException('bound'), Http::STATUS_UNPROCESSABLE_ENTITY, 'bound'], + ]; + foreach ($map as [$exception, $status, $message]) { + $this->plans = $this->createMock(CasePlanService::class); + $this->plans->method('getPlan')->willThrowException($exception); + $response = $this->controller()->show('o'); + $this->assertSame($status, $response->getStatus(), get_class($exception)); + $this->assertSame($message, $response->getData()['error']); + } + + $this->plans = $this->createMock(CasePlanService::class); + $this->plans->method('getPlan')->willThrowException(new RuntimeException('secret detail')); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error')->with($this->stringContains('secret detail')); + $response = $this->controller(logger: $logger)->show('o'); + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertSame('Internal error', $response->getData()['error'], 'The detail is logged, not echoed.'); + }//end testServiceExceptionsMapToStatuses() + + /** + * attach() forwards a body `authorization` key so the service can refuse it. + * + * @return void + */ + public function testAttachForwardsASelfDeclaredAuthorizationForRefusal(): void { + $this->request = $this->createMock(IRequest::class); + $this->request->method('getParams')->willReturn(['authorization' => ['me']]); + $this->plans->expects($this->once())->method('attachAdHoc')->with( + 'o', + $this->callback(static fn (array $data): bool => $data['authorization'] === ['me'] && $data['required'] === false && $data['key'] === 'k' && array_key_exists('name', $data) === false), + 'alice' + )->willThrowException(new CaseValidationException('An ad-hoc item cannot declare its own authorization')); + + $response = $this->controller()->attach('o', 'k', 'humanTask', null, null, 'intake', null, null, null, null, null, null, null, false); + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + }//end testAttachForwardsASelfDeclaredAuthorizationForRefusal() + + /** + * Every public route method returns a JSONResponse. + * + * @return void + */ + public function testEveryRouteMethodReturnsJson(): void { + foreach ((new ReflectionClass(CaseController::class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isConstructor() === true || $method->getDeclaringClass()->getName() !== CaseController::class) { + continue; + } + + $this->assertSame('OCP\AppFramework\Http\JSONResponse', (string)$method->getReturnType(), $method->getName()); + } + }//end testEveryRouteMethodReturnsJson() +}//end class diff --git a/tests/Unit/Db/CaseItemMapperQueriesTest.php b/tests/Unit/Db/CaseItemMapperQueriesTest.php new file mode 100644 index 0000000000..26279c3c3d --- /dev/null +++ b/tests/Unit/Db/CaseItemMapperQueriesTest.php @@ -0,0 +1,208 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use InvalidArgumentException; +use LogicException; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Db\CaseItemAuditMapper; +use OCA\OpenRegister\Db\CaseItemMapper; +use OCA\OpenRegister\Db\Task; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\TestCase; + +/** + * Mapper coverage. + * + * @covers \OCA\OpenRegister\Db\CaseItemMapper + * @covers \OCA\OpenRegister\Db\CaseItemAuditMapper + * @covers \OCA\OpenRegister\Db\CaseItem + * @covers \OCA\OpenRegister\Db\CaseItemAudit + */ +class CaseItemMapperQueriesTest extends TestCase { + use FluentQueryBuilderTrait; + + /** + * A stored plan-item row as the database returns it. + * + * @return array The row. + */ + private function row(): array { + return [ + 'id' => 7, + 'uuid' => 'c-7', + 'item_key' => 'intake', + 'object_uuid' => 'obj-1', + 'origin' => CaseItem::ORIGIN_DEFINED, + 'plan_item_type' => CaseItem::TYPE_STAGE, + 'state' => CaseItem::STATE_ACTIVE, + 'is_terminal' => 0, + 'required' => 1, + 'discretionary' => 0, + 'realisation_count' => 1, + 'entry_criteria' => '[{"id":"s1"}]', + 'created' => '2026-09-01 10:00:00', + ]; + }//end row() + + /** + * findByUuid maps one row, findByObject/findByRealisation map lists, and none throws. + * + * @return void + */ + public function testReadsMapRowsAndThrowOnNone(): void { + $mapper = new CaseItemMapper(db: $this->connectionWith(rows: [$this->row()])); + $item = $mapper->findByUuid(uuid: 'c-7'); + $this->assertSame('intake', $item->getItemKey()); + $this->assertSame([['id' => 's1']], $item->getEntryCriteria()); + $this->assertFalse($item->getIsTerminal()); + $this->assertTrue($this->saw('expr.eq', 'uuid')); + + $this->assertCount(1, $mapper->findByObject(objectUuid: 'obj-1')); + $this->assertTrue($this->saw('expr.eq', 'object_uuid')); + $this->assertTrue($this->saw('orderBy', 'position')); + $this->assertCount(1, $mapper->findByRealisation(realisationUuid: 'task-1')); + $this->assertTrue($this->saw('expr.eq', 'realisation_uuid')); + + $this->expectException(DoesNotExistException::class); + (new CaseItemMapper(db: $this->connectionWith(rows: [])))->findByUuid(uuid: 'ghost'); + }//end testReadsMapRowsAndThrowOnNone() + + /** + * updateIfState writes only the changed fields under the state guard and + * reports whether the row was hit; an unsaved row is refused. + * + * @return void + */ + public function testUpdateIfStateGuardsOnTheStateRead(): void { + $item = new CaseItem(); + $item->setId(7); + $item->setUuid('c-7'); + $item->resetUpdatedFields(); + $item->setState(CaseItem::STATE_COMPLETED); + $item->setIsTerminal(true); + + $mapper = new CaseItemMapper(db: $this->connectionWith(affectedRows: 1)); + $this->assertTrue($mapper->updateIfState(item: $item, expectedState: CaseItem::STATE_ACTIVE)); + $this->assertTrue($this->saw('set', 'state')); + $this->assertTrue($this->saw('set', 'is_terminal')); + $this->assertTrue($this->saw('set', 'updated')); + $this->assertFalse($this->saw('set', 'name'), 'an untouched field is not written'); + $this->assertTrue($this->saw('expr.eq', 'state')); + $this->assertTrue($this->saw('expr.eq', 'id')); + + $this->assertFalse((new CaseItemMapper(db: $this->connectionWith(affectedRows: 0)))->updateIfState(item: $item, expectedState: CaseItem::STATE_ACTIVE)); + + $this->expectException(InvalidArgumentException::class); + (new CaseItemMapper(db: $this->connectionWith()))->updateIfState(item: new CaseItem(), expectedState: 'x'); + }//end testUpdateIfStateGuardsOnTheStateRead() + + /** + * insert stamps uuid and created; update stamps updated; both refuse a foreign entity. + * + * @return void + */ + public function testInsertAndUpdateStampAndGuardTheEntityType(): void { + $mapper = new CaseItemMapper(db: $this->connectionWith()); + $item = new CaseItem(); + $item->setObjectUuid('obj-1'); + $inserted = $mapper->insert($item); + $this->assertNotEmpty($inserted->getUuid()); + $this->assertNotNull($inserted->getCreated()); + + $item->setId(7); + $this->assertNotNull($mapper->update($item)->getUpdated()); + + try { + $mapper->insert(new Task()); + $this->fail('foreign entity'); + } catch (InvalidArgumentException) { + $this->addToAssertionCount(1); + } + + $this->expectException(InvalidArgumentException::class); + $mapper->update(new Task()); + }//end testInsertAndUpdateStampAndGuardTheEntityType() + + /** + * The stuck-where page and its total share one predicate, page in the + * datastore, and skip absent filters; counts read one scalar; delete is by object. + * + * @return void + */ + public function testStuckWhereReadsCountsAndDelete(): void { + $mapper = new CaseItemMapper(db: $this->connectionWith(affectedRows: 3, rows: [$this->row()])); + $page = $mapper->findByTypeAndState(type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE, limit: 10, offset: 20); + $this->assertCount(1, $page); + $this->assertTrue($this->saw('expr.eq', 'plan_item_type')); + $this->assertTrue($this->saw('expr.eq', 'state')); + $this->assertTrue($this->saw('setMaxResults', 10)); + $this->assertTrue($this->saw('setFirstResult', 20)); + + $this->assertIsInt($mapper->countByTypeAndState(type: null, state: ' ')); + $this->assertIsInt($mapper->countOpenByObject(objectUuid: 'obj-1')); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + $this->assertSame(3, $mapper->deleteByObject(objectUuid: 'obj-1')); + $this->assertTrue($this->saw('delete', 'openregister_case_items')); + }//end testStuckWhereReadsCountsAndDelete() + + /** + * The audit mapper appends with a stamp, reads by item and by items, and + * refuses update and delete. + * + * @return void + */ + public function testTheAuditIsAppendOnly(): void { + $auditRow = ['id' => 1, 'case_item_id' => 7, 'from_state' => 'active', 'to_state' => 'completed', 'cause' => 'user', 'authorized' => 1, 'created' => '2026-09-01 10:00:00']; + $mapper = new CaseItemAuditMapper(db: $this->connectionWith(rows: [$auditRow])); + + $entry = new CaseItemAudit(); + $entry->setCaseItemId(7); + $entry->setCause(CaseItemAudit::CAUSE_USER); + $this->assertNotNull($mapper->insert($entry)->getCreated()); + + $this->assertCount(1, $mapper->findForItem(caseItemId: 7)); + $this->assertTrue($this->saw('expr.eq', 'case_item_id')); + $this->assertSame([], $mapper->findForItems(caseItemIds: [])); + $this->assertCount(1, $mapper->findForItems(caseItemIds: [7, 8])); + $this->assertTrue($this->saw('expr.in', 'case_item_id')); + $this->assertSame('completed', $mapper->findForItem(caseItemId: 7)[0]->jsonSerialize()['toState']); + + try { + $mapper->insert(new Task()); + $this->fail('foreign entity'); + } catch (InvalidArgumentException) { + $this->addToAssertionCount(1); + } + + try { + $mapper->update($entry); + $this->fail('append-only'); + } catch (LogicException) { + $this->addToAssertionCount(1); + } + + $this->expectException(LogicException::class); + $mapper->delete($entry); + }//end testTheAuditIsAppendOnly() +}//end class diff --git a/tests/Unit/Listener/CaseListenersTest.php b/tests/Unit/Listener/CaseListenersTest.php new file mode 100644 index 0000000000..7816772f29 --- /dev/null +++ b/tests/Unit/Listener/CaseListenersTest.php @@ -0,0 +1,103 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Event\ObjectTransitionedEvent; +use OCA\OpenRegister\Event\ObjectUpdatedEvent; +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Listener\CaseObjectEventListener; +use OCA\OpenRegister\Listener\CaseRunTerminalListener; +use OCA\OpenRegister\Listener\CaseTaskTerminalListener; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCP\EventDispatcher\Event; +use PHPUnit\Framework\TestCase; + +/** + * Listener coverage. + * + * @covers \OCA\OpenRegister\Listener\CaseTaskTerminalListener + * @covers \OCA\OpenRegister\Listener\CaseRunTerminalListener + * @covers \OCA\OpenRegister\Listener\CaseObjectEventListener + * @covers \OCA\OpenRegister\Event\TaskTerminalEvent + */ +class CaseListenersTest extends TestCase { + + /** + * A terminal task evaluates by its uuid; an empty uuid and other events are ignored. + * + * @return void + */ + public function testATerminalTaskDrivesItsPlan(): void { + $plans = $this->createMock(CasePlanService::class); + $plans->expects($this->once())->method('onRealisationTerminal')->with('task-1'); + $listener = new CaseTaskTerminalListener(plans: $plans); + + $task = new Task(); + $task->setUuid('task-1'); + $event = new TaskTerminalEvent(task: $task); + $this->assertSame($task, $event->getTask()); + $listener->handle($event); + $listener->handle(new TaskTerminalEvent(task: new Task())); + $listener->handle(new Event()); + }//end testATerminalTaskDrivesItsPlan() + + /** + * A terminal run evaluates by its uuid; other events are ignored. + * + * @return void + */ + public function testATerminalRunDrivesItsStage(): void { + $plans = $this->createMock(CasePlanService::class); + $plans->expects($this->once())->method('onRealisationTerminal')->with('run-1'); + $listener = new CaseRunTerminalListener(plans: $plans); + $listener->handle(new FlowRunTerminalEvent(runUuid: 'run-1', status: 'completed')); + $listener->handle(new Event()); + }//end testATerminalRunDrivesItsStage() + + /** + * Object updates and transitions are forwarded with the catalog id and + * the object's data; other events are not. + * + * @return void + */ + public function testObjectEventsAreForwarded(): void { + $object = new ObjectEntity(); + $object->setUuid('obj-1'); + $object->setObject(['status' => 'ingetrokken']); + $plans = $this->createMock(CasePlanService::class); + $plans->expects($this->exactly(2))->method('onObjectEvent')->willReturnCallback( + function (string $uuid, string $event, array $payload): void { + $this->assertSame('obj-1', $uuid); + $this->assertContains($event, ['object.updated', 'object.transitioned']); + $this->assertSame('ingetrokken', $payload['status']); + } + ); + $listener = new CaseObjectEventListener(plans: $plans); + + $listener->handle(new ObjectUpdatedEvent(newObject: $object, oldObject: new ObjectEntity())); + $listener->handle(new ObjectTransitionedEvent(object: $object, action: 'x', from: 'a', to: 'b', userId: null, register: '1', schema: '1')); + $listener->handle(new ObjectUpdatedEvent(newObject: new ObjectEntity(), oldObject: new ObjectEntity())); + $listener->handle(new Event()); + }//end testObjectEventsAreForwarded() +}//end class diff --git a/tests/Unit/Repair/SeedCaseFixturesTest.php b/tests/Unit/Repair/SeedCaseFixturesTest.php new file mode 100644 index 0000000000..fa5483fa28 --- /dev/null +++ b/tests/Unit/Repair/SeedCaseFixturesTest.php @@ -0,0 +1,154 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Repair; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Repair\SeedCaseFixtures; +use OCA\OpenRegister\Tests\Unit\Service\Case\FakeCaseItemMapper; +use OCA\OpenRegister\Tests\Unit\Service\Case\RecordingAuditMapper; +use OCP\IAppConfig; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Seed step coverage. + * + * @covers \OCA\OpenRegister\Repair\SeedCaseFixtures + */ +class SeedCaseFixturesTest extends TestCase { + + /** + * The step over in-memory mappers. + * + * @param bool $enabled The flag. + * @param FakeCaseItemMapper $items The rows. + * @param RecordingAuditMapper $audits The audit. + * + * @return SeedCaseFixtures The step. + */ + private function step(bool $enabled, FakeCaseItemMapper $items, RecordingAuditMapper $audits): SeedCaseFixtures { + $config = $this->createMock(IAppConfig::class); + $config->method('getValueBool')->with('openregister', SeedCaseFixtures::FLAG, false)->willReturn($enabled); + + return new SeedCaseFixtures(appConfig: $config, items: $items, audits: $audits, logger: new NullLogger()); + }//end step() + + /** + * Off by default: nothing written. + * + * @return void + */ + public function testOffByDefault(): void { + $items = new FakeCaseItemMapper($this); + $audits = new RecordingAuditMapper($this); + $output = $this->createMock(IOutput::class); + $output->expects($this->once())->method('info')->with($this->stringContains('skipped')); + + $step = $this->step(enabled: false, items: $items, audits: $audits); + $this->assertStringContainsString('flow-cmmn-case-semantics', $step->getName()); + $step->run($output); + $this->assertSame([], $items->rows); + }//end testOffByDefault() + + /** + * On: the six groups land as trees with parent ids and audit rows, and a + * second run seeds nothing. + * + * @return void + */ + public function testTheSixGroupsAreSeededIdempotently(): void { + $items = new FakeCaseItemMapper($this); + $audits = new RecordingAuditMapper($this); + $output = $this->createMock(IOutput::class); + $step = $this->step(enabled: true, items: $items, audits: $audits); + + $step->run($output); + $this->assertCount(11, $items->rows); + + $byKey = []; + foreach ($items->rows as $row) { + $byKey[(string)$row->getItemKey() . '#' . (int)$row->getRealisationCount()] = $row; + } + + // 1: nesting, milestone sentry, shared anchor with the task fixtures. + $this->assertSame(SeedCaseFixtures::PERMIT_OBJECT, $byKey['intake#1']->getObjectUuid()); + $this->assertSame($byKey['intake#1']->getId(), $byKey['completeness-check#1']->getParentItemId()); + $this->assertSame('00000000-0000-0000-0000-000000000001', $byKey['completeness-check#1']->getRealisationUuid()); + $this->assertSame('completeness-check', $byKey['application-complete#1']->getEntryCriteria()[0]['on']['item']); + // 2: discretionary with its denial audited. + $advice = $byKey['external-advice#1']; + $this->assertTrue($advice->getDiscretionary()); + $this->assertSame(['demo-beslissers'], $advice->getAuthorizationRules()); + $denials = array_filter($audits->findForItem((int)$advice->getId()), static fn (CaseItemAudit $entry): bool => $entry->getAuthorized() === false); + $this->assertCount(1, $denials); + // 3: ad-hoc, no definition key, no flow. + $adhoc = $byKey['adhoc-site-visit#1']; + $this->assertSame(CaseItem::ORIGIN_ADHOC, $adhoc->getOrigin()); + $this->assertNull($adhoc->getDefinitionItemKey()); + $this->assertNull($adhoc->getFlowUuid()); + // 4: the cascade rows name the parent. + $hearing = $byKey['hearing#1']; + foreach (['invite-parties#1', 'hearing-report#1'] as $child) { + $this->assertSame($hearing->getId(), $byKey[$child]->getParentItemId()); + $cascade = array_filter($audits->findForItem((int)$byKey[$child]->getId()), static fn (CaseItemAudit $entry): bool => $entry->getCause() === CaseItemAudit::CAUSE_CASCADE); + $this->assertCount(1, $cascade); + $this->assertSame($hearing->getUuid(), array_values($cascade)[0]->getCauseRef()); + } + + $this->assertSame(CaseItem::STATE_DISABLED, $byKey['hearing-report#1']->getState()); + // 5: two realisations, one completed, one active. + $this->assertSame(CaseItem::STATE_COMPLETED, $byKey['request-documents#1']->getState()); + $this->assertSame(CaseItem::STATE_ACTIVE, $byKey['request-documents#2']->getState()); + // 6: the zaaktype fixture has what the mapper tests need. + $zaaktype = SeedCaseFixtures::zaaktypeFixture(); + $this->assertSame([3, 1, 4, 2], array_column($zaaktype['statustypen'], 'volgnummer')); + $this->assertCount(3, $zaaktype['roltypen']); + $this->assertCount(2, $zaaktype['resultaattypen']); + + foreach ($items->rows as $row) { + $this->assertStringStartsWith('00000000-0000-0000-0000-', (string)$row->getUuid(), 'nil placeholders'); + $this->assertSame($row->isInTerminalState(), $row->getIsTerminal(), 'state and is_terminal agree'); + } + + $auditRows = count($audits->entries); + $step->run($output); + $this->assertCount(11, $items->rows, 'Idempotent on uuid.'); + $this->assertCount($auditRows, $audits->entries); + }//end testTheSixGroupsAreSeededIdempotently() + + /** + * A failing fixture is reported and the rest continue. + * + * @return void + */ + public function testAFailingFixtureIsReportedAndTheRestContinue(): void { + $items = new FakeCaseItemMapper($this); + $audits = new RecordingAuditMapper($this); + $audits->failNext = true; + $output = $this->createMock(IOutput::class); + $output->expects($this->once())->method('warning')->with($this->stringContains('audit table unavailable')); + $output->expects($this->once())->method('info'); + + $this->step(enabled: true, items: $items, audits: $audits)->run($output); + $this->assertGreaterThan(6, count($items->rows), 'The other groups were seeded.'); + }//end testAFailingFixtureIsReportedAndTheRestContinue() +}//end class diff --git a/tests/Unit/Service/Case/CaseAnchorAndWriterTest.php b/tests/Unit/Service/Case/CaseAnchorAndWriterTest.php new file mode 100644 index 0000000000..222016d3f1 --- /dev/null +++ b/tests/Unit/Service/Case/CaseAnchorAndWriterTest.php @@ -0,0 +1,122 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Case\CaseAnchorReader; +use OCA\OpenRegister\Service\Case\CaseBusinessStateWriter; +use OCA\OpenRegister\Service\ObjectService; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * Coverage of CaseAnchorReader and CaseBusinessStateWriter. + * + * @covers \OCA\OpenRegister\Service\Case\CaseAnchorReader + * @covers \OCA\OpenRegister\Service\Case\CaseBusinessStateWriter + */ +class CaseAnchorAndWriterTest extends TestCase { + + /** + * read(): the object's data; [] on null; [] on failure. mayRead(): true + * only on a successful RBAC read. + * + * @return void + */ + public function testTheAnchorReaderFailsClosed(): void { + $entity = new ObjectEntity(); + $entity->setUuid('obj-1'); + $entity->setObject(['status' => 'open']); + $objects = $this->createMock(ObjectService::class); + $objects->method('find')->willReturnCallback( + static function (int|string $id): ?ObjectEntity { + if ($id === 'missing') { + return null; + } + + if ($id === 'broken') { + throw new RuntimeException('no such table'); + } + + return new ObjectEntity(); + } + ); + $reader = new CaseAnchorReader(objects: $objects, logger: new NullLogger()); + + $this->assertSame([], $reader->read(objectUuid: 'missing', registerId: 1, schemaId: 1)); + $this->assertSame([], $reader->read(objectUuid: 'broken', registerId: 1, schemaId: 1)); + $this->assertIsArray($reader->read(objectUuid: 'ok', registerId: null, schemaId: null)); + $this->assertFalse($reader->mayRead(objectUuid: 'missing', registerId: 1, schemaId: 1)); + $this->assertFalse($reader->mayRead(objectUuid: 'broken', registerId: 1, schemaId: 1)); + $this->assertTrue($reader->mayRead(objectUuid: 'ok', registerId: 1, schemaId: 1)); + + $loaded = $this->createMock(ObjectService::class); + $loaded->method('find')->willReturn($entity); + $this->assertSame(['id' => 'obj-1', 'status' => 'open'], (new CaseAnchorReader(objects: $loaded, logger: new NullLogger()))->read(objectUuid: 'x', registerId: 1, schemaId: 1)); + }//end testTheAnchorReaderFailsClosed() + + /** + * mirrorStatus() and mirrorResult() patch the mapped fields with a stamp; + * without a mapping nothing is written. + * + * @return void + */ + public function testTheWriterPatchesOnlyMappedFields(): void { + $objects = $this->createMock(ObjectService::class); + $patches = []; + $objects->method('patchObject')->willReturnCallback( + static function (string $objectId, array $data) use (&$patches): ObjectEntity { + $patches[] = [$objectId, $data]; + + return new ObjectEntity(); + } + ); + $writer = new CaseBusinessStateWriter(objects: $objects); + + $milestone = CaseFixtures::row(id: 1, key: 'application-complete', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_COMPLETED); + $milestone->setName('Aanvraag volledig'); + $milestone->setPlanSettings(['writeThrough' => ['statusField' => 'status', 'statusAtField' => 'statusReachedAt', 'resultField' => 'resultaat']]); + + $this->assertTrue($writer->mirrorStatus(milestone: $milestone)); + $this->assertSame(CaseFixtures::OBJECT, $patches[0][0]); + $this->assertSame('Aanvraag volledig', $patches[0][1]['status']); + $this->assertArrayHasKey('statusReachedAt', $patches[0][1]); + + $this->assertTrue($writer->mirrorResult(anyRow: $milestone, result: 'verleend')); + $this->assertSame(['resultaat' => 'verleend'], $patches[1][1], 'No resultAtField mapped: no stamp.'); + + $unmapped = CaseFixtures::row(id: 2, key: 'm', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_COMPLETED); + $unmapped->setPlanSettings(['writeThrough' => 'status']); + $this->assertFalse($writer->mirrorStatus(milestone: $unmapped)); + $this->assertFalse($writer->mirrorResult(anyRow: $unmapped, result: 'x')); + $unmapped->setPlanSettings(null); + $this->assertFalse($writer->mirrorStatus(milestone: $unmapped)); + $this->assertCount(2, $patches); + + $keyed = CaseFixtures::row(id: 3, key: 'ontvangen', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_COMPLETED); + $keyed->setName(null); + $keyed->setPlanSettings(['writeThrough' => ['statusField' => 'status']]); + $writer->mirrorStatus(milestone: $keyed); + $this->assertSame(['status' => 'ontvangen'], $patches[2][1], 'Without a name the key is the status.'); + }//end testTheWriterPatchesOnlyMappedFields() +}//end class diff --git a/tests/Unit/Service/Case/CaseFixtures.php b/tests/Unit/Service/Case/CaseFixtures.php new file mode 100644 index 0000000000..9cbe350a64 --- /dev/null +++ b/tests/Unit/Service/Case/CaseFixtures.php @@ -0,0 +1,79 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; + +/** + * Builds plan-item rows. + */ +final class CaseFixtures { + + /** + * The demo anchor. + */ + public const OBJECT = '00000000-0000-0000-0000-0000000000aa'; + + /** + * One row. + * + * @param int $id Row id. + * @param string $key Item key. + * @param string $type Plan-item type. + * @param string $state State. + * @param int|null $parentId Parent row id. + * @param bool $required Required-ness. + * @param bool $discretionary Discretionary-ness. + * + * @return CaseItem The row. + */ + public static function row( + int $id, + string $key, + string $type, + string $state, + ?int $parentId = null, + bool $required = true, + bool $discretionary = false, + ): CaseItem { + $row = new CaseItem(); + $row->setId($id); + $row->setUuid('item-' . $id); + $row->setItemKey($key); + $row->setName(ucfirst($key)); + $row->setObjectUuid(self::OBJECT); + $row->setRegisterId(1); + $row->setSchemaId(1); + $row->setOrigin(CaseItem::ORIGIN_DEFINED); + $row->setDefinitionItemKey($key); + $row->setParentItemId($parentId); + $row->setPlanItemType($type); + $row->setPosition($id); + $row->setState($state); + $row->setIsTerminal(in_array($state, CaseItem::TERMINAL_STATES, true)); + $row->setRequired($required); + $row->setDiscretionary($discretionary); + $row->setRealisationCount(1); + $row->setRealisationKind(CaseItem::REALISATION_NONE); + $row->setPlanSettings(['authorization' => ['demo-behandelaars']]); + $row->resetUpdatedFields(); + + return $row; + }//end row() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanAuthorizationServiceTest.php b/tests/Unit/Service/Case/CasePlanAuthorizationServiceTest.php new file mode 100644 index 0000000000..df2b3dcf16 --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanAuthorizationServiceTest.php @@ -0,0 +1,215 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseAccessDeniedException; +use OCA\OpenRegister\Service\Case\CasePlanAuthorizationService; +use OCA\OpenRegister\Service\Case\CasePlanTree; +use OCP\IGroupManager; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * Coverage of CasePlanAuthorizationService. + * + * @covers \OCA\OpenRegister\Service\Case\CasePlanAuthorizationService + * @covers \OCA\OpenRegister\Exception\CaseAccessDeniedException + */ +class CasePlanAuthorizationServiceTest extends TestCase { + + /** + * A group backend where `alice` is in `behandelaars` and `boss` is admin; + * the group `ghost` does not exist. + * + * @return IGroupManager The mocked backend. + */ + private function groupBackend(): IGroupManager { + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturnCallback(static fn (string $uid): bool => $uid === 'boss'); + $groups->method('isInGroup')->willReturnCallback(static fn (string $uid, string $gid): bool => $uid === 'alice' && $gid === 'behandelaars'); + $groups->method('groupExists')->willReturnCallback(static fn (string $gid): bool => $gid !== 'ghost'); + + return $groups; + }//end groups() + + /** + * A tree: a root stage (rules: behandelaars) with a child without rules + * and a grandchild with its own rules (user:carol). + * + * @return array{CasePlanTree, CaseItem, CaseItem, CaseItem} tree, root, child, grandchild. + */ + private function tree(): array { + $root = CaseFixtures::row(id: 1, key: 'root', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $root->setAuthorizationRules(['behandelaars']); + $root->setPlanSettings(['authorization' => ['role:beslissers']]); + $child = CaseFixtures::row(id: 2, key: 'child', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE, parentId: 1); + $child->setPlanSettings(['authorization' => ['role:beslissers']]); + $grandchild = CaseFixtures::row(id: 3, key: 'gc', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE, parentId: 2); + $grandchild->setAuthorizationRules(['user:carol']); + $grandchild->setPlanSettings(['authorization' => ['role:beslissers']]); + + return [new CasePlanTree(items: [$root, $child, $grandchild]), $root, $child, $grandchild]; + }//end tree() + + /** + * A member passes; a non-member is denied; rules derive from the ancestor. + * + * @return void + */ + public function testMembershipDecidesAndRulesDeriveFromTheNearestAncestor(): void { + [$tree, $root, $child, $grandchild] = $this->tree(); + $service = new CasePlanAuthorizationService(groupManager: $this->groupBackend()); + + $service->assertMayAct(verb: 'enable', item: $root, tree: $tree, uid: 'alice'); + $service->assertMayAct(verb: 'enable', item: $child, tree: $tree, uid: 'alice'); + $service->assertMayAct(verb: 'enable', item: $grandchild, tree: $tree, uid: 'carol'); + $this->assertSame(['behandelaars'], $service->effectiveRules(item: $child, tree: $tree)); + $this->assertSame(['role:beslissers'], $service->effectiveRules(item: null, tree: $tree), 'The root derives from the plan settings.'); + + try { + $service->assertMayAct(verb: 'enable', item: $grandchild, tree: $tree, uid: 'alice'); + $this->fail('alice is not carol'); + } catch (CaseAccessDeniedException $denial) { + $this->assertStringContainsString("Verb 'enable' denied", $denial->getMessage()); + } + + $this->expectException(CaseAccessDeniedException::class); + $service->assertMayAct(verb: 'attach', item: $root, tree: $tree, uid: 'stranger'); + }//end testMembershipDecidesAndRulesDeriveFromTheNearestAncestor() + + /** + * An administrator passes everything; nobody passes anonymously. + * + * @return void + */ + public function testAdministratorsPassAndAnonymousNeverDoes(): void { + [$tree, , , $grandchild] = $this->tree(); + $service = new CasePlanAuthorizationService(groupManager: $this->groupBackend()); + + $service->assertMayAct(verb: 'enable', item: $grandchild, tree: $tree, uid: 'boss'); + $service->assertMayAdminister(verb: 'delete-plan', settings: [], uid: 'boss'); + $this->assertTrue($service->isAdministrator(uid: 'boss')); + $this->assertFalse($service->isAdministrator(uid: null)); + $this->assertSame('alice', $service->assertIdentified(uid: ' alice ', verb: 'x')); + + foreach ([null, '', ' '] as $anonymous) { + try { + $service->assertMayAct(verb: 'enable', item: $grandchild, tree: $tree, uid: $anonymous); + $this->fail('anonymous must be denied'); + } catch (CaseAccessDeniedException $denial) { + $this->assertStringContainsString('no acting identity', $denial->getMessage()); + } + } + }//end testAdministratorsPassAndAnonymousNeverDoes() + + /** + * An unresolvable role denies NAMING THE ROLE; a resolvable one is a membership test. + * + * @return void + */ + public function testAnUnresolvableRoleDeniesNamingIt(): void { + $service = new CasePlanAuthorizationService(groupManager: $this->groupBackend()); + $item = CaseFixtures::row(id: 1, key: 'advice', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $item->setAuthorizationRules(['role:ghost']); + $tree = new CasePlanTree(items: [$item]); + + try { + $service->assertMayAct(verb: 'enable', item: $item, tree: $tree, uid: 'alice'); + $this->fail('ghost does not resolve'); + } catch (CaseAccessDeniedException $denial) { + $this->assertStringContainsString("role 'ghost' does not resolve", $denial->getMessage()); + } + + $item->setAuthorizationRules(['role:behandelaars']); + $service->assertMayAct(verb: 'enable', item: $item, tree: $tree, uid: 'alice'); + $this->addToAssertionCount(1); + }//end testAnUnresolvableRoleDeniesNamingIt() + + /** + * No group backend: every membership decision denies, a role cannot resolve, nobody is admin. + * + * @return void + */ + public function testWithoutABackendEverythingDenies(): void { + $service = new CasePlanAuthorizationService(groupManager: null); + $item = CaseFixtures::row(id: 1, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $item->setAuthorizationRules(['behandelaars']); + $tree = new CasePlanTree(items: [$item]); + + $this->assertFalse($service->isAdministrator(uid: 'boss')); + try { + $service->assertMayAct(verb: 'enable', item: $item, tree: $tree, uid: 'alice'); + $this->fail('no backend, no membership'); + } catch (CaseAccessDeniedException) { + $this->addToAssertionCount(1); + } + + $item->setAuthorizationRules(['role:behandelaars']); + try { + $service->assertMayAct(verb: 'enable', item: $item, tree: $tree, uid: 'alice'); + $this->fail('no backend, no role'); + } catch (CaseAccessDeniedException $denial) { + $this->assertStringContainsString('no group backend', $denial->getMessage()); + } + + // user: rules need no backend. + $item->setAuthorizationRules(['user:alice']); + $service->assertMayAct(verb: 'enable', item: $item, tree: $tree, uid: 'alice'); + }//end testWithoutABackendEverythingDenies() + + /** + * A throwing backend grants nothing; no rules anywhere denies non-admins; + * malformed rules admit nobody. + * + * @return void + */ + public function testAThrowingBackendAndMissingOrMalformedRulesDeny(): void { + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willThrowException(new RuntimeException('ldap down')); + $groups->method('isInGroup')->willThrowException(new RuntimeException('ldap down')); + $groups->method('groupExists')->willThrowException(new RuntimeException('ldap down')); + $service = new CasePlanAuthorizationService(groupManager: $groups); + + $this->assertFalse($service->isAdministrator(uid: 'boss')); + $item = CaseFixtures::row(id: 1, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $item->setPlanSettings([]); + $tree = new CasePlanTree(items: [$item]); + try { + $service->assertMayAct(verb: 'enable', item: $item, tree: $tree, uid: 'alice'); + $this->fail('no rules anywhere'); + } catch (CaseAccessDeniedException $denial) { + $this->assertStringContainsString('no authorization is declared', $denial->getMessage()); + } + + $item->setAuthorizationRules(['behandelaars', 42, '', 'role:ghost']); + try { + $service->assertMayAct(verb: 'enable', item: $item, tree: $tree, uid: 'alice'); + $this->fail('throwing backend'); + } catch (CaseAccessDeniedException) { + $this->addToAssertionCount(1); + } + + $this->expectException(CaseAccessDeniedException::class); + $service->assertMayAdminister(verb: 'create-plan', settings: ['authorization' => 'not-a-list'], uid: 'alice'); + }//end testAThrowingBackendAndMissingOrMalformedRulesDeny() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanCascadeTest.php b/tests/Unit/Service/Case/CasePlanCascadeTest.php new file mode 100644 index 0000000000..c799785680 --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanCascadeTest.php @@ -0,0 +1,381 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Exception\CaseCascadeBoundException; +use OCA\OpenRegister\Service\Case\CaseAnchorReader; +use OCA\OpenRegister\Service\Case\CaseBusinessStateWriter; +use OCA\OpenRegister\Service\Case\CasePlanCascade; +use OCA\OpenRegister\Service\Case\CasePlanStateMachine; +use OCA\OpenRegister\Service\Case\CasePlanTransitions; +use OCA\OpenRegister\Service\Case\CasePlanTree; +use OCA\OpenRegister\Service\Case\CaseRealisationService; +use OCA\OpenRegister\Service\Case\CaseSentryEvaluator; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Coverage of CasePlanCascade over the real machine, evaluator and tree. + * + * @covers \OCA\OpenRegister\Service\Case\CasePlanCascade + * @covers \OCA\OpenRegister\Service\Case\CasePlanStateMachine + * @covers \OCA\OpenRegister\Exception\CaseCascadeBoundException + */ +class CasePlanCascadeTest extends TestCase { + + /** + * Rows. + * + * @var FakeCaseItemMapper + */ + private FakeCaseItemMapper $items; + + /** + * Audit. + * + * @var RecordingAuditMapper + */ + private RecordingAuditMapper $audits; + + /** + * Realiser. + * + * @var CaseRealisationService&MockObject + */ + private CaseRealisationService&MockObject $realiser; + + /** + * Anchor. + * + * @var CaseAnchorReader&MockObject + */ + private CaseAnchorReader&MockObject $anchor; + + /** + * Connection. + * + * @var IDBConnection&MockObject + */ + private IDBConnection&MockObject $db; + + /** + * Transaction nesting depth of the mocked connection. + * + * @var integer + */ + private int $depth = 0; + + /** + * Fresh collaborators per test. + * + * @return void + */ + protected function setUp(): void { + $this->items = new FakeCaseItemMapper($this); + $this->audits = new RecordingAuditMapper($this); + $this->realiser = $this->createMock(CaseRealisationService::class); + $this->realiser->method('realise')->willReturnCallback( + static function (CaseItem $row): void { + if ($row->getPlanItemType() === CaseItem::TYPE_HUMAN_TASK) { + $row->setRealisationKind(CaseItem::REALISATION_TASK); + $row->setRealisationUuid('task-for-' . (string)$row->getItemKey() . '-' . (int)$row->getRealisationCount()); + + return; + } + + $row->setRealisationKind(CaseItem::REALISATION_NONE); + } + ); + $this->anchor = $this->createMock(CaseAnchorReader::class); + $this->anchor->method('read')->willReturn(['status' => 'open']); + $this->db = $this->createMock(IDBConnection::class); + $this->db->method('inTransaction')->willReturnCallback(fn (): bool => $this->depth > 0); + $this->db->method('beginTransaction')->willReturnCallback( + function (): void { + $this->depth++; + } + ); + $this->db->method('commit')->willReturnCallback( + function (): void { + $this->depth--; + } + ); + $this->db->method('rollBack')->willReturnCallback( + function (): void { + $this->depth--; + } + ); + $this->depth = 0; + }//end setUp() + + /** + * The cascade over the real machine. + * + * @return CasePlanCascade The cascade. + */ + private function cascade(): CasePlanCascade { + $machine = new CasePlanStateMachine( + items: $this->items, + audits: $this->audits, + table: new CasePlanTransitions(), + realiser: $this->realiser, + writer: $this->createMock(CaseBusinessStateWriter::class), + db: $this->db, + logger: new NullLogger() + ); + + return new CasePlanCascade( + items: $this->items, + machine: $machine, + sentries: new CaseSentryEvaluator(catalog: new EventCatalogService()), + realiser: $this->realiser, + anchor: $this->anchor, + db: $this->db, + logger: new NullLogger() + ); + }//end cascade() + + /** + * The states of the plan, by key. + * + * @return array key => state. + */ + private function states(): array { + return (new CasePlanTree(items: $this->items->findByObject(CaseFixtures::OBJECT)))->stateMap(); + }//end states() + + /** + * Seed 1: the permit case. Creation evaluation enters intake and its + * check; the milestone waits; assessment waits. Completing the task then + * reaches the milestone, which admits assessment, which realises its + * decision item, and intake completes on its required children. One call. + * + * @return void + */ + public function testAMilestoneSatisfiesAnotherItemsSentryInOneEvaluation(): void { + $intake = CaseFixtures::row(id: 1, key: 'intake', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_AVAILABLE); + $check = CaseFixtures::row(id: 2, key: 'check', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE, parentId: 1); + $complete = CaseFixtures::row(id: 3, key: 'complete', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE, parentId: 1); + $complete->setEntryCriteria([['id' => 'after-check', 'on' => ['event' => 'case.item.completed', 'item' => 'check']]]); + $assessment = CaseFixtures::row(id: 4, key: 'assessment', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_AVAILABLE); + $assessment->setEntryCriteria([['id' => 'after-complete', 'on' => ['event' => 'case.item.completed', 'item' => 'complete']]]); + $decide = CaseFixtures::row(id: 5, key: 'decide', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE, parentId: 4); + $this->items->seed([$intake, $check, $complete, $assessment, $decide]); + + $outcomes = []; + $this->realiser->method('terminalOutcome')->willReturnCallback( + static function (CaseItem $row) use (&$outcomes): ?string { + return ($outcomes[(string)$row->getRealisationUuid()] ?? null); + } + ); + + $result = $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT); + $this->assertSame( + ['intake' => 'active', 'check' => 'active', 'complete' => 'available', 'assessment' => 'available', 'decide' => 'available'], + $this->states() + ); + $this->assertSame(2, $result['transitions']); + $this->assertFalse($result['skipped']); + + // The task completes. + $outcomes['task-for-check-1'] = CaseItem::STATE_COMPLETED; + $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT); + + $this->assertSame( + ['intake' => 'completed', 'check' => 'completed', 'complete' => 'completed', 'assessment' => 'active', 'decide' => 'active'], + $this->states() + ); + $this->assertSame(['available->active (sentry)', 'active->completed (realisation)'], $this->audits->trail(2)); + $this->assertSame('task-for-check-1', $this->audits->findForItem(2)[1]->getCauseRef(), 'The task completion is the cause.'); + $this->assertSame('after-check', $this->audits->findForItem(3)[0]->getCauseRef(), 'The admitting sentry is named.'); + $this->assertSame('after-complete', $this->audits->findForItem(4)[0]->getCauseRef()); + $this->assertSame(CaseItemAudit::CAUSE_CASCADE, $this->audits->findForItem(1)[1]->getCause(), 'Stage completion by rule.'); + $this->assertSame('children', $this->audits->findForItem(1)[1]->getCauseRef()); + }//end testAMilestoneSatisfiesAnotherItemsSentryInOneEvaluation() + + /** + * A stage with only discretionary children stays active; enabling one + * starts it; an exit sentry terminates an item and closes its task. + * + * @return void + */ + public function testOptionalChildrenKeepAStageOpenAndEnabledItemsStart(): void { + $stage = CaseFixtures::row(id: 1, key: 'assessment', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_AVAILABLE); + $advice = CaseFixtures::row(id: 2, key: 'advice', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE, parentId: 1, required: false, discretionary: true); + $advice->setExitCriteria([['id' => 'withdrawn', 'if' => ['==' => [['var' => 'json.status'], 'ingetrokken']]]]); + $this->items->seed([$stage, $advice]); + $this->realiser->method('terminalOutcome')->willReturn(null); + + $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT); + $this->assertSame(['assessment' => 'active', 'advice' => 'available'], $this->states(), 'Discretionary: not auto-entered; stage stays open.'); + + $advice->setState(CaseItem::STATE_ENABLED); + $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT, actor: 'alice'); + $this->assertSame('active', $this->states()['advice']); + $this->assertSame('alice', $this->audits->findForItem(2)[0]->getActor()); + $this->assertSame('enable', $this->audits->findForItem(2)[0]->getCauseRef()); + + $this->anchor = $this->createMock(CaseAnchorReader::class); + $this->anchor->method('read')->willReturn(['status' => 'ingetrokken']); + $this->realiser->expects($this->once())->method('terminate')->with($advice, "Exit criterion 'withdrawn' fired."); + $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT); + $this->assertSame('terminated', $this->states()['advice']); + $this->assertSame('assessment', array_search('active', $this->states(), true), 'Only optional children were ever there: the stage stays open.'); + }//end testOptionalChildrenKeepAStageOpenAndEnabledItemsStart() + + /** + * A cycle hits the bound: the failure names it, and the plan is rolled back. + * + * @return void + */ + public function testAnUnboundedCascadeFailsLoudlyAndRollsBack(): void { + // Two repeating items that admit each other: a completes -> b enters and + // (via the realiser) completes at once -> a's next realisation enters ... + $first = CaseFixtures::row(id: 1, key: 'ping', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $first->setRepetition(['max' => 1000]); + $second = CaseFixtures::row(id: 2, key: 'pong', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $second->setRepetition(['max' => 1000]); + $this->items->seed([$first, $second]); + $this->realiser->method('terminalOutcome')->willReturn(CaseItem::STATE_COMPLETED); + + try { + $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT); + $this->fail('must hit the bound'); + } catch (CaseCascadeBoundException $bound) { + $this->assertStringContainsString((string)CasePlanCascade::MAX_CASCADE_DEPTH, $bound->getMessage()); + $this->assertStringContainsString('MAX_CASCADE_DEPTH', $bound->getMessage()); + $this->assertStringContainsString('rolled back', $bound->getMessage()); + } + + $this->assertSame(0, $this->depth, 'The outer transaction was rolled back, not committed.'); + }//end testAnUnboundedCascadeFailsLoudlyAndRollsBack() + + /** + * Repetition: a completed repeating row grows exactly one next row per + * pass, each realisation its own task; the item is terminal iff every + * realisation is terminal and the rule is exhausted. + * + * @return void + */ + public function testRepetitionGrowsRowsAndTheTerminalIffInvariantHolds(): void { + $stage = CaseFixtures::row(id: 1, key: 's', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_AVAILABLE); + $docs = CaseFixtures::row(id: 2, key: 'docs', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE, parentId: 1); + $docs->setRepetition(['max' => 2]); + $docs->setCandidateGroups(['g']); + $this->items->seed([$stage, $docs]); + $outcomes = ['task-for-docs-1' => CaseItem::STATE_COMPLETED]; + $this->realiser->method('terminalOutcome')->willReturnCallback( + static function (CaseItem $row) use (&$outcomes): ?string { + return ($outcomes[(string)$row->getRealisationUuid()] ?? null); + } + ); + + $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT); + $rows = (new CasePlanTree(items: $this->items->findByObject(CaseFixtures::OBJECT)))->rowsForKey(key: 'docs'); + $this->assertCount(2, $rows, 'One plan item, two realisation rows.'); + $this->assertSame([1, 2], [(int)$rows[0]->getRealisationCount(), (int)$rows[1]->getRealisationCount()]); + $this->assertSame(['completed', 'active'], [$rows[0]->getState(), $rows[1]->getState()]); + $this->assertSame(['g'], $rows[1]->getCandidateGroups(), 'The definition is carried onto the next realisation.'); + $this->assertSame('item-2', $this->audits->findForItem((int)$rows[1]->getId())[0]->getCauseRef(), 'Creation names the previous realisation.'); + $this->assertSame('active', $this->states()['s'], 'Stage waits: realisation 2 is active.'); + $this->assertFalse((new CasePlanTree(items: $this->items->findByObject(CaseFixtures::OBJECT)))->isItemTerminal(item: $rows[0])); + + $outcomes['task-for-docs-2'] = CaseItem::STATE_COMPLETED; + $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT); + $tree = new CasePlanTree(items: $this->items->findByObject(CaseFixtures::OBJECT)); + $this->assertCount(2, $tree->rowsForKey(key: 'docs'), 'max 2: no third row.'); + $this->assertTrue($tree->isItemTerminal(item: $rows[0])); + $this->assertSame('completed', $this->states()['s'], 'Now the stage completes.'); + }//end testRepetitionGrowsRowsAndTheTerminalIffInvariantHolds() + + /** + * A run realisation that stopped terminates its stage (with cascade), a + * missing realisation counts as terminated, and the run itself is never + * written: marking, status and log are byte-identical after evaluation. + * + * @return void + */ + public function testARunDrivesItsStageAndIsNeverWritten(): void { + $run = new FlowRun(); + $run->setUuid('run-1'); + $run->setStatus(FlowRun::STATUS_SUSPENDED); + $run->setMarking(['node-a' => 1]); + $run->setLog([['step' => 'a']]); + $run->resetUpdatedFields(); + $before = serialize($run); + + $stage = CaseFixtures::row(id: 1, key: 'auto', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $stage->setRealisationKind(CaseItem::REALISATION_RUN); + $stage->setRealisationUuid('run-1'); + $child = CaseFixtures::row(id: 2, key: 'c', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE, parentId: 1); + $child->setEntryCriteria([['id' => 'never', 'if' => ['==' => [['var' => 'json.nope'], 1]]]]); + $this->items->seed([$stage, $child]); + + $this->realiser->method('terminalOutcome')->willReturn(null); + $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT); + $this->assertSame('active', $this->states()['auto'], 'A run-bound stage is not completed by the children rule.'); + $this->assertSame($before, serialize($run)); + $this->assertSame([], $run->getUpdatedFields()); + + $this->realiser = $this->createMock(CaseRealisationService::class); + $this->realiser->method('terminalOutcome')->willReturn(CaseItem::STATE_TERMINATED); + $this->cascade()->evaluate(objectUuid: CaseFixtures::OBJECT); + $this->assertSame(['auto' => 'terminated', 'c' => 'terminated'], $this->states()); + $this->assertSame($before, serialize($run), 'Marking, status and log untouched.'); + }//end testARunDrivesItsStageAndIsNeverWritten() + + /** + * An empty plan is a no-op; re-entry during evaluation is skipped, not looped. + * + * @return void + */ + public function testEmptyPlansAndReentryAreNoOps(): void { + $cascade = $this->cascade(); + $this->assertSame(['passes' => 1, 'transitions' => 0, 'skipped' => false], $cascade->evaluate(objectUuid: 'nothing-here')); + + $milestone = CaseFixtures::row(id: 1, key: 'm', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE); + $this->items->seed([$milestone]); + $reentered = null; + $this->anchor = $this->createMock(CaseAnchorReader::class); + $this->anchor->method('read')->willReturnCallback( + function () use (&$reentered, &$cascade): array { + // A write-through's object event arriving inside our own transaction. + $reentered = $cascade->evaluate(objectUuid: CaseFixtures::OBJECT); + + return []; + } + ); + $cascade = $this->cascade(); + $result = $cascade->evaluate(objectUuid: CaseFixtures::OBJECT); + $this->assertTrue($reentered['skipped']); + $this->assertSame(1, $result['transitions']); + $this->assertSame('completed', $this->states()['m']); + }//end testEmptyPlansAndReentryAreNoOps() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanDefinitionTest.php b/tests/Unit/Service/Case/CasePlanDefinitionTest.php new file mode 100644 index 0000000000..5bfcc91d31 --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanDefinitionTest.php @@ -0,0 +1,238 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use DateTime; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseValidationException; +use OCA\OpenRegister\Service\Case\CasePlanDefinition; +use OCA\OpenRegister\Service\Case\CaseSentryEvaluator; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use PHPUnit\Framework\TestCase; + +/** + * Coverage of CasePlanDefinition. + * + * @covers \OCA\OpenRegister\Service\Case\CasePlanDefinition + */ +class CasePlanDefinitionTest extends TestCase { + + /** + * The compiler over the real evaluator and catalog. + * + * @return CasePlanDefinition The compiler. + */ + private function definitions(): CasePlanDefinition { + return new CasePlanDefinition(sentries: new CaseSentryEvaluator(catalog: new EventCatalogService())); + }//end definitions() + + /** + * A valid two-stage definition normalises, collecting flow bindings. + * + * @return void + */ + public function testAValidDefinitionNormalises(): void { + $normalised = $this->definitions()->validate(definition: self::permitDefinition()); + + $this->assertSame(['demo-behandelaars'], $normalised['settings']['authorization']); + $this->assertSame(['run-stage' => 'flow-uuid-1'], $normalised['settings']['flows']); + $this->assertCount(3, $normalised['items']); + $this->assertSame([], $normalised['items'][0]['children'][0]['children'], 'A leaf gets an empty children list.'); + $this->assertSame('assessment', $normalised['items'][1]['key']); + }//end testAValidDefinitionNormalises() + + /** + * Every refusal the boundary makes. + * + * @return void + */ + public function testRefusals(): void { + $definitions = $this->definitions(); + $item = static fn (array $over): array => ['items' => [array_merge(['key' => 'a', 'type' => CaseItem::TYPE_HUMAN_TASK], $over)]]; + $refusals = [ + 'no items' => ['items' => []], + 'settings not object' => ['items' => [['key' => 'a', 'type' => 'stage']], 'settings' => 'x'], + 'bad root rules' => ['items' => [['key' => 'a', 'type' => 'stage']], 'settings' => ['authorization' => [1]]], + 'bad results' => ['items' => [['key' => 'a', 'type' => 'stage']], 'settings' => ['results' => 'verleend']], + 'bad writeThrough' => ['items' => [['key' => 'a', 'type' => 'stage']], 'settings' => ['writeThrough' => 'status']], + 'node not object' => ['items' => ['a']], + 'bad key' => $item(['key' => 'has space']), + 'unknown type' => $item(['type' => 'processTask']), + 'unknown event' => $item(['entryCriteria' => [['on' => ['event' => 'case.item.started']]]]), + 'bad exit if' => $item(['exitCriteria' => [['if' => ['nonsuch' => 1]]]]), + 'bad rules' => $item(['authorization' => 'behandelaars']), + 'bad repetition' => $item(['repetition' => ['max' => 0]]), + 'discretionary milestone' => $item(['type' => CaseItem::TYPE_MILESTONE, 'discretionary' => true]), + 'children on a task' => $item(['children' => []]), + 'flow on a task' => $item(['flow' => 'f']), + 'children not list' => ['items' => [['key' => 'a', 'type' => 'stage', 'children' => 'x']]], + 'flow and children' => ['items' => [['key' => 'a', 'type' => 'stage', 'flow' => 'f', 'children' => [['key' => 'b', 'type' => 'milestone']]]]], + 'duplicate key' => ['items' => [['key' => 'a', 'type' => 'stage', 'children' => [['key' => 'a', 'type' => 'milestone']]]]], + ]; + foreach ($refusals as $label => $definition) { + try { + $definitions->validate(definition: $definition); + $this->fail("$label must be refused"); + } catch (CaseValidationException $refusal) { + $this->assertNotSame('', $refusal->getMessage(), $label); + } + } + + $this->assertStringContainsString('case.item.started', $this->refusalMessage($definitions, $item(['entryCriteria' => [['on' => ['event' => 'case.item.started']]]]))); + }//end testRefusals() + + /** + * An ad-hoc item may not guard itself, be discretionary, nest, or bind a flow. + * + * @return void + */ + public function testAdHocRefusals(): void { + $definitions = $this->definitions(); + foreach ([ + ['key' => 'x', 'type' => 'humanTask', 'authorization' => []], + ['key' => 'x', 'type' => 'humanTask', 'discretionary' => true], + ['key' => 'x', 'type' => 'stage', 'children' => []], + ['key' => 'x', 'type' => 'stage', 'flow' => 'f'], + ] as $node) { + try { + $definitions->validateAdHoc(node: $node); + $this->fail('must be refused'); + } catch (CaseValidationException) { + $this->addToAssertionCount(1); + } + } + + $ok = $definitions->validateAdHoc(node: ['key' => 'advice', 'type' => 'humanTask', 'name' => 'Advies']); + $this->assertSame('advice', $ok['key']); + }//end testAdHocRefusals() + + /** + * A compiled row carries every field, starts available and non-terminal, + * and an ad-hoc row has no definition key. + * + * @return void + */ + public function testRowFromCompilesEveryField(): void { + $definitions = $this->definitions(); + $node = [ + 'key' => 'check', + 'type' => CaseItem::TYPE_HUMAN_TASK, + 'name' => ' Controle ', + 'description' => 'd', + 'required' => false, + 'discretionary' => true, + 'entryCriteria' => [['if' => ['var' => 'json.a']]], + 'repetition' => ['max' => 2], + 'authorization' => ['g'], + 'candidateUsers' => ['u1'], + 'candidateGroups' => ['g1'], + 'candidateRole' => 'r', + 'dueAt' => '2026-09-04T17:00:00+02:00', + 'doorlooptijd' => 'P8W', + 'servicenorm' => 'P6W', + ]; + $row = $definitions->rowFrom(node: $node, objectUuid: 'obj', registerId: 1, schemaId: 2, parentId: 9, position: 3, settings: ['a' => 1], origin: CaseItem::ORIGIN_DISCRETIONARY, actor: 'alice', flowUuid: 'f', flowVersion: 4); + + $this->assertSame('check', $row->getItemKey()); + $this->assertSame('check', $row->getDefinitionItemKey()); + $this->assertSame('Controle', $row->getName()); + $this->assertSame(CaseItem::STATE_AVAILABLE, $row->getState()); + $this->assertFalse($row->getIsTerminal()); + $this->assertFalse($row->getRequired()); + $this->assertTrue($row->getDiscretionary()); + $this->assertSame(['max' => 2], $row->getRepetition()); + $this->assertSame(['g'], $row->getAuthorizationRules()); + $this->assertSame(['u1'], $row->getCandidateUsers()); + $this->assertSame('r', $row->getCandidateRole()); + $this->assertInstanceOf(DateTime::class, $row->getDueAt()); + $this->assertNull($row->getExpiresAt()); + $this->assertSame('P8W', $row->getDoorlooptijd()); + $this->assertSame(9, $row->getParentItemId()); + $this->assertSame(3, $row->getPosition()); + $this->assertSame(['a' => 1], $row->getPlanSettings()); + $this->assertSame('alice', $row->getCreatedBy()); + $this->assertSame('f', $row->getFlowUuid()); + $this->assertSame(4, $row->getFlowVersion()); + $this->assertSame(1, $row->getRealisationCount()); + + $adhoc = $definitions->rowFrom(node: ['key' => 'x', 'type' => 'milestone'], objectUuid: 'obj', registerId: null, schemaId: null, parentId: null, position: 0, settings: [], origin: CaseItem::ORIGIN_ADHOC, actor: null); + $this->assertNull($adhoc->getDefinitionItemKey(), 'An ad-hoc item is in no definition.'); + $this->assertTrue($adhoc->getRequired(), 'Required defaults to true.'); + $this->assertNull($adhoc->getEntryCriteria()); + + $this->expectException(CaseValidationException::class); + $definitions->rowFrom(node: ['key' => 'x', 'type' => 'milestone', 'dueAt' => 'not a date'], objectUuid: 'obj', registerId: null, schemaId: null, parentId: null, position: 0, settings: [], origin: CaseItem::ORIGIN_DEFINED, actor: null); + }//end testRowFromCompilesEveryField() + + /** + * The message of a refusal. + * + * @param CasePlanDefinition $definitions The compiler. + * @param array $definition The definition. + * + * @return string The message. + */ + private function refusalMessage(CasePlanDefinition $definitions, array $definition): string { + try { + $definitions->validate(definition: $definition); + } catch (CaseValidationException $refusal) { + return $refusal->getMessage(); + } + + return ''; + }//end refusalMessage() + + /** + * The two-stage permit definition (design.md seed 1 + 2), plus a flow-bound stage. + * + * @return array The definition. + */ + public static function permitDefinition(): array { + return [ + 'settings' => [ + 'authorization' => ['demo-behandelaars'], + 'results' => ['verleend', 'geweigerd'], + 'writeThrough' => ['statusField' => 'status', 'statusAtField' => 'statusReachedAt', 'resultField' => 'resultaat'], + ], + 'items' => [ + [ + 'key' => 'intake', + 'type' => CaseItem::TYPE_STAGE, + 'name' => 'Intake', + 'children' => [ + ['key' => 'completeness-check', 'type' => CaseItem::TYPE_HUMAN_TASK, 'name' => 'Controleer volledigheid', 'candidateGroups' => ['demo-behandelaars']], + ['key' => 'application-complete', 'type' => CaseItem::TYPE_MILESTONE, 'name' => 'Aanvraag volledig', 'entryCriteria' => [['id' => 'complete', 'on' => ['event' => 'case.item.completed', 'item' => 'completeness-check']]]], + ], + ], + [ + 'key' => 'assessment', + 'type' => CaseItem::TYPE_STAGE, + 'name' => 'Beoordeling', + 'entryCriteria' => [['id' => 'after-intake', 'on' => ['event' => 'case.item.completed', 'item' => 'application-complete']]], + 'children' => [ + ['key' => 'external-advice', 'type' => CaseItem::TYPE_HUMAN_TASK, 'name' => 'Extern advies', 'discretionary' => true, 'required' => false, 'authorization' => ['demo-beslissers']], + ['key' => 'decide', 'type' => CaseItem::TYPE_HUMAN_TASK, 'name' => 'Besluit', 'candidateGroups' => ['demo-beslissers']], + ], + ], + ['key' => 'run-stage', 'type' => CaseItem::TYPE_STAGE, 'name' => 'Automated', 'required' => false, 'flow' => 'flow-uuid-1', 'entryCriteria' => [['id' => 'never', 'if' => ['==' => [['var' => 'json.automate'], true]]]]], + ], + ]; + }//end permitDefinition() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanServiceTest.php b/tests/Unit/Service/Case/CasePlanServiceTest.php new file mode 100644 index 0000000000..985ee705f7 --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanServiceTest.php @@ -0,0 +1,531 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Exception\CaseAccessDeniedException; +use OCA\OpenRegister\Exception\CaseTransitionException; +use OCA\OpenRegister\Exception\CaseValidationException; +use OCA\OpenRegister\Service\Case\CaseAnchorReader; +use OCA\OpenRegister\Service\Case\CaseBusinessStateWriter; +use OCA\OpenRegister\Service\Case\CasePlanAuthorizationService; +use OCA\OpenRegister\Service\Case\CasePlanCascade; +use OCA\OpenRegister\Service\Case\CasePlanDefinition; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCA\OpenRegister\Service\Case\CasePlanStateMachine; +use OCA\OpenRegister\Service\Case\CasePlanTransitions; +use OCA\OpenRegister\Service\Case\CaseRealisationService; +use OCA\OpenRegister\Service\Case\CaseSentryEvaluator; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IDBConnection; +use OCP\IGroupManager; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Coverage of CasePlanService over the real machine, cascade, evaluator, + * authorization and compiler, with the mappers, realiser and object access faked. + * + * @covers \OCA\OpenRegister\Service\Case\CasePlanService + * @covers \OCA\OpenRegister\Service\Case\CasePlanCascade + * @covers \OCA\OpenRegister\Service\Case\CasePlanStateMachine + * @covers \OCA\OpenRegister\Service\Case\CasePlanAuthorizationService + */ +class CasePlanServiceTest extends TestCase { + + /** + * Rows. + * + * @var FakeCaseItemMapper + */ + private FakeCaseItemMapper $items; + + /** + * Audit. + * + * @var RecordingAuditMapper + */ + private RecordingAuditMapper $audits; + + /** + * Realiser. + * + * @var CaseRealisationService&MockObject + */ + private CaseRealisationService&MockObject $realiser; + + /** + * Anchor. + * + * @var CaseAnchorReader&MockObject + */ + private CaseAnchorReader&MockObject $anchor; + + /** + * Writer. + * + * @var CaseBusinessStateWriter&MockObject + */ + private CaseBusinessStateWriter&MockObject $writer; + + /** + * Task outcomes by realisation uuid. + * + * @var array + */ + private array $outcomes = []; + + /** + * Fresh collaborators. Groups: alice in demo-behandelaars, bob in + * demo-beslissers, boss is admin, `ghost` does not exist. + * + * @return void + */ + protected function setUp(): void { + $this->items = new FakeCaseItemMapper($this); + $this->audits = new RecordingAuditMapper($this); + $this->outcomes = []; + $this->realiser = $this->createMock(CaseRealisationService::class); + $this->realiser->method('realise')->willReturnCallback( + static function (CaseItem $row): void { + $row->setRealisationKind(CaseItem::REALISATION_NONE); + if ($row->getPlanItemType() === CaseItem::TYPE_HUMAN_TASK) { + $row->setRealisationKind(CaseItem::REALISATION_TASK); + $row->setRealisationUuid('task-for-' . (string)$row->getItemKey()); + } + } + ); + $this->realiser->method('terminalOutcome')->willReturnCallback( + fn (CaseItem $row): ?string => ($this->outcomes[(string)$row->getRealisationUuid()] ?? null) + ); + $this->anchor = $this->createMock(CaseAnchorReader::class); + $this->anchor->method('read')->willReturn(['status' => 'open']); + $this->anchor->method('mayRead')->willReturn(true); + $this->writer = $this->createMock(CaseBusinessStateWriter::class); + }//end setUp() + + /** + * The service. + * + * @return CasePlanService The service. + */ + private function service(): CasePlanService { + $db = $this->createMock(IDBConnection::class); + $db->method('inTransaction')->willReturn(false); + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturnCallback(static fn (string $uid): bool => $uid === 'boss'); + $groups->method('isInGroup')->willReturnCallback( + static fn (string $uid, string $gid): bool => ($uid === 'alice' && $gid === 'demo-behandelaars') || ($uid === 'bob' && $gid === 'demo-beslissers') + ); + $groups->method('groupExists')->willReturnCallback(static fn (string $gid): bool => $gid !== 'ghost'); + $sentries = new CaseSentryEvaluator(catalog: new EventCatalogService()); + $machine = new CasePlanStateMachine( + items: $this->items, + audits: $this->audits, + table: new CasePlanTransitions(), + realiser: $this->realiser, + writer: $this->writer, + db: $db, + logger: new NullLogger() + ); + + return new CasePlanService( + items: $this->items, + audits: $this->audits, + machine: $machine, + cascade: new CasePlanCascade(items: $this->items, machine: $machine, sentries: $sentries, realiser: $this->realiser, anchor: $this->anchor, db: $db, logger: new NullLogger()), + sentries: $sentries, + authorization: new CasePlanAuthorizationService(groupManager: $groups), + anchor: $this->anchor, + writer: $this->writer, + definitions: new CasePlanDefinition(sentries: $sentries), + db: $db, + logger: new NullLogger() + ); + }//end service() + + /** + * The plan's states by key. + * + * @param array $plan A getPlan() result. + * + * @return array key => state. + */ + private static function states(array $plan): array { + $states = []; + foreach ($plan['items'] as $item) { + $states[$item['key']] = $item['state']; + } + + ksort($states); + + return $states; + }//end states() + + /** + * Creating the permit plan enters intake and realises the check, without + * any run; reading it needs no run uuid; a stranger who cannot read the + * object gets "no such plan". + * + * @return void + */ + public function testCreateEntersThePlanAndReadsAreVisibilityGated(): void { + $plan = $this->service()->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'alice'); + + $expected = ['intake' => 'active', 'completeness-check' => 'active', 'application-complete' => 'available', 'assessment' => 'available', 'external-advice' => 'available', 'decide' => 'available', 'run-stage' => 'available']; + ksort($expected); + $this->assertSame($expected, self::states($plan)); + $this->assertSame(['demo-behandelaars'], $plan['settings']['authorization']); + $origins = array_column($plan['items'], 'origin', 'key'); + $this->assertSame(CaseItem::ORIGIN_DISCRETIONARY, $origins['external-advice']); + $this->assertSame(CaseItem::ORIGIN_DEFINED, $origins['decide']); + $this->assertSame('->available (import)', $this->audits->trail(1)[0]); + $this->assertNotEmpty($plan['audit']); + foreach ($plan['items'] as $item) { + $this->assertNull($item['flowUuid'], 'No run, no flow: the plan stands on the object alone.'); + } + + try { + $this->service()->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'alice'); + $this->fail('one plan per object'); + } catch (CaseValidationException $refusal) { + $this->assertStringContainsString('already has a case plan', $refusal->getMessage()); + } + + $this->anchor = $this->createMock(CaseAnchorReader::class); + $this->anchor->method('mayRead')->willReturn(false); + try { + $this->service()->getPlan(objectUuid: CaseFixtures::OBJECT, uid: 'stranger'); + $this->fail('invisible reads as absent'); + } catch (DoesNotExistException) { + $this->addToAssertionCount(1); + } + + $this->assertCount(7, $this->service()->getPlan(objectUuid: CaseFixtures::OBJECT, uid: 'boss')['items'], 'An administrator sees it regardless.'); + $this->expectException(DoesNotExistException::class); + $this->service()->getPlan(objectUuid: 'no-plan', uid: 'boss'); + }//end testCreateEntersThePlanAndReadsAreVisibilityGated() + + /** + * Creation is refused at the boundary before anything is written: an + * unknown event, and a caller who could not administer the plan. + * + * @return void + */ + public function testCreationRefusesBeforeWriting(): void { + $bad = CasePlanDefinitionTest::permitDefinition(); + $bad['items'][0]['entryCriteria'] = [['on' => ['event' => 'case.item.started']]]; + try { + $this->service()->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: $bad, uid: 'alice'); + $this->fail('unknown event'); + } catch (CaseValidationException $refusal) { + $this->assertStringContainsString("'case.item.started'", $refusal->getMessage()); + } + + try { + $this->service()->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'bob'); + $this->fail('bob does not hold the root authorization'); + } catch (CaseAccessDeniedException) { + $this->addToAssertionCount(1); + } + + $this->assertSame([], $this->items->rows, 'No plan item was created.'); + $this->assertSame([], $this->audits->entries); + }//end testCreationRefusesBeforeWriting() + + /** + * Enable: a non-member is denied with the denial audited and nothing + * else written; an unresolvable role denies; a member enables, the item + * starts, and the enableable query reflects it. + * + * @return void + */ + public function testEnableIsAuthorizedFailClosedAndAudited(): void { + $service = $this->service(); + $service->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'alice'); + // Reach assessment: the check completes. + $this->outcomes['task-for-completeness-check'] = CaseItem::STATE_COMPLETED; + $service->evaluate(objectUuid: CaseFixtures::OBJECT, uid: 'alice'); + $this->assertSame('active', self::states($service->getPlan(objectUuid: CaseFixtures::OBJECT, uid: 'alice'))['assessment']); + + $advice = $this->items->findByUuid('item-5'); + $this->assertSame('external-advice', $advice->getItemKey()); + $enableable = $service->enableableItems(objectUuid: CaseFixtures::OBJECT, uid: 'alice'); + $this->assertSame(['external-advice'], array_column($enableable, 'key')); + + $before = count($this->audits->entries); + try { + $service->enableDiscretionary(itemUuid: 'item-5', uid: 'alice'); + $this->fail('alice is a behandelaar, the item wants beslissers'); + } catch (CaseAccessDeniedException) { + $this->addToAssertionCount(1); + } + + $this->assertSame(CaseItem::STATE_AVAILABLE, $advice->getState()); + $this->assertCount($before + 1, $this->audits->entries, 'Exactly the denial was recorded.'); + $denial = $this->audits->entries[$before]; + $this->assertFalse($denial->getAuthorized()); + $this->assertSame('alice', $denial->getActor()); + $this->assertSame(CaseItem::STATE_ENABLED, $denial->getToState()); + + $advice->setAuthorizationRules(['role:ghost']); + try { + $service->enableDiscretionary(itemUuid: 'item-5', uid: 'bob'); + $this->fail('indeterminate role'); + } catch (CaseAccessDeniedException $refusal) { + $this->assertStringContainsString("'ghost'", $refusal->getMessage()); + } + + $advice->setAuthorizationRules(['demo-beslissers']); + $enabled = $service->enableDiscretionary(itemUuid: 'item-5', uid: 'bob'); + $this->assertSame(CaseItem::STATE_ACTIVE, $enabled->getState(), 'Enabled, then started by the evaluation.'); + $this->assertSame('task-for-external-advice', $enabled->getRealisationUuid()); + $authorized = array_values(array_filter($this->audits->findForItem(5), static fn (CaseItemAudit $entry): bool => $entry->getAuthorized() === true)); + $this->assertSame( + ['->available (import)', 'available->enabled (user)', 'enabled->active (user)'], + array_map(static fn (CaseItemAudit $entry): string => sprintf('%s->%s (%s)', (string)$entry->getFromState(), (string)$entry->getToState(), (string)$entry->getCause()), $authorized) + ); + $this->assertCount(2, array_filter($this->audits->findForItem(5), static fn (CaseItemAudit $entry): bool => $entry->getAuthorized() === false), 'Both denials are on record.'); + $this->assertSame([], $service->enableableItems(objectUuid: CaseFixtures::OBJECT, uid: 'alice')); + + // A non-discretionary item is refused as not enableable, once the + // caller (alice holds the root rules) is past authorization. + $this->expectException(CaseValidationException::class); + $service->enableDiscretionary(itemUuid: 'item-6', uid: 'alice'); + }//end testEnableIsAuthorizedFailClosedAndAudited() + + /** + * Attach: an ad-hoc item under the active stage is created, entered and + * realised, inheriting the stage's authorization; a stranger is refused + * with the denial on the parent; a self-declared authorization is refused; + * a non-active parent is refused; no definition changes. + * + * @return void + */ + public function testAttachAdHocDerivesAuthorizationAndEntersAtOnce(): void { + $service = $this->service(); + $service->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'alice'); + $definitionRows = count($this->items->rows); + + try { + $service->attachAdHoc(objectUuid: CaseFixtures::OBJECT, data: ['key' => 'visit', 'type' => 'humanTask', 'parent' => 'intake'], uid: 'stranger'); + $this->fail('stranger'); + } catch (CaseAccessDeniedException) { + $last = end($this->audits->entries); + $this->assertFalse($last->getAuthorized()); + $this->assertSame(1, $last->getCaseItemId(), 'The denial is recorded on the parent stage.'); + } + + try { + $service->attachAdHoc(objectUuid: CaseFixtures::OBJECT, data: ['key' => 'visit', 'type' => 'humanTask', 'parent' => 'intake', 'authorization' => []], uid: 'alice'); + $this->fail('cannot declare itself unguarded'); + } catch (CaseValidationException $refusal) { + $this->assertStringContainsString('cannot declare its own authorization', $refusal->getMessage()); + } + + foreach ([['parent' => 'assessment'], ['parent' => 'completeness-check'], ['parent' => 'nope'], ['key' => 'intake']] as $bad) { + try { + $service->attachAdHoc(objectUuid: CaseFixtures::OBJECT, data: array_merge(['key' => 'visit', 'type' => 'humanTask', 'parent' => 'intake'], $bad), uid: 'alice'); + $this->fail('refused: ' . json_encode($bad)); + } catch (CaseValidationException) { + $this->addToAssertionCount(1); + } + } + + $this->assertCount($definitionRows, $this->items->rows, 'Nothing was written by the refusals.'); + + $visit = $service->attachAdHoc(objectUuid: CaseFixtures::OBJECT, data: ['key' => 'visit', 'type' => 'humanTask', 'name' => 'Locatiebezoek', 'parent' => 'intake', 'required' => false, 'candidateUsers' => ['alice']], uid: 'alice'); + $this->assertSame(CaseItem::ORIGIN_ADHOC, $visit->getOrigin()); + $this->assertNull($visit->getDefinitionItemKey()); + $this->assertNull($visit->getAuthorizationRules(), 'Derived, not declared.'); + $this->assertSame(1, $visit->getParentItemId()); + $this->assertSame(CaseItem::STATE_ACTIVE, $visit->getState(), 'Entered and realised at once.'); + $this->assertSame('task-for-visit', $visit->getRealisationUuid()); + $this->assertSame(['->available (user)', 'available->active (sentry)'], $this->audits->trail((int)$visit->getId())); + $this->assertCount($definitionRows + 1, $this->items->rows); + + // A root-level ad-hoc item derives from the plan root. + $rootLevel = $service->attachAdHoc(objectUuid: CaseFixtures::OBJECT, data: ['key' => 'note', 'type' => 'milestone', 'name' => 'Notitie'], uid: 'alice'); + $this->assertNull($rootLevel->getParentItemId()); + $this->assertSame(CaseItem::STATE_COMPLETED, $rootLevel->getState(), 'A root milestone with no criteria completes at once.'); + + $this->expectException(DoesNotExistException::class); + $service->attachAdHoc(objectUuid: 'no-plan', data: ['key' => 'x', 'type' => 'milestone'], uid: 'alice'); + }//end testAttachAdHocDerivesAuthorizationAndEntersAtOnce() + + /** + * Two overlapping transitions on different items both succeed with their + * own audit rows; a user transition is authorized; an illegal one is refused. + * + * @return void + */ + public function testTransitionsOnDifferentItemsDoNotClobberEachOther(): void { + $service = $this->service(); + $service->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'alice'); + $visitA = $service->attachAdHoc(objectUuid: CaseFixtures::OBJECT, data: ['key' => 'a', 'type' => 'humanTask', 'parent' => 'intake', 'required' => false], uid: 'alice'); + $visitB = $service->attachAdHoc(objectUuid: CaseFixtures::OBJECT, data: ['key' => 'b', 'type' => 'humanTask', 'parent' => 'intake', 'required' => false], uid: 'alice'); + + // Both read as active, then both complete "at the same time": each + // conditional update sees its own row still active. + $this->realiser->expects($this->exactly(2))->method('terminate'); + $doneA = $service->transition(itemUuid: (string)$visitA->getUuid(), to: CaseItem::STATE_COMPLETED, uid: 'alice', reason: 'done a'); + $doneB = $service->transition(itemUuid: (string)$visitB->getUuid(), to: CaseItem::STATE_COMPLETED, uid: 'alice', reason: 'done b'); + $this->assertSame(CaseItem::STATE_COMPLETED, $doneA->getState()); + $this->assertSame(CaseItem::STATE_COMPLETED, $doneB->getState()); + $this->assertSame('active->completed (user)', end($this->audits->trail((int)$visitA->getId()))); + $this->assertSame('active->completed (user)', end($this->audits->trail((int)$visitB->getId()))); + $this->assertSame('done b', $this->audits->findForItem((int)$visitB->getId())[2]->getReason()); + + try { + $service->transition(itemUuid: 'item-3', to: CaseItem::STATE_ACTIVE, uid: 'alice'); + $this->fail('a milestone cannot become active'); + } catch (CaseTransitionException) { + $this->addToAssertionCount(1); + } + + try { + $service->transition(itemUuid: 'item-3', to: 'paused', uid: 'alice'); + $this->fail('not a state'); + } catch (CaseValidationException) { + $this->addToAssertionCount(1); + } + + $this->expectException(CaseAccessDeniedException::class); + $service->transition(itemUuid: 'item-3', to: CaseItem::STATE_COMPLETED, uid: 'stranger'); + }//end testTransitionsOnDifferentItemsDoNotClobberEachOther() + + /** + * Completing the case: a result outside the set is refused naming the set; + * an open required root item refuses; otherwise the result is mirrored. + * Deleting leaves the audit and mirrors nothing. + * + * @return void + */ + public function testCompleteCaseConstrainsTheResultAndDeleteLeavesTheAudit(): void { + $service = $this->service(); + $service->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'alice'); + + try { + $service->completeCase(objectUuid: CaseFixtures::OBJECT, result: 'misschien', uid: 'alice'); + $this->fail('outside the set'); + } catch (CaseValidationException $refusal) { + $this->assertStringContainsString('[verleend, geweigerd]', $refusal->getMessage()); + } + + try { + $service->completeCase(objectUuid: CaseFixtures::OBJECT, result: 'verleend', uid: 'alice'); + $this->fail('intake is still open'); + } catch (CaseValidationException $refusal) { + $this->assertStringContainsString("'intake'", $refusal->getMessage()); + } + + // Finish the work: check, then decide. + $this->outcomes['task-for-completeness-check'] = CaseItem::STATE_COMPLETED; + $this->outcomes['task-for-decide'] = CaseItem::STATE_COMPLETED; + $service->evaluate(objectUuid: CaseFixtures::OBJECT, uid: 'alice'); + $this->writer->expects($this->once())->method('mirrorResult')->with($this->anything(), 'verleend')->willReturn(true); + $finished = $service->completeCase(objectUuid: CaseFixtures::OBJECT, result: 'verleend', uid: 'alice'); + $this->assertSame('verleend', $finished['result']); + $this->assertSame('completed', self::states($finished)['assessment']); + $this->assertSame('available', self::states($finished)['run-stage'], 'Optional and never admitted: not in the way.'); + + try { + $service->completeCase(objectUuid: CaseFixtures::OBJECT, result: 'verleend', uid: 'bob'); + $this->fail('bob may not administer'); + } catch (CaseAccessDeniedException) { + $this->addToAssertionCount(1); + } + + $auditRows = count($this->audits->entries); + $this->assertSame(7, $service->deletePlan(objectUuid: CaseFixtures::OBJECT, uid: 'alice')); + $this->assertCount($auditRows, $this->audits->entries, 'The audit outlives the rows.'); + $this->writer->expects($this->never())->method('mirrorStatus'); + + foreach (['completeCase', 'deletePlan'] as $verb) { + try { + $verb === 'completeCase' + ? $service->completeCase(objectUuid: 'nope', result: 'x', uid: 'boss') + : $service->deletePlan(objectUuid: 'nope', uid: 'boss'); + $this->fail("$verb on no plan"); + } catch (DoesNotExistException) { + $this->addToAssertionCount(1); + } + } + }//end testCompleteCaseConstrainsTheResultAndDeleteLeavesTheAudit() + + /** + * The listener entry points: a task ending evaluates the plans it + * realised; an object event evaluates only objects with a live plan; the + * cross-case listing is an administrator's read with a datastore total. + * + * @return void + */ + public function testEventEntryPointsAndTheStuckQuery(): void { + $service = $this->service(); + $service->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'alice'); + + $this->outcomes['task-for-completeness-check'] = CaseItem::STATE_COMPLETED; + $service->onRealisationTerminal(taskUuid: 'task-for-completeness-check'); + $this->assertSame('completed', self::states($service->getPlan(objectUuid: CaseFixtures::OBJECT, uid: 'alice'))['completeness-check']); + $service->onRealisationTerminal(taskUuid: 'unknown-task'); + + // An object on-part: run-stage waits for json.automate; the event brings it. + $this->anchor = $this->createMock(CaseAnchorReader::class); + $this->anchor->method('read')->willReturn(['automate' => true]); + $this->anchor->method('mayRead')->willReturn(true); + $service = $this->service(); + $service->onObjectEvent(objectUuid: 'object-without-plan', event: 'object.updated', payload: []); + $service->onObjectEvent(objectUuid: CaseFixtures::OBJECT, event: 'object.updated', payload: ['automate' => true]); + $this->assertSame('active', self::states($service->getPlan(objectUuid: CaseFixtures::OBJECT, uid: 'alice'))['run-stage']); + + $page = $service->findStuck(type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE, limit: 1, offset: 0, uid: 'boss'); + $this->assertSame(1, $page['total'], 'decide is the one active human item.'); + $this->assertSame('decide', $page['results'][0]['key']); + $this->assertSame(1, $page['limit']); + + $this->expectException(CaseAccessDeniedException::class); + $service->findStuck(type: null, state: null, limit: 25, offset: 0, uid: 'alice'); + }//end testEventEntryPointsAndTheStuckQuery() + + /** + * A failing event-driven evaluation is logged, never rethrown into the event. + * + * @return void + */ + public function testEventDrivenEvaluationFailuresAreSwallowed(): void { + $service = $this->service(); + $service->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'alice'); + $this->audits->failNext = true; + $this->outcomes['task-for-completeness-check'] = CaseItem::STATE_COMPLETED; + $service->onRealisationTerminal(taskUuid: 'task-for-completeness-check'); + // The in-memory fake has no rollback, so the row itself is not the + // evidence; the audit is: the failed append left no completion entry, + // and the failure never reached the caller. + $completions = array_filter($this->audits->findForItem(2), static fn (CaseItemAudit $entry): bool => $entry->getToState() === CaseItem::STATE_COMPLETED); + $this->assertSame([], $completions); + }//end testEventDrivenEvaluationFailuresAreSwallowed() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanStateMachineTest.php b/tests/Unit/Service/Case/CasePlanStateMachineTest.php new file mode 100644 index 0000000000..6809355af9 --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanStateMachineTest.php @@ -0,0 +1,364 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Event\CaseItemTransitionedEvent; +use OCA\OpenRegister\Exception\CaseTransitionException; +use OCA\OpenRegister\Service\Case\CaseBusinessStateWriter; +use OCA\OpenRegister\Service\Case\CasePlanStateMachine; +use OCA\OpenRegister\Service\Case\CasePlanTransitions; +use OCA\OpenRegister\Service\Case\CasePlanTree; +use OCA\OpenRegister\Service\Case\CaseRealisationService; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * Coverage of CasePlanStateMachine and the events it emits. + * + * @covers \OCA\OpenRegister\Service\Case\CasePlanStateMachine + * @covers \OCA\OpenRegister\Event\CaseItemTransitionedEvent + * @covers \OCA\OpenRegister\Db\CaseItemAudit + */ +class CasePlanStateMachineTest extends TestCase { + + /** + * Rows. + * + * @var FakeCaseItemMapper + */ + private FakeCaseItemMapper $items; + + /** + * Audit. + * + * @var RecordingAuditMapper + */ + private RecordingAuditMapper $audits; + + /** + * Realiser. + * + * @var CaseRealisationService&MockObject + */ + private CaseRealisationService&MockObject $realiser; + + /** + * Writer. + * + * @var CaseBusinessStateWriter&MockObject + */ + private CaseBusinessStateWriter&MockObject $writer; + + /** + * Connection, for transaction assertions. + * + * @var IDBConnection&MockObject + */ + private IDBConnection&MockObject $db; + + /** + * Dispatched events. + * + * @var array + */ + private array $events = []; + + /** + * Fresh collaborators per test. + * + * @return void + */ + protected function setUp(): void { + $this->items = new FakeCaseItemMapper($this); + $this->audits = new RecordingAuditMapper($this); + $this->realiser = $this->createMock(CaseRealisationService::class); + $this->writer = $this->createMock(CaseBusinessStateWriter::class); + $this->db = $this->createMock(IDBConnection::class); + $this->db->method('inTransaction')->willReturn(false); + $this->events = []; + }//end setUp() + + /** + * The machine over the fakes. + * + * @return CasePlanStateMachine The machine. + */ + private function machine(): CasePlanStateMachine { + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + function (object $event): void { + $this->events[] = $event; + } + ); + + return new CasePlanStateMachine( + items: $this->items, + audits: $this->audits, + table: new CasePlanTransitions(), + realiser: $this->realiser, + writer: $this->writer, + db: $this->db, + logger: new NullLogger(), + dispatcher: $dispatcher + ); + }//end machine() + + /** + * Activation realises, stamps entered_at, writes is_terminal with state, + * audits in the transaction and announces after commit. + * + * @return void + */ + public function testActivationRealisesAuditsAndAnnouncesAfterCommit(): void { + $item = CaseFixtures::row(id: 1, key: 'check', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $this->items->seed([$item]); + $this->realiser->expects($this->once())->method('realise')->with($item, 'alice')->willReturnCallback( + static function (CaseItem $row): void { + $row->setRealisationKind(CaseItem::REALISATION_TASK); + $row->setRealisationUuid('task-1'); + } + ); + $this->db->expects($this->once())->method('beginTransaction'); + $this->db->expects($this->once())->method('commit'); + $this->db->expects($this->never())->method('rollBack'); + + $moved = $this->machine()->transition(item: $item, to: CaseItem::STATE_ACTIVE, cause: CaseItemAudit::CAUSE_SENTRY, causeRef: 'entry:default', actor: 'alice'); + + $this->assertSame(CaseItem::STATE_ACTIVE, $moved->getState()); + $this->assertFalse($moved->getIsTerminal()); + $this->assertNotNull($moved->getEnteredAt()); + $this->assertSame('task-1', $moved->getRealisationUuid()); + $this->assertSame(['available->active (sentry)'], $this->audits->trail(1)); + $this->assertSame('entry:default', $this->audits->entries[0]->getCauseRef()); + $this->assertTrue($this->audits->entries[0]->getAuthorized()); + $this->assertCount(1, $this->events); + $this->assertSame('available', $this->events[0]->getFromState()); + $this->assertNull($this->events[0]->getCatalogTrigger(), 'Active is not a catalog event.'); + $this->assertSame(['uuid' => CaseFixtures::OBJECT, 'register' => '1', 'schema' => '1'], $this->events[0]->getSubject()); + $this->assertSame('alice', $this->audits->entries[0]->getActor()); + $this->assertSame(1, $this->audits->entries[0]->jsonSerialize()['id']); + }//end testActivationRealisesAuditsAndAnnouncesAfterCommit() + + /** + * Completing a milestone mirrors status; completing via the realisation + * does NOT terminate the realisation; a user completion of a task item does. + * + * @return void + */ + public function testCompletionMirrorsAMilestoneAndClosesARealisationOnlyWhenItDidNotCauseIt(): void { + $milestone = CaseFixtures::row(id: 1, key: 'm', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE); + $viaTask = CaseFixtures::row(id: 2, key: 't1', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $viaTask->setRealisationKind(CaseItem::REALISATION_TASK); + $viaTask->setRealisationUuid('task-a'); + $byUser = CaseFixtures::row(id: 3, key: 't2', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $byUser->setRealisationKind(CaseItem::REALISATION_TASK); + $byUser->setRealisationUuid('task-b'); + $this->items->seed([$milestone, $viaTask, $byUser]); + + $this->writer->expects($this->once())->method('mirrorStatus')->with($milestone); + $this->realiser->expects($this->once())->method('terminate')->with($byUser, 'done by hand'); + $machine = $this->machine(); + + $machine->transition(item: $milestone, to: CaseItem::STATE_COMPLETED, cause: CaseItemAudit::CAUSE_SENTRY, causeRef: 's', actor: null); + $machine->transition(item: $viaTask, to: CaseItem::STATE_COMPLETED, cause: CaseItemAudit::CAUSE_REALISATION, causeRef: 'task-a', actor: null); + $machine->transition(item: $byUser, to: CaseItem::STATE_COMPLETED, cause: CaseItemAudit::CAUSE_USER, causeRef: null, actor: 'alice', reason: 'done by hand'); + + $this->assertTrue($milestone->getIsTerminal()); + $this->assertNotNull($milestone->getEnteredAt(), 'A milestone is entered when it completes.'); + $this->assertSame(CasePlanStateMachine::SYSTEM_ACTOR, $this->audits->entries[0]->getActor()); + $this->assertSame('case.item.completed', $this->events[0]->getCatalogTrigger()); + $this->assertCount(3, $this->events); + }//end testCompletionMirrorsAMilestoneAndClosesARealisationOnlyWhenItDidNotCauseIt() + + /** + * Terminating a stage cascades: entered children terminated, unentered + * disabled (a milestone terminated), nested stages recursively, each with + * its own `cascade` audit row naming the parent; terminal children untouched. + * + * @return void + */ + public function testTerminatingAStageCascadesWithPerChildAudit(): void { + $stage = CaseFixtures::row(id: 1, key: 'hearing', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $active = CaseFixtures::row(id: 2, key: 'invite', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE, parentId: 1); + $active->setRealisationKind(CaseItem::REALISATION_TASK); + $active->setRealisationUuid('task-1'); + $unentered = CaseFixtures::row(id: 3, key: 'report', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE, parentId: 1); + $milestone = CaseFixtures::row(id: 4, key: 'heard', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE, parentId: 1); + $done = CaseFixtures::row(id: 5, key: 'done', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_COMPLETED, parentId: 1); + $inner = CaseFixtures::row(id: 6, key: 'inner', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE, parentId: 1); + $innerChild = CaseFixtures::row(id: 7, key: 'deep', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ENABLED, parentId: 6); + $this->items->seed([$stage, $active, $unentered, $milestone, $done, $inner, $innerChild]); + $this->realiser->expects($this->once())->method('terminate')->with($active, $this->stringContains("Stage 'hearing' exited")); + + $this->machine()->transition( + item: $stage, + to: CaseItem::STATE_TERMINATED, + cause: CaseItemAudit::CAUSE_USER, + causeRef: null, + actor: 'alice', + reason: 'Bezwaar ingetrokken.', + tree: new CasePlanTree(items: $this->items->findByObject(CaseFixtures::OBJECT)) + ); + + $this->assertSame('Bezwaar ingetrokken.', $stage->getTerminatedReason()); + $this->assertSame(CaseItem::STATE_TERMINATED, $active->getState()); + $this->assertSame(CaseItem::STATE_DISABLED, $unentered->getState()); + $this->assertSame(CaseItem::STATE_TERMINATED, $milestone->getState(), 'A milestone has no disabled edge.'); + $this->assertSame(CaseItem::STATE_COMPLETED, $done->getState(), 'Terminal children are left alone.'); + $this->assertSame(CaseItem::STATE_TERMINATED, $inner->getState()); + $this->assertSame(CaseItem::STATE_TERMINATED, $innerChild->getState(), 'Nested stages cascade in turn.'); + + foreach ([2, 3, 4, 6, 7] as $id) { + $entries = $this->audits->findForItem($id); + $this->assertCount(1, $entries, "child $id has exactly one audit row"); + $this->assertSame(CaseItemAudit::CAUSE_CASCADE, $entries[0]->getCause()); + $this->assertSame($id === 7 ? 'item-6' : 'item-1', $entries[0]->getCauseRef(), 'The cause_ref is the exited parent.'); + $this->assertTrue($this->items->rows[$id]->getIsTerminal()); + } + + $this->assertSame([], $this->audits->findForItem(5)); + $this->assertCount(6, $this->events, 'One event per transition, all after the one commit.'); + }//end testTerminatingAStageCascadesWithPerChildAudit() + + /** + * An illegal transition is refused before any write; a lost race rolls + * back and is refused naming the state. + * + * @return void + */ + public function testIllegalAndRacedTransitionsAreRefused(): void { + $milestone = CaseFixtures::row(id: 1, key: 'm', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE); + $raced = CaseFixtures::row(id: 2, key: 'r', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $this->items->seed([$milestone, $raced]); + $machine = $this->machine(); + + try { + $machine->transition(item: $milestone, to: CaseItem::STATE_ACTIVE, cause: CaseItemAudit::CAUSE_USER, causeRef: null, actor: 'a'); + $this->fail('illegal'); + } catch (CaseTransitionException) { + $this->assertSame(0, $this->items->updates, 'Refused before any write.'); + $this->assertSame([], $this->audits->entries); + } + + $this->items->failUpdateFor['item-2'] = true; + $this->db->expects($this->once())->method('rollBack'); + try { + $machine->transition(item: $raced, to: CaseItem::STATE_COMPLETED, cause: CaseItemAudit::CAUSE_USER, causeRef: null, actor: 'a'); + $this->fail('raced'); + } catch (CaseTransitionException $lost) { + $this->assertStringContainsString("moved concurrently out of 'active'", $lost->getMessage()); + } + + $this->assertSame([], $this->events, 'Nothing is announced after a rollback.'); + }//end testIllegalAndRacedTransitionsAreRefused() + + /** + * An audit-write failure unwinds the transition; a denial is recorded + * outside any transaction; creation is audited from an empty from-state. + * + * @return void + */ + public function testAuditFailureRollsBackAndDenialsAndCreationsAreRecorded(): void { + $item = CaseFixtures::row(id: 1, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $this->items->seed([$item]); + $machine = $this->machine(); + + $this->audits->failNext = true; + $this->db->expects($this->once())->method('rollBack'); + try { + $machine->transition(item: $item, to: CaseItem::STATE_COMPLETED, cause: CaseItemAudit::CAUSE_USER, causeRef: null, actor: 'a'); + $this->fail('audit failure must propagate'); + } catch (RuntimeException $failure) { + $this->assertSame('audit table unavailable', $failure->getMessage()); + } + + $machine->recordDenial(item: $item, to: CaseItem::STATE_ENABLED, actor: 'stranger', reason: 'denied'); + $this->assertFalse($this->audits->entries[0]->getAuthorized()); + $this->assertSame('stranger', $this->audits->entries[0]->getActor()); + $this->assertSame(CaseItem::STATE_ENABLED, $this->audits->entries[0]->getToState()); + + $machine->recordDenial(item: new CaseItem(), to: 'x', actor: null, reason: 'unsaved: ignored'); + $this->audits->failNext = true; + $machine->recordDenial(item: $item, to: 'x', actor: null, reason: 'logged, not thrown'); + $this->assertCount(1, $this->audits->entries); + + $machine->recordCreation(item: $item, cause: CaseItemAudit::CAUSE_IMPORT, causeRef: 'flow-1', actor: null); + // The fake mapper has no real rollback, so the row kept the state the + // failed transition wrote; what matters is the empty from-state. + $this->assertStringEndsWith('(import)', $this->audits->trail(1)[1]); + $this->assertSame('', $this->audits->entries[1]->getFromState()); + $this->assertSame(CasePlanStateMachine::SYSTEM_ACTOR, $this->audits->entries[1]->getActor()); + + $machine->discardEvents(); + $machine->flushEvents(); + $this->assertSame([], $this->events); + }//end testAuditFailureRollsBackAndDenialsAndCreationsAreRecorded() + + /** + * Nested in an outer transaction, events wait for the outer committer; + * a listener failure never unwinds the transition; no dispatcher is fine. + * + * @return void + */ + public function testNestedTransitionsDeferEventsAndListenerFailuresAreSwallowed(): void { + $item = CaseFixtures::row(id: 1, key: 'x', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE); + $this->items->seed([$item]); + $db = $this->createMock(IDBConnection::class); + $db->method('inTransaction')->willReturn(true); + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->expects($this->once())->method('dispatchTyped')->willThrowException(new RuntimeException('listener broke')); + $machine = new CasePlanStateMachine( + items: $this->items, + audits: $this->audits, + table: new CasePlanTransitions(), + realiser: $this->realiser, + writer: $this->writer, + db: $db, + logger: new NullLogger(), + dispatcher: $dispatcher + ); + + $machine->transition(item: $item, to: CaseItem::STATE_COMPLETED, cause: CaseItemAudit::CAUSE_SENTRY, causeRef: 's', actor: null); + $machine->flushEvents(); + $this->assertSame(CaseItem::STATE_COMPLETED, $item->getState()); + + $silent = new CasePlanStateMachine( + items: $this->items, + audits: $this->audits, + table: new CasePlanTransitions(), + realiser: $this->realiser, + writer: $this->writer, + db: $this->db, + logger: new NullLogger() + ); + $other = CaseFixtures::row(id: 2, key: 'y', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE); + $this->items->seed([$other]); + $silent->transition(item: $other, to: CaseItem::STATE_TERMINATED, cause: CaseItemAudit::CAUSE_USER, causeRef: null, actor: 'a', reason: 'r'); + $this->assertSame('r', $other->getTerminatedReason()); + }//end testNestedTransitionsDeferEventsAndListenerFailuresAreSwallowed() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanTransitionsTest.php b/tests/Unit/Service/Case/CasePlanTransitionsTest.php new file mode 100644 index 0000000000..737eefcc01 --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanTransitionsTest.php @@ -0,0 +1,196 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseTransitionException; +use OCA\OpenRegister\Service\Case\CasePlanTransitions; +use PHPUnit\Framework\TestCase; + +/** + * Table-driven coverage of CasePlanTransitions. + * + * @covers \OCA\OpenRegister\Service\Case\CasePlanTransitions + * @covers \OCA\OpenRegister\Exception\CaseTransitionException + */ +class CasePlanTransitionsTest extends TestCase { + + /** + * The legal edges, as the spec and the reference implementation state them. + * + * @return array type, from, to. + */ + public static function legalEdges(): array { + $cases = []; + foreach ([CaseItem::TYPE_STAGE, CaseItem::TYPE_HUMAN_TASK] as $type) { + foreach ([ + [CaseItem::STATE_AVAILABLE, CaseItem::STATE_ENABLED], + [CaseItem::STATE_AVAILABLE, CaseItem::STATE_ACTIVE], + [CaseItem::STATE_AVAILABLE, CaseItem::STATE_DISABLED], + [CaseItem::STATE_AVAILABLE, CaseItem::STATE_TERMINATED], + [CaseItem::STATE_ENABLED, CaseItem::STATE_ACTIVE], + [CaseItem::STATE_ENABLED, CaseItem::STATE_DISABLED], + [CaseItem::STATE_ENABLED, CaseItem::STATE_TERMINATED], + [CaseItem::STATE_ACTIVE, CaseItem::STATE_COMPLETED], + [CaseItem::STATE_ACTIVE, CaseItem::STATE_TERMINATED], + ] as [$from, $to]) { + $cases["$type $from -> $to"] = [$type, $from, $to]; + } + } + + $cases['milestone available -> completed'] = [CaseItem::TYPE_MILESTONE, CaseItem::STATE_AVAILABLE, CaseItem::STATE_COMPLETED]; + $cases['milestone available -> terminated'] = [CaseItem::TYPE_MILESTONE, CaseItem::STATE_AVAILABLE, CaseItem::STATE_TERMINATED]; + + return $cases; + }//end legalEdges() + + /** + * Every edge the table names is legal. + * + * @dataProvider legalEdges + * + * @param string $type The type. + * @param string $from The from-state. + * @param string $to The to-state. + * + * @return void + */ + public function testLegalEdgesPass(string $type, string $from, string $to): void { + $table = new CasePlanTransitions(); + $this->assertTrue($table->isLegal(type: $type, from: $from, to: $to)); + + $item = $this->item(type: $type, state: $from); + $table->assertLegal(item: $item, to: $to); + $this->addToAssertionCount(1); + }//end testLegalEdgesPass() + + /** + * Every other (type, from, to) triple is refused, including self-loops. + * + * @return void + */ + public function testEveryEdgeOutsideTheTableIsRefused(): void { + $table = new CasePlanTransitions(); + $legal = []; + foreach (self::legalEdges() as [$type, $from, $to]) { + $legal["$type|$from|$to"] = true; + } + + $refused = 0; + foreach (CaseItem::TYPES as $type) { + foreach (CaseItem::STATES as $from) { + foreach (CaseItem::STATES as $to) { + if (isset($legal["$type|$from|$to"]) === true) { + continue; + } + + $this->assertFalse($table->isLegal(type: $type, from: $from, to: $to), "$type $from -> $to must be illegal"); + $refused++; + } + } + } + + // 3 types x 36 pairs = 108 triples, 20 legal. + $this->assertSame(88, $refused); + }//end testEveryEdgeOutsideTheTableIsRefused() + + /** + * A milestone has exactly the two edges and cannot become active. + * + * @return void + */ + public function testAMilestoneCannotBecomeActiveAndTheRefusalNamesAllFourFacts(): void { + $table = new CasePlanTransitions(); + $this->assertSame( + [CaseItem::STATE_COMPLETED, CaseItem::STATE_TERMINATED], + $table->targetsFor(type: CaseItem::TYPE_MILESTONE, from: CaseItem::STATE_AVAILABLE) + ); + + $item = $this->item(type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE); + try { + $table->assertLegal(item: $item, to: CaseItem::STATE_ACTIVE); + $this->fail('A milestone must not become active.'); + } catch (CaseTransitionException $refusal) { + $this->assertStringContainsString('ms-1', $refusal->getMessage()); + $this->assertStringContainsString('milestone', $refusal->getMessage()); + $this->assertStringContainsString("'available'", $refusal->getMessage()); + $this->assertStringContainsString("'active'", $refusal->getMessage()); + } + + $this->assertSame(CaseItem::STATE_AVAILABLE, $item->getState(), 'The item is unchanged.'); + }//end testAMilestoneCannotBecomeActiveAndTheRefusalNamesAllFourFacts() + + /** + * Nothing leaves a terminal state, for any type; the refusal names the state. + * + * @return void + */ + public function testATerminalStateAcceptsNothingFurther(): void { + $table = new CasePlanTransitions(); + foreach (CaseItem::TYPES as $type) { + foreach (CaseItem::TERMINAL_STATES as $terminal) { + $this->assertTrue($table->isTerminal(state: $terminal)); + $this->assertSame([], $table->targetsFor(type: $type, from: $terminal)); + try { + $table->assertLegal(item: $this->item(type: $type, state: $terminal), to: CaseItem::STATE_ACTIVE); + $this->fail("$type must not leave $terminal"); + } catch (CaseTransitionException $refusal) { + $this->assertStringContainsString("'$terminal'", $refusal->getMessage()); + } + } + } + + $this->assertFalse($table->isTerminal(state: CaseItem::STATE_ACTIVE)); + }//end testATerminalStateAcceptsNothingFurther() + + /** + * A same-state transition is illegal, and an unknown type has no edges. + * + * @return void + */ + public function testSelfLoopsAndUnknownTypesHaveNoEdges(): void { + $table = new CasePlanTransitions(); + foreach (CaseItem::STATES as $state) { + $this->assertFalse($table->isLegal(type: CaseItem::TYPE_STAGE, from: $state, to: $state)); + } + + $this->assertSame([], $table->targetsFor(type: 'processTask', from: CaseItem::STATE_AVAILABLE)); + }//end testSelfLoopsAndUnknownTypesHaveNoEdges() + + /** + * A row of a type in a state. + * + * @param string $type The type. + * @param string $state The state. + * + * @return CaseItem The row. + */ + private function item(string $type, string $state): CaseItem { + $item = new CaseItem(); + $item->setUuid('ms-1'); + $item->setPlanItemType($type); + $item->setState($state); + + return $item; + }//end item() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanTreeTest.php b/tests/Unit/Service/Case/CasePlanTreeTest.php new file mode 100644 index 0000000000..2189d36a58 --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanTreeTest.php @@ -0,0 +1,125 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Service\Case\CasePlanTree; +use PHPUnit\Framework\TestCase; + +/** + * Structural coverage of CasePlanTree. + * + * @covers \OCA\OpenRegister\Service\Case\CasePlanTree + * @covers \OCA\OpenRegister\Db\CaseItem + */ +class CasePlanTreeTest extends TestCase { + + /** + * A stage with only optional children does NOT complete. + * + * @return void + */ + public function testAStageWithOnlyOptionalChildrenMayNotComplete(): void { + $stage = CaseFixtures::row(id: 1, key: 'assessment', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $optional = CaseFixtures::row(id: 2, key: 'advice', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE, parentId: 1, required: false, discretionary: true); + $tree = new CasePlanTree(items: [$stage, $optional]); + + $this->assertFalse($tree->stageMayComplete(stage: $stage), 'No required child found: not complete, never trivially complete.'); + }//end testAStageWithOnlyOptionalChildrenMayNotComplete() + + /** + * A stage completes when every required child is terminal and none is active. + * + * @return void + */ + public function testAStageCompletesOnItsRequiredChildrenAlone(): void { + $stage = CaseFixtures::row(id: 1, key: 's', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $required = CaseFixtures::row(id: 2, key: 'r', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_COMPLETED, parentId: 1); + $optionalOpen = CaseFixtures::row(id: 3, key: 'o', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ENABLED, parentId: 1, required: false); + $tree = new CasePlanTree(items: [$stage, $required, $optionalOpen]); + $this->assertTrue($tree->stageMayComplete(stage: $stage), 'An enabled optional child does not hold the stage open.'); + + $optionalOpen->setState(CaseItem::STATE_ACTIVE); + $this->assertFalse($tree->stageMayComplete(stage: $stage), 'An ACTIVE child of any kind does.'); + + $optionalOpen->setState(CaseItem::STATE_COMPLETED); + $required->setState(CaseItem::STATE_ACTIVE); + $this->assertFalse($tree->stageMayComplete(stage: $stage), 'An open required child does.'); + }//end testAStageCompletesOnItsRequiredChildrenAlone() + + /** + * A repeating item is terminal only when exhausted AND every realisation is terminal. + * + * @return void + */ + public function testARepeatingItemIsTerminalIffExhaustedAndAllRealisationsTerminal(): void { + $first = CaseFixtures::row(id: 1, key: 'docs', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_COMPLETED); + $first->setRepetition(['max' => 2]); + $second = CaseFixtures::row(id: 2, key: 'docs', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $second->setRepetition(['max' => 2]); + $second->setRealisationCount(2); + + $tree = new CasePlanTree(items: [$first, $second]); + $this->assertFalse($tree->isItemTerminal(item: $first), 'Realisation 2 is active: the item is not terminal.'); + $this->assertFalse($tree->repetitionExhausted(item: $first), 'One of two done: not exhausted.'); + + $second->setState(CaseItem::STATE_COMPLETED); + $this->assertTrue($tree->repetitionExhausted(item: $second)); + $this->assertTrue($tree->isItemTerminal(item: $first), 'Both terminal and max reached.'); + + $terminated = CaseFixtures::row(id: 3, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_TERMINATED); + $terminated->setRepetition(['max' => 5]); + $this->assertTrue((new CasePlanTree(items: [$terminated]))->repetitionExhausted(item: $terminated), 'A terminated item does not repeat.'); + $this->assertSame(['docs' => CaseItem::STATE_COMPLETED], $tree->stateMap(), 'The state map reports the latest realisation.'); + }//end testARepeatingItemIsTerminalIffExhaustedAndAllRealisationsTerminal() + + /** + * Children, descendants, ancestors, parent-active and settings. + * + * @return void + */ + public function testStructureReads(): void { + $root = CaseFixtures::row(id: 1, key: 'root', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $root->setPlanSettings(['authorization' => ['g']]); + $inner = CaseFixtures::row(id: 2, key: 'inner', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_AVAILABLE, parentId: 1); + $leaf = CaseFixtures::row(id: 3, key: 'leaf', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE, parentId: 2); + $orphan = CaseFixtures::row(id: 4, key: 'orphan', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE, parentId: 99); + $tree = new CasePlanTree(items: [$root, $inner, $leaf, $orphan]); + + $this->assertSame([$root], $tree->children(parentId: null)); + $this->assertSame([$inner, $leaf], $tree->descendants(parentId: 1)); + $this->assertSame([$inner, $root], $tree->ancestors(item: $leaf)); + $this->assertTrue($tree->isParentActive(item: $root), 'A root has no parent to wait for.'); + $this->assertTrue($tree->isParentActive(item: $inner)); + $this->assertFalse($tree->isParentActive(item: $leaf), 'inner is available, not active.'); + $this->assertFalse($tree->isParentActive(item: $orphan), 'A dangling parent id is not "no parent".'); + $this->assertSame(['authorization' => ['g']], $tree->settings()); + $this->assertNull($tree->byId(id: null)); + $this->assertSame($leaf, $tree->byId(id: 3)); + $this->assertTrue($tree->keyHasState(key: 'root', state: CaseItem::STATE_ACTIVE)); + $this->assertFalse($tree->keyHasState(key: 'nope', state: CaseItem::STATE_ACTIVE)); + $this->assertTrue($leaf->isEntered() === false && $root->isEntered() === true); + + $serialised = $root->hydrate(['name' => 'Root', 'id' => 77, 'unknown' => 1])->jsonSerialize(); + $this->assertSame('Root', $serialised['name']); + $this->assertSame(1, $serialised['id'], 'hydrate() never sets id.'); + $this->assertSame(['authorization' => ['g']], $serialised['planSettings']); + }//end testStructureReads() +}//end class diff --git a/tests/Unit/Service/Case/CaseRealisationServiceTest.php b/tests/Unit/Service/Case/CaseRealisationServiceTest.php new file mode 100644 index 0000000000..eb80117598 --- /dev/null +++ b/tests/Unit/Service/Case/CaseRealisationServiceTest.php @@ -0,0 +1,280 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use DateTime; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Case\CaseRealisationService; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * Coverage of CaseRealisationService. + * + * @covers \OCA\OpenRegister\Service\Case\CaseRealisationService + */ +class CaseRealisationServiceTest extends TestCase { + + /** + * Task lifecycle. + * + * @var TaskService&MockObject + */ + private TaskService&MockObject $tasks; + + /** + * Task rows. + * + * @var TaskMapper&MockObject + */ + private TaskMapper&MockObject $taskRows; + + /** + * Runs. + * + * @var FlowRunService&MockObject + */ + private FlowRunService&MockObject $runs; + + /** + * Run rows. + * + * @var FlowRunMapper&MockObject + */ + private FlowRunMapper&MockObject $runRows; + + /** + * Fresh mocks. + * + * @return void + */ + protected function setUp(): void { + $this->tasks = $this->createMock(TaskService::class); + $this->taskRows = $this->createMock(TaskMapper::class); + $this->runs = $this->createMock(FlowRunService::class); + $this->runRows = $this->createMock(FlowRunMapper::class); + }//end setUp() + + /** + * The service. + * + * @return CaseRealisationService The service. + */ + private function service(): CaseRealisationService { + return new CaseRealisationService(tasks: $this->tasks, taskRows: $this->taskRows, runs: $this->runs, runRows: $this->runRows, logger: new NullLogger()); + }//end service() + + /** + * A human item becomes a pooled task via import(), with the anchor and the carried terms. + * + * @return void + */ + public function testAHumanItemBecomesATaskThroughTheTrustedPath(): void { + $item = CaseFixtures::row(id: 1, key: 'check', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $item->setName('Controleer'); + $item->setCandidateGroups(['behandelaars']); + $item->setDueAt(new DateTime('2026-09-04T17:00:00+02:00')); + $item->setDoorlooptijd('P8W'); + $item->setCreatedBy('requester-1'); + + $created = new Task(); + $created->setUuid('task-9'); + $this->tasks->expects($this->once())->method('import')->with( + $this->callback( + function (array $data): bool { + $this->assertSame('Controleer', $data['title']); + $this->assertSame(Task::STATE_ENABLED, $data['state'], 'Pooled: enabled.'); + $this->assertSame(Task::PERFORMER_GROUP, $data['performerType']); + $this->assertSame(['behandelaars'], $data['candidateGroups']); + $this->assertArrayNotHasKey('candidateUsers', $data); + $this->assertSame(CaseFixtures::OBJECT, $data['objectUuid']); + $this->assertSame(1, $data['registerId']); + $this->assertSame('requester-1', $data['requester']); + $this->assertSame('2026-09-04T17:00:00+02:00', $data['dueAt']); + $this->assertArrayNotHasKey('expiresAt', $data); + $this->assertSame('P8W', $data['metadata']['doorlooptijd']); + $this->assertSame('item-1', $data['metadata']['caseItem']); + + return true; + } + ), + 'alice' + )->willReturn($created); + + $this->service()->realise(item: $item, actor: 'alice'); + $this->assertSame(CaseItem::REALISATION_TASK, $item->getRealisationKind()); + $this->assertSame('task-9', $item->getRealisationUuid()); + + // Without candidates: available, performer user, requester = actor. + $bare = CaseFixtures::row(id: 2, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $bare->setCandidateUsers(['u1']); + $bare->setCandidateRole('r'); + $bare->setExpiresAt(new DateTime('2026-10-01T00:00:00+00:00')); + $data = $this->service()->taskDataFor(item: $bare, actor: 'case-plan'); + $this->assertSame(Task::STATE_ENABLED, $data['state']); + $this->assertSame(Task::PERFORMER_USER, $data['performerType']); + $this->assertSame('case-plan', $data['requester']); + $this->assertSame('r', $data['candidateRole']); + $this->assertSame(['u1'], $data['candidateUsers']); + $this->assertArrayHasKey('expiresAt', $data); + $bare->setCandidateUsers(null); + $bare->setCandidateRole(null); + $this->assertSame(Task::STATE_AVAILABLE, $this->service()->taskDataFor(item: $bare, actor: 'a')['state']); + }//end testAHumanItemBecomesATaskThroughTheTrustedPath() + + /** + * A flow-bound stage queues a run against the plan's binding; an unbound + * stage and a milestone realise nothing. + * + * @return void + */ + public function testAFlowBoundStageQueuesARunAndOthersRealiseNothing(): void { + $stage = CaseFixtures::row(id: 1, key: 'auto', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_AVAILABLE); + $stage->setPlanSettings(['flows' => ['auto' => 'flow-7']]); + $run = new FlowRun(); + $run->setUuid('run-3'); + $this->runs->expects($this->once())->method('queue')->with( + 'flow-7', + ['uuid' => CaseFixtures::OBJECT, 'register' => '1', 'schema' => '1'], + CaseRealisationService::RUN_TRIGGER, + ['caseItem' => 'item-1'], + 'alice' + )->willReturn($run); + + $service = $this->service(); + $service->realise(item: $stage, actor: 'alice'); + $this->assertSame(CaseItem::REALISATION_RUN, $stage->getRealisationKind()); + $this->assertSame('run-3', $stage->getRealisationUuid()); + + $plain = CaseFixtures::row(id: 2, key: 'plain', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_AVAILABLE); + $service->realise(item: $plain, actor: 'alice'); + $this->assertSame(CaseItem::REALISATION_NONE, $plain->getRealisationKind()); + $this->assertNull($plain->getRealisationUuid()); + }//end testAFlowBoundStageQueuesARunAndOthersRealiseNothing() + + /** + * Terminal outcomes: task completed/terminated, run completed/stopped, + * open = null, missing = terminated, none = null. + * + * @return void + */ + public function testTerminalOutcomes(): void { + $service = $this->service(); + $taskItem = CaseFixtures::row(id: 1, key: 't', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $taskItem->setRealisationKind(CaseItem::REALISATION_TASK); + $taskItem->setRealisationUuid('task-1'); + $task = new Task(); + $this->taskRows->method('findByUuid')->willReturnCallback( + static function (string $uuid) use ($task): Task { + if ($uuid === 'gone') { + throw new DoesNotExistException('gone'); + } + + return $task; + } + ); + + $task->setState(Task::STATE_ACTIVE); + $this->assertNull($service->terminalOutcome(item: $taskItem)); + $task->setState(Task::STATE_COMPLETED); + $this->assertSame(CaseItem::STATE_COMPLETED, $service->terminalOutcome(item: $taskItem)); + $task->setState(Task::STATE_DISABLED); + $this->assertSame(CaseItem::STATE_TERMINATED, $service->terminalOutcome(item: $taskItem)); + $taskItem->setRealisationUuid('gone'); + $this->assertSame(CaseItem::STATE_TERMINATED, $service->terminalOutcome(item: $taskItem)); + + $runItem = CaseFixtures::row(id: 2, key: 'r', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $runItem->setRealisationKind(CaseItem::REALISATION_RUN); + $runItem->setRealisationUuid('run-1'); + $run = new FlowRun(); + $this->runRows->method('findByUuid')->willReturn($run); + $run->setStatus(FlowRun::STATUS_SUSPENDED); + $this->assertNull($service->terminalOutcome(item: $runItem)); + $run->setStatus(FlowRun::STATUS_COMPLETED); + $this->assertSame(CaseItem::STATE_COMPLETED, $service->terminalOutcome(item: $runItem)); + $run->setStatus(FlowRun::STATUS_STOPPED); + $this->assertSame(CaseItem::STATE_TERMINATED, $service->terminalOutcome(item: $runItem)); + + $none = CaseFixtures::row(id: 3, key: 'n', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE); + $this->assertNull($service->terminalOutcome(item: $none)); + $none->setRealisationKind('other'); + $none->setRealisationUuid('x'); + $this->assertNull($service->terminalOutcome(item: $none)); + }//end testTerminalOutcomes() + + /** + * Termination: a task is terminated as moot naming the item; a gone task + * is fine; another failure propagates; a run is logged, not touched; no + * realisation is a no-op. + * + * @return void + */ + public function testTerminateWritesOnlyToTasks(): void { + $service = $this->service(); + $taskItem = CaseFixtures::row(id: 1, key: 't', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $taskItem->setRealisationKind(CaseItem::REALISATION_TASK); + $taskItem->setRealisationUuid('task-1'); + $this->tasks->expects($this->exactly(3))->method('terminateAsMoot')->willReturnCallback( + static function (string $uuid, string $reason, string $source): Task { + if ($reason === 'gone') { + throw new DoesNotExistException('gone'); + } + + if ($reason === 'boom') { + throw new RuntimeException('boom'); + } + + TestCase::assertSame('task-1', $uuid); + TestCase::assertSame('case-item:item-1', $source); + + return new Task(); + } + ); + + $service->terminate(item: $taskItem, reason: 'stage exited'); + $service->terminate(item: $taskItem, reason: 'gone'); + try { + $service->terminate(item: $taskItem, reason: 'boom'); + $this->fail('propagates'); + } catch (RuntimeException $failure) { + $this->assertSame('boom', $failure->getMessage()); + } + + $runItem = CaseFixtures::row(id: 2, key: 'r', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $runItem->setRealisationKind(CaseItem::REALISATION_RUN); + $runItem->setRealisationUuid('run-1'); + $this->runs->expects($this->never())->method($this->anything()); + $service->terminate(item: $runItem, reason: 'x'); + + $none = CaseFixtures::row(id: 3, key: 'n', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE); + $service->terminate(item: $none, reason: 'x'); + }//end testTerminateWritesOnlyToTasks() +}//end class diff --git a/tests/Unit/Service/Case/CaseSentryEvaluatorTest.php b/tests/Unit/Service/Case/CaseSentryEvaluatorTest.php new file mode 100644 index 0000000000..e403dffee5 --- /dev/null +++ b/tests/Unit/Service/Case/CaseSentryEvaluatorTest.php @@ -0,0 +1,227 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseValidationException; +use OCA\OpenRegister\Service\Case\CasePlanTree; +use OCA\OpenRegister\Service\Case\CaseSentryEvaluator; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use PHPUnit\Framework\TestCase; + +/** + * Coverage of CaseSentryEvaluator, and of the three catalog entries it relies on. + * + * @covers \OCA\OpenRegister\Service\Case\CaseSentryEvaluator + * @covers \OCA\OpenRegister\Service\Flow\EventCatalogService + * @covers \OCA\OpenRegister\Exception\CaseValidationException + */ +class CaseSentryEvaluatorTest extends TestCase { + + /** + * The evaluator over the real catalog. + * + * @return CaseSentryEvaluator The evaluator. + */ + private function evaluator(): CaseSentryEvaluator { + return new CaseSentryEvaluator(catalog: new EventCatalogService()); + }//end evaluator() + + /** + * The catalog carries the three plan-item events, additively. + * + * @return void + */ + public function testTheCatalogCarriesTheThreeCaseEvents(): void { + $catalog = new EventCatalogService(); + $known = $catalog->knownTriggerIds(); + foreach (array_keys(CaseSentryEvaluator::ITEM_EVENTS) as $event) { + $this->assertContains($event, $known); + $this->assertSame([$event], $catalog->aliasesFor(dispatched: $event), 'A case event has no legacy alias.'); + } + + $this->assertSame(['object.created', 'created'], $catalog->aliasesFor(dispatched: 'created'), 'aliasesFor() is unchanged for existing entries.'); + $this->assertContains('object.transitioned', $known); + }//end testTheCatalogCarriesTheThreeCaseEvents() + + /** + * A milestone completing satisfies another item's on-part, read from state. + * + * @return void + */ + public function testAPlanItemOnPartReadsCurrentStateAndNamesTheSentry(): void { + $milestone = CaseFixtures::row(id: 1, key: 'advice-received', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE); + $item = CaseFixtures::row(id: 2, key: 'decide', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $item->setEntryCriteria([['id' => 'after-advice', 'on' => ['event' => 'case.item.completed', 'item' => 'advice-received']]]); + $tree = new CasePlanTree(items: [$milestone, $item]); + + $this->assertNull($this->evaluator()->entrySentry(item: $item, tree: $tree, object: []), 'Not yet.'); + + $milestone->setState(CaseItem::STATE_COMPLETED); + $this->assertSame('after-advice', $this->evaluator()->entrySentry(item: $item, tree: $tree, object: []), 'Now, without any event in hand.'); + }//end testAPlanItemOnPartReadsCurrentStateAndNamesTheSentry() + + /** + * AND within a sentry, OR across the criteria array. + * + * @return void + */ + public function testAndWithinOrAcross(): void { + $done = CaseFixtures::row(id: 1, key: 'a', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_COMPLETED); + $item = CaseFixtures::row(id: 2, key: 'b', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $item->setEntryCriteria( + [ + // on holds, if fails: this sentry does not fire. + ['id' => 'first', 'on' => ['event' => 'case.item.completed', 'item' => 'a'], 'if' => ['==' => [['var' => 'json.amount'], 999]]], + // both hold: fires. + ['id' => 'second', 'on' => ['event' => 'case.item.completed', 'item' => 'a'], 'if' => ['>' => [['var' => 'json.amount'], 100]]], + ] + ); + $tree = new CasePlanTree(items: [$done, $item]); + + $this->assertSame('second', $this->evaluator()->entrySentry(item: $item, tree: $tree, object: ['amount' => 500])); + $this->assertNull($this->evaluator()->entrySentry(item: $item, tree: $tree, object: ['amount' => 50]), 'Both if-parts false: none fires.'); + }//end testAndWithinOrAcross() + + /** + * An if-part over a field the object does not have is FALSE, not vacuously true. + * + * @return void + */ + public function testAnUnevaluableIfPartBlocks(): void { + $item = CaseFixtures::row(id: 1, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $item->setEntryCriteria([['id' => 'guard', 'if' => ['==' => [['var' => 'json.missing.deep'], 'yes']]]]); + $tree = new CasePlanTree(items: [$item]); + + $this->assertNull($this->evaluator()->entrySentry(item: $item, tree: $tree, object: ['other' => 1])); + $this->assertSame('guard', $this->evaluator()->entrySentry(item: $item, tree: $tree, object: ['missing' => ['deep' => 'yes']])); + }//end testAnUnevaluableIfPartBlocks() + + /** + * The if-part sees the `case` key: another item's state and the object. + * + * @return void + */ + public function testTheIfPartSeesTheCaseDocument(): void { + $other = CaseFixtures::row(id: 1, key: 'intake', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $item = CaseFixtures::row(id: 2, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $item->setExitCriteria([['id' => 'withdrawn', 'if' => ['and' => [['==' => [['var' => 'case.items.intake'], 'active']], ['==' => [['var' => 'case.object.status'], 'ingetrokken']]]]]]); + $tree = new CasePlanTree(items: [$other, $item]); + $evaluator = $this->evaluator(); + + $this->assertSame('withdrawn', $evaluator->exitSentry(item: $item, tree: $tree, object: ['status' => 'ingetrokken'])); + $this->assertNull($evaluator->exitSentry(item: $item, tree: $tree, object: ['status' => 'open'])); + $data = $evaluator->dataFor(tree: $tree, object: ['status' => 'open'], event: 'object.updated', payload: ['k' => 1]); + $this->assertSame(['intake' => 'active', 'x' => 'available'], $data['case']['items']); + $this->assertSame('object.updated', $data['context']['event']); + $this->assertSame(['status' => 'open'], $data['json']); + }//end testTheIfPartSeesTheCaseDocument() + + /** + * Empty entry = default (parent active is the caller's check); empty exit = never. + * + * @return void + */ + public function testTheTwoDefaults(): void { + $item = CaseFixtures::row(id: 1, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $tree = new CasePlanTree(items: [$item]); + $this->assertSame(CaseSentryEvaluator::DEFAULT_ENTRY, $this->evaluator()->entrySentry(item: $item, tree: $tree, object: [])); + $this->assertNull($this->evaluator()->exitSentry(item: $item, tree: $tree, object: [])); + }//end testTheTwoDefaults() + + /** + * Malformed sentries never fire: neither part, an if-part naming no + * field, a non-object sentry, an on-part without an event, an item event + * without an item. + * + * @return void + */ + public function testAMalformedSentryNeverFires(): void { + $done = CaseFixtures::row(id: 1, key: 'a', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_COMPLETED); + $item = CaseFixtures::row(id: 2, key: 'b', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_AVAILABLE); + $item->setExitCriteria( + [ + ['id' => 'empty'], + ['id' => 'literal', 'if' => true], + ['id' => 'no-field', 'if' => ['==' => [1, 1]]], + ['id' => 'list-literal', 'if' => [['var' => 'json.x'], 'yes']], + 'not-an-object', + ['id' => 'no-event', 'on' => ['item' => 'a']], + ['id' => 'no-item', 'on' => ['event' => 'case.item.completed']], + ['id' => 'on-not-object', 'on' => 'case.item.completed'], + ] + ); + $tree = new CasePlanTree(items: [$done, $item]); + + $this->assertNull($this->evaluator()->exitSentry(item: $item, tree: $tree, object: ['x' => 1], event: 'object.updated')); + }//end testAMalformedSentryNeverFires() + + /** + * An object on-part fires only for the event being handled, alias-aware, + * and a sentry without an id is named by position. + * + * @return void + */ + public function testAnObjectOnPartReadsTheEventBeingHandled(): void { + $item = CaseFixtures::row(id: 1, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $item->setExitCriteria([['on' => ['event' => 'object.updated']]]); + $tree = new CasePlanTree(items: [$item]); + $evaluator = $this->evaluator(); + + $this->assertNull($evaluator->exitSentry(item: $item, tree: $tree, object: []), 'No event in hand.'); + $this->assertNull($evaluator->exitSentry(item: $item, tree: $tree, object: [], event: 'object.transitioned')); + $this->assertSame('sentry:1', $evaluator->exitSentry(item: $item, tree: $tree, object: [], event: 'updated'), 'The legacy alias counts.'); + }//end testAnObjectOnPartReadsTheEventBeingHandled() + + /** + * Save-time validation refuses what the editor should not accept. + * + * @return void + */ + public function testValidationRefusesAtSaveTime(): void { + $evaluator = $this->evaluator(); + $evaluator->validateCriteria(criteria: null, where: 'x'); + $evaluator->validateCriteria(criteria: [], where: 'x'); + $evaluator->validateCriteria(criteria: [['on' => ['event' => 'object.transitioned']], ['if' => ['var' => 'json.a']], ['on' => ['event' => 'case.item.disabled', 'item' => 'k']]], where: 'x'); + $this->addToAssertionCount(1); + + $refusals = [ + 'not a list' => 'not-a-list', + 'not an object' => ['sentry'], + 'neither part' => [['id' => 'bare']], + 'unknown event' => [['on' => ['event' => 'case.item.started']]], + 'on without event' => [['on' => ['item' => 'k']]], + 'item event without item' => [['on' => ['event' => 'case.item.completed']]], + 'invalid if' => [['if' => ['nonsuchoperator' => [1]]]], + ]; + foreach ($refusals as $label => $criteria) { + try { + $evaluator->validateCriteria(criteria: $criteria, where: "'k' entry"); + $this->fail("$label must be refused"); + } catch (CaseValidationException $refusal) { + $this->assertNotSame('', $refusal->getMessage(), $label); + if ($label === 'unknown event') { + $this->assertStringContainsString("'case.item.started'", $refusal->getMessage(), 'The unknown event is named.'); + } + } + } + }//end testValidationRefusesAtSaveTime() +}//end class diff --git a/tests/Unit/Service/Case/FakeCaseItemMapper.php b/tests/Unit/Service/Case/FakeCaseItemMapper.php new file mode 100644 index 0000000000..ed2cc29f5d --- /dev/null +++ b/tests/Unit/Service/Case/FakeCaseItemMapper.php @@ -0,0 +1,277 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; + +/** + * In-memory plan-item storage. + */ +class FakeCaseItemMapper extends CaseItemMapper { + + /** + * Rows by id. + * + * @var array + */ + public array $rows = []; + + /** + * Uuids whose next conditional update must report "somebody else moved it". + * + * @var array + */ + public array $failUpdateFor = []; + + /** + * How many conditional updates ran. + * + * @var integer + */ + public int $updates = 0; + + /** + * Next id. + * + * @var integer + */ + private int $nextId = 1; + + /** + * Constructor over a mocked connection. + * + * @param TestCase $test The test, for the connection mock. + */ + public function __construct(TestCase $test) { + parent::__construct(db: $test->getMockBuilder(IDBConnection::class)->getMock()); + + }//end __construct() + + /** + * Seed rows (ids assigned when absent). + * + * @param array $rows The rows. + * + * @return void + */ + public function seed(array $rows): void { + foreach ($rows as $row) { + $this->insert($row); + } + }//end seed() + + /** + * Insert. + * + * @param Entity $entity The row. + * + * @return CaseItem The row with an id. + */ + public function insert(Entity $entity): CaseItem { + /* + * @var CaseItem $entity + */ + if ($entity->getId() === null) { + $entity->setId($this->nextId); + } + + $this->nextId = max($this->nextId, (int)$entity->getId()) + 1; + if (trim((string)$entity->getUuid()) === '') { + $entity->setUuid('item-' . (int)$entity->getId()); + } + + if ($entity->getCreated() === null) { + $entity->setCreated(new \DateTime()); + } + + $entity->resetUpdatedFields(); + $this->rows[(int)$entity->getId()] = $entity; + + return $entity; + }//end insert() + + /** + * Update. + * + * @param Entity $entity The row. + * + * @return CaseItem The row. + */ + public function update(Entity $entity): CaseItem { + /* + * @var CaseItem $entity + */ + $this->rows[(int)$entity->getId()] = $entity; + + return $entity; + }//end update() + + /** + * Conditional update. + * + * @param CaseItem $item The row. + * @param string $expectedState The state it must still be in. + * + * @return boolean Whether it moved. + */ + public function updateIfState(CaseItem $item, string $expectedState): bool { + $this->updates++; + $uuid = (string)$item->getUuid(); + if (isset($this->failUpdateFor[$uuid]) === true) { + unset($this->failUpdateFor[$uuid]); + + return false; + } + + $stored = ($this->rows[(int)$item->getId()] ?? null); + if ($stored === null) { + return false; + } + + // The stored row and the in-memory row are the same object in these + // tests, so the "expected state" check is against what the caller read. + if ($stored !== $item && $stored->getState() !== $expectedState) { + return false; + } + + $this->rows[(int)$item->getId()] = $item; + + return true; + }//end updateIfState() + + /** + * By uuid. + * + * @param string $uuid The uuid. + * + * @return CaseItem The row. + */ + public function findByUuid(string $uuid): CaseItem { + foreach ($this->rows as $row) { + if ($row->getUuid() === $uuid) { + return $row; + } + } + + throw new DoesNotExistException('no such item ' . $uuid); + }//end findByUuid() + + /** + * By object. + * + * @param string $objectUuid The object. + * + * @return array The rows. + */ + public function findByObject(string $objectUuid): array { + $rows = array_values(array_filter($this->rows, static fn (CaseItem $row): bool => $row->getObjectUuid() === $objectUuid)); + usort($rows, static fn (CaseItem $a, CaseItem $b): int => [(int)$a->getPosition(), (int)$a->getId()] <=> [(int)$b->getPosition(), (int)$b->getId()]); + + return $rows; + }//end findByObject() + + /** + * Open count by object. + * + * @param string $objectUuid The object. + * + * @return int The count. + */ + public function countOpenByObject(string $objectUuid): int { + return count(array_filter($this->findByObject($objectUuid), static fn (CaseItem $row): bool => $row->isInTerminalState() === false)); + }//end countOpenByObject() + + /** + * By realisation. + * + * @param string $realisationUuid The task or run uuid. + * + * @return array The rows. + */ + public function findByRealisation(string $realisationUuid): array { + return array_values(array_filter($this->rows, static fn (CaseItem $row): bool => $row->getRealisationUuid() === $realisationUuid)); + }//end findByRealisation() + + /** + * By type and state. + * + * @param string|null $type The type. + * @param string|null $state The state. + * @param int $limit Page size. + * @param int $offset Offset. + * + * @return array The page. + */ + public function findByTypeAndState(?string $type, ?string $state, int $limit = 25, int $offset = 0): array { + return array_slice($this->matching($type, $state), $offset, $limit); + }//end findByTypeAndState() + + /** + * Count by type and state. + * + * @param string|null $type The type. + * @param string|null $state The state. + * + * @return int The total. + */ + public function countByTypeAndState(?string $type, ?string $state): int { + return count($this->matching($type, $state)); + }//end countByTypeAndState() + + /** + * Delete by object. + * + * @param string $objectUuid The object. + * + * @return int Deleted. + */ + public function deleteByObject(string $objectUuid): int { + $before = count($this->rows); + $this->rows = array_filter($this->rows, static fn (CaseItem $row): bool => $row->getObjectUuid() !== $objectUuid); + + return $before - count($this->rows); + }//end deleteByObject() + + /** + * Rows matching the filters. + * + * @param string|null $type The type. + * @param string|null $state The state. + * + * @return array The rows. + */ + private function matching(?string $type, ?string $state): array { + return array_values( + array_filter( + $this->rows, + static fn (CaseItem $row): bool => ($type === null || $row->getPlanItemType() === $type) && ($state === null || $row->getState() === $state) + ) + ); + }//end matching() +}//end class diff --git a/tests/Unit/Service/Case/RecordingAuditMapper.php b/tests/Unit/Service/Case/RecordingAuditMapper.php new file mode 100644 index 0000000000..d8bc605495 --- /dev/null +++ b/tests/Unit/Service/Case/RecordingAuditMapper.php @@ -0,0 +1,114 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Db\CaseItemAuditMapper; +use OCP\AppFramework\Db\Entity; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * Append-only audit storage in memory. + */ +class RecordingAuditMapper extends CaseItemAuditMapper { + + /** + * Everything appended. + * + * @var array + */ + public array $entries = []; + + /** + * When true, the next insert throws (to test rollback). + * + * @var boolean + */ + public bool $failNext = false; + + /** + * Constructor over a mocked connection. + * + * @param TestCase $test The test. + */ + public function __construct(TestCase $test) { + parent::__construct(db: $test->getMockBuilder(IDBConnection::class)->getMock()); + + }//end __construct() + + /** + * Append. + * + * @param Entity $entity The entry. + * + * @return CaseItemAudit The entry. + */ + public function insert(Entity $entity): CaseItemAudit { + if ($this->failNext === true) { + $this->failNext = false; + throw new RuntimeException('audit table unavailable'); + } + + /* + * @var CaseItemAudit $entity + */ + $entity->setId(count($this->entries) + 1); + $this->entries[] = $entity; + + return $entity; + }//end insert() + + /** + * For one item. + * + * @param int $caseItemId The item id. + * + * @return array The entries. + */ + public function findForItem(int $caseItemId): array { + return array_values(array_filter($this->entries, static fn (CaseItemAudit $entry): bool => $entry->getCaseItemId() === $caseItemId)); + }//end findForItem() + + /** + * For several items. + * + * @param array $caseItemIds The item ids. + * + * @return array The entries. + */ + public function findForItems(array $caseItemIds): array { + return array_values(array_filter($this->entries, static fn (CaseItemAudit $entry): bool => in_array($entry->getCaseItemId(), $caseItemIds, true))); + }//end findForItems() + + /** + * Entries for an item as `from->to (cause)` strings, for compact assertions. + * + * @param int $caseItemId The item id. + * + * @return array The trail. + */ + public function trail(int $caseItemId): array { + return array_map( + static fn (CaseItemAudit $entry): string => sprintf('%s->%s (%s)', (string)$entry->getFromState(), (string)$entry->getToState(), (string)$entry->getCause()), + $this->findForItem($caseItemId) + ); + }//end trail() +}//end class diff --git a/tests/Unit/Service/Case/ZaaktypeCaseSkeletonMapperTest.php b/tests/Unit/Service/Case/ZaaktypeCaseSkeletonMapperTest.php new file mode 100644 index 0000000000..2de4176acf --- /dev/null +++ b/tests/Unit/Service/Case/ZaaktypeCaseSkeletonMapperTest.php @@ -0,0 +1,144 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Repair\SeedCaseFixtures; +use OCA\OpenRegister\Service\Case\CasePlanDefinition; +use OCA\OpenRegister\Service\Case\CaseSentryEvaluator; +use OCA\OpenRegister\Service\Case\ZaaktypeCaseSkeletonMapper; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use PHPUnit\Framework\TestCase; + +/** + * Coverage of ZaaktypeCaseSkeletonMapper over the design's fixture. + * + * @covers \OCA\OpenRegister\Service\Case\ZaaktypeCaseSkeletonMapper + */ +class ZaaktypeCaseSkeletonMapperTest extends TestCase { + + /** + * Four out-of-order statustypen become four milestones in volgnummer order, + * and the skeleton is a valid draft definition. + * + * @return void + */ + public function testStatusesBecomeSequenceOrderedMilestonesInAValidDraft(): void { + $result = (new ZaaktypeCaseSkeletonMapper())->map(zaaktype: SeedCaseFixtures::zaaktypeFixture()); + + $this->assertTrue($result['draft']); + $milestones = []; + foreach ($result['definition']['items'] as $stage) { + foreach ($stage['children'] as $child) { + if ($child['type'] === CaseItem::TYPE_MILESTONE) { + $milestones[] = $child['name']; + } + } + } + + $this->assertSame(['Ontvangen', 'Volledig', 'In behandeling', 'Afgehandeld'], $milestones); + $this->assertSame([], $result['definition']['items'][0]['entryCriteria'], 'The first stage enters at once.'); + $this->assertSame('status-1-ontvangen', $result['definition']['items'][1]['entryCriteria'][0]['on']['item'], 'The second waits for the first milestone.'); + + // The draft compiles through the real definition boundary. + $normalised = (new CasePlanDefinition(sentries: new CaseSentryEvaluator(catalog: new EventCatalogService())))->validate(definition: $result['definition']); + $this->assertCount(4, $normalised['items']); + }//end testStatusesBecomeSequenceOrderedMilestonesInAValidDraft() + + /** + * Roles, results and terms land where the spec says; unmappable content is reported, never dropped. + * + * @return void + */ + public function testRolesResultsTermsAndTheReport(): void { + $result = (new ZaaktypeCaseSkeletonMapper())->map(zaaktype: SeedCaseFixtures::zaaktypeFixture()); + $settings = $result['definition']['settings']; + + $this->assertSame(['Verleend', 'Geweigerd'], $settings['results']); + $this->assertSame('blijvend_bewaren', $settings['resultMetadata']['Verleend']['archiefnominatie']); + $this->assertSame('P8W', $settings['doorlooptijd']); + $this->assertSame('P6W', $settings['servicenorm']); + $this->assertCount(3, $settings['candidateRoles']); + $this->assertSame('initiator', $settings['candidateRoles'][0]['generic'], 'The generic designation is preserved.'); + + $task = $result['definition']['items'][0]['children'][0]; + $this->assertSame(CaseItem::TYPE_HUMAN_TASK, $task['type']); + $this->assertSame('Vergunningverlener', $task['candidateRole'], 'The behandelaar role is on the human items.'); + $this->assertSame('P8W', $task['doorlooptijd']); + + $byElement = []; + foreach ($result['report'] as $line) { + $byElement[$line['element']] = $line; + $this->assertNotSame('', $line['action']); + } + + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $byElement['publicatieIndicatie']['status']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $byElement['verlengingMogelijk']['status']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $byElement['authorization']['status']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::CARRIED, $byElement['doorlooptijd']['status']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::CARRIED, $byElement["roltypen 'Welstandscommissie'"]['status']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::MAPPED, $byElement["roltypen 'Vergunningverlener'"]['status']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::MAPPED, $byElement['resultaattypen']['status']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::APPROXIMATE, $byElement['statustypen (volgnummer 1)']['status']); + $this->assertArrayNotHasKey('omschrijving', $byElement, 'Identity elements are not reported as unmapped.'); + }//end testRolesResultsTermsAndTheReport() + + /** + * Degenerate input: no statuses, no results, malformed entries, an + * unnumbered status placed last, extra status attributes reported. + * + * @return void + */ + public function testDegenerateInputIsReportedNotGuessed(): void { + $mapper = new ZaaktypeCaseSkeletonMapper(); + $empty = $mapper->map(zaaktype: ['identificatie' => 'X']); + $elements = array_column($empty['report'], 'status', 'element'); + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $elements['statustypen']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $elements['resultaattypen']); + $this->assertSame([], $empty['definition']['items']); + $this->assertSame('X', $empty['definition']['settings']['name']); + + $odd = $mapper->map( + zaaktype: [ + 'statustypen' => [ + 'not-an-object', + ['omschrijving' => 'Zonder nummer', 'statustekst' => 'tekst'], + ['volgnummer' => 2, 'omschrijving' => 'Twee'], + ['volgnummer' => 1, 'omschrijving' => '***'], + ], + 'roltypen' => ['x', ['omschrijvingGeneriek' => 'adviseur']], + 'resultaattypen' => [['archiefnominatie' => 'v']], + 'doorlooptijd' => '', + ] + ); + $names = array_map(static fn (array $stage): string => $stage['name'], $odd['definition']['items']); + $this->assertSame(['***', 'Twee', 'Zonder nummer'], $names, 'Numbered first, unnumbered last.'); + $this->assertStringStartsWith('fase-1-status', $odd['definition']['items'][0]['key'], 'An unsluggable label falls back.'); + $statuses = array_column($odd['report'], 'status', 'element'); + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $statuses['statustypen[0]']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::APPROXIMATE, $statuses['statustypen[1]']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $statuses['statustypen (volgnummer -).statustekst']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $statuses['roltypen[0]']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $statuses['roltypen[1]']); + $this->assertSame(ZaaktypeCaseSkeletonMapper::UNMAPPED, $statuses['resultaattypen[0]']); + $this->assertSame([], $odd['definition']['settings']['results']); + $this->assertArrayNotHasKey('doorlooptijd', $odd['definition']['settings']); + }//end testDegenerateInputIsReportedNotGuessed() +}//end class diff --git a/tests/e2e/case-plan.spec.ts b/tests/e2e/case-plan.spec.ts new file mode 100644 index 0000000000..421f95fda5 --- /dev/null +++ b/tests/e2e/case-plan.spec.ts @@ -0,0 +1,416 @@ +/* + * SPDX-FileCopyrightText: 2026 Open Register Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * Case plan e2e: the six @e2e-marked scenarios of the flow-cases spec, driven + * through the REST API against one freshly created anchor object. + * + * 1. the case-plan route: a plan is read by the object's uuid alone, with + * every item's state, type and parent, and no run uuid anywhere. + * 2. the sentry cascade: completing the intake task reaches the milestone, + * which admits the assessment stage and its decision item, in one pass. + * 3. stage termination: terminating the assessment stage terminates its + * active child and disables its unentered child, each with a `cascade` + * audit row naming the stage. + * 4. task realisation: completing the realising task completes the plan item + * with the task completion as the audited cause. + * 5. the ad-hoc item: attached to the active stage, entered and realised as + * a task, without any flow or definition version being touched. + * 6. write-through: the anchoring object carries the mirrored status and the + * moment it was reached, read through the ordinary object API. + * + * Scenario 4 is driven through `POST /api/cases/{objectUuid}/evaluate` after + * completing the task. Once flow-user-task-node (#3269) lands, TaskService + * announces TaskTerminalEvent and CaseTaskTerminalListener performs that + * evaluation itself; the explicit call stays valid (idempotent) either way. + * + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-stages-nest-and-complete-by-a-written-rule + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-human-plan-item-is-realised-by-a-task-and-a-stage-may-be-realised-by-a-flow-run + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-a-caseworker-may-attach-work-no-author-drew + * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-business-state-is-written-through-to-the-register-never-owned-by-the-engine + */ +import type { APIRequestContext } from '@playwright/test' + +import { expect, test } from '@playwright/test' + +const RUN_ID = `e2e-case-${Date.now().toString(36)}` +const ADMIN = process.env.OR_USER || 'admin' + +// Known register + schema from the dev seed (same pair core-crud.spec.ts uses). +const REGISTER_ID = process.env.OR_CASE_REGISTER || '8' +const SCHEMA_ID = process.env.OR_CASE_SCHEMA || '18' + +// Same reasoning as task-inbox.spec.ts: Basic auth, no session cookie, so no +// CSRF token is demanded; `OCS-APIRequest` marks the calls as API traffic. +const NO_SESSION = { cookies: [], origins: [] } +const ADMIN_HEADERS = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from( + `${ADMIN}:${process.env.OR_PASS || 'admin'}`, + ).toString('base64')}`, +} + +const CASES = '/index.php/apps/openregister/api/cases' +const TASKS = '/index.php/apps/openregister/api/flow-tasks' +const OBJECTS = `/index.php/apps/openregister/api/objects/${REGISTER_ID}/${SCHEMA_ID}` + +test.use({ storageState: NO_SESSION, extraHTTPHeaders: ADMIN_HEADERS }) + +type Item = { + uuid: string + key: string + type: string + state: string + parentItemId: number | null + origin: string + realisationKind: string | null + realisationUuid: string | null + flowUuid: string | null +} +type Audit = { + caseItemId: number + toState: string + cause: string + causeRef: string | null +} +type Plan = { objectUuid: string; items: Item[]; audit: Audit[] } + +/** The two-stage permit definition from design.md, seed 1 and 2. */ +function permitDefinition() { + return { + settings: { + authorization: [`user:${ADMIN}`], + results: ['verleend', 'geweigerd'], + writeThrough: { + statusField: 'status', + statusAtField: 'statusReachedAt', + }, + }, + items: [ + { + key: 'intake', + type: 'stage', + name: 'Intake', + children: [ + { + key: 'completeness-check', + type: 'humanTask', + name: `${RUN_ID} volledigheid`, + candidateUsers: [ADMIN], + }, + { + key: 'application-complete', + type: 'milestone', + name: 'Aanvraag volledig', + entryCriteria: [ + { + id: 'complete', + on: { + event: 'case.item.completed', + item: 'completeness-check', + }, + }, + ], + }, + ], + }, + { + key: 'assessment', + type: 'stage', + name: 'Beoordeling', + entryCriteria: [ + { + id: 'after-intake', + on: { + event: 'case.item.completed', + item: 'application-complete', + }, + }, + ], + children: [ + { + key: 'decide', + type: 'humanTask', + name: `${RUN_ID} besluit`, + candidateUsers: [ADMIN], + }, + { + key: 'decided', + type: 'milestone', + name: 'Besloten', + entryCriteria: [ + { + id: 'after-decide', + on: { event: 'case.item.completed', item: 'decide' }, + }, + ], + }, + ], + }, + ], + } +} + +/** Create the anchor object and hand back its uuid. */ +async function createObject(request: APIRequestContext): Promise { + const response = await request.post(OBJECTS, { + data: { title: `${RUN_ID} case`, name: `${RUN_ID} case`, status: 'nieuw' }, + }) + expect(response.status(), await response.text()).toBe(201) + const body = await response.json() + return body['@self']?.id ?? body.id ?? body.uuid +} + +/** Read the plan. */ +async function readPlan( + request: APIRequestContext, + objectUuid: string, +): Promise { + const response = await request.get(`${CASES}/${objectUuid}`) + expect(response.status(), await response.text()).toBe(200) + return response.json() +} + +function byKey(plan: Plan, key: string): Item { + const item = plan.items.find((candidate) => candidate.key === key) + expect(item, `item ${key} exists`).toBeTruthy() + return item as Item +} + +/** Claim and complete a task as the admin. */ +async function completeTask(request: APIRequestContext, taskUuid: string) { + const claim = await request.post(`${TASKS}/${taskUuid}/claim`) + expect(claim.status(), await claim.text()).toBe(200) + const complete = await request.post(`${TASKS}/${taskUuid}/complete`, { + data: { outcome: 'done', comment: `${RUN_ID}` }, + }) + expect(complete.status(), await complete.text()).toBe(200) +} + +/** Delete the plan and the object; never a verdict on the code under test. */ +async function cleanup(request: APIRequestContext, objectUuid: string | null) { + if (objectUuid === null) return + try { + await request.delete(`${CASES}/${objectUuid}`) + await request.delete(`${OBJECTS}/${objectUuid}`) + } catch (error) { + console.warn('[case-plan] cleanup failed:', error) + } +} + +test.describe('flow-cases — a case plan anchored to an object', () => { + let objectUuid: string | null = null + + test.afterEach(async ({ request }) => { + await cleanup(request, objectUuid) + objectUuid = null + }) + + test('the case plan is read by object uuid, without any run', async ({ + request, + }) => { + objectUuid = await createObject(request) + const created = await request.post(`${CASES}/${objectUuid}`, { + data: { + register: Number(REGISTER_ID), + schema: Number(SCHEMA_ID), + definition: permitDefinition(), + }, + }) + expect(created.status(), await created.text()).toBe(201) + + const plan = await readPlan(request, objectUuid) + expect(plan.objectUuid).toBe(objectUuid) + expect(plan.items).toHaveLength(6) + for (const item of plan.items) { + expect(['stage', 'humanTask', 'milestone']).toContain(item.type) + expect(item.state).toBeTruthy() + expect(item.flowUuid).toBeNull() + } + const intake = byKey(plan, 'intake') + expect(intake.state).toBe('active') + expect(intake.parentItemId).toBeNull() + expect(byKey(plan, 'completeness-check').state).toBe('active') + expect(byKey(plan, 'application-complete').state).toBe('available') + expect(byKey(plan, 'assessment').state).toBe('available') + expect(byKey(plan, 'decide').parentItemId).not.toBeNull() + + // A definition naming an event outside the catalog is refused at save time. + const bad = permitDefinition() + bad.items[1].entryCriteria = [ + { id: 'x', on: { event: 'case.item.started', item: 'y' } }, + ] + const other = await createObject(request) + try { + const refused = await request.post(`${CASES}/${other}`, { + data: { + register: Number(REGISTER_ID), + schema: Number(SCHEMA_ID), + definition: bad, + }, + }) + expect(refused.status()).toBe(400) + expect((await refused.json()).error).toContain('case.item.started') + const none = await request.get(`${CASES}/${other}`) + expect(none.status()).toBe(404) + } finally { + await cleanup(request, other) + } + }) + + test('completing the task completes its item, the milestone admits the next stage, and status is written through', async ({ + request, + }) => { + objectUuid = await createObject(request) + const created = await request.post(`${CASES}/${objectUuid}`, { + data: { + register: Number(REGISTER_ID), + schema: Number(SCHEMA_ID), + definition: permitDefinition(), + }, + }) + expect(created.status(), await created.text()).toBe(201) + + let plan = await readPlan(request, objectUuid) + const check = byKey(plan, 'completeness-check') + expect(check.realisationKind).toBe('task') + expect(check.realisationUuid).toBeTruthy() + + // Scenario 4: the task's completion drives the item. + await completeTask(request, check.realisationUuid as string) + const evaluated = await request.post(`${CASES}/${objectUuid}/evaluate`) + expect(evaluated.status(), await evaluated.text()).toBe(200) + + plan = await readPlan(request, objectUuid) + const checkDone = byKey(plan, 'completeness-check') + expect(checkDone.state).toBe('completed') + const checkAudit = plan.audit.filter( + (entry) => + entry.caseItemId + === Number((check as unknown as { id: number }).id ?? -1) + || entry.causeRef === check.realisationUuid, + ) + expect( + checkAudit.some( + (entry) => + entry.cause === 'realisation' + && entry.causeRef === check.realisationUuid, + ), + ).toBe(true) + + // Scenario 2: the milestone was reached and admitted the assessment stage in the same evaluation. + expect(byKey(plan, 'application-complete').state).toBe('completed') + expect(byKey(plan, 'intake').state).toBe('completed') + expect(byKey(plan, 'assessment').state).toBe('active') + expect(byKey(plan, 'decide').state).toBe('active') + expect( + plan.audit.some( + (entry) => + entry.cause === 'sentry' && entry.causeRef === 'after-intake', + ), + ).toBe(true) + + // Scenario 6: the object carries the mirrored status, read by a consumer + // that knows nothing about plan items. + const object = await request.get(`${OBJECTS}/${objectUuid}`) + expect(object.status(), await object.text()).toBe(200) + const body = await object.json() + expect(body.status).toBe('Aanvraag volledig') + expect(body.statusReachedAt).toBeTruthy() + }) + + test('an ad-hoc item is attached to the live stage and realised, and terminating the stage cascades', async ({ + request, + }) => { + objectUuid = await createObject(request) + const created = await request.post(`${CASES}/${objectUuid}`, { + data: { + register: Number(REGISTER_ID), + schema: Number(SCHEMA_ID), + definition: permitDefinition(), + }, + }) + expect(created.status(), await created.text()).toBe(201) + + const flowsBefore = await ( + await request.get('/index.php/apps/openregister/api/flows?limit=500') + ).json() + + // Scenario 5: attach an unplanned advice request to the active intake stage. + const attached = await request.post(`${CASES}/${objectUuid}/items`, { + data: { + key: 'external-advice', + type: 'humanTask', + name: `${RUN_ID} extern advies`, + parent: 'intake', + required: false, + candidateUsers: [ADMIN], + }, + }) + expect(attached.status(), await attached.text()).toBe(201) + const advice: Item = await attached.json() + expect(advice.origin).toBe('adhoc') + expect(advice.state).toBe('active') + expect(advice.realisationKind).toBe('task') + expect(advice.flowUuid).toBeNull() + const task = await request.get(`${TASKS}/${advice.realisationUuid}`) + expect(task.status(), await task.text()).toBe(200) + expect((await task.json()).runUuid).toBeNull() + + // No flow definition and no definition version changed. + const flowsAfter = await ( + await request.get('/index.php/apps/openregister/api/flows?limit=500') + ).json() + expect(JSON.stringify(flowsAfter.results ?? flowsAfter)).toBe( + JSON.stringify(flowsBefore.results ?? flowsBefore), + ) + + // An ad-hoc item may not declare itself unguarded. + const unguarded = await request.post(`${CASES}/${objectUuid}/items`, { + data: { + key: 'sneaky', + type: 'humanTask', + parent: 'intake', + authorization: [], + }, + }) + expect(unguarded.status()).toBe(400) + + // Scenario 3: terminate the intake stage: the active items terminate, + // the unentered milestone is terminated too (it has no disabled edge), + // each with a cascade audit row naming the stage. + let plan = await readPlan(request, objectUuid) + const intake = byKey(plan, 'intake') + const terminated = await request.post( + `${CASES}/items/${intake.uuid}/transition`, + { + data: { to: 'terminated', reason: `${RUN_ID} withdrawn` }, + }, + ) + expect(terminated.status(), await terminated.text()).toBe(200) + + plan = await readPlan(request, objectUuid) + expect(byKey(plan, 'intake').state).toBe('terminated') + expect(byKey(plan, 'completeness-check').state).toBe('terminated') + expect(byKey(plan, 'external-advice').state).toBe('terminated') + expect(byKey(plan, 'application-complete').state).toBe('terminated') + const cascaded = plan.audit.filter( + (entry) => entry.cause === 'cascade' && entry.causeRef === intake.uuid, + ) + expect(cascaded.length).toBeGreaterThanOrEqual(3) + + // The realising task is terminated with a reason and leaves the inbox. + const adviceTask = await request.get(`${TASKS}/${advice.realisationUuid}`) + expect((await adviceTask.json()).state).toBe('terminated') + + // A terminal item accepts nothing further (409), and a milestone cannot become active. + const again = await request.post( + `${CASES}/items/${intake.uuid}/transition`, + { data: { to: 'completed' } }, + ) + expect(again.status()).toBe(409) + }) +}) From f9c4b10f158497a3a50c8961c730bfa4306f8919 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 18:49:06 +0200 Subject: [PATCH 33/68] feat(flow): the portal-task node, the external performer type and the portal delivery seam (#3282) * feat(tasks): the task store - four tables, entities and mappers The flow-task-entity storage layer (openspec/changes/flow-task-entity): openregister_tasks with the resolved 23-shape union (no overdue column, no app-named column, performer_type as an open string vocabulary so the coming external type is an append, definition_version shipped now), openregister_task_candidates as the pooled-inbox index half of the candidate pool, openregister_task_relations as the typed anchor table, and openregister_task_audit with an append-only mapper whose update and delete refuse. claim() is a conditional update so the database decides the race, and the inbox page and total share one predicate builder. Ticks tasks 1.1 and 1.2. * feat(tasks): the vocabularies, the authorization and the routing Normalisation at the boundary: TaskState publishes the one legacy-status mapping onto the six CMMN states (collapsed distinctions survive on outcome, an unmapped value is refused naming itself, and a migration may declare its source vocabulary so procest's status 'open' fails loudly); TaskPriority lands all four fleet scales on low|normal|high|urgent and refuses pipelinq's 'normaal'; TaskTemporalProjection is the ONE overdue derivation, computed and never stored. TaskAuthorizationService decides every verb fail-closed BEFORE mutation: no group backend, an unresolvable role or an unknown performer type each DENY rather than skip, and no nullable service-unavailable return exists. TaskPerformerResolver carries the five routing strategies; a strategy that finds nobody assigns nobody. Ticks tasks 2.1, 2.2, 3.1 and 3.2. * feat(tasks): the lifecycle service, cancellation propagation and the inbox TaskService: create/offer/claim/unclaim/assign/reassign/delegate/ resolve/complete/cancel, each authorized fail-closed before any mutation, each audited in the SAME transaction (an audit-write failure unwinds the completion), denials audited too. One write path maintains the candidate JSON and the candidate index rows together; state and is_terminal move in one statement; claim is the mapper's conditional update so the race's loser gets a conflict; a rejecting outcome without a comment is refused before anything moves; the template snapshot is frozen at creation. Cancellation propagation (design D-8): FlowRunMapper::update() - the one choke point every terminal run write passes - dispatches FlowRunTerminalEvent, and TaskRunTerminalListener terminates that run's open tasks with the reason recorded. Idempotent, and a task with run_uuid null is structurally out of reach. TaskInboxService answers assigned/pooled/watched/by-object in the datastore over one shared predicate set, with the total from the same predicates, subject context batched per page, a display title synthesized on read for titleless tasks (never persisted), and the derived temporal projection attached to rows only. Ticks tasks 4.1-4.5, 5.1, 6.1 and 6.2. * feat(tasks): the REST surface and the seed fixtures TaskController + /api/flow-tasks routes for the inbox, the detail and audit reads (both visibility-checked) and every lifecycle verb. The family is named for the flow-tasks CAPABILITY, not a flow requirement - /api/tasks itself already belongs to the CalDAV VTODO leaf. Every method declares its auth posture attribute AND delegates its actual authorization to TaskAuthorizationService inside the service; refusals translate uniformly (400 validation, 403 denial, 409 conflict naming the current state, 404 absence). SeedTaskFixtures installs the five seed groups from design.md (pooled municipal check with no run, delegated approval with enforcing expiry on a run, agent task with a typed checklist, one completed 'approved' and one terminated by propagation, plus audit fixtures including one DENIED entry), idempotent on uuid - and is REGISTERED in appinfo/info.xml in this same commit, because an unregistered repair step is a known fleet defect. Ticks tasks 7.1 and 8.1. * test(tasks): the guarantees, pinned - 93 tests over the task layer Table-driven legacy-status and priority tests including the live fleet defects: procest's status 'open' against a source vocabulary that does not define it is refused naming the value, and pipelinq's 'normaal' is refused on every scale. Authorization: a stranger who knows the uuid is denied on every verb, an unresolvable role denies naming the role, an absent group backend denies, an unknown performer type denies, watchers read and never act. Concurrency and transactionality: the two-claim race yields one assignee and one conflict, a rejection without a comment moves nothing, and an injected audit-write failure rolls the completion back. Derivation: overdue flips with an injected clock while the stored row stays byte-identical. Inbox: the page and the total run over the SAME criteria object (25 of 120 reported honestly), a titleless task presents a synthesized display title while its stored title stays null, and a runless task completes identically to a runful one. Mutation-checked: disabling assertMay fails 7 tests; removing the transaction rollback fails 2; both restored byte-identical (sha256-verified). Ticks tasks 9.1, 9.2 and 9.3. * test(tasks): playwright coverage for the two e2e-marked scenarios 'a stranger is refused on the task detail route': a provisioned second account gets 403 on the detail read AND on complete, and the task provably did not move - the positive control for the hole where knowing a uuid was the whole check. 'the inbox route returns tasks with subject context': one request lists the assignee's work with a display title, the subject slot, the derived overdue flag and a datastore total, and when the dev seed offers a live object the anchored subject context rides along. Both specs clean up after themselves and skip loudly when the instance cannot provision a stranger. Ticks task 9.4. * style(tasks): named parameters on every internal call, no inline ifs The repo's PHPCS standard requires named arguments on calls into OCA code and forbids ternaries. Exception constructors now name message:, the transactional/stringOrNull/arrayOrNull/respondWith calls name their parameter, the nine isset-ternaries collapse into one intOrNull helper, and the two remaining conditionals become explicit if blocks. No behaviour change: the 93 task tests are unchanged and green. * refactor(tasks): the quality pass - intake extracted, complexity earned down TaskBuilder now owns intake: boundary data in, a validated unsaved Task out, with every published vocabulary applied there once. That returns TaskService to lifecycle only and under the complexity budget. The authorization switch becomes a verb-to-rule table with static dispatch; the inbox predicate builder splits into scope, visibility and filters; the migration's indexes and the seed fixtures get methods of their own; sort and direction travel as one parameter (-dueAt) so the inbox route keeps a single-digit parameter list. The few remaining suppressions each carry their justification inline. Behaviour unchanged: the 93 task tests pass untouched except for injecting the builder. * fix(tasks): the PHPStan pass - typed mapper overrides and a DBAL constant The mapper overrides now guard their entity type with a throwing instanceof check, which is what lets the generic QBMapper contract type-check (and turns a wrong entity into a named error instead of a silent write). candidateMembershipPredicate declares the IQueryFunction it returns. The migration uses Types::DATETIME_MUTABLE, the constant the current DBAL stubs actually define. * style(tasks): import InvalidArgumentException in the three mapper guards PHPMD MissingImport on the type guards added in the PHPStan pass. No behaviour change. * feat(flow): the openregister.user-task node puts a person into the graph The node creates ONE task through TaskService on its first firing with items, stores the uuid in its own resume slot, and suspends with a heartbeat that is never null (findAbandonedSignals reaps null and would fail slow approvals at 14 days). Later firings read the TASK for terminality, never context.signal, so two user-task nodes in one flow keep independent answers. The outcome bag lands on every item's json under outcomeKey (default task), with decided/rejected separating a person's decision from a task that merely ended; failOnReject is opt-in. The advance budget (ADR-098 D9) is 0 | N | "all", null refused by name (FlowAdvanceBudget). TaskService announces every committed terminal transition (TaskTerminalEvent, after the transaction); the listener wakes the run with an empty signal and, per the node's stored budget, continues it through FlowRunAdvancer with a per-walk ceiling the engine consumes (CONTEXT_ADVANCE_BUDGET). A spent budget parks the run as due. Branch mootness: the engine reports pruned exits to FlowTaskMootness, which terminates the task of a user-task node standing on a cleared place. Run terminality stays with TaskRunTerminalListener from #3258. Ticks tasks 1.1-1.4, 2.1-2.3, 3.1-3.2, 4.1-4.3, 5.1-5.2. * fix(tasks): the review pass - offer is the requester's, and the inbox works on PostgreSQL Two blocking defects from the independent review of #3258: - offer bypassed per-task authorization and rewrote the assignee. Any authenticated user could POST offer {"routingFallback": "mallory"} on an assigned active task, become its assignee, and then complete it. offer now needs the requester (or an administrator), refuses a task that already has an assignee (unclaim or reassign instead), and is in the stranger-denied test's verb list, whose omission is how the suite stayed green. - The watchers LIKE ran against a json column, which PostgreSQL refuses (operator does not exist: json ~~ unknown): every non-admin inbox request 500ed there. The column is now cast per platform (AS TEXT on PostgreSQL, AS CHAR elsewhere), every identifier in the mapper's raw SQL goes through the platform's own quoter (no backticks), and TaskMapperTest pins the cast on a PostgreSQL platform mock. The should-fix items: authorization runs before the terminality check and a task the caller may not see answers 404 on every route, verbs included (no existence or state oracle); over HTTP the requester is pinned to the actor and a terminal creation state is refused, with import() as the trusted path for migrations and the user-task node; every state-changing write is a conditional update (WHERE is_terminal = false) so a second completion conflicts instead of overwriting; delegate refuses an empty delegate and keeps naming the original performer on re-delegation; an unexpected failure is a generic 500 whose detail goes to the log; propagation continues past a failing task; role pools are matched by the inbox EXISTS; the seed fixtures run only when app config openregister/seed_demo_tasks is true; the CSRF posture is recorded in the controller docblock; the e2e stranger scenario fails loudly instead of skipping and now covers the non-admin inbox and a watcher. Class-level @spec tags on every new class. TaskControllerTest is the contract test for all fourteen /api/flow-tasks endpoints (gate-25 PASS). Checks on this tree: PHPCS 0, PHPMD 0 (touched files), PHPStan 0, Psalm 0, 120 task tests green. * style(tasks): format the task inbox e2e spec with the Nextcloud prettier config CI's Frontend Check (format) named tests/e2e/task-inbox.spec.ts. Formatted with @nextcloud/prettier-config's options (tabs, no semicolons, single quotes, trailing commas, print width 85, operators leading). * test(tasks): cover the entities, the listener, the seed step and the builder CI's pgsql cell did not fail in PHPUnit (17860 tests, 0 failures, and app:enable succeeded); it failed the coverage guard: the changed files measured 3.85% (69/1791 statements) against a 7.32% base, because the suite runs with strict coverage metadata and collaborators exercised through TaskServiceTest were never credited. This adds the tests that were genuinely missing: the four entities' typing, hydration and serialisation (no overdue anywhere in the stored row); the terminal-run listener and event; the seed step off by default, seeding the five groups when on, idempotent on uuid, and continuing past a failing fixture; and TaskBuilder credited where TaskServiceTest exercises it. 133 task tests green. * test(flow): the user-task node, its budget, its bridge and its propagation Node: one task per node per run across a heartbeat wake, empty firing creates nothing and does not suspend, a claim is not a completion, the signal slot cannot answer for a performer, askedAt is not restamped, two nodes need two answers, outcome placement under json., rejection as a branch with failOnReject opt-in, a terminated task is not a rejection, the config-validation table (advance 0/3/all accepted; null, '', -1, 'unlimited' refused naming the value; no performer refused). Budget: FlowAdvanceBudget shapes; the engine parks at a spent ceiling and the worker walk runs the remainder; an oversight veto still applies in-request; pruned exits reach the mootness collaborator. Bridge: the provenance stamp, offer-on-strategy, 0/N/all continuation, and a failed continuation leaving the run due. Propagation: a run stopped with two tasks terminates both once across two observations; a losing branch takes its task; a run-less task is never touched. TaskService announces terminality once, after commit, and a listener failure cannot undo it. Mutation-checked: dropping the run-less guard, the performer guard or the signal-ignore each turns the suite red. Splits the node's config intake into UserTaskConfig and groups the form so the node stays under the PHPMD complexity and method-length ceilings. Ticks tasks 6.1-6.4. * test(e2e): the eight user-task scenarios over the live API Authored through POST /api/flows, run through the synchronous test endpoint, driven through the flow-tasks verbs. Two scenarios need the worker (the default budget parks for it; a run has no stop verb, so the operator's kill switch is the stop) and drive it through occ in the dev container, skipping loudly where occ is not reachable. Each test carries its scenario slug for gate-19. Ticks task 6.5 (19/19). * docs(flow): spec anchors on the three public UserTaskConfig readers gate-16 named assignee(), outcomeKey() and renderedTitle(). * test(tasks): walk the mapper queries and credit every exercised class The pgsql cell's coverage guard credits only statements in classes a test names with @covers (strict coverage metadata), and the changed files measured 20.16% against a 23.46% base. Two moves: every task test class now names each class it actually exercises (TaskBuilder, TaskState and TaskPriority behind TaskServiceTest, TaskInboxCriteria behind the inbox and controller tests, the entities and exceptions everywhere they are built), and the largest untested code is now walked: TaskMapper's whole query vocabulary, the candidate, relation and audit mappers, and FlowRunMapper's terminality announcement, over a fluent query-builder double that records which predicate and column each query sets, so a conditional update without its openness guard or a pooled scope without its EXISTS would fail here. Plus the untested TaskService verbs (unclaim, assign, reassign, cancel, resolve, terminateAsMoot, relations, intake refusals), TaskInboxService's batched subject context, and the controller's filter, flag and failing-backend paths. 168 task tests. * wip: checkpoint before rate-limit pause (2026-09-01 09:45) * chore: drop the node_modules symlink the wip checkpoint committed An absolute path into another checkout has no business in the tree; the symlink only served a local Playwright --list and stays untracked. * style(tasks): a top-level type import and sorted specifiers in the inbox e2e spec Vue Quality (eslint) on #3258 named four errors in tests/e2e/task-inbox.spec.ts: perfectionist/sort-named-imports (the specifiers now read apiRequest, expect, test) and import-extensions/ban-inline-type-imports (APIRequestContext is a top-level import type). eslint --fix plus a plain run: 0 errors; the sibling flow-engine.spec.ts as control also 0; prettier clean. * style(e2e): lint and format the user-task spec under the tests/ lint scope A top-level type import, sorted specifiers, the .ts extension on the relative import, and the repo's prettier config. Also carries #3258's matching fix for task-inbox.spec.ts through the merge. * wip: checkpoint after host restart (2026-09-01 14:08) * fix(merge): resolve the conflict markers the restart checkpoint committed half-merged The host restart interrupted a merge of origin/development (#3258 task entity, #3259 parallel streams) and the checkpoint commit carried eight files with conflict markers. Each is re-merged three-way against the task-entity base the branch was built on, so both sides survive: the squash-merged development content, the stacked user-task-node additions, and the portal-task changes to the task layer. * feat(flow): the portal-task node, the external performer type and the portal seam A resident who authenticates at portaliq's edge becomes a first-class performer: openregister.portal-task matches the party role on the case (initiator by default), freezes the reference on an external task, records the match in the audit, requests delivery through the portal seam and parks the run on a non-null heartbeat. The completion endpoint authorizes the acting portal subject against the STORED party reference before anything else, validates the upload constraints, stores every file on the case object through FileService::addFile and only then records the completion. A re-ask is graph re-entry with a mandatory reason, a new task, cycle two and the previous uuid. External tasks are refused claim, unclaim, delegate, offer, assign and reassign, are excluded from every Nextcloud inbox shape but the case-anchored read, and admit no administrator bypass on completion. The overdue path consumes flow-business-timers: a preBreach rung becomes a portal reminder, a slaBreached rung stays inward. Tasks 1.1, 1.2, 1.3, 2.1, 2.2, 3.1, 3.2, 4.1, 4.2, 5.1, 5.2, 5.3, 6.1, 7.1, 7.2, 8.1, 10.1, 10.2 of openspec/changes/flow-portal-task/tasks.md. * style(portal): clear every phpcs finding in the portal seam Named arguments on internal calls, no inline doc blocks (the duck-typed listener reads its accessors dynamically instead), the docblock order of the inbox service constructor, and the overlong l10n help strings wrapped. * test(flow): the portal-task e2e over the live API, and the ticked task list Seven @e2e-marked scenarios: the suspension with the ask on the case and in no inbox, subject-scoped visibility, the fail-closed refusal of another subject, the upload landing as a case file with the run advancing on the answer, and the re-ask cycle carrying the reviewer's reason. The resident acts through a credential-less request context under a minted X-Portal-Subject assertion; where occ cannot configure the shared secret the portal scenarios skip, loudly. Task 10.3; 9.1 stays open as portaliq's own follow-up change. * refactor(portal): clear every PHPMD finding on the touched files Complexity split where a method carried two jobs (the assertion verifier's structure vs claims, the upload validator's per-file half, the delivery summary's state fold, the authorization pre-checks), and suppressed with a written reason where the shape is the design (the rule-per-relationship authorization class, the config boundary's small readers, the mapper's one WHERE-clause vocabulary). * fix(portal): a volume ceiling on the three public portal routes (ADR-082) The subject routes are PublicPage by design (the caller is portaliq's server-to-server forward, not a session), which makes them anonymous surface: gate-82. Reads at 120/min like the other public read surfaces; the completion with its uploads at 30/min. * test(tasks): the performer vocabulary now carries external The entity test pinned the four-type list; flow-portal-task admits the external performer exactly the way the entity's own docblock said it would: an append to the array. * test(portal): complete the coverage metadata and close the uncovered seams The pgsql cell discards a test's coverage when it executes code its metadata does not list (beStrictAboutCoverageMetadata), which is what read five portal files as near-zero: every warned test class now lists what it executes. Three new suites close the real holes the clover named: the node registration listener end to end, the task service's two portal windows (openFor audits a denial before rethrowing, record grows the trail without moving the task, a completion carries responses and evidence), and the inbox row's delivery state over a present, absent and failing ledger. * style(tasks): one TooManyMethods suppression, not two The development merge left the class docblock with my suppression AND the one #3271 added upstream; phpcs reads the duplicate as an ungrouped tag. The upstream wording stays. * style(migration): drop the else in the pg_trgm probe branch PHPMD's ElseExpression on the hotfix migration that rode in with the development merge; the already-installed case now informs and returns, so the fresh-install line stands on its own. --------- Co-authored-by: Conduction Release Bot --- appinfo/routes.php | 15 + lib/AppInfo/Application.php | 41 ++ lib/Controller/PortalTaskController.php | 406 +++++++++++ lib/Db/PortalTaskDelivery.php | 321 +++++++++ lib/Db/PortalTaskDeliveryMapper.php | 219 ++++++ lib/Db/Task.php | 26 +- lib/Db/TaskMapper.php | 102 +++ .../PortalPartyNotFoundException.php | 36 + lib/Exception/PortalSubjectException.php | 73 ++ lib/Listener/FlowNodeRegistrationListener.php | 10 + lib/Listener/PortalTaskReminderListener.php | 261 +++++++ lib/Migration/Version1Date20260901000000.php | 6 +- lib/Migration/Version1Date20260901160000.php | 90 +++ lib/Service/Flow/FlowTaskBridge.php | 19 + lib/Service/Flow/Nodes/PortalTaskConfig.php | 540 +++++++++++++++ lib/Service/Flow/Nodes/PortalTaskNode.php | 649 +++++++++++++++++ lib/Service/Portal/PortalPartyResolver.php | 267 +++++++ lib/Service/Portal/PortalSubject.php | 94 +++ lib/Service/Portal/PortalSubjectAssertion.php | 252 +++++++ .../Portal/PortalTaskDeliveryService.php | 196 ++++++ lib/Service/Portal/PortalTaskService.php | 445 ++++++++++++ lib/Service/Task/TaskAuthorizationService.php | 169 ++++- lib/Service/Task/TaskInboxService.php | 74 ++ lib/Service/Task/TaskService.php | 89 ++- openspec/changes/flow-portal-task/tasks.md | 38 +- .../Controller/PortalTaskControllerTest.php | 326 +++++++++ .../Unit/Db/PortalTaskDeliveryMapperTest.php | 127 ++++ tests/Unit/Db/TaskEntitiesTest.php | 2 +- tests/Unit/Db/TaskMapperExternalTest.php | 89 +++ .../FlowNodeRegistrationListenerTest.php | 137 ++++ .../PortalTaskReminderListenerTest.php | 259 +++++++ .../Unit/Service/Flow/PortalTaskNodeTest.php | 585 ++++++++++++++++ .../Portal/PortalPartyResolverTest.php | 146 ++++ .../Portal/PortalSubjectAssertionTest.php | 180 +++++ .../Portal/PortalTaskDeliveryServiceTest.php | 233 +++++++ .../Service/Portal/PortalTaskServiceTest.php | 405 +++++++++++ .../Task/TaskAuthorizationExternalTest.php | 189 +++++ .../Task/TaskAuthorizationServiceTest.php | 6 +- .../Task/TaskInboxDeliveryStateTest.php | 131 ++++ .../Task/TaskServicePortalWindowsTest.php | 270 ++++++++ tests/e2e/api-direct/flow-portal-task.spec.ts | 652 ++++++++++++++++++ 41 files changed, 8130 insertions(+), 45 deletions(-) create mode 100644 lib/Controller/PortalTaskController.php create mode 100644 lib/Db/PortalTaskDelivery.php create mode 100644 lib/Db/PortalTaskDeliveryMapper.php create mode 100644 lib/Exception/PortalPartyNotFoundException.php create mode 100644 lib/Exception/PortalSubjectException.php create mode 100644 lib/Listener/PortalTaskReminderListener.php create mode 100644 lib/Migration/Version1Date20260901160000.php create mode 100644 lib/Service/Flow/Nodes/PortalTaskConfig.php create mode 100644 lib/Service/Flow/Nodes/PortalTaskNode.php create mode 100644 lib/Service/Portal/PortalPartyResolver.php create mode 100644 lib/Service/Portal/PortalSubject.php create mode 100644 lib/Service/Portal/PortalSubjectAssertion.php create mode 100644 lib/Service/Portal/PortalTaskDeliveryService.php create mode 100644 lib/Service/Portal/PortalTaskService.php create mode 100644 tests/Unit/Controller/PortalTaskControllerTest.php create mode 100644 tests/Unit/Db/PortalTaskDeliveryMapperTest.php create mode 100644 tests/Unit/Db/TaskMapperExternalTest.php create mode 100644 tests/Unit/Listener/FlowNodeRegistrationListenerTest.php create mode 100644 tests/Unit/Listener/PortalTaskReminderListenerTest.php create mode 100644 tests/Unit/Service/Flow/PortalTaskNodeTest.php create mode 100644 tests/Unit/Service/Portal/PortalPartyResolverTest.php create mode 100644 tests/Unit/Service/Portal/PortalSubjectAssertionTest.php create mode 100644 tests/Unit/Service/Portal/PortalTaskDeliveryServiceTest.php create mode 100644 tests/Unit/Service/Portal/PortalTaskServiceTest.php create mode 100644 tests/Unit/Service/Task/TaskAuthorizationExternalTest.php create mode 100644 tests/Unit/Service/Task/TaskInboxDeliveryStateTest.php create mode 100644 tests/Unit/Service/Task/TaskServicePortalWindowsTest.php create mode 100644 tests/e2e/api-direct/flow-portal-task.spec.ts diff --git a/appinfo/routes.php b/appinfo/routes.php index 0ac23fac03..b443d0d94b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1374,6 +1374,21 @@ ['name' => 'task#complete', 'url' => '/api/flow-tasks/{uuid}/complete', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'task#cancel', 'url' => '/api/flow-tasks/{uuid}/cancel', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'task#checkItem', 'url' => '/api/flow-tasks/{uuid}/checklist/{itemId}', 'verb' => 'PATCH', 'requirements' => ['uuid' => '[^/]+', 'itemId' => '[^/]+']], + + // The portal seam (flow-portal-task): a party OUTSIDE the instance, + // authenticated at portaliq's edge, acts here under a signed + // X-Portal-Subject assertion, never a Nextcloud session. The subject + // routes are PublicPage by design and authorized inside the service + // against the task's STORED party reference. The delivery routes are + // the operator's (administrator): portaliq settles what it sent. + // `deliveries` is registered before `{uuid}` so the literal wins. + ['name' => 'portalTask#index', 'url' => '/api/portal-tasks', 'verb' => 'GET'], + ['name' => 'portalTask#deliveries', 'url' => '/api/portal-tasks/deliveries', 'verb' => 'GET'], + ['name' => 'portalTask#deliveryDelivered', 'url' => '/api/portal-tasks/deliveries/{uuid}/delivered', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'portalTask#deliveryFailed', 'url' => '/api/portal-tasks/deliveries/{uuid}/failed', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'portalTask#show', 'url' => '/api/portal-tasks/{uuid}', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'portalTask#complete', 'url' => '/api/portal-tasks/{uuid}/complete', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + // The case layer (flow-cmmn-case-semantics): a plan of stages, human // items and milestones anchored to an OpenRegister OBJECT. There is no // case id: every plan route is keyed by the anchoring object's uuid, and diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 91cd0f593f..a8a924a621 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -235,6 +235,7 @@ use OCA\OpenRegister\Service\OpenProjectLinkService; use OCA\OpenRegister\Service\OrganisationService; use OCA\OpenRegister\Service\PhotoLinkService; +use OCA\OpenRegister\Service\Portal\PortalPartyResolver; use OCA\OpenRegister\Service\Schema\SchemaDiffService; use OCA\OpenRegister\Service\Schema\SchemaMigrationPlanner; use OCA\OpenRegister\Service\Schema\SchemaMigrationService; @@ -242,6 +243,7 @@ use OCA\OpenRegister\Service\Schema\SchemaVersioningService; use OCA\OpenRegister\Service\SchemaImport\DialectDetector; use OCA\OpenRegister\Service\SchemaImport\SchemaImportService; +use OCA\OpenRegister\Service\Task\TaskInboxService; use OCA\OpenRegister\Service\SchemaImport\ThreeWayMerge; use OCA\OpenRegister\Service\Schemas\FacetCacheHandler; use OCA\OpenRegister\Service\Schemas\PropertyValidatorHandler; @@ -822,6 +824,33 @@ function (ContainerInterface $container) { } ); + // The task inbox and the portal party resolver read the object store + // through AbstractObjectMapper, which the autowirer cannot build (it is + // abstract) and would silently default to null: an inbox row without + // subject context, and a portal task that can match nobody. Both are + // wired to the MagicMapper explicitly for that reason. + $context->registerService( + TaskInboxService::class, + function (ContainerInterface $container) { + return new TaskInboxService( + tasks: $container->get(\OCA\OpenRegister\Db\TaskMapper::class), + temporal: $container->get(\OCA\OpenRegister\Service\Task\TaskTemporalProjection::class), + logger: $container->get('Psr\Log\LoggerInterface'), + objects: $container->get(MagicMapper::class), + deliveries: $container->get(\OCA\OpenRegister\Db\PortalTaskDeliveryMapper::class) + ); + } + ); + + $context->registerService( + PortalPartyResolver::class, + function (ContainerInterface $container) { + return new PortalPartyResolver( + objects: $container->get(MagicMapper::class) + ); + } + ); + // EntityRelationMapper is registered explicitly because it constructor-injects // `IEventDispatcher` to dispatch `EntityRelationDecisionUpdatedEvent`. Every // other event-dispatcher-dependent mapper in this method (SchemaMapper, @@ -2554,6 +2583,18 @@ private function registerEventListeners(IRegistrationContext $context): void { \OCA\OpenRegister\Listener\UserTaskTerminalListener::class ); + // The portal reminder (flow-portal-task, design D-8): a preBreach rung + // of flow-business-timers on an EXTERNAL task becomes a reminder + // delivery request through the portal seam; a slaBreached rung stays + // inward. Registered by the event's NAME because the timers change is + // built in parallel and its event class may not be on this branch yet; + // the listener is duck-typed against the event's published surface, so + // the two merge in either order. + $context->registerEventListener( + \OCA\OpenRegister\Listener\PortalTaskReminderListener::EVENT_CLASS, + \OCA\OpenRegister\Listener\PortalTaskReminderListener::class + ); + // Task projections (flow-task-inbox-projections): a committed // transition becomes a declarative notification and a VTODO in the // assignee's calendar. Both run AFTER the commit and neither can fail diff --git a/lib/Controller/PortalTaskController.php b/lib/Controller/PortalTaskController.php new file mode 100644 index 0000000000..664d316143 --- /dev/null +++ b/lib/Controller/PortalTaskController.php @@ -0,0 +1,406 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Controller + * @package OCA\OpenRegister\Controller + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Exception\PortalSubjectException; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Portal\PortalSubject; +use OCA\OpenRegister\Service\Portal\PortalSubjectAssertion; +use OCA\OpenRegister\Service\Portal\PortalTaskDeliveryService; +use OCA\OpenRegister\Service\Portal\PortalTaskService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * REST surface of the portal task seam. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The controller mediates + * between HTTP and the seam's three services plus their exception shapes; + * that is the whole of its job. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ +class PortalTaskController extends Controller { + + /** + * Constructor. + * + * @param string $appName The app id. + * @param IRequest $request The request. + * @param PortalSubjectAssertion $assertion Verifies the acting subject. + * @param PortalTaskService $portal The subject-scoped read and completion. + * @param PortalTaskDeliveryService $delivery The delivery ledger. + * @param IUserSession $userSession Names the operator on the delivery routes. + * @param IGroupManager|null $groupManager Decides administrator status; + * absent, nobody is one. + * @param LoggerInterface|null $logger Where an unexpected failure's detail + * goes, INSTEAD of the response. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly PortalSubjectAssertion $assertion, + private readonly PortalTaskService $portal, + private readonly PortalTaskDeliveryService $delivery, + private readonly IUserSession $userSession, + private readonly ?IGroupManager $groupManager = null, + private readonly ?LoggerInterface $logger = null, + ) { + parent::__construct(appName: $appName, request: $request); + + }//end __construct() + + /** + * The acting subject's open portal tasks, with case context. + * + * @param int $limit Page size. + * @param int $offset Page offset. + * + * @return JSONResponse The page: results, total, limit, offset. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 120, period: 60)] + public function index(int $limit = 25, int $offset = 0): JSONResponse { + return $this->asSubject( + action: fn (PortalSubject $subject): JSONResponse => new JSONResponse( + $this->portal->listForSubject(subject: $subject, limit: $limit, offset: $offset) + ) + ); + }//end index() + + /** + * One portal task, if it is the acting subject's. + * + * @param string $uuid The task uuid. + * + * @return JSONResponse The row; 404 when absent or not this subject's. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 120, period: 60)] + public function show(string $uuid): JSONResponse { + return $this->asSubject( + action: fn (PortalSubject $subject): JSONResponse => new JSONResponse( + $this->portal->row(task: $this->portal->show(subject: $subject, uuid: $uuid)) + ) + ); + }//end show() + + /** + * Complete a portal task as the acting subject, with answers and uploads. + * + * Multipart or JSON: files arrive under `files[]` or `file`; `answers` is + * an object (or a JSON string of one); `comment` and `outcome` are plain. + * + * @param string $uuid The task uuid. + * @param string $outcome The outcome to record; `submitted` by default. + * @param string|null $comment The party's comment. + * + * @return JSONResponse The completed task row, or a named refusal. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-an-upload-completion-lands-as-a-file-attachment-on-the-case-object + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 30, period: 60)] + public function complete(string $uuid, string $outcome = PortalTaskService::DEFAULT_OUTCOME, ?string $comment = null): JSONResponse { + return $this->asSubject( + action: fn (PortalSubject $subject): JSONResponse => new JSONResponse( + $this->portal->row( + task: $this->portal->complete( + subject: $subject, + uuid: $uuid, + answers: $this->answers(), + comment: $comment, + files: $this->uploads(), + outcome: $outcome + ) + ) + ) + ); + }//end complete() + + /** + * The delivery requests awaiting settlement: what portaliq renders and sends. + * + * Administrator only. The route declares no NoAdminRequired, so the + * framework already refuses a non-administrator; the explicit check below + * makes the posture readable and holds if the attribute set ever changes. + * + * @param int $limit Page size. + * + * @return JSONResponse The pending rows. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + #[NoCSRFRequired] + public function deliveries(int $limit = 100): JSONResponse { + $refusal = $this->refuseUnlessAdmin(); + if ($refusal !== null) { + return $refusal; + } + + $rows = array_map( + static fn (PortalTaskDelivery $row): array => $row->jsonSerialize(), + $this->delivery->pending(limit: $limit) + ); + + return new JSONResponse(['results' => $rows, 'total' => count($rows)]); + }//end deliveries() + + /** + * Portaliq reports a delivery request as sent. + * + * @param string $uuid The delivery uuid. + * + * @return JSONResponse The settled row; 404 when no such request exists. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + #[NoCSRFRequired] + public function deliveryDelivered(string $uuid): JSONResponse { + $refusal = $this->refuseUnlessAdmin(); + if ($refusal !== null) { + return $refusal; + } + + try { + return new JSONResponse($this->delivery->markDelivered(uuid: $uuid)->jsonSerialize()); + } catch (DoesNotExistException) { + return new JSONResponse(['error' => 'No such delivery request', 'code' => 'no-such-delivery'], Http::STATUS_NOT_FOUND); + } + }//end deliveryDelivered() + + /** + * Portaliq reports a delivery request as failed, and why. + * + * @param string $uuid The delivery uuid. + * @param string $error The failure. + * + * @return JSONResponse The settled row; 404 when no such request exists. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + #[NoCSRFRequired] + public function deliveryFailed(string $uuid, string $error = ''): JSONResponse { + $refusal = $this->refuseUnlessAdmin(); + if ($refusal !== null) { + return $refusal; + } + + if (trim($error) === '') { + return new JSONResponse(['error' => 'A failed delivery needs an error naming why.', 'code' => 'error-required'], Http::STATUS_BAD_REQUEST); + } + + try { + return new JSONResponse($this->delivery->markFailed(uuid: $uuid, error: $error)->jsonSerialize()); + } catch (DoesNotExistException) { + return new JSONResponse(['error' => 'No such delivery request', 'code' => 'no-such-delivery'], Http::STATUS_NOT_FOUND); + } + }//end deliveryFailed() + + /** + * Resolve the acting subject, run the action as them, and translate every + * refusal to the wire, uniformly. + * + * @param callable(PortalSubject): JSONResponse $action The action. + * + * @return JSONResponse The action's response, or the refusal. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + private function asSubject(callable $action): JSONResponse { + try { + $subject = $this->assertion->resolve(request: $this->request); + } catch (PortalSubjectException $refused) { + // No subject, no answer. The code says which class of defect; the + // verifier's own message stays in the log. + $this->logger?->info('[PortalTaskController] Refused a portal request: ' . $refused->getMessage(), ['code' => $refused->refusal()]); + + return new JSONResponse(['error' => 'No acting portal subject', 'code' => $refused->refusal()], Http::STATUS_UNAUTHORIZED); + } + + try { + return $action($subject); + } catch (TaskValidationException $refused) { + return new JSONResponse(['error' => $refused->getMessage(), 'code' => 'upload-constraint'], Http::STATUS_BAD_REQUEST); + } catch (TaskAccessDeniedException) { + // Denied reads as absent: another subject who knows the uuid learns + // nothing, not even that it exists. The denial is in the audit. + return new JSONResponse(['error' => 'No such task', 'code' => 'no-such-task'], Http::STATUS_NOT_FOUND); + } catch (DoesNotExistException) { + return new JSONResponse(['error' => 'No such task', 'code' => 'no-such-task'], Http::STATUS_NOT_FOUND); + } catch (TaskConflictException $conflict) { + return new JSONResponse(['error' => $conflict->getMessage(), 'code' => 'task-closed'], Http::STATUS_CONFLICT); + } catch (Throwable $failure) { + $this->logger?->error('[PortalTaskController] Portal task operation failed: ' . $failure->getMessage(), ['exception' => $failure]); + + return new JSONResponse( + ['error' => 'The portal task operation failed. The details are in the server log.', 'code' => 'internal'], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end asSubject() + + /** + * The submitted answer fields: an object, or a JSON string of one. + * + * @return array The answers; empty when none were sent. + */ + private function answers(): array { + $raw = $this->request->getParam('answers'); + if (is_string($raw) === true) { + $decoded = json_decode($raw, true); + if (is_array($decoded) === true) { + return $decoded; + } + + return []; + } + + if (is_array($raw) === true) { + return $raw; + } + + return []; + }//end answers() + + /** + * The uploads, normalised to {name, type, size, tmp_name} regardless of + * whether they came as `files[]` or one `file`. + * + * @return array> The uploads; empty when none. + */ + private function uploads(): array { + $uploads = []; + foreach (['files', 'file'] as $key) { + $raw = $this->request->getUploadedFile($key); + if (is_array($raw) === false || $raw === []) { + continue; + } + + $names = ($raw['name'] ?? null); + if (is_array($names) === false) { + $uploads[] = $this->oneUpload(raw: $raw); + continue; + } + + foreach (array_keys($names) as $index) { + $uploads[] = $this->oneUpload( + raw: [ + 'name' => ($raw['name'][$index] ?? ''), + 'type' => ($raw['type'][$index] ?? ''), + 'tmp_name' => ($raw['tmp_name'][$index] ?? ''), + 'size' => ($raw['size'][$index] ?? 0), + 'error' => ($raw['error'][$index] ?? UPLOAD_ERR_NO_FILE), + ] + ); + } + }//end foreach + + // A slot that carried no file (UPLOAD_ERR_NO_FILE) is not an upload. + return array_values(array_filter($uploads, static fn (array $file): bool => (int)$file['error'] === UPLOAD_ERR_OK)); + }//end uploads() + + /** + * One `$_FILES`-shaped entry, normalised. + * + * @param array $raw The entry. + * + * @return array {name, type, size, tmp_name, error}. + */ + private function oneUpload(array $raw): array { + return [ + 'name' => (string)($raw['name'] ?? ''), + 'type' => (string)($raw['type'] ?? ''), + 'size' => (int)($raw['size'] ?? 0), + 'tmp_name' => (string)($raw['tmp_name'] ?? ''), + 'error' => (int)($raw['error'] ?? UPLOAD_ERR_NO_FILE), + ]; + }//end oneUpload() + + /** + * Refuse the delivery routes to anyone but an authenticated administrator. + * + * @return JSONResponse|null The refusal, or null to proceed. + */ + private function refuseUnlessAdmin(): ?JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'No session', 'code' => 'no-session'], Http::STATUS_UNAUTHORIZED); + } + + $isAdmin = false; + try { + $isAdmin = ($this->groupManager?->isAdmin($user->getUID()) === true); + } catch (Throwable) { + $isAdmin = false; + } + + if ($isAdmin === false) { + return new JSONResponse(['error' => 'Administrators only', 'code' => 'admin-required'], Http::STATUS_FORBIDDEN); + } + + return null; + }//end refuseUnlessAdmin() +}//end class diff --git a/lib/Db/PortalTaskDelivery.php b/lib/Db/PortalTaskDelivery.php new file mode 100644 index 0000000000..d96da07a39 --- /dev/null +++ b/lib/Db/PortalTaskDelivery.php @@ -0,0 +1,321 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * Class PortalTaskDelivery + * + * @method string|null getUuid() + * @method void setUuid(?string $uuid) + * @method string|null getTaskUuid() + * @method void setTaskUuid(?string $taskUuid) + * @method string|null getPartyReference() + * @method void setPartyReference(?string $partyReference) + * @method string|null getChannel() + * @method void setChannel(?string $channel) + * @method string|null getKind() + * @method void setKind(?string $kind) + * @method string|null getState() + * @method void setState(?string $state) + * @method array|null getMessage() + * @method void setMessage(?array $message) + * @method string|null getError() + * @method void setError(?string $error) + * @method DateTime|null getRequestedAt() + * @method void setRequestedAt(?DateTime $requestedAt) + * @method DateTime|null getDeliveredAt() + * @method void setDeliveredAt(?DateTime $deliveredAt) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ +class PortalTaskDelivery extends Entity implements JsonSerializable { + + /** + * The two channels an ask travels by. Nothing Nextcloud-facing is a + * channel here: no notification, no calendar projection. + */ + public const CHANNEL_PORTAL_INBOX = 'portal-inbox'; + + public const CHANNEL_MAIL = 'mail'; + + /** + * Every channel. + * + * @var array + */ + public const CHANNELS = [ + self::CHANNEL_PORTAL_INBOX, + self::CHANNEL_MAIL, + ]; + + /** + * What is being delivered: the first ask, a re-ask, or a timer reminder. + */ + public const KIND_ASK = 'ask'; + + public const KIND_RE_ASK = 're-ask'; + + public const KIND_REMINDER = 'reminder'; + + /** + * Delivery states. `not-recorded` is never stored: it is the summary of + * a task with NO rows, which is the outage the spec wants readable. + */ + public const STATE_REQUESTED = 'requested'; + + public const STATE_DELIVERED = 'delivered'; + + public const STATE_FAILED = 'failed'; + + public const STATE_NOT_RECORDED = 'not-recorded'; + + /** + * Public identifier. + * + * @var string|null + */ + protected ?string $uuid = null; + + /** + * The task whose ask this delivers. + * + * @var string|null + */ + protected ?string $taskUuid = null; + + /** + * The matched party (the task's stored party reference). + * + * @var string|null + */ + protected ?string $partyReference = null; + + /** + * `portal-inbox` or `mail`. + * + * @var string|null + */ + protected ?string $channel = null; + + /** + * `ask`, `re-ask` or `reminder`. + * + * @var string|null + */ + protected ?string $kind = null; + + /** + * `requested`, `delivered` or `failed`. + * + * @var string|null + */ + protected ?string $state = null; + + /** + * What portaliq renders: title, description, reason, cycle, case context. + * + * @var array|null + */ + protected ?array $message = null; + + /** + * Why a delivery failed, when it did. + * + * @var string|null + */ + protected ?string $error = null; + + /** + * When the request was recorded. + * + * @var DateTime|null + */ + protected ?DateTime $requestedAt = null; + + /** + * When the channel reported the delivery. + * + * @var DateTime|null + */ + protected ?DateTime $deliveredAt = null; + + /** + * Row creation timestamp. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Constructor: declare field types so the mapper hydrates them correctly. + */ + public function __construct() { + $this->addType(fieldName: 'uuid', type: 'string'); + $this->addType(fieldName: 'taskUuid', type: 'string'); + $this->addType(fieldName: 'partyReference', type: 'string'); + $this->addType(fieldName: 'channel', type: 'string'); + $this->addType(fieldName: 'kind', type: 'string'); + $this->addType(fieldName: 'state', type: 'string'); + $this->addType(fieldName: 'message', type: 'json'); + $this->addType(fieldName: 'error', type: 'string'); + $this->addType(fieldName: 'requestedAt', type: 'datetime'); + $this->addType(fieldName: 'deliveredAt', type: 'datetime'); + $this->addType(fieldName: 'created', type: 'datetime'); + + }//end __construct() + + /** + * Summarise a task's delivery rows into the one state its row reports. + * + * `not-recorded` for no rows; `failed` if any channel failed; `delivered` + * once the portal inbox message went out (the mail is best-effort and + * does not hold the state back); otherwise `requested`. The most recent + * request decides, so a re-ask's fresh rows are what the caseworker sees. + * + * @param array $rows The task's delivery rows, any order. + * + * @return array {state, channels, requestedAt, deliveredAt}. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public static function summarise(array $rows): array { + if ($rows === []) { + return [ + 'state' => self::STATE_NOT_RECORDED, + 'channels' => [], + 'requestedAt' => null, + 'deliveredAt' => null, + ]; + } + + $latest = self::latestRequestAt(rows: $rows); + $channels = []; + $state = self::STATE_REQUESTED; + $deliveredAt = null; + foreach ($rows as $row) { + $requestedAt = $row->getRequestedAt(); + if ($latest !== null && $requestedAt !== null && $requestedAt < $latest) { + continue; + } + + $channels[(string)$row->getChannel()] = [ + 'state' => $row->getState(), + 'kind' => $row->getKind(), + 'error' => $row->getError(), + 'deliveredAt' => $row->getDeliveredAt()?->format('c'), + ]; + + [$state, $deliveredAt] = self::foldState(state: $state, deliveredAt: $deliveredAt, row: $row); + } + + return [ + 'state' => $state, + 'channels' => $channels, + 'requestedAt' => $latest?->format('c'), + 'deliveredAt' => $deliveredAt, + ]; + }//end summarise() + + /** + * Fold one row into the summary state: any failure wins; the portal inbox + * going out makes the round delivered (the mail is best-effort). + * + * @param string $state The state so far. + * @param string|null $deliveredAt When the round was delivered, so far. + * @param PortalTaskDelivery $row The row folded in. + * + * @return array{0: string, 1: string|null} The state and deliveredAt after. + */ + private static function foldState(string $state, ?string $deliveredAt, PortalTaskDelivery $row): array { + if ($row->getState() === self::STATE_FAILED) { + return [self::STATE_FAILED, $deliveredAt]; + } + + if ($row->getChannel() === self::CHANNEL_PORTAL_INBOX + && $row->getState() === self::STATE_DELIVERED + && $state !== self::STATE_FAILED + ) { + return [self::STATE_DELIVERED, $row->getDeliveredAt()?->format('c')]; + } + + return [$state, $deliveredAt]; + }//end foldState() + + /** + * The newest request instant: only that round counts, so a re-ask's fresh + * rows are what the caseworker sees. + * + * @param array $rows The task's delivery rows. + * + * @return DateTime|null The newest requestedAt, or null when none carries one. + */ + private static function latestRequestAt(array $rows): ?DateTime { + $latest = null; + foreach ($rows as $row) { + $requestedAt = $row->getRequestedAt(); + if ($requestedAt !== null && ($latest === null || $requestedAt > $latest)) { + $latest = $requestedAt; + } + } + + return $latest; + }//end latestRequestAt() + + /** + * Serialise for the API. + * + * @return array The delivery as plain data. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'taskUuid' => $this->taskUuid, + 'partyReference' => $this->partyReference, + 'channel' => $this->channel, + 'kind' => $this->kind, + 'state' => $this->state, + 'message' => $this->message, + 'error' => $this->error, + 'requestedAt' => $this->requestedAt?->format('c'), + 'deliveredAt' => $this->deliveredAt?->format('c'), + 'created' => $this->created?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/PortalTaskDeliveryMapper.php b/lib/Db/PortalTaskDeliveryMapper.php new file mode 100644 index 0000000000..5ad76adc34 --- /dev/null +++ b/lib/Db/PortalTaskDeliveryMapper.php @@ -0,0 +1,219 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use InvalidArgumentException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Symfony\Component\Uid\Uuid; + +/** + * Mapper for {@see PortalTaskDelivery}. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ +class PortalTaskDeliveryMapper extends QBMapper { + + /** + * The table name. + * + * @var string + */ + public const TABLE = 'openregister_portal_deliveries'; + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: self::TABLE, entityClass: PortalTaskDelivery::class); + + }//end __construct() + + /** + * Insert a delivery request, stamping uuid, state and timestamps. + * + * @param Entity $entity The delivery to insert. + * + * @return PortalTaskDelivery The inserted row. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function insert(Entity $entity): PortalTaskDelivery { + if ($entity instanceof PortalTaskDelivery === false) { + throw new InvalidArgumentException('PortalTaskDeliveryMapper stores PortalTaskDelivery rows only.'); + } + + if ($entity->getUuid() === null) { + $entity->setUuid(Uuid::v4()->toRfc4122()); + } + + if ($entity->getState() === null) { + $entity->setState(PortalTaskDelivery::STATE_REQUESTED); + } + + $now = new DateTime(); + if ($entity->getRequestedAt() === null) { + $entity->setRequestedAt($now); + } + + $entity->setCreated($now); + + return parent::insert(entity: $entity); + }//end insert() + + /** + * One delivery by uuid. + * + * @param string $uuid The delivery uuid. + * + * @return PortalTaskDelivery The row. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException When absent. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function findByUuid(string $uuid): PortalTaskDelivery { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))); + + return $this->findEntity(query: $qb); + }//end findByUuid() + + /** + * Every delivery row of one task, oldest first. + * + * @param string $taskUuid The task. + * + * @return array The rows. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function findForTask(string $taskUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('task_uuid', $qb->createNamedParameter($taskUuid))) + ->orderBy('requested_at', 'ASC') + ->addOrderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findForTask() + + /** + * Delivery requests not yet reported on, oldest first: what portaliq + * picks up to render and send. + * + * @param int $limit Page size. + * + * @return array The pending rows. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function findPending(int $limit = 100): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('state', $qb->createNamedParameter(PortalTaskDelivery::STATE_REQUESTED))) + ->orderBy('requested_at', 'ASC') + ->addOrderBy('id', 'ASC') + ->setMaxResults(max(1, min($limit, 500))); + + return $this->findEntities(query: $qb); + }//end findPending() + + /** + * A channel reports the delivery went out. + * + * @param PortalTaskDelivery $delivery The row. + * + * @return PortalTaskDelivery The updated row. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function markDelivered(PortalTaskDelivery $delivery): PortalTaskDelivery { + $delivery->setState(PortalTaskDelivery::STATE_DELIVERED); + $delivery->setDeliveredAt(new DateTime()); + $delivery->setError(null); + + return parent::update(entity: $delivery); + }//end markDelivered() + + /** + * A channel reports the delivery failed, and why. + * + * @param PortalTaskDelivery $delivery The row. + * @param string $error The failure, as the channel described it. + * + * @return PortalTaskDelivery The updated row. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function markFailed(PortalTaskDelivery $delivery, string $error): PortalTaskDelivery { + $delivery->setState(PortalTaskDelivery::STATE_FAILED); + $delivery->setError(mb_substr($error, 0, 1000)); + + return parent::update(entity: $delivery); + }//end markFailed() + + /** + * The delivery rows of several tasks in ONE query, grouped by task uuid. + * + * @param array $taskUuids The tasks. + * + * @return array> Rows by task uuid. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function findForTasks(array $taskUuids): array { + $taskUuids = array_values(array_unique(array_filter(array_map('strval', $taskUuids), static fn (string $uuid): bool => $uuid !== ''))); + if ($taskUuids === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->in('task_uuid', $qb->createNamedParameter($taskUuids, IQueryBuilder::PARAM_STR_ARRAY))) + ->orderBy('requested_at', 'ASC') + ->addOrderBy('id', 'ASC'); + + $grouped = []; + foreach ($this->findEntities(query: $qb) as $row) { + $grouped[(string)$row->getTaskUuid()][] = $row; + } + + return $grouped; + }//end findForTasks() +}//end class diff --git a/lib/Db/Task.php b/lib/Db/Task.php index dddb02fa5d..2349bbd0e2 100644 --- a/lib/Db/Task.php +++ b/lib/Db/Task.php @@ -213,9 +213,9 @@ class Task extends Entity implements JsonSerializable { * * DELIBERATELY NOT A CLOSED SET at the storage level: the column is a * plain string, and this list is the vocabulary the service validates - * against today. An `external` (portal party) type is being added by a - * parallel ADR-098 amendment; admitting it is an append to this array, - * not a migration. + * against today. `external` (a portal party, ADR-098 D3 as amended + * 2026-08-31 for flow-portal-task) was admitted exactly that way: an + * append to this array, not a migration. */ public const PERFORMER_USER = 'user'; @@ -225,6 +225,25 @@ class Task extends Entity implements JsonSerializable { public const PERFORMER_WORKER = 'worker'; + /** + * A party outside the instance, reached through the portal seam. Its + * performer reference is a PARTY reference (see EXTERNAL_PARTY_PREFIX), + * never a Nextcloud uid, group or role; it is never pooled, claimed or + * delegated, and only the matched portal subject may complete it. + * + * @spec openspec/changes/flow-portal-task/specs/flow-tasks/spec.md#requirement-the-external-performer-type-is-portal-scoped-and-never-pooled + */ + public const PERFORMER_EXTERNAL = 'external'; + + /** + * The prefix an external task's assignee carries. A Nextcloud uid cannot + * contain a colon, so a party reference can never collide with a uid and + * a uid can never be mistaken for the matched party. + * + * @var string + */ + public const EXTERNAL_PARTY_PREFIX = 'party:'; + /** * The performer types known to this release. * @@ -235,6 +254,7 @@ class Task extends Entity implements JsonSerializable { self::PERFORMER_GROUP, self::PERFORMER_AGENT, self::PERFORMER_WORKER, + self::PERFORMER_EXTERNAL, ]; /** diff --git a/lib/Db/TaskMapper.php b/lib/Db/TaskMapper.php index 8283a7da06..fc0c755925 100644 --- a/lib/Db/TaskMapper.php +++ b/lib/Db/TaskMapper.php @@ -45,6 +45,10 @@ /** * Reads and writes tasks. * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of the inbox + * predicates plus the portal seam's two party-scoped finders; each method is + * small, and moving the party predicates to a second mapper would split the + * one WHERE-clause vocabulary this class exists to keep together. * @SuppressWarnings(PHPMD.TooManyPublicMethods) A mapper's public methods * are its query vocabulary, one per distinct question the service and the * inbox ask of the table (same reasoning as FlowRunMapper); two of them @@ -462,8 +466,106 @@ private function applyInboxPredicates(IQueryBuilder $qb, TaskInboxCriteria $crit $this->applyScope(qb: $qb, criteria: $criteria); $this->applyVisibility(qb: $qb, criteria: $criteria); $this->applyFilters(qb: $qb, criteria: $criteria); + $this->applyExternalExclusion(qb: $qb, criteria: $criteria); }//end applyInboxPredicates() + /** + * An EXTERNAL task is in nobody's Nextcloud inbox and in nobody's total. + * + * The one read that admits it is the read anchored to its subject object + * (`objectUuid` set): that is the caseworker looking at the case, which is + * the visibility the spec grants inside the instance. Every other inbox + * shape (assigned, pooled, watched, all; administrator or not) excludes + * it in the datastore, so a badge count can never include a resident's + * ask. NULL performer types are legacy rows and stay visible. + * + * @param IQueryBuilder $qb The query under construction. + * @param TaskInboxCriteria $criteria Carries the object anchor, when any. + * + * @return void + * + * @spec openspec/changes/flow-portal-task/specs/flow-tasks/spec.md#requirement-the-external-performer-type-is-portal-scoped-and-never-pooled + */ + private function applyExternalExclusion(IQueryBuilder $qb, TaskInboxCriteria $criteria): void { + if ($criteria->objectUuid !== null) { + return; + } + + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('performer_type'), + $qb->expr()->neq('performer_type', $qb->createNamedParameter(Task::PERFORMER_EXTERNAL)) + ) + ); + }//end applyExternalExclusion() + + /** + * One portal subject's open external tasks, oldest ask first. + * + * Subject-scoped in the WHERE clause, never post-filtered: the party + * reference, the performer type and non-terminality are all predicates, + * so a page and its count (see {@see countOpenExternalForParty()}) agree + * by construction and no other subject's row is ever fetched. + * + * @param string $partyReference The stored party reference (`party:`). + * @param int $limit Page size. + * @param int $offset Page offset. + * + * @return array The page. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function findOpenExternalForParty(string $partyReference, int $limit = 25, int $offset = 0): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*')->from($this->getTableName()); + $this->applyPartyPredicates(qb: $qb, partyReference: $partyReference); + $qb->orderBy('created', 'ASC')->addOrderBy('id', 'ASC'); + $qb->setMaxResults($limit)->setFirstResult($offset); + + return $this->findEntities(query: $qb); + }//end findOpenExternalForParty() + + /** + * The total behind {@see findOpenExternalForParty()}, over the SAME predicates. + * + * @param string $partyReference The stored party reference. + * + * @return int The total. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function countOpenExternalForParty(string $partyReference): int { + $qb = $this->db->getQueryBuilder(); + $qb->selectAlias($qb->func()->count('id'), 'total')->from($this->getTableName()); + $this->applyPartyPredicates(qb: $qb, partyReference: $partyReference); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row === false) { + return 0; + } + + return (int)$row['total']; + }//end countOpenExternalForParty() + + /** + * The portal seam's predicate: this party's, external, still open. + * + * @param IQueryBuilder $qb The query under construction. + * @param string $partyReference The stored party reference. + * + * @return void + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + private function applyPartyPredicates(IQueryBuilder $qb, string $partyReference): void { + $qb->andWhere($qb->expr()->eq('assignee', $qb->createNamedParameter($partyReference))); + $qb->andWhere($qb->expr()->eq('performer_type', $qb->createNamedParameter(Task::PERFORMER_EXTERNAL))); + $qb->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))); + }//end applyPartyPredicates() + /** * The scope half of the predicate: which relationship the list is about. * diff --git a/lib/Exception/PortalPartyNotFoundException.php b/lib/Exception/PortalPartyNotFoundException.php new file mode 100644 index 0000000000..8e6ae2afac --- /dev/null +++ b/lib/Exception/PortalPartyNotFoundException.php @@ -0,0 +1,36 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * No party for the role on the case: the message names both. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ +class PortalPartyNotFoundException extends RuntimeException { +}//end class diff --git a/lib/Exception/PortalSubjectException.php b/lib/Exception/PortalSubjectException.php new file mode 100644 index 0000000000..f6d0333c61 --- /dev/null +++ b/lib/Exception/PortalSubjectException.php @@ -0,0 +1,73 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * No resolvable portal subject: every such case is a denial. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ +class PortalSubjectException extends RuntimeException { + + /** + * The refusal codes. + */ + public const CODE_MISSING = 'portal-subject-missing'; + + public const CODE_INVALID = 'portal-subject-invalid'; + + public const CODE_EXPIRED = 'portal-subject-expired'; + + public const CODE_UNCONFIGURED = 'portal-subject-unconfigured'; + + /** + * Constructor. + * + * @param string $refusal One of the CODE_* values. + * @param string $message What went wrong, for the log. + */ + public function __construct( + private readonly string $refusal, + string $message, + ) { + parent::__construct(message: $message); + + }//end __construct() + + /** + * The stable refusal code. + * + * @return string The code. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + public function refusal(): string { + return $this->refusal; + }//end refusal() +}//end class diff --git a/lib/Listener/FlowNodeRegistrationListener.php b/lib/Listener/FlowNodeRegistrationListener.php index 369952feb5..6baecfc076 100644 --- a/lib/Listener/FlowNodeRegistrationListener.php +++ b/lib/Listener/FlowNodeRegistrationListener.php @@ -39,6 +39,7 @@ use OCA\OpenRegister\Service\Flow\Nodes\MergeNode; use OCA\OpenRegister\Service\Flow\Nodes\ObjectReadNode; use OCA\OpenRegister\Service\Flow\Nodes\ObjectWriteNode; +use OCA\OpenRegister\Service\Flow\Nodes\PortalTaskNode; use OCA\OpenRegister\Service\Flow\Nodes\RouterNode; use OCA\OpenRegister\Service\Flow\Nodes\SendEmailNode; use OCA\OpenRegister\Service\Flow\Nodes\SendNotificationNode; @@ -87,6 +88,7 @@ class FlowNodeRegistrationListener implements IEventListener { * @param TriggerScheduleNode $triggerSchedule The "On a schedule" entry point. * @param TriggerManualNode $triggerManual The "When someone runs it" entry point. * @param UserTaskNode $userTask The built-in "Ask a person" node. + * @param PortalTaskNode $portalTask The built-in "Ask a party outside the organisation" node. */ public function __construct( private readonly SetFieldsNode $setFields, @@ -112,6 +114,7 @@ public function __construct( private readonly TriggerScheduleNode $triggerSchedule, private readonly TriggerManualNode $triggerManual, private readonly UserTaskNode $userTask, + private readonly PortalTaskNode $portalTask, ) { }//end __construct() @@ -163,6 +166,13 @@ public function handle(Event $event): void { // performer who has to be found, told, and allowed to say no. $event->registerNode(node: $this->userTask); + // The third waiter (flow-portal-task): a party OUTSIDE the instance, + // matched from the case and reached through the portal seam. The three + // palette descriptions are written as a set: signal for a system that + // calls back, user task for a performer in the organisation, portal + // task for a party outside it. + $event->registerNode(node: $this->portalTask); + // Entry points. Registered like any other node so the palette can offer // them and the preflight can check their config — a trigger is where a // run BEGINS, not work it performs, and each `execute()` is a diff --git a/lib/Listener/PortalTaskReminderListener.php b/lib/Listener/PortalTaskReminderListener.php new file mode 100644 index 0000000000..e23ba87579 --- /dev/null +++ b/lib/Listener/PortalTaskReminderListener.php @@ -0,0 +1,261 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-overdue-path-is-consumed-from-flow-business-timers-never-rebuilt + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Portal\PortalTaskDeliveryService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Turns a preBreach rung on an external task into a portal reminder. + * + * @template-implements IEventListener + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-overdue-path-is-consumed-from-flow-business-timers-never-rebuilt + */ +class PortalTaskReminderListener implements IEventListener { + + /** + * The event this listener is registered for, by name: flow-business-timers'. + * + * @var string + */ + public const EVENT_CLASS = 'OCA\OpenRegister\Event\FlowTimerFiredEvent'; + + /** + * The rung trigger that is the party's reminder. The rung key the timers + * generate starts with the trigger (`preBreach::`). + * + * @var string + */ + public const TRIGGER_PRE_BREACH = 'preBreach'; + + /** + * The rung trigger that escalates inward and never reaches the party. + * + * @var string + */ + public const TRIGGER_BREACHED = 'slaBreached'; + + /** + * Constructor. + * + * @param PortalTaskDeliveryService $delivery Records the reminder for the portal. + * @param TaskService $tasks Reads the timer's subject task. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly PortalTaskDeliveryService $delivery, + private readonly TaskService $tasks, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle a fired timer. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-overdue-path-is-consumed-from-flow-business-timers-never-rebuilt + */ + public function handle(Event $event): void { + if ($this->isTimerFire(event: $event) === false) { + return; + } + + try { + $this->remind(event: $event); + } catch (Throwable $failure) { + // The rung has fired and is recorded by the timers; a reminder that + // could not be requested costs the party a nudge, never the ask. + $this->logger->warning( + '[PortalTaskReminderListener] Could not request a portal reminder: ' . $failure->getMessage(), + ['exception' => $failure] + ); + } + }//end handle() + + /** + * Decide, per rung, whether the party is reminded through the seam. + * + * @param Event $event A timer fire, duck-typed. + * + * @return void + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-overdue-path-is-consumed-from-flow-business-timers-never-rebuilt + */ + private function remind(Event $event): void { + if ((string)$this->read(source: $event, method: 'getKind') !== 'rung') { + // Expiry enforcement transitions the task in the timers; the run + // learns of it through terminality. Nothing to deliver. + return; + } + + $rungKey = (string)$this->read(source: $event, method: 'getRungKey'); + if ($this->triggerOf(rungKey: $rungKey) !== self::TRIGGER_PRE_BREACH) { + // A slaBreached rung (and anything unknown) escalates inward. + // Deliberately no delivery to the party here. + return; + } + + $task = $this->subjectTask(event: $event); + if ($task === null || (string)$task->getPerformerType() !== Task::PERFORMER_EXTERNAL || $task->isInTerminalState() === true) { + return; + } + + $party = (string)$task->getAssignee(); + if ($this->addressesParty(recipients: (array)$this->read(source: $event, method: 'getRecipients'), party: $party) === false) { + return; + } + + $message = $this->delivery->messageFor(task: $task); + $message['rungKey'] = $rungKey; + $message['priority'] = $this->read(source: $event, method: 'getPriority'); + $message['messageKey'] = $this->read(source: $event, method: 'getMessage'); + $this->delivery->request(task: $task, kind: PortalTaskDelivery::KIND_REMINDER, message: $message); + }//end remind() + + /** + * The rung's trigger: the first segment of its key. + * + * @param string $rungKey The rung key. + * + * @return string `preBreach`, `slaBreached`, or whatever an author keyed it. + */ + private function triggerOf(string $rungKey): string { + $segments = explode(':', $rungKey, 2); + + return trim($segments[0]); + }//end triggerOf() + + /** + * Whether the rung's resolved recipients include the external party. + * + * The timers resolve the subject task's performer as a recipient with + * the performer TYPE as its type (`external`) and the assignee as its id. + * + * @param array $recipients The resolved recipients. + * @param string $party The task's stored party reference. + * + * @return bool True when at least one recipient is the party. + */ + private function addressesParty(array $recipients, string $party): bool { + foreach ($recipients as $recipient) { + if (is_array($recipient) === false) { + continue; + } + + $id = (string)($recipient['id'] ?? ''); + $type = (string)($recipient['type'] ?? ''); + if (($type === Task::PERFORMER_EXTERNAL || str_starts_with($id, Task::EXTERNAL_PARTY_PREFIX) === true) && hash_equals($party, $id) === true) { + return true; + } + } + + return false; + }//end addressesParty() + + /** + * The task the timer is anchored to, or null when it is not a task or is gone. + * + * @param Event $event The timer fire. + * + * @return Task|null The subject task. + */ + private function subjectTask(Event $event): ?Task { + $timer = $this->read(source: $event, method: 'getTimer'); + if (is_object($timer) === false || method_exists($timer, 'getSubjectType') === false || method_exists($timer, 'getSubjectUuid') === false) { + return null; + } + + if ((string)$this->read(source: $timer, method: 'getSubjectType') !== 'task') { + return null; + } + + $uuid = trim((string)$this->read(source: $timer, method: 'getSubjectUuid')); + if ($uuid === '') { + return null; + } + + try { + return $this->tasks->get(uuid: $uuid); + } catch (Throwable) { + return null; + } + }//end subjectTask() + + /** + * Read one published accessor of a duck-typed object. + * + * @param object $source The event or timer. + * @param string $method The accessor, already known to exist. + * + * @return mixed What it answers. + */ + private function read(object $source, string $method): mixed { + return $source->{$method}(); + }//end read() + + /** + * Whether an event is a timer fire, by the surface it publishes. + * + * @param Event $event The event. + * + * @return bool True when every method this listener reads exists. + */ + private function isTimerFire(Event $event): bool { + foreach (['getKind', 'getRungKey', 'getTimer', 'getRecipients', 'getMessage', 'getPriority'] as $method) { + if (method_exists($event, $method) === false) { + return false; + } + } + + return true; + }//end isTimerFire() +}//end class diff --git a/lib/Migration/Version1Date20260901000000.php b/lib/Migration/Version1Date20260901000000.php index 4ab9525711..eb4dd63e4a 100644 --- a/lib/Migration/Version1Date20260901000000.php +++ b/lib/Migration/Version1Date20260901000000.php @@ -116,10 +116,12 @@ private function ensurePgTrgmExtension(IOutput $output): void { $this->connection->executeStatement('CREATE EXTENSION IF NOT EXISTS pg_trgm'); if ($wasAlreadyInstalled === true) { $output->info('pg_trgm extension already installed (no-op re-run)'); - } else { - $output->info('pg_trgm extension installed by re-run (fuzzy/substring search indexes can now be created)'); + + return; } + $output->info('pg_trgm extension installed by re-run (fuzzy/substring search indexes can now be created)'); + return; } catch (Exception $e) { // Creation failed (usually privileges); check whether it already exists. diff --git a/lib/Migration/Version1Date20260901160000.php b/lib/Migration/Version1Date20260901160000.php new file mode 100644 index 0000000000..f6edba20fb --- /dev/null +++ b/lib/Migration/Version1Date20260901160000.php @@ -0,0 +1,90 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Creates `openregister_portal_deliveries`. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ +class Version1Date20260901160000 extends SimpleMigrationStep { + + /** + * The table. + * + * @var string + */ + private const TABLE = 'openregister_portal_deliveries'; + + /** + * Create the table when it does not exist yet. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the schema wrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema instanceof ISchemaWrapper === false || $schema->hasTable(self::TABLE) === true) { + return null; + } + + $table = $schema->createTable(self::TABLE); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('task_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('party_reference', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('channel', Types::STRING, ['notnull' => true, 'length' => 32]); + $table->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 32]); + $table->addColumn('state', Types::STRING, ['notnull' => true, 'length' => 32]); + $table->addColumn('message', Types::JSON, ['notnull' => false]); + $table->addColumn('error', Types::TEXT, ['notnull' => false]); + $table->addColumn('requested_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('delivered_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('created', Types::DATETIME, ['notnull' => false]); + + $table->setPrimaryKey(['id']); + // Names stay under 30 characters, the Oracle/PostgreSQL identifier cap. + $table->addUniqueIndex(['uuid'], 'or_portaldeliv_uuid_idx'); + $table->addIndex(['task_uuid'], 'or_portaldeliv_task_idx'); + $table->addIndex(['state', 'requested_at'], 'or_portaldeliv_state_idx'); + $table->addIndex(['party_reference'], 'or_portaldeliv_party_idx'); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Service/Flow/FlowTaskBridge.php b/lib/Service/Flow/FlowTaskBridge.php index 9f978a8566..1c1e751ffb 100644 --- a/lib/Service/Flow/FlowTaskBridge.php +++ b/lib/Service/Flow/FlowTaskBridge.php @@ -167,6 +167,25 @@ public function createTask(array $data, string $runUuid, string $nodeId, ?string return $task; }//end createTask() + /** + * Record a fact about a task the graph raised, in its audit, without + * moving it: the portal-task node records the party role it matched and + * the reference it froze. Delegates to the task service, which owns the + * audit's shape. + * + * @param string $uuid The task. + * @param string $action The audited action name. + * @param string|null $actor The run's acting identity. + * @param string $reason What is being recorded. + * + * @return void + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + public function record(string $uuid, string $action, ?string $actor, string $reason): void { + $this->tasks->record(uuid: $uuid, action: $action, actor: $actor, reason: $reason); + }//end record() + /** * The task a node's resume slot points at, or null when it is gone. * diff --git a/lib/Service/Flow/Nodes/PortalTaskConfig.php b/lib/Service/Flow/Nodes/PortalTaskConfig.php new file mode 100644 index 0000000000..a81aae4351 --- /dev/null +++ b/lib/Service/Flow/Nodes/PortalTaskConfig.php @@ -0,0 +1,540 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Flow\FlowAdvanceBudget; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Flow\FlowValueTemplate; +use OCA\OpenRegister\Service\Portal\PortalPartyResolver; +use OCP\IL10N; +use UnexpectedValueException; + +/** + * Reads, validates and templates a portal-task step's configuration. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate and FlowAdvanceBudget + * are stateless helpers over values; a factory to call them would add a + * dependency to say the same thing. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of many small + * readers, each a default-or-refuse over one key; the same shape and the same + * justification as UserTaskConfig, which sits just under the threshold only + * because it has no upload vocabulary. + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One reader per config key + * family the node consumes (role, upload, reason, outcome, heartbeat, slot); + * folding them into fewer readers would put the vocabulary back into the + * node, which is the split this class exists to keep. + */ +final class PortalTaskConfig { + + /** + * Minutes between heartbeats when the flow does not choose. Same value and + * same reasoning as the other two waiters. + * + * @var int + */ + private const DEFAULT_HEARTBEAT_MINUTES = 15; + + /** + * The floor a configured heartbeat is clamped to: the stock cron period. + * + * @var int + */ + private const MIN_HEARTBEAT_MINUTES = 5; + + /** + * The item key the outcome lands under when the flow does not choose. + * Distinct from `task` and `signal` so a flow holding all three waiters + * never has them writing over each other by default. + * + * @var string + */ + private const DEFAULT_OUTCOME_KEY = 'portalTask'; + + /** + * The item field the re-ask reason is read from when the flow does not choose. + * + * @var string + */ + private const DEFAULT_REASON_FIELD = 'reason'; + + /** + * Slot key: the cycle number of the task currently held. + * + * @var string + */ + public const SLOT_CYCLE = 'cycle'; + + /** + * Slot key: the uuid of the task the previous cycle held. + * + * @var string + */ + public const SLOT_PREVIOUS_TASK_UUID = 'previousTaskUuid'; + + /** + * Slot key: when the node last CONTINUED past its terminal task. Set once + * per cycle; its presence is what tells a re-entry from a first pass. + * + * @var string + */ + public const SLOT_PASSED_AT = 'passedAt'; + + /** + * Constructor. + * + * @param IL10N $l10n Translations, for refusal messages an author reads. + */ + public function __construct( + private readonly IL10N $l10n, + ) { + + }//end __construct() + + /** + * Refuse a step that asks nothing, names a blank party role, or carries + * an unreadable limit or budget. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When the config is refused. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + public function validate(array $config): void { + if (trim((string)($config['title'] ?? '')) === '') { + throw new UnexpectedValueException( + $this->l10n->t('Say what is being asked, or nobody can do it.') + ); + } + + if (array_key_exists('partyRole', $config) === true && trim((string)$config['partyRole']) === '') { + throw new UnexpectedValueException( + $this->l10n->t('Name the party role on the case (for example "initiator"), or leave it out to use the default.') + ); + } + + $this->refuseNonNumericLimits(config: $config); + + // Throws its own message, which names the value and states the spelling. + FlowAdvanceBudget::fromConfig(config: $config); + + }//end validate() + + /** + * Refuse a limit that is not a number, and a file count below one. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When a limit is unreadable. + */ + private function refuseNonNumericLimits(array $config): void { + foreach (['uploadMaxFiles', 'uploadMaxSizeMb', 'heartbeatMinutes'] as $numeric) { + $value = ($config[$numeric] ?? null); + if ($value !== null && $value !== '' && is_numeric($value) === false) { + throw new UnexpectedValueException( + $this->l10n->t('%1$s must be a number.', [$numeric]) + ); + } + } + + if (isset($config['uploadMaxFiles']) === true && is_numeric($config['uploadMaxFiles']) === true && (int)$config['uploadMaxFiles'] < 1) { + throw new UnexpectedValueException( + $this->l10n->t('uploadMaxFiles must be at least 1; turn "Upload required" off to allow a completion without a file.') + ); + } + }//end refuseNonNumericLimits() + + /** + * The party role the node matches on the case. + * + * @param array $config The step configuration. + * + * @return string The role; `initiator` by default. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + public function partyRole(array $config): string { + $role = trim((string)($config['partyRole'] ?? '')); + if ($role === '') { + return PortalPartyResolver::DEFAULT_ROLE; + } + + return $role; + }//end partyRole() + + /** + * The upload constraints, normalised for the task's metadata and the + * completion validator. + * + * @param array $config The step configuration. + * + * @return array{required: bool, maxFiles: int, acceptedTypes: array, maxSizeBytes: int|null} The constraints. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-an-upload-completion-lands-as-a-file-attachment-on-the-case-object + */ + public function uploadConstraints(array $config): array { + $maxFiles = 1; + if (is_numeric($config['uploadMaxFiles'] ?? null) === true) { + $maxFiles = max(1, (int)$config['uploadMaxFiles']); + } + + $maxBytes = null; + if (is_numeric($config['uploadMaxSizeMb'] ?? null) === true && (float)$config['uploadMaxSizeMb'] > 0) { + $maxBytes = (int)round((float)$config['uploadMaxSizeMb'] * 1024 * 1024); + } + + return [ + 'required' => $this->truthy(value: ($config['uploadRequired'] ?? false)), + 'maxFiles' => $maxFiles, + 'acceptedTypes' => $this->listOf(value: ($config['uploadAcceptedTypes'] ?? null)), + 'maxSizeBytes' => $maxBytes, + ]; + }//end uploadConstraints() + + /** + * The task fields for one ask, templated against the representative item. + * + * Everything here is passed THROUGH to the task builder. The party + * reference is the resolved, frozen match; the state is `active` because + * an external task is always assigned at creation (never pooled); the + * cycle, previous task and reason ride under `metadata` so "asked three + * times" is a read, not a reconstruction. + * + * @param array $config The step configuration. + * @param array $items The input items; the first is the representative. + * @param string $nodeId The node raising the task. + * @param string $nodeType The node's catalogue id. + * @param string $partyReference The frozen party reference (`party:`). + * @param int $cycle This ask's cycle number, 1 for the first. + * @param string|null $previousTaskUuid The previous cycle's task, on a re-ask. + * @param string|null $reason The re-ask reason, on a re-ask. + * + * @return array The creation payload. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function taskData( + array $config, + array $items, + string $nodeId, + string $nodeType, + string $partyReference, + int $cycle = 1, + ?string $previousTaskUuid = null, + ?string $reason = null, + ): array { + $json = $this->representativeJson(items: $items); + + $data = [ + 'title' => $this->renderedTitle(config: $config, items: $items), + 'description' => $this->renderedOrNull(value: ($config['description'] ?? null), json: $json), + 'state' => Task::STATE_ACTIVE, + 'performerType' => Task::PERFORMER_EXTERNAL, + 'priority' => 'normal', + 'assignee' => $partyReference, + 'dueAt' => $this->renderedOrNull(value: ($config['dueAt'] ?? null), json: $json), + 'expiresAt' => $this->renderedOrNull(value: ($config['expiresAt'] ?? null), json: $json), + 'metadata' => [ + 'flowNodeType' => $nodeType, + 'flowNode' => $nodeId, + 'outcomeKey' => $this->outcomeKey(config: $config), + 'partyRole' => $this->partyRole(config: $config), + 'partyReference' => $partyReference, + 'cycle' => $cycle, + 'previousTaskUuid' => $previousTaskUuid, + 'reaskReason' => $reason, + 'upload' => $this->uploadConstraints(config: $config), + ], + ]; + + return array_merge($data, $this->subjectAnchor(json: $json)); + }//end taskData() + + /** + * What the node remembers in its resume slot once a task exists. + * + * The assignee stored is the PARTY reference: `FlowRunAssignee` reads it + * at the resume door, and no Nextcloud identity can equal a party + * reference, so a resume POSTed at the run is refused for everyone, which + * is the contract (the resume endpoint cannot answer for a resident). + * + * @param array $config The step configuration. + * @param array $items The input items. + * @param string $taskUuid The created task. + * @param string $partyReference The frozen party reference. + * @param int $cycle The cycle number. + * @param string|null $previousTaskUuid The previous cycle's task, on a re-ask. + * + * @return array The slot values. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-re-ask-creates-a-new-task-carrying-a-mandatory-reason + */ + public function slotValues(array $config, array $items, string $taskUuid, string $partyReference, int $cycle, ?string $previousTaskUuid): array { + return [ + FlowTaskBridge::SLOT_TASK_UUID => $taskUuid, + FlowTaskBridge::SLOT_ASKED_AT => (new DateTime())->format('c'), + FlowTaskBridge::SLOT_ADVANCE => FlowAdvanceBudget::fromConfig(config: $config)->toStored(), + 'assignee' => $partyReference, + 'title' => $this->renderedTitle(config: $config, items: $items), + self::SLOT_CYCLE => $cycle, + self::SLOT_PREVIOUS_TASK_UUID => $previousTaskUuid, + self::SLOT_PASSED_AT => null, + ]; + }//end slotValues() + + /** + * The re-ask reason the items carry, read from the configured field. + * + * @param array $config The step configuration. + * @param array $items The input items. + * + * @return string The reason, trimmed; '' when the field is absent or empty. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-re-ask-creates-a-new-task-carrying-a-mandatory-reason + */ + public function reasonFrom(array $config, array $items): string { + $rendered = FlowValueTemplate::render( + value: '{{ ' . $this->reasonField(config: $config) . ' }}', + json: $this->representativeJson(items: $items) + ); + if (is_scalar($rendered) === false) { + return ''; + } + + return trim((string)$rendered); + }//end reasonFrom() + + /** + * The item field the re-ask reason is read from. + * + * @param array $config The step configuration. + * + * @return string The dotted field path; `reason` by default. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-re-ask-creates-a-new-task-carrying-a-mandatory-reason + */ + public function reasonField(array $config): string { + $field = trim((string)($config['reasonField'] ?? '')); + if ($field === '') { + return self::DEFAULT_REASON_FIELD; + } + + return $field; + }//end reasonField() + + /** + * The item key the outcome is written under. + * + * @param array $config The step configuration. + * + * @return string The key; `portalTask` by default. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-suspension-is-heartbeat-safe-and-continues-on-task-terminality + */ + public function outcomeKey(array $config): string { + $key = trim((string)($config['outcomeKey'] ?? '')); + if ($key === '') { + return self::DEFAULT_OUTCOME_KEY; + } + + return $key; + }//end outcomeKey() + + /** + * The title, templated against the representative item. + * + * @param array $config The step configuration. + * @param array $items The input items. + * + * @return string The rendered title. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function renderedTitle(array $config, array $items): string { + return trim((string)FlowValueTemplate::render( + value: (string)($config['title'] ?? ''), + json: $this->representativeJson(items: $items) + )); + }//end renderedTitle() + + /** + * When to wake up and re-check, absent a wake. Never null. + * + * @param array $config The step configuration. + * + * @return DateTime The next heartbeat. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-suspension-is-heartbeat-safe-and-continues-on-task-terminality + */ + public function heartbeatAt(array $config): DateTime { + $minutes = (int)($config['heartbeatMinutes'] ?? self::DEFAULT_HEARTBEAT_MINUTES); + if ($minutes < self::MIN_HEARTBEAT_MINUTES) { + $minutes = self::MIN_HEARTBEAT_MINUTES; + } + + return (new DateTime())->modify('+' . $minutes . ' minutes'); + }//end heartbeatAt() + + /** + * The uuid of the subject case object the representative item is about. + * + * @param array $items The input items. + * + * @return string The object uuid, or '' when the item names none. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + public function subjectObjectUuid(array $items): string { + return (string)($this->subjectAnchor(json: $this->representativeJson(items: $items))['objectUuid'] ?? ''); + }//end subjectObjectUuid() + + /** + * The object anchor an item carries, when it carries one. + * + * @param array $json The representative record. + * + * @return array objectUuid, registerId and schemaId as far as known. + */ + private function subjectAnchor(array $json): array { + $self = (array)($json['@self'] ?? []); + $objectUuid = trim((string)($self['uuid'] ?? ($json['uuid'] ?? ($self['id'] ?? '')))); + if ($objectUuid === '') { + return []; + } + + $anchor = ['objectUuid' => $objectUuid]; + if (is_numeric($self['register'] ?? null) === true) { + $anchor['registerId'] = (int)$self['register']; + } + + if (is_numeric($self['schema'] ?? null) === true) { + $anchor['schemaId'] = (int)$self['schema']; + } + + return $anchor; + }//end subjectAnchor() + + /** + * A list from a list or a comma-separated string; empty for anything else. + * + * @param mixed $value The configured value. + * + * @return array Trimmed, non-empty entries. + */ + private function listOf(mixed $value): array { + if (is_string($value) === true) { + $value = explode(',', $value); + } + + if (is_array($value) === false) { + return []; + } + + $list = []; + foreach ($value as $entry) { + if (is_scalar($entry) === false) { + continue; + } + + $entry = trim((string)$entry); + if ($entry !== '') { + $list[] = $entry; + } + } + + return $list; + }//end listOf() + + /** + * A boolean read the way a form or a JSON body may spell it. + * + * @param mixed $value The configured value. + * + * @return bool The boolean. + */ + private function truthy(mixed $value): bool { + if (is_bool($value) === true) { + return $value; + } + + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + }//end truthy() + + /** + * The record of the representative item: the first array item's json. + * + * @param array $items The input items. + * + * @return array The record, empty when there is none. + */ + private function representativeJson(array $items): array { + foreach ($items as $item) { + if (is_array($item) === true) { + return (array)($item[FlowItems::JSON] ?? []); + } + } + + return []; + }//end representativeJson() + + /** + * A templated string, or null when it renders to nothing. + * + * @param mixed $value The configured value. + * @param array $json The representative record. + * + * @return string|null The rendered string, or null. + */ + private function renderedOrNull(mixed $value, array $json): ?string { + if ($value === null) { + return null; + } + + $rendered = FlowValueTemplate::render(value: $value, json: $json); + if (is_scalar($rendered) === false) { + return null; + } + + $rendered = trim((string)$rendered); + if ($rendered === '') { + return null; + } + + return $rendered; + }//end renderedOrNull() +}//end class diff --git a/lib/Service/Flow/Nodes/PortalTaskNode.php b/lib/Service/Flow/Nodes/PortalTaskNode.php new file mode 100644 index 0000000000..5633041eb2 --- /dev/null +++ b/lib/Service/Flow/Nodes/PortalTaskNode.php @@ -0,0 +1,649 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use DateTime; +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowSuspension; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; +use OCA\OpenRegister\Service\Portal\PortalPartyResolver; +use OCA\OpenRegister\Service\Portal\PortalTaskDeliveryService; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\WorkflowEngine\IManager; +use RuntimeException; + +/** + * Matches the party, creates one external task, delivers it, suspends until + * it is terminal, places the answer, and re-asks with a reason on re-entry. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowTaskBridge::outcomeBagFor is a + * stateless helper over a value; a factory to call it would add a dependency + * to say the same thing. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The node joins the graph + * side (items, resume state, suspension, bridge, the three node interfaces) + * to the portal side (party resolver, delivery seam); every import is one of + * the two halves it exists to connect. + */ +class PortalTaskNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm { + + /** + * The configuration boundary: validation and templating. + * + * @var PortalTaskConfig + */ + private readonly PortalTaskConfig $config; + + /** + * Constructor. + * + * @param FlowTaskBridge $bridge Creates and reads the node's task. + * @param PortalPartyResolver $parties Resolves the party role against the case. + * @param PortalTaskDeliveryService $delivery Records the ask for the portal seam. + * @param IL10N $l10n Translations. + * @param IURLGenerator $urls For the palette icon. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function __construct( + private readonly FlowTaskBridge $bridge, + private readonly PortalPartyResolver $parties, + private readonly PortalTaskDeliveryService $delivery, + private readonly IL10N $l10n, + private readonly IURLGenerator $urls, + ) { + $this->config = new PortalTaskConfig(l10n: $l10n); + + }//end __construct() + + /** + * The step type. + * + * @return string The id. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function getId(): string { + return 'openregister.portal-task'; + }//end getId() + + /** + * Palette name. + * + * @return string The display name. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function getDisplayName(): string { + return $this->l10n->t('Ask a party outside the organisation'); + }//end getDisplayName() + + /** + * Palette description, written as the third of the waiter set. + * + * @return string The description. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function getDescription(): string { + return $this->l10n->t( + 'Ask the applicant or another party on the case, through the portal, and wait for their answer or upload. ' + . 'For someone in the organisation use "Ask a person"; for a system that will call back use "Wait for an answer".' + ); + }//end getDescription() + + /** + * Palette icon. + * + * @return string The icon URL. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function getIcon(): string { + return $this->urls->imagePath('core', 'actions/share.svg'); + }//end getIcon() + + /** + * Asking somebody grants no privilege; the seam authorizes the answer. + * + * @param int $scope The scope constant. + * + * @return boolean Whether it is available. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function isAvailableForScope(int $scope): bool { + return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); + }//end isAvailableForScope() + + /** + * The config vocabulary of a portal-task step. + * + * @return array The accepted config keys. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function configKeys(): array { + return [ + 'title', + 'description', + 'partyRole', + 'uploadRequired', + 'uploadMaxFiles', + 'uploadAcceptedTypes', + 'uploadMaxSizeMb', + 'outcomeKey', + 'reasonField', + 'dueAt', + 'expiresAt', + 'heartbeatMinutes', + 'advance', + ]; + }//end configKeys() + + /** + * The fields this node is edited through: what is asked, who on the case + * is asked, what they may hand in, when, and how the flow continues. + * + * @return array> The field descriptions. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function configForm(): array { + return array_merge( + $this->whatFields(), + $this->whoFields(), + $this->uploadFields(), + $this->whenFields(), + $this->continuationFields() + ); + }//end configForm() + + /** + * Validate through the configuration boundary. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws \UnexpectedValueException When the config is refused. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + public function validateConfig(array $config): void { + $this->config->validate(config: $config); + + }//end validateConfig() + + /** + * Match, create and deliver on the first pass; suspend until the task + * ends; place the answer once; re-ask with a reason on re-entry. + * + * @param array $items The input items. + * @param array $config The step configuration. + * @param array $context Run-level metadata. + * + * @return array The items, each carrying the outcome bag. + * + * @throws FlowSuspension While the task is not terminal. + * @throws RuntimeException When the node has no slot, its task is gone, + * the case names nobody, or a re-ask has no reason. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + */ + public function execute(array $items, array $config, array $context): array { + if ($items === []) { + // An empty branch reaching this node is the normal case in a + // priority-ordered graph. Nothing to ask about, nobody to ask, and + // suspension is a RUN-level act this branch has no right to. + return $items; + } + + $resume = ($context[FlowNodeResumeState::CONTEXT_KEY] ?? null); + if ($resume instanceof FlowNodeResumeState === false) { + throw new RuntimeException('openregister.portal-task needs a node resume slot; without one every heartbeat would create a task.'); + } + + $taskUuid = trim((string)$resume->get(key: FlowTaskBridge::SLOT_TASK_UUID, default: '')); + if ($taskUuid === '') { + $this->ask(items: $items, config: $config, context: $context, resume: $resume, cycle: 1, previousTaskUuid: null, reason: null); + + throw $this->suspension(config: $config, items: $items); + } + + $task = $this->bridge->taskOrNull(uuid: $taskUuid); + if ($task === null) { + throw new RuntimeException(sprintf('Task %s, which this step was waiting on, no longer exists.', $taskUuid)); + } + + if ($task->isInTerminalState() === false) { + // A heartbeat wake, a reminder, a nudge: none is an answer. Suspend + // again and do NOT touch the slot: askedAt stays what it was. + throw $this->suspension(config: $config, items: $items); + } + + if ($resume->get(key: PortalTaskConfig::SLOT_PASSED_AT, default: null) === null) { + // The first pass over a terminal task: the answer travels on. Marked + // ONCE, so the next firing of this node in this run is a re-entry. + $resume->set(key: PortalTaskConfig::SLOT_PASSED_AT, value: (new DateTime())->format('c')); + + return $this->placeOutcome(items: $items, config: $config, task: $task); + } + + // Re-entry: the graph routed back into this node after it continued. + $reason = $this->config->reasonFrom(config: $config, items: $items); + if ($reason === '') { + throw new RuntimeException( + sprintf( + 'openregister.portal-task re-entered after task %s ended, but the items carry no reason under "%s"; ' + . 'a party is not asked the same thing twice without an explanation.', + $taskUuid, + $this->config->reasonField(config: $config) + ) + ); + } + + $cycle = ((int)$resume->get(key: PortalTaskConfig::SLOT_CYCLE, default: 1) + 1); + $this->ask(items: $items, config: $config, context: $context, resume: $resume, cycle: $cycle, previousTaskUuid: $taskUuid, reason: $reason); + + throw $this->suspension(config: $config, items: $items); + }//end execute() + + /** + * One ask: match the party on the case, create the external task, record + * the match in its audit, request delivery, and remember it in the slot. + * + * @param array $items The input items; the first is the representative. + * @param array $config The step configuration. + * @param array $context Run-level metadata. + * @param FlowNodeResumeState $resume This node's slot. + * @param int $cycle This ask's cycle number. + * @param string|null $previousTaskUuid The previous cycle's task, on a re-ask. + * @param string|null $reason The re-ask reason, on a re-ask. + * + * @return void + * + * @throws RuntimeException When the run has no uuid, the item names no + * case, or the case names nobody for the role. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + private function ask( + array $items, + array $config, + array $context, + FlowNodeResumeState $resume, + int $cycle, + ?string $previousTaskUuid, + ?string $reason, + ): void { + $runUuid = trim((string)($context[FlowRunContext::CONTEXT_RUN] ?? ($context['runUuid'] ?? ''))); + if ($runUuid === '') { + throw new RuntimeException('openregister.portal-task cannot create a task outside a persisted run: the task must carry the run uuid.'); + } + + $caseUuid = $this->config->subjectObjectUuid(items: $items); + if ($caseUuid === '') { + throw new RuntimeException( + 'openregister.portal-task needs the item to be about a case object (@self.uuid); there is no case to match a party on.' + ); + } + + // Matched ONCE, here. PortalPartyNotFoundException is a RuntimeException + // naming the role and the case, and it fails the firing: an ask nobody + // can perform must not be parked in a suspended run. + $role = $this->config->partyRole(config: $config); + $party = $this->parties->resolveFromObject(objectUuid: $caseUuid, role: $role); + + $actor = $this->actingIdentity(context: $context); + $task = $this->bridge->createTask( + data: $this->config->taskData( + config: $config, + items: $items, + nodeId: $resume->nodeId(), + nodeType: $this->getId(), + partyReference: $party, + cycle: $cycle, + previousTaskUuid: $previousTaskUuid, + reason: $reason + ), + runUuid: $runUuid, + nodeId: $resume->nodeId(), + actor: $actor + ); + + $this->bridge->record( + uuid: (string)$task->getUuid(), + action: 'match', + actor: $actor, + reason: sprintf("Matched party role '%s' on case '%s' to '%s' (cycle %d).", $role, $caseUuid, $party, $cycle) + ); + + $kind = PortalTaskDelivery::KIND_ASK; + if ($cycle > 1) { + $kind = PortalTaskDelivery::KIND_RE_ASK; + } + + // Never throws: a delivery that cannot be recorded leaves the task and + // the suspension standing, and its state reads not-recorded. + $this->delivery->request(task: $task, kind: $kind, message: $this->delivery->messageFor(task: $task, reason: $reason)); + + $resume->merge( + values: $this->config->slotValues( + config: $config, + items: $items, + taskUuid: (string)$task->getUuid(), + partyReference: $party, + cycle: $cycle, + previousTaskUuid: $previousTaskUuid + ) + ); + + }//end ask() + + /** + * Write the outcome bag onto every item, under the configured key. + * + * The bridge's bag, extended with what a portal answer carries: the + * submitted fields, the stored file references, the matched party, the + * cycle, and an explicit `expired` flag so an expiry-terminated ask is + * never read as an answer downstream. + * + * @param array $items The items to pass on. + * @param array $config The step configuration. + * @param Task $task The terminal task. + * + * @return array The items, each carrying the bag. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-suspension-is-heartbeat-safe-and-continues-on-task-terminality + */ + private function placeOutcome(array $items, array $config, Task $task): array { + $bag = FlowTaskBridge::outcomeBagFor(task: $task); + $metadata = ($task->getMetadata() ?? []); + $bag['answers'] = ($task->getResponses() ?? []); + $bag['files'] = ($task->getEvidence() ?? []); + $bag['party'] = $task->getAssignee(); + $bag['cycle'] = (int)($metadata['cycle'] ?? 1); + $bag['reason'] = ($metadata['reaskReason'] ?? null); + $bag['expired'] = ($bag['decided'] === false && str_starts_with((string)$bag['outcome'], 'expir') === true); + + $key = $this->config->outcomeKey(config: $config); + foreach ($items as $index => $item) { + if (is_array($item) === false) { + continue; + } + + $json = (array)($item[FlowItems::JSON] ?? []); + $json[$key] = $bag; + $item[FlowItems::JSON] = $json; + $items[$index] = $item; + } + + return $items; + }//end placeOutcome() + + /** + * The suspension this node parks on: a non-null heartbeat, and a reason + * that names what is being waited for. + * + * @param array $config The step configuration. + * @param array $items The input items. + * + * @return FlowSuspension The suspension to throw. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-suspension-is-heartbeat-safe-and-continues-on-task-terminality + */ + private function suspension(array $config, array $items): FlowSuspension { + $title = $this->config->renderedTitle(config: $config, items: $items); + if ($title === '') { + $title = 'a portal task'; + } + + return new FlowSuspension( + resumeAt: $this->config->heartbeatAt(config: $config), + reason: sprintf('waiting for a party outside the organisation: %s', $title) + ); + }//end suspension() + + /** + * The run's acting identity: who the task is requested by. + * + * @param array $context Run-level metadata. + * + * @return string|null The uid, or null when the run has none. + */ + private function actingIdentity(array $context): ?string { + foreach (['runAs', 'triggeredBy'] as $key) { + $uid = trim((string)($context[$key] ?? '')); + if ($uid !== '') { + return $uid; + } + } + + return null; + }//end actingIdentity() + + /** + * What the task is. + * + * @return array> The field descriptions. + */ + private function whatFields(): array { + return [ + [ + 'key' => 'title', + 'label' => $this->l10n->t('What is being asked'), + 'type' => 'text', + 'help' => $this->l10n->t('The task title, shown in the portal. Fields of the item can be used, like {{ name }}.'), + 'required' => true, + ], + [ + 'key' => 'description', + 'label' => $this->l10n->t('Details'), + 'type' => 'textarea', + 'help' => $this->l10n->t('What the party needs to know to do it. Templates work here too.'), + ], + ]; + }//end whatFields() + + /** + * Who on the case is asked. + * + * @return array> The field descriptions. + */ + private function whoFields(): array { + return [ + [ + 'key' => 'partyRole', + 'label' => $this->l10n->t('Party role on the case'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'Which party on the case is asked, by role. Defaults to "initiator". The party is matched once, when the task is created, ' + . 'and a case that names nobody for the role fails the step.' + ), + ], + ]; + }//end whoFields() + + /** + * What they may hand in. + * + * @return array> The field descriptions. + */ + private function uploadFields(): array { + return [ + [ + 'key' => 'uploadRequired', + 'label' => $this->l10n->t('Upload required'), + 'type' => 'boolean', + 'help' => $this->l10n->t('When on, the party must hand in at least one file. Every file lands on the case object.'), + ], + [ + 'key' => 'uploadMaxFiles', + 'label' => $this->l10n->t('Maximum number of files'), + 'type' => 'number', + 'help' => $this->l10n->t('How many files one answer may carry. Defaults to 1.'), + ], + [ + 'key' => 'uploadAcceptedTypes', + 'label' => $this->l10n->t('Accepted file types'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'Media types or extensions, comma separated, like "application/pdf, image/*, docx". Leave empty to accept any type.' + ), + ], + [ + 'key' => 'uploadMaxSizeMb', + 'label' => $this->l10n->t('Maximum file size (MB)'), + 'type' => 'number', + 'help' => $this->l10n->t('Per file. Leave empty for the instance default.'), + ], + ]; + }//end uploadFields() + + /** + * When. + * + * @return array> The field descriptions. + */ + private function whenFields(): array { + return [ + [ + 'key' => 'dueAt', + 'label' => $this->l10n->t('Due'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'When the party should have answered: a date, a field like {{ deadline }}, or a relative time like "+14 days". ' + . 'Reminders are business timer rungs on this date.' + ), + ], + [ + 'key' => 'expiresAt', + 'label' => $this->l10n->t('Expires'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'When the ask stops being answerable. Same shapes as "Due". Expiry is enforced by the business timers, not by this step.' + ), + ], + [ + 'key' => 'heartbeatMinutes', + 'label' => $this->l10n->t('Re-check every (minutes)'), + 'type' => 'number', + 'help' => $this->l10n->t( + 'Safety net for a wake that never arrives. Lower is not faster: a completed task wakes the run immediately either way.' + ), + ], + ]; + }//end whenFields() + + /** + * How the flow continues once answered, and how it asks again. + * + * @return array> The field descriptions. + */ + private function continuationFields(): array { + return [ + [ + 'key' => 'outcomeKey', + 'label' => $this->l10n->t('Field to store the answer in'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'The answer, the uploaded files and the matched party are written onto every item under this field. Defaults to "portalTask".' + ), + ], + [ + 'key' => 'reasonField', + 'label' => $this->l10n->t('Field carrying the reason to ask again'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'When the flow routes back into this step, the party is asked again with the reason read from this item field, like "review.comment". ' + . 'Without a reason the step fails rather than asking twice unexplained. Defaults to "reason".' + ), + ], + [ + 'key' => 'advance', + 'label' => $this->l10n->t('Continue after the answer'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'How far the run continues inside the request that completes the task: 0 leaves it to the background worker (default), ' + . 'a number runs that many steps, "all" runs to the next pause or the end.' + ), + ], + ]; + }//end continuationFields() +}//end class diff --git a/lib/Service/Portal/PortalPartyResolver.php b/lib/Service/Portal/PortalPartyResolver.php new file mode 100644 index 0000000000..02be4e7e4c --- /dev/null +++ b/lib/Service/Portal/PortalPartyResolver.php @@ -0,0 +1,267 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Portal + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Portal; + +use OCA\OpenRegister\Db\AbstractObjectMapper; +use OCA\OpenRegister\Exception\PortalPartyNotFoundException; +use Throwable; + +/** + * Party matching against the subject case object. + * + * @SuppressWarnings(PHPMD.StaticAccess) PortalSubject::partyReferenceFor is a + * stateless helper over a value; a factory to call it would add a dependency + * to say the same thing. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ +class PortalPartyResolver { + + /** + * The default role. + * + * @var string + */ + public const DEFAULT_ROLE = 'initiator'; + + /** + * Where a party value keeps its reference, in order of preference. + * + * @var array + */ + private const REFERENCE_KEYS = ['subjectRef', 'portalSubject', 'identificatie', 'bsn', 'kvk', 'rsin', 'uuid', 'id', 'ref']; + + /** + * Nested containers a party value may wrap its identification in. + * + * @var array + */ + private const NESTED_KEYS = ['betrokkeneIdentificatie', 'identification', 'party', 'betrokkene', 'subject']; + + /** + * Where a case keeps its party list. + * + * @var array + */ + private const LIST_KEYS = ['rollen', 'roles', 'parties', 'betrokkenen']; + + /** + * Which entry keys mark an entry's role. + * + * @var array + */ + private const ROLE_MARKERS = ['role', 'rol', 'roltype', 'rolType', 'type', 'omschrijvingGeneriek']; + + /** + * Constructor. + * + * @param AbstractObjectMapper|null $objects Reads the case object. Nullable + * so the resolver stays + * constructible bare; ABSENT, a + * resolution by uuid throws, so + * the firing fails rather than + * matching nobody quietly. + */ + public function __construct( + private readonly ?AbstractObjectMapper $objects = null, + ) { + + }//end __construct() + + /** + * Resolve a role on the case object with this uuid to a party reference. + * + * @param string $objectUuid The subject case object. + * @param string $role The party role. + * + * @return string The party reference, `party:`. + * + * @throws PortalPartyNotFoundException When the case cannot be read or names nobody. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + public function resolveFromObject(string $objectUuid, string $role): string { + if ($this->objects === null) { + throw new PortalPartyNotFoundException( + message: sprintf("Cannot resolve party role '%s' on case '%s': no object store is available.", $role, $objectUuid) + ); + } + + try { + $object = $this->objects->find(identifier: $objectUuid, _rbac: false, _multitenancy: false); + } catch (Throwable $failure) { + throw new PortalPartyNotFoundException( + message: sprintf("Cannot resolve party role '%s': case '%s' could not be read (%s).", $role, $objectUuid, $failure->getMessage()), + code: 0, + previous: $failure + ); + } + + return $this->resolve(case: $object->getObject(), role: $role, caseUuid: $objectUuid); + }//end resolveFromObject() + + /** + * Resolve a role against a case's data. + * + * @param array $case The case object's data. + * @param string $role The party role. + * @param string $caseUuid The case, for the refusal message. + * + * @return string The party reference, `party:`. + * + * @throws PortalPartyNotFoundException When the case names nobody for the role. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + public function resolve(array $case, string $role, string $caseUuid = ''): string { + $role = trim($role); + if ($role === '') { + $role = self::DEFAULT_ROLE; + } + + $reference = $this->referenceOf(value: ($case[$role] ?? null)); + if ($reference === '') { + $reference = $this->fromPartyLists(case: $case, role: $role); + } + + if ($reference === '') { + throw new PortalPartyNotFoundException( + message: sprintf("Case '%s' names no party for role '%s'; the portal task cannot be addressed.", $caseUuid, $role) + ); + } + + return PortalSubject::partyReferenceFor(reference: $reference); + }//end resolve() + + /** + * Look the role up in the case's party lists. + * + * @param array $case The case data. + * @param string $role The role. + * + * @return string The raw reference, or ''. + */ + private function fromPartyLists(array $case, string $role): string { + foreach (self::LIST_KEYS as $listKey) { + $list = ($case[$listKey] ?? null); + if (is_array($list) === false) { + continue; + } + + foreach ($list as $entry) { + if (is_array($entry) === false || $this->entryHasRole(entry: $entry, role: $role) === false) { + continue; + } + + $reference = $this->referenceOf(value: $entry); + if ($reference !== '') { + return $reference; + } + } + } + + return ''; + }//end fromPartyLists() + + /** + * Whether a list entry is marked with the role, case-insensitively. + * + * @param array $entry The entry. + * @param string $role The role. + * + * @return bool True when one of the role markers equals the role. + */ + private function entryHasRole(array $entry, string $role): bool { + foreach (self::ROLE_MARKERS as $marker) { + $value = ($entry[$marker] ?? null); + if (is_scalar($value) === true && strcasecmp(trim((string)$value), $role) === 0) { + return true; + } + } + + return false; + }//end entryHasRole() + + /** + * The reference inside a party value: a scalar is the reference itself; an + * array yields the first published key, looking one level into the known + * nesting containers. + * + * @param mixed $value The party value. + * + * @return string The raw reference, or ''. + */ + private function referenceOf(mixed $value): string { + if (is_scalar($value) === true) { + return trim((string)$value); + } + + if (is_array($value) === false) { + return ''; + } + + foreach (self::REFERENCE_KEYS as $key) { + $candidate = ($value[$key] ?? null); + if (is_scalar($candidate) === true && trim((string)$candidate) !== '') { + return trim((string)$candidate); + } + } + + return $this->nestedReferenceOf(value: $value); + }//end referenceOf() + + /** + * The reference one level down, inside the known nesting containers. + * + * @param array $value The party value. + * + * @return string The raw reference, or ''. + */ + private function nestedReferenceOf(array $value): string { + foreach (self::NESTED_KEYS as $key) { + $nested = ($value[$key] ?? null); + if (is_array($nested) === true || is_scalar($nested) === true) { + $reference = $this->referenceOf(value: $nested); + if ($reference !== '') { + return $reference; + } + } + } + + return ''; + }//end nestedReferenceOf() +}//end class diff --git a/lib/Service/Portal/PortalSubject.php b/lib/Service/Portal/PortalSubject.php new file mode 100644 index 0000000000..1bdc3ccf89 --- /dev/null +++ b/lib/Service/Portal/PortalSubject.php @@ -0,0 +1,94 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Portal + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Portal; + +use OCA\OpenRegister\Db\Task; + +/** + * One resolved portal subject. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ +final class PortalSubject { + + /** + * Constructor. + * + * @param string $subjectRef The server-derived subject reference. + * @param string $audience The external audience (`client`, `supplier`, ...). + * @param string $organisation The tenant the subject is scoped to. + * @param string $trust The trust level (`low|substantial|high`). + * @param string $jti The originating session id, for audit correlation. + */ + public function __construct( + public readonly string $subjectRef, + public readonly string $audience = '', + public readonly string $organisation = '', + public readonly string $trust = '', + public readonly string $jti = '', + ) { + + }//end __construct() + + /** + * The party reference this subject acts as: what is compared, whole, to + * a task's stored party reference. + * + * @return string `party:`. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + public function partyReference(): string { + return Task::EXTERNAL_PARTY_PREFIX . $this->subjectRef; + }//end partyReference() + + /** + * The identity recorded in the task audit for this subject's acts. + * + * @return string The party reference; the audit's `performer_type` says `external`. + * + * @spec openspec/changes/flow-portal-task/specs/flow-tasks/spec.md#requirement-the-external-performer-type-is-portal-scoped-and-never-pooled + */ + public function actor(): string { + return $this->partyReference(); + }//end actor() + + /** + * Build a party reference for a resolved case party, the way the node + * freezes it on the task. + * + * @param string $reference The raw reference read from the case. + * + * @return string `party:`. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + public static function partyReferenceFor(string $reference): string { + return Task::EXTERNAL_PARTY_PREFIX . trim($reference); + }//end partyReferenceFor() +}//end class diff --git a/lib/Service/Portal/PortalSubjectAssertion.php b/lib/Service/Portal/PortalSubjectAssertion.php new file mode 100644 index 0000000000..de9c91a64f --- /dev/null +++ b/lib/Service/Portal/PortalSubjectAssertion.php @@ -0,0 +1,252 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Portal + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Portal; + +use OCA\OpenRegister\Exception\PortalSubjectException; +use OCP\IConfig; +use OCP\IRequest; + +/** + * Resolves the acting portal subject from a request, fail-closed. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ +class PortalSubjectAssertion { + + /** + * The header the assertion travels in. + * + * @var string + */ + public const HEADER = 'X-Portal-Subject'; + + /** + * OpenRegister's own secret key, consulted first. + * + * @var string + */ + public const CONFIG_SECRET = 'portal_assertion_secret'; + + /** + * The issuer portaliq stamps. + * + * @var string + */ + private const ISSUER = 'portaliq'; + + /** + * The `use` claim that marks an assertion, as opposed to a session. + * + * @var string + */ + private const USE_ASSERTION = 'assertion'; + + /** + * Constructor. + * + * @param IConfig $config Where the shared secret is read from. + */ + public function __construct( + private readonly IConfig $config, + ) { + + }//end __construct() + + /** + * The subject a request acts as. + * + * @param IRequest $request The incoming request. + * + * @return PortalSubject The verified subject. + * + * @throws PortalSubjectException When the header is missing, the verifier is + * unconfigured, or the token is invalid or expired. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + public function resolve(IRequest $request): PortalSubject { + $token = trim($request->getHeader(self::HEADER)); + if ($token === '') { + throw new PortalSubjectException( + refusal: PortalSubjectException::CODE_MISSING, + message: 'No X-Portal-Subject assertion on the request.' + ); + } + + return $this->fromToken(token: $token); + }//end resolve() + + /** + * Verify a compact assertion and read its subject. + * + * @param string $token The compact JWT. + * + * @return PortalSubject The verified subject. + * + * @throws PortalSubjectException On any defect; the code says which class. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + public function fromToken(string $token): PortalSubject { + $secret = $this->secret(); + if ($secret === '') { + throw new PortalSubjectException( + refusal: PortalSubjectException::CODE_UNCONFIGURED, + message: 'No portal assertion secret is configured; every assertion is refused.' + ); + } + + return $this->subjectOf(claims: $this->verifiedClaims(token: $token, secret: $secret)); + }//end fromToken() + + /** + * The claims of a structurally valid, correctly signed assertion. + * + * @param string $token The compact JWT. + * @param string $secret The HMAC secret. + * + * @return array The claims. + * + * @throws PortalSubjectException On any structural or signature defect. + */ + private function verifiedClaims(string $token, string $secret): array { + $parts = explode('.', $token); + if (count($parts) !== 3) { + throw new PortalSubjectException(refusal: PortalSubjectException::CODE_INVALID, message: 'Malformed assertion.'); + } + + [$headerPart, $claimsPart, $signaturePart] = $parts; + $header = json_decode($this->b64UrlDecode(encoded: $headerPart), true); + if (is_array($header) === false || ($header['alg'] ?? '') !== 'HS256') { + throw new PortalSubjectException(refusal: PortalSubjectException::CODE_INVALID, message: 'Unsupported assertion algorithm.'); + } + + $expected = $this->b64UrlEncode(bytes: hash_hmac('sha256', $headerPart . '.' . $claimsPart, $secret, true)); + if (hash_equals($expected, $signaturePart) === false) { + throw new PortalSubjectException(refusal: PortalSubjectException::CODE_INVALID, message: 'Assertion signature does not verify.'); + } + + $claims = json_decode($this->b64UrlDecode(encoded: $claimsPart), true); + if (is_array($claims) === false) { + throw new PortalSubjectException(refusal: PortalSubjectException::CODE_INVALID, message: 'Malformed assertion claims.'); + } + + return $claims; + }//end verifiedClaims() + + /** + * The subject a verified claim set asserts, refused on any wrong claim. + * + * @param array $claims The verified claims. + * + * @return PortalSubject The subject. + * + * @throws PortalSubjectException When the issuer, use, expiry or subject is wrong. + */ + private function subjectOf(array $claims): PortalSubject { + if (($claims['iss'] ?? '') !== self::ISSUER) { + throw new PortalSubjectException(refusal: PortalSubjectException::CODE_INVALID, message: 'Unexpected assertion issuer.'); + } + + if (($claims['use'] ?? '') !== self::USE_ASSERTION) { + // A session token is not an assertion; presenting one here is the + // token-confusion case the `use` claim exists to refuse. + throw new PortalSubjectException(refusal: PortalSubjectException::CODE_INVALID, message: 'Token is not a subject assertion.'); + } + + if (isset($claims['exp']) === false || (int)$claims['exp'] < time()) { + throw new PortalSubjectException(refusal: PortalSubjectException::CODE_EXPIRED, message: 'Assertion expired or carries no expiry.'); + } + + $subjectRef = trim((string)($claims['sub'] ?? '')); + if ($subjectRef === '') { + throw new PortalSubjectException(refusal: PortalSubjectException::CODE_INVALID, message: 'Assertion names no subject.'); + } + + return new PortalSubject( + subjectRef: $subjectRef, + audience: trim((string)($claims['audience'] ?? '')), + organisation: trim((string)($claims['organisation'] ?? '')), + trust: trim((string)($claims['trust'] ?? '')), + jti: trim((string)($claims['jti'] ?? '')) + ); + }//end subjectOf() + + /** + * The shared secret: OpenRegister's own key, else portaliq's. + * + * @return string The secret, or '' when neither is set. + */ + private function secret(): string { + $own = trim((string)$this->config->getAppValue('openregister', self::CONFIG_SECRET, '')); + if ($own !== '') { + return $own; + } + + return trim((string)$this->config->getAppValue('portaliq', 'jwt_signing_secret', '')); + }//end secret() + + /** + * URL-safe base64, unpadded. + * + * @param string $bytes The bytes. + * + * @return string The encoding. + */ + private function b64UrlEncode(string $bytes): string { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + }//end b64UrlEncode() + + /** + * The inverse of {@see b64UrlEncode()}. + * + * @param string $encoded The encoding. + * + * @return string The bytes; '' on a malformed input. + */ + private function b64UrlDecode(string $encoded): string { + $padded = strtr($encoded, '-_', '+/'); + $padded .= str_repeat('=', (4 - (strlen($padded) % 4)) % 4); + $decoded = base64_decode($padded, true); + if ($decoded === false) { + return ''; + } + + return $decoded; + }//end b64UrlDecode() +}//end class diff --git a/lib/Service/Portal/PortalTaskDeliveryService.php b/lib/Service/Portal/PortalTaskDeliveryService.php new file mode 100644 index 0000000000..effa1271bc --- /dev/null +++ b/lib/Service/Portal/PortalTaskDeliveryService.php @@ -0,0 +1,196 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Portal + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Portal; + +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\PortalTaskDeliveryMapper; +use OCA\OpenRegister\Db\Task; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Records, reads and settles portal delivery requests. + * + * @SuppressWarnings(PHPMD.StaticAccess) PortalTaskDelivery::summarise is a + * stateless fold over rows; an instance to call it would be a second copy. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ +class PortalTaskDeliveryService { + + /** + * Constructor. + * + * @param PortalTaskDeliveryMapper $deliveries The delivery records. + * @param LoggerInterface $logger Where a recording failure is reported. + */ + public function __construct( + private readonly PortalTaskDeliveryMapper $deliveries, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Record a delivery request on every channel for a task's ask. + * + * One row per channel, so a mail outage and a portal outage are told + * apart. Never throws: each failure is logged with the task, and the + * rows that could be written are returned. + * + * @param Task $task The external task. + * @param string $kind `ask`, `re-ask` or `reminder`. + * @param array $message What portaliq renders. + * + * @return array The rows written (possibly none). + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function request(Task $task, string $kind, array $message): array { + $written = []; + foreach (PortalTaskDelivery::CHANNELS as $channel) { + $row = new PortalTaskDelivery(); + $row->setTaskUuid((string)$task->getUuid()); + $row->setPartyReference((string)$task->getAssignee()); + $row->setChannel($channel); + $row->setKind($kind); + $row->setMessage($message); + + try { + $written[] = $this->deliveries->insert($row); + } catch (Throwable $failure) { + $this->logger->warning( + '[PortalTaskDeliveryService] Could not record the ' . $channel . ' delivery request of task ' + . $task->getUuid() . '; the task and its run stand, the delivery state reads not-recorded: ' . $failure->getMessage(), + ['task' => $task->getUuid(), 'channel' => $channel, 'kind' => $kind] + ); + } + } + + return $written; + }//end request() + + /** + * The message an ask, re-ask or reminder carries to portaliq. + * + * Descriptors and the case anchor, never case data: portaliq reads the + * case itself through its subject-scoped readers (ADR-046 rule 3). + * + * @param Task $task The external task. + * @param string|null $reason The re-ask reason, when this is a re-ask. + * + * @return array The message. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function messageFor(Task $task, ?string $reason = null): array { + $metadata = ($task->getMetadata() ?? []); + + return [ + 'taskUuid' => $task->getUuid(), + 'title' => $task->getTitle(), + 'description' => $task->getDescription(), + 'reason' => $reason, + 'cycle' => (int)($metadata['cycle'] ?? 1), + 'partyReference' => $task->getAssignee(), + 'dueAt' => $task->getDueAt()?->format('c'), + 'expiresAt' => $task->getExpiresAt()?->format('c'), + 'case' => [ + 'uuid' => $task->getObjectUuid(), + 'register' => $task->getRegisterId(), + 'schema' => $task->getSchemaId(), + ], + 'upload' => ($metadata['upload'] ?? null), + ]; + }//end messageFor() + + /** + * The delivery state of one task, summarised for its row. + * + * @param Task $task The task. + * + * @return array {state, channels, requestedAt, deliveredAt}. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function stateFor(Task $task): array { + return PortalTaskDelivery::summarise(rows: $this->deliveries->findForTask(taskUuid: (string)$task->getUuid())); + }//end stateFor() + + /** + * The requests portaliq has not yet reported on, oldest first. + * + * @param int $limit Page size. + * + * @return array The pending rows. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function pending(int $limit = 100): array { + return $this->deliveries->findPending(limit: $limit); + }//end pending() + + /** + * Portaliq reports a request as sent. + * + * @param string $uuid The delivery uuid. + * + * @return PortalTaskDelivery The settled row. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException When no such request exists. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function markDelivered(string $uuid): PortalTaskDelivery { + return $this->deliveries->markDelivered(delivery: $this->deliveries->findByUuid(uuid: $uuid)); + }//end markDelivered() + + /** + * Portaliq reports a request as failed, and why. + * + * @param string $uuid The delivery uuid. + * @param string $error The failure. + * + * @return PortalTaskDelivery The settled row. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException When no such request exists. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function markFailed(string $uuid, string $error): PortalTaskDelivery { + return $this->deliveries->markFailed(delivery: $this->deliveries->findByUuid(uuid: $uuid), error: $error); + }//end markFailed() +}//end class diff --git a/lib/Service/Portal/PortalTaskService.php b/lib/Service/Portal/PortalTaskService.php new file mode 100644 index 0000000000..09301206c9 --- /dev/null +++ b/lib/Service/Portal/PortalTaskService.php @@ -0,0 +1,445 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Portal + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-an-upload-completion-lands-as-a-file-attachment-on-the-case-object + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Portal; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; +use OCP\AppFramework\Db\DoesNotExistException; +use Psr\Log\LoggerInterface; + +/** + * Lists a subject's portal tasks and completes one with uploads. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The seam joins the task + * layer (service, mapper, inbox rows, temporal projection) to the file + * service and the subject value; each import is one side of that join. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-an-upload-completion-lands-as-a-file-attachment-on-the-case-object + */ +class PortalTaskService { + + /** + * The outcome a portal completion records when the party names none. + * + * @var string + */ + public const DEFAULT_OUTCOME = 'submitted'; + + /** + * The tag every stored upload carries, so the case's file list can say + * which portal task delivered it. + * + * @var string + */ + public const FILE_TAG_PREFIX = 'portal-task:'; + + /** + * Constructor. + * + * @param TaskService $tasks The authorized lifecycle. + * @param TaskMapper $mapper The subject-scoped finders. + * @param TaskInboxService $inbox Row shaping and case context. + * @param TaskTemporalProjection $temporal The clock for the row projection. + * @param FileService $files Stores uploads onto the case object. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly TaskService $tasks, + private readonly TaskMapper $mapper, + private readonly TaskInboxService $inbox, + private readonly TaskTemporalProjection $temporal, + private readonly FileService $files, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * One subject's open portal tasks with case context, and the total the + * same predicates count. + * + * @param PortalSubject $subject The acting subject. + * @param int $limit Page size (clamped to 1..500). + * @param int $offset Page offset. + * + * @return array{results: array>, total: int, limit: int, offset: int} The page. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function listForSubject(PortalSubject $subject, int $limit = 25, int $offset = 0): array { + $limit = max(1, min($limit, 500)); + $offset = max(0, $offset); + $party = $subject->partyReference(); + + $page = $this->mapper->findOpenExternalForParty(partyReference: $party, limit: $limit, offset: $offset); + $total = $this->mapper->countOpenExternalForParty(partyReference: $party); + $contexts = $this->inbox->subjectContextsFor(tasks: $page); + $now = $this->temporal->now(); + + $results = []; + foreach ($page as $task) { + // Belt and braces over the WHERE clause: a row that is not this + // party's never leaves the service, whatever the query returned. + if (hash_equals($party, (string)$task->getAssignee()) === false) { + continue; + } + + $results[] = $this->inbox->row(task: $task, subjects: $contexts, now: $now); + } + + return [ + 'results' => $results, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ]; + }//end listForSubject() + + /** + * One portal task, if it is this subject's. + * + * @param PortalSubject $subject The acting subject. + * @param string $uuid The task uuid. + * + * @return Task The task. + * + * @throws DoesNotExistException When absent, not external, or not this + * subject's: all three read identically, so + * a stranger learns nothing from the answer. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function show(PortalSubject $subject, string $uuid): Task { + $task = $this->tasks->get(uuid: $uuid); + if ((string)$task->getPerformerType() !== Task::PERFORMER_EXTERNAL + || hash_equals($subject->partyReference(), (string)$task->getAssignee()) === false + ) { + throw new DoesNotExistException('No such portal task.'); + } + + return $task; + }//end show() + + /** + * The row shape of one task for the portal, with delivery state and + * case context. + * + * @param Task $task The task. + * + * @return array The row. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function row(Task $task): array { + return $this->inbox->row( + task: $task, + subjects: $this->inbox->subjectContextsFor(tasks: [$task]), + now: $this->temporal->now() + ); + }//end row() + + /** + * Complete a portal task as the acting subject, storing its uploads on + * the case object first. + * + * @param PortalSubject $subject The acting subject. + * @param string $uuid The task uuid. + * @param array $answers The submitted answer fields. + * @param string|null $comment The party's comment, when any. + * @param array> $files The uploads, each + * {name, type, size, tmp_name|content}. + * @param string $outcome The outcome to record; defaults to `submitted`. + * + * @return Task The completed task. + * + * @throws \OCA\OpenRegister\Exception\TaskAccessDeniedException When the + * subject is not the matched party (audited before anything is stored). + * @throws \OCA\OpenRegister\Exception\TaskConflictException When the task is terminal. + * @throws TaskValidationException When an upload constraint is violated (nothing stored). + * @throws DoesNotExistException When no such task exists. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-an-upload-completion-lands-as-a-file-attachment-on-the-case-object + */ + public function complete( + PortalSubject $subject, + string $uuid, + array $answers = [], + ?string $comment = null, + array $files = [], + string $outcome = self::DEFAULT_OUTCOME, + ): Task { + // 1. Authorize, and audit a denial, BEFORE any other step. A stranger + // who knows the uuid must not get a validation message, let alone a + // file on somebody else's case. + $task = $this->tasks->openFor(verb: 'complete', uuid: $uuid, actor: $subject->actor()); + + // 2. Validate against the constraints the node froze on the task. + // Refused means nothing was stored and the task is still open. + $this->assertUploadConstraints(task: $task, files: $files); + + // 3. Store every file on the CASE object. Order matters (design D-5): + // an orphaned file on the right case is recoverable and visible; a + // completed task whose evidence does not exist is neither. + $evidence = $this->storeFiles(task: $task, files: $files); + + // 4. Record the completion, referencing the stored files. + $outcome = trim($outcome); + if ($outcome === '') { + $outcome = self::DEFAULT_OUTCOME; + } + + return $this->tasks->complete( + uuid: $uuid, + outcome: $outcome, + resultText: null, + comment: $comment, + actor: $subject->actor(), + responses: $answers, + evidence: $evidence + ); + }//end complete() + + /** + * Refuse a completion that violates the task's upload constraints, + * naming the constraint. + * + * @param Task $task The task, carrying `metadata.upload`. + * @param array> $files The uploads. + * + * @return void + * + * @throws TaskValidationException On the first violated constraint. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-an-upload-completion-lands-as-a-file-attachment-on-the-case-object + */ + public function assertUploadConstraints(Task $task, array $files): void { + $constraints = (array)(($task->getMetadata() ?? [])['upload'] ?? []); + $required = (($constraints['required'] ?? false) === true); + $maxFiles = (int)($constraints['maxFiles'] ?? 1); + $maxBytes = ($constraints['maxSizeBytes'] ?? null); + $accepted = array_values(array_filter(array_map('strval', (array)($constraints['acceptedTypes'] ?? [])))); + + if ($required === true && $files === []) { + throw new TaskValidationException(message: 'This task requires at least one file (uploadRequired).'); + } + + if ($maxFiles > 0 && count($files) > $maxFiles) { + throw new TaskValidationException( + message: sprintf('At most %d file(s) are accepted (uploadMaxFiles); %d were sent.', $maxFiles, count($files)) + ); + } + + if ($files !== [] && trim((string)$task->getObjectUuid()) === '') { + throw new TaskValidationException(message: 'This task is anchored to no case object, so a file has nowhere to land.'); + } + + $maxBytesLimit = 0; + if (is_numeric($maxBytes) === true) { + $maxBytesLimit = (int)$maxBytes; + } + + foreach ($files as $file) { + $this->assertFileAllowed(file: $file, maxBytes: $maxBytesLimit, accepted: $accepted); + } + }//end assertUploadConstraints() + + /** + * Refuse ONE file that breaks a per-file constraint, naming it. + * + * @param array $file The upload. + * @param int $maxBytes The size limit; 0 for none. + * @param array $accepted The accepted types; empty for any. + * + * @return void + * + * @throws TaskValidationException On the violated constraint. + */ + private function assertFileAllowed(array $file, int $maxBytes, array $accepted): void { + $name = trim((string)($file['name'] ?? '')); + if ($name === '') { + throw new TaskValidationException(message: 'Every uploaded file needs a name.'); + } + + $size = (int)($file['size'] ?? 0); + if ($maxBytes > 0 && $size > $maxBytes) { + throw new TaskValidationException( + message: sprintf("File '%s' is %d bytes, larger than the %d byte limit (uploadMaxSizeMb).", $name, $size, $maxBytes) + ); + } + + $type = trim((string)($file['type'] ?? '')); + if ($accepted !== [] && $this->typeAccepted(name: $name, type: $type, accepted: $accepted) === false) { + throw new TaskValidationException( + message: sprintf("File '%s' has type '%s', which is not one of %s (uploadAcceptedTypes).", $name, $type, implode(', ', $accepted)) + ); + } + }//end assertFileAllowed() + + /** + * Whether a file matches the accepted-type list: an exact media type, a + * `type/*` wildcard, or an extension (`pdf` or `.pdf`). + * + * @param string $name The file name. + * @param string $type The declared media type. + * @param array $accepted The accepted entries. + * + * @return bool True when at least one entry admits the file. + */ + private function typeAccepted(string $name, string $type, array $accepted): bool { + $extension = strtolower((string)pathinfo($name, PATHINFO_EXTENSION)); + $type = strtolower($type); + foreach ($accepted as $entry) { + $entry = strtolower(trim($entry)); + if ($entry === '') { + continue; + } + + if (str_contains($entry, '/') === true) { + if ($this->mediaTypeMatches(entry: $entry, type: $type) === true) { + return true; + } + + continue; + } + + if (ltrim($entry, '.') === $extension && $extension !== '') { + return true; + } + } + + return false; + }//end typeAccepted() + + /** + * Whether an accepted media-type entry admits a declared type: exactly, + * or as a `type/*` wildcard. + * + * @param string $entry The accepted entry, lower-cased. + * @param string $type The declared media type, lower-cased. + * + * @return bool True when the entry admits the type. + */ + private function mediaTypeMatches(string $entry, string $type): bool { + if ($entry === $type) { + return true; + } + + return str_ends_with($entry, '/*') === true && $type !== '' && str_starts_with($type, substr($entry, 0, -1)) === true; + }//end mediaTypeMatches() + + /** + * Store the uploads on the case object; return what the completion references. + * + * @param Task $task The task, anchored to the case. + * @param array> $files The uploads. + * + * @return array> One reference per stored file. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-an-upload-completion-lands-as-a-file-attachment-on-the-case-object + */ + private function storeFiles(Task $task, array $files): array { + $references = []; + foreach ($files as $file) { + $name = (string)$file['name']; + $content = $this->contentOf(file: $file); + $stored = $this->files->addFile( + objectEntity: (string)$task->getObjectUuid(), + fileName: $name, + content: $content, + share: false, + tags: [self::FILE_TAG_PREFIX . (string)$task->getUuid()], + registerId: $task->getRegisterId() + ); + + if (is_resource($content) === true) { + fclose($content); + } + + $references[] = [ + 'fileId' => $stored->getId(), + 'name' => $stored->getName(), + 'path' => $stored->getPath(), + 'size' => $stored->getSize(), + 'mimeType' => $stored->getMimeType(), + 'storedAt' => (new DateTime())->format('c'), + 'taskUuid' => $task->getUuid(), + ]; + + $this->logger->info( + '[PortalTaskService] Stored portal upload ' . $name . ' on case ' . $task->getObjectUuid() . ' for task ' . $task->getUuid(), + ['fileId' => $stored->getId()] + ); + }//end foreach + + return $references; + }//end storeFiles() + + /** + * The bytes of an upload: a stream over its temporary path, or the + * inline content a caller handed in. + * + * @param array $file The upload. + * + * @return resource|string The content. + * + * @throws TaskValidationException When the upload carries no readable content. + */ + private function contentOf(array $file): mixed { + $path = trim((string)($file['tmp_name'] ?? '')); + if ($path !== '') { + $stream = fopen($path, 'rb'); + if ($stream === false) { + throw new TaskValidationException(message: sprintf("File '%s' could not be read.", (string)($file['name'] ?? ''))); + } + + return $stream; + } + + if (isset($file['content']) === true && is_string($file['content']) === true) { + return $file['content']; + } + + throw new TaskValidationException(message: sprintf("File '%s' carries no content.", (string)($file['name'] ?? ''))); + }//end contentOf() +}//end class diff --git a/lib/Service/Task/TaskAuthorizationService.php b/lib/Service/Task/TaskAuthorizationService.php index d54f3fa94b..324cec6dc5 100644 --- a/lib/Service/Task/TaskAuthorizationService.php +++ b/lib/Service/Task/TaskAuthorizationService.php @@ -42,6 +42,11 @@ /** * Decides who may run which lifecycle verb on which task. * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One small rule method per + * relationship the spec names, plus the external rule set (flow-portal-task), + * which lives HERE deliberately: a second authorization service would be two + * places fail-closed has to hold instead of one. + * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed */ class TaskAuthorizationService { @@ -70,6 +75,21 @@ class TaskAuthorizationService { 'offer' => 'assertRequester', ]; + /** + * Verbs no caller may run on an external task (flow-portal-task). + * + * @var array + */ + private const REFUSED_FOR_EXTERNAL = ['claim', 'unclaim', 'delegate', 'offer', 'assign', 'reassign']; + + /** + * Verbs that ANSWER a task, admitted on an external task to the matched + * party alone. + * + * @var array + */ + private const ANSWERING_VERBS = ['complete', 'resolve', 'checklist']; + /** * Constructor. * @@ -108,21 +128,15 @@ public function __construct( * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed */ public function assertMay(string $verb, Task $task, ?string $uid): void { - // No verb is anonymous, and no verb is reachable by uuid alone. - if ($uid === null || trim($uid) === '') { - throw new TaskAccessDeniedException( - message: sprintf("Verb '%s' denied: no acting identity.", $verb) - ); - } - - // An unknown performer type is UNDETERMINABLE, which is a denial. - // PERFORMER_TYPES is the extensible vocabulary: adding `external` - // there admits it everywhere at once. - $performerType = (string)$task->getPerformerType(); - if (in_array($performerType, Task::PERFORMER_TYPES, true) === false) { - throw new TaskAccessDeniedException( - message: sprintf("Verb '%s' denied: performer type '%s' is unknown, so authorization cannot be determined.", $verb, $performerType) - ); + $uid = $this->assertDeterminable(verb: $verb, task: $task, uid: $uid); + + // An EXTERNAL task is decided by its own rule set, BEFORE the + // administrator bypass: the matched party is the only identity that + // may answer, and "an administrator acting through the seam" is one + // of the callers the spec names as denied. + if ((string)$task->getPerformerType() === Task::PERFORMER_EXTERNAL) { + $this->assertExternal(verb: $verb, task: $task, uid: $uid); + return; } if ($this->isAdmin(uid: $uid) === true) { @@ -239,6 +253,131 @@ private function isAdmin(string $uid): bool { } }//end isAdmin() + /** + * The two checks every verb makes before any rule: an acting identity + * exists, and the performer type is in the vocabulary. Anything else is + * UNDETERMINABLE, which is a denial — adding a type to PERFORMER_TYPES is + * what admits it everywhere at once. + * + * @param string $verb The verb being attempted. + * @param Task $task The task acted on. + * @param string|null $uid The acting identity, or null when there is none. + * + * @return string The non-empty acting identity. + * + * @throws TaskAccessDeniedException When anonymous or undeterminable. + * + * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + */ + private function assertDeterminable(string $verb, Task $task, ?string $uid): string { + // No verb is anonymous, and no verb is reachable by uuid alone. + if ($uid === null || trim($uid) === '') { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: no acting identity.", $verb) + ); + } + + $performerType = (string)$task->getPerformerType(); + if (in_array($performerType, Task::PERFORMER_TYPES, true) === false) { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: performer type '%s' is unknown, so authorization cannot be determined.", $verb, $performerType) + ); + } + + return $uid; + }//end assertDeterminable() + + /** + * The rule set of an external (portal party) task. + * + * Three groups of verbs, decided in this order. The pooling and mandate + * verbs (`claim`, `unclaim`, `delegate`, and the assignment verbs + * `offer`, `assign`, `reassign` that would move the frozen match) are + * REFUSED for everyone, naming the performer type: there is no candidate + * pool to claim from and no mandate model for a party outside the + * instance, and the frozen match is corrected by cancel or re-ask, never + * by moving the reference (design D-3). The answering verbs (`complete`, + * `resolve`, `checklist`) admit exactly ONE identity: the stored party + * reference, compared as a whole, with no administrator bypass and no + * on-behalf path. The requester's verb (`cancel`) keeps its ordinary + * rule, administrator included, because withdrawing an ask is the + * caseworker's act, not an answer. + * + * @param string $verb The verb being attempted. + * @param Task $task The external task. + * @param string $uid The acting identity (a party reference, or a uid). + * + * @return void + * + * @throws TaskAccessDeniedException When denied, or undeterminable. + * + * @spec openspec/changes/flow-portal-task/specs/flow-tasks/spec.md#requirement-the-external-performer-type-is-portal-scoped-and-never-pooled + */ + private function assertExternal(string $verb, Task $task, string $uid): void { + if ($verb === 'create') { + return; + } + + if (in_array($verb, self::REFUSED_FOR_EXTERNAL, true) === true) { + throw new TaskAccessDeniedException( + message: sprintf( + "Verb '%s' refused: performer type '%s' has no candidate pool and no mandate model; cancel or re-ask instead.", + $verb, + Task::PERFORMER_EXTERNAL + ) + ); + } + + if (in_array($verb, self::ANSWERING_VERBS, true) === true) { + $this->assertMatchedParty(verb: $verb, task: $task, uid: $uid); + return; + } + + if ($verb === 'cancel') { + if ($this->isAdmin(uid: $uid) === true) { + return; + } + + $this->assertRequester(verb: $verb, task: $task, uid: $uid); + return; + } + + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: no authorization rule exists for it on performer type '%s'.", $verb, Task::PERFORMER_EXTERNAL) + ); + }//end assertExternal() + + /** + * The caller must BE the stored party reference, as a whole. + * + * Fail closed on every undeterminable shape: no stored reference, a + * reference that is not a party reference, or a caller that is not one. + * Only a whole-string match of two party references admits; there is no + * administrator bypass and no on-behalf path. + * + * @param string $verb The verb, for the denial message. + * @param Task $task The external task. + * @param string $uid The acting identity. + * + * @return void + * + * @throws TaskAccessDeniedException When the caller is not the matched party. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + private function assertMatchedParty(string $verb, Task $task, string $uid): void { + $party = trim((string)$task->getAssignee()); + if ($party === '' + || str_starts_with($party, Task::EXTERNAL_PARTY_PREFIX) === false + || str_starts_with($uid, Task::EXTERNAL_PARTY_PREFIX) === false + || hash_equals($party, $uid) === false + ) { + throw new TaskAccessDeniedException( + message: sprintf("Verb '%s' denied: only the matched portal subject may answer an external task.", $verb) + ); + } + }//end assertMatchedParty() + /** * The caller must be the task's current assignee. * diff --git a/lib/Service/Task/TaskInboxService.php b/lib/Service/Task/TaskInboxService.php index 4110c48aed..ca8cfbbc41 100644 --- a/lib/Service/Task/TaskInboxService.php +++ b/lib/Service/Task/TaskInboxService.php @@ -36,6 +36,8 @@ namespace OCA\OpenRegister\Service\Task; use OCA\OpenRegister\Db\AbstractObjectMapper; +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\PortalTaskDeliveryMapper; use OCA\OpenRegister\Db\Task; use OCA\OpenRegister\Db\TaskInboxCriteria; use OCA\OpenRegister\Db\TaskMapper; @@ -46,6 +48,9 @@ * Lists and counts tasks for a caller, with subject context attached. * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + * + * @SuppressWarnings(PHPMD.StaticAccess) PortalTaskDelivery::summarise is a + * stateless fold over rows; an instance to call it would be a second copy. */ class TaskInboxService { @@ -63,12 +68,23 @@ class TaskInboxService { * reads null, which is honest — * the TASK list never fails * over a context lookup. + * @param PortalTaskDeliveryMapper|null $deliveries Reads the delivery + * state of an EXTERNAL + * task's ask, attached + * to its row so the + * caseworker sees "not + * yet delivered" instead + * of silence. Nullable + * for the same reason as + * the object store; absent, + * the row says so. */ public function __construct( private readonly TaskMapper $tasks, private readonly TaskTemporalProjection $temporal, private readonly LoggerInterface $logger, private readonly ?AbstractObjectMapper $objects = null, + private readonly ?PortalTaskDeliveryMapper $deliveries = null, ) { }//end __construct() @@ -152,9 +168,67 @@ public function row(Task $task, array $subjects, \DateTimeInterface $now): array $row['daysUntilDue'] = $projection['daysUntilDue']; $row['daysOverdue'] = $projection['daysOverdue']; + if ((string)$task->getPerformerType() === Task::PERFORMER_EXTERNAL) { + $row['delivery'] = $this->deliveryState(task: $task); + } + return $row; }//end row() + /** + * The delivery state of an external task's ask, for its row. + * + * Summarised from the delivery request records: `requested` until every + * channel reports, `delivered` when the portal inbox message went out, + * `failed` when a channel failed, and `not-recorded` when no request row + * exists at all (the outage case the spec wants visible). Never throws: + * the task list does not fail over a delivery lookup. + * + * @param Task $task The external task. + * + * @return array {state, channels, requestedAt, deliveredAt}. + * + * @spec openspec/changes/flow-portal-task/specs/flow-tasks/spec.md#requirement-the-external-performer-type-is-portal-scoped-and-never-pooled + */ + public function deliveryState(Task $task): array { + $unknown = [ + 'state' => PortalTaskDelivery::STATE_NOT_RECORDED, + 'channels' => [], + 'requestedAt' => null, + 'deliveredAt' => null, + ]; + if ($this->deliveries === null) { + return $unknown; + } + + try { + $rows = $this->deliveries->findForTask(taskUuid: (string)$task->getUuid()); + } catch (Throwable $failure) { + $this->logger->debug( + '[TaskInboxService] Could not read delivery state: ' . $failure->getMessage(), + ['task' => $task->getUuid()] + ); + + return $unknown; + } + + return PortalTaskDelivery::summarise(rows: $rows); + }//end deliveryState() + + /** + * Subject context for a set of tasks, for readers outside this service + * (the portal seam lists a subject's tasks WITH their case context). + * + * @param array $tasks The tasks. + * + * @return array> Context by object uuid. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + public function subjectContextsFor(array $tasks): array { + return $this->subjectContexts(tasks: $tasks); + }//end subjectContextsFor() + /** * One task as an API row, with its subject context resolved. * diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php index ac2817ff2f..42ade82a42 100644 --- a/lib/Service/Task/TaskService.php +++ b/lib/Service/Task/TaskService.php @@ -478,19 +478,36 @@ public function resolve(string $uuid, ?string $resultText, ?string $comment, ?st * @param string|null $resultText Free-text result. * @param string|null $comment Completion comment. * @param string|null $actor The completing identity — must be the assignee. + * @param array|null $responses The submitted answer fields, + * when the completion carries + * any (a portal task's form). + * @param array>|null $evidence References to the + * files ALREADY stored + * for this completion; + * never bytes. * * @return Task The completed task. * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed */ - public function complete(string $uuid, string $outcome, ?string $resultText, ?string $comment, ?string $actor): Task { + public function complete( + string $uuid, + string $outcome, + ?string $resultText, + ?string $comment, + ?string $actor, + ?array $responses = null, + ?array $evidence = null, + ): Task { return $this->completeInternal( verb: 'complete', uuid: $uuid, outcome: $outcome, resultText: $resultText, comment: $comment, - actor: $actor + actor: $actor, + responses: $responses, + evidence: $evidence ); }//end complete() @@ -687,6 +704,60 @@ public function get(string $uuid): Task { return $this->tasks->findByUuid(uuid: $uuid); }//end get() + /** + * Resolve, authorize and audit a verb's task WITHOUT running the verb. + * + * For a caller that must do work between the authorization and the + * verb: the portal completion stores uploads on the case object before it + * records the completion, and a stranger must be refused, and the refusal + * audited, BEFORE any byte lands on a case that is not theirs. Same three + * checks in the same order as every verb (exists, authorized, open), so a + * denial here reads in the audit exactly as a denial from the verb would. + * + * @param string $verb The verb about to be attempted. + * @param string $uuid The task uuid. + * @param string|null $actor The acting identity. + * + * @return Task The open, authorized task. + * + * @throws TaskAccessDeniedException When authorization denies (audited). + * @throws TaskConflictException When the task is already terminal. + * @throws \OCP\AppFramework\Db\DoesNotExistException When no such task exists. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-only-the-matched-party-completes-fail-closed + */ + public function openFor(string $verb, string $uuid, ?string $actor): Task { + return $this->openTaskFor(verb: $verb, uuid: $uuid, actor: $actor); + }//end openFor() + + /** + * Append an audit entry that records a FACT about the task without + * moving it: the party a portal task was matched to, and the role it was + * matched from. The state is unchanged; only the trail grows. + * + * @param string $uuid The task uuid. + * @param string $action The audited action name. + * @param string|null $actor The acting identity. + * @param string $reason What is being recorded. + * + * @return Task The task, unchanged. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException When no such task exists. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-the-matched-party-comes-from-the-case-and-is-frozen-at-creation + */ + public function record(string $uuid, string $action, ?string $actor, string $reason): Task { + $task = $this->tasks->findByUuid(uuid: $uuid); + + return $this->transactional( + mutation: function () use ($task, $action, $actor, $reason): Task { + $this->appendAudit(task: $task, action: $action, actor: $actor, reason: $reason); + + return $task; + } + ); + }//end record() + /** * The audit trail of a task, oldest first. * @@ -744,6 +815,8 @@ private function assignInternal(string $uuid, string $assignee, ?string $actor, * @param string|null $resultText Free-text result. * @param string|null $comment Completion comment. * @param string|null $actor The acting identity. + * @param array|null $responses Submitted answer fields, when any. + * @param array>|null $evidence Stored file references, when any. * * @return Task The completed task. * @@ -758,6 +831,8 @@ private function completeInternal( ?string $resultText, ?string $comment, ?string $actor, + ?array $responses = null, + ?array $evidence = null, ): Task { $task = $this->openTaskFor(verb: $verb, uuid: $uuid, actor: $actor); @@ -770,10 +845,18 @@ private function completeInternal( } return $this->transactional( - mutation: function () use ($task, $outcome, $resultText, $comment, $actor, $verb): Task { + mutation: function () use ($task, $outcome, $resultText, $comment, $actor, $verb, $responses, $evidence): Task { $task->setOutcome($outcome); $task->setResultText($resultText); $task->setComment($comment); + if ($responses !== null) { + $task->setResponses($responses); + } + + if ($evidence !== null) { + $task->setEvidence($evidence); + } + $task->setCompletedAt(new DateTime()); $task->setCompletedBy($actor); $this->applyState(task: $task, state: Task::STATE_COMPLETED, action: $verb); diff --git a/openspec/changes/flow-portal-task/tasks.md b/openspec/changes/flow-portal-task/tasks.md index b89fe129bb..bb6ea52ae0 100644 --- a/openspec/changes/flow-portal-task/tasks.md +++ b/openspec/changes/flow-portal-task/tasks.md @@ -2,88 +2,88 @@ ## 1. The node -- [ ] 1.1 `lib/Service/Flow/Nodes/PortalTaskNode.php` implementing +- [x] 1.1 `lib/Service/Flow/Nodes/PortalTaskNode.php` implementing `IFlowNode`, `IFlowNodeConfigKeys` and `IFlowNodeConfigForm`. Follow `UserTaskNode` for shape: EUPL-1.2 header, `@spec` on every method, a file docblock stating the three-waiter division of labour (signal: a system that calls back; user task: a performer in the organisation; portal task: a party outside it). `getId()` returns `openregister.portal-task`. -- [ ] 1.2 `configForm()` + `configKeys()`: title/description templates, +- [x] 1.2 `configForm()` + `configKeys()`: title/description templates, party role (default `initiator`), upload requirements (required, count, types, max size), `outcomeKey` (default `portalTask`), re-ask reason item field, `dueAt`/`expiresAt` references, `heartbeatMinutes`, `advance`. Register the node in `lib/Listener/FlowNodeRegistrationListener.php`. -- [ ] 1.3 `validateConfig()`: refuse a config with no party role; refuse +- [x] 1.3 `validateConfig()`: refuse a config with no party role; refuse `advance: null` exactly as `UserTaskNode` does. The mandatory re-ask reason cannot be checked statically (whether a firing is a re-entry is runtime knowledge), so it is validated at fire time per section 6. ## 2. Party matching -- [ ] 2.1 Party resolver: resolve the configured role against the subject +- [x] 2.1 Party resolver: resolve the configured role against the subject case object at creation, freeze the reference on the task, record role and reference in the audit; fail the firing loudly when the case names nobody for the role. -- [ ] 2.2 No re-resolution anywhere: completion authorization reads the +- [x] 2.2 No re-resolution anywhere: completion authorization reads the STORED reference only; add the case-edit scenario as a regression test. ## 3. Suspend, resume, outcome -- [ ] 3.1 Reuse `flow-user-task-node`'s bridge for suspension and +- [x] 3.1 Reuse `flow-user-task-node`'s bridge for suspension and continuation: one task per node per run via the resume slot, non-null heartbeat `resumeAt` (15-minute default, 5-minute floor, never null: `FlowRunMapper::findAbandonedSignals()` matches `resume_at IS NULL` and `FlowRunWorker` fails matches at 14 days), continuation on task terminality read from the task. -- [ ] 3.2 Outcome placement onto every item under `outcomeKey`: outcome, +- [x] 3.2 Outcome placement onto every item under `outcomeKey`: outcome, answer fields, stored file references, matched party reference; expiry/termination distinguishable from completion. ## 4. The portal delivery seam -- [ ] 4.1 Subject-scoped portal-task read: list one portal subject's open +- [x] 4.1 Subject-scoped portal-task read: list one portal subject's open external tasks with case context, shaped for ADR-046 consumption (descriptor aggregate, subject-scoped rows, no cross-subject rows or counts). -- [ ] 4.2 Delivery request record (portal inbox message + mail) written at +- [x] 4.2 Delivery request record (portal inbox message + mail) written at creation and re-ask, queryable delivery state, failure leaves the task and suspension standing. ## 5. Completion -- [ ] 5.1 Completion endpoint on the portal seam: validate upload +- [x] 5.1 Completion endpoint on the portal seam: validate upload constraints fail-closed, store each accepted file via `FileService::addFile()` onto the CASE object BEFORE recording the completion, reference the stored files from the completion. -- [ ] 5.2 Completion authorization: acting portal subject vs stored party +- [x] 5.2 Completion authorization: acting portal subject vs stored party reference, deny on any mismatch or unresolvable comparison, audit every denial; no completion-on-behalf path. -- [ ] 5.3 Keep `POST /api/flow-runs/{uuid}/resume` unable to touch an +- [x] 5.3 Keep `POST /api/flow-runs/{uuid}/resume` unable to touch an external task (same contract as `flow-user-task-node`); regression test it. ## 6. Re-ask -- [ ] 6.1 Re-entry path: slot task terminal + reason present → new task +- [x] 6.1 Re-entry path: slot task terminal + reason present → new task (fresh match, reason, cycle number, previous task uuid) + delivery; slot task terminal + reason absent → fail the firing naming the missing reason; slot task open → suspend again (heartbeat case). ## 7. The external performer type (flow-tasks delta) -- [ ] 7.1 Extend the task entity/service with `performer_type: external` +- [x] 7.1 Extend the task entity/service with `performer_type: external` and the party-reference performer shape; refuse `claim`, `unclaim` and `delegate` for it naming the performer type. -- [ ] 7.2 Exclude external tasks from every Nextcloud inbox query, count +- [x] 7.2 Exclude external tasks from every Nextcloud inbox query, count and projection; keep them readable on their anchored object for authorized caseworkers, with delivery state. ## 8. Timers -- [ ] 8.1 Pass `due_at`/`expires_at` references through to the task; wire +- [x] 8.1 Pass `due_at`/`expires_at` references through to the task; wire the preBreach (party reminder via the portal seam) and slaBreached (caseworker escalation) rung addressing as consumption of `flow-business-timers`; add no clock, sweep or business-day code @@ -98,15 +98,15 @@ ## 10. Tests -- [ ] 10.1 Node unit tests: idempotence across a heartbeat wake, empty +- [x] 10.1 Node unit tests: idempotence across a heartbeat wake, empty firing, frozen match incl. the case-edit regression, re-ask cycle and mandatory reason, expiry-vs-completion distinguishability, non-null `resumeAt`. -- [ ] 10.2 Authorization and validation tables: wrong subject, +- [x] 10.2 Authorization and validation tables: wrong subject, unresolvable subject, missing required upload, oversized file, and the refused verbs (`claim`/`unclaim`/`delegate`) on an external task. -- [ ] 10.3 Playwright coverage for the six `@e2e`-marked scenarios across +- [x] 10.3 Playwright coverage for the six `@e2e`-marked scenarios across `specs/flow-portal-task/spec.md` and `specs/flow-tasks/spec.md`, including the negative one: another portal subject cannot complete a task that is not theirs. diff --git a/tests/Unit/Controller/PortalTaskControllerTest.php b/tests/Unit/Controller/PortalTaskControllerTest.php new file mode 100644 index 0000000000..6eb515045b --- /dev/null +++ b/tests/Unit/Controller/PortalTaskControllerTest.php @@ -0,0 +1,326 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use OCA\OpenRegister\Controller\PortalTaskController; +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\PortalSubjectException; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Portal\PortalSubject; +use OCA\OpenRegister\Service\Portal\PortalSubjectAssertion; +use OCA\OpenRegister\Service\Portal\PortalTaskDeliveryService; +use OCA\OpenRegister\Service\Portal\PortalTaskService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * HTTP status and body translation for every portal task route. + * + * @covers \OCA\OpenRegister\Controller\PortalTaskController + * @covers \OCA\OpenRegister\Exception\PortalSubjectException + * @covers \OCA\OpenRegister\Db\PortalTaskDelivery + * @uses \OCA\OpenRegister\Db\Task + * @uses \OCA\OpenRegister\Service\Portal\PortalSubject + */ +class PortalTaskControllerTest extends TestCase { + + /** + * The verifier, mocked. + * + * @var PortalSubjectAssertion&MockObject + */ + private PortalSubjectAssertion&MockObject $assertion; + + /** + * The seam service, mocked. + * + * @var PortalTaskService&MockObject + */ + private PortalTaskService&MockObject $portal; + + /** + * The delivery ledger, mocked. + * + * @var PortalTaskDeliveryService&MockObject + */ + private PortalTaskDeliveryService&MockObject $delivery; + + /** + * The request, mocked. + * + * @var IRequest&MockObject + */ + private IRequest&MockObject $request; + + /** + * The log, mocked. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Build the mocks. + * + * @return void + */ + protected function setUp(): void { + $this->assertion = $this->createMock(PortalSubjectAssertion::class); + $this->portal = $this->createMock(PortalTaskService::class); + $this->delivery = $this->createMock(PortalTaskDeliveryService::class); + $this->request = $this->createMock(IRequest::class); + $this->logger = $this->createMock(LoggerInterface::class); + }//end setUp() + + /** + * A controller with the given session user and admin status. + * + * @param string|null $uid The session user, or null for none. + * @param bool $admin Whether that user is an administrator. + * + * @return PortalTaskController The controller. + */ + private function controller(?string $uid = null, bool $admin = false): PortalTaskController { + $session = $this->createMock(IUserSession::class); + if ($uid !== null) { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $session->method('getUser')->willReturn($user); + } + + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturn($admin); + + return new PortalTaskController('openregister', $this->request, $this->assertion, $this->portal, $this->delivery, $session, $groups, $this->logger); + }//end controller() + + /** + * The verifier resolves subject `sub-1`. + * + * @return PortalSubject The subject. + */ + private function subjectResolves(): PortalSubject { + $subject = new PortalSubject(subjectRef: 'sub-1'); + $this->assertion->method('resolve')->willReturn($subject); + + return $subject; + }//end subjectResolves() + + /** + * A completed task row. + * + * @return Task The task. + */ + private function task(): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setState(Task::STATE_COMPLETED); + $task->setPerformerType(Task::PERFORMER_EXTERNAL); + + return $task; + }//end task() + + /** + * GET /api/portal-tasks: without a resolvable subject, 401 with the code; with one, the page. + * + * @return void + */ + public function testIndexRequiresASubjectAndReturnsThePage(): void { + $this->assertion->method('resolve')->willThrowException( + new PortalSubjectException(refusal: PortalSubjectException::CODE_MISSING, message: 'no header') + ); + $response = $this->controller()->index(); + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + $this->assertSame('portal-subject-missing', $response->getData()['code']); + $this->assertStringNotContainsString('no header', (string)json_encode($response->getData()), 'the verifier detail stays in the log'); + + $this->setUp(); + $subject = $this->subjectResolves(); + $this->portal->expects($this->once())->method('listForSubject')->with($subject, 10, 5)->willReturn(['results' => [], 'total' => 0, 'limit' => 10, 'offset' => 5]); + $response = $this->controller()->index(limit: 10, offset: 5); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(0, $response->getData()['total']); + }//end testIndexRequiresASubjectAndReturnsThePage() + + /** + * GET /api/portal-tasks/{uuid}: the row for the subject's task; 404 otherwise. + * + * @return void + */ + public function testShowAnswersTheRowOrAbsence(): void { + $this->subjectResolves(); + $task = $this->task(); + $this->portal->method('show')->willReturnCallback( + static function (PortalSubject $subject, string $uuid) use ($task): Task { + if ($uuid === 't-1') { + return $task; + } + + throw new DoesNotExistException('nope'); + } + ); + $this->portal->method('row')->willReturn(['uuid' => 't-1']); + + $this->assertSame(Http::STATUS_OK, $this->controller()->show(uuid: 't-1')->getStatus()); + $absent = $this->controller()->show(uuid: 'other'); + $this->assertSame(Http::STATUS_NOT_FOUND, $absent->getStatus()); + $this->assertSame('no-such-task', $absent->getData()['code']); + }//end testShowAnswersTheRowOrAbsence() + + /** + * POST /api/portal-tasks/{uuid}/complete: the uploads and answers reach the + * service normalised, and the completed row comes back. + * + * @return void + */ + public function testCompletePassesNormalisedUploadsAndAnswers(): void { + $subject = $this->subjectResolves(); + $this->request->method('getParam')->with('answers')->willReturn('{"remarks":"ok"}'); + $this->request->method('getUploadedFile')->willReturnCallback( + static function (string $key): ?array { + if ($key === 'files') { + return [ + 'name' => ['a.pdf', ''], + 'type' => ['application/pdf', ''], + 'tmp_name' => ['/tmp/a', ''], + 'size' => [12, 0], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_NO_FILE], + ]; + } + + return null; + } + ); + $this->portal->expects($this->once()) + ->method('complete') + ->with( + $subject, + 't-1', + ['remarks' => 'ok'], + 'here', + [['name' => 'a.pdf', 'type' => 'application/pdf', 'size' => 12, 'tmp_name' => '/tmp/a', 'error' => UPLOAD_ERR_OK]], + 'submitted' + ) + ->willReturn($this->task()); + $this->portal->method('row')->willReturn(['uuid' => 't-1', 'state' => 'completed']); + + $response = $this->controller()->complete(uuid: 't-1', comment: 'here'); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame('completed', $response->getData()['state']); + }//end testCompletePassesNormalisedUploadsAndAnswers() + + /** + * Completion refusals on the wire: 404 for a denial (a stranger confirms + * nothing), 400 naming the constraint, 409 for a closed task, a generic 500. + * + * @return void + */ + public function testCompletionRefusalsTranslateUniformly(): void { + $this->subjectResolves(); + $this->request->method('getUploadedFile')->willReturn(null); + $cases = [ + [new TaskAccessDeniedException('only the matched portal subject may answer'), Http::STATUS_NOT_FOUND, 'no-such-task'], + [new DoesNotExistException('gone'), Http::STATUS_NOT_FOUND, 'no-such-task'], + [new TaskValidationException(message: 'This task requires at least one file (uploadRequired).'), Http::STATUS_BAD_REQUEST, 'upload-constraint'], + [new TaskConflictException(message: "task 't-1' is already in terminal state 'completed'"), Http::STATUS_CONFLICT, 'task-closed'], + [new RuntimeException('SQLSTATE[HY000] with bound parameters'), Http::STATUS_INTERNAL_SERVER_ERROR, 'internal'], + ]; + + foreach ($cases as [$failure, $status, $code]) { + $portal = $this->createMock(PortalTaskService::class); + $portal->method('complete')->willThrowException($failure); + $this->portal = $portal; + $response = $this->controller()->complete(uuid: 't-1'); + $this->assertSame($status, $response->getStatus(), get_class($failure)); + $this->assertSame($code, $response->getData()['code']); + if ($status === Http::STATUS_NOT_FOUND) { + $this->assertStringNotContainsString('matched', (string)$response->getData()['error'], 'a denial reads as absence'); + } + + if ($status === Http::STATUS_BAD_REQUEST) { + $this->assertStringContainsString('uploadRequired', (string)$response->getData()['error'], 'the constraint is named'); + } + + if ($status === Http::STATUS_INTERNAL_SERVER_ERROR) { + $this->assertStringNotContainsString('SQLSTATE', (string)$response->getData()['error']); + } + } + }//end testCompletionRefusalsTranslateUniformly() + + /** + * The delivery routes: 401 without a session, 403 for a non-administrator, + * the rows for an administrator; settlement passes through and 404s an + * unknown request; a failure needs an error. + * + * @return void + */ + public function testDeliveryRoutesAreTheAdministrators(): void { + $this->assertSame(Http::STATUS_UNAUTHORIZED, $this->controller()->deliveries()->getStatus()); + $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller(uid: 'alice')->deliveries()->getStatus()); + $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller(uid: 'alice')->deliveryDelivered(uuid: 'd-1')->getStatus()); + $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller(uid: 'alice')->deliveryFailed(uuid: 'd-1', error: 'x')->getStatus()); + + $row = new PortalTaskDelivery(); + $row->setUuid('d-1'); + $row->setChannel(PortalTaskDelivery::CHANNEL_MAIL); + $row->setState(PortalTaskDelivery::STATE_REQUESTED); + $this->delivery->method('pending')->with(100)->willReturn([$row]); + $this->delivery->method('markDelivered')->willReturnCallback( + static function (string $uuid) use ($row): PortalTaskDelivery { + if ($uuid !== 'd-1') { + throw new DoesNotExistException('nope'); + } + + $row->setState(PortalTaskDelivery::STATE_DELIVERED); + + return $row; + } + ); + $this->delivery->method('markFailed')->willReturn($row); + + $admin = $this->controller(uid: 'root', admin: true); + $listed = $admin->deliveries(); + $this->assertSame(Http::STATUS_OK, $listed->getStatus()); + $this->assertSame(1, $listed->getData()['total']); + $this->assertSame('d-1', $listed->getData()['results'][0]['uuid']); + + $this->assertSame('delivered', $admin->deliveryDelivered(uuid: 'd-1')->getData()['state']); + $this->assertSame(Http::STATUS_NOT_FOUND, $admin->deliveryDelivered(uuid: 'ghost')->getStatus()); + $this->assertSame(Http::STATUS_BAD_REQUEST, $admin->deliveryFailed(uuid: 'd-1', error: '')->getStatus()); + $this->assertSame(Http::STATUS_OK, $admin->deliveryFailed(uuid: 'd-1', error: 'smtp down')->getStatus()); + }//end testDeliveryRoutesAreTheAdministrators() +}//end class diff --git a/tests/Unit/Db/PortalTaskDeliveryMapperTest.php b/tests/Unit/Db/PortalTaskDeliveryMapperTest.php new file mode 100644 index 0000000000..0a932e1926 --- /dev/null +++ b/tests/Unit/Db/PortalTaskDeliveryMapperTest.php @@ -0,0 +1,127 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\PortalTaskDeliveryMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\TestCase; + +/** + * Tests for {@see PortalTaskDeliveryMapper}. + * + * @covers \OCA\OpenRegister\Db\PortalTaskDeliveryMapper + * @covers \OCA\OpenRegister\Db\PortalTaskDelivery + */ +class PortalTaskDeliveryMapperTest extends TestCase { + use FluentQueryBuilderTrait; + + /** + * A stored row. + * + * @return array The row. + */ + private function row(): array { + return [ + 'id' => 5, + 'uuid' => 'd-5', + 'task_uuid' => 't-1', + 'party_reference' => 'party:bsn-1', + 'channel' => 'mail', + 'kind' => 'ask', + 'state' => 'requested', + 'message' => '{"title":"x"}', + 'requested_at' => '2026-09-01 10:00:00', + ]; + }//end row() + + /** + * An insert stamps uuid, state and requestedAt when the caller left them out. + * + * @return void + */ + public function testInsertStampsUuidStateAndRequestedAt(): void { + $mapper = new PortalTaskDeliveryMapper(db: $this->connectionWith()); + $row = new PortalTaskDelivery(); + $row->setTaskUuid('t-1'); + $row->setChannel(PortalTaskDelivery::CHANNEL_MAIL); + $row->setKind(PortalTaskDelivery::KIND_ASK); + + $inserted = $mapper->insert($row); + $this->assertNotEmpty($inserted->getUuid()); + $this->assertSame(PortalTaskDelivery::STATE_REQUESTED, $inserted->getState()); + $this->assertNotNull($inserted->getRequestedAt()); + $this->assertNotNull($inserted->getCreated()); + $this->assertTrue($this->saw('insert')); + }//end testInsertStampsUuidStateAndRequestedAt() + + /** + * The finders predicate on uuid, task uuid and state, and map the row. + * + * @return void + */ + public function testFindersPredicateAndMap(): void { + $mapper = new PortalTaskDeliveryMapper(db: $this->connectionWith(rows: [$this->row()])); + $found = $mapper->findByUuid(uuid: 'd-5'); + $this->assertSame('t-1', $found->getTaskUuid()); + $this->assertSame(['title' => 'x'], $found->getMessage()); + $this->assertTrue($this->saw('expr.eq', 'uuid')); + + $mapper->findForTask(taskUuid: 't-1'); + $this->assertTrue($this->saw('expr.eq', 'task_uuid')); + + $mapper->findPending(limit: 5); + $this->assertTrue($this->saw('expr.eq', 'state')); + $this->assertTrue($this->saw('setMaxResults', 5)); + + $grouped = $mapper->findForTasks(taskUuids: ['t-1', 't-1', '']); + $this->assertTrue($this->saw('expr.in', 'task_uuid')); + $this->assertArrayHasKey('t-1', $grouped); + $this->assertSame([], $mapper->findForTasks(taskUuids: [])); + + $this->expectException(DoesNotExistException::class); + (new PortalTaskDeliveryMapper(db: $this->connectionWith(rows: [])))->findByUuid(uuid: 'ghost'); + }//end testFindersPredicateAndMap() + + /** + * The two state moves set what they say and nothing else. + * + * @return void + */ + public function testTheStateMovesSetWhatTheySay(): void { + $mapper = new PortalTaskDeliveryMapper(db: $this->connectionWith()); + $row = new PortalTaskDelivery(); + $row->setId(5); + $row->setUuid('d-5'); + $row->setState(PortalTaskDelivery::STATE_REQUESTED); + $row->setError('earlier'); + + $delivered = $mapper->markDelivered(delivery: $row); + $this->assertSame(PortalTaskDelivery::STATE_DELIVERED, $delivered->getState()); + $this->assertNotNull($delivered->getDeliveredAt()); + $this->assertNull($delivered->getError()); + + $failed = $mapper->markFailed(delivery: $row, error: str_repeat('x', 1200)); + $this->assertSame(PortalTaskDelivery::STATE_FAILED, $failed->getState()); + $this->assertSame(1000, strlen((string)$failed->getError()), 'the error is bounded'); + $this->assertTrue($this->saw('update')); + }//end testTheStateMovesSetWhatTheySay() +}//end class diff --git a/tests/Unit/Db/TaskEntitiesTest.php b/tests/Unit/Db/TaskEntitiesTest.php index b213707097..d51c7f0d76 100644 --- a/tests/Unit/Db/TaskEntitiesTest.php +++ b/tests/Unit/Db/TaskEntitiesTest.php @@ -103,7 +103,7 @@ public function testTaskTerminalityAndVocabularies(): void { } $this->assertCount(6, Task::STATES); - $this->assertSame(['user', 'group', 'agent', 'worker'], Task::PERFORMER_TYPES); + $this->assertSame(['user', 'group', 'agent', 'worker', 'external'], Task::PERFORMER_TYPES); $this->assertSame(['low', 'normal', 'high', 'urgent'], Task::PRIORITIES); $this->assertCount(5, Task::ROUTING_STRATEGIES); }//end testTaskTerminalityAndVocabularies() diff --git a/tests/Unit/Db/TaskMapperExternalTest.php b/tests/Unit/Db/TaskMapperExternalTest.php new file mode 100644 index 0000000000..6975622434 --- /dev/null +++ b/tests/Unit/Db/TaskMapperExternalTest.php @@ -0,0 +1,89 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Db\TaskMapper; +use PHPUnit\Framework\TestCase; + +/** + * Tests for the external branch of {@see TaskMapper}. + * + * @covers \OCA\OpenRegister\Db\TaskMapper + * @covers \OCA\OpenRegister\Db\TaskInboxCriteria + * @uses \OCA\OpenRegister\Db\Task + */ +class TaskMapperExternalTest extends TestCase { + use FluentQueryBuilderTrait; + + /** + * Every inbox shape without an object anchor excludes external tasks in + * the WHERE clause, page and count alike; the anchored read does not. + * + * @return void + */ + public function testEveryInboxShapeButTheAnchoredReadExcludesExternalTasks(): void { + foreach ([TaskInboxCriteria::SCOPE_ASSIGNED, TaskInboxCriteria::SCOPE_POOLED, TaskInboxCriteria::SCOPE_WATCHED, TaskInboxCriteria::SCOPE_ALL] as $scope) { + foreach ([false, true] as $admin) { + $this->calls = []; + $mapper = new TaskMapper(db: $this->connectionWith(rows: [])); + $criteria = new TaskInboxCriteria(uid: 'alice', groupIds: ['g'], isAdmin: $admin, scope: $scope); + $mapper->findInbox(criteria: $criteria); + $this->assertTrue($this->saw('expr.neq', 'performer_type'), "$scope admin=" . var_export($admin, true) . ' page excludes external'); + $this->assertTrue($this->saw('expr.isNull', 'performer_type'), 'legacy rows with no type stay visible'); + + $this->calls = []; + $mapper->countInbox(criteria: $criteria); + $this->assertTrue($this->saw('expr.neq', 'performer_type'), "$scope count excludes external"); + } + } + + $this->calls = []; + $mapper = new TaskMapper(db: $this->connectionWith(rows: [])); + $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'alice', isAdmin: true, scope: TaskInboxCriteria::SCOPE_ALL, objectUuid: 'case-7')); + $this->assertTrue($this->saw('expr.eq', 'object_uuid')); + $this->assertFalse($this->saw('expr.neq', 'performer_type'), 'the case-anchored read shows the external task to the caseworker'); + }//end testEveryInboxShapeButTheAnchoredReadExcludesExternalTasks() + + /** + * The party finders predicate on assignee, performer type and openness, + * in the page and the count alike. + * + * @return void + */ + public function testThePartyFindersPredicateOnPartyTypeAndOpenness(): void { + $mapper = new TaskMapper(db: $this->connectionWith(rows: [['id' => 1, 'uuid' => 't-1', 'state' => 'active', 'is_terminal' => 0, 'performer_type' => 'external', 'assignee' => 'party:bsn-1']])); + $page = $mapper->findOpenExternalForParty(partyReference: 'party:bsn-1', limit: 10, offset: 20); + $this->assertCount(1, $page); + $this->assertSame('party:bsn-1', $page[0]->getAssignee()); + $this->assertTrue($this->saw('expr.eq', 'assignee')); + $this->assertTrue($this->saw('expr.eq', 'performer_type')); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + $this->assertTrue($this->saw('setMaxResults', 10)); + $this->assertTrue($this->saw('setFirstResult', 20)); + + $this->calls = []; + $counter = new TaskMapper(db: $this->connectionWith(rows: [['total' => 3]])); + $this->assertSame(3, $counter->countOpenExternalForParty(partyReference: 'party:bsn-1')); + $this->assertTrue($this->saw('expr.eq', 'assignee')); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + $this->assertSame(0, (new TaskMapper(db: $this->connectionWith(rows: [])))->countOpenExternalForParty(partyReference: 'party:nobody')); + }//end testThePartyFindersPredicateOnPartyTypeAndOpenness() +}//end class diff --git a/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php b/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php new file mode 100644 index 0000000000..48ef92d68a --- /dev/null +++ b/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php @@ -0,0 +1,137 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +use OCA\OpenRegister\Listener\FlowNodeRegistrationListener; +use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\Nodes\AwaitSignalNode; +use OCA\OpenRegister\Service\Flow\Nodes\EndNode; +use OCA\OpenRegister\Service\Flow\Nodes\ExplodeNode; +use OCA\OpenRegister\Service\Flow\Nodes\FilterNode; +use OCA\OpenRegister\Service\Flow\Nodes\FlowStateNode; +use OCA\OpenRegister\Service\Flow\Nodes\IterateNode; +use OCA\OpenRegister\Service\Flow\Nodes\LoopNode; +use OCA\OpenRegister\Service\Flow\Nodes\MapNode; +use OCA\OpenRegister\Service\Flow\Nodes\MergeNode; +use OCA\OpenRegister\Service\Flow\Nodes\ObjectReadNode; +use OCA\OpenRegister\Service\Flow\Nodes\ObjectWriteNode; +use OCA\OpenRegister\Service\Flow\Nodes\PortalTaskNode; +use OCA\OpenRegister\Service\Flow\Nodes\RouterNode; +use OCA\OpenRegister\Service\Flow\Nodes\SendEmailNode; +use OCA\OpenRegister\Service\Flow\Nodes\SendNotificationNode; +use OCA\OpenRegister\Service\Flow\Nodes\SendTalkMessageNode; +use OCA\OpenRegister\Service\Flow\Nodes\SetFieldsNode; +use OCA\OpenRegister\Service\Flow\Nodes\SubFlowNode; +use OCA\OpenRegister\Service\Flow\Nodes\SwitchNode; +use OCA\OpenRegister\Service\Flow\Nodes\TriggerManualNode; +use OCA\OpenRegister\Service\Flow\Nodes\TriggerObjectNode; +use OCA\OpenRegister\Service\Flow\Nodes\TriggerScheduleNode; +use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; +use OCA\OpenRegister\Service\Flow\Nodes\WaitNode; +use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; +use OCP\EventDispatcher\Event; +use PHPUnit\Framework\TestCase; + +/** + * Tests for {@see FlowNodeRegistrationListener}. + * + * @covers \OCA\OpenRegister\Listener\FlowNodeRegistrationListener + * @covers \OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent + */ +class FlowNodeRegistrationListenerTest extends TestCase { + + /** + * The listener over one mock per built-in node. + * + * @param array $registered Filled with what reaches the registry. + * + * @return FlowNodeRegistrationListener The listener. + */ + private function listener(array &$registered): FlowNodeRegistrationListener { + $mock = fn (string $class) => $this->createMock($class); + $listener = new FlowNodeRegistrationListener( + setFields: $mock(SetFieldsNode::class), + explode: $mock(ExplodeNode::class), + filter: $mock(FilterNode::class), + wait: $mock(WaitNode::class), + awaitSignal: $mock(AwaitSignalNode::class), + switch: $mock(SwitchNode::class), + end: $mock(EndNode::class), + merge: $mock(MergeNode::class), + loop: $mock(LoopNode::class), + subFlow: $mock(SubFlowNode::class), + router: $mock(RouterNode::class), + objectWrite: $mock(ObjectWriteNode::class), + objectRead: $mock(ObjectReadNode::class), + flowState: $mock(FlowStateNode::class), + map: $mock(MapNode::class), + iterate: $mock(IterateNode::class), + sendNotification: $mock(SendNotificationNode::class), + sendEmail: $mock(SendEmailNode::class), + sendTalkMessage: $mock(SendTalkMessageNode::class), + triggerObject: $mock(TriggerObjectNode::class), + triggerSchedule: $mock(TriggerScheduleNode::class), + triggerManual: $mock(TriggerManualNode::class), + userTask: $mock(UserTaskNode::class), + portalTask: $mock(PortalTaskNode::class), + ); + + return $listener; + }//end listener() + + /** + * Handling the registration event hands EVERY built-in to the registry, + * the three waiters among them. + * + * @return void + */ + public function testEveryBuiltInReachesTheRegistryPortalTaskIncluded(): void { + $registered = []; + $listener = $this->listener($registered); + + $registry = $this->createMock(FlowNodeRegistry::class); + $registry->method('register')->willReturnCallback( + static function (IFlowNode $node) use (&$registered): void { + $registered[] = $node; + } + ); + + $listener->handle(new RegisterFlowNodesEvent(registry: $registry)); + + $this->assertCount(24, $registered, 'all twenty-four built-ins are registered'); + $classes = array_map(static fn (IFlowNode $node): string => get_parent_class($node) ?: get_class($node), $registered); + foreach ([PortalTaskNode::class, UserTaskNode::class, AwaitSignalNode::class] as $waiter) { + $this->assertContains($waiter, $classes, "$waiter is registered"); + } + }//end testEveryBuiltInReachesTheRegistryPortalTaskIncluded() + + /** + * A foreign event registers nothing. + * + * @return void + */ + public function testAForeignEventRegistersNothing(): void { + $registered = []; + $listener = $this->listener($registered); + $listener->handle(new Event()); + $this->assertSame([], $registered); + }//end testAForeignEventRegistersNothing() +}//end class diff --git a/tests/Unit/Listener/PortalTaskReminderListenerTest.php b/tests/Unit/Listener/PortalTaskReminderListenerTest.php new file mode 100644 index 0000000000..da7816265d --- /dev/null +++ b/tests/Unit/Listener/PortalTaskReminderListenerTest.php @@ -0,0 +1,259 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Listener\PortalTaskReminderListener; +use OCA\OpenRegister\Service\Portal\PortalTaskDeliveryService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\Event; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * A stand-in for flow-business-timers' fired event, with its published surface. + */ +final class FakeTimerFiredEvent extends Event { + + /** + * Constructor. + * + * @param string $kind `rung` or `expiry`. + * @param string|null $rungKey The rung key. + * @param array> $recipients The recipients. + * @param object|null $timer The timer. + */ + public function __construct( + private readonly string $kind, + private readonly ?string $rungKey, + private readonly array $recipients, + private readonly ?object $timer, + ) { + parent::__construct(); + }//end __construct() + + public function getKind(): string { + return $this->kind; + }//end getKind() + + public function getRungKey(): ?string { + return $this->rungKey; + }//end getRungKey() + + public function getRecipients(): array { + return $this->recipients; + }//end getRecipients() + + public function getTimer(): ?object { + return $this->timer; + }//end getTimer() + + public function getPriority(): ?string { + return 'medium'; + }//end getPriority() + + public function getMessage(): ?string { + return 'reminder.first'; + }//end getMessage() +}//end class + +/** + * Tests for {@see PortalTaskReminderListener}. + * + * @covers \OCA\OpenRegister\Listener\PortalTaskReminderListener + * @uses \OCA\OpenRegister\Db\Task + * @uses \OCA\OpenRegister\Db\PortalTaskDelivery + */ +class PortalTaskReminderListenerTest extends TestCase { + + /** + * The seam, mocked. + * + * @var PortalTaskDeliveryService&MockObject + */ + private PortalTaskDeliveryService&MockObject $delivery; + + /** + * The lifecycle, mocked. + * + * @var TaskService&MockObject + */ + private TaskService&MockObject $tasks; + + /** + * The listener under test. + * + * @var PortalTaskReminderListener + */ + private PortalTaskReminderListener $listener; + + /** + * Build the listener. + * + * @return void + */ + protected function setUp(): void { + $this->delivery = $this->createMock(PortalTaskDeliveryService::class); + $this->delivery->method('messageFor')->willReturn(['title' => 'Send the payslip']); + $this->tasks = $this->createMock(TaskService::class); + $this->listener = new PortalTaskReminderListener(delivery: $this->delivery, tasks: $this->tasks, logger: new NullLogger()); + }//end setUp() + + /** + * A timer anchored to a task. + * + * @param string $subjectType The subject type. + * + * @return object The timer. + */ + private function timer(string $subjectType = 'task'): object { + return new class ($subjectType) { + public function __construct(private readonly string $type) { + } + + public function getSubjectType(): string { + return $this->type; + } + + public function getSubjectUuid(): string { + return 't-1'; + } + }; + }//end timer() + + /** + * An open external task matched to `party:bsn-1`. + * + * @return Task The task. + */ + private function externalTask(): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setState(Task::STATE_ACTIVE); + $task->setPerformerType(Task::PERFORMER_EXTERNAL); + $task->setAssignee('party:bsn-1'); + + return $task; + }//end externalTask() + + /** + * The party as the timers resolve it: type external, id the party reference. + * + * @return array The recipient. + */ + private function partyRecipient(): array { + return ['type' => 'external', 'id' => 'party:bsn-1', 'role' => 'handler']; + }//end partyRecipient() + + /** + * A preBreach rung addressed to the party becomes a reminder delivery + * request through the seam, carrying the rung. + * + * @return void + */ + public function testAPreBreachRungRemindsThePartyThroughTheSeam(): void { + $task = $this->externalTask(); + $this->tasks->method('get')->with('t-1')->willReturn($task); + $this->delivery->expects($this->once()) + ->method('request') + ->with( + $task, + PortalTaskDelivery::KIND_REMINDER, + $this->callback(static fn (array $message): bool => $message['rungKey'] === 'preBreach:2:businessDays' && $message['priority'] === 'medium') + ) + ->willReturn([]); + + $this->listener->handle(new FakeTimerFiredEvent('rung', 'preBreach:2:businessDays', [$this->partyRecipient()], $this->timer())); + }//end testAPreBreachRungRemindsThePartyThroughTheSeam() + + /** + * A slaBreached rung escalates inward: NOTHING is delivered to the party, + * even when the timers listed them as a recipient. + * + * @return void + */ + public function testABreachRungDeliversNothingToTheParty(): void { + $this->tasks->method('get')->willReturn($this->externalTask()); + $this->delivery->expects($this->never())->method('request'); + + $this->listener->handle( + new FakeTimerFiredEvent('rung', 'slaBreached:0:hours', [$this->partyRecipient(), ['type' => 'role', 'id' => 'teamLeader', 'role' => 'teamLeader']], $this->timer()) + ); + }//end testABreachRungDeliversNothingToTheParty() + + /** + * Nothing is delivered for: an expiry, a rung not addressed to the party, + * a non-task subject, a non-external task, a terminal task, a vanished + * task, or an event that is not a timer fire. + * + * @return void + */ + public function testNothingElseReachesTheSeam(): void { + $this->delivery->expects($this->never())->method('request'); + $this->tasks->method('get')->willReturnCallback( + function (string $uuid): Task { + if ($uuid === 'gone') { + throw new DoesNotExistException('gone'); + } + + return $this->externalTask(); + } + ); + + $this->listener->handle(new FakeTimerFiredEvent('expiry', null, [$this->partyRecipient()], $this->timer())); + $this->listener->handle(new FakeTimerFiredEvent('rung', 'preBreach:1:hours', [['type' => 'user', 'id' => 'alice', 'role' => 'handler']], $this->timer())); + $this->listener->handle(new FakeTimerFiredEvent('rung', 'preBreach:1:hours', [$this->partyRecipient()], $this->timer('object'))); + $this->listener->handle(new FakeTimerFiredEvent('rung', 'preBreach:1:hours', [$this->partyRecipient()], null)); + $this->listener->handle(new Event()); + + $internal = $this->externalTask(); + $internal->setPerformerType(Task::PERFORMER_USER); + $internal->setAssignee('alice'); + $tasks = $this->createMock(TaskService::class); + $tasks->method('get')->willReturn($internal); + (new PortalTaskReminderListener(delivery: $this->delivery, tasks: $tasks, logger: new NullLogger())) + ->handle(new FakeTimerFiredEvent('rung', 'preBreach:1:hours', [['type' => 'user', 'id' => 'alice', 'role' => 'handler']], $this->timer())); + + $closed = $this->externalTask(); + $closed->setState(Task::STATE_COMPLETED); + $tasks = $this->createMock(TaskService::class); + $tasks->method('get')->willReturn($closed); + (new PortalTaskReminderListener(delivery: $this->delivery, tasks: $tasks, logger: new NullLogger())) + ->handle(new FakeTimerFiredEvent('rung', 'preBreach:1:hours', [$this->partyRecipient()], $this->timer())); + + $this->addToAssertionCount(1); + }//end testNothingElseReachesTheSeam() + + /** + * A seam failure is logged and swallowed: the rung has fired regardless. + * + * @return void + */ + public function testASeamFailureIsSwallowed(): void { + $this->tasks->method('get')->willReturn($this->externalTask()); + $this->delivery->method('request')->willThrowException(new \RuntimeException('db gone')); + + $this->listener->handle(new FakeTimerFiredEvent('rung', 'preBreach:1:hours', [$this->partyRecipient()], $this->timer())); + $this->addToAssertionCount(1); + }//end testASeamFailureIsSwallowed() +}//end class diff --git a/tests/Unit/Service/Flow/PortalTaskNodeTest.php b/tests/Unit/Service/Flow/PortalTaskNodeTest.php new file mode 100644 index 0000000000..7d4a9ac8ae --- /dev/null +++ b/tests/Unit/Service/Flow/PortalTaskNodeTest.php @@ -0,0 +1,585 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\PortalPartyNotFoundException; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; +use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowSuspension; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Flow\Nodes\PortalTaskConfig; +use OCA\OpenRegister\Service\Flow\Nodes\PortalTaskNode; +use OCA\OpenRegister\Service\Portal\PortalPartyResolver; +use OCA\OpenRegister\Service\Portal\PortalTaskDeliveryService; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\WorkflowEngine\IManager; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use UnexpectedValueException; + +/** + * Behavioural tests for {@see PortalTaskNode} and {@see PortalTaskConfig}. + * + * @covers \OCA\OpenRegister\Service\Flow\Nodes\PortalTaskNode + * @covers \OCA\OpenRegister\Service\Flow\Nodes\PortalTaskConfig + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Service\Flow\FlowNodeResumeState + * @covers \OCA\OpenRegister\Service\Flow\FlowResumeState + * @covers \OCA\OpenRegister\Service\Flow\FlowSuspension + * @uses \OCA\OpenRegister\Service\Flow\FlowAdvanceBudget + * @uses \OCA\OpenRegister\Service\Flow\FlowItems + * @uses \OCA\OpenRegister\Service\Flow\FlowTaskBridge + * @uses \OCA\OpenRegister\Service\Flow\FlowValueTemplate + * @uses \OCA\OpenRegister\Service\Task\TaskState + * @uses \OCA\OpenRegister\Db\PortalTaskDelivery + */ +class PortalTaskNodeTest extends TestCase { + + /** + * The bridge, mocked. + * + * @var FlowTaskBridge&MockObject + */ + private FlowTaskBridge&MockObject $bridge; + + /** + * The party resolver, mocked. + * + * @var PortalPartyResolver&MockObject + */ + private PortalPartyResolver&MockObject $parties; + + /** + * The delivery seam, mocked. + * + * @var PortalTaskDeliveryService&MockObject + */ + private PortalTaskDeliveryService&MockObject $delivery; + + /** + * The node under test. + * + * @var PortalTaskNode + */ + private PortalTaskNode $node; + + /** + * Build the node over mocked collaborators. + * + * @return void + */ + protected function setUp(): void { + $this->bridge = $this->createMock(FlowTaskBridge::class); + $this->parties = $this->createMock(PortalPartyResolver::class); + $this->delivery = $this->createMock(PortalTaskDeliveryService::class); + $this->delivery->method('messageFor')->willReturn(['title' => 'x']); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static function (string $text, array $parameters = []): string { + if ($parameters === []) { + return $text; + } + + return vsprintf($text, $parameters); + } + ); + + $this->node = new PortalTaskNode($this->bridge, $this->parties, $this->delivery, $l10n, $this->createMock(IURLGenerator::class)); + }//end setUp() + + /** + * A step configuration with overrides. + * + * @param array $overrides Overrides. + * + * @return array The config. + */ + private function config(array $overrides = []): array { + return array_merge(['title' => 'Send the missing {{ document }}', 'uploadRequired' => true], $overrides); + }//end config() + + /** + * A run context carrying a resume state for one node. + * + * @param FlowResumeState $state The run's slots. + * @param string $nodeId The node. + * @param array $extra Extra context. + * + * @return array The context. + */ + private function context(FlowResumeState $state, string $nodeId = 'ask', array $extra = []): array { + return array_merge( + [ + FlowResumeState::CONTEXT_KEY => $state, + FlowNodeResumeState::CONTEXT_KEY => $state->forNode(nodeId: $nodeId), + FlowRunContext::CONTEXT_RUN => 'run-1', + 'runUuid' => 'run-1', + 'runAs' => 'caseworker', + ], + $extra + ); + }//end context() + + /** + * One item about a case object. + * + * @param array $extra Extra json fields. + * + * @return array The items. + */ + private function items(array $extra = []): array { + return [ + FlowItems::item( + json: array_merge(['document' => 'payslip', '@self' => ['uuid' => 'case-7', 'register' => 3, 'schema' => 9]], $extra) + ), + ]; + }//end items() + + /** + * An external task in a state. + * + * @param string $state The state. + * @param string $uuid The uuid. + * @param array $metadata Metadata overrides. + * + * @return Task The task. + */ + private function task(string $state, string $uuid = 't-1', array $metadata = []): Task { + $task = new Task(); + $task->setUuid($uuid); + $task->setState($state); + $task->setIsTerminal(in_array($state, Task::TERMINAL_STATES, true)); + $task->setPerformerType(Task::PERFORMER_EXTERNAL); + $task->setAssignee('party:bsn-1'); + $task->setRunUuid('run-1'); + $task->setNodeId('ask'); + $task->setMetadata(array_merge(['cycle' => 1], $metadata)); + + return $task; + }//end task() + + // ---- Catalogue --------------------------------------------------------- + + /** + * The palette entry: id, the three-waiter description, keys and form agree. + * + * @return void + */ + public function testThePaletteEntryStatesTheThreeWaiterDivision(): void { + $this->assertSame('openregister.portal-task', $this->node->getId()); + $this->assertStringContainsString('Ask a person', $this->node->getDescription()); + $this->assertStringContainsString('Wait for an answer', $this->node->getDescription()); + $this->assertTrue($this->node->isAvailableForScope(IManager::SCOPE_USER)); + + $formKeys = array_map(static fn (array $field): string => $field['key'], $this->node->configForm()); + foreach ($this->node->configKeys() as $key) { + $this->assertContains($key, $formKeys, "config key $key has a form field"); + } + + foreach (['partyRole', 'uploadRequired', 'uploadMaxFiles', 'uploadAcceptedTypes', 'uploadMaxSizeMb', 'reasonField', 'advance'] as $key) { + $this->assertContains($key, $this->node->configKeys()); + } + }//end testThePaletteEntryStatesTheThreeWaiterDivision() + + /** + * Validation refuses what would bury a mistake in a suspended run. + * + * @return void + */ + public function testValidationRefusesNoTitleABlankRoleAndANullBudget(): void { + $this->node->validateConfig($this->config()); + $this->node->validateConfig($this->config(['partyRole' => 'applicant', 'uploadMaxFiles' => 3, 'advance' => 'all'])); + + foreach ([['title' => ''], ['partyRole' => ' '], ['uploadMaxFiles' => 'many'], ['uploadMaxFiles' => 0], ['advance' => null]] as $bad) { + try { + $this->node->validateConfig($this->config($bad)); + $this->fail('Expected refusal for ' . json_encode($bad)); + } catch (UnexpectedValueException) { + $this->addToAssertionCount(1); + } + } + }//end testValidationRefusesNoTitleABlankRoleAndANullBudget() + + // ---- First ask --------------------------------------------------------- + + /** + * The first firing: match the initiator, create ONE external task + * assigned to the frozen party, record the match, request delivery, and + * suspend with a non-null heartbeat. + * + * @return void + */ + public function testTheFirstFiringMatchesCreatesDeliversAndSuspends(): void { + $state = new FlowResumeState(); + $this->parties->expects($this->once()) + ->method('resolveFromObject') + ->with('case-7', 'initiator') + ->willReturn('party:bsn-1'); + + $created = $this->task(state: Task::STATE_ACTIVE); + $this->bridge->expects($this->once()) + ->method('createTask') + ->with( + $this->callback(function (array $data): bool { + $this->assertSame('Send the missing payslip', $data['title']); + $this->assertSame(Task::PERFORMER_EXTERNAL, $data['performerType']); + $this->assertSame('party:bsn-1', $data['assignee'], 'the frozen match is the assignee'); + $this->assertSame(Task::STATE_ACTIVE, $data['state'], 'an external task is always assigned at creation'); + $this->assertSame('case-7', $data['objectUuid']); + $this->assertSame(3, $data['registerId']); + $this->assertSame('initiator', $data['metadata']['partyRole']); + $this->assertSame('party:bsn-1', $data['metadata']['partyReference']); + $this->assertSame(1, $data['metadata']['cycle']); + $this->assertNull($data['metadata']['previousTaskUuid']); + $this->assertTrue($data['metadata']['upload']['required']); + $this->assertSame('portalTask', $data['metadata']['outcomeKey']); + $this->assertArrayNotHasKey('candidateUsers', $data, 'no pool for an external task'); + + return true; + }), + 'run-1', + 'ask', + 'caseworker' + ) + ->willReturn($created); + + $this->bridge->expects($this->once()) + ->method('record') + ->with('t-1', 'match', 'caseworker', $this->stringContains("role 'initiator' on case 'case-7' to 'party:bsn-1'")); + + $this->delivery->expects($this->once()) + ->method('request') + ->with($created, PortalTaskDelivery::KIND_ASK, $this->isType('array')); + + try { + $this->node->execute($this->items(), $this->config(), $this->context($state)); + $this->fail('Expected the node to suspend.'); + } catch (FlowSuspension $suspension) { + $this->assertNotNull($suspension->getResumeAt(), 'a null resumeAt is the one shape the 14-day reaper fails'); + $this->assertGreaterThan(new DateTime(), $suspension->getResumeAt()); + $this->assertStringContainsString('outside the organisation', $suspension->getMessage()); + } + + $slot = $state->read(nodeId: 'ask'); + $this->assertSame('t-1', $slot[FlowTaskBridge::SLOT_TASK_UUID]); + $this->assertSame('party:bsn-1', $slot['assignee'], 'the resume door compares against the party reference, which no uid can equal'); + $this->assertSame(1, $slot[PortalTaskConfig::SLOT_CYCLE]); + $this->assertNull($slot[PortalTaskConfig::SLOT_PASSED_AT]); + }//end testTheFirstFiringMatchesCreatesDeliversAndSuspends() + + /** + * A configured role other than the default is what is matched. + * + * @return void + */ + public function testTheConfiguredPartyRoleIsMatched(): void { + $this->parties->expects($this->once())->method('resolveFromObject')->with('case-7', 'applicant')->willReturn('party:kvk-9'); + $this->bridge->method('createTask')->willReturn($this->task(state: Task::STATE_ACTIVE)); + + $this->expectException(FlowSuspension::class); + $this->node->execute($this->items(), $this->config(['partyRole' => 'applicant']), $this->context(new FlowResumeState())); + }//end testTheConfiguredPartyRoleIsMatched() + + /** + * A case naming nobody fails the firing loudly and creates no task. + * + * @return void + */ + public function testACaseWithNoPartyForTheRoleFailsTheFiringAndCreatesNothing(): void { + $this->parties->method('resolveFromObject') + ->willThrowException(new PortalPartyNotFoundException("Case 'case-7' names no party for role 'initiator'; the portal task cannot be addressed.")); + $this->bridge->expects($this->never())->method('createTask'); + $this->delivery->expects($this->never())->method('request'); + + $this->expectException(PortalPartyNotFoundException::class); + $this->expectExceptionMessageMatches("/role 'initiator'/"); + $this->node->execute($this->items(), $this->config(), $this->context(new FlowResumeState())); + }//end testACaseWithNoPartyForTheRoleFailsTheFiringAndCreatesNothing() + + /** + * An item that is about no case object cannot be matched: loud failure. + * + * @return void + */ + public function testAnItemWithoutACaseObjectFailsTheFiring(): void { + $this->bridge->expects($this->never())->method('createTask'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/no case to match/'); + $this->node->execute([FlowItems::item(json: ['document' => 'x'])], $this->config(), $this->context(new FlowResumeState())); + }//end testAnItemWithoutACaseObjectFailsTheFiring() + + /** + * An empty branch creates nothing and does not suspend. + * + * @return void + */ + public function testAnEmptyBranchCreatesNothingAndDoesNotSuspend(): void { + $this->bridge->expects($this->never())->method('createTask'); + $this->parties->expects($this->never())->method('resolveFromObject'); + + $this->assertSame([], $this->node->execute([], $this->config(), $this->context(new FlowResumeState()))); + }//end testAnEmptyBranchCreatesNothingAndDoesNotSuspend() + + /** + * Without a resume slot the node refuses to run at all. + * + * @return void + */ + public function testANodeWithoutAResumeSlotRefusesToRun(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/resume slot/'); + $this->node->execute($this->items(), $this->config(), [FlowRunContext::CONTEXT_RUN => 'run-1']); + }//end testANodeWithoutAResumeSlotRefusesToRun() + + /** + * A delivery seam that records nothing still leaves the task and the + * suspension standing: the ask outlives a delivery outage. + * + * @return void + */ + public function testAFailedDeliveryRequestLeavesTheAskStanding(): void { + $state = new FlowResumeState(); + $this->parties->method('resolveFromObject')->willReturn('party:bsn-1'); + $this->bridge->method('createTask')->willReturn($this->task(state: Task::STATE_ACTIVE)); + // The seam's contract: never throws, returns what it could write. + $this->delivery->expects($this->once())->method('request')->willReturn([]); + + try { + $this->node->execute($this->items(), $this->config(), $this->context($state)); + $this->fail('Expected the node to suspend.'); + } catch (FlowSuspension $suspension) { + $this->assertNotNull($suspension->getResumeAt()); + } + + $this->assertSame('t-1', $state->read(nodeId: 'ask')[FlowTaskBridge::SLOT_TASK_UUID], 'the slot holds the task despite the delivery failure'); + }//end testAFailedDeliveryRequestLeavesTheAskStanding() + + // ---- Heartbeat and continuation --------------------------------------- + + /** + * A heartbeat wake over an open task asks nothing new and suspends again + * without restamping askedAt. + * + * @return void + */ + public function testAHeartbeatWakeDoesNotAskTwice(): void { + $state = new FlowResumeState(); + $state->write(nodeId: 'ask', values: [FlowTaskBridge::SLOT_TASK_UUID => 't-1', FlowTaskBridge::SLOT_ASKED_AT => '2026-01-01T00:00:00+00:00', PortalTaskConfig::SLOT_CYCLE => 1]); + $this->bridge->method('taskOrNull')->with('t-1')->willReturn($this->task(state: Task::STATE_ACTIVE)); + $this->bridge->expects($this->never())->method('createTask'); + $this->parties->expects($this->never())->method('resolveFromObject'); + $this->delivery->expects($this->never())->method('request'); + + try { + $this->node->execute($this->items(), $this->config(), $this->context($state)); + $this->fail('Expected the node to suspend again.'); + } catch (FlowSuspension $suspension) { + $this->assertNotNull($suspension->getResumeAt()); + } + + $this->assertSame('2026-01-01T00:00:00+00:00', $state->read(nodeId: 'ask')[FlowTaskBridge::SLOT_ASKED_AT], 'askedAt is written once'); + }//end testAHeartbeatWakeDoesNotAskTwice() + + /** + * A task that vanished fails the step rather than waiting forever. + * + * @return void + */ + public function testAVanishedTaskFailsTheStep(): void { + $state = new FlowResumeState(); + $state->write(nodeId: 'ask', values: [FlowTaskBridge::SLOT_TASK_UUID => 'gone']); + $this->bridge->method('taskOrNull')->willReturn(null); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/no longer exists/'); + $this->node->execute($this->items(), $this->config(), $this->context($state)); + }//end testAVanishedTaskFailsTheStep() + + /** + * Completion places the answer bag on EVERY item under the configured key, + * with answers, files, party and cycle, and marks the slot passed ONCE. + * + * @return void + */ + public function testACompletedTaskPlacesTheAnswerOnEveryItemAndMarksThePass(): void { + $state = new FlowResumeState(); + $state->write(nodeId: 'ask', values: [FlowTaskBridge::SLOT_TASK_UUID => 't-1', PortalTaskConfig::SLOT_CYCLE => 1]); + $task = $this->task(state: Task::STATE_COMPLETED); + $task->setOutcome('submitted'); + $task->setCompletedBy('party:bsn-1'); + $task->setResponses(['remarks' => 'here you go']); + $task->setEvidence([['fileId' => 42, 'name' => 'payslip.pdf']]); + $this->bridge->method('taskOrNull')->willReturn($task); + + $items = [ + FlowItems::item(json: ['n' => 1, '@self' => ['uuid' => 'case-7']]), + FlowItems::item(json: ['n' => 2, '@self' => ['uuid' => 'case-7']]), + 'not-an-item', + ]; + $out = $this->node->execute($items, $this->config(['outcomeKey' => 'answer']), $this->context($state)); + + $this->assertCount(3, $out); + foreach ([0, 1] as $index) { + $bag = $out[$index][FlowItems::JSON]['answer']; + $this->assertTrue($bag['decided']); + $this->assertFalse($bag['expired']); + $this->assertSame('submitted', $bag['outcome']); + $this->assertSame(['remarks' => 'here you go'], $bag['answers']); + $this->assertSame(42, $bag['files'][0]['fileId']); + $this->assertSame('party:bsn-1', $bag['party']); + $this->assertSame(1, $bag['cycle']); + } + + $this->assertSame('not-an-item', $out[2], 'a non-array item is left alone'); + $this->assertNotNull($state->read(nodeId: 'ask')[PortalTaskConfig::SLOT_PASSED_AT], 'the pass is marked so the next firing is a re-entry'); + }//end testACompletedTaskPlacesTheAnswerOnEveryItemAndMarksThePass() + + /** + * An expiry-terminated task continues the run distinguishably from an answer. + * + * @return void + */ + public function testAnExpiredAskIsNotAnAnswer(): void { + $state = new FlowResumeState(); + $state->write(nodeId: 'ask', values: [FlowTaskBridge::SLOT_TASK_UUID => 't-1']); + $task = $this->task(state: Task::STATE_TERMINATED); + $task->setOutcome('expired'); + $this->bridge->method('taskOrNull')->willReturn($task); + + $out = $this->node->execute($this->items(), $this->config(), $this->context($state)); + $bag = $out[0][FlowItems::JSON]['portalTask']; + $this->assertFalse($bag['decided']); + $this->assertTrue($bag['expired']); + $this->assertSame('expired', $bag['outcome']); + $this->assertSame([], $bag['files']); + }//end testAnExpiredAskIsNotAnAnswer() + + // ---- Re-ask ------------------------------------------------------------ + + /** + * Re-entry with a reason creates a NEW task: fresh match, reason, cycle 2, + * the previous uuid, delivered as a re-ask; the slot moves to the new task. + * + * @return void + */ + public function testReEntryWithAReasonCreatesANewTaskCarryingCycleTwoAndThePreviousUuid(): void { + $state = new FlowResumeState(); + $state->write( + nodeId: 'ask', + values: [FlowTaskBridge::SLOT_TASK_UUID => 't-1', PortalTaskConfig::SLOT_CYCLE => 1, PortalTaskConfig::SLOT_PASSED_AT => '2026-01-02T00:00:00+00:00'] + ); + $first = $this->task(state: Task::STATE_COMPLETED); + $this->bridge->method('taskOrNull')->with('t-1')->willReturn($first); + $this->parties->expects($this->once())->method('resolveFromObject')->with('case-7', 'initiator')->willReturn('party:bsn-1'); + + $second = $this->task(state: Task::STATE_ACTIVE, uuid: 't-2'); + $this->bridge->expects($this->once()) + ->method('createTask') + ->with( + $this->callback(function (array $data): bool { + $this->assertSame(2, $data['metadata']['cycle']); + $this->assertSame('t-1', $data['metadata']['previousTaskUuid']); + $this->assertSame('The scan is unreadable', $data['metadata']['reaskReason']); + + return true; + }), + 'run-1', + 'ask', + 'caseworker' + ) + ->willReturn($second); + $this->delivery->expects($this->once())->method('request')->with($second, PortalTaskDelivery::KIND_RE_ASK, $this->isType('array')); + + $items = $this->items(['review' => ['outcome' => 'rejected', 'comment' => 'The scan is unreadable']]); + try { + $this->node->execute($items, $this->config(['reasonField' => 'review.comment']), $this->context($state)); + $this->fail('Expected the re-ask to suspend.'); + } catch (FlowSuspension $suspension) { + $this->assertNotNull($suspension->getResumeAt()); + } + + $slot = $state->read(nodeId: 'ask'); + $this->assertSame('t-2', $slot[FlowTaskBridge::SLOT_TASK_UUID]); + $this->assertSame(2, $slot[PortalTaskConfig::SLOT_CYCLE]); + $this->assertSame('t-1', $slot[PortalTaskConfig::SLOT_PREVIOUS_TASK_UUID]); + $this->assertNull($slot[PortalTaskConfig::SLOT_PASSED_AT], 'the new cycle has not passed yet'); + $this->assertSame(Task::STATE_COMPLETED, $first->getState(), 'the first task is untouched'); + }//end testReEntryWithAReasonCreatesANewTaskCarryingCycleTwoAndThePreviousUuid() + + /** + * Re-entry WITHOUT a reason fails the firing naming the field, and asks nobody. + * + * @return void + */ + public function testReEntryWithoutAReasonIsRefusedAndCreatesNothing(): void { + $state = new FlowResumeState(); + $state->write(nodeId: 'ask', values: [FlowTaskBridge::SLOT_TASK_UUID => 't-1', PortalTaskConfig::SLOT_PASSED_AT => '2026-01-02T00:00:00+00:00']); + $this->bridge->method('taskOrNull')->willReturn($this->task(state: Task::STATE_COMPLETED)); + $this->bridge->expects($this->never())->method('createTask'); + $this->parties->expects($this->never())->method('resolveFromObject'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/no reason under "review\.comment"/'); + $this->node->execute($this->items(), $this->config(['reasonField' => 'review.comment']), $this->context($state)); + }//end testReEntryWithoutAReasonIsRefusedAndCreatesNothing() + + // ---- Config boundary --------------------------------------------------- + + /** + * The upload constraints normalise to the shape the completion validates. + * + * @return void + */ + public function testUploadConstraintsNormalise(): void { + $config = new PortalTaskConfig(l10n: $this->createMock(IL10N::class)); + $this->assertSame( + ['required' => false, 'maxFiles' => 1, 'acceptedTypes' => [], 'maxSizeBytes' => null], + $config->uploadConstraints(config: []) + ); + $this->assertSame( + ['required' => true, 'maxFiles' => 3, 'acceptedTypes' => ['application/pdf', 'image/*'], 'maxSizeBytes' => 2621440], + $config->uploadConstraints(config: ['uploadRequired' => 'true', 'uploadMaxFiles' => '3', 'uploadAcceptedTypes' => 'application/pdf, image/*', 'uploadMaxSizeMb' => 2.5]) + ); + $this->assertSame('initiator', $config->partyRole(config: [])); + $this->assertSame('reason', $config->reasonField(config: [])); + $this->assertSame('portalTask', $config->outcomeKey(config: [])); + $this->assertGreaterThanOrEqual( + (new DateTime())->modify('+4 minutes'), + $config->heartbeatAt(config: ['heartbeatMinutes' => 1]), + 'the heartbeat is clamped to the five-minute floor' + ); + }//end testUploadConstraintsNormalise() +}//end class diff --git a/tests/Unit/Service/Portal/PortalPartyResolverTest.php b/tests/Unit/Service/Portal/PortalPartyResolverTest.php new file mode 100644 index 0000000000..683cc07541 --- /dev/null +++ b/tests/Unit/Service/Portal/PortalPartyResolverTest.php @@ -0,0 +1,146 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Portal; + +use OCA\OpenRegister\Db\AbstractObjectMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Exception\PortalPartyNotFoundException; +use OCA\OpenRegister\Service\Portal\PortalPartyResolver; +use OCA\OpenRegister\Service\Portal\PortalSubject; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * Tests for {@see PortalPartyResolver}. + * + * @covers \OCA\OpenRegister\Service\Portal\PortalPartyResolver + * @covers \OCA\OpenRegister\Service\Portal\PortalSubject + * @covers \OCA\OpenRegister\Exception\PortalPartyNotFoundException + */ +class PortalPartyResolverTest extends TestCase { + + /** + * Case shapes and the reference each resolves to. + * + * @return array, 1: string, 2: string}> + */ + public static function caseShapes(): array { + return [ + 'a scalar field named for the role' => [['initiator' => 'bsn-123'], 'initiator', 'party:bsn-123'], + 'an object field with a subjectRef' => [['initiator' => ['subjectRef' => 'sub-1', 'bsn' => 'ignored']], 'initiator', 'party:sub-1'], + 'an object field falling back to bsn' => [['initiator' => ['name' => 'Jan', 'bsn' => '999']], 'initiator', 'party:999'], + 'a nested identification' => [['initiator' => ['betrokkeneIdentificatie' => ['inpBsn' => 'x', 'bsn' => '555']]], 'initiator', 'party:555'], + 'a ZGW rollen list' => [ + ['rollen' => [['roltype' => 'behandelaar', 'betrokkene' => 'u1'], ['roltype' => 'Initiator', 'betrokkeneIdentificatie' => ['bsn' => '777']]]], + 'initiator', + 'party:777', + ], + 'a parties list with a custom role' => [ + ['parties' => [['role' => 'applicant', 'subjectRef' => 'kvk-1']]], + 'applicant', + 'party:kvk-1', + ], + 'a blank role falls back to initiator' => [['initiator' => 'a'], '', 'party:a'], + ]; + }//end caseShapes() + + /** + * Each shape resolves to the expected party reference. + * + * @param array $case The case data. + * @param string $role The role. + * @param string $expected The party reference. + * + * @return void + */ + #[DataProvider('caseShapes')] + public function testTheCaseShapesResolve(array $case, string $role, string $expected): void { + $this->assertSame($expected, (new PortalPartyResolver())->resolve(case: $case, role: $role, caseUuid: 'c-1')); + }//end testTheCaseShapesResolve() + + /** + * A case naming nobody for the role refuses, naming the role and the case. + * + * @return void + */ + public function testACaseNamingNobodyRefusesNamingRoleAndCase(): void { + $resolver = new PortalPartyResolver(); + foreach ([[], ['initiator' => ''], ['initiator' => ['name' => 'nobody']], ['rollen' => [['roltype' => 'behandelaar', 'bsn' => '1']]]] as $case) { + try { + $resolver->resolve(case: $case, role: 'initiator', caseUuid: 'case-7'); + $this->fail('Expected a refusal for ' . json_encode($case)); + } catch (PortalPartyNotFoundException $refused) { + $this->assertStringContainsString("role 'initiator'", $refused->getMessage()); + $this->assertStringContainsString("'case-7'", $refused->getMessage()); + } + } + }//end testACaseNamingNobodyRefusesNamingRoleAndCase() + + /** + * Resolution by uuid reads the case through the object store, without + * the session's RBAC (the node runs in a background worker). + * + * @return void + */ + public function testResolutionByUuidReadsTheCaseFromTheStore(): void { + $object = $this->createMock(ObjectEntity::class); + $object->method('getObject')->willReturn(['initiator' => ['subjectRef' => 'sub-9']]); + $objects = $this->createMock(AbstractObjectMapper::class); + $objects->expects($this->once()) + ->method('find') + ->with('case-7', null, null, false, false, false) + ->willReturn($object); + + $this->assertSame('party:sub-9', (new PortalPartyResolver(objects: $objects))->resolveFromObject(objectUuid: 'case-7', role: 'initiator')); + }//end testResolutionByUuidReadsTheCaseFromTheStore() + + /** + * An unreadable case and an absent store both refuse rather than match nobody quietly. + * + * @return void + */ + public function testAnUnreadableCaseOrAbsentStoreRefuses(): void { + $objects = $this->createMock(AbstractObjectMapper::class); + $objects->method('find')->willThrowException(new DoesNotExistException('gone')); + try { + (new PortalPartyResolver(objects: $objects))->resolveFromObject(objectUuid: 'case-7', role: 'initiator'); + $this->fail('Expected a refusal.'); + } catch (PortalPartyNotFoundException $refused) { + $this->assertStringContainsString('could not be read', $refused->getMessage()); + } + + $this->expectException(PortalPartyNotFoundException::class); + $this->expectExceptionMessageMatches('/no object store/'); + (new PortalPartyResolver())->resolveFromObject(objectUuid: 'case-7', role: 'initiator'); + }//end testAnUnreadableCaseOrAbsentStoreRefuses() + + /** + * The subject value: its party reference and actor are the prefixed subjectRef. + * + * @return void + */ + public function testTheSubjectValueBuildsThePartyReference(): void { + $subject = new PortalSubject(subjectRef: 'sub-1', audience: 'client', organisation: 'org', trust: 'substantial', jti: 'j'); + $this->assertSame('party:sub-1', $subject->partyReference()); + $this->assertSame('party:sub-1', $subject->actor()); + $this->assertSame('party:sub-1', PortalSubject::partyReferenceFor(reference: ' sub-1 ')); + }//end testTheSubjectValueBuildsThePartyReference() +}//end class diff --git a/tests/Unit/Service/Portal/PortalSubjectAssertionTest.php b/tests/Unit/Service/Portal/PortalSubjectAssertionTest.php new file mode 100644 index 0000000000..a9c21ed2d2 --- /dev/null +++ b/tests/Unit/Service/Portal/PortalSubjectAssertionTest.php @@ -0,0 +1,180 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Portal; + +use OCA\OpenRegister\Exception\PortalSubjectException; +use OCA\OpenRegister\Service\Portal\PortalSubjectAssertion; +use OCP\IConfig; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; + +/** + * Tests for {@see PortalSubjectAssertion}. + * + * @covers \OCA\OpenRegister\Service\Portal\PortalSubjectAssertion + * @covers \OCA\OpenRegister\Service\Portal\PortalSubject + * @covers \OCA\OpenRegister\Exception\PortalSubjectException + */ +class PortalSubjectAssertionTest extends TestCase { + + /** + * The shared secret the tests sign with. + * + * @var string + */ + private const SECRET = 'a-secret-of-at-least-sixteen-characters'; + + /** + * A verifier over a config with the given app values. + * + * @param string $own OpenRegister's own secret. + * @param string $portaliq Portaliq's secret. + * + * @return PortalSubjectAssertion The verifier. + */ + private function verifier(string $own = '', string $portaliq = self::SECRET): PortalSubjectAssertion { + $config = $this->createMock(IConfig::class); + $config->method('getAppValue')->willReturnCallback( + static function (string $app, string $key, string $default = '') use ($own, $portaliq): string { + if ($app === 'openregister' && $key === 'portal_assertion_secret') { + return $own; + } + + if ($app === 'portaliq' && $key === 'jwt_signing_secret') { + return $portaliq; + } + + return $default; + } + ); + + return new PortalSubjectAssertion(config: $config); + }//end verifier() + + /** + * Mint an assertion the way portaliq does. + * + * @param array $claims Claim overrides. + * @param string $secret The signing secret. + * @param array $header Header overrides. + * + * @return string The compact JWT. + */ + private function token(array $claims = [], string $secret = self::SECRET, array $header = []): string { + $header = array_merge(['alg' => 'HS256', 'typ' => 'JWT'], $header); + $claims = array_merge( + ['sub' => 'sub-1', 'audience' => 'client', 'organisation' => 'org-1', 'trust' => 'substantial', 'jti' => 'sess-1', 'use' => 'assertion', 'iat' => time(), 'exp' => (time() + 60), 'iss' => 'portaliq'], + $claims + ); + $encode = static fn (string $bytes): string => rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + $h = $encode((string)json_encode($header)); + $c = $encode((string)json_encode($claims)); + + return $h . '.' . $c . '.' . $encode(hash_hmac('sha256', $h . '.' . $c, $secret, true)); + }//end token() + + /** + * A request carrying the header. + * + * @param string $token The header value. + * + * @return IRequest The request. + */ + private function request(string $token): IRequest { + $request = $this->createMock(IRequest::class); + $request->method('getHeader')->with('X-Portal-Subject')->willReturn($token); + + return $request; + }//end request() + + /** + * A valid assertion resolves to the subject it names. + * + * @return void + */ + public function testAValidAssertionResolvesTheSubject(): void { + $subject = $this->verifier()->resolve(request: $this->request($this->token())); + $this->assertSame('sub-1', $subject->subjectRef); + $this->assertSame('client', $subject->audience); + $this->assertSame('org-1', $subject->organisation); + $this->assertSame('substantial', $subject->trust); + $this->assertSame('sess-1', $subject->jti); + $this->assertSame('party:sub-1', $subject->partyReference()); + }//end testAValidAssertionResolvesTheSubject() + + /** + * OpenRegister's own secret wins over portaliq's when both are set. + * + * @return void + */ + public function testOpenRegistersOwnSecretIsConsultedFirst(): void { + $verifier = $this->verifier(own: 'own-secret-sixteen-chars!', portaliq: self::SECRET); + $this->assertSame('sub-1', $verifier->fromToken(token: $this->token(secret: 'own-secret-sixteen-chars!'))->subjectRef); + + $this->expectException(PortalSubjectException::class); + $verifier->fromToken(token: $this->token(secret: self::SECRET)); + }//end testOpenRegistersOwnSecretIsConsultedFirst() + + /** + * Every defect denies with its code: missing header, no secret, bad + * signature, wrong algorithm, wrong issuer, a session token, expiry, no + * subject, malformed. + * + * @return void + */ + public function testEveryDefectDenies(): void { + $verifier = $this->verifier(); + $cases = [ + [PortalSubjectException::CODE_MISSING, static fn (): string => ''], + [PortalSubjectException::CODE_INVALID, fn (): string => $this->token(secret: 'the-wrong-secret-entirely')], + [PortalSubjectException::CODE_INVALID, fn (): string => $this->token(header: ['alg' => 'none'])], + [PortalSubjectException::CODE_INVALID, fn (): string => $this->token(claims: ['iss' => 'somebody'])], + [PortalSubjectException::CODE_INVALID, fn (): string => $this->token(claims: ['use' => 'session'])], + [PortalSubjectException::CODE_EXPIRED, fn (): string => $this->token(claims: ['exp' => (time() - 1)])], + [PortalSubjectException::CODE_EXPIRED, fn (): string => $this->token(claims: ['exp' => null])], + [PortalSubjectException::CODE_INVALID, fn (): string => $this->token(claims: ['sub' => ' '])], + [PortalSubjectException::CODE_INVALID, static fn (): string => 'not.a-jwt'], + [PortalSubjectException::CODE_INVALID, static fn (): string => 'a.b.c'], + ]; + + foreach ($cases as [$code, $make]) { + try { + $verifier->resolve(request: $this->request($make())); + $this->fail('Expected refusal ' . $code); + } catch (PortalSubjectException $refused) { + $this->assertSame($code, $refused->refusal(), $refused->getMessage()); + } + } + }//end testEveryDefectDenies() + + /** + * With no secret anywhere, a perfectly formed assertion is still refused. + * + * @return void + */ + public function testAnUnconfiguredVerifierRefusesEverything(): void { + try { + $this->verifier(own: '', portaliq: '')->fromToken(token: $this->token()); + $this->fail('An unconfigured verifier admitted an assertion.'); + } catch (PortalSubjectException $refused) { + $this->assertSame(PortalSubjectException::CODE_UNCONFIGURED, $refused->refusal()); + } + }//end testAnUnconfiguredVerifierRefusesEverything() +}//end class diff --git a/tests/Unit/Service/Portal/PortalTaskDeliveryServiceTest.php b/tests/Unit/Service/Portal/PortalTaskDeliveryServiceTest.php new file mode 100644 index 0000000000..5b4ee17c15 --- /dev/null +++ b/tests/Unit/Service/Portal/PortalTaskDeliveryServiceTest.php @@ -0,0 +1,233 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Portal; + +use DateTime; +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\PortalTaskDeliveryMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Portal\PortalTaskDeliveryService; +use OCP\AppFramework\Db\Entity; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use ReflectionClass; +use RuntimeException; + +/** + * Tests for {@see PortalTaskDeliveryService} and {@see PortalTaskDelivery}. + * + * @covers \OCA\OpenRegister\Service\Portal\PortalTaskDeliveryService + * @covers \OCA\OpenRegister\Db\PortalTaskDelivery + * @uses \OCA\OpenRegister\Db\Task + */ +class PortalTaskDeliveryServiceTest extends TestCase { + + /** + * The mapper, mocked. + * + * @var PortalTaskDeliveryMapper&MockObject + */ + private PortalTaskDeliveryMapper&MockObject $deliveries; + + /** + * The service under test. + * + * @var PortalTaskDeliveryService + */ + private PortalTaskDeliveryService $service; + + /** + * Build the service. + * + * @return void + */ + protected function setUp(): void { + $this->deliveries = $this->createMock(PortalTaskDeliveryMapper::class); + $this->service = new PortalTaskDeliveryService(deliveries: $this->deliveries, logger: new NullLogger()); + }//end setUp() + + /** + * An external task. + * + * @return Task The task. + */ + private function task(): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setTitle('Send the payslip'); + $task->setPerformerType(Task::PERFORMER_EXTERNAL); + $task->setAssignee('party:bsn-1'); + $task->setObjectUuid('case-7'); + $task->setRegisterId(3); + $task->setMetadata(['cycle' => 2, 'upload' => ['required' => true]]); + + return $task; + }//end task() + + /** + * A delivery row. + * + * @param string $channel The channel. + * @param string $state The state. + * @param string $requestedAt When. + * + * @return PortalTaskDelivery The row. + */ + private function row(string $channel, string $state, string $requestedAt = '2026-09-01T10:00:00+00:00'): PortalTaskDelivery { + $row = new PortalTaskDelivery(); + $row->setChannel($channel); + $row->setState($state); + $row->setKind(PortalTaskDelivery::KIND_ASK); + $row->setRequestedAt(new DateTime($requestedAt)); + if ($state === PortalTaskDelivery::STATE_DELIVERED) { + $row->setDeliveredAt(new DateTime($requestedAt)); + } + + return $row; + }//end row() + + /** + * A request writes one row per channel: portal inbox and mail, both + * `requested`, carrying the message and the party. + * + * @return void + */ + public function testARequestWritesOneRowPerChannel(): void { + $written = []; + $this->deliveries->expects($this->exactly(2)) + ->method('insert') + ->willReturnCallback( + static function (Entity $row) use (&$written): PortalTaskDelivery { + $written[] = $row; + + return $row; + } + ); + + $message = $this->service->messageFor(task: $this->task(), reason: 'unreadable scan'); + $rows = $this->service->request(task: $this->task(), kind: PortalTaskDelivery::KIND_RE_ASK, message: $message); + + $this->assertCount(2, $rows); + $this->assertSame([PortalTaskDelivery::CHANNEL_PORTAL_INBOX, PortalTaskDelivery::CHANNEL_MAIL], array_map(static fn (PortalTaskDelivery $r): string => (string)$r->getChannel(), $written)); + foreach ($written as $row) { + $this->assertSame('t-1', $row->getTaskUuid()); + $this->assertSame('party:bsn-1', $row->getPartyReference()); + $this->assertSame(PortalTaskDelivery::KIND_RE_ASK, $row->getKind()); + $this->assertSame('unreadable scan', $row->getMessage()['reason']); + $this->assertSame(2, $row->getMessage()['cycle']); + $this->assertSame('case-7', $row->getMessage()['case']['uuid']); + } + }//end testARequestWritesOneRowPerChannel() + + /** + * A recording failure does not throw: the ask stands, and the rows that + * could be written are returned. + * + * @return void + */ + public function testARecordingFailureNeverThrows(): void { + $this->deliveries->method('insert')->willReturnCallback( + static function (Entity $row): PortalTaskDelivery { + if ($row->getChannel() === PortalTaskDelivery::CHANNEL_MAIL) { + throw new RuntimeException('database gone'); + } + + return $row; + } + ); + + $rows = $this->service->request(task: $this->task(), kind: PortalTaskDelivery::KIND_ASK, message: []); + $this->assertCount(1, $rows); + $this->assertSame(PortalTaskDelivery::CHANNEL_PORTAL_INBOX, $rows[0]->getChannel()); + }//end testARecordingFailureNeverThrows() + + /** + * NO NEXTCLOUD CHANNEL, STRUCTURALLY: the seam depends on nothing that + * could send a notification, a mail or a calendar entry, and its channel + * vocabulary names none. + * + * @return void + */ + public function testTheSeamHasNoNextcloudChannel(): void { + $parameters = (new ReflectionClass(PortalTaskDeliveryService::class))->getConstructor()?->getParameters() ?? []; + foreach ($parameters as $parameter) { + $type = (string)$parameter->getType(); + $this->assertDoesNotMatchRegularExpression('/Notification|Mailer|Calendar|CalDAV|VTODO/i', $type, "constructor parameter $type"); + } + + $this->assertSame(['portal-inbox', 'mail'], PortalTaskDelivery::CHANNELS); + $source = (string)file_get_contents((new ReflectionClass(PortalTaskDeliveryService::class))->getFileName()); + $this->assertStringNotContainsString('INotificationManager', $source); + $this->assertStringNotContainsString('IMailer', $source); + }//end testTheSeamHasNoNextcloudChannel() + + /** + * The summary: no rows is `not-recorded`; requested; delivered once the + * portal inbox went out; failed when any channel failed; and only the + * latest request round counts. + * + * @return void + */ + public function testTheSummaryReadsTheLatestRound(): void { + $this->assertSame(PortalTaskDelivery::STATE_NOT_RECORDED, PortalTaskDelivery::summarise(rows: [])['state']); + + $requested = PortalTaskDelivery::summarise(rows: [$this->row('portal-inbox', 'requested'), $this->row('mail', 'requested')]); + $this->assertSame('requested', $requested['state']); + $this->assertSame(['portal-inbox', 'mail'], array_keys($requested['channels'])); + + $delivered = PortalTaskDelivery::summarise(rows: [$this->row('portal-inbox', 'delivered'), $this->row('mail', 'requested')]); + $this->assertSame('delivered', $delivered['state']); + $this->assertNotNull($delivered['deliveredAt']); + + $failed = PortalTaskDelivery::summarise(rows: [$this->row('portal-inbox', 'delivered'), $this->row('mail', 'failed')]); + $this->assertSame('failed', $failed['state']); + + $reasked = PortalTaskDelivery::summarise( + rows: [ + $this->row('portal-inbox', 'delivered', '2026-09-01T10:00:00+00:00'), + $this->row('portal-inbox', 'requested', '2026-09-02T10:00:00+00:00'), + ] + ); + $this->assertSame('requested', $reasked['state'], 'the re-ask round decides'); + $this->assertSame('2026-09-02T10:00:00+00:00', $reasked['requestedAt']); + }//end testTheSummaryReadsTheLatestRound() + + /** + * Settling: pending, delivered, failed pass through to the mapper. + * + * @return void + */ + public function testSettlingPassesThroughToTheMapper(): void { + $row = $this->row('mail', 'requested'); + $row->setUuid('d-1'); + $this->deliveries->method('findByUuid')->with('d-1')->willReturn($row); + $this->deliveries->method('findPending')->with(10)->willReturn([$row]); + $this->deliveries->method('markDelivered')->with($row)->willReturn($row); + $this->deliveries->method('markFailed')->with($row, 'smtp down')->willReturn($row); + $this->deliveries->method('findForTask')->with('t-1')->willReturn([$row]); + + $this->assertSame([$row], $this->service->pending(limit: 10)); + $this->assertSame($row, $this->service->markDelivered(uuid: 'd-1')); + $this->assertSame($row, $this->service->markFailed(uuid: 'd-1', error: 'smtp down')); + $this->assertSame('requested', $this->service->stateFor(task: $this->task())['state']); + $this->assertSame('d-1', $row->jsonSerialize()['uuid']); + }//end testSettlingPassesThroughToTheMapper() +}//end class diff --git a/tests/Unit/Service/Portal/PortalTaskServiceTest.php b/tests/Unit/Service/Portal/PortalTaskServiceTest.php new file mode 100644 index 0000000000..7273df1ebf --- /dev/null +++ b/tests/Unit/Service/Portal/PortalTaskServiceTest.php @@ -0,0 +1,405 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Portal; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Portal\PortalSubject; +use OCA\OpenRegister\Service\Portal\PortalTaskService; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Files\File; +use OCP\IGroupManager; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Tests for {@see PortalTaskService}. + * + * @covers \OCA\OpenRegister\Service\Portal\PortalTaskService + * @covers \OCA\OpenRegister\Service\Portal\PortalSubject + * @covers \OCA\OpenRegister\Service\Task\TaskAuthorizationService + * @covers \OCA\OpenRegister\Service\Task\TaskInboxService + * @covers \OCA\OpenRegister\Db\Task + * @uses \OCA\OpenRegister\Db\PortalTaskDelivery + * @uses \OCA\OpenRegister\Service\Task\TaskTemporalProjection + */ +class PortalTaskServiceTest extends TestCase { + + /** + * The lifecycle, mocked. + * + * @var TaskService&MockObject + */ + private TaskService&MockObject $tasks; + + /** + * The mapper, mocked. + * + * @var TaskMapper&MockObject + */ + private TaskMapper&MockObject $mapper; + + /** + * The file service, mocked. + * + * @var FileService&MockObject + */ + private FileService&MockObject $files; + + /** + * The service under test. + * + * @var PortalTaskService + */ + private PortalTaskService $service; + + /** + * What happened, in order. + * + * @var array + */ + private array $trace = []; + + /** + * Build the service over mocks; `openFor` runs the REAL authorization + * rule so the party comparison under test is the production one. + * + * @return void + */ + protected function setUp(): void { + $this->tasks = $this->createMock(TaskService::class); + $this->mapper = $this->createMock(TaskMapper::class); + $this->files = $this->createMock(FileService::class); + $inbox = new TaskInboxService(tasks: $this->mapper, temporal: new TaskTemporalProjection(), logger: new NullLogger()); + $this->service = new PortalTaskService( + tasks: $this->tasks, + mapper: $this->mapper, + inbox: $inbox, + temporal: new TaskTemporalProjection(), + files: $this->files, + logger: new NullLogger() + ); + }//end setUp() + + /** + * An open external task matched to `party:bsn-1`, anchored to case-7. + * + * @param array $upload Upload constraint overrides. + * + * @return Task The task. + */ + private function task(array $upload = []): Task { + $task = new Task(); + $task->setId(7); + $task->setUuid('t-1'); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setPerformerType(Task::PERFORMER_EXTERNAL); + $task->setAssignee('party:bsn-1'); + $task->setObjectUuid('case-7'); + $task->setRegisterId(3); + $task->setMetadata(['upload' => array_merge(['required' => true, 'maxFiles' => 1, 'acceptedTypes' => [], 'maxSizeBytes' => null], $upload)]); + + return $task; + }//end task() + + /** + * Wire `openFor` to the real authorization service over the given task, + * tracing the call. + * + * @param Task $task The task the uuid resolves to. + * + * @return void + */ + private function openForRuns(Task $task): void { + $authorization = new TaskAuthorizationService(); + $this->tasks->method('openFor')->willReturnCallback( + function (string $verb, string $uuid, ?string $actor) use ($task, $authorization): Task { + $this->trace[] = 'authorize:' . $actor; + $authorization->assertMay(verb: $verb, task: $task, uid: $actor); + + return $task; + } + ); + }//end openForRuns() + + /** + * A stored file, as the file service returns it. + * + * @return File&MockObject The file. + */ + private function storedFile(): File&MockObject { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn(42); + $file->method('getName')->willReturn('payslip.pdf'); + $file->method('getPath')->willReturn('/case-7/payslip.pdf'); + $file->method('getSize')->willReturn(1200); + $file->method('getMimeType')->willReturn('application/pdf'); + + return $file; + }//end storedFile() + + /** + * The matched party completes with a file: authorize, store on the CASE, + * then record, with the stored reference on the completion. + * + * @return void + */ + public function testTheMatchedPartyStoresOnTheCaseBeforeTheCompletionIsRecorded(): void { + $task = $this->task(); + $this->openForRuns($task); + $this->files->expects($this->once()) + ->method('addFile') + ->with('case-7', 'payslip.pdf', 'PDFBYTES', false, ['portal-task:t-1'], null, null, 3) + ->willReturnCallback( + function (): File { + $this->trace[] = 'store'; + + return $this->storedFile(); + } + ); + $this->tasks->expects($this->once()) + ->method('complete') + ->willReturnCallback( + function (string $uuid, string $outcome, ?string $resultText, ?string $comment, ?string $actor, ?array $responses, ?array $evidence) use ($task): Task { + $this->trace[] = 'record'; + $this->assertSame('t-1', $uuid); + $this->assertSame('submitted', $outcome); + $this->assertSame('party:bsn-1', $actor); + $this->assertSame(['remarks' => 'ok'], $responses); + $this->assertSame(42, $evidence[0]['fileId']); + $this->assertSame('/case-7/payslip.pdf', $evidence[0]['path']); + $task->setState(Task::STATE_COMPLETED); + + return $task; + } + ); + + $completed = $this->service->complete( + subject: new PortalSubject(subjectRef: 'bsn-1'), + uuid: 't-1', + answers: ['remarks' => 'ok'], + comment: null, + files: [['name' => 'payslip.pdf', 'type' => 'application/pdf', 'size' => 1200, 'content' => 'PDFBYTES']] + ); + + $this->assertSame(Task::STATE_COMPLETED, $completed->getState()); + $this->assertSame(['authorize:party:bsn-1', 'store', 'record'], $this->trace, 'authorize, store, THEN record'); + }//end testTheMatchedPartyStoresOnTheCaseBeforeTheCompletionIsRecorded() + + /** + * Another subject who knows the uuid is denied before anything is stored, + * and the task is unchanged. + * + * @return void + */ + public function testAnotherSubjectIsDeniedBeforeAnythingIsStored(): void { + $task = $this->task(); + $this->openForRuns($task); + $this->files->expects($this->never())->method('addFile'); + $this->tasks->expects($this->never())->method('complete'); + + try { + $this->service->complete( + subject: new PortalSubject(subjectRef: 'bsn-2'), + uuid: 't-1', + files: [['name' => 'x.pdf', 'type' => 'application/pdf', 'size' => 1, 'content' => 'x']] + ); + $this->fail('Another subject completed the task.'); + } catch (TaskAccessDeniedException $denied) { + $this->assertStringContainsString('matched portal subject', $denied->getMessage()); + } + + $this->assertSame(Task::STATE_ACTIVE, $task->getState()); + }//end testAnotherSubjectIsDeniedBeforeAnythingIsStored() + + /** + * THE CASE-EDIT REGRESSION: the party is compared to the task's STORED + * reference. The case's initiator has moved to party B; B is still denied + * and A still completes, because nothing re-resolves. + * + * @return void + */ + public function testEditingTheCaseDoesNotMoveTheOpenAsk(): void { + $task = $this->task(['required' => false]); + $this->openForRuns($task); + // No party resolver is consulted at completion at all: the service has + // none, and the mapper/object store is never asked for the case. + $this->mapper->expects($this->never())->method('findByUuid'); + $this->tasks->method('complete')->willReturn($task); + + try { + $this->service->complete(subject: new PortalSubject(subjectRef: 'party-b'), uuid: 't-1'); + $this->fail('Party B completed a task matched to party A.'); + } catch (TaskAccessDeniedException) { + $this->addToAssertionCount(1); + } + + $this->service->complete(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1'); + $this->assertContains('authorize:party:bsn-1', $this->trace); + }//end testEditingTheCaseDoesNotMoveTheOpenAsk() + + /** + * A Nextcloud administrator acting through the seam is denied like any + * other non-party: there is no admin bypass on an external completion. + * + * @return void + */ + public function testAnAdministratorIsDeniedThroughTheSeam(): void { + $task = $this->task(['required' => false]); + // The real rule over a backend that calls EVERYONE an administrator. + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturn(true); + $authorization = new TaskAuthorizationService(groupManager: $groups); + $this->tasks->method('openFor')->willReturnCallback( + static function (string $verb, string $uuid, ?string $actor) use ($task, $authorization): Task { + $authorization->assertMay(verb: $verb, task: $task, uid: $actor); + + return $task; + } + ); + $this->files->expects($this->never())->method('addFile'); + + $this->expectException(TaskAccessDeniedException::class); + $this->service->complete(subject: new PortalSubject(subjectRef: 'admin'), uuid: 't-1'); + }//end testAnAdministratorIsDeniedThroughTheSeam() + + /** + * The upload constraints refuse, naming the constraint, BEFORE any storage: + * required, count, size, type, and a task with no case to land on. + * + * @return void + */ + public function testUploadConstraintsRefuseNamingTheConstraintBeforeAnythingIsStored(): void { + $this->files->expects($this->never())->method('addFile'); + $this->tasks->expects($this->never())->method('complete'); + $subject = new PortalSubject(subjectRef: 'bsn-1'); + $pdf = ['name' => 'a.pdf', 'type' => 'application/pdf', 'size' => 100, 'content' => 'x']; + + $cases = [ + ['uploadRequired', $this->task(), []], + ['uploadMaxFiles', $this->task(['maxFiles' => 1]), [$pdf, $pdf]], + ['uploadMaxSizeMb', $this->task(['maxSizeBytes' => 50]), [$pdf]], + ['uploadAcceptedTypes', $this->task(['acceptedTypes' => ['image/*', 'docx']]), [$pdf]], + ]; + $anchorless = $this->task(['required' => false]); + $anchorless->setObjectUuid(null); + $cases[] = ['no case object', $anchorless, [$pdf]]; + + foreach ($cases as [$constraint, $task, $files]) { + $tasks = $this->createMock(TaskService::class); + $tasks->method('openFor')->willReturn($task); + $service = new PortalTaskService( + tasks: $tasks, + mapper: $this->mapper, + inbox: new TaskInboxService(tasks: $this->mapper, temporal: new TaskTemporalProjection(), logger: new NullLogger()), + temporal: new TaskTemporalProjection(), + files: $this->files, + logger: new NullLogger() + ); + try { + $service->complete(subject: $subject, uuid: 't-1', files: $files); + $this->fail("Expected a refusal naming $constraint"); + } catch (TaskValidationException $refused) { + $this->assertStringContainsString($constraint, $refused->getMessage()); + } + + $this->assertSame(Task::STATE_ACTIVE, $task->getState(), 'the task remains open'); + } + }//end testUploadConstraintsRefuseNamingTheConstraintBeforeAnythingIsStored() + + /** + * Accepted types admit by exact media type, wildcard, or extension. + * + * @return void + */ + public function testAcceptedTypesAdmitByMediaTypeWildcardOrExtension(): void { + $task = $this->task(['acceptedTypes' => ['application/pdf', 'image/*', '.docx'], 'maxFiles' => 5]); + $this->service->assertUploadConstraints( + task: $task, + files: [ + ['name' => 'a.pdf', 'type' => 'application/pdf', 'size' => 1], + ['name' => 'b.png', 'type' => 'image/png', 'size' => 1], + ['name' => 'c.docx', 'type' => 'application/octet-stream', 'size' => 1], + ] + ); + $this->addToAssertionCount(1); + + $this->expectException(TaskValidationException::class); + $this->service->assertUploadConstraints(task: $task, files: [['name' => 'd.exe', 'type' => 'application/x-msdownload', 'size' => 1]]); + }//end testAcceptedTypesAdmitByMediaTypeWildcardOrExtension() + + /** + * The subject-scoped read: the party reference is the query predicate, the + * total is the same predicate's count, and a foreign row never leaves. + * + * @return void + */ + public function testTheListIsScopedToTheSubjectInTheQueryAndTheTotal(): void { + $mine = $this->task(); + $foreign = $this->task(); + $foreign->setUuid('t-9'); + $foreign->setAssignee('party:bsn-2'); + $this->mapper->expects($this->once())->method('findOpenExternalForParty')->with('party:bsn-1', 25, 0)->willReturn([$mine, $foreign]); + $this->mapper->expects($this->once())->method('countOpenExternalForParty')->with('party:bsn-1')->willReturn(1); + + $page = $this->service->listForSubject(subject: new PortalSubject(subjectRef: 'bsn-1')); + $this->assertSame(1, $page['total']); + $this->assertCount(1, $page['results'], 'a row that is not this party\'s never leaves the service'); + $this->assertSame('t-1', $page['results'][0]['uuid']); + $this->assertSame('not-recorded', $page['results'][0]['delivery']['state'], 'an external row carries its delivery state'); + }//end testTheListIsScopedToTheSubjectInTheQueryAndTheTotal() + + /** + * `show` answers absence for a task that is not this subject's or not external. + * + * @return void + */ + public function testShowReadsAForeignOrInternalTaskAsAbsent(): void { + $task = $this->task(); + $this->tasks->method('get')->willReturn($task); + $this->assertSame('t-1', $this->service->show(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1')->getUuid()); + + try { + $this->service->show(subject: new PortalSubject(subjectRef: 'bsn-2'), uuid: 't-1'); + $this->fail('A foreign subject read the task.'); + } catch (DoesNotExistException) { + $this->addToAssertionCount(1); + } + + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('party:bsn-1'); + $this->expectException(DoesNotExistException::class); + $this->service->show(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1'); + }//end testShowReadsAForeignOrInternalTaskAsAbsent() +}//end class diff --git a/tests/Unit/Service/Task/TaskAuthorizationExternalTest.php b/tests/Unit/Service/Task/TaskAuthorizationExternalTest.php new file mode 100644 index 0000000000..915bab653d --- /dev/null +++ b/tests/Unit/Service/Task/TaskAuthorizationExternalTest.php @@ -0,0 +1,189 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCP\IGroupManager; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * Tests for the external branch of {@see TaskAuthorizationService}. + * + * @covers \OCA\OpenRegister\Service\Task\TaskAuthorizationService + * @covers \OCA\OpenRegister\Db\Task + */ +class TaskAuthorizationExternalTest extends TestCase { + + /** + * An external task matched to `party:bsn-1`, requested by `caseworker`. + * + * @return Task The task. + */ + private function externalTask(): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setState(Task::STATE_ACTIVE); + $task->setPerformerType(Task::PERFORMER_EXTERNAL); + $task->setAssignee('party:bsn-1'); + $task->setRequester('caseworker'); + + return $task; + }//end externalTask() + + /** + * A group backend where `root` is the administrator. + * + * @return IGroupManager The backend. + */ + private function adminIsRoot(): IGroupManager { + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturnCallback(static fn (string $uid): bool => $uid === 'root'); + $groups->method('isInGroup')->willReturn(false); + $groups->method('groupExists')->willReturn(false); + + return $groups; + }//end adminIsRoot() + + /** + * The refused verbs, with every kind of caller. + * + * @return array + */ + public static function refusedVerbsAndCallers(): array { + $rows = []; + foreach (['claim', 'unclaim', 'delegate', 'offer', 'assign', 'reassign'] as $verb) { + foreach (['party:bsn-1' => 'the party', 'caseworker' => 'the requester', 'root' => 'an administrator', 'stranger' => 'a stranger'] as $uid => $who) { + $rows["$verb by $who"] = [$verb, $uid]; + } + } + + return $rows; + }//end refusedVerbsAndCallers() + + /** + * Claim, unclaim, delegate, offer, assign and reassign are refused for + * everyone, naming the performer type. + * + * @param string $verb The verb. + * @param string $uid The caller. + * + * @return void + */ + #[DataProvider('refusedVerbsAndCallers')] + public function testPoolingAndMandateVerbsAreRefusedForEveryoneNamingTheType(string $verb, string $uid): void { + $service = new TaskAuthorizationService(groupManager: $this->adminIsRoot()); + try { + $service->assertMay(verb: $verb, task: $this->externalTask(), uid: $uid); + $this->fail("$verb by $uid was admitted on an external task."); + } catch (TaskAccessDeniedException $refused) { + $this->assertStringContainsString("'external'", $refused->getMessage()); + $this->assertStringContainsString("'$verb'", $refused->getMessage()); + } + }//end testPoolingAndMandateVerbsAreRefusedForEveryoneNamingTheType() + + /** + * Only the stored party completes: another party, the requester, an + * administrator, a uid equal to the bare reference, and nobody are denied. + * + * @return void + */ + public function testOnlyTheStoredPartyMayAnswer(): void { + $service = new TaskAuthorizationService(groupManager: $this->adminIsRoot()); + $task = $this->externalTask(); + + foreach (['complete', 'resolve', 'checklist'] as $verb) { + $service->assertMay(verb: $verb, task: $task, uid: 'party:bsn-1'); + $this->addToAssertionCount(1); + + foreach (['party:bsn-2', 'caseworker', 'root', 'bsn-1', 'party:', 'PARTY:bsn-1', null, ''] as $uid) { + try { + $service->assertMay(verb: $verb, task: $task, uid: $uid); + $this->fail("$verb by " . var_export($uid, true) . ' was admitted.'); + } catch (TaskAccessDeniedException) { + $this->addToAssertionCount(1); + } + } + } + }//end testOnlyTheStoredPartyMayAnswer() + + /** + * An external task with no stored reference, or one that is not a party + * reference, admits nobody: undeterminable is a denial. + * + * @return void + */ + public function testAnUndeterminableComparisonDenies(): void { + $service = new TaskAuthorizationService(); + foreach ([null, '', 'alice'] as $assignee) { + $task = $this->externalTask(); + $task->setAssignee($assignee); + foreach (['party:alice', 'alice', 'party:'] as $uid) { + try { + $service->assertMay(verb: 'complete', task: $task, uid: $uid); + $this->fail('An undeterminable comparison admitted ' . $uid); + } catch (TaskAccessDeniedException) { + $this->addToAssertionCount(1); + } + } + } + }//end testAnUndeterminableComparisonDenies() + + /** + * Cancel stays the requester's (and the administrator's); the party and a + * stranger cannot withdraw the ask. + * + * @return void + */ + public function testCancelIsTheRequestersOrAnAdministrators(): void { + $service = new TaskAuthorizationService(groupManager: $this->adminIsRoot()); + $task = $this->externalTask(); + $service->assertMay(verb: 'cancel', task: $task, uid: 'caseworker'); + $service->assertMay(verb: 'cancel', task: $task, uid: 'root'); + $this->addToAssertionCount(2); + + foreach (['party:bsn-1', 'stranger'] as $uid) { + try { + $service->assertMay(verb: 'cancel', task: $task, uid: $uid); + $this->fail("cancel by $uid was admitted."); + } catch (TaskAccessDeniedException) { + $this->addToAssertionCount(1); + } + } + + // Creation needs an identity and nothing else; an unknown verb has no rule. + $service->assertMay(verb: 'create', task: $task, uid: 'caseworker'); + $this->expectException(TaskAccessDeniedException::class); + $service->assertMay(verb: 'frobnicate', task: $task, uid: 'caseworker'); + }//end testCancelIsTheRequestersOrAnAdministrators() + + /** + * The vocabulary carries the type and the prefix a uid can never contain. + * + * @return void + */ + public function testTheVocabularyCarriesExternal(): void { + $this->assertContains(Task::PERFORMER_EXTERNAL, Task::PERFORMER_TYPES); + $this->assertSame('party:', Task::EXTERNAL_PARTY_PREFIX); + }//end testTheVocabularyCarriesExternal() +}//end class diff --git a/tests/Unit/Service/Task/TaskAuthorizationServiceTest.php b/tests/Unit/Service/Task/TaskAuthorizationServiceTest.php index b6422b5d1b..64c5283535 100644 --- a/tests/Unit/Service/Task/TaskAuthorizationServiceTest.php +++ b/tests/Unit/Service/Task/TaskAuthorizationServiceTest.php @@ -135,13 +135,15 @@ public function testTheAssigneeMayCompleteWhateverThePerformerType(): void { public function testAnUnknownPerformerTypeDenies(): void { $service = new TaskAuthorizationService(groupManager: $this->emptyGroupBackend()); $task = $this->assignedTask(); - $task->setPerformerType('external'); + // `external` used to be the example here; flow-portal-task admitted it + // to the vocabulary, so the unknown type is now one nothing declares. + $task->setPerformerType('robot'); try { $service->assertMay(verb: 'complete', task: $task, uid: 'alice'); $this->fail('An unknown performer type was admitted.'); } catch (TaskAccessDeniedException $denied) { - $this->assertStringContainsString("'external'", $denied->getMessage()); + $this->assertStringContainsString("'robot'", $denied->getMessage()); } }//end testAnUnknownPerformerTypeDenies() diff --git a/tests/Unit/Service/Task/TaskInboxDeliveryStateTest.php b/tests/Unit/Service/Task/TaskInboxDeliveryStateTest.php new file mode 100644 index 0000000000..9918b68584 --- /dev/null +++ b/tests/Unit/Service/Task/TaskInboxDeliveryStateTest.php @@ -0,0 +1,131 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use DateTime; +use OCA\OpenRegister\Db\PortalTaskDelivery; +use OCA\OpenRegister\Db\PortalTaskDeliveryMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * Tests for the delivery half of {@see TaskInboxService}. + * + * @covers \OCA\OpenRegister\Service\Task\TaskInboxService + * @covers \OCA\OpenRegister\Db\PortalTaskDelivery + * @uses \OCA\OpenRegister\Db\Task + * @uses \OCA\OpenRegister\Service\Task\TaskTemporalProjection + */ +class TaskInboxDeliveryStateTest extends TestCase { + + /** + * An external task. + * + * @return Task The task. + */ + private function externalTask(): Task { + $task = new Task(); + $task->setId(1); + $task->setUuid('t-1'); + $task->setState(Task::STATE_ACTIVE); + $task->setPerformerType(Task::PERFORMER_EXTERNAL); + $task->setAssignee('party:bsn-1'); + + return $task; + }//end externalTask() + + /** + * A service over a delivery mapper (or none). + * + * @param PortalTaskDeliveryMapper|null $deliveries The mapper. + * + * @return TaskInboxService The service. + */ + private function service(?PortalTaskDeliveryMapper $deliveries): TaskInboxService { + return new TaskInboxService( + tasks: $this->createMock(TaskMapper::class), + temporal: new TaskTemporalProjection(), + logger: new NullLogger(), + objects: null, + deliveries: $deliveries + ); + }//end service() + + /** + * An external row carries the summarised delivery state; a user task's + * row carries none. + * + * @return void + */ + public function testTheExternalRowCarriesTheSummarisedDeliveryState(): void { + $row = new PortalTaskDelivery(); + $row->setChannel(PortalTaskDelivery::CHANNEL_PORTAL_INBOX); + $row->setState(PortalTaskDelivery::STATE_DELIVERED); + $row->setKind(PortalTaskDelivery::KIND_ASK); + $row->setRequestedAt(new DateTime('2026-09-01T10:00:00+00:00')); + $row->setDeliveredAt(new DateTime('2026-09-01T10:05:00+00:00')); + + $deliveries = $this->createMock(PortalTaskDeliveryMapper::class); + $deliveries->method('findForTask')->with('t-1')->willReturn([$row]); + $service = $this->service($deliveries); + + $rendered = $service->row(task: $this->externalTask(), subjects: [], now: new DateTime()); + $this->assertSame('delivered', $rendered['delivery']['state']); + $this->assertArrayHasKey('portal-inbox', $rendered['delivery']['channels']); + + $user = $this->externalTask(); + $user->setPerformerType(Task::PERFORMER_USER); + $user->setAssignee('alice'); + $this->assertArrayNotHasKey('delivery', $service->row(task: $user, subjects: [], now: new DateTime())); + }//end testTheExternalRowCarriesTheSummarisedDeliveryState() + + /** + * No mapper and a failing mapper both read `not-recorded`, never a throw. + * + * @return void + */ + public function testAnAbsentOrFailingLedgerReadsNotRecorded(): void { + $this->assertSame( + PortalTaskDelivery::STATE_NOT_RECORDED, + $this->service(null)->deliveryState(task: $this->externalTask())['state'] + ); + + $broken = $this->createMock(PortalTaskDeliveryMapper::class); + $broken->method('findForTask')->willThrowException(new RuntimeException('db gone')); + $this->assertSame( + PortalTaskDelivery::STATE_NOT_RECORDED, + $this->service($broken)->deliveryState(task: $this->externalTask())['state'] + ); + }//end testAnAbsentOrFailingLedgerReadsNotRecorded() + + /** + * The subject-context pass-through answers empty without an object store. + * + * @return void + */ + public function testSubjectContextsForAnswersEmptyWithoutAStore(): void { + $this->assertSame([], $this->service(null)->subjectContextsFor(tasks: [$this->externalTask()])); + }//end testSubjectContextsForAnswersEmptyWithoutAStore() +}//end class diff --git a/tests/Unit/Service/Task/TaskServicePortalWindowsTest.php b/tests/Unit/Service/Task/TaskServicePortalWindowsTest.php new file mode 100644 index 0000000000..1902799c23 --- /dev/null +++ b/tests/Unit/Service/Task/TaskServicePortalWindowsTest.php @@ -0,0 +1,270 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCA\OpenRegister\Service\Task\TaskPerformerResolver; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Tests for {@see TaskService::openFor()}, {@see TaskService::record()} and + * the completion's responses and evidence. + * + * @covers \OCA\OpenRegister\Service\Task\TaskService + * @covers \OCA\OpenRegister\Service\Flow\FlowTaskBridge + * @uses \OCA\OpenRegister\Db\Task + * @uses \OCA\OpenRegister\Db\TaskAudit + * @uses \OCA\OpenRegister\Service\Task\TaskState + */ +class TaskServicePortalWindowsTest extends TestCase { + + /** + * The task table, mocked. + * + * @var TaskMapper&MockObject + */ + private TaskMapper&MockObject $tasks; + + /** + * The audit rows, mocked. + * + * @var TaskAuditMapper&MockObject + */ + private TaskAuditMapper&MockObject $audits; + + /** + * Authorization, mocked. + * + * @var TaskAuthorizationService&MockObject + */ + private TaskAuthorizationService&MockObject $authorization; + + /** + * The service under test. + * + * @var TaskService + */ + private TaskService $service; + + /** + * Happy plumbing: the row exists, updates go through, audits insert. + * + * @return void + */ + protected function setUp(): void { + $this->tasks = $this->createMock(TaskMapper::class); + $this->audits = $this->createMock(TaskAuditMapper::class); + $this->authorization = $this->createMock(TaskAuthorizationService::class); + $this->tasks->method('updateIfOpen')->willReturn(true); + $this->audits->method('insert')->willReturnArgument(0); + + $this->service = new TaskService( + tasks: $this->tasks, + candidates: $this->createMock(TaskCandidateMapper::class), + relations: $this->createMock(TaskRelationMapper::class), + audits: $this->audits, + authorization: $this->authorization, + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->createMock(IDBConnection::class), + logger: new NullLogger(), + builder: new TaskBuilder() + ); + }//end setUp() + + /** + * An open external task. + * + * @return Task The task. + */ + private function task(): Task { + $task = new Task(); + $task->setId(7); + $task->setUuid('t-1'); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setPerformerType(Task::PERFORMER_EXTERNAL); + $task->setAssignee('party:bsn-1'); + + return $task; + }//end task() + + /** + * openFor returns the open, authorized task without mutating it. + * + * @return void + */ + public function testOpenForReturnsTheOpenAuthorizedTask(): void { + $task = $this->task(); + $this->tasks->method('findByUuid')->willReturn($task); + $this->authorization->expects($this->once())->method('assertMay')->with('complete', $task, 'party:bsn-1'); + + $this->assertSame($task, $this->service->openFor(verb: 'complete', uuid: 't-1', actor: 'party:bsn-1')); + $this->assertSame(Task::STATE_ACTIVE, $task->getState()); + }//end testOpenForReturnsTheOpenAuthorizedTask() + + /** + * A denial through openFor is AUDITED as unauthorized, then rethrown. + * + * @return void + */ + public function testOpenForAuditsADenialBeforeRethrowingIt(): void { + $task = $this->task(); + $this->tasks->method('findByUuid')->willReturn($task); + $this->authorization->method('assertMay')->willThrowException( + new TaskAccessDeniedException("Verb 'complete' denied: only the matched portal subject may answer an external task.") + ); + $audited = null; + $this->audits->expects($this->once())->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$audited): TaskAudit { + $audited = $entry; + + return $entry; + } + ); + + try { + $this->service->openFor(verb: 'complete', uuid: 't-1', actor: 'party:bsn-2'); + $this->fail('The denial was swallowed.'); + } catch (TaskAccessDeniedException) { + $this->addToAssertionCount(1); + } + + $this->assertNotNull($audited); + $this->assertFalse($audited->getAuthorized()); + $this->assertSame('complete', $audited->getAction()); + $this->assertSame('party:bsn-2', $audited->getActor()); + $this->assertSame(Task::PERFORMER_EXTERNAL, $audited->getPerformerType()); + }//end testOpenForAuditsADenialBeforeRethrowingIt() + + /** + * A terminal task conflicts through openFor, exactly as through a verb. + * + * @return void + */ + public function testOpenForConflictsOnATerminalTask(): void { + $task = $this->task(); + $task->setState(Task::STATE_COMPLETED); + $task->setIsTerminal(true); + $this->tasks->method('findByUuid')->willReturn($task); + + $this->expectException(TaskConflictException::class); + $this->service->openFor(verb: 'complete', uuid: 't-1', actor: 'party:bsn-1'); + }//end testOpenForConflictsOnATerminalTask() + + /** + * record grows the audit trail with the fact, and moves nothing. + * + * @return void + */ + public function testRecordAppendsTheFactWithoutMovingTheTask(): void { + $task = $this->task(); + $this->tasks->method('findByUuid')->willReturn($task); + $this->tasks->expects($this->never())->method('updateIfOpen'); + $audited = null; + $this->audits->expects($this->once())->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$audited): TaskAudit { + $audited = $entry; + + return $entry; + } + ); + + $returned = $this->service->record(uuid: 't-1', action: 'match', actor: 'caseworker', reason: "Matched role 'initiator' to 'party:bsn-1'."); + $this->assertSame($task, $returned); + $this->assertSame(Task::STATE_ACTIVE, $task->getState()); + $this->assertSame('match', $audited->getAction()); + $this->assertTrue($audited->getAuthorized()); + $this->assertStringContainsString('initiator', (string)$audited->getReason()); + }//end testRecordAppendsTheFactWithoutMovingTheTask() + + /** + * A completion may carry the answers and the stored file references, and + * both land on the task; without them nothing is overwritten. + * + * @return void + */ + public function testCompleteCarriesResponsesAndEvidence(): void { + $task = $this->task(); + $task->setResponses(['kept' => true]); + $this->tasks->method('findByUuid')->willReturn($task); + + $completed = $this->service->complete( + uuid: 't-1', + outcome: 'submitted', + resultText: null, + comment: null, + actor: 'party:bsn-1', + responses: ['remarks' => 'here'], + evidence: [['fileId' => 42, 'name' => 'payslip.pdf']] + ); + $this->assertSame(['remarks' => 'here'], $completed->getResponses()); + $this->assertSame(42, $completed->getEvidence()[0]['fileId']); + $this->assertSame(Task::STATE_COMPLETED, $completed->getState()); + + // A second, fresh service: a mock's first findByUuid expectation is + // not overridable, and the first task is terminal by now. + $this->setUp(); + $again = $this->task(); + $again->setResponses(['kept' => true]); + $again->setEvidence([['fileId' => 1]]); + $this->tasks->method('findByUuid')->willReturn($again); + $plain = $this->service->complete(uuid: 't-1', outcome: 'done', resultText: null, comment: null, actor: 'party:bsn-1'); + $this->assertSame(['kept' => true], $plain->getResponses(), 'absent responses overwrite nothing'); + $this->assertSame([['fileId' => 1]], $plain->getEvidence(), 'absent evidence overwrites nothing'); + }//end testCompleteCarriesResponsesAndEvidence() + + /** + * The bridge's record window delegates to the service, so the node needs + * no task-service dependency of its own. + * + * @return void + */ + public function testTheBridgeRecordWindowDelegates(): void { + $tasks = $this->createMock(TaskService::class); + $tasks->expects($this->once()) + ->method('record') + ->with('t-1', 'match', 'caseworker', 'the reason') + ->willReturn($this->task()); + + $bridge = new FlowTaskBridge( + tasks: $tasks, + runs: $this->createMock(\OCA\OpenRegister\Db\FlowRunMapper::class), + container: $this->createMock(\Psr\Container\ContainerInterface::class), + logger: new NullLogger() + ); + $bridge->record(uuid: 't-1', action: 'match', actor: 'caseworker', reason: 'the reason'); + }//end testTheBridgeRecordWindowDelegates() +}//end class diff --git a/tests/e2e/api-direct/flow-portal-task.spec.ts b/tests/e2e/api-direct/flow-portal-task.spec.ts new file mode 100644 index 0000000000..fb2231ec02 --- /dev/null +++ b/tests/e2e/api-direct/flow-portal-task.spec.ts @@ -0,0 +1,652 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The portal-task node, end to end over the live HTTP API: the seven + * @e2e-marked scenarios of the flow-portal-task change (six on its own spec, + * one on the flow-tasks delta). + * + * A case object with an initiator is created through the objects API, a flow + * with an `openregister.portal-task` node is authored through the flows API + * and run through the synchronous test endpoint. The resident acts through + * the PORTAL seam: a request context with NO Nextcloud credentials, carrying + * a signed X-Portal-Subject assertion minted here with the same HS256 shape + * portaliq mints — which requires the shared secret to be configured, done + * through `occ config:app:set openregister portal_assertion_secret`. Where + * occ is not reachable the portal-side scenarios SKIP, loudly. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md + */ +import type { APIRequestContext } from '@playwright/test' + +import { request as apiRequest, expect, test } from '@playwright/test' +import { execSync } from 'node:child_process' +import { createHmac } from 'node:crypto' +import { resolveBaseUrl, resolveContainer } from '../base-url.ts' + +const API = '/index.php/apps/openregister/api' +const JSON_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json', +} +const RUN_ID = `e2e-portaltask-${Date.now().toString(36)}` +const ADMIN = process.env.NEXTCLOUD_ADMIN_USER || process.env.OR_USER || 'admin' +const ADMIN_PASS = + process.env.NEXTCLOUD_ADMIN_PASSWORD || process.env.OR_PASS || 'admin' +const SECRET = `${RUN_ID}-shared-assertion-secret` +const SUBJECT_A = `${RUN_ID}-resident-a` +const SUBJECT_B = `${RUN_ID}-resident-b` + +// Basic auth, no session cookie: no CSRF token is demanded and +// `OCS-APIRequest` marks the calls as API traffic. +const NO_SESSION = { cookies: [], origins: [] } +const ADMIN_HEADERS = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${ADMIN}:${ADMIN_PASS}`).toString('base64')}`, +} + +test.use({ storageState: NO_SESSION, extraHTTPHeaders: ADMIN_HEADERS }) +test.describe.configure({ mode: 'serial' }) + +const CONTAINER = resolveContainer() + +function occ(args: string): string { + if (CONTAINER === null) { + throw new Error('NC_CONTAINER is not set; refusing to guess a container.') + } + return execSync(`docker exec -u www-data ${CONTAINER} php occ ${args}`, { + encoding: 'utf8', + }) +} + +/** Whether the shared assertion secret could be configured; null = not tried. */ +let secretConfigured: boolean | null = null + +function ensureSecret(): boolean { + if (secretConfigured !== null) { + return secretConfigured + } + try { + occ( + `config:app:set openregister portal_assertion_secret --value="${SECRET}"`, + ) + secretConfigured = true + } catch { + secretConfigured = false + } + return secretConfigured +} + +/** Mint the X-Portal-Subject assertion exactly as portaliq's edge does. */ +function assertionFor(subjectRef: string): string { + const b64 = (bytes: Buffer): string => + bytes + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + const header = b64(Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + const now = Math.floor(Date.now() / 1000) + const claims = b64( + Buffer.from( + JSON.stringify({ + sub: subjectRef, + audience: 'client', + organisation: 'e2e', + trust: 'substantial', + jti: `${RUN_ID}-session`, + use: 'assertion', + iat: now, + exp: now + 300, + iss: 'portaliq', + }), + ), + ) + const signature = b64( + createHmac('sha256', SECRET).update(`${header}.${claims}`).digest(), + ) + return `${header}.${claims}.${signature}` +} + +/** A request context acting as a PORTAL SUBJECT: no Nextcloud credentials at all. */ +async function portalContext(subjectRef: string): Promise { + return apiRequest.newContext({ + baseURL: resolveBaseUrl(), + extraHTTPHeaders: { + Accept: 'application/json', + 'X-Portal-Subject': assertionFor(subjectRef), + }, + }) +} + +type Node = Record + +async function createFlow( + request: APIRequestContext, + label: string, + nodes: Node[], + edges: Array>, +): Promise { + const resp = await request.post(`${API}/flows`, { + headers: JSON_HEADERS, + data: { + name: `${RUN_ID} ${label}`, + description: 'Created by the flow-portal-task e2e suite.', + trigger: 'manual', + enabled: true, + nodes, + edges, + }, + }) + expect(resp.status(), await resp.text()).toBe(201) + const body = await resp.json() + return (body.uuid ?? body.id) as string +} + +function portalTask(id: string, config: Record = {}): Node { + return { + id, + type: 'openregister.portal-task', + config: { + title: `${RUN_ID} ${id}: send the missing {{ name }}`, + partyRole: 'initiator', + advance: 'all', + ...config, + }, + position: { x: 0, y: 0 }, + } +} + +function setFields(id: string, set: Record): Node { + return { + id, + type: 'openregister.set-fields', + config: { set }, + position: { x: 0, y: 0 }, + } +} + +/** Run a flow synchronously over the case object as its one seed item. */ +async function testRun( + request: APIRequestContext, + flowId: string, + caseAnchor: Record, +) { + const resp = await request.post(`${API}/flow-runs/test`, { + headers: JSON_HEADERS, + data: { flowId, seedItems: [{ json: caseAnchor }] }, + }) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() +} + +async function readRun(request: APIRequestContext, uuid: string) { + const resp = await request.get(`${API}/flow-runs/${uuid}`) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() +} + +/** The external task a run's node created, read on its CASE (the caseworker's view). */ +async function caseTaskFor( + request: APIRequestContext, + caseUuid: string, + runUuid: string, + openOnly = true, +) { + const terminal = openOnly ? '&isTerminal=false' : '' + const resp = await request.get( + `${API}/flow-tasks?scope=all&objectUuid=${caseUuid}${terminal}&limit=100&sort=created`, + ) + expect(resp.status(), await resp.text()).toBe(200) + const rows = ((await resp.json()).results ?? []) as Array< + Record + > + return rows.filter((row) => row.runUuid === runUuid) +} + +test.describe('flow-portal-task: a party outside the instance in the graph', () => { + const flows: string[] = [] + let registerId: number + let schemaId: number + let caseUuid: string + let caseId: string + + test.beforeAll(async ({ request }) => { + // The register, the schema and the case object the flows are about. + const register = await request.post(`${API}/registers`, { + headers: JSON_HEADERS, + data: { + slug: `${RUN_ID}-register`, + title: `${RUN_ID} register`, + description: 'flow-portal-task e2e', + }, + }) + expect(register.status(), await register.text()).toBeLessThanOrEqual(201) + registerId = (await register.json()).id + + const schema = await request.post(`${API}/schemas`, { + headers: JSON_HEADERS, + data: { + slug: `${RUN_ID}-schema`, + title: `${RUN_ID} case`, + description: 'flow-portal-task e2e case schema', + properties: { + name: { type: 'string', title: 'Name' }, + initiator: { type: 'string', title: 'Initiator' }, + }, + }, + }) + expect(schema.status(), await schema.text()).toBeLessThanOrEqual(201) + schemaId = (await schema.json()).id + + const created = await request.post( + `${API}/objects/${registerId}/${schemaId}`, + { + headers: JSON_HEADERS, + data: { name: 'passport renewal', initiator: SUBJECT_A }, + }, + ) + expect(created.status(), await created.text()).toBeLessThanOrEqual(201) + const caseBody = await created.json() + caseUuid = (caseBody['@self']?.uuid + ?? caseBody.uuid + ?? caseBody.id) as string + caseId = (caseBody['@self']?.id ?? caseBody.id ?? caseUuid) as string + expect(caseUuid, 'the case object has a uuid').toBeTruthy() + }) + + test.afterAll(async ({ request }) => { + for (const uuid of flows) { + await request.delete(`${API}/flows/${uuid}`).catch(() => {}) + } + }) + + /** The seed item: the case object, anchored the way object triggers anchor it. */ + function caseItem(extra: Record = {}) { + return { + name: 'passport renewal', + initiator: SUBJECT_A, + '@self': { uuid: caseUuid, register: registerId, schema: schemaId }, + ...extra, + } + } + + test('the node catalog offers the portal-task node with its form', async ({ + request, + }) => { + const resp = await request.get(`${API}/flow/node-catalog`) + expect(resp.status(), await resp.text()).toBe(200) + const entry = ((await resp.json()).results ?? []).find( + (node: { id?: string }) => node.id === 'openregister.portal-task', + ) + expect(entry, 'the palette carries openregister.portal-task').toBeTruthy() + const formKeys = entry.configForm.map((field: { key: string }) => field.key) + for (const key of [ + 'title', + 'partyRole', + 'uploadRequired', + 'uploadMaxFiles', + 'uploadAcceptedTypes', + 'uploadMaxSizeMb', + 'reasonField', + 'advance', + ]) { + expect(formKeys, `form field ${key}`).toContain(key) + } + // The three-waiter division is stated where the author picks. + expect(String(entry.description)).toContain('Ask a person') + expect(String(entry.description)).toContain('Wait for an answer') + }) + + // @e2e flow-portal-task::the-first-firing-produces-an-external-task-and-a-suspended-run + // @e2e flow-tasks::the-caseworker-still-sees-the-ask-on-the-case + test('a flow with a portal task suspends; the ask reaches the case, no inbox', async ({ + request, + }) => { + const flowId = await createFlow( + request, + 'suspends', + [portalTask('ask'), setFields('done', { finished: true })], + [{ id: 'e1', from: 'ask', to: 'done' }], + ) + flows.push(flowId) + + const run = await testRun(request, flowId, caseItem()) + expect(run.status).toBe('suspended') + // The heartbeat: a suspension a clock can reach. Null is the one shape + // the 14-day abandoned-signal reaper would FAIL, and a hersteltermijn + // outlives fourteen days. + expect( + run.resumeAt, + 'a portal task never parks on a null resumeAt', + ).toBeTruthy() + + // The caseworker's view: the ask is on the case, external, matched to + // the initiator, with a queryable delivery state. + const [task] = await caseTaskFor(request, caseUuid, run.uuid) + expect(task, 'the ask is anchored to the case').toBeTruthy() + expect(task.performerType).toBe('external') + expect(task.assignee).toBe(`party:${SUBJECT_A}`) + expect(task.state).toBe('active') + expect(task.delivery, 'the delivery state is on the row').toBeTruthy() + expect(['requested', 'delivered', 'failed', 'not-recorded']).toContain( + (task.delivery as Record).state, + ) + + // NO Nextcloud inbox carries it: not assigned, not pooled, not in the + // admin's "everything" view, not in the count. + for (const scope of ['assigned', 'pooled', 'all']) { + const inbox = await request.get( + `${API}/flow-tasks?scope=${scope}&isTerminal=false&limit=100`, + ) + expect(inbox.status()).toBe(200) + const body = await inbox.json() + const rows = (body.results ?? []) as Array> + expect( + rows.find((row) => row.uuid === task.uuid), + `scope=${scope} must not list the external task`, + ).toBeUndefined() + } + }) + + // @e2e flow-portal-task::the-task-is-visible-to-its-matched-subject-and-to-nobody-else + test('a portal task is listed for its matched subject and hidden from another', async ({ + request, + }) => { + test.skip( + !ensureSecret(), + 'occ not reachable; the assertion secret cannot be configured', + ) + + const flowId = await createFlow( + request, + 'visibility', + [portalTask('ask')], + [], + ) + flows.push(flowId) + const run = await testRun(request, flowId, caseItem()) + expect(run.status).toBe('suspended') + const [task] = await caseTaskFor(request, caseUuid, run.uuid) + + const asA = await portalContext(SUBJECT_A) + const asB = await portalContext(SUBJECT_B) + try { + const mine = await asA.get(`${API}/portal-tasks?limit=100`) + expect(mine.status(), await mine.text()).toBe(200) + const mineBody = await mine.json() + const mineRow = ( + mineBody.results as Array> + ).find((row) => row.uuid === task.uuid) + expect(mineRow, "subject A's list carries the task").toBeTruthy() + expect(mineRow!.subject, 'with its case context').toBeTruthy() + + const theirs = await asB.get(`${API}/portal-tasks?limit=100`) + expect(theirs.status(), await theirs.text()).toBe(200) + const theirsBody = await theirs.json() + expect( + (theirsBody.results as Array>).find( + (row) => row.uuid === task.uuid, + ), + "subject B's list must not carry it", + ).toBeUndefined() + // And not counted either: B has no tasks at all in this suite. + expect(theirsBody.total).toBe(0) + + // Reading it directly answers absence, not denial. + const peek = await asB.get(`${API}/portal-tasks/${task.uuid}`) + expect(peek.status()).toBe(404) + + // No assertion at all is a 401 with a stable code. + const anonymous = await apiRequest.newContext({ + baseURL: resolveBaseUrl(), + }) + const bare = await anonymous.get(`${API}/portal-tasks`) + expect(bare.status()).toBe(401) + expect((await bare.json()).code).toBe('portal-subject-missing') + await anonymous.dispose() + } finally { + await asA.dispose() + await asB.dispose() + } + }) + + // @e2e flow-portal-task::another-subject-who-knows-the-task-cannot-answer-it + test('another portal subject cannot complete a task that is not theirs', async ({ + request, + }) => { + test.skip( + !ensureSecret(), + 'occ not reachable; the assertion secret cannot be configured', + ) + + const flowId = await createFlow(request, 'refusal', [portalTask('ask')], []) + flows.push(flowId) + const run = await testRun(request, flowId, caseItem()) + const [task] = await caseTaskFor(request, caseUuid, run.uuid) + + const asB = await portalContext(SUBJECT_B) + try { + const answered = await asB.post( + `${API}/portal-tasks/${task.uuid}/complete`, + { + multipart: { + outcome: 'submitted', + answers: JSON.stringify({ remarks: 'not mine' }), + }, + }, + ) + // Fail-closed AND unrevealing: a stranger who knows the uuid gets + // absence, never a denial that confirms it. + expect(answered.status(), await answered.text()).toBe(404) + expect((await answered.json()).code).toBe('no-such-task') + } finally { + await asB.dispose() + } + + const [after] = await caseTaskFor(request, caseUuid, run.uuid) + expect(after.state).toBe('active') + expect(after.isTerminal).toBe(false) + expect((await readRun(request, run.uuid)).status).toBe('suspended') + }) + + // @e2e flow-portal-task::the-uploaded-file-is-on-the-case + // @e2e flow-portal-task::completing-the-task-advances-the-run-with-the-answer-on-the-items + test("a resident's upload lands on the case and the run advances with the answer", async ({ + request, + }) => { + test.skip( + !ensureSecret(), + 'occ not reachable; the assertion secret cannot be configured', + ) + + const flowId = await createFlow( + request, + 'upload', + [ + portalTask('ask', { + uploadRequired: true, + uploadAcceptedTypes: 'application/pdf', + uploadMaxSizeMb: 5, + }), + setFields('done', { finished: true }), + ], + [{ id: 'e1', from: 'ask', to: 'done' }], + ) + flows.push(flowId) + const run = await testRun(request, flowId, caseItem()) + expect(run.status).toBe('suspended') + const [task] = await caseTaskFor(request, caseUuid, run.uuid) + + const asA = await portalContext(SUBJECT_A) + try { + // A required upload cannot be skipped: refused naming the requirement. + const empty = await asA.post( + `${API}/portal-tasks/${task.uuid}/complete`, + { + multipart: { outcome: 'submitted' }, + }, + ) + expect(empty.status(), await empty.text()).toBe(400) + expect((await empty.json()).error).toContain('uploadRequired') + + const completed = await asA.post( + `${API}/portal-tasks/${task.uuid}/complete`, + { + multipart: { + outcome: 'submitted', + answers: JSON.stringify({ remarks: 'here it is' }), + comment: 'uploaded from the portal', + file: { + name: 'payslip.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from(`%PDF-1.4 ${RUN_ID} payslip`), + }, + }, + }, + ) + expect(completed.status(), await completed.text()).toBe(200) + const completedBody = await completed.json() + expect(completedBody.state).toBe('completed') + expect(completedBody.completedBy).toBe(`party:${SUBJECT_A}`) + expect(completedBody.evidence?.[0]?.name).toBe('payslip.pdf') + } finally { + await asA.dispose() + } + + // The file IS on the case object, as an ordinary OR file attachment. + const files = await request.get( + `${API}/objects/${registerId}/${schemaId}/${caseId}/files`, + ) + expect(files.status(), await files.text()).toBe(200) + const fileRows = JSON.stringify(await files.json()) + expect(fileRows, 'the upload is a file on the case').toContain('payslip.pdf') + + // advance:'all' ran the completion request to the end of the graph: + // the run is completed and every item carries the answer bag. + const after = await readRun(request, run.uuid) + expect(after.status).toBe('completed') + const item = (after.items ?? [])[0]?.json ?? {} + expect(item.portalTask?.decided).toBe(true) + expect(item.portalTask?.outcome).toBe('submitted') + expect(item.portalTask?.party).toBe(`party:${SUBJECT_A}`) + expect(item.portalTask?.answers?.remarks).toBe('here it is') + expect(String(item.portalTask?.files?.[0]?.name)).toBe('payslip.pdf') + expect(item.finished).toBe(true) + }) + + // @e2e flow-portal-task::a-rejected-submission-goes-back-with-the-reason + test('a rejected submission returns to the resident with the reason', async ({ + request, + }) => { + test.skip( + !ensureSecret(), + 'occ not reachable; the assertion secret cannot be configured', + ) + + const flowId = await createFlow( + request, + 're-ask', + [ + portalTask('ask', { reasonField: 'review.comment' }), + { + id: 'review', + type: 'openregister.user-task', + config: { + title: `${RUN_ID} review the submission`, + assignee: ADMIN, + outcomes: 'approved, rejected', + outcomeKey: 'review', + advance: 'all', + }, + position: { x: 0, y: 0 }, + }, + { + id: 'route', + type: 'openregister.route', + config: { + rules: [ + { + condition: { + '==': [ + { var: 'json.review.outcome' }, + 'rejected', + ], + }, + output: 'back', + }, + ], + default: 'on', + }, + exits: [{ id: 'back' }, { id: 'on' }], + position: { x: 0, y: 0 }, + }, + setFields('done', { finished: true }), + ], + [ + { id: 'e1', from: 'ask', to: 'review' }, + { id: 'e2', from: 'review', to: 'route' }, + { id: 'e3', from: 'route', fromExit: 'back', to: 'ask' }, + { id: 'e4', from: 'route', fromExit: 'on', to: 'done' }, + ], + ) + flows.push(flowId) + + const run = await testRun(request, flowId, caseItem()) + expect(run.status).toBe('suspended') + const [first] = await caseTaskFor(request, caseUuid, run.uuid) + expect(first.assignee).toBe(`party:${SUBJECT_A}`) + + // The resident answers; advance:'all' walks on to the review task. + const asA = await portalContext(SUBJECT_A) + try { + const submitted = await asA.post( + `${API}/portal-tasks/${first.uuid}/complete`, + { + multipart: { + outcome: 'submitted', + answers: JSON.stringify({ remarks: 'first try' }), + }, + }, + ) + expect(submitted.status(), await submitted.text()).toBe(200) + } finally { + await asA.dispose() + } + + // The caseworker rejects, with the reason the resident will read. + const review = await request.get( + `${API}/flow-tasks?scope=assigned&isTerminal=false&limit=100`, + ) + const reviewTask = ( + ((await review.json()).results ?? []) as Array> + ).find((row) => row.runUuid === run.uuid && row.nodeId === 'review') + expect(reviewTask, 'the review task is in the caseworker inbox').toBeTruthy() + const rejected = await request.post( + `${API}/flow-tasks/${reviewTask!.uuid}/complete`, + { + headers: JSON_HEADERS, + data: { outcome: 'rejected', comment: 'The scan is unreadable' }, + }, + ) + expect(rejected.status(), await rejected.text()).toBe(200) + + // The rejection routed the walk back into the node: a SECOND ask + // exists, cycle 2, carrying the reason and the first task's uuid; the + // first task is untouched; the run waits on the resident again. + const open = await caseTaskFor(request, caseUuid, run.uuid) + const second = open.find((row) => row.uuid !== first.uuid) + expect(second, 'a second ask exists').toBeTruthy() + expect(second!.state).toBe('active') + const metadata = second!.metadata as Record + expect(metadata.cycle).toBe(2) + expect(metadata.previousTaskUuid).toBe(first.uuid) + expect(metadata.reaskReason).toBe('The scan is unreadable') + + const all = await caseTaskFor(request, caseUuid, run.uuid, false) + const firstAfter = all.find((row) => row.uuid === first.uuid) + expect(firstAfter!.state).toBe('completed') + expect((await readRun(request, run.uuid)).status).toBe('suspended') + }) +}) From 562724ac00dfaf66126215050d4ae84ac17c10a3 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 19:09:02 +0200 Subject: [PATCH 34/68] feat(flow): task forms, the fields a user task's completion supplies (#3281) * feat(tasks): the task store - four tables, entities and mappers The flow-task-entity storage layer (openspec/changes/flow-task-entity): openregister_tasks with the resolved 23-shape union (no overdue column, no app-named column, performer_type as an open string vocabulary so the coming external type is an append, definition_version shipped now), openregister_task_candidates as the pooled-inbox index half of the candidate pool, openregister_task_relations as the typed anchor table, and openregister_task_audit with an append-only mapper whose update and delete refuse. claim() is a conditional update so the database decides the race, and the inbox page and total share one predicate builder. Ticks tasks 1.1 and 1.2. * feat(tasks): the vocabularies, the authorization and the routing Normalisation at the boundary: TaskState publishes the one legacy-status mapping onto the six CMMN states (collapsed distinctions survive on outcome, an unmapped value is refused naming itself, and a migration may declare its source vocabulary so procest's status 'open' fails loudly); TaskPriority lands all four fleet scales on low|normal|high|urgent and refuses pipelinq's 'normaal'; TaskTemporalProjection is the ONE overdue derivation, computed and never stored. TaskAuthorizationService decides every verb fail-closed BEFORE mutation: no group backend, an unresolvable role or an unknown performer type each DENY rather than skip, and no nullable service-unavailable return exists. TaskPerformerResolver carries the five routing strategies; a strategy that finds nobody assigns nobody. Ticks tasks 2.1, 2.2, 3.1 and 3.2. * feat(tasks): the lifecycle service, cancellation propagation and the inbox TaskService: create/offer/claim/unclaim/assign/reassign/delegate/ resolve/complete/cancel, each authorized fail-closed before any mutation, each audited in the SAME transaction (an audit-write failure unwinds the completion), denials audited too. One write path maintains the candidate JSON and the candidate index rows together; state and is_terminal move in one statement; claim is the mapper's conditional update so the race's loser gets a conflict; a rejecting outcome without a comment is refused before anything moves; the template snapshot is frozen at creation. Cancellation propagation (design D-8): FlowRunMapper::update() - the one choke point every terminal run write passes - dispatches FlowRunTerminalEvent, and TaskRunTerminalListener terminates that run's open tasks with the reason recorded. Idempotent, and a task with run_uuid null is structurally out of reach. TaskInboxService answers assigned/pooled/watched/by-object in the datastore over one shared predicate set, with the total from the same predicates, subject context batched per page, a display title synthesized on read for titleless tasks (never persisted), and the derived temporal projection attached to rows only. Ticks tasks 4.1-4.5, 5.1, 6.1 and 6.2. * feat(tasks): the REST surface and the seed fixtures TaskController + /api/flow-tasks routes for the inbox, the detail and audit reads (both visibility-checked) and every lifecycle verb. The family is named for the flow-tasks CAPABILITY, not a flow requirement - /api/tasks itself already belongs to the CalDAV VTODO leaf. Every method declares its auth posture attribute AND delegates its actual authorization to TaskAuthorizationService inside the service; refusals translate uniformly (400 validation, 403 denial, 409 conflict naming the current state, 404 absence). SeedTaskFixtures installs the five seed groups from design.md (pooled municipal check with no run, delegated approval with enforcing expiry on a run, agent task with a typed checklist, one completed 'approved' and one terminated by propagation, plus audit fixtures including one DENIED entry), idempotent on uuid - and is REGISTERED in appinfo/info.xml in this same commit, because an unregistered repair step is a known fleet defect. Ticks tasks 7.1 and 8.1. * test(tasks): the guarantees, pinned - 93 tests over the task layer Table-driven legacy-status and priority tests including the live fleet defects: procest's status 'open' against a source vocabulary that does not define it is refused naming the value, and pipelinq's 'normaal' is refused on every scale. Authorization: a stranger who knows the uuid is denied on every verb, an unresolvable role denies naming the role, an absent group backend denies, an unknown performer type denies, watchers read and never act. Concurrency and transactionality: the two-claim race yields one assignee and one conflict, a rejection without a comment moves nothing, and an injected audit-write failure rolls the completion back. Derivation: overdue flips with an injected clock while the stored row stays byte-identical. Inbox: the page and the total run over the SAME criteria object (25 of 120 reported honestly), a titleless task presents a synthesized display title while its stored title stays null, and a runless task completes identically to a runful one. Mutation-checked: disabling assertMay fails 7 tests; removing the transaction rollback fails 2; both restored byte-identical (sha256-verified). Ticks tasks 9.1, 9.2 and 9.3. * test(tasks): playwright coverage for the two e2e-marked scenarios 'a stranger is refused on the task detail route': a provisioned second account gets 403 on the detail read AND on complete, and the task provably did not move - the positive control for the hole where knowing a uuid was the whole check. 'the inbox route returns tasks with subject context': one request lists the assignee's work with a display title, the subject slot, the derived overdue flag and a datastore total, and when the dev seed offers a live object the anchored subject context rides along. Both specs clean up after themselves and skip loudly when the instance cannot provision a stranger. Ticks task 9.4. * style(tasks): named parameters on every internal call, no inline ifs The repo's PHPCS standard requires named arguments on calls into OCA code and forbids ternaries. Exception constructors now name message:, the transactional/stringOrNull/arrayOrNull/respondWith calls name their parameter, the nine isset-ternaries collapse into one intOrNull helper, and the two remaining conditionals become explicit if blocks. No behaviour change: the 93 task tests are unchanged and green. * refactor(tasks): the quality pass - intake extracted, complexity earned down TaskBuilder now owns intake: boundary data in, a validated unsaved Task out, with every published vocabulary applied there once. That returns TaskService to lifecycle only and under the complexity budget. The authorization switch becomes a verb-to-rule table with static dispatch; the inbox predicate builder splits into scope, visibility and filters; the migration's indexes and the seed fixtures get methods of their own; sort and direction travel as one parameter (-dueAt) so the inbox route keeps a single-digit parameter list. The few remaining suppressions each carry their justification inline. Behaviour unchanged: the 93 task tests pass untouched except for injecting the builder. * fix(tasks): the PHPStan pass - typed mapper overrides and a DBAL constant The mapper overrides now guard their entity type with a throwing instanceof check, which is what lets the generic QBMapper contract type-check (and turns a wrong entity into a named error instead of a silent write). candidateMembershipPredicate declares the IQueryFunction it returns. The migration uses Types::DATETIME_MUTABLE, the constant the current DBAL stubs actually define. * style(tasks): import InvalidArgumentException in the three mapper guards PHPMD MissingImport on the type guards added in the PHPStan pass. No behaviour change. * feat(flow): the openregister.user-task node puts a person into the graph The node creates ONE task through TaskService on its first firing with items, stores the uuid in its own resume slot, and suspends with a heartbeat that is never null (findAbandonedSignals reaps null and would fail slow approvals at 14 days). Later firings read the TASK for terminality, never context.signal, so two user-task nodes in one flow keep independent answers. The outcome bag lands on every item's json under outcomeKey (default task), with decided/rejected separating a person's decision from a task that merely ended; failOnReject is opt-in. The advance budget (ADR-098 D9) is 0 | N | "all", null refused by name (FlowAdvanceBudget). TaskService announces every committed terminal transition (TaskTerminalEvent, after the transaction); the listener wakes the run with an empty signal and, per the node's stored budget, continues it through FlowRunAdvancer with a per-walk ceiling the engine consumes (CONTEXT_ADVANCE_BUDGET). A spent budget parks the run as due. Branch mootness: the engine reports pruned exits to FlowTaskMootness, which terminates the task of a user-task node standing on a cleared place. Run terminality stays with TaskRunTerminalListener from #3258. Ticks tasks 1.1-1.4, 2.1-2.3, 3.1-3.2, 4.1-4.3, 5.1-5.2. * fix(tasks): the review pass - offer is the requester's, and the inbox works on PostgreSQL Two blocking defects from the independent review of #3258: - offer bypassed per-task authorization and rewrote the assignee. Any authenticated user could POST offer {"routingFallback": "mallory"} on an assigned active task, become its assignee, and then complete it. offer now needs the requester (or an administrator), refuses a task that already has an assignee (unclaim or reassign instead), and is in the stranger-denied test's verb list, whose omission is how the suite stayed green. - The watchers LIKE ran against a json column, which PostgreSQL refuses (operator does not exist: json ~~ unknown): every non-admin inbox request 500ed there. The column is now cast per platform (AS TEXT on PostgreSQL, AS CHAR elsewhere), every identifier in the mapper's raw SQL goes through the platform's own quoter (no backticks), and TaskMapperTest pins the cast on a PostgreSQL platform mock. The should-fix items: authorization runs before the terminality check and a task the caller may not see answers 404 on every route, verbs included (no existence or state oracle); over HTTP the requester is pinned to the actor and a terminal creation state is refused, with import() as the trusted path for migrations and the user-task node; every state-changing write is a conditional update (WHERE is_terminal = false) so a second completion conflicts instead of overwriting; delegate refuses an empty delegate and keeps naming the original performer on re-delegation; an unexpected failure is a generic 500 whose detail goes to the log; propagation continues past a failing task; role pools are matched by the inbox EXISTS; the seed fixtures run only when app config openregister/seed_demo_tasks is true; the CSRF posture is recorded in the controller docblock; the e2e stranger scenario fails loudly instead of skipping and now covers the non-admin inbox and a watcher. Class-level @spec tags on every new class. TaskControllerTest is the contract test for all fourteen /api/flow-tasks endpoints (gate-25 PASS). Checks on this tree: PHPCS 0, PHPMD 0 (touched files), PHPStan 0, Psalm 0, 120 task tests green. * style(tasks): format the task inbox e2e spec with the Nextcloud prettier config CI's Frontend Check (format) named tests/e2e/task-inbox.spec.ts. Formatted with @nextcloud/prettier-config's options (tabs, no semicolons, single quotes, trailing commas, print width 85, operators leading). * test(tasks): cover the entities, the listener, the seed step and the builder CI's pgsql cell did not fail in PHPUnit (17860 tests, 0 failures, and app:enable succeeded); it failed the coverage guard: the changed files measured 3.85% (69/1791 statements) against a 7.32% base, because the suite runs with strict coverage metadata and collaborators exercised through TaskServiceTest were never credited. This adds the tests that were genuinely missing: the four entities' typing, hydration and serialisation (no overdue anywhere in the stored row); the terminal-run listener and event; the seed step off by default, seeding the five groups when on, idempotent on uuid, and continuing past a failing fixture; and TaskBuilder credited where TaskServiceTest exercises it. 133 task tests green. * test(flow): the user-task node, its budget, its bridge and its propagation Node: one task per node per run across a heartbeat wake, empty firing creates nothing and does not suspend, a claim is not a completion, the signal slot cannot answer for a performer, askedAt is not restamped, two nodes need two answers, outcome placement under json., rejection as a branch with failOnReject opt-in, a terminated task is not a rejection, the config-validation table (advance 0/3/all accepted; null, '', -1, 'unlimited' refused naming the value; no performer refused). Budget: FlowAdvanceBudget shapes; the engine parks at a spent ceiling and the worker walk runs the remainder; an oversight veto still applies in-request; pruned exits reach the mootness collaborator. Bridge: the provenance stamp, offer-on-strategy, 0/N/all continuation, and a failed continuation leaving the run due. Propagation: a run stopped with two tasks terminates both once across two observations; a losing branch takes its task; a run-less task is never touched. TaskService announces terminality once, after commit, and a listener failure cannot undo it. Mutation-checked: dropping the run-less guard, the performer guard or the signal-ignore each turns the suite red. Splits the node's config intake into UserTaskConfig and groups the form so the node stays under the PHPMD complexity and method-length ceilings. Ticks tasks 6.1-6.4. * test(e2e): the eight user-task scenarios over the live API Authored through POST /api/flows, run through the synchronous test endpoint, driven through the flow-tasks verbs. Two scenarios need the worker (the default budget parks for it; a run has no stop verb, so the operator's kill switch is the stop) and drive it through occ in the dev container, skipping loudly where occ is not reachable. Each test carries its scenario slug for gate-19. Ticks task 6.5 (19/19). * docs(flow): spec anchors on the three public UserTaskConfig readers gate-16 named assignee(), outcomeKey() and renderedTitle(). * test(tasks): walk the mapper queries and credit every exercised class The pgsql cell's coverage guard credits only statements in classes a test names with @covers (strict coverage metadata), and the changed files measured 20.16% against a 23.46% base. Two moves: every task test class now names each class it actually exercises (TaskBuilder, TaskState and TaskPriority behind TaskServiceTest, TaskInboxCriteria behind the inbox and controller tests, the entities and exceptions everywhere they are built), and the largest untested code is now walked: TaskMapper's whole query vocabulary, the candidate, relation and audit mappers, and FlowRunMapper's terminality announcement, over a fluent query-builder double that records which predicate and column each query sets, so a conditional update without its openness guard or a pooled scope without its EXISTS would fail here. Plus the untested TaskService verbs (unclaim, assign, reassign, cancel, resolve, terminateAsMoot, relations, intake refusals), TaskInboxService's batched subject context, and the controller's filter, flag and failing-backend paths. 168 task tests. * wip: checkpoint before rate-limit pause (2026-09-01 09:45) * chore: drop the node_modules symlink the wip checkpoint committed An absolute path into another checkout has no business in the tree; the symlink only served a local Playwright --list and stays untracked. * style(tasks): a top-level type import and sorted specifiers in the inbox e2e spec Vue Quality (eslint) on #3258 named four errors in tests/e2e/task-inbox.spec.ts: perfectionist/sort-named-imports (the specifiers now read apiRequest, expect, test) and import-extensions/ban-inline-type-imports (APIRequestContext is a top-level import type). eslint --fix plus a plain run: 0 errors; the sibling flow-engine.spec.ts as control also 0; prettier clean. * style(e2e): lint and format the user-task spec under the tests/ lint scope A top-level type import, sorted specifiers, the .ts extension on the relative import, and the repo's prettier config. Also carries #3258's matching fix for task-inbox.spec.ts through the merge. * wip: checkpoint after host restart (2026-09-01 14:08) * fix: resolve the committed development-merge conflict markers * fix(task-forms): green the unit suites and wrap the long lines - TaskFormCompletionTest asserts the record's content: getObject() carries the entity id alongside the record. - TaskFormReaderTest allows the reader to consult declaredInputs from both validate() and declaredFields(). - UserTaskNode help texts and TransitionEngine return shapes fit the 150-character line limit. * style(task-forms): reasoned PHPMD suppressions for the counts the form work crossed * fix(lifecycle): document the transition write's full throws surface and align the action shapes phpstan read the narrower docblock as ground truth and called the completion path's catches dead; the engine really does raise NotAuthorizedException itself and lets the save path's refusals bubble. The graph-derived action shape now declares its inputs key too. * style(e2e): lint-clean imports for the task-forms spec * fix(ci): keep the form suites' coverage credit, and fit the action shapes on a line The pgsql cell collects coverage with covers metadata enforced, so a test that executes a class its suite does not list is risky and PHPUnit discards its whole coverage. The completion and resolver suites executed the task and flow entities they build fixtures from, contributing zero credit and dropping kept-or-added coverage by 14.8 percent. Each suite now lists its fixture classes with @uses (and the controller suite covers the two refusal exceptions whose HTTP mapping it asserts). phpcs on CI also flagged the two action-shape docblocks at 162 and 161 characters; my local check truncated the report below its own findings. They are list<> shapes now, under the limit. * style(task): fold the duplicated suppressions the projections merge brought in * fix(quality): drop the else branch PHPMD flags in the pg_trgm re-run migration Pre-existing on development via the hotfix back-merge; CI runs this PR on the merge ref, so the finding lands here. Same log lines, one exit. --------- Co-authored-by: Conduction Release Bot --- lib/Controller/TaskController.php | 89 ++- lib/Exception/TaskFormRefusedException.php | 110 ++++ .../TaskSubjectWriteRefusedException.php | 41 ++ lib/Service/Flow/Nodes/UserTaskConfig.php | 14 + lib/Service/Flow/Nodes/UserTaskNode.php | 82 ++- lib/Service/Lifecycle/TransitionEngine.php | 98 ++- lib/Service/Task/TaskForm.php | 141 +++++ lib/Service/Task/TaskFormCompletion.php | 374 +++++++++++ lib/Service/Task/TaskFormReader.php | 480 ++++++++++++++ lib/Service/Task/TaskFormResolver.php | 359 +++++++++++ lib/Service/Task/TaskService.php | 111 +++- openspec/changes/flow-task-forms/tasks.md | 34 +- tests/Unit/Controller/TaskControllerTest.php | 135 +++- tests/Unit/Service/Flow/UserTaskNodeTest.php | 16 +- .../Lifecycle/TransitionEngineInputsTest.php | 190 ++++++ .../Service/Task/TaskFormCompletionTest.php | 454 ++++++++++++++ .../Unit/Service/Task/TaskFormReaderTest.php | 395 ++++++++++++ .../Service/Task/TaskFormResolverTest.php | 401 ++++++++++++ tests/Unit/Service/Task/TaskServiceTest.php | 112 ++++ tests/e2e/api-direct/flow-task-forms.spec.ts | 592 ++++++++++++++++++ 20 files changed, 4184 insertions(+), 44 deletions(-) create mode 100644 lib/Exception/TaskFormRefusedException.php create mode 100644 lib/Exception/TaskSubjectWriteRefusedException.php create mode 100644 lib/Service/Task/TaskForm.php create mode 100644 lib/Service/Task/TaskFormCompletion.php create mode 100644 lib/Service/Task/TaskFormReader.php create mode 100644 lib/Service/Task/TaskFormResolver.php create mode 100644 tests/Unit/Service/Task/TaskFormCompletionTest.php create mode 100644 tests/Unit/Service/Task/TaskFormReaderTest.php create mode 100644 tests/Unit/Service/Task/TaskFormResolverTest.php create mode 100644 tests/e2e/api-direct/flow-task-forms.spec.ts diff --git a/lib/Controller/TaskController.php b/lib/Controller/TaskController.php index 9dc81fe1b4..578fe51941 100644 --- a/lib/Controller/TaskController.php +++ b/lib/Controller/TaskController.php @@ -46,8 +46,12 @@ use OCA\OpenRegister\Db\TaskInboxCriteria; use OCA\OpenRegister\Exception\TaskAccessDeniedException; use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskFormRefusedException; +use OCA\OpenRegister\Exception\TaskSubjectWriteRefusedException; use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskFormCompletion; +use OCA\OpenRegister\Service\Task\TaskFormResolver; use OCA\OpenRegister\Service\Task\TaskInboxService; use OCA\OpenRegister\Service\Task\TaskService; use OCA\OpenRegister\Service\Task\TaskTemporalProjection; @@ -68,11 +72,20 @@ /** * REST surface for the fleet-generic task. * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of the verb + * routes and one catch per refusal shape in {@see respondWith()}; both scale + * with the spec, not with entanglement. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) One collaborator per + * concern, injected: the deep-link builder the projections carry, the form + * resolver and the form-aware completion. The controller is the one place + * those meet HTTP, and a facade over them would add a class that only + * forwards. + * @SuppressWarnings(PHPMD.CyclomaticComplexity) respondWith() holds ONE + * catch per refusal shape so no verb can drift; folding two shapes into one + * catch is how a 400 and a 422 end up indistinguishable. * @SuppressWarnings(PHPMD.TooManyPublicMethods) One route method per * lifecycle verb the spec names, plus the three reads. Folding verbs into a * mode parameter is how per-verb authorization rules get lost. - * @SuppressWarnings(PHPMD.ExcessiveParameterList) One collaborator per - * concern, injected; the tenth builds the deep link the projections carry. * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The controller mediates * between HTTP and the task services plus their three exception shapes; * that is the whole of its job. @@ -91,6 +104,9 @@ class TaskController extends Controller { * @param TaskAuthorizationService $authorization Read-visibility decisions. * @param TaskTemporalProjection $temporal The one overdue derivation. * @param IUserSession $userSession Names the acting identity. + * @param TaskFormResolver $forms Resolves the form a task presents, per read. + * @param TaskFormCompletion $completion Completes a task with a form payload, + * writing the subject first. * @param LoggerInterface|null $logger Where an unexpected failure's * detail goes, INSTEAD of the response. * @param IGroupManager|null $groupManager Resolves the caller's groups @@ -111,6 +127,8 @@ public function __construct( private readonly TaskAuthorizationService $authorization, private readonly TaskTemporalProjection $temporal, private readonly IUserSession $userSession, + private readonly TaskFormResolver $forms, + private readonly TaskFormCompletion $completion, private readonly ?LoggerInterface $logger = null, private readonly ?IGroupManager $groupManager = null, private readonly ?IURLGenerator $urlGenerator = null, @@ -226,13 +244,20 @@ public function index( }//end index() /** - * One task, visibility-checked. + * One task, visibility-checked, with the form it presents. + * + * The row carries `form` (null when the step declares none, else the + * resolved description with each field's `required` and `order` from the + * declaration) and `requireChecklist`, so the completion surface needs no + * second round-trip. Derived on this read from the pinned declaration + * and the live schema; nothing is stored. * * @param string $uuid The task uuid. * * @return JSONResponse The task row; 404 when absent OR invisible. * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-rendered-form-carries-the-declarations-required-flags-and-order */ #[NoAdminRequired] #[NoCSRFRequired] @@ -250,7 +275,10 @@ public function show(string $uuid): JSONResponse { } return new JSONResponse( - $this->inbox->row(task: $task, subjects: [], now: $this->temporal->now()) + array_merge( + $this->inbox->row(task: $task, subjects: [], now: $this->temporal->now()), + $this->forms->describe(task: $task) + ) ); }//end show() @@ -437,20 +465,37 @@ public function resolve(string $uuid, ?string $resultText = null, ?string $comme * @param string|null $resultText Free-text result. * @param string|null $comment Completion comment — MANDATORY on a * rejecting or returning outcome. + * @param mixed $data The declared form field values, as an object; the + * same key the object transition endpoint uses. Absent + * for a form-less completion. An undeclared key or a + * missing required input is a 400 naming the fields; + * a value the subject schema refuses is a 422. * * @return JSONResponse The completed task, or a named refusal. * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else */ #[NoAdminRequired] #[NoCSRFRequired] - public function complete(string $uuid, string $outcome = 'done', ?string $resultText = null, ?string $comment = null): JSONResponse { + public function complete( + string $uuid, + string $outcome = 'done', + ?string $resultText = null, + ?string $comment = null, + mixed $data = null, + ): JSONResponse { + if ($data !== null && is_array($data) === false) { + return new JSONResponse(['error' => 'Field "data" must be an object of field values.'], Http::STATUS_BAD_REQUEST); + } + return $this->respondWith( - verb: fn (): Task => $this->tasks->complete( + verb: fn (): Task => $this->completion->complete( uuid: $uuid, outcome: $outcome, resultText: $resultText, comment: $comment, + data: ($data ?? []), actor: $this->uid() ), uuid: $uuid @@ -502,12 +547,15 @@ public function checkItem(string $uuid, string $itemId, mixed $checked = 'true') /** * Run a verb and translate its refusals to HTTP, uniformly. * - * One translation so no verb can drift: validation 400; denial 403 for - * a caller who may READ the task and 404 for one who may not (so a - * stranger cannot confirm a uuid by probing a verb); conflict 409 (the - * current state in the message, per the spec); absence 404; everything - * else a LOGGED 500 with a generic message, never the exception text, - * which for a database failure carries SQL and bound parameters. + * One translation so no verb can drift: validation 400, with the + * offending `fields` and the refusal `kind` beside the message when a + * form payload was refused; a subject write the schema or lifecycle + * refused 422; denial 403 for a caller who may READ the task and 404 for + * one who may not (so a stranger cannot confirm a uuid by probing a + * verb); conflict 409 (the current state in the message, per the spec); + * absence 404; everything else a LOGGED 500 with a generic message, + * never the exception text, which for a database failure carries SQL and + * bound parameters. * * @param callable(): Task $verb The service call. * @param int $successStatus The status of the happy path. @@ -516,6 +564,7 @@ public function checkItem(string $uuid, string $itemId, mixed $checked = 'true') * @return JSONResponse The task, or the refusal. * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-validation-failure-names-its-fields-and-completes-nothing */ private function respondWith(callable $verb, int $successStatus = Http::STATUS_OK, ?string $uuid = null): JSONResponse { try { @@ -525,8 +574,24 @@ private function respondWith(callable $verb, int $successStatus = Http::STATUS_O $this->inbox->row(task: $task, subjects: [], now: $this->temporal->now()), $successStatus ); + } catch (TaskFormRefusedException $refused) { + // The form contract's refusal: the fields machine-readable next + // to the message, and the kind, so a client can flag each field + // and tell an undeclared key from a missing required input. + return new JSONResponse( + [ + 'error' => $refused->getMessage(), + 'fields' => $refused->getFields(), + 'kind' => $refused->getKind(), + ], + Http::STATUS_BAD_REQUEST + ); } catch (TaskValidationException $refused) { return new JSONResponse(['error' => $refused->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (TaskSubjectWriteRefusedException $refused) { + // The payload passed the form and the SUBJECT refused it, on the + // ordinary save path: not malformed, not completed. + return new JSONResponse(['error' => $refused->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); } catch (TaskAccessDeniedException $denied) { if ($uuid !== null && $this->mayReadUuid(uuid: $uuid) === false) { return new JSONResponse(['error' => 'No such task'], Http::STATUS_NOT_FOUND); diff --git a/lib/Exception/TaskFormRefusedException.php b/lib/Exception/TaskFormRefusedException.php new file mode 100644 index 0000000000..f52e5faf46 --- /dev/null +++ b/lib/Exception/TaskFormRefusedException.php @@ -0,0 +1,110 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-validation-failure-names-its-fields-and-completes-nothing + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +/** + * A refused completion payload, naming the fields and the kind of refusal. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-validation-failure-names-its-fields-and-completes-nothing + */ +class TaskFormRefusedException extends TaskValidationException { + + /** + * The payload carried a key the form did not declare. + * + * @var string + */ + public const KIND_UNDECLARED = 'undeclared'; + + /** + * A required input was absent or an empty string. + * + * @var string + */ + public const KIND_MISSING = 'missing'; + + /** + * A mandatory checklist item is still unchecked. + * + * @var string + */ + public const KIND_CHECKLIST = 'checklist'; + + /** + * The form itself could not be resolved, so nothing can be completed. + * + * @var string + */ + public const KIND_UNRESOLVABLE = 'unresolvable'; + + /** + * The task has no subject object for the values to be written to. + * + * @var string + */ + public const KIND_NO_SUBJECT = 'no-subject'; + + /** + * Constructor. + * + * @param string $message The human message, naming the fields. + * @param string $kind One of the KIND_* constants. + * @param array $fields The offending field names or checklist item ids. + */ + public function __construct( + string $message, + private readonly string $kind, + private readonly array $fields = [], + ) { + parent::__construct(message: $message); + + }//end __construct() + + /** + * Which kind of refusal this is. + * + * @return string One of the KIND_* constants. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-validation-failure-names-its-fields-and-completes-nothing + */ + public function getKind(): string { + return $this->kind; + }//end getKind() + + /** + * The offending field names or checklist item ids. + * + * @return array The names. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-validation-failure-names-its-fields-and-completes-nothing + */ + public function getFields(): array { + return $this->fields; + }//end getFields() +}//end class diff --git a/lib/Exception/TaskSubjectWriteRefusedException.php b/lib/Exception/TaskSubjectWriteRefusedException.php new file mode 100644 index 0000000000..25023a9c35 --- /dev/null +++ b/lib/Exception/TaskSubjectWriteRefusedException.php @@ -0,0 +1,41 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * The subject object's own validation or lifecycle refused the completion's write. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ +class TaskSubjectWriteRefusedException extends RuntimeException { +}//end class diff --git a/lib/Service/Flow/Nodes/UserTaskConfig.php b/lib/Service/Flow/Nodes/UserTaskConfig.php index 8ab6dfb398..f474824958 100644 --- a/lib/Service/Flow/Nodes/UserTaskConfig.php +++ b/lib/Service/Flow/Nodes/UserTaskConfig.php @@ -24,6 +24,7 @@ * @link https://OpenRegister.app * * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved */ declare(strict_types=1); @@ -36,6 +37,7 @@ use OCA\OpenRegister\Service\Flow\FlowItems; use OCA\OpenRegister\Service\Flow\FlowTaskBridge; use OCA\OpenRegister\Service\Flow\FlowValueTemplate; +use OCA\OpenRegister\Service\Task\TaskFormReader; use OCP\IL10N; use UnexpectedValueException; @@ -80,9 +82,11 @@ final class UserTaskConfig { * Constructor. * * @param IL10N $l10n Translations, for refusal messages an author reads. + * @param TaskFormReader $forms Reads and refuses the step's form declaration. */ public function __construct( private readonly IL10N $l10n, + private readonly TaskFormReader $forms, ) { }//end __construct() @@ -96,6 +100,12 @@ public function __construct( * is refused by name so an author who asked for unlimited never silently * gets zero. * + * The form declaration is checked here too, against the LIVE subject + * schema: a field that is not a property, or is read-only or not visible, + * an action the schema does not declare, or an external form without the + * Forms app, is refused now, naming schema, field and reason. Left to run + * time it would surface as a refusal the performer cannot act on. + * * @param array $config The step configuration. * * @return void @@ -103,6 +113,7 @@ public function __construct( * @throws UnexpectedValueException When the config is refused. * * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved */ public function validate(array $config): void { if (trim((string)($config['title'] ?? '')) === '') { @@ -138,6 +149,9 @@ public function validate(array $config): void { // Throws its own message, which names the value and states the spelling. FlowAdvanceBudget::fromConfig(config: $config); + // Throws naming schema, field and reason; a step with no form passes. + $this->forms->validate(form: $this->forms->fromConfig(config: $config)); + }//end validate() /** diff --git a/lib/Service/Flow/Nodes/UserTaskNode.php b/lib/Service/Flow/Nodes/UserTaskNode.php index 2e9218f2b9..aa254479e3 100644 --- a/lib/Service/Flow/Nodes/UserTaskNode.php +++ b/lib/Service/Flow/Nodes/UserTaskNode.php @@ -55,6 +55,7 @@ * @link https://OpenRegister.app * * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-task-form-is-a-declaration-of-existing-fields-not-a-new-form-definition */ declare(strict_types=1); @@ -70,6 +71,7 @@ use OCA\OpenRegister\Service\Flow\IFlowNode; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; +use OCA\OpenRegister\Service\Task\TaskFormReader; use OCP\IL10N; use OCP\IURLGenerator; use OCP\WorkflowEngine\IManager; @@ -78,6 +80,10 @@ /** * Creates one task, suspends until it is terminal, and routes on the outcome. * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The thirteenth dependency + * is the task-form reader, which the node needs so a form declaration is + * refused when the step is SAVED rather than when the performer meets it; + * dropping a name here would move that failure onto the performer. * @SuppressWarnings(PHPMD.StaticAccess) FlowTaskBridge::outcomeBagFor is a * stateless helper over a value; a factory to call it would add a dependency * to say the same thing. @@ -97,6 +103,7 @@ class UserTaskNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigFor * @param FlowTaskBridge $bridge Creates and reads the node's task. * @param IL10N $l10n Translations. * @param IURLGenerator $urls For the palette icon. + * @param TaskFormReader $forms Reads and refuses the step's form declaration. * * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md */ @@ -104,8 +111,9 @@ public function __construct( private readonly FlowTaskBridge $bridge, private readonly IL10N $l10n, private readonly IURLGenerator $urls, + TaskFormReader $forms, ) { - $this->config = new UserTaskConfig(l10n: $l10n); + $this->config = new UserTaskConfig(l10n: $l10n, forms: $forms); }//end __construct() @@ -194,24 +202,27 @@ public function configKeys(): array { 'failOnReject', 'heartbeatMinutes', 'advance', + ...TaskFormReader::CONFIG_KEYS, ]; }//end configKeys() /** * The fields this node is edited through, in the order the spec names them: * what the task is, who may perform it, how urgent and when, and how the - * flow continues. + * flow continues, and what the performer fills in. * * @return array> The field descriptions. * * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-the-node-describes-its-own-form-served-from-the-node-catalog + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved */ public function configForm(): array { return array_merge( $this->whatFields(), $this->whoFields(), $this->whenFields(), - $this->continuationFields() + $this->continuationFields(), + $this->formFields() ); }//end configForm() @@ -568,4 +579,69 @@ private function continuationFields(): array { ], ]; }//end continuationFields() + + /** + * What the performer fills in. + * + * Flat keys, like the rest of the vocabulary, so the server-driven config + * form draws them without an editor change. The field list is a + * constrained pick from the subject schema's properties, never a + * free-typed name: a misspelled field is refused when the step is saved, + * so the performer never meets a refusal they cannot act on. + * + * @return array> The field descriptions. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved + */ + private function formFields(): array { + return [ + [ + 'key' => 'formKind', + 'label' => $this->l10n->t('Form'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'What the performer fills in besides the outcome: leave empty for none, "fields" for fields of the subject object, ' + . '"external" for a Nextcloud Forms form bound to the subject. Fields are validated by the subject schema; ' + . 'an external form is recorded as evidence and writes nothing to the object.' + ), + ], + [ + 'key' => 'formSchema', + 'label' => $this->l10n->t('Subject schema'), + 'type' => 'select', + 'optionsFrom' => '/apps/openregister/api/schemas', + 'help' => $this->l10n->t('The schema the fields belong to. Required for a field form.'), + ], + [ + 'key' => 'formAction', + 'label' => $this->l10n->t('Lifecycle action'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'Inherit the fields a lifecycle transition of the subject schema declares, and apply that transition on completion. ' + . 'Leave empty to list fields instead.' + ), + ], + [ + 'key' => 'formFields', + 'label' => $this->l10n->t('Fields to ask for'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'Properties of the subject schema the performer supplies, comma separated; add * after a name to make it required, ' + . 'like "reason*". A name the schema does not have is refused when the step is saved. Not combined with a lifecycle action.' + ), + ], + [ + 'key' => 'formId', + 'label' => $this->l10n->t('Nextcloud Forms form'), + 'type' => 'number', + 'help' => $this->l10n->t('The id of the Forms form linked to the subject object. Needs the Forms app.'), + ], + [ + 'key' => 'formRequireChecklist', + 'label' => $this->l10n->t('Require every checklist item checked'), + 'type' => 'boolean', + 'help' => $this->l10n->t('Refuse completion while a checklist item is unchecked, naming it. Checklist state is never part of the form values.'), + ], + ]; + }//end formFields() }//end class diff --git a/lib/Service/Lifecycle/TransitionEngine.php b/lib/Service/Lifecycle/TransitionEngine.php index 2401873bfa..329f581551 100644 --- a/lib/Service/Lifecycle/TransitionEngine.php +++ b/lib/Service/Lifecycle/TransitionEngine.php @@ -33,6 +33,7 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Event\ObjectTransitionedEvent; +use OCA\OpenRegister\Exception\HookStoppedException; use OCA\OpenRegister\Exception\InvalidTransitionInputException; use OCA\OpenRegister\Exception\NotAuthorizedException; use OCA\OpenRegister\Service\Object\PermissionHandler; @@ -245,9 +246,15 @@ private function transitionEventScope(ObjectEntity $object): array { * @throws RuntimeException When the object/schema/transition is missing, * the action is not allowed from the current * state, or the underlying save is rejected. + * @throws NotAuthorizedException When the caller lacks `update` permission + * on the object. * @throws InvalidTransitionInputException When `$data` contains a key the * transition does not declare, or a `required` * input is absent or empty-string. + * @throws \Exception When the save path refuses the merged write — schema + * validation, readOnly enforcement or a vetoing + * hook ({@see HookStoppedException}) — exactly as + * it would refuse any other object write. * * @SuppressWarnings(PHPMD.ExcessiveMethodLength) Linear resolve→guard→mutate→save flow; splitting would obscure the transition contract. * @@ -388,7 +395,13 @@ public function transition(string $objectId, string $action, array $data = []): * * @param string $objectId Object id/uuid/slug. * - * @return array + * Every entry carries the transition's declared `inputs` in the contract's + * own shape (`[{field, required}]`), EMPTY rather than absent when the + * transition declares none: empty is the positive statement "this + * transition accepts no payload", which is exactly what the allowlist in + * {@see resolveTransitionInputs()} enforces. + * + * @return list,label?:string}> * * @SuppressWarnings(PHPMD.CyclomaticComplexity) RBAC check + missing-object guard + annotation-absent * guard + per-transition from/requires/description checks each add one branch; none can be removed @@ -399,6 +412,7 @@ public function transition(string $objectId, string $action, array $data = []): * * @spec openspec/specs/object-lifecycle/spec.md * @spec openspec/changes/fk-graph-lifecycle-transitions/specs/object-lifecycle/spec.md + * @spec openspec/changes/flow-task-forms/specs/object-lifecycle/spec.md#requirement-the-available-actions-response-must-publish-each-actions-declared-inputs */ public function availableActions(string $objectId): array { $object = $this->objectService->find(id: $objectId); @@ -479,6 +493,7 @@ public function availableActions(string $objectId): array { 'to' => (string)($spec['to'] ?? ''), 'requires' => $requires, 'description' => $description, + 'inputs' => $this->publishedInputs(inputs: (array)($spec['inputs'] ?? [])), ]; }//end foreach @@ -502,7 +517,7 @@ public function availableActions(string $objectId): array { * @param array $graph The `graph` block off the annotation. * @param string $field The lifecycle field name on the object. * - * @return array + * @return list}> * * @SuppressWarnings(PHPMD.CyclomaticComplexity) FK read + sibling fetch + current-state * location + per-move-policy branching are each irreducible steps of the derivation. @@ -650,9 +665,13 @@ function (ObjectEntity $a, ObjectEntity $b) use ($orderField): int { * * @param ObjectEntity $sibling The target sibling to move to. * - * @return array{action: string, to: string, label: string, requires: ?string, description: ?string} + * A graph-derived action declares no `inputs`, and says so: the key is + * present and empty, so a client handles static and graph responses alike. + * + * @return array{action:string, to:string, label:string, requires:?string, description:?string, inputs:array} * * @spec openspec/changes/fk-graph-lifecycle-transitions/specs/object-lifecycle/spec.md + * @spec openspec/changes/flow-task-forms/specs/object-lifecycle/spec.md#requirement-the-available-actions-response-must-publish-each-actions-declared-inputs */ private function buildGraphAction(ObjectEntity $sibling): array { $uuid = (string)$sibling->getUuid(); @@ -668,9 +687,72 @@ private function buildGraphAction(ObjectEntity $sibling): array { 'label' => (string)$name, 'requires' => null, 'description' => null, + 'inputs' => [], ]; }//end buildGraphAction() + /** + * A transition's declared `inputs`, normalised to the contract's shape. + * + * Null when the schema declares no lifecycle annotation or no static + * transition of that name, so a caller can tell "declares nothing" (an + * empty list) from "no such transition". Graph-mode schemas declare no + * static transitions and therefore answer null for every action; their + * derived `move-to-*` actions accept no payload, which is what + * {@see availableActions()} publishes for them. + * + * The second consumer of the contract, next to the write path: a + * user-task step that names an action inherits this list as its form, + * verbatim, so a schema change and a form change are the same edit. + * + * @param Schema $schema The schema whose lifecycle declares the transition. + * @param string $action The transition action name. + * + * @return array|null The declared inputs, or null when the action is not declared. + * + * @spec openspec/changes/flow-task-forms/specs/object-lifecycle/spec.md#requirement-a-transition-may-declare-inputs-bounding-the-payload-it-accepts + */ + public function declaredInputs(Schema $schema, string $action): ?array { + $annotation = $this->getLifecycleAnnotation(schema: $schema); + if ($annotation === null) { + return null; + } + + $transitions = (array)($annotation['transitions'] ?? []); + $spec = ($transitions[$action] ?? null); + if (is_array($spec) === false) { + return null; + } + + return $this->publishedInputs(inputs: (array)($spec['inputs'] ?? [])); + }//end declaredInputs() + + /** + * The response shape of a declared `inputs` list: `[{field, required}]`. + * + * Runs the declaration through the SAME normalisation the allowlist uses, + * so what a client is told the transition accepts is exactly what the + * write path will accept; a malformed entry the allowlist skips is not + * published either. + * + * @param array $inputs The transition's declared `inputs` list. + * + * @return array The published list, in declaration order. + * + * @spec openspec/changes/flow-task-forms/specs/object-lifecycle/spec.md#requirement-the-available-actions-response-must-publish-each-actions-declared-inputs + */ + private function publishedInputs(array $inputs): array { + $published = []; + foreach ($this->normaliseDeclaredInputs(inputs: $inputs) as $field => $required) { + $published[] = [ + 'field' => (string)$field, + 'required' => $required, + ]; + } + + return $published; + }//end publishedInputs() + /** * Validate a transition `data` payload against the declared `inputs` allowlist. * @@ -683,6 +765,13 @@ private function buildGraphAction(ObjectEntity $sibling): array { * carrying object write, so the standard save-path validation (and readOnly * enforcement) applies to them exactly like any other object write. * + * Public since the task form became its second caller: a user-task + * completion that carries field values runs THIS allowlist over its + * payload before writing the subject object, so the form layer never + * grows a validator of its own. The signature and the throws are the + * write path's, unchanged; a test asserts both callers refuse the same + * payloads identically. + * * @param array $inputs The transition's declared `inputs` list. * @param array $data The caller-supplied payload. * @param string $action The transition action name, for error messages. @@ -693,8 +782,9 @@ private function buildGraphAction(ObjectEntity $sibling): array { * key, or a `required` input is absent or empty-string. * * @spec openspec/specs/object-lifecycle/spec.md + * @spec openspec/changes/flow-task-forms/specs/object-lifecycle/spec.md#requirement-a-transition-may-declare-inputs-bounding-the-payload-it-accepts */ - private function resolveTransitionInputs(array $inputs, array $data, string $action): array { + public function resolveTransitionInputs(array $inputs, array $data, string $action): array { $declared = $this->normaliseDeclaredInputs(inputs: $inputs); // Reject any payload key the transition does not declare. diff --git a/lib/Service/Task/TaskForm.php b/lib/Service/Task/TaskForm.php new file mode 100644 index 0000000000..79ed43515f --- /dev/null +++ b/lib/Service/Task/TaskForm.php @@ -0,0 +1,141 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-task-form-is-a-declaration-of-existing-fields-not-a-new-form-definition + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +/** + * A step's completion declaration: its form, if any, and its checklist rule. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) `requireChecklist` is a stored + * fact on a readonly value object, constructed with named arguments by one + * reader; it selects no behaviour in this class, which is what the rule is + * actually about. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-task-form-is-a-declaration-of-existing-fields-not-a-new-form-definition + */ +final class TaskForm { + + /** + * The native kind: fields of the subject schema. + * + * @var string + */ + public const KIND_FIELDS = 'fields'; + + /** + * The external kind: a Nextcloud Forms form bound to the subject. + * + * @var string + */ + public const KIND_EXTERNAL = 'external'; + + /** + * The two kinds. No third exists. + * + * @var array + */ + public const KINDS = [self::KIND_FIELDS, self::KIND_EXTERNAL]; + + /** + * Constructor. + * + * @param string|null $kind One of KINDS, or null when the step declares no form. + * @param string $schema The subject schema reference (id, uuid or slug); '' when not native. + * @param string|null $action The lifecycle action whose inputs are the field list, or null. + * @param array $fields The inline field list, in declared order. + * @param int|null $formId The Nextcloud Forms form id, for the external kind. + * @param bool $requireChecklist Whether every checklist item must be checked before completion. + */ + public function __construct( + public readonly ?string $kind, + public readonly string $schema = '', + public readonly ?string $action = null, + public readonly array $fields = [], + public readonly ?int $formId = null, + public readonly bool $requireChecklist = false, + ) { + + }//end __construct() + + /** + * Whether the step declares a form at all. + * + * @return bool True for either kind; false for outcome-and-comment completion. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-task-form-is-a-declaration-of-existing-fields-not-a-new-form-definition + */ + public function hasForm(): bool { + return $this->kind !== null; + }//end hasForm() + + /** + * Whether the form is the native, field-list kind. + * + * @return bool True when fields of the subject schema are asked for. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-task-form-is-a-declaration-of-existing-fields-not-a-new-form-definition + */ + public function isNative(): bool { + return $this->kind === self::KIND_FIELDS; + }//end isNative() + + /** + * Whether the form is the external, Nextcloud Forms kind. + * + * @return bool True when a bound Forms form is the way to finish. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-external-form-path-binds-an-existing-forms-form-and-validates-nothing-about-its-contents + */ + public function isExternal(): bool { + return $this->kind === self::KIND_EXTERNAL; + }//end isExternal() + + /** + * The record shape: what a run-less task carries under `metadata.form`. + * + * @return array The declaration, ready to store or serialise. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-form-a-task-presents-is-the-one-its-flow-version-declared + */ + public function toArray(): array { + return [ + 'kind' => $this->kind, + 'schema' => $this->schema, + 'action' => $this->action, + 'fields' => $this->fields, + 'formId' => $this->formId, + 'requireChecklist' => $this->requireChecklist, + ]; + }//end toArray() +}//end class diff --git a/lib/Service/Task/TaskFormCompletion.php b/lib/Service/Task/TaskFormCompletion.php new file mode 100644 index 0000000000..2eeb3206d7 --- /dev/null +++ b/lib/Service/Task/TaskFormCompletion.php @@ -0,0 +1,374 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Exception\HookStoppedException; +use OCA\OpenRegister\Exception\InvalidTransitionInputException; +use OCA\OpenRegister\Exception\NotAuthorizedException; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskFormRefusedException; +use OCA\OpenRegister\Exception\TaskSubjectWriteRefusedException; +use OCA\OpenRegister\Exception\ValidationException; +use OCA\OpenRegister\Service\Lifecycle\TransitionEngine; +use OCA\OpenRegister\Service\ObjectService; +use OCP\IUserSession; +use RuntimeException; + +/** + * Validates a completion payload through the lifecycle allowlist and writes it before completing. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) This class IS the seam + * between the task verb and the object write: it has to name the task + * service, the resolver, the engine, the object service and the five + * exception shapes it translates between. Splitting it would put the two + * halves of "complete with a payload" in two files that must agree on order. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ +class TaskFormCompletion { + + /** + * The action name the allowlist reports for an inline field list. + * + * @var string + */ + private const INLINE_ACTION = 'complete'; + + /** + * Constructor. + * + * @param TaskService $tasks The authorized task lifecycle. + * @param TaskFormResolver $forms Resolves the task's form. + * @param TransitionEngine $engine The one allowlist, and the transition write. + * @param ObjectService $objects The ordinary object write, for an inline field list. + * @param IUserSession $userSession The acting identity the object write authorizes. + */ + public function __construct( + private readonly TaskService $tasks, + private readonly TaskFormResolver $forms, + private readonly TransitionEngine $engine, + private readonly ObjectService $objects, + private readonly IUserSession $userSession, + ) { + + }//end __construct() + + /** + * Complete a task, writing its form payload to the subject first. + * + * @param string $uuid The task uuid. + * @param string $outcome The completion outcome. + * @param string|null $resultText Free-text result. + * @param string|null $comment Completion comment. + * @param array $data The declared field values; empty for a form-less completion. + * @param string|null $actor The completing identity. + * + * @return Task The completed task. + * + * @throws TaskFormRefusedException When the payload violates the form (400, fields named). + * @throws TaskSubjectWriteRefusedException When the subject's own validation or lifecycle refuses the write (422). + * @throws TaskAccessDeniedException When the task verb or the object write is not authorized. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ + public function complete(string $uuid, string $outcome, ?string $resultText, ?string $comment, array $data, ?string $actor): Task { + // The task verb's authorization FIRST, fail-closed and audited on + // denial, before a single byte reaches the subject object. + $task = $this->tasks->authorizedOpenTask(verb: 'complete', uuid: $uuid, actor: $actor); + + try { + $described = $this->forms->describe(task: $task); + $this->refuseUnresolvable(described: $described); + $this->refuseUncheckedItems(task: $task, required: $described['requireChecklist']); + $this->writeSubject(task: $task, form: $described['form'], data: $data); + } catch (TaskFormRefusedException | TaskSubjectWriteRefusedException $refused) { + // A refused attempt, distinguishable from a completion: the task + // keeps its pre-call state and the audit says somebody tried. + $this->tasks->recordRefusedCompletion(task: $task, reason: $refused->getMessage(), actor: $actor); + + throw $refused; + } + + return $this->tasks->complete(uuid: $uuid, outcome: $outcome, resultText: $resultText, comment: $comment, actor: $actor); + }//end complete() + + /** + * An unresolvable form completes nothing: an empty form would report success for a task that required evidence. + * + * @param array{form: array|null, requireChecklist: bool} $described The resolved surface. + * + * @return void + * + * @throws TaskFormRefusedException When the form's state is unresolvable. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-form-a-task-presents-is-the-one-its-flow-version-declared + */ + private function refuseUnresolvable(array $described): void { + $form = $described['form']; + if ($form !== null && ($form['state'] ?? null) === TaskFormResolver::STATE_UNRESOLVABLE) { + throw new TaskFormRefusedException( + message: (string)($form['error'] ?? 'The form of this task cannot be resolved.'), + kind: TaskFormRefusedException::KIND_UNRESOLVABLE + ); + } + }//end refuseUnresolvable() + + /** + * The checklist precondition: every item checked, or the completion is refused naming the unchecked ones. + * + * A checklist item is task state, written through the task's own verb; it + * is never a field and never enters the allowlist. It refuses the same way + * a missing required field does: named, not completed, run not advanced. + * + * @param Task $task The task. + * @param bool $required Whether the step requires every item checked. + * + * @return void + * + * @throws TaskFormRefusedException When an item is still unchecked. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-checklist-is-presented-beside-the-field-form-never-merged-into-it + */ + private function refuseUncheckedItems(Task $task, bool $required): void { + if ($required === false) { + return; + } + + $unchecked = []; + foreach (($task->getChecklist() ?? []) as $item) { + if (is_array($item) === false) { + continue; + } + + if (filter_var(($item['checked'] ?? false), FILTER_VALIDATE_BOOLEAN) === false) { + $unchecked[] = (string)($item['id'] ?? ($item['label'] ?? '')); + } + } + + if ($unchecked !== []) { + throw new TaskFormRefusedException( + message: sprintf( + 'Every checklist item must be checked before this task can be completed; still unchecked: %s.', + '"' . implode('", "', $unchecked) . '"' + ), + kind: TaskFormRefusedException::KIND_CHECKLIST, + fields: $unchecked + ); + } + }//end refuseUncheckedItems() + + /** + * Write the payload to the subject through one of the two existing paths, never a third. + * + * @param Task $task The task. + * @param array|null $form The resolved form description. + * @param array $data The submitted field values. + * + * @return void + * + * @throws TaskFormRefusedException When the payload violates the allowlist. + * @throws TaskSubjectWriteRefusedException When the subject refuses the write. + * @throws TaskAccessDeniedException When the object write is not authorized. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ + private function writeSubject(Task $task, ?array $form, array $data): void { + if ($form === null || ($form['kind'] ?? null) !== TaskForm::KIND_FIELDS) { + // No form, or an external one: no field values are accepted at + // all. An empty payload skips the allowlist entirely, so a + // form-less completion is exactly what it was before. + if ($data !== []) { + $this->allowlist(declared: [], data: $data, action: self::INLINE_ACTION); + } + + return; + } + + $objectUuid = trim((string)$task->getObjectUuid()); + if ($objectUuid === '') { + throw new TaskFormRefusedException( + message: 'This task has no subject object, so its form values have nowhere to be written.', + kind: TaskFormRefusedException::KIND_NO_SUBJECT + ); + } + + $action = ($form['action'] ?? null); + if (is_string($action) === true && $action !== '') { + $this->transition(objectUuid: $objectUuid, action: $action, data: $data); + + return; + } + + $declared = []; + foreach ((array)($form['fields'] ?? []) as $field) { + $declared[] = [ + 'field' => (string)$field['field'], + 'required' => (bool)$field['required'], + ]; + } + + $accepted = $this->allowlist(declared: $declared, data: $data, action: self::INLINE_ACTION); + if ($accepted === []) { + return; + } + + $this->save(objectUuid: $objectUuid, accepted: $accepted); + }//end writeSubject() + + /** + * The ONE allowlist, with its refusal translated to the task's 400 shape. + * + * The kind is derived from the declaration, not parsed from the message: + * a refused field the form declared was missing, one it did not was + * undeclared. + * + * @param array $declared The declared fields. + * @param array $data The submitted values. + * @param string $action The action name, for the message. + * + * @return array The accepted values. + * + * @throws TaskFormRefusedException When the allowlist refuses. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-validation-failure-names-its-fields-and-completes-nothing + */ + private function allowlist(array $declared, array $data, string $action): array { + try { + return $this->engine->resolveTransitionInputs(inputs: $declared, data: $data, action: $action); + } catch (InvalidTransitionInputException $refused) { + throw $this->refusal(refused: $refused, declared: $declared); + } + }//end allowlist() + + /** + * The transition path: allowlist, merge and lifecycle flip in one save. + * + * @param string $objectUuid The subject object. + * @param string $action The lifecycle action. + * @param array $data The submitted values. + * + * @return void + * + * @throws TaskFormRefusedException When the allowlist refuses. + * @throws TaskAccessDeniedException When the caller may not transition the object. + * @throws TaskSubjectWriteRefusedException When the schema or the lifecycle refuses. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ + private function transition(string $objectUuid, string $action, array $data): void { + try { + $this->engine->transition(objectId: $objectUuid, action: $action, data: $data); + } catch (InvalidTransitionInputException $refused) { + // The engine's kind is not recoverable from its declaration here, + // so read it off the message the same method wrote. + throw $this->refusal(refused: $refused, declared: null); + } catch (NotAuthorizedException $denied) { + throw new TaskAccessDeniedException(message: $denied->getMessage()); + } catch (HookStoppedException | ValidationException | CustomValidationException | RuntimeException $refused) { + throw new TaskSubjectWriteRefusedException(message: $refused->getMessage(), previous: $refused); + } + }//end transition() + + /** + * The inline path: the accepted values through the ordinary object write. + * + * @param string $objectUuid The subject object. + * @param array $accepted The values the allowlist accepted. + * + * @return void + * + * @throws TaskAccessDeniedException When the caller may not write the object. + * @throws TaskSubjectWriteRefusedException When the schema refuses, or the object is gone. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ + private function save(string $objectUuid, array $accepted): void { + $object = $this->objects->find(id: $objectUuid); + if ($object === null) { + throw new TaskSubjectWriteRefusedException( + message: sprintf('Subject object "%s" no longer exists, so the form values cannot be written.', $objectUuid) + ); + } + + try { + $this->objects->saveObject( + object: array_merge($object->getObject(), $accepted), + register: $object->getRegister(), + schema: $object->getSchema(), + uuid: $object->getUuid(), + currentUser: $this->userSession->getUser() + ); + } catch (NotAuthorizedException $denied) { + throw new TaskAccessDeniedException(message: $denied->getMessage()); + } catch (HookStoppedException | ValidationException | CustomValidationException $refused) { + throw new TaskSubjectWriteRefusedException(message: $refused->getMessage(), previous: $refused); + } + }//end save() + + /** + * The task-side 400 for an allowlist refusal, its kind derived rather than guessed. + * + * @param InvalidTransitionInputException $refused The engine's refusal. + * @param array|null $declared The declaration, when this class holds it. + * + * @return TaskFormRefusedException The translated refusal. + */ + private function refusal(InvalidTransitionInputException $refused, ?array $declared): TaskFormRefusedException { + $fields = $refused->getFields(); + $kind = TaskFormRefusedException::KIND_UNDECLARED; + + if ($declared !== null) { + $names = array_column($declared, 'field'); + if ($fields !== [] && array_diff($fields, $names) === []) { + $kind = TaskFormRefusedException::KIND_MISSING; + } + } else if (str_contains($refused->getMessage(), 'missing required') === true) { + $kind = TaskFormRefusedException::KIND_MISSING; + } + + return new TaskFormRefusedException(message: $refused->getMessage(), kind: $kind, fields: $fields); + }//end refusal() +}//end class diff --git a/lib/Service/Task/TaskFormReader.php b/lib/Service/Task/TaskFormReader.php new file mode 100644 index 0000000000..0a4dda9a7b --- /dev/null +++ b/lib/Service/Task/TaskFormReader.php @@ -0,0 +1,480 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Lifecycle\TransitionEngine; +use OCP\App\IAppManager; +use OCP\IL10N; +use Throwable; +use UnexpectedValueException; + +/** + * Normalises and validates task form declarations against the live schema. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The complexity IS the + * refusal list: every branch is one named reason a declaration cannot be + * rendered (unknown kind, both spellings, duplicate field, absent property, + * read-only, invisible, undeclared action, missing app). Splitting reader + * from refuser would put a declaration's shape and its reasons in two files + * that must agree. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved + */ +class TaskFormReader { + + /** + * The Nextcloud app the external kind depends on. + * + * @var string + */ + public const FORMS_APP = 'forms'; + + /** + * The flat config keys a user-task step spells its form with. + * + * @var array + */ + public const CONFIG_KEYS = [ + 'formKind', + 'formSchema', + 'formAction', + 'formFields', + 'formId', + 'formRequireChecklist', + ]; + + /** + * Constructor. + * + * @param SchemaMapper $schemas Resolves the subject schema. + * @param TransitionEngine $engine Reads a transition's declared inputs. + * @param IAppManager $apps Answers whether the Forms app is installed. + * @param IL10N $l10n Translations, for refusals an author reads. + */ + public function __construct( + private readonly SchemaMapper $schemas, + private readonly TransitionEngine $engine, + private readonly IAppManager $apps, + private readonly IL10N $l10n, + ) { + + }//end __construct() + + /** + * The declaration a user-task step's flat config spells. + * + * @param array $config The step configuration. + * + * @return TaskForm The declaration; `hasForm()` is false when `formKind` is empty. + * + * @throws UnexpectedValueException When the declaration is malformed. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-task-form-is-a-declaration-of-existing-fields-not-a-new-form-definition + */ + public function fromConfig(array $config): TaskForm { + return $this->fromRecord( + record: [ + 'kind' => ($config['formKind'] ?? null), + 'schema' => ($config['formSchema'] ?? null), + 'action' => ($config['formAction'] ?? null), + 'fields' => ($config['formFields'] ?? null), + 'formId' => ($config['formId'] ?? null), + 'requireChecklist' => ($config['formRequireChecklist'] ?? null), + ] + ); + }//end fromConfig() + + /** + * The declaration a task record carries under `metadata.form`. + * + * @param array $record The stored declaration. + * + * @return TaskForm The declaration; `hasForm()` is false when `kind` is empty. + * + * @throws UnexpectedValueException When the declaration is malformed. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-form-a-task-presents-is-the-one-its-flow-version-declared + */ + public function fromRecord(array $record): TaskForm { + $requireChecklist = filter_var(($record['requireChecklist'] ?? false), FILTER_VALIDATE_BOOLEAN); + + $kind = trim((string)($record['kind'] ?? '')); + if ($kind === '') { + $this->refuseOrphanedFormKeys(record: $record); + + return new TaskForm(kind: null, requireChecklist: $requireChecklist); + } + + if (in_array($kind, TaskForm::KINDS, true) === false) { + throw new UnexpectedValueException( + $this->l10n->t('Form kind "%1$s" is not one of %2$s.', [$kind, implode(', ', TaskForm::KINDS)]) + ); + } + + if ($kind === TaskForm::KIND_EXTERNAL) { + return new TaskForm( + kind: $kind, + formId: $this->formIdOf(record: $record), + requireChecklist: $requireChecklist + ); + } + + $action = $this->nullIfEmpty(value: trim((string)($record['action'] ?? ''))); + $fields = $this->fieldsOf(value: ($record['fields'] ?? null)); + if ($action !== null && $fields !== []) { + throw new UnexpectedValueException( + $this->l10n->t('Name a lifecycle action or list the fields, not both: the action already declares its fields.') + ); + } + + if ($action === null && $fields === []) { + throw new UnexpectedValueException( + $this->l10n->t('A field form needs a lifecycle action to inherit its fields from, or a list of fields.') + ); + } + + return new TaskForm( + kind: $kind, + schema: trim((string)($record['schema'] ?? '')), + action: $action, + fields: $fields, + requireChecklist: $requireChecklist + ); + }//end fromRecord() + + /** + * Refuse a declaration no performer could complete, naming what and why. + * + * @param TaskForm $form The declaration. + * + * @return void + * + * @throws UnexpectedValueException When the declaration is refused. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved + */ + public function validate(TaskForm $form): void { + if ($form->hasForm() === false) { + return; + } + + if ($form->isExternal() === true) { + $this->validateExternal(); + + return; + } + + $schema = $this->schema(reference: $form->schema); + foreach ($this->declaredFields(form: $form, schema: $schema) as $field) { + $reason = $this->unrenderableReason(schema: $schema, field: $field['field']); + if ($reason !== null) { + throw new UnexpectedValueException( + $this->l10n->t( + 'Field "%1$s" of schema "%2$s" cannot be asked for: %3$s', + [$field['field'], (string)$schema->getSlug(), $reason] + ) + ); + } + } + }//end validate() + + /** + * The fields a native form asks for: the action's declared inputs, or the inline list. + * + * @param TaskForm $form The declaration. + * @param Schema $schema The live subject schema. + * + * @return array The field list, in declared order. + * + * @throws UnexpectedValueException When the named action is not declared on the schema. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-task-form-is-a-declaration-of-existing-fields-not-a-new-form-definition + */ + public function declaredFields(TaskForm $form, Schema $schema): array { + if ($form->action === null) { + return $form->fields; + } + + $declared = $this->engine->declaredInputs(schema: $schema, action: $form->action); + if ($declared === null) { + throw new UnexpectedValueException( + $this->l10n->t( + 'Schema "%1$s" declares no lifecycle action "%2$s".', + [(string)$schema->getSlug(), $form->action] + ) + ); + } + + return $declared; + }//end declaredFields() + + /** + * Why a field of this schema cannot be rendered, or null when it can. + * + * The three reasons are the three the shared renderer drops a property for + * BEFORE it consults the field whitelist; a declared field hitting any of + * them renders nothing at all. + * + * @param Schema $schema The live subject schema. + * @param string $field The property name. + * + * @return string|null The reason, translated, or null when renderable. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved + */ + public function unrenderableReason(Schema $schema, string $field): ?string { + $properties = $schema->getProperties(); + if (array_key_exists($field, $properties) === false) { + return $this->l10n->t('the schema has no such property.'); + } + + $property = (array)$properties[$field]; + if (($property['readOnly'] ?? false) === true) { + return $this->l10n->t('the schema marks it read-only, so a submitted value would be refused.'); + } + + if (($property['visible'] ?? true) === false) { + return $this->l10n->t('the schema marks it not visible, so no form can show it.'); + } + + return null; + }//end unrenderableReason() + + /** + * The live subject schema a reference names. + * + * @param string $reference The schema id, uuid or slug. + * + * @return Schema The schema. + * + * @throws UnexpectedValueException When the reference is empty or names no schema. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved + */ + public function schema(string $reference): Schema { + if (trim($reference) === '') { + throw new UnexpectedValueException( + $this->l10n->t('A field form must name the subject schema its fields belong to.') + ); + } + + try { + // Multitenancy off: a flow's step is authored against a schema by + // reference and the performer may sit in another organisation; + // what may be WRITTEN is decided on the save path, not here. + return $this->schemas->find($reference, _multitenancy: false); + } catch (Throwable) { + throw new UnexpectedValueException( + $this->l10n->t('Schema "%1$s" does not exist.', [$reference]) + ); + } + }//end schema() + + /** + * Refuse an external form where the Forms app is absent. + * + * Moved from completion time, where the link service would raise it in + * front of the performer, to save time, where the author can act on it. + * + * @return void + * + * @throws UnexpectedValueException When the Forms app is not installed. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-external-form-path-binds-an-existing-forms-form-and-validates-nothing-about-its-contents + */ + private function validateExternal(): void { + if ($this->apps->isInstalled(self::FORMS_APP) === false) { + throw new UnexpectedValueException( + $this->l10n->t('An external form needs the Nextcloud Forms app, which is not installed on this instance.') + ); + } + }//end validateExternal() + + /** + * The Forms form id an external declaration names. + * + * @param array $record The declaration. + * + * @return int The form id. + * + * @throws UnexpectedValueException When absent or not a positive integer. + */ + private function formIdOf(array $record): int { + $raw = ($record['formId'] ?? null); + if (is_numeric($raw) === false || (int)$raw <= 0) { + throw new UnexpectedValueException( + $this->l10n->t('An external form must name the Forms form by its id.') + ); + } + + return (int)$raw; + }//end formIdOf() + + /** + * A field list from any of the accepted spellings. + * + * Accepted: a list of `{field, required}` objects (the contract's shape); + * a list of property names, where a trailing `*` marks the field required + * (what a multi-select editor yields); or that list as a comma-separated + * string. A duplicate field is refused rather than merged, because two + * entries with different `required` flags have no honest resolution. + * + * @param mixed $value The configured value. + * + * @return array The normalised list. + * + * @throws UnexpectedValueException When an entry is malformed or duplicated. + */ + private function fieldsOf(mixed $value): array { + if (is_string($value) === true) { + $value = explode(',', $value); + } + + if ($value === null) { + return []; + } + + if (is_array($value) === false) { + throw new UnexpectedValueException( + $this->l10n->t('Form fields must be a list of {field, required} entries.') + ); + } + + $fields = []; + foreach ($value as $entry) { + $field = $this->fieldOf(entry: $entry); + if ($field === null) { + continue; + } + + if (array_key_exists($field['field'], $fields) === true) { + throw new UnexpectedValueException( + $this->l10n->t('Field "%1$s" is listed twice.', [$field['field']]) + ); + } + + $fields[$field['field']] = $field; + } + + return array_values($fields); + }//end fieldsOf() + + /** + * One field entry, from an object or a name; null for a blank entry. + * + * @param mixed $entry The configured entry. + * + * @return array{field: string, required: bool}|null The normalised entry. + * + * @throws UnexpectedValueException When the entry is neither. + */ + private function fieldOf(mixed $entry): ?array { + if (is_array($entry) === true) { + $name = trim((string)($entry['field'] ?? '')); + if ($name === '') { + throw new UnexpectedValueException( + $this->l10n->t('Every form field entry must name its field.') + ); + } + + return [ + 'field' => $name, + 'required' => filter_var(($entry['required'] ?? false), FILTER_VALIDATE_BOOLEAN), + ]; + } + + if (is_scalar($entry) === false) { + throw new UnexpectedValueException( + $this->l10n->t('Form fields must be a list of {field, required} entries.') + ); + } + + $name = trim((string)$entry); + if ($name === '') { + return null; + } + + $required = str_ends_with($name, '*'); + if ($required === true) { + $name = rtrim(substr($name, 0, -1)); + } + + return [ + 'field' => $name, + 'required' => $required, + ]; + }//end fieldOf() + + /** + * Refuse form keys given without a kind: they would look configured and do nothing. + * + * @param array $record The declaration. + * + * @return void + * + * @throws UnexpectedValueException When any form key other than the checklist rule is set. + */ + private function refuseOrphanedFormKeys(array $record): void { + foreach (['schema', 'action', 'fields', 'formId'] as $key) { + $value = ($record[$key] ?? null); + if ($value === null || $value === '' || $value === []) { + continue; + } + + throw new UnexpectedValueException( + $this->l10n->t('A form was described without a kind. Set the form kind to "fields" or "external", or clear the form settings.') + ); + } + }//end refuseOrphanedFormKeys() + + /** + * Null for an empty string. + * + * @param string $value The value. + * + * @return string|null The value, or null when empty. + */ + private function nullIfEmpty(string $value): ?string { + if ($value === '') { + return null; + } + + return $value; + }//end nullIfEmpty() +}//end class diff --git a/lib/Service/Task/TaskFormResolver.php b/lib/Service/Task/TaskFormResolver.php new file mode 100644 index 0000000000..51f39b55ad --- /dev/null +++ b/lib/Service/Task/TaskFormResolver.php @@ -0,0 +1,359 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-form-a-task-presents-is-the-one-its-flow-version-declared + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use DateTime; +use OCA\OpenRegister\Db\FlowMapper; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FormLinkMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Flow\FlowPublishedGraph; +use OCP\IL10N; +use Throwable; +use UnexpectedValueException; + +/** + * Resolves and describes the form a task presents, per read. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-form-a-task-presents-is-the-one-its-flow-version-declared + */ +class TaskFormResolver { + + /** + * Every declared field renders. + * + * @var string + */ + public const STATE_READY = 'ready'; + + /** + * At least one declared field no longer renders against the live schema. + * + * @var string + */ + public const STATE_BROKEN = 'broken'; + + /** + * The declaration itself could not be resolved. + * + * @var string + */ + public const STATE_UNRESOLVABLE = 'unresolvable'; + + /** + * The bound external form is not usable. + * + * @var string + */ + public const STATE_UNAVAILABLE = 'unavailable'; + + /** + * Constructor. + * + * @param FlowRunMapper $runs Reads the run for its flow and version pin. + * @param FlowMapper $flows Reads the live flow, for an unpinned draft test run only. + * @param FlowPublishedGraph $published Resolves the pinned graph of a run. + * @param FormLinkMapper $formLinks Reads the subject's bound Forms form. + * @param TaskFormReader $reader Normalises and inspects declarations. + * @param IL10N $l10n Translations, for reasons a performer reads. + */ + public function __construct( + private readonly FlowRunMapper $runs, + private readonly FlowMapper $flows, + private readonly FlowPublishedGraph $published, + private readonly FormLinkMapper $formLinks, + private readonly TaskFormReader $reader, + private readonly IL10N $l10n, + ) { + + }//end __construct() + + /** + * The task's completion surface, as the read exposes it. + * + * `form` is null when the step declares none, else a description carrying + * `kind`, `state` and, for the native kind, each declared field with its + * `required` flag from the DECLARATION, its `order` from the declaration + * position, and whether it is `renderable` against the live schema. An + * unresolvable declaration is a form with `state` unresolvable and an + * `error` naming the flow and the version: never an empty field list. + * + * @param Task $task The task. + * + * @return array{form: array|null, requireChecklist: bool} The description. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-rendered-form-carries-the-declarations-required-flags-and-order + */ + public function describe(Task $task): array { + try { + $declaration = $this->declarationOf(task: $task); + } catch (UnexpectedValueException $unresolvable) { + return [ + 'form' => [ + 'kind' => null, + 'state' => self::STATE_UNRESOLVABLE, + 'error' => $unresolvable->getMessage(), + ], + 'requireChecklist' => false, + ]; + } + + $form = null; + if ($declaration->isNative() === true) { + $form = $this->describeNative(declaration: $declaration); + } else if ($declaration->isExternal() === true) { + $form = $this->describeExternal(declaration: $declaration, task: $task); + } + + return [ + 'form' => $form, + 'requireChecklist' => $declaration->requireChecklist, + ]; + }//end describe() + + /** + * The declaration a task resolves to: pinned version, or its own record. + * + * @param Task $task The task. + * + * @return TaskForm The declaration. + * + * @throws UnexpectedValueException When the run, its version or its step cannot be resolved. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-form-a-task-presents-is-the-one-its-flow-version-declared + */ + public function declarationOf(Task $task): TaskForm { + $runUuid = trim((string)$task->getRunUuid()); + if ($runUuid === '') { + // A run-less task is first-class: its record is its declaration. + return $this->reader->fromRecord(record: (array)(($task->getMetadata() ?? [])['form'] ?? [])); + } + + try { + $run = $this->runs->findByUuid(uuid: $runUuid); + } catch (Throwable) { + throw new UnexpectedValueException( + $this->l10n->t('Task %1$s belongs to run %2$s, which no longer exists.', [(string)$task->getUuid(), $runUuid]) + ); + } + + $nodeId = trim((string)$task->getNodeId()); + foreach ($this->nodesOf(run: $run) as $node) { + if (is_array($node) === true && trim((string)($node['id'] ?? '')) === $nodeId) { + return $this->reader->fromConfig(config: (array)($node['config'] ?? [])); + } + } + + throw new UnexpectedValueException( + $this->l10n->t( + 'Version %1$s of flow %2$s has no step "%3$s", so the form of task %4$s cannot be resolved.', + [$this->versionLabel(run: $run), (string)$run->getFlowId(), $nodeId, (string)$task->getUuid()] + ) + ); + }//end declarationOf() + + /** + * The nodes of the graph a run walks: its pinned version, or the live + * document for the one dispatch that may walk a draft, the interactive + * test run, which carries no pin because there is no published version + * to pin. + * + * @param FlowRun $run The run. + * + * @return array The node entries. + * + * @throws UnexpectedValueException When the pinned version cannot be resolved. + */ + private function nodesOf(FlowRun $run): array { + if ($run->getFlowVersion() === null) { + try { + return ($this->flows->findByUuid(uuid: (string)$run->getFlowId())->getNodes() ?? []); + } catch (Throwable) { + throw new UnexpectedValueException( + $this->l10n->t('Flow %1$s no longer exists.', [(string)$run->getFlowId()]) + ); + } + } + + $graph = $this->published->ofRun(run: $run); + if ($graph === null) { + throw new UnexpectedValueException( + $this->l10n->t( + 'Version %1$s of flow %2$s cannot be resolved, so the form of this task cannot be shown.', + [$this->versionLabel(run: $run), (string)$run->getFlowId()] + ) + ); + } + + return (array)($graph['nodes'] ?? []); + }//end nodesOf() + + /** + * The native form against the live schema: per field, render or broken-with-reason. + * + * @param TaskForm $declaration The declaration. + * + * @return array The description. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-rendered-form-carries-the-declarations-required-flags-and-order + */ + private function describeNative(TaskForm $declaration): array { + try { + $schema = $this->reader->schema(reference: $declaration->schema); + $declared = $this->reader->declaredFields(form: $declaration, schema: $schema); + } catch (UnexpectedValueException $broken) { + return [ + 'kind' => TaskForm::KIND_FIELDS, + 'state' => self::STATE_BROKEN, + 'error' => $broken->getMessage(), + 'schema' => null, + 'action' => $declaration->action, + 'fields' => [], + ]; + } + + $fields = []; + $state = self::STATE_READY; + foreach ($declared as $order => $field) { + $reason = $this->reader->unrenderableReason(schema: $schema, field: $field['field']); + if ($reason !== null) { + $state = self::STATE_BROKEN; + } + + $fields[] = [ + 'field' => $field['field'], + 'required' => $field['required'], + 'order' => $order, + 'renderable' => ($reason === null), + 'reason' => $reason, + ]; + } + + return [ + 'kind' => TaskForm::KIND_FIELDS, + 'state' => $state, + 'error' => null, + 'schema' => [ + 'id' => $schema->getId(), + 'uuid' => $schema->getUuid(), + 'slug' => $schema->getSlug(), + 'title' => $schema->getTitle(), + ], + 'action' => $declaration->action, + 'fields' => $fields, + ]; + }//end describeNative() + + /** + * The external form through the subject's link, saying so when it is unusable. + * + * Read from the link's cached snapshot (title, status, expiry), which the + * link service keeps precisely so a surface can still say what happened + * when the Forms app is gone or the form was deleted. + * + * @param TaskForm $declaration The declaration. + * @param Task $task The task, for its subject anchor. + * + * @return array The description. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-external-form-path-binds-an-existing-forms-form-and-validates-nothing-about-its-contents + */ + private function describeExternal(TaskForm $declaration, Task $task): array { + $description = [ + 'kind' => TaskForm::KIND_EXTERNAL, + 'state' => self::STATE_UNAVAILABLE, + 'error' => null, + 'formId' => $declaration->formId, + 'formHash' => null, + 'title' => null, + 'status' => null, + 'expiresAt' => null, + ]; + + $objectUuid = trim((string)$task->getObjectUuid()); + $link = null; + if ($objectUuid !== '' && $declaration->formId !== null) { + $link = $this->formLinks->findFormLink(objectUuid: $objectUuid, formId: $declaration->formId); + } + + if ($link === null) { + $description['error'] = $this->l10n->t('The form is no longer linked to this task\'s subject, so it cannot be used to finish the work.'); + + return $description; + } + + $status = (string)($link->getStatus() ?? 'open'); + $expiresAt = $link->getExpiresAt(); + $description['formHash'] = $link->getFormHash(); + $description['title'] = $link->getTitle(); + $description['status'] = $status; + $description['expiresAt'] = $expiresAt?->format('c'); + + if (in_array($status, ['archived', 'closed', 'draft'], true) === true) { + $description['error'] = $this->l10n->t('The form is %1$s and cannot be filled in.', [$status]); + + return $description; + } + + if ($expiresAt !== null && $expiresAt < new DateTime()) { + $description['error'] = $this->l10n->t('The form expired on %1$s and cannot be filled in.', [$expiresAt->format('Y-m-d')]); + + return $description; + } + + $description['state'] = self::STATE_READY; + + return $description; + }//end describeExternal() + + /** + * A run's version for a message: its number, or "draft" for an unpinned test run. + * + * @param FlowRun $run The run. + * + * @return string The label. + */ + private function versionLabel(FlowRun $run): string { + $version = $run->getFlowVersion(); + if ($version === null) { + return 'draft'; + } + + return (string)$version; + }//end versionLabel() +}//end class diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php index 42ade82a42..170021759a 100644 --- a/lib/Service/Task/TaskService.php +++ b/lib/Service/Task/TaskService.php @@ -57,10 +57,15 @@ use OCP\IDBConnection; use Psr\Log\LoggerInterface; use Throwable; +use UnexpectedValueException; /** * Creates, routes, claims, completes, cancels and terminates tasks. * + * @SuppressWarnings(PHPMD.TooManyMethods) The verbs, one private helper per + * concern they share (open, authorize, audit, persist, announce), and the two + * seams the task-form completion uses. Merging helpers to duck a count would + * hide which rule a verb relies on. * @SuppressWarnings(PHPMD.TooManyPublicMethods) One method per lifecycle * verb the spec names (create/offer/claim/unclaim/assign/reassign/delegate/ * resolve/complete/cancel) plus the two propagation entry points. Merging @@ -76,9 +81,6 @@ * rules (authorize, then terminality, then the verb's precondition, then * the conditional write); folding verbs together to lower the number would * hide exactly the per-verb rules the spec enumerates. - * @SuppressWarnings(PHPMD.TooManyMethods) One private helper per concern the - * verbs share (open, authorize, audit, persist, announce); merging them would - * hide which rule a verb relies on. * @SuppressWarnings(PHPMD.StaticAccess) TaskState is a stateless published * vocabulary (the one status mapping); calling it statically is the point, * an instance would be a second copy of the same table. @@ -120,6 +122,13 @@ class TaskService { * is projected, terminality goes * unannounced, and the lifecycle is * unchanged. + * @param TaskFormReader|null $forms Refuses a run-less task's own form + * declaration (`metadata.form`) at + * creation, the way a step's is refused + * at save. Nullable for the same reason + * as the dispatcher; absent, a record + * declaration is stored unchecked and + * judged on read. */ public function __construct( private readonly TaskMapper $tasks, @@ -132,6 +141,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly TaskBuilder $builder, private readonly ?IEventDispatcher $dispatcher = null, + private readonly ?TaskFormReader $forms = null, ) { }//end __construct() @@ -212,6 +222,7 @@ public function create(array $data, ?string $actor): Task { */ public function import(array $data, ?string $actor): Task { $task = $this->builder->fromData(data: $data, actor: $actor); + $this->refuseUnrenderableForm(task: $task); $this->authorizeOrRecord(verb: 'create', task: $task, actor: $actor); $this->pending = [ 'assignee' => null, @@ -869,6 +880,100 @@ private function completeInternal( }//end completeInternal() + /** + * A verb's task, resolved for a caller that writes something else FIRST. + * + * The task-form completion writes the subject object before it completes + * the task, and the task verb's authorization must be settled before a + * byte reaches the subject. This is {@see openTaskFor()} made reachable + * for that one caller: exists, authorized (a denial audited), open. + * + * @param string $verb The verb about to be attempted. + * @param string $uuid The task uuid. + * @param string|null $actor The acting identity. + * + * @return Task The open, authorized task. + * + * @throws TaskConflictException When the task is already terminal. + * @throws TaskAccessDeniedException When authorization denies (audited). + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ + public function authorizedOpenTask(string $verb, string $uuid, ?string $actor): Task { + return $this->openTaskFor(verb: $verb, uuid: $uuid, actor: $actor); + }//end authorizedOpenTask() + + /** + * Record that a completion was attempted and refused, without completing anything. + * + * Its own action name, so a refused attempt and a completion are + * distinguishable in the trail; `authorized` stays true because the caller + * WAS allowed to try and it was the payload that was refused. Outside any + * transaction, because nothing else changed. "The performer tried three + * times" is the signal that a form is wrong, which is why it is kept. + * + * @param Task $task The task the attempt was made on, unchanged. + * @param string $reason Why the attempt was refused. + * @param string|null $actor The attempting identity. + * + * @return void + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-validation-failure-names-its-fields-and-completes-nothing + */ + public function recordRefusedCompletion(Task $task, string $reason, ?string $actor): void { + if ($task->getId() === null) { + return; + } + + try { + $entry = new TaskAudit(); + $entry->setTaskId((int)$task->getId()); + $entry->setAction('complete-refused'); + $entry->setStateAfter($task->getState()); + $entry->setActor($actor); + $entry->setPerformerType($task->getPerformerType()); + $entry->setOnBehalfOf($task->getOnBehalfOf()); + $entry->setMandate($task->getMandate()); + $entry->setReason($reason); + $entry->setAuthorized(true); + $this->audits->insert($entry); + } catch (Throwable $auditFailure) { + $this->logger->warning( + '[TaskService] Could not record a refused completion: ' . $auditFailure->getMessage(), + ['task' => $task->getUuid()] + ); + } + }//end recordRefusedCompletion() + + /** + * Refuse a run-less task whose own form declaration could not be rendered. + * + * The record is the declaration for a task with no run, so creation is + * its save time: a field the subject schema lacks, or marks read-only or + * not visible, is refused now, naming it, rather than left for the + * performer to meet. + * + * @param Task $task The task about to be inserted. + * + * @return void + * + * @throws TaskValidationException When the declaration is refused. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved + */ + private function refuseUnrenderableForm(Task $task): void { + $record = (($task->getMetadata() ?? [])['form'] ?? null); + if ($this->forms === null || is_array($record) === false || $record === []) { + return; + } + + try { + $this->forms->validate(form: $this->forms->fromRecord(record: $record)); + } catch (UnexpectedValueException $refused) { + throw new TaskValidationException(message: $refused->getMessage(), previous: $refused); + } + }//end refuseUnrenderableForm() + /** * Resolve a verb's task: it must exist, the caller must be authorized, * and it must be non-terminal — in that order, before any mutation. diff --git a/openspec/changes/flow-task-forms/tasks.md b/openspec/changes/flow-task-forms/tasks.md index 631e623e5e..bd373e37fc 100644 --- a/openspec/changes/flow-task-forms/tasks.md +++ b/openspec/changes/flow-task-forms/tasks.md @@ -2,26 +2,26 @@ ## 1. The contract gets its discovery half -- [ ] 1.1 `TransitionEngine::availableActions()` (`lib/Service/Lifecycle/TransitionEngine.php:457-484`) +- [x] 1.1 `TransitionEngine::availableActions()` (`lib/Service/Lifecycle/TransitionEngine.php:457-484`) publishes each action's declared `inputs` — normalised through `normaliseDeclaredInputs()` (`:774-790`) so the response shape is the contract's shape — and `buildGraphAction()` (`:640-671`) does the same for graph mode. A transition with no declaration publishes an EMPTY list, never an absent key. The read-permission gate at `:414-433` is untouched. -- [ ] 1.2 Make `resolveTransitionInputs()` (`:697-729`) reachable from a +- [x] 1.2 Make `resolveTransitionInputs()` (`:697-729`) reachable from a second caller without duplicating it: one narrow internal seam, unchanged signature and unchanged throws. Both call sites MUST refuse the same payloads — assert it with a shared test fixture, not by reading the code. -- [ ] 1.3 Write the `inputs` contract into `openspec/specs/object-lifecycle/` +- [x] 1.3 Write the `inputs` contract into `openspec/specs/object-lifecycle/` via this change's delta. The contract has shipped since the transition engine's input work and has never been a requirement; the delta is the first time the allowlist and its 400 shape are specified. ## 2. Declaring a form on a user-task step -- [ ] 2.1 The `openregister.user-task` node's `configForm()` gains the `form` +- [x] 2.1 The `openregister.user-task` node's `configForm()` gains the `form` block — `kind: fields | external`, an optional lifecycle `action`, an inline `[{field, required}]` list, and the external form reference — served unchanged through `FlowNodeRegistry::palette()` @@ -31,46 +31,46 @@ field name; `CnFormBuilder` is NOT used — it has no `schema` prop and a hand-typed `key` (`nextcloud-vue/src/components/CnFormBuilder/CnFormBuilder.vue:166-219`). -- [ ] 2.2 `validateConfig()` refuses, naming schema + field + reason: a field +- [x] 2.2 `validateConfig()` refuses, naming schema + field + reason: a field that is not a property of the subject schema; a field the schema marks `readOnly`; a field the schema marks `visible: false`; both an `action` and an inline list; and an `action` the subject schema does not declare. -- [ ] 2.3 `validateConfig()` refuses `kind: external` when the Forms app is +- [x] 2.3 `validateConfig()` refuses `kind: external` when the Forms app is not installed, mirroring `FormLinkService::createAndLinkForm()`'s 503 (`lib/Service/FormLinkService.php:385-398`) at authoring time rather than in front of the performer. ## 3. Resolving the form -- [ ] 3.1 `lib/Service/Task/TaskFormResolver.php` — a task with a `run_uuid` +- [x] 3.1 `lib/Service/Task/TaskFormResolver.php` — a task with a `run_uuid` resolves its declaration through the run's PINNED flow version (`flow-definition-versioning`), never the editable head; a run-less task reads the declaration off its own record. No third path, no fallback to head/latest/empty, and an unresolvable version fails naming flow AND version. -- [ ] 3.2 The resolver intersects the declaration with the LIVE subject +- [x] 3.2 The resolver intersects the declaration with the LIVE subject schema on every call and returns per field: render / broken-with-reason. Nothing is cached on the task row (design.md, D-1 — derived, never stored). -- [ ] 3.3 Expose the resolved form on the task read so the completion surface +- [x] 3.3 Expose the resolved form on the task read so the completion surface needs no second round-trip, carrying each field's `required` from the DECLARATION and its position from the declaration order. ## 4. Completing with a payload -- [ ] 4.1 Completion with a form that names an action calls +- [x] 4.1 Completion with a form that names an action calls `TransitionEngine::transition($objectId, $action, $data)` so the allowlist, the merge and the lifecycle flip land in one save (`:344-356`); completion with an inline list runs the same allowlist and writes through `ObjectService::saveObject()`. -- [ ] 4.2 Ordering and failure: the object write commits BEFORE the task is +- [x] 4.2 Ordering and failure: the object write commits BEFORE the task is completed. A refused write leaves the task actionable and the run suspended; a task-completion failure after a successful write leaves the write standing and the resubmit idempotent (design.md, D-5). -- [ ] 4.3 Both authorizations apply and either may refuse: the task verb's +- [x] 4.3 Both authorizations apply and either may refuse: the task verb's (`flow-task-entity`) and the object write's. Being the assignee grants no write on the subject. -- [ ] 4.4 Checklist completion stays on the task's own verbs and never enters +- [x] 4.4 Checklist completion stays on the task's own verbs and never enters the field payload; the optional "every item checked" precondition refuses a completion naming the unchecked item, without advancing the run. @@ -96,14 +96,14 @@ - [ ] 5.3 `CnLifecycleActions.vue:251` gains the ability to send `data` for a transition whose published `inputs` are non-empty, and keeps sending `{action}` alone when they are empty. -- [ ] 5.4 External path: the task presents the bound Forms form through +- [x] 5.4 External path: the task presents the bound Forms form through `FormLink` (`lib/Db/FormLink.php:70-127`), resolved via the subject anchor with no new table; an expired, deleted or archived form makes the task say so instead of offering a dead link. ## 6. Tests -- [ ] 6.1 Contract tests: an undeclared key, a missing required input, an +- [x] 6.1 Contract tests: an undeclared key, a missing required input, an empty-string required input, and a payload against a transition with no declaration — each refused, each naming its fields, each leaving the lifecycle field unchanged; plus an accepted value that the schema then @@ -111,11 +111,11 @@ `available-actions` publishes `inputs` for static and graph modes, an empty list is present rather than absent, and a caller without read permission gets nothing. -- [ ] 6.2 Binding tests: a declaration whose `required` disagrees with +- [x] 6.2 Binding tests: a declaration whose `required` disagrees with `schema.required` in BOTH directions renders correctly; declared order survives a schema whose own `order` disagrees; a readOnly or invisible declared field is refused at save and never reaches a render. -- [ ] 6.3 Versioning and regression: an open task keeps its form across a +- [x] 6.3 Versioning and regression: an open task keeps its form across a publish that changes the step, while a new run gets the new form; an unresolvable pinned version fails loudly; and a pass with opencatalogi and softwarecatalog installed proving their lifecycle transitions still diff --git a/tests/Unit/Controller/TaskControllerTest.php b/tests/Unit/Controller/TaskControllerTest.php index 0de395322f..aa4c84535a 100644 --- a/tests/Unit/Controller/TaskControllerTest.php +++ b/tests/Unit/Controller/TaskControllerTest.php @@ -32,8 +32,12 @@ use OCA\OpenRegister\Db\TaskInboxCriteria; use OCA\OpenRegister\Exception\TaskAccessDeniedException; use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskFormRefusedException; +use OCA\OpenRegister\Exception\TaskSubjectWriteRefusedException; use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskFormCompletion; +use OCA\OpenRegister\Service\Task\TaskFormResolver; use OCA\OpenRegister\Service\Task\TaskInboxService; use OCA\OpenRegister\Service\Task\TaskService; use OCA\OpenRegister\Service\Task\TaskTemporalProjection; @@ -58,6 +62,8 @@ * @covers \OCA\OpenRegister\Exception\TaskValidationException * @covers \OCA\OpenRegister\Exception\TaskAccessDeniedException * @covers \OCA\OpenRegister\Exception\TaskConflictException + * @covers \OCA\OpenRegister\Exception\TaskFormRefusedException + * @covers \OCA\OpenRegister\Exception\TaskSubjectWriteRefusedException */ class TaskControllerTest extends TestCase { @@ -89,6 +95,10 @@ class TaskControllerTest extends TestCase { */ private LoggerInterface&MockObject $logger; + private TaskFormResolver&MockObject $forms; + + private TaskFormCompletion&MockObject $completion; + /** * The controller under test. * @@ -106,6 +116,9 @@ protected function setUp(): void { $this->inbox = $this->createMock(originalClassName: TaskInboxService::class); $this->authorization = $this->createMock(originalClassName: TaskAuthorizationService::class); $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + $this->forms = $this->createMock(originalClassName: TaskFormResolver::class); + $this->forms->method('describe')->willReturn(['form' => null, 'requireChecklist' => false]); + $this->completion = $this->createMock(originalClassName: TaskFormCompletion::class); $user = $this->createMock(originalClassName: IUser::class); $user->method('getUID')->willReturn('alice'); @@ -132,6 +145,8 @@ protected function setUp(): void { authorization: $this->authorization, temporal: new TaskTemporalProjection(), userSession: $session, + forms: $this->forms, + completion: $this->completion, logger: $this->logger, groupManager: $groups ); @@ -193,6 +208,110 @@ public function testShowIs404ForAnInvisibleTaskAnd200ForAVisibleOne(): void { $this->assertSame('t-1', $shown->getData()['uuid']); }//end testShowIs404ForAnInvisibleTaskAnd200ForAVisibleOne() + /** + * GET /api/flow-tasks/{uuid} carries the resolved form and the checklist + * rule, so the completion surface needs no second round-trip. + * + * @return void + */ + public function testShowCarriesTheResolvedForm(): void { + $this->tasks->method('get')->willReturn($this->task()); + $this->authorization->method('mayRead')->willReturn(true); + $forms = $this->createMock(originalClassName: TaskFormResolver::class); + $forms->expects($this->once())->method('describe')->willReturn( + [ + 'form' => ['kind' => 'fields', 'state' => 'ready', 'fields' => [['field' => 'reason', 'required' => true, 'order' => 0, 'renderable' => true, 'reason' => null]]], + 'requireChecklist' => true, + ] + ); + $controller = new TaskController( + appName: 'openregister', + request: $this->createMock(originalClassName: IRequest::class), + tasks: $this->tasks, + inbox: $this->inbox, + authorization: $this->authorization, + temporal: new TaskTemporalProjection(), + userSession: $this->createMock(originalClassName: IUserSession::class), + forms: $forms, + completion: $this->completion + ); + + $data = $controller->show(uuid: 't-1')->getData(); + + $this->assertSame('t-1', $data['uuid']); + $this->assertSame('fields', $data['form']['kind']); + $this->assertTrue($data['form']['fields'][0]['required']); + $this->assertTrue($data['requireChecklist']); + }//end testShowCarriesTheResolvedForm() + + /** + * POST complete passes the `data` object through to the form-aware completion. + * + * @return void + */ + public function testCompletePassesTheFieldValuesThrough(): void { + $this->completion->expects($this->once())->method('complete') + ->with('t-1', 'rejected', null, 'late', ['reason' => 'late'], 'alice') + ->willReturn($this->task()); + + $response = $this->controller->complete(uuid: 't-1', outcome: 'rejected', comment: 'late', data: ['reason' => 'late']); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + }//end testCompletePassesTheFieldValuesThrough() + + /** + * A `data` that is not an object is a 400 before any service is reached. + * + * @return void + */ + public function testANonObjectDataIs400(): void { + $this->completion->expects($this->never())->method('complete'); + + $response = $this->controller->complete(uuid: 't-1', outcome: 'approved', data: 'late'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertStringContainsString('"data"', $response->getData()['error']); + }//end testANonObjectDataIs400() + + /** + * A refused form payload is a 400 carrying the fields and the kind, + * machine-readably, next to the message. + * + * @return void + */ + public function testAFormRefusalIs400WithFieldsAndKind(): void { + $this->completion->method('complete')->willThrowException( + new TaskFormRefusedException( + message: 'Transition "complete" is missing required input field(s): "reason".', + kind: TaskFormRefusedException::KIND_MISSING, + fields: ['reason'] + ) + ); + + $response = $this->controller->complete(uuid: 't-1', outcome: 'approved', data: []); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertSame(['reason'], $response->getData()['fields']); + $this->assertSame('missing', $response->getData()['kind']); + $this->assertStringContainsString('reason', $response->getData()['error']); + }//end testAFormRefusalIs400WithFieldsAndKind() + + /** + * A write the subject refused is a 422: not malformed, not completed. + * + * @return void + */ + public function testASubjectWriteRefusalIs422(): void { + $this->completion->method('complete')->willThrowException( + new TaskSubjectWriteRefusedException('reason must be one of: late, incomplete') + ); + + $response = $this->controller->complete(uuid: 't-1', outcome: 'approved', data: ['reason' => 'other']); + + $this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $response->getStatus()); + $this->assertStringContainsString('reason must be', $response->getData()['error']); + }//end testASubjectWriteRefusalIs422() + /** * GET /api/flow-tasks/{uuid}: an absent uuid reads exactly like an * invisible one. @@ -267,7 +386,7 @@ public function testValidationIs400(): void { * @return void */ public function testADeniedVerbIs404WhenTheCallerMayNotReadTheTask(): void { - $this->tasks->method('complete')->willThrowException(new TaskAccessDeniedException('denied')); + $this->completion->method('complete')->willThrowException(new TaskAccessDeniedException('denied')); $this->tasks->method('get')->willReturn($this->task()); $this->authorization->method('mayRead')->willReturn(false); @@ -284,7 +403,7 @@ public function testADeniedVerbIs404WhenTheCallerMayNotReadTheTask(): void { * @return void */ public function testADeniedVerbIs403WhenTheCallerMayReadTheTask(): void { - $this->tasks->method('complete')->willThrowException(new TaskAccessDeniedException("Verb 'complete' denied: only the current assignee may perform it.")); + $this->completion->method('complete')->willThrowException(new TaskAccessDeniedException("Verb 'complete' denied: only the current assignee may perform it.")); $this->tasks->method('get')->willReturn($this->task()); $this->authorization->method('mayRead')->willReturn(true); @@ -338,10 +457,14 @@ public function testAnUnexpectedFailureIsAGeneric500ThatIsLogged(): void { * @return void */ public function testEveryVerbRouteReachesItsServiceVerb(): void { - foreach (['offer', 'claim', 'unclaim', 'assign', 'reassign', 'delegate', 'resolve', 'complete', 'cancel', 'checkChecklistItem'] as $verb) { + foreach (['offer', 'claim', 'unclaim', 'assign', 'reassign', 'delegate', 'resolve', 'cancel', 'checkChecklistItem'] as $verb) { $this->tasks->expects($this->once())->method($verb)->willReturn($this->task()); } + // Complete goes through the form-aware completion, which owns the + // write-then-complete order and delegates the verb to the service. + $this->completion->expects($this->once())->method('complete')->willReturn($this->task()); + $responses = [ $this->controller->offer(uuid: 't-1'), $this->controller->claim(uuid: 't-1'), @@ -496,6 +619,8 @@ function (TaskInboxCriteria $criteria): array { authorization: $this->authorization, temporal: new TaskTemporalProjection(), userSession: $session, + forms: $this->forms, + completion: $this->completion, logger: $this->logger, groupManager: $groups ); @@ -518,7 +643,9 @@ public function testIndexWithoutASessionIs401(): void { inbox: $this->inbox, authorization: $this->authorization, temporal: new TaskTemporalProjection(), - userSession: $session + userSession: $session, + forms: $this->forms, + completion: $this->completion ); $this->assertSame(Http::STATUS_UNAUTHORIZED, $controller->index()->getStatus()); diff --git a/tests/Unit/Service/Flow/UserTaskNodeTest.php b/tests/Unit/Service/Flow/UserTaskNodeTest.php index c10d495cad..673e6d5ad1 100644 --- a/tests/Unit/Service/Flow/UserTaskNodeTest.php +++ b/tests/Unit/Service/Flow/UserTaskNodeTest.php @@ -20,6 +20,8 @@ use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\FlowTaskBridge; use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; +use OCA\OpenRegister\Service\Task\TaskForm; +use OCA\OpenRegister\Service\Task\TaskFormReader; use OCP\IL10N; use OCP\IURLGenerator; use OCP\WorkflowEngine\IManager; @@ -55,7 +57,12 @@ static function (string $text, array $parameters = []): string { } ); - $this->node = new UserTaskNode($this->bridge, $l10n, $this->createMock(IURLGenerator::class)); + // A reader that accepts every declaration: the form contract has its + // own suite, this one is about the node's lifecycle. + $forms = $this->createMock(TaskFormReader::class); + $forms->method('fromConfig')->willReturn(new TaskForm(kind: null)); + + $this->node = new UserTaskNode($this->bridge, $l10n, $this->createMock(IURLGenerator::class), $forms); }//end setUp() /** @@ -573,6 +580,13 @@ public function testTheFormCoversTheSpecifiedFieldsAndOnlyDeclaredKeys(): void { $this->assertContains($required, $formKeys); } + // The form block: kind, subject schema, action or inline list, the + // external form reference, and the checklist rule (flow-task-forms 2.1). + foreach (['formKind', 'formSchema', 'formAction', 'formFields', 'formId', 'formRequireChecklist'] as $formKey) { + $this->assertContains($formKey, $formKeys); + $this->assertContains($formKey, $keys); + } + $this->assertNotEmpty($this->node->configForm()); }//end testTheFormCoversTheSpecifiedFieldsAndOnlyDeclaredKeys() diff --git a/tests/Unit/Service/Lifecycle/TransitionEngineInputsTest.php b/tests/Unit/Service/Lifecycle/TransitionEngineInputsTest.php index b0e64e1d68..f9224a8893 100644 --- a/tests/Unit/Service/Lifecycle/TransitionEngineInputsTest.php +++ b/tests/Unit/Service/Lifecycle/TransitionEngineInputsTest.php @@ -31,12 +31,14 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Exception\InvalidTransitionInputException; +use OCA\OpenRegister\Exception\NotAuthorizedException; use OCA\OpenRegister\Service\Lifecycle\TransitionEngine; use OCA\OpenRegister\Service\Object\PermissionHandler; use OCA\OpenRegister\Service\ObjectService; use OCP\EventDispatcher\IEventDispatcher; use OCP\IAppConfig; use OCP\IUserSession; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -390,4 +392,192 @@ public function testGraphModeRejectsAnyPayload(): void { $this->assertSame(['note'], $e->getFields()); } }//end testGraphModeRejectsAnyPayload() + + /** + * DISCOVERY: available-actions publishes each transition's declared inputs in + * the contract's shape, so a client can present the payload without reading + * the schema. + * + * @return void + */ + public function testAvailableActionsPublishesDeclaredInputs(): void { + $this->wire( + $this->timesheet(), + $this->annotation([['field' => 'reason', 'required' => true], ['field' => 'note'], 'malformed', ['required' => true]]) + ); + + $actions = $this->engine->availableActions(self::OBJ); + + $this->assertCount(1, $actions); + $this->assertSame('submit', $actions[0]['action']); + $this->assertSame( + [ + ['field' => 'reason', 'required' => true], + ['field' => 'note', 'required' => false], + ], + $actions[0]['inputs'], + 'malformed entries are skipped by the allowlist, so they are not published either' + ); + }//end testAvailableActionsPublishesDeclaredInputs() + + /** + * A transition declaring no inputs publishes an EMPTY list: the key is present, + * because empty is the positive statement "this transition accepts no payload". + * + * @return void + */ + public function testANonDeclaringTransitionPublishesAnEmptyListNotAnAbsentKey(): void { + $this->wire($this->timesheet(), $this->annotation()); + + $actions = $this->engine->availableActions(self::OBJ); + + $this->assertArrayHasKey('inputs', $actions[0]); + $this->assertSame([], $actions[0]['inputs']); + // The existing keys are unchanged. + $this->assertSame(['action', 'to', 'requires', 'description', 'inputs'], array_keys($actions[0])); + }//end testANonDeclaringTransitionPublishesAnEmptyListNotAnAbsentKey() + + /** + * Graph-derived actions carry the same key, empty, so a client need not know the mode. + * + * @return void + */ + public function testGraphActionsCarryAnEmptyInputsList(): void { + $case = $this->timesheet(); + $case->setObject(['caseType' => 'p-1', 'status' => 's-1']); + $this->wire( + $case, + [ + 'field' => 'status', + 'graph' => [ + 'schema' => 'statustype', + 'parentField' => 'caseType', + 'parentFrom' => 'caseType', + 'orderField' => 'order', + 'finalField' => 'isFinal', + 'allowedMoves' => 'forward', + ], + ] + ); + $siblings = []; + foreach ([['s-1', 1], ['s-2', 2]] as [$uuid, $order]) { + $sibling = new ObjectEntity(); + $sibling->setUuid($uuid); + $sibling->setName('Status ' . $order); + $sibling->setObject(['caseType' => 'p-1', 'order' => $order, 'isFinal' => false]); + $siblings[] = $sibling; + } + + $this->objectService->method('findAll')->willReturn($siblings); + + $actions = $this->engine->availableActions(self::OBJ); + + $this->assertSame('move-to-s-2', $actions[0]['action']); + $this->assertArrayHasKey('inputs', $actions[0]); + $this->assertSame([], $actions[0]['inputs']); + }//end testGraphActionsCarryAnEmptyInputsList() + + /** + * A caller without read permission still learns nothing: the refusal carries no field name. + * + * @return void + */ + public function testACallerWithoutReadPermissionLearnsNoFieldName(): void { + $this->wire($this->timesheet(), $this->annotation([['field' => 'secretReason', 'required' => true]])); + $permission = $this->createMock(PermissionHandler::class); + $permission->method('hasPermission')->willReturn(false); + $engine = new TransitionEngine( + $this->objectService, + $this->schemaMapper, + $this->dispatcher, + $this->userSession, + $permission, + $this->registerMapper, + $this->appConfig, + $this->logger + ); + + try { + $engine->availableActions(self::OBJ); + $this->fail('Expected NotAuthorizedException was not thrown.'); + } catch (NotAuthorizedException $e) { + $this->assertStringNotContainsString('secretReason', $e->getMessage()); + } + }//end testACallerWithoutReadPermissionLearnsNoFieldName() + + /** + * `declaredInputs()` answers the form's question: the list for a declared action, + * empty for a declaring-nothing action, null for an unknown action or a schema + * without a lifecycle. + * + * @return void + */ + public function testDeclaredInputsReadsTheTransitionOrAnswersNull(): void { + $schema = $this->createMock(Schema::class); + $schema->method('getConfiguration')->willReturn( + ['x-openregister-lifecycle' => $this->annotation([['field' => 'reason', 'required' => true]])] + ); + + $this->assertSame([['field' => 'reason', 'required' => true]], $this->engine->declaredInputs($schema, 'submit')); + $this->assertNull($this->engine->declaredInputs($schema, 'vanish')); + + $plain = $this->createMock(Schema::class); + $plain->method('getConfiguration')->willReturn(['x-openregister-lifecycle' => $this->annotation()]); + $this->assertSame([], $this->engine->declaredInputs($plain, 'submit'), 'declared without inputs is an empty list'); + + $lifeless = $this->createMock(Schema::class); + $lifeless->method('getConfiguration')->willReturn([]); + $this->assertNull($this->engine->declaredInputs($lifeless, 'submit')); + }//end testDeclaredInputsReadsTheTransitionOrAnswersNull() + + /** + * The payloads both call sites must refuse identically. + * + * @return array>, 1: array}> + */ + public static function refusedPayloads(): array { + $declared = [['field' => 'reason', 'required' => true], ['field' => 'note']]; + + return [ + 'undeclared key' => [$declared, ['reason' => 'late', 'extra' => 1]], + 'missing required' => [$declared, ['note' => 'n']], + 'empty-string required' => [$declared, ['reason' => '']], + 'payload against no declaration' => [[], ['reason' => 'late']], + ]; + }//end refusedPayloads() + + /** + * ONE allowlist, two callers: the write path and the task-form completion + * refuse the same payloads with the same fields and the same message. Asserted + * with a shared fixture rather than by reading the code. + * + * @param array> $declared The transition's `inputs`. + * @param array $payload The payload both callers receive. + * + * @return void + */ + #[DataProvider('refusedPayloads')] + public function testBothCallSitesRefuseTheSamePayloads(array $declared, array $payload): void { + $this->wire($this->timesheet(), $this->annotation($declared)); + $this->objectService->expects($this->never())->method('saveObject'); + + $viaWrite = null; + try { + $this->engine->transition(self::OBJ, 'submit', $payload); + } catch (InvalidTransitionInputException $e) { + $viaWrite = $e; + } + + $viaForm = null; + try { + $this->engine->resolveTransitionInputs($declared, $payload, 'submit'); + } catch (InvalidTransitionInputException $e) { + $viaForm = $e; + } + + $this->assertNotNull($viaWrite, 'the write path must refuse'); + $this->assertNotNull($viaForm, 'the form path must refuse'); + $this->assertSame($viaWrite->getFields(), $viaForm->getFields()); + $this->assertSame($viaWrite->getMessage(), $viaForm->getMessage()); + }//end testBothCallSitesRefuseTheSamePayloads() }//end class diff --git a/tests/Unit/Service/Task/TaskFormCompletionTest.php b/tests/Unit/Service/Task/TaskFormCompletionTest.php new file mode 100644 index 0000000000..762ae3b450 --- /dev/null +++ b/tests/Unit/Service/Task/TaskFormCompletionTest.php @@ -0,0 +1,454 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-completion-payload-is-validated-by-the-lifecycle-input-allowlist-and-by-nothing-else + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\HookStoppedException; +use OCA\OpenRegister\Exception\InvalidTransitionInputException; +use OCA\OpenRegister\Exception\NotAuthorizedException; +use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskFormRefusedException; +use OCA\OpenRegister\Exception\TaskSubjectWriteRefusedException; +use OCA\OpenRegister\Exception\ValidationException; +use OCA\OpenRegister\Service\Lifecycle\TransitionEngine; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\Task\TaskFormCompletion; +use OCA\OpenRegister\Service\Task\TaskFormResolver; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * The completion path with a form payload. + * + * @covers \OCA\OpenRegister\Service\Task\TaskFormCompletion + * @covers \OCA\OpenRegister\Exception\TaskFormRefusedException + * @covers \OCA\OpenRegister\Exception\TaskSubjectWriteRefusedException + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Db\Task + * @uses \OCA\OpenRegister\Exception\InvalidTransitionInputException + * @uses \OCA\OpenRegister\Exception\NotAuthorizedException + * @uses \OCA\OpenRegister\Exception\ValidationException + * @uses \OCA\OpenRegister\Exception\HookStoppedException + * @uses \OCA\OpenRegister\Exception\TaskAccessDeniedException + */ +class TaskFormCompletionTest extends TestCase { + + private TaskService&MockObject $tasks; + + private TaskFormResolver&MockObject $forms; + + private TransitionEngine&MockObject $engine; + + private ObjectService&MockObject $objects; + + private TaskFormCompletion $completion; + + /** + * The order in which collaborators were called, by name. + * + * @var array + */ + private array $calls = []; + + protected function setUp(): void { + $this->tasks = $this->createMock(TaskService::class); + $this->forms = $this->createMock(TaskFormResolver::class); + $this->engine = $this->createMock(TransitionEngine::class); + $this->objects = $this->createMock(ObjectService::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + + $this->completion = new TaskFormCompletion( + tasks: $this->tasks, + forms: $this->forms, + engine: $this->engine, + objects: $this->objects, + userSession: $session + ); + }//end setUp() + + /** + * An open task anchored to subject `obj-1`. + */ + private function task(): Task { + $task = new Task(); + $task->setId(7); + $task->setUuid('t-7'); + $task->setState(Task::STATE_ACTIVE); + $task->setAssignee('alice'); + $task->setObjectUuid('obj-1'); + + return $task; + }//end task() + + /** + * A resolved native form: `reason` required, `note` optional, with or without an action. + * + * @return array{form: array, requireChecklist: bool} + */ + private function nativeForm(?string $action = null, bool $requireChecklist = false): array { + return [ + 'form' => [ + 'kind' => 'fields', + 'state' => TaskFormResolver::STATE_READY, + 'error' => null, + 'schema' => ['id' => 5, 'uuid' => null, 'slug' => 'case', 'title' => 'Case'], + 'action' => $action, + 'fields' => [ + ['field' => 'reason', 'required' => true, 'order' => 0, 'renderable' => true, 'reason' => null], + ['field' => 'note', 'required' => false, 'order' => 1, 'renderable' => true, 'reason' => null], + ], + ], + 'requireChecklist' => $requireChecklist, + ]; + }//end nativeForm() + + /** + * The task service authorizes and returns the open task, and records each call's order. + */ + private function openTask(Task $task): void { + $this->tasks->method('authorizedOpenTask')->with('complete', 't-7', 'alice')->willReturnCallback( + function () use ($task): Task { + $this->calls[] = 'authorize'; + + return $task; + } + ); + $this->tasks->method('complete')->willReturnCallback( + function () use ($task): Task { + $this->calls[] = 'complete'; + + return $task; + } + ); + }//end openTask() + + /** + * A step with no form completes with an outcome alone, and no field validation is applied. + */ + public function testAFormlessTaskCompletesWithoutAnyFieldValidation(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn(['form' => null, 'requireChecklist' => false]); + $this->engine->expects($this->never())->method('resolveTransitionInputs'); + $this->engine->expects($this->never())->method('transition'); + $this->objects->expects($this->never())->method('saveObject'); + $this->tasks->expects($this->once())->method('complete')->with('t-7', 'approved', null, 'fine', 'alice'); + + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: 'fine', data: [], actor: 'alice'); + }//end testAFormlessTaskCompletesWithoutAnyFieldValidation() + + /** + * A step declaring no fields accepts no field values: the allowlist runs with an + * empty declaration and refuses the key, the task is not completed, the attempt is audited. + */ + public function testAFormlessTaskRefusesAnyFieldValue(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn(['form' => null, 'requireChecklist' => false]); + $this->engine->expects($this->once())->method('resolveTransitionInputs') + ->with([], ['reason' => 'x'], 'complete') + ->willThrowException(new InvalidTransitionInputException('Transition "complete" does not accept input field(s): "reason".', ['reason'])); + $this->tasks->expects($this->never())->method('complete'); + $this->tasks->expects($this->once())->method('recordRefusedCompletion')->with($this->anything(), $this->stringContains('reason'), 'alice'); + + try { + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: ['reason' => 'x'], actor: 'alice'); + $this->fail('Expected a refusal.'); + } catch (TaskFormRefusedException $refused) { + $this->assertSame(TaskFormRefusedException::KIND_UNDECLARED, $refused->getKind()); + $this->assertSame(['reason'], $refused->getFields()); + } + }//end testAFormlessTaskRefusesAnyFieldValue() + + /** + * A missing required field is named, with the kind derived from the declaration. + */ + public function testAMissingRequiredFieldIsNamedAsMissing(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn($this->nativeForm()); + $this->engine->method('resolveTransitionInputs') + ->with([['field' => 'reason', 'required' => true], ['field' => 'note', 'required' => false]], ['note' => 'n'], 'complete') + ->willThrowException(new InvalidTransitionInputException('Transition "complete" is missing required input field(s): "reason".', ['reason'])); + $this->objects->expects($this->never())->method('saveObject'); + $this->tasks->expects($this->never())->method('complete'); + + try { + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: ['note' => 'n'], actor: 'alice'); + $this->fail('Expected a refusal.'); + } catch (TaskFormRefusedException $refused) { + $this->assertSame(TaskFormRefusedException::KIND_MISSING, $refused->getKind()); + $this->assertSame(['reason'], $refused->getFields()); + } + }//end testAMissingRequiredFieldIsNamedAsMissing() + + /** + * An undeclared key against a declaring form is named as undeclared. + */ + public function testAnUndeclaredKeyIsNamedAsUndeclared(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn($this->nativeForm()); + $this->engine->method('resolveTransitionInputs') + ->willThrowException(new InvalidTransitionInputException('Transition "complete" does not accept input field(s): "extra".', ['extra'])); + + try { + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: ['reason' => 'r', 'extra' => 1], actor: 'alice'); + $this->fail('Expected a refusal.'); + } catch (TaskFormRefusedException $refused) { + $this->assertSame(TaskFormRefusedException::KIND_UNDECLARED, $refused->getKind()); + $this->assertSame(['extra'], $refused->getFields()); + } + }//end testAnUndeclaredKeyIsNamedAsUndeclared() + + /** + * The inline path: accepted values merge into the subject through the ordinary + * object write, as the acting user, BEFORE the task completes. + */ + public function testAnInlineFormWritesTheSubjectBeforeCompleting(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn($this->nativeForm()); + $this->engine->method('resolveTransitionInputs')->willReturn(['reason' => 'late']); + $subject = new ObjectEntity(); + $subject->setUuid('obj-1'); + $subject->setRegister('1'); + $subject->setSchema('5'); + $subject->setObject(['name' => 'Case 7', 'reason' => null]); + $this->objects->method('find')->with('obj-1')->willReturn($subject); + $this->objects->expects($this->once())->method('saveObject')->willReturnCallback( + function (array $object, ?array $extend, mixed $register, mixed $schema, ?string $uuid, bool $_rbac, bool $_multitenancy, bool $silent, bool $_validation, ?array $uploadedFiles, ?IUser $currentUser) use ($subject): ObjectEntity { + $this->calls[] = 'save'; + // getObject() carries the entity's own id alongside the record, + // so assert the record's content rather than the exact array. + $this->assertSame('Case 7', $object['name']); + $this->assertSame('late', $object['reason']); + $this->assertSame('1', $register); + $this->assertSame('5', $schema); + $this->assertSame('obj-1', $uuid); + $this->assertSame('alice', $currentUser?->getUID()); + + return $subject; + } + ); + $this->engine->expects($this->never())->method('transition'); + + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: ['reason' => 'late'], actor: 'alice'); + + $this->assertSame(['authorize', 'save', 'complete'], $this->calls); + }//end testAnInlineFormWritesTheSubjectBeforeCompleting() + + /** + * Nothing accepted, nothing written: an all-optional form left empty skips the write. + */ + public function testAnEmptyAcceptedPayloadWritesNothing(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn($this->nativeForm()); + $this->engine->method('resolveTransitionInputs')->willReturn([]); + $this->objects->expects($this->never())->method('find'); + $this->objects->expects($this->never())->method('saveObject'); + + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: [], actor: 'alice'); + + $this->assertSame(['authorize', 'complete'], $this->calls); + }//end testAnEmptyAcceptedPayloadWritesNothing() + + /** + * The action path: ONE engine call carries allowlist, merge and lifecycle flip, then the task completes. + */ + public function testAnActionFormTransitionsTheSubjectBeforeCompleting(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn($this->nativeForm(action: 'reject')); + $this->engine->expects($this->once())->method('transition')->with('obj-1', 'reject', ['reason' => 'late'])->willReturnCallback( + function (): ObjectEntity { + $this->calls[] = 'transition'; + + return new ObjectEntity(); + } + ); + $this->engine->expects($this->never())->method('resolveTransitionInputs'); + $this->objects->expects($this->never())->method('saveObject'); + + $this->completion->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: 'late', data: ['reason' => 'late'], actor: 'alice'); + + $this->assertSame(['authorize', 'transition', 'complete'], $this->calls); + }//end testAnActionFormTransitionsTheSubjectBeforeCompleting() + + /** + * A value the schema refuses is refused by the save path: 422, task not completed, attempt audited. + */ + public function testASchemaRefusalLeavesTheTaskOpen(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn($this->nativeForm(action: 'reject')); + $this->engine->method('transition')->willThrowException(new HookStoppedException('reason must be one of: late, incomplete')); + $this->tasks->expects($this->never())->method('complete'); + $this->tasks->expects($this->once())->method('recordRefusedCompletion'); + + $this->expectException(TaskSubjectWriteRefusedException::class); + $this->expectExceptionMessage('reason must be one of'); + $this->completion->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: 'x', data: ['reason' => 'other'], actor: 'alice'); + }//end testASchemaRefusalLeavesTheTaskOpen() + + /** + * The inline path's save can be refused by the schema too, the same way. + */ + public function testAnInlineSaveRefusalLeavesTheTaskOpen(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn($this->nativeForm()); + $this->engine->method('resolveTransitionInputs')->willReturn(['reason' => 7]); + $subject = new ObjectEntity(); + $subject->setUuid('obj-1'); + $this->objects->method('find')->willReturn($subject); + $this->objects->method('saveObject')->willThrowException(new ValidationException('reason must be a string')); + $this->tasks->expects($this->never())->method('complete'); + + $this->expectException(TaskSubjectWriteRefusedException::class); + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: ['reason' => 7], actor: 'alice'); + }//end testAnInlineSaveRefusalLeavesTheTaskOpen() + + /** + * The action's own refusal from the current state is the subject's refusal, not a malformed payload. + */ + public function testATransitionRefusedFromTheCurrentStateIs422NotMalformed(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn($this->nativeForm(action: 'reject')); + $this->engine->method('transition')->willThrowException(new \RuntimeException('Transition "reject" is not allowed from current state "closed".')); + + $this->expectException(TaskSubjectWriteRefusedException::class); + $this->completion->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: 'x', data: ['reason' => 'late'], actor: 'alice'); + }//end testATransitionRefusedFromTheCurrentStateIs422NotMalformed() + + /** + * Being the assignee grants no write on the subject: the object write's denial stands, unaudited as a refusal. + */ + public function testTheObjectWritesAuthorizationMayRefuse(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn($this->nativeForm(action: 'reject')); + $this->engine->method('transition')->willThrowException(new NotAuthorizedException('You do not have permission to transition object "obj-1".')); + $this->tasks->expects($this->never())->method('complete'); + $this->tasks->expects($this->never())->method('recordRefusedCompletion'); + + $this->expectException(TaskAccessDeniedException::class); + $this->expectExceptionMessage('obj-1'); + $this->completion->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: 'x', data: ['reason' => 'late'], actor: 'alice'); + }//end testTheObjectWritesAuthorizationMayRefuse() + + /** + * The task verb's authorization is settled FIRST: a denial reaches no resolver and no write. + */ + public function testTheTaskVerbIsAuthorizedBeforeAnythingElse(): void { + $this->tasks->method('authorizedOpenTask')->willThrowException(new TaskAccessDeniedException('not the assignee')); + $this->forms->expects($this->never())->method('describe'); + $this->engine->expects($this->never())->method('transition'); + + $this->expectException(TaskAccessDeniedException::class); + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: ['reason' => 'late'], actor: 'mallory'); + }//end testTheTaskVerbIsAuthorizedBeforeAnythingElse() + + /** + * An unresolvable form completes nothing: no empty form is completable. + */ + public function testAnUnresolvableFormRefusesTheCompletion(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn( + [ + 'form' => ['kind' => null, 'state' => TaskFormResolver::STATE_UNRESOLVABLE, 'error' => 'Version 7 of flow flow-1 cannot be resolved'], + 'requireChecklist' => false, + ] + ); + $this->tasks->expects($this->never())->method('complete'); + + try { + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: [], actor: 'alice'); + $this->fail('Expected a refusal.'); + } catch (TaskFormRefusedException $refused) { + $this->assertSame(TaskFormRefusedException::KIND_UNRESOLVABLE, $refused->getKind()); + $this->assertStringContainsString('Version 7 of flow flow-1', $refused->getMessage()); + } + }//end testAnUnresolvableFormRefusesTheCompletion() + + /** + * An unchecked mandatory checklist item refuses the completion naming the item, + * before any write; the checklist never enters the field payload. + */ + public function testAnUncheckedMandatoryItemRefusesNamingIt(): void { + $task = $this->task(); + $task->setChecklist( + [ + ['id' => 'c1', 'label' => 'Identity verified', 'checked' => true], + ['id' => 'c2', 'label' => 'Documents scanned', 'checked' => false], + ] + ); + $this->openTask($task); + $this->forms->method('describe')->willReturn($this->nativeForm(action: 'reject', requireChecklist: true)); + $this->engine->expects($this->never())->method('transition'); + $this->tasks->expects($this->never())->method('complete'); + + try { + $this->completion->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: 'x', data: ['reason' => 'late'], actor: 'alice'); + $this->fail('Expected a refusal.'); + } catch (TaskFormRefusedException $refused) { + $this->assertSame(TaskFormRefusedException::KIND_CHECKLIST, $refused->getKind()); + $this->assertSame(['c2'], $refused->getFields()); + } + }//end testAnUncheckedMandatoryItemRefusesNamingIt() + + /** + * With every item checked the precondition is satisfied and the write proceeds. + */ + public function testACompleteChecklistSatisfiesThePrecondition(): void { + $task = $this->task(); + $task->setChecklist([['id' => 'c1', 'label' => 'Identity verified', 'checked' => true]]); + $this->openTask($task); + $this->forms->method('describe')->willReturn(['form' => null, 'requireChecklist' => true]); + $this->tasks->expects($this->once())->method('complete'); + + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: [], actor: 'alice'); + }//end testACompleteChecklistSatisfiesThePrecondition() + + /** + * A field form on a task with no subject has nowhere to write: refused, named. + */ + public function testAFieldFormWithoutASubjectIsRefused(): void { + $task = $this->task(); + $task->setObjectUuid(null); + $this->openTask($task); + $this->forms->method('describe')->willReturn($this->nativeForm()); + + try { + $this->completion->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, data: ['reason' => 'r'], actor: 'alice'); + $this->fail('Expected a refusal.'); + } catch (TaskFormRefusedException $refused) { + $this->assertSame(TaskFormRefusedException::KIND_NO_SUBJECT, $refused->getKind()); + } + }//end testAFieldFormWithoutASubjectIsRefused() + + /** + * An external form writes no object field by this capability; the completion records the outcome. + */ + public function testAnExternalFormWritesNoObjectField(): void { + $this->openTask($this->task()); + $this->forms->method('describe')->willReturn( + ['form' => ['kind' => 'external', 'state' => TaskFormResolver::STATE_READY, 'formId' => 9], 'requireChecklist' => false] + ); + $this->engine->expects($this->never())->method('transition'); + $this->objects->expects($this->never())->method('saveObject'); + $this->tasks->expects($this->once())->method('complete')->with('t-7', 'submitted', 'submission 41', null, 'alice'); + + $this->completion->complete(uuid: 't-7', outcome: 'submitted', resultText: 'submission 41', comment: null, data: [], actor: 'alice'); + }//end testAnExternalFormWritesNoObjectField() +}//end class diff --git a/tests/Unit/Service/Task/TaskFormReaderTest.php b/tests/Unit/Service/Task/TaskFormReaderTest.php new file mode 100644 index 0000000000..62fcc676c5 --- /dev/null +++ b/tests/Unit/Service/Task/TaskFormReaderTest.php @@ -0,0 +1,395 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-a-field-that-cannot-be-rendered-is-refused-when-the-step-is-saved + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Lifecycle\TransitionEngine; +use OCA\OpenRegister\Service\Task\TaskForm; +use OCA\OpenRegister\Service\Task\TaskFormReader; +use OCP\App\IAppManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IL10N; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use UnexpectedValueException; + +/** + * Normalising and refusing task form declarations. + * + * @covers \OCA\OpenRegister\Service\Task\TaskFormReader + * @covers \OCA\OpenRegister\Service\Task\TaskForm + * @uses \OCA\OpenRegister\Db\Schema + */ +class TaskFormReaderTest extends TestCase { + + private SchemaMapper&MockObject $schemas; + + private TransitionEngine&MockObject $engine; + + private IAppManager&MockObject $apps; + + private TaskFormReader $reader; + + protected function setUp(): void { + $this->schemas = $this->createMock(SchemaMapper::class); + $this->engine = $this->createMock(TransitionEngine::class); + $this->apps = $this->createMock(IAppManager::class); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static function (string $text, array $parameters = []): string { + if ($parameters === []) { + return $text; + } + + return vsprintf($text, $parameters); + } + ); + + $this->reader = new TaskFormReader(schemas: $this->schemas, engine: $this->engine, apps: $this->apps, l10n: $l10n); + }//end setUp() + + /** + * A subject schema with one plain, one read-only and one invisible property. + */ + private function caseSchema(): Schema { + $schema = new Schema(); + $schema->setSlug('case'); + $schema->setProperties( + [ + 'reason' => ['type' => 'string'], + 'note' => ['type' => 'string'], + 'locked' => ['type' => 'string', 'readOnly' => true], + 'hidden' => ['type' => 'string', 'visible' => false], + ] + ); + + return $schema; + }//end caseSchema() + + /** + * A step with no form keys declares no form; completion stays outcome-and-comment. + */ + public function testNoFormKeysMeansNoForm(): void { + $form = $this->reader->fromConfig(config: ['title' => 'Approve']); + + $this->assertFalse($form->hasForm()); + $this->assertFalse($form->isNative()); + $this->assertFalse($form->isExternal()); + $this->assertFalse($form->requireChecklist); + $this->reader->validate(form: $form); + }//end testNoFormKeysMeansNoForm() + + /** + * The checklist rule stands on its own: it is not a field and needs no form. + */ + public function testTheChecklistRuleNeedsNoForm(): void { + $form = $this->reader->fromConfig(config: ['formRequireChecklist' => true]); + + $this->assertFalse($form->hasForm()); + $this->assertTrue($form->requireChecklist); + }//end testTheChecklistRuleNeedsNoForm() + + /** + * The inline list in the contract's shape, in declared order, required from the declaration. + */ + public function testAnInlineListKeepsOrderAndRequired(): void { + $form = $this->reader->fromConfig( + config: [ + 'formKind' => 'fields', + 'formSchema' => 'case', + 'formFields' => [ + ['field' => 'note', 'required' => false], + ['field' => 'reason', 'required' => true], + ], + ] + ); + + $this->assertTrue($form->isNative()); + $this->assertSame('case', $form->schema); + $this->assertNull($form->action); + $this->assertSame( + [ + ['field' => 'note', 'required' => false], + ['field' => 'reason', 'required' => true], + ], + $form->fields + ); + }//end testAnInlineListKeepsOrderAndRequired() + + /** + * The name spelling a multi-select editor yields: `name*` is required. + */ + public function testANameListMarksRequiredWithAStar(): void { + $form = $this->reader->fromConfig( + config: [ + 'formKind' => 'fields', + 'formSchema' => 'case', + 'formFields' => 'reason*, note', + ] + ); + + $this->assertSame( + [ + ['field' => 'reason', 'required' => true], + ['field' => 'note', 'required' => false], + ], + $form->fields + ); + }//end testANameListMarksRequiredWithAStar() + + /** + * Naming an action inherits its inputs; the declaration must not restate them. + */ + public function testAnActionAndAnInlineListTogetherAreRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('not both'); + $this->reader->fromConfig( + config: [ + 'formKind' => 'fields', + 'formSchema' => 'case', + 'formAction' => 'reject', + 'formFields' => 'reason*', + ] + ); + }//end testAnActionAndAnInlineListTogetherAreRefused() + + /** + * A field form with neither an action nor fields would be an empty form: refused. + */ + public function testAnEmptyFieldFormIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('lifecycle action'); + $this->reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'case']); + }//end testAnEmptyFieldFormIsRefused() + + /** + * A third kind does not exist. + */ + public function testAnUnknownKindIsRefusedNamingTheTwo(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('fields, external'); + $this->reader->fromConfig(config: ['formKind' => 'adhoc']); + }//end testAnUnknownKindIsRefusedNamingTheTwo() + + /** + * Form keys without a kind would look configured and do nothing. + */ + public function testFormKeysWithoutAKindAreRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('without a kind'); + $this->reader->fromConfig(config: ['formFields' => 'reason*']); + }//end testFormKeysWithoutAKindAreRefused() + + /** + * A field listed twice has no honest resolution when the flags differ. + */ + public function testADuplicateFieldIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('"reason" is listed twice'); + $this->reader->fromConfig( + config: [ + 'formKind' => 'fields', + 'formSchema' => 'case', + 'formFields' => [['field' => 'reason', 'required' => true], 'reason'], + ] + ); + }//end testADuplicateFieldIsRefused() + + /** + * A field entry without a name is malformed. + */ + public function testAFieldEntryWithoutANameIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('name its field'); + $this->reader->fromConfig( + config: [ + 'formKind' => 'fields', + 'formSchema' => 'case', + 'formFields' => [['required' => true]], + ] + ); + }//end testAFieldEntryWithoutANameIsRefused() + + /** + * An external form names the Forms form by id, or it names nothing. + */ + public function testAnExternalFormNeedsAFormId(): void { + $form = $this->reader->fromConfig(config: ['formKind' => 'external', 'formId' => '12']); + $this->assertTrue($form->isExternal()); + $this->assertSame(12, $form->formId); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('by its id'); + $this->reader->fromConfig(config: ['formKind' => 'external']); + }//end testAnExternalFormNeedsAFormId() + + /** + * A misspelled field is refused at save time naming the schema and the field. + */ + public function testAFieldTheSchemaLacksIsRefusedNamingSchemaAndField(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $form = $this->reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => 'reasonn*']); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Field "reasonn" of schema "case" cannot be asked for: the schema has no such property.'); + $this->reader->validate(form: $form); + }//end testAFieldTheSchemaLacksIsRefusedNamingSchemaAndField() + + /** + * A read-only field is refused rather than rendered blank. + */ + public function testAReadOnlyFieldIsRefusedNamingReadOnly(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $form = $this->reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => 'locked']); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('read-only'); + $this->reader->validate(form: $form); + }//end testAReadOnlyFieldIsRefusedNamingReadOnly() + + /** + * An invisible field is unrenderable by construction, so it is refused. + */ + public function testAnInvisibleFieldIsRefusedNamingVisibility(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $form = $this->reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => 'hidden']); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('not visible'); + $this->reader->validate(form: $form); + }//end testAnInvisibleFieldIsRefusedNamingVisibility() + + /** + * Renderable fields pass, whatever the schema's own `required` says. + */ + public function testRenderableFieldsPass(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $form = $this->reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => 'reason*, note']); + + $this->reader->validate(form: $form); + $this->assertNull($this->reader->unrenderableReason(schema: $this->caseSchema(), field: 'reason')); + }//end testRenderableFieldsPass() + + /** + * A step naming an action inherits the transition's inputs verbatim, from the engine. + */ + public function testAnActionInheritsTheTransitionsDeclaredInputs(): void { + $schema = $this->caseSchema(); + $this->schemas->method('find')->willReturn($schema); + $this->engine->expects($this->atLeastOnce())->method('declaredInputs') + ->with($schema, 'reject') + ->willReturn([['field' => 'reason', 'required' => true], ['field' => 'note', 'required' => false]]); + $form = $this->reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'case', 'formAction' => 'reject']); + + $this->reader->validate(form: $form); + $this->assertSame( + [['field' => 'reason', 'required' => true], ['field' => 'note', 'required' => false]], + $this->reader->declaredFields(form: $form, schema: $schema) + ); + }//end testAnActionInheritsTheTransitionsDeclaredInputs() + + /** + * An action the subject schema does not declare is refused naming both. + */ + public function testAnUndeclaredActionIsRefused(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $this->engine->method('declaredInputs')->willReturn(null); + $form = $this->reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'case', 'formAction' => 'vanish']); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Schema "case" declares no lifecycle action "vanish".'); + $this->reader->validate(form: $form); + }//end testAnUndeclaredActionIsRefused() + + /** + * An inherited input the schema marks read-only is as unrenderable as an inline one. + */ + public function testAnInheritedReadOnlyInputIsRefused(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $this->engine->method('declaredInputs')->willReturn([['field' => 'locked', 'required' => true]]); + $form = $this->reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'case', 'formAction' => 'reject']); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('read-only'); + $this->reader->validate(form: $form); + }//end testAnInheritedReadOnlyInputIsRefused() + + /** + * A schema reference that names nothing is refused naming the reference. + */ + public function testAMissingSchemaIsRefusedNamingIt(): void { + $this->schemas->method('find')->willThrowException(new DoesNotExistException('gone')); + $form = $this->reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'ghost', 'formFields' => 'reason']); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Schema "ghost" does not exist.'); + $this->reader->validate(form: $form); + }//end testAMissingSchemaIsRefusedNamingIt() + + /** + * A field form must say which schema its fields belong to. + */ + public function testAFieldFormWithoutASchemaIsRefused(): void { + $form = $this->reader->fromConfig(config: ['formKind' => 'fields', 'formFields' => 'reason']); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('subject schema'); + $this->reader->validate(form: $form); + }//end testAFieldFormWithoutASchemaIsRefused() + + /** + * An external step is refused without the Forms app, at save time, naming the app. + */ + public function testAnExternalFormIsRefusedWithoutTheFormsApp(): void { + $this->apps->method('isInstalled')->with('forms')->willReturn(false); + $form = $this->reader->fromConfig(config: ['formKind' => 'external', 'formId' => 3]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Nextcloud Forms app'); + $this->reader->validate(form: $form); + }//end testAnExternalFormIsRefusedWithoutTheFormsApp() + + /** + * With the Forms app present the external kind validates nothing about the form's contents. + */ + public function testAnExternalFormPassesWithTheFormsApp(): void { + $this->apps->method('isInstalled')->with('forms')->willReturn(true); + $this->schemas->expects($this->never())->method('find'); + $form = $this->reader->fromConfig(config: ['formKind' => 'external', 'formId' => 3]); + + $this->reader->validate(form: $form); + }//end testAnExternalFormPassesWithTheFormsApp() + + /** + * The record shape round-trips: what a run-less task stores is what it reads back. + */ + public function testTheRecordShapeRoundTrips(): void { + $form = $this->reader->fromConfig( + config: [ + 'formKind' => 'fields', + 'formSchema' => 'case', + 'formFields' => 'reason*, note', + 'formRequireChecklist' => 'true', + ] + ); + + $again = $this->reader->fromRecord(record: $form->toArray()); + + $this->assertSame($form->toArray(), $again->toArray()); + $this->assertTrue($again->requireChecklist); + $this->assertSame(TaskForm::KIND_FIELDS, $again->kind); + }//end testTheRecordShapeRoundTrips() +}//end class diff --git a/tests/Unit/Service/Task/TaskFormResolverTest.php b/tests/Unit/Service/Task/TaskFormResolverTest.php new file mode 100644 index 0000000000..17db2c489a --- /dev/null +++ b/tests/Unit/Service/Task/TaskFormResolverTest.php @@ -0,0 +1,401 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md#requirement-the-form-a-task-presents-is-the-one-its-flow-version-declared + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use DateTime; +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowMapper; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FormLink; +use OCA\OpenRegister\Db\FormLinkMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Flow\FlowPublishedGraph; +use OCA\OpenRegister\Service\Lifecycle\TransitionEngine; +use OCA\OpenRegister\Service\Task\TaskFormReader; +use OCA\OpenRegister\Service\Task\TaskFormResolver; +use OCP\App\IAppManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IL10N; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Resolving and describing a task's form, per read. + * + * @covers \OCA\OpenRegister\Service\Task\TaskFormResolver + * @covers \OCA\OpenRegister\Service\Task\TaskFormReader + * @covers \OCA\OpenRegister\Service\Task\TaskForm + * @uses \OCA\OpenRegister\Db\Flow + * @uses \OCA\OpenRegister\Db\FlowRun + * @uses \OCA\OpenRegister\Db\FormLink + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Db\Task + */ +class TaskFormResolverTest extends TestCase { + + private const FLOW = 'flow-1'; + + private FlowRunMapper&MockObject $runs; + + private FlowMapper&MockObject $flows; + + private FlowPublishedGraph&MockObject $published; + + private FormLinkMapper&MockObject $formLinks; + + private SchemaMapper&MockObject $schemas; + + private TransitionEngine&MockObject $engine; + + private TaskFormResolver $resolver; + + protected function setUp(): void { + $this->runs = $this->createMock(FlowRunMapper::class); + $this->flows = $this->createMock(FlowMapper::class); + $this->published = $this->createMock(FlowPublishedGraph::class); + $this->formLinks = $this->createMock(FormLinkMapper::class); + $this->schemas = $this->createMock(SchemaMapper::class); + $this->engine = $this->createMock(TransitionEngine::class); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static function (string $text, array $parameters = []): string { + if ($parameters === []) { + return $text; + } + + return vsprintf($text, $parameters); + } + ); + + $reader = new TaskFormReader( + schemas: $this->schemas, + engine: $this->engine, + apps: $this->createMock(IAppManager::class), + l10n: $l10n + ); + $this->resolver = new TaskFormResolver( + runs: $this->runs, + flows: $this->flows, + published: $this->published, + formLinks: $this->formLinks, + reader: $reader, + l10n: $l10n + ); + }//end setUp() + + /** + * The live subject schema: `reason` and `note`, `reason` NOT in the schema's own required list. + */ + private function caseSchema(): Schema { + $schema = new Schema(); + $schema->setId(5); + $schema->setSlug('case'); + $schema->setTitle('Case'); + $schema->setProperties( + [ + 'note' => ['type' => 'string', 'order' => 1], + 'reason' => ['type' => 'string', 'order' => 2], + ] + ); + $schema->setRequired(['note']); + + return $schema; + }//end caseSchema() + + /** + * A task raised by node `ask` of a run. + */ + private function taskOfRun(?string $runUuid = 'run-1'): Task { + $task = new Task(); + $task->setUuid('t-1'); + $task->setRunUuid($runUuid); + $task->setNodeId('ask'); + $task->setObjectUuid('obj-1'); + + return $task; + }//end taskOfRun() + + /** + * A run of the flow, pinned to a version (or unpinned when null). + */ + private function pinnedRun(?int $version): FlowRun { + $run = new FlowRun(); + $run->setUuid('run-1'); + $run->setFlowId(self::FLOW); + $run->setFlowVersion($version); + + return $run; + }//end run() + + /** + * A graph whose `ask` node declares the given form config. + * + * @param array $config The node's form keys. + * + * @return array + */ + private function graphWith(array $config): array { + return [ + 'nodes' => [ + ['id' => 'start', 'type' => 'openregister.set-fields', 'config' => []], + ['id' => 'ask', 'type' => 'openregister.user-task', 'config' => array_merge(['title' => 'Approve'], $config)], + ], + 'edges' => [], + ]; + }//end graphWith() + + /** + * A run-less task reads its declaration off its own record: required from the + * DECLARATION (the schema says `reason` is optional), order from the declaration. + */ + public function testARunlessTaskCarriesItsOwnDeclaration(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $this->runs->expects($this->never())->method('findByUuid'); + $task = $this->taskOfRun(runUuid: null); + $task->setMetadata( + [ + 'form' => [ + 'kind' => 'fields', + 'schema' => 'case', + 'fields' => [['field' => 'reason', 'required' => true], ['field' => 'note', 'required' => false]], + 'requireChecklist' => true, + ], + ] + ); + + $described = $this->resolver->describe(task: $task); + + $this->assertTrue($described['requireChecklist']); + $form = $described['form']; + $this->assertSame('fields', $form['kind']); + $this->assertSame(TaskFormResolver::STATE_READY, $form['state']); + $this->assertSame(['id' => 5, 'uuid' => null, 'slug' => 'case', 'title' => 'Case'], $form['schema']); + $this->assertSame( + [ + ['field' => 'reason', 'required' => true, 'order' => 0, 'renderable' => true, 'reason' => null], + ['field' => 'note', 'required' => false, 'order' => 1, 'renderable' => true, 'reason' => null], + ], + $form['fields'] + ); + }//end testARunlessTaskCarriesItsOwnDeclaration() + + /** + * A pinned task resolves through the run's version and NEVER reads the editable head. + */ + public function testAPinnedTaskResolvesThroughTheVersionNotTheHead(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $this->runs->method('findByUuid')->with('run-1')->willReturn($this->pinnedRun(version: 3)); + $this->published->expects($this->once())->method('ofRun') + ->willReturn($this->graphWith(['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => 'reason*, note'])); + $this->flows->expects($this->never())->method('findByUuid'); + + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + + $this->assertSame(TaskFormResolver::STATE_READY, $form['state']); + $this->assertSame(['reason', 'note'], array_column($form['fields'], 'field')); + $this->assertTrue($form['fields'][0]['required']); + }//end testAPinnedTaskResolvesThroughTheVersionNotTheHead() + + /** + * Editing the flow leaves an open task's form alone: the head declares four + * fields, the pinned version two, and the task shows two. + */ + public function testEditingTheFlowLeavesAnOpenTasksFormAlone(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: 1)); + $this->published->method('ofRun') + ->willReturn($this->graphWith(['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => 'reason*, note'])); + $head = new Flow(); + $head->setNodes($this->graphWith(['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => 'reason*, note, a, b'])['nodes']); + $this->flows->method('findByUuid')->willReturn($head); + + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + + $this->assertCount(2, $form['fields']); + }//end testEditingTheFlowLeavesAnOpenTasksFormAlone() + + /** + * An unresolvable pinned version fails loudly, naming flow and version, and + * presents no field list at all: not the head, not empty. + */ + public function testAnUnresolvableVersionFailsNamingFlowAndVersion(): void { + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: 7)); + $this->published->method('ofRun')->willReturn(null); + $this->flows->expects($this->never())->method('findByUuid'); + + $described = $this->resolver->describe(task: $this->taskOfRun()); + + $this->assertSame(TaskFormResolver::STATE_UNRESOLVABLE, $described['form']['state']); + $this->assertStringContainsString('Version 7 of flow flow-1', $described['form']['error']); + $this->assertArrayNotHasKey('fields', $described['form']); + $this->assertFalse($described['requireChecklist']); + }//end testAnUnresolvableVersionFailsNamingFlowAndVersion() + + /** + * A version that has no such step is as unresolvable as a missing version. + */ + public function testAVersionWithoutTheStepIsUnresolvable(): void { + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: 2)); + $this->published->method('ofRun')->willReturn(['nodes' => [['id' => 'other', 'type' => 'openregister.end', 'config' => []]]]); + + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + + $this->assertSame(TaskFormResolver::STATE_UNRESOLVABLE, $form['state']); + $this->assertStringContainsString('no step "ask"', $form['error']); + }//end testAVersionWithoutTheStepIsUnresolvable() + + /** + * A run that no longer exists cannot lend its declaration. + */ + public function testAVanishedRunIsUnresolvable(): void { + $this->runs->method('findByUuid')->willThrowException(new DoesNotExistException('gone')); + + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + + $this->assertSame(TaskFormResolver::STATE_UNRESOLVABLE, $form['state']); + $this->assertStringContainsString('run run-1', $form['error']); + }//end testAVanishedRunIsUnresolvable() + + /** + * The interactive draft test run is the one unpinned dispatch; it walks the live document. + */ + public function testAnUnpinnedTestRunReadsTheLiveDocument(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: null)); + $this->published->expects($this->never())->method('ofRun'); + $live = new Flow(); + $live->setNodes($this->graphWith(['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => 'note'])['nodes']); + $this->flows->method('findByUuid')->with(self::FLOW)->willReturn($live); + + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + + $this->assertSame(['note'], array_column($form['fields'], 'field')); + }//end testAnUnpinnedTestRunReadsTheLiveDocument() + + /** + * A field the schema dropped after the step was saved renders as broken with + * its reason; the form is not presented as complete and correct; the other + * field still renders. + */ + public function testAFieldTheSchemaDroppedIsVisibleAsBroken(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: 1)); + $this->published->method('ofRun') + ->willReturn($this->graphWith(['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => 'evidence*, note'])); + + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + + $this->assertSame(TaskFormResolver::STATE_BROKEN, $form['state']); + $this->assertFalse($form['fields'][0]['renderable']); + $this->assertStringContainsString('no such property', $form['fields'][0]['reason']); + $this->assertTrue($form['fields'][0]['required'], 'the declaration still says required; nothing is silently dropped'); + $this->assertTrue($form['fields'][1]['renderable']); + }//end testAFieldTheSchemaDroppedIsVisibleAsBroken() + + /** + * A step naming an action carries the transition's inputs, required from the transition. + */ + public function testAnActionFormCarriesTheTransitionsInputs(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $this->engine->method('declaredInputs')->with($this->anything(), 'reject') + ->willReturn([['field' => 'reason', 'required' => true]]); + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: 1)); + $this->published->method('ofRun') + ->willReturn($this->graphWith(['formKind' => 'fields', 'formSchema' => 'case', 'formAction' => 'reject'])); + + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + + $this->assertSame('reject', $form['action']); + $this->assertSame([['field' => 'reason', 'required' => true, 'order' => 0, 'renderable' => true, 'reason' => null]], $form['fields']); + }//end testAnActionFormCarriesTheTransitionsInputs() + + /** + * A schema that no longer declares the action breaks the form, saying so. + */ + public function testASchemaThatDroppedTheActionBreaksTheForm(): void { + $this->schemas->method('find')->willReturn($this->caseSchema()); + $this->engine->method('declaredInputs')->willReturn(null); + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: 1)); + $this->published->method('ofRun') + ->willReturn($this->graphWith(['formKind' => 'fields', 'formSchema' => 'case', 'formAction' => 'reject'])); + + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + + $this->assertSame(TaskFormResolver::STATE_BROKEN, $form['state']); + $this->assertStringContainsString('declares no lifecycle action "reject"', $form['error']); + $this->assertSame([], $form['fields']); + }//end testASchemaThatDroppedTheActionBreaksTheForm() + + /** + * A step declaring no form describes none; the checklist rule still travels. + */ + public function testAStepWithoutAFormDescribesNone(): void { + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: 1)); + $this->published->method('ofRun')->willReturn($this->graphWith(['formRequireChecklist' => true])); + $this->schemas->expects($this->never())->method('find'); + + $described = $this->resolver->describe(task: $this->taskOfRun()); + + $this->assertNull($described['form']); + $this->assertTrue($described['requireChecklist']); + }//end testAStepWithoutAFormDescribesNone() + + /** + * An external form resolves through the subject's link and is ready while open. + */ + public function testAnOpenExternalFormIsReady(): void { + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: 1)); + $this->published->method('ofRun')->willReturn($this->graphWith(['formKind' => 'external', 'formId' => 9])); + $link = new FormLink(); + $link->setFormId(9); + $link->setFormHash('abc'); + $link->setTitle('Intake'); + $link->setStatus('open'); + $this->formLinks->method('findFormLink')->with('obj-1', 9)->willReturn($link); + + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + + $this->assertSame('external', $form['kind']); + $this->assertSame(TaskFormResolver::STATE_READY, $form['state']); + $this->assertSame('abc', $form['formHash']); + $this->assertSame('Intake', $form['title']); + }//end testAnOpenExternalFormIsReady() + + /** + * An expired, archived or unlinked bound form is not offered as the way to finish. + */ + public function testAnUnusableExternalFormSaysSo(): void { + $this->runs->method('findByUuid')->willReturn($this->pinnedRun(version: 1)); + $this->published->method('ofRun')->willReturn($this->graphWith(['formKind' => 'external', 'formId' => 9])); + + $expired = new FormLink(); + $expired->setFormId(9); + $expired->setStatus('open'); + $expired->setExpiresAt(new DateTime('2020-01-01')); + $archived = new FormLink(); + $archived->setFormId(9); + $archived->setStatus('archived'); + $this->formLinks->method('findFormLink')->willReturnOnConsecutiveCalls($expired, $archived, null); + + foreach (['expired', 'archived', 'no longer linked'] as $expected) { + $form = $this->resolver->describe(task: $this->taskOfRun())['form']; + $this->assertSame(TaskFormResolver::STATE_UNAVAILABLE, $form['state']); + $this->assertStringContainsString($expected, (string)$form['error']); + } + }//end testAnUnusableExternalFormSaysSo() +}//end class diff --git a/tests/Unit/Service/Task/TaskServiceTest.php b/tests/Unit/Service/Task/TaskServiceTest.php index e44e89af79..f8112953a7 100644 --- a/tests/Unit/Service/Task/TaskServiceTest.php +++ b/tests/Unit/Service/Task/TaskServiceTest.php @@ -38,6 +38,8 @@ use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\Task\TaskAuthorizationService; use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCA\OpenRegister\Service\Task\TaskForm; +use OCA\OpenRegister\Service\Task\TaskFormReader; use OCA\OpenRegister\Service\Task\TaskPerformerResolver; use OCA\OpenRegister\Service\Task\TaskService; use OCP\IDBConnection; @@ -45,6 +47,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use RuntimeException; +use UnexpectedValueException; /** * Authorization ordering, concurrency, transactionality and normalisation. @@ -59,6 +62,7 @@ * @covers \OCA\OpenRegister\Exception\TaskValidationException * @covers \OCA\OpenRegister\Exception\TaskAccessDeniedException * @covers \OCA\OpenRegister\Exception\TaskConflictException + * @uses \OCA\OpenRegister\Service\Task\TaskForm */ class TaskServiceTest extends TestCase { @@ -606,6 +610,114 @@ public function testASecondCompletionLosesTheConditionalUpdate(): void { $this->service()->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: 'no', actor: 'alice'); }//end testASecondCompletionLosesTheConditionalUpdate() + /** + * A REFUSED COMPLETION IS AUDITED, DISTINCTLY: its own action name, the + * task's unchanged state, authorized (the caller was allowed to try), and + * outside any transaction because nothing else changed. + * + * @return void + */ + public function testARefusedCompletionIsAuditedDistinctlyFromACompletion(): void { + $this->audits->expects($this->once())->method('insert')->willReturnCallback( + function (TaskAudit $entry): TaskAudit { + $this->assertSame('complete-refused', $entry->getAction()); + $this->assertSame(Task::STATE_ACTIVE, $entry->getStateAfter()); + $this->assertTrue($entry->getAuthorized()); + $this->assertSame('alice', $entry->getActor()); + $this->assertStringContainsString('reason', (string)$entry->getReason()); + + return $entry; + } + ); + $this->db->expects($this->never())->method('beginTransaction'); + $this->tasks->expects($this->never())->method('updateIfOpen'); + + $this->service()->recordRefusedCompletion(task: $this->openTask(), reason: 'missing required input field(s): "reason"', actor: 'alice'); + }//end testARefusedCompletionIsAuditedDistinctlyFromACompletion() + + /** + * The seam a write-first caller uses: exists, authorized, open. A terminal + * task is a conflict and a denial is audited, exactly as for the verb. + * + * @return void + */ + public function testAuthorizedOpenTaskRefusesATerminalTaskAndAuditsADenial(): void { + $closed = $this->openTask(); + $closed->setState(Task::STATE_COMPLETED); + $closed->setIsTerminal(true); + $this->tasks->method('findByUuid')->willReturn($closed); + + try { + $this->service()->authorizedOpenTask(verb: 'complete', uuid: 't-7', actor: 'alice'); + $this->fail('Expected a conflict.'); + } catch (TaskConflictException $conflict) { + $this->assertStringContainsString('completed', $conflict->getMessage()); + } + + $this->authorization->method('assertMay')->willThrowException(new TaskAccessDeniedException('not the assignee')); + $this->audits->expects($this->once())->method('insert'); + + $this->expectException(TaskAccessDeniedException::class); + $this->service()->authorizedOpenTask(verb: 'complete', uuid: 't-7', actor: 'mallory'); + }//end testAuthorizedOpenTaskRefusesATerminalTaskAndAuditsADenial() + + /** + * A run-less task's own form declaration is refused at creation, the way a + * step's is at save: nothing is inserted, the reader's reason is the message. + * + * @return void + */ + public function testCreateRefusesARecordFormTheReaderRefuses(): void { + $this->authorization->method('isAdministrator')->willReturn(false); + $reader = $this->createMock(TaskFormReader::class); + $reader->method('fromRecord')->willReturn(new TaskForm(kind: 'fields', schema: 'case', fields: [['field' => 'reasonn', 'required' => true]])); + $reader->method('validate')->willThrowException(new UnexpectedValueException('Field "reasonn" of schema "case" cannot be asked for: the schema has no such property.')); + $service = new TaskService( + tasks: $this->tasks, + candidates: $this->candidates, + relations: $this->relations, + audits: $this->audits, + authorization: $this->authorization, + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->db, + logger: new NullLogger(), + builder: new TaskBuilder(), + forms: $reader + ); + $this->tasks->expects($this->never())->method('insert'); + + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage('reasonn'); + $service->create(data: ['title' => 'Approve', 'metadata' => ['form' => ['kind' => 'fields', 'schema' => 'case', 'fields' => 'reasonn*']]], actor: 'alice'); + }//end testCreateRefusesARecordFormTheReaderRefuses() + + /** + * A task without a record form is created without consulting the reader. + * + * @return void + */ + public function testCreateWithoutARecordFormNeverConsultsTheReader(): void { + $this->authorization->method('isAdministrator')->willReturn(false); + $reader = $this->createMock(TaskFormReader::class); + $reader->expects($this->never())->method('validate'); + $service = new TaskService( + tasks: $this->tasks, + candidates: $this->candidates, + relations: $this->relations, + audits: $this->audits, + authorization: $this->authorization, + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->db, + logger: new NullLogger(), + builder: new TaskBuilder(), + forms: $reader + ); + + $created = $service->create(data: ['title' => 'Approve'], actor: 'alice'); + + $this->assertSame('Approve', $created->getTitle()); + }//end testCreateWithoutARecordFormNeverConsultsTheReader() + /** * OVER HTTP, THE REQUESTER IS THE ACTOR: an ordinary caller cannot write * somebody else's name into the seat that owns cancel and reassign. diff --git a/tests/e2e/api-direct/flow-task-forms.spec.ts b/tests/e2e/api-direct/flow-task-forms.spec.ts new file mode 100644 index 0000000000..b1676a5d23 --- /dev/null +++ b/tests/e2e/api-direct/flow-task-forms.spec.ts @@ -0,0 +1,592 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Task forms, end to end over the live HTTP API: the @e2e-marked scenarios + * of the flow-task-forms spec. + * + * A register and a subject schema with a lifecycle are created first; the + * schema's `reject` transition declares `inputs`, and `reason` is deliberately + * NOT in the schema's own `required` list, so the read must carry `required` + * from the DECLARATION. A flow is authored through `POST /api/flows`, run + * through the synchronous test endpoint until its user-task node suspends, + * and the task is read and completed through the flow-tasks verbs. + * + * The rendering half of the "renders as required" scenario is the shared + * component library's; this suite proves the server half it renders from. + * + * @spec openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md + */ +import type { APIRequestContext } from '@playwright/test' + +import { expect, test } from '@playwright/test' + +const API = '/index.php/apps/openregister/api' +const JSON_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json', +} +const RUN_ID = `e2e-taskform-${Date.now().toString(36)}` +const ADMIN = process.env.NEXTCLOUD_ADMIN_USER || process.env.OR_USER || 'admin' +const ADMIN_PASS = + process.env.NEXTCLOUD_ADMIN_PASSWORD || process.env.OR_PASS || 'admin' + +// Basic auth, no session cookie, so no CSRF token is demanded. +const NO_SESSION = { cookies: [], origins: [] } +const ADMIN_HEADERS = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${ADMIN}:${ADMIN_PASS}`).toString('base64')}`, +} + +test.use({ storageState: NO_SESSION, extraHTTPHeaders: ADMIN_HEADERS }) +test.describe.configure({ mode: 'serial' }) + +type Node = Record + +async function createFlow( + request: APIRequestContext, + label: string, + nodes: Node[], + edges: Array>, +): Promise { + const resp = await request.post(`${API}/flows`, { + headers: JSON_HEADERS, + data: { + name: `${RUN_ID} ${label}`, + description: 'Created by the flow-task-forms e2e suite.', + trigger: 'manual', + enabled: true, + nodes, + edges, + }, + }) + expect(resp.status(), await resp.text()).toBe(201) + const body = await resp.json() + const uuid = body.uuid ?? body.id + expect(uuid, 'flow uuid').toBeTruthy() + return uuid as string +} + +function userTask(id: string, config: Record = {}): Node { + return { + id, + type: 'openregister.user-task', + config: { + title: `${RUN_ID} ${id}: assess {{ name }}`, + assignee: ADMIN, + outcomes: 'approved, rejected', + ...config, + }, + position: { x: 0, y: 0 }, + } +} + +function setFields(id: string, set: Record): Node { + return { + id, + type: 'openregister.set-fields', + config: { set }, + position: { x: 0, y: 0 }, + } +} + +/** Run a flow synchronously against ONE seeded item carrying the subject anchor. */ +async function testRun( + request: APIRequestContext, + flowId: string, + subject: Record, +) { + const resp = await request.post(`${API}/flow-runs/test`, { + headers: JSON_HEADERS, + data: { flowId, seedItems: [{ json: subject }] }, + }) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() +} + +async function readRun(request: APIRequestContext, uuid: string) { + const resp = await request.get(`${API}/flow-runs/${uuid}`) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() +} + +async function inboxTaskFor( + request: APIRequestContext, + runUuid: string, + nodeId: string, +) { + const resp = await request.get( + `${API}/flow-tasks?scope=assigned&isTerminal=false&limit=100&sort=created&direction=desc`, + ) + expect(resp.status(), await resp.text()).toBe(200) + const rows = ((await resp.json()).results ?? []) as Array< + Record + > + return ( + rows.find((row) => row.runUuid === runUuid && row.nodeId === nodeId) ?? null + ) +} + +async function readTask(request: APIRequestContext, uuid: string) { + const resp = await request.get(`${API}/flow-tasks/${uuid}`) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() +} + +async function postComplete( + request: APIRequestContext, + uuid: string, + body: Record, +) { + return request.post(`${API}/flow-tasks/${uuid}/complete`, { + headers: JSON_HEADERS, + data: body, + }) +} + +test.describe('flow-task-forms: the form a person fills to complete a task', () => { + const flows: string[] = [] + let registerId: string + let schemaId: string + let schemaSlug: string + + /** A fresh subject object in `open` state; returns its record with the `@self` anchor. */ + async function subject( + request: APIRequestContext, + name: string, + ): Promise> { + const resp = await request.post(`${API}/objects/${registerId}/${schemaId}`, { + headers: JSON_HEADERS, + data: { name, status: 'open' }, + }) + expect(resp.status(), await resp.text()).toBeLessThanOrEqual(201) + const body = await resp.json() + const self = body['@self'] ?? {} + expect(self.uuid ?? body.uuid, 'subject uuid').toBeTruthy() + return { + ...body, + '@self': { + uuid: self.uuid ?? body.uuid, + register: Number(self.register ?? registerId), + schema: Number(self.schema ?? schemaId), + }, + } + } + + async function readSubject(request: APIRequestContext, uuid: string) { + const resp = await request.get( + `${API}/objects/${registerId}/${schemaId}/${uuid}`, + ) + expect(resp.status(), await resp.text()).toBe(200) + return resp.json() + } + + test.beforeAll(async ({ request }) => { + const register = await request.post(`${API}/registers`, { + headers: JSON_HEADERS, + data: { + title: `${RUN_ID} register`, + description: 'flow-task-forms e2e', + }, + }) + expect(register.status(), await register.text()).toBeLessThanOrEqual(201) + const registerBody = await register.json() + registerId = String(registerBody.id ?? registerBody['@self']?.id) + + schemaSlug = `${RUN_ID}-case` + const schema = await request.post(`${API}/schemas`, { + headers: JSON_HEADERS, + data: { + title: `${RUN_ID} case`, + slug: schemaSlug, + properties: { + name: { type: 'string', title: 'Name' }, + status: { type: 'string', title: 'Status' }, + // Optional on the object, mandatory when rejecting. + reason: { type: 'string', title: 'Reason' }, + note: { type: 'string', title: 'Note' }, + }, + required: ['name'], + configuration: { + 'x-openregister-lifecycle': { + field: 'status', + transitions: { + approve: { from: ['open'], to: 'approved' }, + reject: { + from: ['open'], + to: 'rejected', + inputs: [ + { field: 'reason', required: true }, + { field: 'note', required: false }, + ], + }, + }, + }, + }, + }, + }) + expect(schema.status(), await schema.text()).toBeLessThanOrEqual(201) + const schemaBody = await schema.json() + schemaId = String(schemaBody.id ?? schemaBody['@self']?.id) + }) + + test.afterAll(async ({ request }) => { + for (const uuid of flows) { + await request.delete(`${API}/flows/${uuid}`).catch(() => {}) + } + }) + + // @e2e flow-task-forms::a-step-with-no-form-still-completes + test('a task with no form completes with an outcome alone', async ({ + request, + }) => { + const flowId = await createFlow( + request, + 'no form', + [ + setFields('start', { step: 1 }), + userTask('ask'), + setFields('done', { step: 2 }), + ], + [ + { id: 'e1', from: 'start', to: 'ask' }, + { id: 'e2', from: 'ask', to: 'done' }, + ], + ) + flows.push(flowId) + const run = await testRun(request, flowId, await subject(request, 'Case A')) + expect(run.status).toBe('suspended') + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task).toBeTruthy() + + const read = await readTask(request, task!.uuid as string) + expect(read.form, 'a step with no form describes none').toBeNull() + expect(read.requireChecklist).toBe(false) + + const done = await postComplete(request, task!.uuid as string, { + outcome: 'approved', + comment: 'fine', + }) + expect(done.status(), await done.text()).toBe(200) + expect((await done.json()).state).toBe('completed') + }) + + // @e2e flow-task-forms::a-transition-required-field-renders-as-required + test('the task read marks a transition-required, schema-optional field as required', async ({ + request, + }) => { + const flowId = await createFlow( + request, + 'reject form', + [ + setFields('start', { step: 1 }), + userTask('ask', { + formKind: 'fields', + formSchema: schemaSlug, + formAction: 'reject', + }), + setFields('done', { step: 2 }), + ], + [ + { id: 'e1', from: 'start', to: 'ask' }, + { id: 'e2', from: 'ask', to: 'done' }, + ], + ) + flows.push(flowId) + const run = await testRun(request, flowId, await subject(request, 'Case B')) + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task).toBeTruthy() + + const read = await readTask(request, task!.uuid as string) + expect(read.form.kind).toBe('fields') + expect(read.form.state).toBe('ready') + expect(read.form.action).toBe('reject') + const fields = read.form.fields as Array> + expect(fields.map((f) => f.field)).toEqual(['reason', 'note']) + // `reason` is NOT in the schema's required list; the declaration says it is. + expect(fields[0].required).toBe(true) + expect(fields[0].order).toBe(0) + expect(fields[1].required).toBe(false) + expect(fields[1].order).toBe(1) + + // Cleanup: complete through the form so the run can end. + const done = await postComplete(request, task!.uuid as string, { + outcome: 'rejected', + comment: 'late', + data: { reason: 'late' }, + }) + expect(done.status(), await done.text()).toBe(200) + }) + + // @e2e flow-task-forms::a-missing-required-field-is-named-and-the-task-stays-open + test('a completion missing a required field is refused naming it, and the task stays in the inbox', async ({ + request, + }) => { + const flowId = await createFlow( + request, + 'missing required', + [ + setFields('start', { step: 1 }), + userTask('ask', { + formKind: 'fields', + formSchema: schemaSlug, + formAction: 'reject', + }), + ], + [{ id: 'e1', from: 'start', to: 'ask' }], + ) + flows.push(flowId) + const record = await subject(request, 'Case C') + const run = await testRun(request, flowId, record) + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task).toBeTruthy() + + const refused = await postComplete(request, task!.uuid as string, { + outcome: 'rejected', + comment: 'no reason given', + data: { note: 'only a note' }, + }) + expect(refused.status(), await refused.text()).toBe(400) + const body = await refused.json() + expect(body.fields).toEqual(['reason']) + expect(body.kind).toBe('missing') + + // An undeclared key is a different refusal, named as such. + const undeclared = await postComplete(request, task!.uuid as string, { + outcome: 'rejected', + comment: 'x', + data: { reason: 'late', surprise: 1 }, + }) + expect(undeclared.status()).toBe(400) + expect((await undeclared.json()).fields).toEqual(['surprise']) + expect((await undeclared.json()).kind).toBe('undeclared') + + // The task is still actionable, the run still suspended, the subject unchanged. + const still = await inboxTaskFor(request, run.uuid, 'ask') + expect(still, 'the task stays in the assignee inbox').toBeTruthy() + expect(still!.state).toBe('active') + expect((await readRun(request, run.uuid)).status).toBe('suspended') + const selfUuid = (record['@self'] as Record).uuid as string + const object = await readSubject(request, selfUuid) + expect(object.status).toBe('open') + expect(object.reason ?? null).toBeNull() + + // The audit knows about the attempts, and records no completion. + const audit = await request.get(`${API}/flow-tasks/${task!.uuid}/audit`) + const actions = ((await audit.json()).results ?? []).map( + (row: { action: string }) => row.action, + ) + expect(actions).toContain('complete-refused') + expect(actions).not.toContain('complete') + + // A correct payload lands the values and the state change in one save. + const done = await postComplete(request, task!.uuid as string, { + outcome: 'rejected', + comment: 'late', + data: { reason: 'late', note: 'second attempt' }, + }) + expect(done.status(), await done.text()).toBe(200) + const written = await readSubject(request, selfUuid) + expect(written.status).toBe('rejected') + expect(written.reason).toBe('late') + expect(written.note).toBe('second attempt') + }) + + // @e2e flow-task-forms::a-field-the-schema-dropped-later-is-visible-as-broken + test('a declared field the schema no longer has shows as broken, never silently omitted', async ({ + request, + }) => { + const dropSlug = `${RUN_ID}-drift` + const created = await request.post(`${API}/schemas`, { + headers: JSON_HEADERS, + data: { + title: `${RUN_ID} drift`, + slug: dropSlug, + properties: { + name: { type: 'string', title: 'Name' }, + evidence: { type: 'string', title: 'Evidence' }, + }, + }, + }) + expect(created.status(), await created.text()).toBeLessThanOrEqual(201) + const driftId = String( + (await created.json()).id ?? (await created.json())['@self']?.id, + ) + + const flowId = await createFlow( + request, + 'drift', + [ + setFields('start', { step: 1 }), + userTask('ask', { + formKind: 'fields', + formSchema: dropSlug, + formFields: 'evidence*', + }), + ], + [{ id: 'e1', from: 'start', to: 'ask' }], + ) + flows.push(flowId) + const run = await testRun(request, flowId, { name: 'Drift' }) + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task).toBeTruthy() + + // The schema drops the field AFTER the step was saved and the task created. + const patched = await request.put(`${API}/schemas/${driftId}`, { + headers: JSON_HEADERS, + data: { + title: `${RUN_ID} drift`, + slug: dropSlug, + properties: { name: { type: 'string', title: 'Name' } }, + }, + }) + expect(patched.status(), await patched.text()).toBeLessThanOrEqual(201) + + const read = await readTask(request, task!.uuid as string) + expect(read.form.state).toBe('broken') + const field = (read.form.fields as Array>)[0] + expect(field.field).toBe('evidence') + expect(field.renderable).toBe(false) + expect(String(field.reason)).toContain('no such property') + expect(field.required, 'the declaration is not silently narrowed').toBe(true) + + await postComplete(request, task!.uuid as string, { + outcome: 'approved', + }).catch(() => {}) + }) + + // @e2e flow-task-forms::editing-the-flow-leaves-an-open-tasks-form-alone + test('editing and publishing the flow does not change an already-open task form', async ({ + request, + }) => { + const flowId = await createFlow( + request, + 'versioned', + [ + setFields('start', { step: 1 }), + userTask('ask', { + formKind: 'fields', + formSchema: schemaSlug, + formFields: 'reason*, note', + }), + ], + [{ id: 'e1', from: 'start', to: 'ask' }], + ) + flows.push(flowId) + + // Publish, so a run pins to version 1 rather than walking the draft. + const published = await request.post(`${API}/flows/${flowId}/publish`, { + headers: JSON_HEADERS, + data: {}, + }) + test.skip( + published.status() === 404, + 'no publish endpoint on this build; versioning covered by unit tests', + ) + expect(published.status(), await published.text()).toBeLessThanOrEqual(201) + + const run = await testRun(request, flowId, await subject(request, 'Case D')) + const task = await inboxTaskFor(request, run.uuid, 'ask') + expect(task).toBeTruthy() + const before = await readTask(request, task!.uuid as string) + expect((before.form.fields as unknown[]).length).toBe(2) + + // Edit the step to declare four fields and publish again. + const flow = await (await request.get(`${API}/flows/${flowId}`)).json() + const nodes = (flow.nodes as Node[]).map((node) => + node.id === 'ask' + ? { + ...node, + config: { + ...(node.config as Record), + formFields: 'reason*, note, name, status', + }, + } + : node, + ) + const edited = await request.put(`${API}/flows/${flowId}`, { + headers: JSON_HEADERS, + data: { ...flow, nodes }, + }) + expect(edited.status(), await edited.text()).toBeLessThanOrEqual(201) + const republished = await request.post(`${API}/flows/${flowId}/publish`, { + headers: JSON_HEADERS, + data: {}, + }) + expect(republished.status(), await republished.text()).toBeLessThanOrEqual( + 201, + ) + + const after = await readTask(request, task!.uuid as string) + expect( + (after.form.fields as unknown[]).length, + 'the open task keeps the form its version declared', + ).toBe(2) + + await postComplete(request, task!.uuid as string, { + outcome: 'approved', + data: { reason: 'ok' }, + }) + }) + + // @e2e flow-task-forms::an-unchecked-mandatory-item-refuses-the-completion + test('an unchecked mandatory checklist item refuses the completion naming the item', async ({ + request, + }) => { + // A run-less task, first-class: the checklist and the rule live on the record. + const created = await request.post(`${API}/flow-tasks`, { + headers: JSON_HEADERS, + data: { + title: `${RUN_ID} checklist`, + assignee: ADMIN, + state: 'active', + checklist: [ + { + id: 'c1', + label: 'Identity verified', + description: '', + checked: true, + }, + { + id: 'c2', + label: 'Documents scanned', + description: '', + checked: false, + }, + ], + metadata: { form: { kind: null, requireChecklist: true } }, + }, + }) + expect(created.status(), await created.text()).toBe(201) + const task = await created.json() + + const read = await readTask(request, task.uuid as string) + expect(read.requireChecklist).toBe(true) + expect(read.form).toBeNull() + + const refused = await postComplete(request, task.uuid as string, { + outcome: 'approved', + }) + expect(refused.status(), await refused.text()).toBe(400) + const body = await refused.json() + expect(body.kind).toBe('checklist') + expect(body.fields).toEqual(['c2']) + + // Checking the item is task state, through the task's own verb. + const checked = await request.patch( + `${API}/flow-tasks/${task.uuid}/checklist/c2`, + { + headers: JSON_HEADERS, + data: { checked: true }, + }, + ) + expect(checked.status(), await checked.text()).toBe(200) + + const done = await postComplete(request, task.uuid as string, { + outcome: 'approved', + }) + expect(done.status(), await done.text()).toBe(200) + expect((await done.json()).state).toBe('completed') + }) +}) From 9e6bb06de36c10b3d178881a4742859e04280e05 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 19:09:43 +0200 Subject: [PATCH 35/68] fix(flow): imported flows fire (id/slug seam) and can be adopted (#3285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(flow): object triggers fire in one vocabulary — slugs — on both sides The trigger index and the flow trigger columns hold SLUGS (the index column is literally schema_slug, and an imported x-openregister-flows declaration cannot know an instance's row ids), while the listener fired the object's NUMERIC ids. flowUuidsFor() and findByTrigger() compared the two literally, so 16 never equalled dossiq: on a clean instance (dossiq 0.3.11-unstable, openregister 2.0.13-unstable) three case creations with the imported flow enabled and owned queued nothing and logged nothing. Hand-inserting an index row keyed 16/26 made the next creation queue a run, proving the match was the broken seam. The shipped Bezwaar advies flow had the same defect. The slug is canonical, and the resolution lives in ONE new seam, FlowTriggerSlugs (id, uuid or slug in; slug out; pass-through, never blank, when nothing resolves), used by both sides: - FlowTriggerListener resolves the fired subject's ids to slugs, so an id-keyed event matches a slug-declared trigger. - FlowTriggerIndex normalises every derived trigger before writing, so a builder-authored node holding numeric ids produces the same slug row an imported declaration does, and two nodes naming one triple through different identifiers collapse to one row. - The registered BackfillFlowTriggerIndex repair step (post-migration in appinfo/info.xml) rebuilds through that same writer, so existing id-keyed rows are rewritten as slugs on upgrade and already-imported flows start firing without being re-saved. EventCatalogListener is retired with it: it was registered for the SAME seven object events as FlowTriggerListener, each handler calling FlowTriggerService::fire() — a double-fire invisible only while both spoke the wrong vocabulary and matched nothing. Fixing the vocabulary with both registered would queue every matched flow twice per event. FlowTriggerListener is the superset (user attribution, transition context), so it is the one path kept. Tests: FlowTriggerIdSlugMatchTest wires the real listener → trigger service → locator over honest doubles and proves an object saved by id queues a run for a slug-declared flow (and slugs stay idempotent); FlowTriggerIndexSlugNormalisationTest proves the write side and the rebuild/repair path; FlowTriggerSlugsTest pins the resolver contract. * feat(flow): an adoption seam — a shipped flow can finally gain an owner An imported x-openregister-flows flow deliberately arrives enabled=false, owner=null, and Flow::canDispatch() fails closed on the missing owner. That half is correct: a schema save is not a person volunteering to run a graph. But 'inert until somebody makes it theirs' had no second half — owner is not among applyEditableFields()'s allowlist (correctly, so a payload cannot hand a flow to an arbitrary uid), no occ command sets it, and dispatchableUuids() drops the ownerless flow with only a log line. Measured 2026-09-01 on a clean instance: the only route from shipped to runnable was raw SQL. POST /api/flows/{id}/adopt is the deliberate act: - The CALLER becomes the owner; the body is ignored entirely. An endpoint that accepted a uid would let anyone with flow.update volunteer a colleague's identity for unattended execution. - Behind the flow.update right, reached through the organisation-scoped FlowService::find(), so a foreign flow answers the same 404 as a missing one. - A flow already owned by another user is refused with a machine-readable 409 (already-owned): adoption is not a takeover. Re-adopting one's own flow is an idempotent no-op. - Enabling stays separate: adoption answers whose identity, enabled answers may it run, and the flow dispatches only when both are true. - The adoption is audited at info level with the flow, the adopter and the previous (null) owner. - No occ command, deliberately: occ has no acting user to volunteer, which is the same reason the import listener cannot set an owner. Tests: FlowControllerTest covers the contract (caller-as-owner with the body never read, 401 anonymous, 403 without the right, 404 foreign, 409 owned); FlowAdoptionTest covers the service (refusals write nothing, idempotence) and proves through the real FlowLocator that the same flow is dropped as imported and dispatched once adopted and enabled. * chore(quality): absorb the adoption seam within the analyzers' ceilings FlowService's overall complexity went 49 to 52 with adopt()'s three branches, over PHPMD's 50. Rather than suppressing, four null-or-empty double-checks are rewritten to single conditions with identical semantics (actingUser, activeOrganisation, flowToSave, persistFlow), landing at 48 — the seam pays for itself. FlowController tripped ExcessiveClassLength (1,012 of 1,000) and TooManyMethods (26 of 25) together when adopt() was added; both are suppressed with the same rationale the class already carries three times: adoption is one more verb on the same /api/flows/{id} resource, and a controller of its own would be the two-controllers-behind-one-prefix split every existing suppression declines. Also declares FlowControllerTest::$actionAuth, which was a dynamic property (deprecated since PHP 8.2) created on the fly in setUp(), and names the message parameter in FlowAdoptionRefused's parent constructor call per the named-parameters sniff. phpcs (lib) 0, PHPMD per touched subdir 0 on both rulesets, Psalm 0, PHPStan 0, hydra gates --scope-to-diff: all 41 applicable gates green. * test(flow): stop PHPUnit discarding the new tests' coverage, and cover the catalog The PHPUnit job failed on the coverage guard: Surviving code, head: 44.20% (297/672 statements) Surviving code, base: 48.90% (288/589 statements) FAIL: coverage of the code this change KEEPS or ADDS dropped by 4.7%. The mechanism, verified against the job log and reproduced under pcov: phpunit.xml sets beStrictAboutCoverageMetadata, so a covers-tagged test that executes any class not named in its metadata is RISKY, and PHPUnit discards a risky test's coverage wholesale. Every new test was covers-tagged without a uses-list, so FlowTriggerListener measured 0/40 with a green, dedicated test suite — and tagging the pre-existing FlowTriggerListenerTest threw away coverage the base still had, which is most of the drop. Fix: complete uses-lists on every covers-tagged test this branch owns (entities, events, Register/Schema, FlowLocator, FlowTriggerDerivation), plus the pre-existing gap in FlowControllerTest, whose whole coverage was already being discarded on development over the real FlowAccess and FlowState it deliberately constructs. Also covers EventCatalogService (0/14 in scope, no test at all): the catalog must offer every trigger the listener fires, legacy aliases must keep resolving, and an unknown id must round-trip. Verified in a pcov container: the covers-tagged slice runs 57 tests, 0 risky, and the changed files credit as FlowTriggerListener 37/40, FlowTriggerSlugs 26/26, FlowAdoptionRefused 2/2, EventCatalogService 14/14, FlowTriggerIndex 43/73, FlowController 107/269 from this slice alone. Also merges origin/development (#3271 landed since the branch was cut). --- appinfo/routes.php | 4 + lib/AppInfo/Application.php | 37 +-- lib/Controller/FlowController.php | 72 ++++++ lib/Event/CaseItemTransitionedEvent.php | 6 +- lib/Listener/EventCatalogListener.php | 158 ------------- lib/Listener/FlowTriggerListener.php | 67 +++++- lib/Repair/BackfillFlowTriggerIndex.php | 10 + lib/Service/Flow/EventCatalogService.php | 4 +- lib/Service/Flow/FlowAdoptionRefused.php | 77 +++++++ lib/Service/Flow/FlowService.php | 92 +++++++- lib/Service/Flow/FlowTriggerIndex.php | 36 ++- lib/Service/Flow/FlowTriggerSlugs.php | 167 ++++++++++++++ openspec/changes/flow-adoption/proposal.md | 41 ++++ .../flow-adoption/specs/flow-storage/spec.md | 57 +++++ openspec/changes/flow-adoption/tasks.md | 12 + .../flow-trigger-canonical-slugs/proposal.md | 53 +++++ .../specs/flow-engine/spec.md | 59 +++++ .../flow-trigger-canonical-slugs/tasks.md | 14 ++ tests/Unit/Controller/FlowControllerTest.php | 152 +++++++++++++ .../Unit/Listener/FlowTriggerListenerTest.php | 166 +++++++++++++- .../Unit/Service/Case/CasePlanServiceTest.php | 9 +- .../Service/Flow/EventCatalogServiceTest.php | 89 ++++++++ tests/Unit/Service/Flow/FlowAdoptionTest.php | 212 ++++++++++++++++++ .../Flow/FlowTriggerIdSlugMatchTest.php | 208 +++++++++++++++++ .../Flow/FlowTriggerIndexPublishedTest.php | 13 ++ .../FlowTriggerIndexSlugNormalisationTest.php | 202 +++++++++++++++++ .../Service/Flow/FlowTriggerSlugsTest.php | 137 +++++++++++ 27 files changed, 1961 insertions(+), 193 deletions(-) delete mode 100644 lib/Listener/EventCatalogListener.php create mode 100644 lib/Service/Flow/FlowAdoptionRefused.php create mode 100644 lib/Service/Flow/FlowTriggerSlugs.php create mode 100644 openspec/changes/flow-adoption/proposal.md create mode 100644 openspec/changes/flow-adoption/specs/flow-storage/spec.md create mode 100644 openspec/changes/flow-adoption/tasks.md create mode 100644 openspec/changes/flow-trigger-canonical-slugs/proposal.md create mode 100644 openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md create mode 100644 openspec/changes/flow-trigger-canonical-slugs/tasks.md create mode 100644 tests/Unit/Service/Flow/EventCatalogServiceTest.php create mode 100644 tests/Unit/Service/Flow/FlowAdoptionTest.php create mode 100644 tests/Unit/Service/Flow/FlowTriggerIdSlugMatchTest.php create mode 100644 tests/Unit/Service/Flow/FlowTriggerIndexSlugNormalisationTest.php create mode 100644 tests/Unit/Service/Flow/FlowTriggerSlugsTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index b443d0d94b..8da104b1ee 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -550,6 +550,10 @@ // The VERSION number is `\d+`, not `[^/]+`. Without that, // `/versions/publish` would match `version` with the literal string // "publish" and return a 404 for a route that exists. + // Adoption: the CALLER becomes the owner of a shipped, ownerless flow. + // A deliberate act with its own verb — `owner` is not an editable field + // on PUT, so this is the only path from imported to dispatchable. + ['name' => 'flow#adopt', 'url' => '/api/flows/{id}/adopt', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], ['name' => 'flow#versions', 'url' => '/api/flows/{id}/versions', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], ['name' => 'flow#version', 'url' => '/api/flows/{id}/versions/{version}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+', 'version' => '\d+']], ['name' => 'flow#publish', 'url' => '/api/flows/{id}/publish', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index a8a924a621..99c329bf05 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -101,7 +101,6 @@ use OCA\OpenRegister\Listener\CalculationOnSaveListener; use OCA\OpenRegister\Listener\CommentsEntityListener; use OCA\OpenRegister\Listener\ContextChatSubmissionListener; -use OCA\OpenRegister\Listener\EventCatalogListener; use OCA\OpenRegister\Listener\FileChangeListener; use OCA\OpenRegister\Listener\FilesSidebarListener; use OCA\OpenRegister\Listener\FlowEngineRegistrationListener; @@ -2650,9 +2649,15 @@ private function registerEventListeners(IRegistrationContext $context): void { ); $context->registerEventListener(ObjectUpdatedEvent::class, \OCA\OpenRegister\Listener\CaseObjectEventListener::class); $context->registerEventListener(ObjectTransitionedEvent::class, \OCA\OpenRegister\Listener\CaseObjectEventListener::class); + // Routed through FlowTriggerListener, the ONE object-trigger path (the + // branch that retired EventCatalogListener as a duplicate). The case + // branch moved with the retirement, and gains what the one path has: + // the subject's register/schema resolved to the SLUGS the trigger + // index stores — CaseItemTransitionedEvent::getSubject() answers the + // item's numeric ids — and the acting user attributed to the run. $context->registerEventListener( \OCA\OpenRegister\Event\CaseItemTransitionedEvent::class, - \OCA\OpenRegister\Listener\EventCatalogListener::class + \OCA\OpenRegister\Listener\FlowTriggerListener::class ); // Lifecycle annotation listeners — see x-openregister-lifecycle. @@ -2750,22 +2755,18 @@ private function registerEventListeners(IRegistrationContext $context): void { $context->registerEventListener(ObjectUpdatedEvent::class, AnnotationNotificationListener::class); $context->registerEventListener(ObjectTransitionedEvent::class, AnnotationNotificationListener::class); - // Object-CRUD flow triggers. These route through EventCatalogListener - // like every other catalog event, so there is ONE path from a dispatched - // event to a queued run — the action-list engine that used to handle - // create/update/delete separately is gone. - $context->registerEventListener(ObjectCreatedEvent::class, EventCatalogListener::class); - $context->registerEventListener(ObjectUpdatedEvent::class, EventCatalogListener::class); - $context->registerEventListener(ObjectDeletedEvent::class, EventCatalogListener::class); - - // Additional flow-catalog triggers beyond CRUD (lock/unlock/revert/state - // transition). Routed by EventCatalogListener so create/update/delete are - // not double-handled. Each event carries the object, so its schema's flows - // run — see EventCatalogService for the trigger ids. - $context->registerEventListener(ObjectLockedEvent::class, EventCatalogListener::class); - $context->registerEventListener(ObjectUnlockedEvent::class, EventCatalogListener::class); - $context->registerEventListener(ObjectRevertedEvent::class, EventCatalogListener::class); - $context->registerEventListener(ObjectTransitionedEvent::class, EventCatalogListener::class); + // Object-lifecycle flow triggers have ONE listener: FlowTriggerListener, + // registered above with the other flow triggers. EventCatalogListener + // used to be registered here for the SAME seven events, each handler + // calling FlowTriggerService::fire() — every object event reached the + // trigger service twice. The double-fire was invisible only because both + // listeners fired the object's numeric register/schema ids against an + // index that holds slugs, so neither ever matched; the moment the + // vocabulary was fixed, two registrations would have queued every + // matched flow twice per event. FlowTriggerListener is the one kept + // because it is the superset: it attributes the acting user to the run + // and carries the transition's action/from/to as context, both of which + // EventCatalogListener dropped. // Native Nextcloud Flow (workflowengine) composition — expose OR objects // as a Flow entity and OR flows as a Flow operation. Guarded by diff --git a/lib/Controller/FlowController.php b/lib/Controller/FlowController.php index b63836d845..4c8a89f5dd 100644 --- a/lib/Controller/FlowController.php +++ b/lib/Controller/FlowController.php @@ -41,6 +41,7 @@ use OCA\OpenRegister\Db\FlowStateMapper; use OCA\OpenRegister\Service\Flow\EventCatalogService; use OCA\OpenRegister\Service\Flow\FlowAccess; +use OCA\OpenRegister\Service\Flow\FlowAdoptionRefused; use OCA\OpenRegister\Service\Flow\FlowDeadEnd; use OCA\OpenRegister\Service\Flow\FlowLifecycleRefused; use OCA\OpenRegister\Service\Flow\FlowNodePreflight; @@ -92,6 +93,19 @@ * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * + * Length (1,012 lines against 1,000) and method count (26 against 25) tripped + * together when `adopt()` was added — the fourth and fifth instances of the + * same trade. Adoption is one more verb on the same `/api/flows/{id}` resource + * the other lifecycle verbs (publish, draft, deprecate) live on; a controller + * of its own would be the two-controllers-behind-one-prefix split every + * suppression above declines, this time for a single method. The lines are + * mostly documentation, and trimming the explanations to duck a line counter + * is the corrosive direction. Revisit alongside the complexity note above if a + * genuinely separable second concern appears. + * + * @SuppressWarnings(PHPMD.ExcessiveClassLength) + * @SuppressWarnings(PHPMD.TooManyMethods) + * * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md */ class FlowController extends Controller { @@ -1027,6 +1041,64 @@ public function deprecate(string $id): JSONResponse { return new JSONResponse($version->jsonSerialize()); }//end deprecate() + /** + * Adopt a flow: the CALLING user becomes its owner. + * + * The missing half of the shipped-flow lifecycle. An imported flow arrives + * `enabled=false, owner=null` and `Flow::canDispatch()` fails closed on + * the missing owner — correct — but no endpoint could supply one: `owner` + * is deliberately not an editable field, so "until somebody adopts it" had + * no mechanism and the only route on a live instance was raw SQL. + * + * The body is IGNORED. The new owner is the session's user, never a uid a + * request names — an endpoint that accepted one would let any caller with + * `flow.update` volunteer a colleague's identity for unattended execution. + * Enabling stays separate: adoption answers "whose flow", the `enabled` + * flag answers "may it run", and the flow becomes dispatchable only when + * both are true. + * + * @param string $id The flow uuid. + * + * @return JSONResponse The adopted flow, 401, 404, or 409 when it already + * belongs to someone else. + * + * @NoAdminRequired + * + * @no-admin-idor-exempt Guarded downstream: `FlowService::find()` applies + * the organisation scope (a foreign flow answers the same 404 as a missing + * one), and `adopt()` writes only the CALLER's uid — there is no + * caller-chosen object state to abuse. + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + #[NoAdminRequired] + public function adopt(string $id): JSONResponse { + $denied = $this->denyUnless(action: 'flow.update'); + if ($denied !== null) { + return $denied; + } + + try { + $flow = $this->flows->find(uuid: $id); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'No such flow'], Http::STATUS_NOT_FOUND); + } + + try { + $adopted = $this->flows->adopt(flow: $flow); + } catch (FlowAdoptionRefused $e) { + return new JSONResponse( + [ + 'error' => $e->getMessage(), + 'reason' => $e->getReason(), + ], + Http::STATUS_CONFLICT + ); + } + + return new JSONResponse($adopted->jsonSerialize()); + }//end adopt() + /** * The flow fields carried on a create or update request. * diff --git a/lib/Event/CaseItemTransitionedEvent.php b/lib/Event/CaseItemTransitionedEvent.php index 3c2f8313a7..5c167ad550 100644 --- a/lib/Event/CaseItemTransitionedEvent.php +++ b/lib/Event/CaseItemTransitionedEvent.php @@ -6,7 +6,7 @@ * Dispatched by the case layer AFTER the transaction that moved the item * closes, never inside it. For the three terminal states the event carries * a catalog trigger id (`case.item.completed`, `case.item.terminated`, - * `case.item.disabled`) so {@see \OCA\OpenRegister\Listener\EventCatalogListener} + * `case.item.disabled`) so {@see \OCA\OpenRegister\Listener\FlowTriggerListener} * can fire it against the anchoring object like any other catalog event: * a catalog entry that nothing dispatches would be the "declared but never * fired" trigger the catalog's own docblock forbids. @@ -95,7 +95,9 @@ public function getCatalogTrigger(): ?string { /** * The anchoring object as a trigger subject: the same shape - * `EventCatalogListener::dispatch()` builds from an ObjectEntity. + * `FlowTriggerListener` fires for an ObjectEntity. `register` and + * `schema` are the item's NUMERIC ids; the listener resolves them to + * the slugs the trigger index stores before firing. * * @return array{uuid: string, register: string, schema: string} The subject. * diff --git a/lib/Listener/EventCatalogListener.php b/lib/Listener/EventCatalogListener.php deleted file mode 100644 index 68fc804449..0000000000 --- a/lib/Listener/EventCatalogListener.php +++ /dev/null @@ -1,158 +0,0 @@ - - * SPDX-License-Identifier: EUPL-1.2 - * - * @category Listener - * @package OCA\OpenRegister\Listener - * - * @author Conduction Development Team - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @link https://OpenRegister.app - * - * @spec openspec/changes/visual-flow-builder/specs/flow-builder/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Listener; - -use OCA\OpenRegister\Db\ObjectEntity; -use OCA\OpenRegister\Event\CaseItemTransitionedEvent; -use OCA\OpenRegister\Event\ObjectCreatedEvent; -use OCA\OpenRegister\Event\ObjectDeletedEvent; -use OCA\OpenRegister\Event\ObjectLockedEvent; -use OCA\OpenRegister\Event\ObjectRevertedEvent; -use OCA\OpenRegister\Event\ObjectTransitionedEvent; -use OCA\OpenRegister\Event\ObjectUnlockedEvent; -use OCA\OpenRegister\Event\ObjectUpdatedEvent; -use OCA\OpenRegister\Service\Flow\FlowTriggerService; -use OCP\EventDispatcher\Event; -use OCP\EventDispatcher\IEventListener; - -/** - * Routes every catalog event to the flow trigger service. - * - * This is now the ONE path from a dispatched object event to a queued run. - * Create/update/delete used to be handled separately by the action-list - * engine's own listener, which meant two independent notions of "a flow fired" - * that could — and did — disagree about which flows were wired to an event. - * - * @template-implements IEventListener - */ -class EventCatalogListener implements IEventListener { - /** - * Constructor. - * - * @param FlowTriggerService $triggers Queues an engine run per wired flow. - */ - public function __construct( - private readonly FlowTriggerService $triggers, - ) { - }//end __construct() - - /** - * Map a catalog event to its trigger id and run the object's flows. - * - * @param Event $event The dispatched event. - * - * @return void - * - * @spec openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md#requirement-sentries-are-entry-and-exit-criteria-over-existing-engine-primitives - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) A dispatch table: one - * instanceof branch per catalog event, each a one-liner; splitting it - * would spread the catalog over several methods. - * @SuppressWarnings(PHPMD.NPathComplexity) Same cause. - */ - public function handle(Event $event): void { - // A plan item reaching a terminal state (flow-cmmn-case-semantics) fires - // its catalog trigger against the ANCHORING object, the same shape as - // every object event below; no separate subject type is introduced. - if ($event instanceof CaseItemTransitionedEvent) { - $trigger = $event->getCatalogTrigger(); - if ($trigger !== null) { - $this->triggers->fire(event: $trigger, subject: $event->getSubject()); - } - - return; - } - - if ($event instanceof ObjectCreatedEvent) { - $this->dispatch(object: $event->getObject(), trigger: 'object.created'); - return; - } - - if ($event instanceof ObjectUpdatedEvent) { - $this->dispatch(object: $event->getNewObject(), trigger: 'object.updated'); - return; - } - - if ($event instanceof ObjectDeletedEvent) { - $this->dispatch(object: $event->getObject(), trigger: 'object.deleted'); - return; - } - - if ($event instanceof ObjectLockedEvent) { - $this->dispatch(object: $event->getObject(), trigger: 'object.locked'); - return; - } - - if ($event instanceof ObjectUnlockedEvent) { - $this->dispatch(object: $event->getObject(), trigger: 'object.unlocked'); - return; - } - - if ($event instanceof ObjectRevertedEvent) { - $this->dispatch(object: $event->getObject(), trigger: 'object.reverted'); - return; - } - - if ($event instanceof ObjectTransitionedEvent) { - $this->dispatch(object: $event->getObject(), trigger: 'object.transitioned'); - return; - } - }//end handle() - - /** - * Run flows for an object, guarding against a null payload. - * - * @param ObjectEntity|null $object The object the event carried. - * @param string $trigger The catalog trigger id. - * - * @return void - */ - private function dispatch(?ObjectEntity $object, string $trigger): void { - if ($object === null) { - return; - } - - // Queue an engine run per wired flow, rather than executing an action - // list inline. A catalog event fires inside somebody's save, so the - // trigger records intent and returns; the worker does the walking. - $this->triggers->fire( - event: $trigger, - subject: [ - 'uuid' => $object->getUuid(), - 'register' => (string)$object->getRegister(), - 'schema' => (string)$object->getSchema(), - ] - ); - }//end dispatch() -}//end class diff --git a/lib/Listener/FlowTriggerListener.php b/lib/Listener/FlowTriggerListener.php index 9d9efb7470..b7763ce6f1 100644 --- a/lib/Listener/FlowTriggerListener.php +++ b/lib/Listener/FlowTriggerListener.php @@ -36,6 +36,7 @@ namespace OCA\OpenRegister\Listener; +use OCA\OpenRegister\Event\CaseItemTransitionedEvent; use OCA\OpenRegister\Event\ObjectCreatedEvent; use OCA\OpenRegister\Event\ObjectDeletedEvent; use OCA\OpenRegister\Event\ObjectLockedEvent; @@ -44,6 +45,7 @@ use OCA\OpenRegister\Event\ObjectUnlockedEvent; use OCA\OpenRegister\Event\ObjectUpdatedEvent; use OCA\OpenRegister\Service\Flow\FlowTriggerService; +use OCA\OpenRegister\Service\Flow\FlowTriggerSlugs; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\IUserSession; @@ -51,7 +53,7 @@ /** * Queues flow runs on every object-lifecycle event. * - * @template-implements IEventListener + * @template-implements IEventListener */ class FlowTriggerListener implements IEventListener { /** @@ -59,10 +61,12 @@ class FlowTriggerListener implements IEventListener { * * @param FlowTriggerService $triggers Queues the runs. * @param IUserSession $userSession The acting user, for attribution. + * @param FlowTriggerSlugs $slugs Turns the object's numeric ids into the slugs triggers match on. */ public function __construct( private readonly FlowTriggerService $triggers, private readonly IUserSession $userSession, + private readonly FlowTriggerSlugs $slugs, ) { }//end __construct() @@ -77,6 +81,17 @@ public function __construct( * @spec openspec/changes/or-flow-triggers/specs/flow-triggers/spec.md */ public function handle(Event $event): void { + // A plan item reaching a terminal state (flow-cmmn-case-semantics) + // fires its catalog trigger against the ANCHORING object — the same + // subject shape as every object event below, so it goes through the + // same seam: the item's numeric register/schema ids are resolved to + // the slugs the trigger index stores, and the acting user rides along. + // A non-terminal transition names no catalog trigger and fires nothing. + if ($event instanceof CaseItemTransitionedEvent) { + $this->fireCaseItemTrigger(event: $event); + return; + } + $eventId = $this->eventIdFor(event: $event); if ($eventId === null) { return; @@ -88,12 +103,20 @@ public function handle(Event $event): void { $user = $this->userSession->getUser()->getUID(); } + // 🔴 SLUGS, NOT THE OBJECT'S NUMERIC IDS. The trigger index and the + // flow trigger columns both hold slugs (`dossiq`/`case`) — an imported + // `x-openregister-flows` declaration cannot know an instance's row ids + // — while `$object->getRegister()` answers `16`. Firing the ids meant + // the comparison was `16 === 'dossiq'` on every event: three case + // creations on a clean instance queued NOTHING, with the flow enabled + // and owned, and nothing logged. Measured 2026-09-01 on dossiq + // 0.3.11-unstable / openregister 2.0.13-unstable. $this->triggers->fire( event: $eventId, subject: [ 'uuid' => (string)$object->getUuid(), - 'register' => (string)$object->getRegister(), - 'schema' => (string)$object->getSchema(), + 'register' => $this->slugs->registerSlug(identifier: (string)$object->getRegister()), + 'schema' => $this->slugs->schemaSlug(identifier: (string)$object->getSchema()), ], user: $user, context: $this->contextFor(event: $event) @@ -101,6 +124,44 @@ public function handle(Event $event): void { }//end handle() + /** + * Fire a terminal plan-item transition as its catalog trigger. + * + * Lived in `EventCatalogListener` for the days between the case layer + * landing and that listener's retirement as a duplicate trigger path. + * Moving here rather than surviving there is what the retirement MEANS: + * one listener decides what a fired subject looks like. This branch also + * gains the two things the duplicate path dropped — the id-to-slug + * resolution without which a case trigger never matches an imported + * flow's index rows, and the acting user on the run. + * + * @param CaseItemTransitionedEvent $event The transition, already persisted. + * + * @return void + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ + private function fireCaseItemTrigger(CaseItemTransitionedEvent $event): void { + $trigger = $event->getCatalogTrigger(); + if ($trigger === null) { + return; + } + + $subject = $event->getSubject(); + $user = $this->userSession->getUser()?->getUID(); + + $this->triggers->fire( + event: $trigger, + subject: [ + 'uuid' => (string)($subject['uuid'] ?? ''), + 'register' => $this->slugs->registerSlug(identifier: (string)($subject['register'] ?? '')), + 'schema' => $this->slugs->schemaSlug(identifier: (string)($subject['schema'] ?? '')), + ], + user: $user + ); + + }//end fireCaseItemTrigger() + /** * Extra run context an event carries beyond the object it is about. * diff --git a/lib/Repair/BackfillFlowTriggerIndex.php b/lib/Repair/BackfillFlowTriggerIndex.php index da4ec53d90..ded5b8c6dc 100644 --- a/lib/Repair/BackfillFlowTriggerIndex.php +++ b/lib/Repair/BackfillFlowTriggerIndex.php @@ -25,6 +25,16 @@ * Reporting matters more than converting here: a backfill that silently did * what it could would leave an operator believing the cutover was complete. * + * RE-RUNNING THIS STEP IS ALSO THE ROW-VOCABULARY REPAIR. The index used to + * store whatever a trigger node's config held — a numeric register/schema id + * from the builder, a slug from an imported declaration — while the fired + * subject arrived as ids, so only rows that happened to hold ids ever matched. + * `FlowTriggerIndex` now writes SLUGS canonically and the listener fires + * slugs, so this step's rebuild (post-migration, every upgrade) rewrites every + * existing row into the slug vocabulary: flows imported before the fix start + * firing without anyone re-saving them, and a row hand-inserted with ids is + * replaced by its reproducible slug form. + * * @category Repair * @package OCA\OpenRegister\Repair * diff --git a/lib/Service/Flow/EventCatalogService.php b/lib/Service/Flow/EventCatalogService.php index fa251e50aa..e1fbe0c3e4 100644 --- a/lib/Service/Flow/EventCatalogService.php +++ b/lib/Service/Flow/EventCatalogService.php @@ -6,8 +6,8 @@ * Single source of truth for the triggers a declarative flow (`x-openregister-flows`) * may subscribe to. The visual flow builder reads this catalog (via * `GET /api/flow/event-catalog`) to populate its trigger palette, and - * {@see \OCA\OpenRegister\Listener\EventCatalogListener} uses the same map to - * route dispatched events to {@see \OCA\OpenRegister\Service\Flow\FlowActionService}. + * {@see \OCA\OpenRegister\Listener\FlowTriggerListener} fires the same ids + * into {@see \OCA\OpenRegister\Service\Flow\FlowTriggerService}. * * Every entry is a real, dispatched event that resolves to an OpenRegister * object (so the flow's schema selects which flows run) — there are no diff --git a/lib/Service/Flow/FlowAdoptionRefused.php b/lib/Service/Flow/FlowAdoptionRefused.php new file mode 100644 index 0000000000..8f78b0f033 --- /dev/null +++ b/lib/Service/Flow/FlowAdoptionRefused.php @@ -0,0 +1,77 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use RuntimeException; + +/** + * A refusal carrying the reason a flow could not be adopted. + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ +class FlowAdoptionRefused extends RuntimeException { + /** + * There is no acting user to become the owner. + * + * @var string + */ + public const REASON_NO_ACTING_USER = 'no-acting-user'; + + /** + * The flow already has a different owner; adoption is not a takeover. + * + * @var string + */ + public const REASON_ALREADY_OWNED = 'already-owned'; + + /** + * Constructor. + * + * @param string $reason One of the REASON_* constants. + * @param string $message The human sentence. + */ + public function __construct( + private readonly string $reason, + string $message, + ) { + parent::__construct(message: $message); + }//end __construct() + + /** + * The machine-readable reason. + * + * @return string One of the REASON_* constants. + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + public function getReason(): string { + return $this->reason; + }//end getReason() +}//end class diff --git a/lib/Service/Flow/FlowService.php b/lib/Service/Flow/FlowService.php index 1185477f49..fbb3dbbdb7 100644 --- a/lib/Service/Flow/FlowService.php +++ b/lib/Service/Flow/FlowService.php @@ -208,6 +208,86 @@ public function find(string $uuid): Flow { return $flow; }//end find() + /** + * Make the CALLING user the flow's owner — the adoption seam. + * + * An imported flow deliberately arrives `enabled=false, owner=null` + * ({@see \OCA\OpenRegister\Listener\SchemaFlowImportListener}): a schema + * save is not a person volunteering to run a graph, and + * `Flow::canDispatch()` fails closed on the missing owner. That design is + * right — but until this method existed it had no counterpart: `owner` is + * not among `applyEditableFields()`'s allowlist (correctly, so a payload + * cannot hand a flow to an arbitrary uid), no occ command sets it, and the + * only route from "shipped" to "runnable" on a live instance was raw SQL. + * "Inert until somebody makes it theirs" needs a deliberate act to exist. + * + * The owner is ALWAYS the caller, never a uid from a request body — + * adoption is a volunteering, and volunteering somebody else defeats the + * reason ownership exists. A flow already owned by another user is refused + * rather than taken over: a silent takeover would re-point whose identity + * existing subscriptions run as, which is the exact move the import path + * refuses on upgrade. Adopting a flow one already owns is idempotent. + * + * Enabling stays SEPARATE, deliberately: publishing answers "which graph", + * adoption answers "whose identity", enabling answers "may it run" — and + * collapsing the last two would make viewing a shipped flow's checkbox a + * consent to run it. + * + * The adoption is audited out loud (who, which flow, when) at info level: + * an ownership change is the fact an operator reads back after a run did + * something surprising. + * + * @param Flow $flow The flow to adopt — already loaded through `find()`, + * so the organisation guard has been applied. + * + * @return Flow The stored flow, now owned by the caller. + * + * @throws FlowAdoptionRefused When there is no acting user, or the flow + * already belongs to someone else. + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + public function adopt(Flow $flow): Flow { + $uid = $this->actingUser(); + if ($uid === null) { + throw new FlowAdoptionRefused( + reason: FlowAdoptionRefused::REASON_NO_ACTING_USER, + message: 'Adopting a flow makes YOU its owner, so it needs a signed-in user.' + ); + } + + $current = trim((string)($flow->getOwner() ?? '')); + if ($current === $uid) { + return $flow; + } + + if ($current !== '') { + throw new FlowAdoptionRefused( + reason: FlowAdoptionRefused::REASON_ALREADY_OWNED, + message: 'This flow already belongs to "' . $current . '". Adoption is not a takeover.' + ); + } + + $flow->setOwner($uid); + $flow->setUpdated(new DateTime()); + $stored = $this->mapper->update($flow); + + $this->logger->info( + message: '[FlowService] Flow "' . (string)$stored->getUuid() . '" ("' . (string)$stored->getName() + . '", app "' . (string)$stored->getApp() . '") was ADOPTED by "' . $uid + . '": it now has an owner and will dispatch once enabled.', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'flow' => (string)$stored->getUuid(), + 'adoptedBy' => $uid, + 'previousOwner' => null, + ] + ); + + return $stored; + }//end adopt() + /** * Create or update a flow. * @@ -328,7 +408,7 @@ private function graphSignature(Flow $flow): string { * @return Flow The stored flow, as the mapper returned it. */ private function persistFlow(Flow $flow, ?string $uuid): Flow { - if ($uuid === null || $uuid === '') { + if (($uuid ?? '') === '') { return $this->mapper->insert($flow); } @@ -350,7 +430,7 @@ private function persistFlow(Flow $flow, ?string $uuid): Flow { * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md */ private function flowToSave(array $data, ?string $uuid): Flow { - if ($uuid !== null && $uuid !== '') { + if (($uuid ?? '') !== '') { // Goes through find(), so an update to a flow the caller cannot see // is refused with the same "no such flow" as a missing one. return $this->find(uuid: $uuid); @@ -658,12 +738,12 @@ public function callerOwnership(): array { * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md */ private function actingUser(): ?string { - $uid = $this->userSession->getUser()?->getUID(); - if ($uid === null || $uid === '') { + $uid = (string)($this->userSession->getUser()?->getUID() ?? ''); + if ($uid === '') { return null; } - return (string)$uid; + return $uid; }//end actingUser() /** @@ -690,7 +770,7 @@ private function activeOrganisation(): ?string { return null; } - if ($uuid === null || $uuid === '') { + if ((string)$uuid === '') { return null; } diff --git a/lib/Service/Flow/FlowTriggerIndex.php b/lib/Service/Flow/FlowTriggerIndex.php index 3f46a26016..01603aaf2f 100644 --- a/lib/Service/Flow/FlowTriggerIndex.php +++ b/lib/Service/Flow/FlowTriggerIndex.php @@ -58,12 +58,14 @@ class FlowTriggerIndex { * * @param FlowTriggerMapper $mapper The indexed trigger set. * @param FlowTriggerDerivation $derivation Nodes to triggers. + * @param FlowTriggerSlugs $slugs Normalises a derived triple to the slugs matching runs on. * @param LoggerInterface $logger Diagnostics. * @param FlowPublishedGraph|null $published Resolves the published graph. */ public function __construct( private readonly FlowTriggerMapper $mapper, private readonly FlowTriggerDerivation $derivation, + private readonly FlowTriggerSlugs $slugs, private readonly LoggerInterface $logger, private readonly ?FlowPublishedGraph $published = null, ) { @@ -105,7 +107,7 @@ public function reindex(Flow $flow): int { // trigger node has to actually unsubscribe it. return $this->mapper->replaceFor( flowUuid: $uuid, - triggers: $triggers, + triggers: $this->canonical(triggers: $triggers), enabled: ($flow->getEnabled() === true) ); } catch (Throwable $e) { @@ -120,6 +122,38 @@ public function reindex(Flow $flow): int { }//end reindex() + /** + * The derived triggers, each register and schema resolved to its SLUG. + * + * A trigger node's config holds whatever its authoring surface put there — + * an imported declaration writes slugs, the builder may write the numeric + * id its select handed it. The MATCHING surface speaks slugs (the column + * is `schema_slug`, and the fired subject is resolved to slugs the same + * way), so the rows are normalised HERE, at the only writer, rather than + * asking every matcher to try both vocabularies forever. Two nodes that + * name the same triple through different identifiers collapse to one row. + * + * @param array $triggers The derived triggers. + * + * @return array The slug-keyed triggers. + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ + private function canonical(array $triggers): array { + $rows = []; + foreach ($triggers as $trigger) { + $row = [ + 'event' => (string)$trigger['event'], + 'register' => $this->slugs->registerSlug(identifier: (string)$trigger['register']), + 'schema' => $this->slugs->schemaSlug(identifier: (string)$trigger['schema']), + ]; + + $rows[$row['event'] . '|' . $row['register'] . '|' . $row['schema']] = $row; + } + + return array_values($rows); + }//end canonical() + /** * The flow as its PUBLISHED version sees it. * diff --git a/lib/Service/Flow/FlowTriggerSlugs.php b/lib/Service/Flow/FlowTriggerSlugs.php new file mode 100644 index 0000000000..2cd9cb761e --- /dev/null +++ b/lib/Service/Flow/FlowTriggerSlugs.php @@ -0,0 +1,167 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\SchemaMapper; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The one place an id, uuid or slug becomes the slug triggers match on. + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ +class FlowTriggerSlugs { + + /** + * Constructor. + * + * The mappers memoise `find()` per request, so resolving the same + * register/schema for several triggers in one dispatch costs one read. + * + * @param RegisterMapper $registers Resolves a register by id, uuid or slug. + * @param SchemaMapper $schemas Resolves a schema by id, uuid or slug. + * @param LoggerInterface $logger Records identifiers that resolve to nothing. + */ + public function __construct( + private readonly RegisterMapper $registers, + private readonly SchemaMapper $schemas, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The slug of the register the given identifier names. + * + * Loads with RBAC and multitenancy OFF: this runs on the trigger path, + * inside the dispatch of whatever fired the event, where there may be no + * session — and a register's SLUG is its public name, not its data. + * + * @param string $identifier The register's id, uuid or slug. + * + * @return string The slug, or the trimmed identifier when it does not resolve. + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ + public function registerSlug(string $identifier): string { + $identifier = trim($identifier); + if ($identifier === '') { + return ''; + } + + try { + $slug = trim((string)$this->registers->find(id: $identifier, _rbac: false, _multitenancy: false)->getSlug()); + } catch (Throwable $e) { + $this->reportUnresolved(kind: 'register', identifier: $identifier, error: $e); + + return $identifier; + } + + if ($slug === '') { + return $identifier; + } + + return $slug; + }//end registerSlug() + + /** + * The slug of the schema the given identifier names. + * + * A bare schema slug is only unique within a register, but that is no + * hazard here: resolving a slug globally can only ever answer that same + * slug back, and ids and uuids are globally unique. Nothing is LOADED + * through the answer — it is a name, used for an equality comparison. + * + * @param string $identifier The schema's id, uuid or slug. + * + * @return string The slug, or the trimmed identifier when it does not resolve. + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ + public function schemaSlug(string $identifier): string { + $identifier = trim($identifier); + if ($identifier === '') { + return ''; + } + + try { + $slug = trim((string)$this->schemas->find(id: $identifier, _rbac: false, _multitenancy: false)->getSlug()); + } catch (Throwable $e) { + $this->reportUnresolved(kind: 'schema', identifier: $identifier, error: $e); + + return $identifier; + } + + if ($slug === '') { + return $identifier; + } + + return $slug; + }//end schemaSlug() + + /** + * Say out loud that an identifier resolved to nothing. + * + * Debug level, deliberately: an identifier that is ALREADY a slug of a + * since-deleted register still has to pass through unchanged, and warning + * on every event for it would drown the log without changing the outcome. + * + * @param string $kind Which catalogue was asked. + * @param string $identifier What was asked for. + * @param Throwable $error Why it did not resolve. + * + * @return void + */ + private function reportUnresolved(string $kind, string $identifier, Throwable $error): void { + $this->logger->debug( + message: '[FlowTriggerSlugs] The ' . $kind . ' identifier "' . $identifier + . '" did not resolve to a slug and is matched as-is: ' . $error->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + + }//end reportUnresolved() +}//end class diff --git a/openspec/changes/flow-adoption/proposal.md b/openspec/changes/flow-adoption/proposal.md new file mode 100644 index 0000000000..9b360a0812 --- /dev/null +++ b/openspec/changes/flow-adoption/proposal.md @@ -0,0 +1,41 @@ +# Adopting a shipped flow is a deliberate act with a mechanism + +## Why + +An imported `x-openregister-flows` flow deliberately arrives +`enabled=false, owner=null`, and `Flow::canDispatch()` fails closed on the +missing owner. That half is correct — a schema save is not a person +volunteering to run a graph as themselves. But "stored, visible and inert +until somebody makes it theirs" had no second half: `owner` is not among +`FlowService::applyEditableFields()`'s allowlist (correctly, so a payload +cannot hand a flow to an arbitrary uid), no occ command sets it, and +`dispatchableUuids()` drops the ownerless flow with only a log line. Measured +2026-09-01 on a clean instance: the only route from "shipped" to "runnable" +was raw SQL on the flows table. + +## What changes + +- `POST /api/flows/{id}/adopt` sets the flow's owner to the CALLING user. + The request body is ignored — an endpoint that accepted a uid would let any + caller with `flow.update` volunteer a colleague's identity for unattended + execution. +- The endpoint requires the `flow.update` right and reaches the flow through + `FlowService::find()`, so the organisation scope applies and a foreign flow + answers the same 404 as a missing one. +- A flow already owned by ANOTHER user is refused with a machine-readable + 409 (`already-owned`): adoption is not a takeover. Adopting a flow one + already owns is idempotent. +- Enabling stays a separate act. Adoption answers "whose identity", `enabled` + answers "may it run"; the flow dispatches only when both are true. +- The adoption is audited: an info-level log line naming the flow, the + adopter and the previous (null) owner. +- No occ command: the owner is the identity a run is attributed to, and occ + has no acting user to volunteer — the same reason the import listener + cannot set one. + +## Impact + +- Affected specs: flow-storage (ownership / dispatch) +- Affected code: `lib/Controller/FlowController.php`, + `lib/Service/Flow/FlowService.php`, + `lib/Service/Flow/FlowAdoptionRefused.php` (new), `appinfo/routes.php` diff --git a/openspec/changes/flow-adoption/specs/flow-storage/spec.md b/openspec/changes/flow-adoption/specs/flow-storage/spec.md new file mode 100644 index 0000000000..4f251e178c --- /dev/null +++ b/openspec/changes/flow-adoption/specs/flow-storage/spec.md @@ -0,0 +1,57 @@ +# flow-storage + +## ADDED Requirements + +### Requirement: Adopting a flow is a deliberate, caller-bound act + +The system SHALL provide an explicit adoption action +(`POST /api/flows/{id}/adopt`) that sets a flow's `owner` to the CALLING +user's uid. The new owner SHALL NEVER be taken from the request body or any +other caller-chosen value. The action SHALL require the `flow.update` right +and SHALL be organisation-scoped the way every per-flow action is: a flow of +another organisation answers the same not-found a missing flow does. + +A flow whose `owner` is already another user SHALL be refused with a +machine-readable reason rather than taken over — adoption re-points whose +identity existing subscriptions run as, which must never happen silently. +Adopting a flow one already owns SHALL succeed without a write. + +Adoption SHALL NOT change `enabled`. A shipped flow becomes dispatchable only +when it has been both adopted and enabled, as two separate acts. + +Every successful adoption SHALL be audited with the flow, the adopter and the +previous owner. + +#### Scenario: The caller becomes the owner + +- **GIVEN** an imported flow with `owner=null` +- **WHEN** an authorized user posts to its adopt action, whatever the body + carries +- **THEN** the stored flow's `owner` MUST be that user's uid, and the + adoption MUST be audited +- @e2e exclude API-layer seam — covered by `FlowControllerTest` (contract) + and `FlowAdoptionTest` (service) + +#### Scenario: Adoption is not a takeover + +- **GIVEN** a flow owned by another user +- **WHEN** a caller posts to its adopt action +- **THEN** the request MUST be refused with reason `already-owned` and the + owner MUST be unchanged +- @e2e exclude API-layer refusal — covered by `FlowAdoptionTest` + +#### Scenario: An adopted and enabled flow dispatches + +- **GIVEN** an imported flow that has been adopted and then enabled +- **WHEN** its trigger fires +- **THEN** the flow MUST be dispatchable — the ownerless refusal no longer + applies +- @e2e exclude engine-internal dispatch check — covered by `FlowAdoptionTest` + through `FlowLocator` + +#### Scenario: An anonymous or unauthorized caller is refused + +- **WHEN** a caller with no session, or without the `flow.update` right, + posts to a flow's adopt action +- **THEN** the request MUST be refused (401 / 403) and no owner written +- @e2e exclude auth posture — covered by `FlowControllerTest` diff --git a/openspec/changes/flow-adoption/tasks.md b/openspec/changes/flow-adoption/tasks.md new file mode 100644 index 0000000000..da2632bdc0 --- /dev/null +++ b/openspec/changes/flow-adoption/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +## 1. The seam +- [x] 1.1 `FlowService::adopt()`: caller becomes owner; idempotent for the owner; refuses a takeover; audited +- [x] 1.2 `POST /api/flows/{id}/adopt` behind `flow.update`, organisation-scoped through `find()` +- [x] 1.3 `FlowAdoptionRefused` with machine-readable reasons + +## 2. Tests +- [x] 2.1 Adopt sets the CALLER as owner, ignoring any uid in the body +- [x] 2.2 Anonymous and unauthorized callers are refused (401/403); a foreign flow is a 404 +- [x] 2.3 A flow owned by someone else answers 409 `already-owned` +- [x] 2.4 An adopted and enabled flow becomes dispatchable diff --git a/openspec/changes/flow-trigger-canonical-slugs/proposal.md b/openspec/changes/flow-trigger-canonical-slugs/proposal.md new file mode 100644 index 0000000000..94302e4898 --- /dev/null +++ b/openspec/changes/flow-trigger-canonical-slugs/proposal.md @@ -0,0 +1,53 @@ +# Trigger matching speaks slugs on both sides + +## Why + +An object event fires with the object's NUMERIC register and schema ids +(`16`/`26`), while the trigger index and the flow trigger columns hold what +the flow's authoring surface wrote — for an imported `x-openregister-flows` +declaration, SLUGS (`dossiq`/`case`), because an app shipping a flow cannot +know an instance's row ids. `FlowTriggerMapper::flowUuidsFor()` and +`FlowMapper::findByTrigger()` compare the two literally, so `16` never equals +`dossiq` and the trigger never fires. + +Measured 2026-09-01 on a clean instance (dossiq 0.3.11-unstable, openregister +2.0.13-unstable): three cases created with the imported flow enabled and owned +queued NOTHING and logged nothing. Hand-inserting an index row keyed `16/26` +made the next creation queue a run — proving the match, not the engine, was +the broken seam. The shipped `Bezwaar advies` flow has the same defect. + +## What changes + +The slug is canonical on the matching surface — the index column is literally +`schema_slug`, and every matcher documents its parameters as slugs — so both +sides are resolved to slugs at their single seams: + +- `FlowTriggerSlugs` is the one resolver: id, uuid or slug in, slug out, + pass-through (never dropped) when the identifier resolves to nothing. +- `FlowTriggerListener` resolves the fired object's ids through it, so the + subject a run is queued with carries slugs. +- `FlowTriggerIndex` normalises every derived trigger through it before + writing, so a builder-authored node holding numeric ids still produces slug + rows, and two nodes naming one triple through different identifiers collapse + to one row. +- The registered `BackfillFlowTriggerIndex` repair step (post-migration, every + upgrade) rebuilds the index through the same writer, which rewrites existing + id-keyed rows into the slug vocabulary — already-imported flows start firing + without being re-saved. +- `EventCatalogListener` is retired. It was registered for the SAME seven + object events as `FlowTriggerListener`, each handler calling + `FlowTriggerService::fire()` — a double-fire that was invisible only while + both spoke the wrong vocabulary and matched nothing. Fixing the vocabulary + with both registered would queue every matched flow twice per event. + `FlowTriggerListener` is the superset (user attribution, transition + context), so it is the one path kept. + +## Impact + +- Affected specs: flow-engine (trigger matching) +- Affected code: `lib/Listener/FlowTriggerListener.php`, + `lib/Service/Flow/FlowTriggerSlugs.php` (new), + `lib/Service/Flow/FlowTriggerIndex.php`, + `lib/Repair/BackfillFlowTriggerIndex.php` (docs only; behaviour follows the + writer it already calls), `lib/Listener/EventCatalogListener.php` (removed), + `lib/AppInfo/Application.php` (seven duplicate registrations removed) diff --git a/openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md b/openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md new file mode 100644 index 0000000000..257982b889 --- /dev/null +++ b/openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md @@ -0,0 +1,59 @@ +# flow-engine + +## ADDED Requirements + +### Requirement: Trigger matching speaks slugs on both sides + +The `(event, register, schema)` triple is matched as SLUGS. The trigger index +SHALL store the register slug and schema slug for every derived row, whatever +identifier the trigger node's config holds — an imported declaration's slug +and a builder select's numeric id MUST produce the same row. The fired +subject's register and schema SHALL be resolved to slugs before matching, so +an object event carrying numeric ids matches a trigger declared with slugs +and vice versa. + +An identifier that resolves to no register or schema SHALL pass through +unchanged rather than being dropped or blanked: an unresolvable identifier +that silently became an empty value would unsubscribe the flow, which is the +silence this rule exists to end. + +The resolution SHALL live in exactly one implementation, used by the listener +and by the index writer alike — a second answer to "what does this triple look +like" would diverge on exactly the flows nobody looks at. + +#### Scenario: An imported flow fires on an object event + +- **GIVEN** a flow imported from `x-openregister-flows` whose trigger node + names its register and schema by slug +- **WHEN** an object of that register and schema is created, the event + carrying the object's numeric ids +- **THEN** the flow MUST match and a run MUST be queued +- @e2e exclude engine-internal matching seam — covered by + `FlowTriggerIdSlugMatchTest` end to end and `FlowTriggerListenerTest` at the + listener + +#### Scenario: A trigger node holding numeric ids indexes as slugs + +- **GIVEN** a flow whose published trigger node config holds a numeric + register id and schema id +- **WHEN** its trigger rows are derived +- **THEN** the written rows MUST hold the register and schema SLUGS +- @e2e exclude engine-internal index write — covered by + `FlowTriggerIndexSlugNormalisationTest` + +#### Scenario: The registered repair rewrites pre-fix rows + +- **GIVEN** an instance whose trigger index still holds rows in the id + vocabulary, written before this rule +- **WHEN** the `BackfillFlowTriggerIndex` repair step runs on upgrade +- **THEN** every flow's rows MUST be rebuilt through the normalising writer, + so already-imported flows start firing without being re-saved +- @e2e exclude an upgrade-time repair — covered by + `FlowTriggerIndexSlugNormalisationTest` through the same rebuild path + +#### Scenario: An unresolvable identifier is matched as-is + +- **WHEN** a subject or trigger names a register that no longer resolves +- **THEN** the value MUST pass through unchanged rather than becoming empty +- @e2e exclude engine-internal resolver behaviour — covered by + `FlowTriggerSlugsTest` diff --git a/openspec/changes/flow-trigger-canonical-slugs/tasks.md b/openspec/changes/flow-trigger-canonical-slugs/tasks.md new file mode 100644 index 0000000000..95a8b893d0 --- /dev/null +++ b/openspec/changes/flow-trigger-canonical-slugs/tasks.md @@ -0,0 +1,14 @@ +# Tasks + +## 1. Canonical slug seam +- [x] 1.1 Add `FlowTriggerSlugs`: id/uuid/slug in, slug out, pass-through on no resolution +- [x] 1.2 Resolve the fired subject's register/schema to slugs in `FlowTriggerListener` +- [x] 1.3 Normalise derived triggers to slugs in `FlowTriggerIndex` before `replaceFor()` + +## 2. Repair +- [x] 2.1 Confirm `BackfillFlowTriggerIndex` (registered post-migration) rewrites existing rows through the normalising writer + +## 3. Tests +- [x] 3.1 Listener-level: an object arriving with ids matches a trigger declared with slugs +- [x] 3.2 Write-side: a trigger node holding ids indexes as slug rows (and the repair rebuild proves the rewrite) +- [x] 3.3 `FlowTriggerSlugs` unit: id resolves, slug is idempotent, unresolvable passes through diff --git a/tests/Unit/Controller/FlowControllerTest.php b/tests/Unit/Controller/FlowControllerTest.php index aa4b40f601..c86e16e961 100644 --- a/tests/Unit/Controller/FlowControllerTest.php +++ b/tests/Unit/Controller/FlowControllerTest.php @@ -43,6 +43,18 @@ /** * @covers \OCA\OpenRegister\Controller\FlowController + * + * The uses-list below is load-bearing, not decoration. phpunit.xml sets + * `beStrictAboutCoverageMetadata`, so a covers-tagged test that executes an + * unlisted class is RISKY and PHPUnit discards its coverage entirely — this + * class's whole coverage was being thrown away over the real FlowAccess it + * deliberately constructs, and the CI coverage guard read that as the + * controller being untested. + * + * @uses \OCA\OpenRegister\Service\Flow\FlowAccess + * @uses \OCA\OpenRegister\Db\Flow + * @uses \OCA\OpenRegister\Db\FlowState + * @uses \OCA\OpenRegister\Service\Flow\FlowAdoptionRefused */ class FlowControllerTest extends TestCase { @@ -81,6 +93,16 @@ class FlowControllerTest extends TestCase { */ private $flows; + /** + * The action-rights matrix, mocked to allow by default. + * + * Declared rather than assigned dynamically: PHP 8.2 deprecates dynamic + * properties, and this one was created on the fly in setUp(). + * + * @var \OCA\OpenRegister\Service\OpenRegisterActionAuthService + */ + private $actionAuth; + /** * The mocked user session, used to resolve the caller. * @@ -867,4 +889,134 @@ public function testLogActionsReturnsAnEmptyListForAnAbsentEntry(): void { $this->assertSame(200, $response->getStatus()); $this->assertSame([], $response->getData()['results']); }//end testLogActionsReturnsAnEmptyListForAnAbsentEntry() + + /** + * Adoption makes the CALLER the owner — the body is never consulted. + * + * The service receives the flow the organisation-scoped `find()` loaded + * and nothing else: there is no parameter a request could smuggle a + * different uid through, which is the security property of the seam. + * + * @return void + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + public function testAdoptSetsTheCallerAsOwner(): void { + $flow = new Flow(); + $flow->setUuid('flow-1'); + + $adopted = new Flow(); + $adopted->setUuid('flow-1'); + $adopted->setOwner('admin'); + + $this->flows->expects($this->once())->method('find')->with('flow-1')->willReturn($flow); + $this->flows->expects($this->once())->method('adopt')->with($flow)->willReturn($adopted); + // The body must be irrelevant: the controller never reads it. + $this->request->expects($this->never())->method('getParams'); + + $response = $this->controller->adopt('flow-1'); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame('admin', $response->getData()['owner']); + }//end testAdoptSetsTheCallerAsOwner() + + /** + * Without the `flow.update` right the adoption never reaches the service. + * + * @return void + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + public function testAdoptIsRefusedWithoutTheRight(): void { + $denying = $this->createMock(\OCA\OpenRegister\Service\OpenRegisterActionAuthService::class); + $denying->method('can')->willReturn(false); + + $controller = new FlowController( + 'openregister', + $this->request, + $this->createMock(EventCatalogService::class), + $this->nodes, + $this->createMock(originalClassName: \OCA\OpenRegister\Db\FlowStateMapper::class), + $this->preflight, + $this->flows, + $this->access($this->userSession, $this->groupManager, $denying), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) + ); + + $this->flows->expects($this->never())->method('adopt'); + + $this->assertSame(403, $controller->adopt('flow-1')->getStatus()); + }//end testAdoptIsRefusedWithoutTheRight() + + /** + * No session, no adoption: there is nobody to become the owner. + * + * @return void + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + public function testAdoptRequiresASession(): void { + $anonymous = $this->createMock(IUserSession::class); + $anonymous->method('getUser')->willReturn(null); + + $controller = new FlowController( + 'openregister', + $this->request, + $this->createMock(EventCatalogService::class), + $this->nodes, + $this->createMock(originalClassName: \OCA\OpenRegister\Db\FlowStateMapper::class), + $this->preflight, + $this->flows, + $this->access($anonymous, $this->groupManager, $this->actionAuth), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) + ); + + $this->flows->expects($this->never())->method('adopt'); + + $this->assertSame(401, $controller->adopt('flow-1')->getStatus()); + }//end testAdoptRequiresASession() + + /** + * A flow the caller may not see answers the same 404 a missing one does — + * the adopt route must not become an oracle for other tenants' flow ids. + * + * @return void + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + public function testAdoptAnswersNotFoundForAFlowTheCallerMayNotSee(): void { + $this->flows->method('find')->willThrowException( + new \OCP\AppFramework\Db\DoesNotExistException('No such flow') + ); + $this->flows->expects($this->never())->method('adopt'); + + $this->assertSame(404, $this->controller->adopt('foreign-flow')->getStatus()); + }//end testAdoptAnswersNotFoundForAFlowTheCallerMayNotSee() + + /** + * Adoption is not a takeover: a flow that already belongs to someone else + * answers a machine-readable 409. + * + * @return void + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + public function testAdoptAnswersConflictWhenAlreadyOwned(): void { + $flow = new Flow(); + $flow->setUuid('flow-1'); + $flow->setOwner('bob'); + + $this->flows->method('find')->willReturn($flow); + $this->flows->method('adopt')->willThrowException( + new \OCA\OpenRegister\Service\Flow\FlowAdoptionRefused( + reason: \OCA\OpenRegister\Service\Flow\FlowAdoptionRefused::REASON_ALREADY_OWNED, + message: 'This flow already belongs to "bob". Adoption is not a takeover.' + ) + ); + + $response = $this->controller->adopt('flow-1'); + + $this->assertSame(409, $response->getStatus()); + $this->assertSame('already-owned', $response->getData()['reason']); + }//end testAdoptAnswersConflictWhenAlreadyOwned() }//end class diff --git a/tests/Unit/Listener/FlowTriggerListenerTest.php b/tests/Unit/Listener/FlowTriggerListenerTest.php index db12e43368..89e4f9d97e 100644 --- a/tests/Unit/Listener/FlowTriggerListenerTest.php +++ b/tests/Unit/Listener/FlowTriggerListenerTest.php @@ -9,18 +9,46 @@ namespace Unit\Listener; +use OCA\OpenRegister\Db\CaseItem; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Event\CaseItemTransitionedEvent; use OCA\OpenRegister\Event\ObjectCreatedEvent; use OCA\OpenRegister\Event\ObjectLockedEvent; use OCA\OpenRegister\Event\ObjectRevertedEvent; use OCA\OpenRegister\Event\ObjectTransitionedEvent; use OCA\OpenRegister\Event\ObjectUnlockedEvent; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Listener\FlowTriggerListener; use OCA\OpenRegister\Service\Flow\FlowTriggerService; +use OCA\OpenRegister\Service\Flow\FlowTriggerSlugs; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\Event; use OCP\IUserSession; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +/** + * @covers \OCA\OpenRegister\Listener\FlowTriggerListener + * @covers \OCA\OpenRegister\Service\Flow\FlowTriggerSlugs + * + * Every collaborator the tests execute is named in the uses-list because + * `beStrictAboutCoverageMetadata` is on: an unlisted executed class marks the + * test risky and PHPUnit then discards its coverage wholesale. + * + * @uses \OCA\OpenRegister\Db\CaseItem + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Db\Register + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Event\CaseItemTransitionedEvent + * @uses \OCA\OpenRegister\Event\ObjectCreatedEvent + * @uses \OCA\OpenRegister\Event\ObjectLockedEvent + * @uses \OCA\OpenRegister\Event\ObjectUnlockedEvent + * @uses \OCA\OpenRegister\Event\ObjectRevertedEvent + * @uses \OCA\OpenRegister\Event\ObjectTransitionedEvent + */ class FlowTriggerListenerTest extends TestCase { private FlowTriggerService $triggers; @@ -31,7 +59,47 @@ protected function setUp(): void { $session = $this->createMock(IUserSession::class); $session->method('getUser')->willReturn(null); - $this->listener = new FlowTriggerListener($this->triggers, $session); + $this->listener = new FlowTriggerListener($this->triggers, $session, $this->slugs()); + } + + /** + * A real FlowTriggerSlugs over mapper doubles. + * + * The mappers know exactly one register (`16` => `dossiq`) and one schema + * (`26` => `case`); anything else does not resolve, which per the resolver's + * contract passes the identifier through unchanged — so the pre-existing + * tests that fire on `reg`/`sch` keep asserting exactly what they did. + */ + private function slugs(): FlowTriggerSlugs { + $registers = $this->createMock(RegisterMapper::class); + $registers->method('find')->willReturnCallback( + static function (string|int $id): Register { + if ((string)$id !== '16') { + throw new DoesNotExistException('no such register'); + } + + $register = new Register(); + $register->setSlug('dossiq'); + + return $register; + } + ); + + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturnCallback( + static function (string|int $id): Schema { + if ((string)$id !== '26') { + throw new DoesNotExistException('no such schema'); + } + + $schema = new Schema(); + $schema->setSlug('case'); + + return $schema; + } + ); + + return new FlowTriggerSlugs($registers, $schemas, new NullLogger()); } private function object(): ObjectEntity { @@ -93,4 +161,100 @@ public function testCreateStillCarriesNoExtraContext(): void { $this->listener->handle(new ObjectCreatedEvent($this->object())); } + + /** + * A CaseItem's terminal transition (flow-cmmn-case-semantics) is fired + * through the SAME seam as every object event: the item's numeric + * register/schema ids arrive at the trigger service as the slugs the + * index stores. This branch lived in the retired EventCatalogListener, + * which fired the raw ids — the id/slug defect on the case path. + */ + public function testATerminalCaseItemFiresItsCatalogTriggerWithSlugs(): void { + $this->triggers->expects($this->once())->method('fire') + ->with( + 'case.item.completed', + $this->callback( + static fn (array $s): bool => ($s['register'] ?? null) === 'dossiq' + && ($s['schema'] ?? null) === 'case' + && ($s['uuid'] ?? null) === 'case-obj-1' + ), + null + ) + ->willReturn(1); + + $item = new CaseItem(); + $item->setObjectUuid('case-obj-1'); + $item->setRegisterId(16); + $item->setSchemaId(26); + $item->setState(CaseItem::STATE_COMPLETED); + + $this->listener->handle(new CaseItemTransitionedEvent($item, 'active')); + } + + /** + * A non-terminal transition names no catalog trigger and fires nothing — + * an available or active item is progress, not an event a flow starts on. + */ + public function testANonTerminalCaseItemTransitionFiresNothing(): void { + $this->triggers->expects($this->never())->method('fire'); + + $item = new CaseItem(); + $item->setObjectUuid('case-obj-1'); + $item->setRegisterId(16); + $item->setSchemaId(26); + $item->setState(CaseItem::STATE_ACTIVE); + + $this->listener->handle(new CaseItemTransitionedEvent($item, 'available')); + } + + /** + * 🔴 THE DEFECT: an object event carries NUMERIC register/schema ids, and + * the trigger index holds SLUGS — an imported declaration cannot know an + * instance's row ids. The subject must therefore fire with the slugs, so a + * trigger declared as `dossiq`/`case` matches an object saved as `16`/`26`. + * Before the fix this fired `16`/`26` literally, and three case creations + * on a clean instance queued nothing. + */ + public function testNumericIdsFireAsTheirSlugs(): void { + $this->triggers->expects($this->once())->method('fire') + ->with( + 'object.created', + $this->callback( + static fn (array $s): bool => ($s['register'] ?? null) === 'dossiq' + && ($s['schema'] ?? null) === 'case' + && ($s['uuid'] ?? null) === 'obj-1' + ), + null, + [] + ) + ->willReturn(1); + + $object = new ObjectEntity(); + $object->setUuid('obj-1'); + $object->setRegister('16'); + $object->setSchema('26'); + + $this->listener->handle(new ObjectCreatedEvent($object)); + } + + /** + * An object already carrying slugs fires them unchanged: resolution is + * idempotent, and an identifier that resolves to nothing passes through + * rather than being blanked — a blank would silently unsubscribe the flow. + */ + public function testSlugsAndUnresolvablesPassThroughUnchanged(): void { + $this->triggers->expects($this->once())->method('fire') + ->with( + 'object.created', + $this->callback( + static fn (array $s): bool => ($s['register'] ?? null) === 'reg' + && ($s['schema'] ?? null) === 'sch' + ), + null, + [] + ) + ->willReturn(1); + + $this->listener->handle(new ObjectCreatedEvent($this->object())); + } } diff --git a/tests/Unit/Service/Case/CasePlanServiceTest.php b/tests/Unit/Service/Case/CasePlanServiceTest.php index 985ee705f7..0c0f1c39a9 100644 --- a/tests/Unit/Service/Case/CasePlanServiceTest.php +++ b/tests/Unit/Service/Case/CasePlanServiceTest.php @@ -396,8 +396,13 @@ public function testTransitionsOnDifferentItemsDoNotClobberEachOther(): void { $doneB = $service->transition(itemUuid: (string)$visitB->getUuid(), to: CaseItem::STATE_COMPLETED, uid: 'alice', reason: 'done b'); $this->assertSame(CaseItem::STATE_COMPLETED, $doneA->getState()); $this->assertSame(CaseItem::STATE_COMPLETED, $doneB->getState()); - $this->assertSame('active->completed (user)', end($this->audits->trail((int)$visitA->getId()))); - $this->assertSame('active->completed (user)', end($this->audits->trail((int)$visitB->getId()))); + // Into variables first: end() takes its argument by reference, and + // handing it a function's return value raises "Only variables should + // be passed by reference" on every run of this test. + $trailA = $this->audits->trail((int)$visitA->getId()); + $trailB = $this->audits->trail((int)$visitB->getId()); + $this->assertSame('active->completed (user)', end($trailA)); + $this->assertSame('active->completed (user)', end($trailB)); $this->assertSame('done b', $this->audits->findForItem((int)$visitB->getId())[2]->getReason()); try { diff --git a/tests/Unit/Service/Flow/EventCatalogServiceTest.php b/tests/Unit/Service/Flow/EventCatalogServiceTest.php new file mode 100644 index 0000000000..d1d9b3aab8 --- /dev/null +++ b/tests/Unit/Service/Flow/EventCatalogServiceTest.php @@ -0,0 +1,89 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ + +declare(strict_types=1); + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- arrange/act/assert PHPUnit conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit assertion helpers use positional args. + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\Flow\EventCatalogService + */ +class EventCatalogServiceTest extends TestCase { + + public function testTheCatalogCarriesEveryObjectEventTheListenerFires(): void { + $ids = array_column((new EventCatalogService())->getCatalog(), 'id'); + + foreach ([ + 'object.created', + 'object.updated', + 'object.deleted', + 'object.locked', + 'object.unlocked', + 'object.reverted', + 'object.transitioned', + ] as $fired) { + $this->assertContains($fired, $ids, 'the palette must offer every trigger the listener fires'); + } + } + + public function testKnownTriggerIdsIncludeTheLegacyAliases(): void { + $known = (new EventCatalogService())->knownTriggerIds(); + + foreach (['object.created', 'created', 'object.updated', 'updated', 'object.deleted', 'deleted'] as $id) { + $this->assertContains($id, $known, 'a legacy alias that stops being known orphans pre-catalog flows'); + } + } + + public function testAliasesForBridgesCanonicalAndLegacyBothWays(): void { + $catalog = new EventCatalogService(); + + $this->assertSame(['object.created', 'created'], $catalog->aliasesFor(dispatched: 'object.created')); + $this->assertSame(['object.created', 'created'], $catalog->aliasesFor(dispatched: 'created')); + } + + public function testAnUnknownTriggerMatchesOnlyItself(): void { + $this->assertSame( + ['myapp.custom'], + (new EventCatalogService())->aliasesFor(dispatched: 'myapp.custom'), + 'an unknown id must round-trip rather than vanish or widen' + ); + } + + public function testAnEventWithoutALegacyAliasAnswersJustItsCanonicalId(): void { + $this->assertSame( + ['object.locked'], + (new EventCatalogService())->aliasesFor(dispatched: 'object.locked'), + 'no phantom alias may be invented for a post-catalog event' + ); + } +} diff --git a/tests/Unit/Service/Flow/FlowAdoptionTest.php b/tests/Unit/Service/Flow/FlowAdoptionTest.php new file mode 100644 index 0000000000..ebcd22303a --- /dev/null +++ b/tests/Unit/Service/Flow/FlowAdoptionTest.php @@ -0,0 +1,212 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-adoption/specs/flow-storage/spec.md + */ + +declare(strict_types=1); + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- arrange/act/assert PHPUnit conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit assertion helpers use positional args. + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowMapper; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowRunStepMapper; +use OCA\OpenRegister\Db\FlowStateMapper; +use OCA\OpenRegister\Db\FlowTriggerMapper; +use OCA\OpenRegister\Service\Flow\FlowAdoptionRefused; +use OCA\OpenRegister\Service\Flow\FlowLocator; +use OCA\OpenRegister\Service\Flow\FlowRunAdvancer; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\FlowService; +use OCA\OpenRegister\Service\Flow\FlowTriggerIndex; +use OCA\OpenRegister\Service\ObjectService; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\Flow\FlowService + * @covers \OCA\OpenRegister\Service\Flow\FlowAdoptionRefused + * + * @uses \OCA\OpenRegister\Db\Flow + * @uses \OCA\OpenRegister\Service\Flow\FlowLocator + */ +class FlowAdoptionTest extends TestCase { + + /** + * The flows update() persisted. + * + * @var array + */ + private array $updated = []; + + /** + * A FlowService whose session answers $uid (or nobody when null). + */ + private function service(?string $uid): FlowService { + $mapper = $this->createMock(FlowMapper::class); + $mapper->method('update')->willReturnCallback( + function (Flow $flow): Flow { + $this->updated[] = $flow; + + return $flow; + } + ); + + $session = $this->createMock(IUserSession::class); + if ($uid === null) { + $session->method('getUser')->willReturn(null); + } + + if ($uid !== null) { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $session->method('getUser')->willReturn($user); + } + + return new FlowService( + $mapper, + $this->createMock(FlowTriggerIndex::class), + $this->createMock(FlowRunService::class), + $this->createMock(FlowRunAdvancer::class), + $this->createMock(FlowRunMapper::class), + $this->createMock(FlowRunStepMapper::class), + $this->createMock(FlowStateMapper::class), + $session, + $this->createMock(LoggerInterface::class), + $this->createMock(ContainerInterface::class) + ); + } + + /** + * A freshly imported flow: enabled=false, owner=null. + */ + private function importedFlow(): Flow { + $flow = new Flow(); + $flow->setUuid('flow-imported'); + $flow->setName('Bezwaar advies'); + $flow->setApp('dossiq'); + $flow->setEnabled(false); + $flow->setOwner(null); + + return $flow; + } + + public function testAdoptSetsTheCallerAsOwner(): void { + $adopted = $this->service('alice')->adopt(flow: $this->importedFlow()); + + $this->assertSame('alice', $adopted->getOwner(), 'the caller, and nobody else, becomes the owner'); + $this->assertCount(1, $this->updated, 'the adoption must be persisted'); + $this->assertFalse((bool)$adopted->getEnabled(), 'adoption must NOT enable: whose flow and may-it-run are separate acts'); + } + + public function testAdoptionWithNoSessionIsRefused(): void { + $service = $this->service(null); + + try { + $service->adopt(flow: $this->importedFlow()); + $this->fail('an ownerless session must not produce an owner'); + } catch (FlowAdoptionRefused $e) { + $this->assertSame(FlowAdoptionRefused::REASON_NO_ACTING_USER, $e->getReason()); + } + + $this->assertCount(0, $this->updated, 'nothing may be written on a refusal'); + } + + public function testAdoptionIsNotATakeover(): void { + $flow = $this->importedFlow(); + $flow->setOwner('bob'); + + try { + $this->service('alice')->adopt(flow: $flow); + $this->fail('a flow that belongs to bob must not silently become alice\'s'); + } catch (FlowAdoptionRefused $e) { + $this->assertSame(FlowAdoptionRefused::REASON_ALREADY_OWNED, $e->getReason()); + } + + $this->assertSame('bob', $flow->getOwner(), 'the owner must be untouched by the refused attempt'); + $this->assertCount(0, $this->updated); + } + + public function testAdoptingOnesOwnFlowIsIdempotent(): void { + $flow = $this->importedFlow(); + $flow->setOwner('alice'); + + $adopted = $this->service('alice')->adopt(flow: $flow); + + $this->assertSame('alice', $adopted->getOwner()); + $this->assertCount(0, $this->updated, 're-adopting what one owns is a no-op, not a rewrite'); + } + + /** + * 🔑 THE POINT OF THE SEAM: an adopted AND enabled flow dispatches. + * + * Walked through the real `FlowLocator`, whose `dispatchableUuids()` is + * the exact gate that dropped the ownerless imported flow with only a log + * line. The same flow is offered twice: as imported (dropped) and as + * adopted+enabled (dispatched) — so this fails if either half of the + * lifecycle stops reaching `canDispatch()`. + */ + public function testAnAdoptedAndEnabledFlowBecomesDispatchable(): void { + $imported = $this->importedFlow(); + + $adopted = $this->service('alice')->adopt(flow: $this->importedFlow()); + $adopted->setEnabled(true); + + $this->assertFalse($imported->canDispatch(), 'the shipped state must stay fail-closed'); + $this->assertTrue($adopted->canDispatch(), 'adopted + enabled is the runnable state'); + + foreach ([['flow' => $imported, 'expected' => []], ['flow' => $adopted, 'expected' => ['flow-imported']]] as $case) { + $triggerMapper = $this->createMock(FlowTriggerMapper::class); + $triggerMapper->method('flowUuidsFor')->willReturn(['flow-imported']); + $triggerMapper->method('representedFlowUuids')->willReturn(['flow-imported']); + + $flowMapper = $this->createMock(FlowMapper::class); + $flowMapper->method('findByTrigger')->willReturn([]); + $flowMapper->method('findByUuid')->willReturn($case['flow']); + + $locator = new FlowLocator( + $flowMapper, + $triggerMapper, + $this->createMock(ObjectService::class), + new NullLogger() + ); + + $this->assertSame( + $case['expected'], + $locator->flowsForTrigger(event: 'object.created', register: 'dossiq', schema: 'case'), + 'the ownerless flow is refused at dispatch; the adopted and enabled one runs' + ); + } + } +} diff --git a/tests/Unit/Service/Flow/FlowTriggerIdSlugMatchTest.php b/tests/Unit/Service/Flow/FlowTriggerIdSlugMatchTest.php new file mode 100644 index 0000000000..0eb31ea63f --- /dev/null +++ b/tests/Unit/Service/Flow/FlowTriggerIdSlugMatchTest.php @@ -0,0 +1,208 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ + +declare(strict_types=1); + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- arrange/act/assert PHPUnit conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit assertion helpers use positional args. + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowMapper; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowTriggerMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Event\ObjectCreatedEvent; +use OCA\OpenRegister\Listener\FlowTriggerListener; +use OCA\OpenRegister\Service\Flow\FlowLocator; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\FlowTriggerService; +use OCA\OpenRegister\Service\Flow\FlowTriggerSlugs; +use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Listener\FlowTriggerListener + * @covers \OCA\OpenRegister\Service\Flow\FlowTriggerService + * @covers \OCA\OpenRegister\Service\Flow\FlowLocator + * @covers \OCA\OpenRegister\Service\Flow\FlowTriggerSlugs + * + * @uses \OCA\OpenRegister\Db\Flow + * @uses \OCA\OpenRegister\Db\FlowRun + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Db\Register + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Event\ObjectCreatedEvent + */ +class FlowTriggerIdSlugMatchTest extends TestCase { + + /** + * Every queue() call's flowId. + * + * @var array + */ + private array $queued = []; + + /** + * What flowUuidsFor() was asked for. + * + * @var array + */ + private array $asked = []; + + /** + * The real listener → trigger service → locator chain over doubles. + * + * The trigger index double holds ONE row, keyed the way an imported + * declaration writes it: `object.created` on `dossiq`/`case`. It honours + * its arguments — asking for `16`/`26` answers nothing, exactly like the + * real indexed lookup — so the pre-fix listener turns this red. + */ + private function listener(): FlowTriggerListener { + $triggerMapper = $this->createMock(FlowTriggerMapper::class); + $triggerMapper->method('flowUuidsFor')->willReturnCallback( + function (string $event, string $register, string $schema): array { + $this->asked[] = ['event' => $event, 'register' => $register, 'schema' => $schema]; + if ($event === 'object.created' && $register === 'dossiq' && $schema === 'case') { + return ['flow-imported']; + } + + return []; + } + ); + $triggerMapper->method('representedFlowUuids')->willReturn(['flow-imported']); + + $flow = new Flow(); + $flow->setUuid('flow-imported'); + $flow->setEnabled(true); + $flow->setOwner('alice'); + $flow->setExecutionMode('async'); + $flow->setNodes([]); + $flow->setEdges([]); + + $flowMapper = $this->createMock(FlowMapper::class); + $flowMapper->method('findByTrigger')->willReturn([]); + $flowMapper->method('findByUuid')->willReturn($flow); + + $locator = new FlowLocator( + $flowMapper, + $triggerMapper, + $this->createMock(ObjectService::class), + new NullLogger() + ); + + $runner = $this->createMock(FlowRunService::class); + $runner->method('queue')->willReturnCallback( + function (string $flowId): FlowRun { + $this->queued[] = $flowId; + $run = new FlowRun(); + $run->setUuid('run-1'); + + return $run; + } + ); + + $registers = $this->createMock(RegisterMapper::class); + $registers->method('find')->willReturnCallback( + static function (string|int $id): Register { + if ((string)$id !== '16') { + throw new DoesNotExistException('no such register'); + } + + $register = new Register(); + $register->setSlug('dossiq'); + + return $register; + } + ); + + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturnCallback( + static function (string|int $id): Schema { + if ((string)$id !== '26') { + throw new DoesNotExistException('no such schema'); + } + + $schema = new Schema(); + $schema->setSlug('case'); + + return $schema; + } + ); + + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn(null); + + return new FlowTriggerListener( + new FlowTriggerService($locator, $runner, new NullLogger()), + $session, + new FlowTriggerSlugs($registers, $schemas, new NullLogger()) + ); + } + + public function testAnObjectSavedByIdQueuesARunForASlugDeclaredFlow(): void { + $object = new ObjectEntity(); + $object->setUuid('case-1'); + $object->setRegister('16'); + $object->setSchema('26'); + + $this->listener()->handle(new ObjectCreatedEvent($object)); + + $this->assertSame( + ['flow-imported'], + $this->queued, + 'three case creations on the live instance queued nothing; an id-keyed event must reach a slug-declared flow' + ); + $this->assertSame( + [['event' => 'object.created', 'register' => 'dossiq', 'schema' => 'case']], + $this->asked, + 'the indexed lookup must be asked in the slug vocabulary the index stores' + ); + } + + public function testAnObjectAlreadyCarryingSlugsStillMatches(): void { + $object = new ObjectEntity(); + $object->setUuid('case-2'); + $object->setRegister('dossiq'); + $object->setSchema('case'); + + $this->listener()->handle(new ObjectCreatedEvent($object)); + + $this->assertSame(['flow-imported'], $this->queued, 'slug-keyed events must keep matching: resolution is idempotent'); + } +} diff --git a/tests/Unit/Service/Flow/FlowTriggerIndexPublishedTest.php b/tests/Unit/Service/Flow/FlowTriggerIndexPublishedTest.php index 7d584acbf3..227f4f4453 100644 --- a/tests/Unit/Service/Flow/FlowTriggerIndexPublishedTest.php +++ b/tests/Unit/Service/Flow/FlowTriggerIndexPublishedTest.php @@ -24,6 +24,9 @@ use OCA\OpenRegister\Service\Flow\FlowPublishedGraph; use OCA\OpenRegister\Service\Flow\FlowTriggerDerivation; use OCA\OpenRegister\Service\Flow\FlowTriggerIndex; +use OCA\OpenRegister\Service\Flow\FlowTriggerSlugs; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\SchemaMapper; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -53,9 +56,19 @@ function (string $flowUuid, array $triggers, bool $enabled): int { $published = $this->createMock(FlowPublishedGraph::class); $published->method('graphOf')->willReturn($publishedGraph); + // The slug resolver's mappers resolve nothing here, so every identifier + // passes through unchanged — these tests are about WHICH version's + // nodes reach the index, not about the vocabulary they arrive in. + $slugs = new FlowTriggerSlugs( + $this->createMock(RegisterMapper::class), + $this->createMock(SchemaMapper::class), + new NullLogger() + ); + return new FlowTriggerIndex( $mapper, new FlowTriggerDerivation(), + $slugs, new NullLogger(), $published ); diff --git a/tests/Unit/Service/Flow/FlowTriggerIndexSlugNormalisationTest.php b/tests/Unit/Service/Flow/FlowTriggerIndexSlugNormalisationTest.php new file mode 100644 index 0000000000..2c9644f0ea --- /dev/null +++ b/tests/Unit/Service/Flow/FlowTriggerIndexSlugNormalisationTest.php @@ -0,0 +1,202 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ + +declare(strict_types=1); + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- arrange/act/assert PHPUnit conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit assertion helpers use positional args. + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowTriggerMapper; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Flow\FlowPublishedGraph; +use OCA\OpenRegister\Service\Flow\FlowTriggerDerivation; +use OCA\OpenRegister\Service\Flow\FlowTriggerIndex; +use OCA\OpenRegister\Service\Flow\FlowTriggerSlugs; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\Flow\FlowTriggerIndex + * @covers \OCA\OpenRegister\Service\Flow\FlowTriggerSlugs + * + * @uses \OCA\OpenRegister\Db\Flow + * @uses \OCA\OpenRegister\Db\Register + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Service\Flow\FlowTriggerDerivation + */ +class FlowTriggerIndexSlugNormalisationTest extends TestCase { + + /** + * Every replaceFor() call, in order: `{flow, triggers, enabled}`. + * + * @var array> + */ + private array $written = []; + + /** + * An index over a resolver that knows `16`=>`dossiq` and `26`=>`case`, + * whose published graph answers $nodes for every flow. + */ + private function index(array $nodes): FlowTriggerIndex { + $mapper = $this->createMock(FlowTriggerMapper::class); + $mapper->method('replaceFor')->willReturnCallback( + function (string $flowUuid, array $triggers, bool $enabled): int { + $this->written[] = ['flow' => $flowUuid, 'triggers' => $triggers, 'enabled' => $enabled]; + + return count($triggers); + } + ); + + $registers = $this->createMock(RegisterMapper::class); + $registers->method('find')->willReturnCallback( + static function (string|int $id): Register { + if (in_array((string)$id, ['16', 'dossiq'], true) === false) { + throw new DoesNotExistException('no such register'); + } + + $register = new Register(); + $register->setSlug('dossiq'); + + return $register; + } + ); + + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturnCallback( + static function (string|int $id): Schema { + if (in_array((string)$id, ['26', 'case'], true) === false) { + throw new DoesNotExistException('no such schema'); + } + + $schema = new Schema(); + $schema->setSlug('case'); + + return $schema; + } + ); + + $published = $this->createMock(FlowPublishedGraph::class); + $published->method('graphOf')->willReturn(['nodes' => $nodes, 'edges' => []]); + + return new FlowTriggerIndex( + $mapper, + new FlowTriggerDerivation(), + new FlowTriggerSlugs($registers, $schemas, new NullLogger()), + new NullLogger(), + $published + ); + } + + /** + * One object trigger node whose config holds the given identifiers. + */ + private function triggerNode(string $register, string $schema): array { + return [ + 'id' => 'trigger-' . $register . '-' . $schema, + 'type' => 'openregister.trigger-object', + 'config' => ['event' => 'object.created', 'register' => $register, 'schema' => $schema], + ]; + } + + /** + * A stored, enabled flow. + */ + private function flow(string $uuid): Flow { + $flow = new Flow(); + $flow->setUuid($uuid); + $flow->setEnabled(true); + $flow->setNodes([]); + $flow->setEdges([]); + + return $flow; + } + + public function testATriggerNodeHoldingNumericIdsIndexesAsSlugs(): void { + $this->index([$this->triggerNode('16', '26')])->reindex(flow: $this->flow('flow-1')); + + $this->assertSame( + [['event' => 'object.created', 'register' => 'dossiq', 'schema' => 'case']], + $this->written[0]['triggers'], + 'a builder-authored node holding row ids must produce the same slug row an imported declaration does' + ); + } + + public function testTwoNodesNamingOneTripleThroughDifferentIdentifiersCollapse(): void { + $this->index( + [$this->triggerNode('16', '26'), $this->triggerNode('dossiq', 'case')] + )->reindex(flow: $this->flow('flow-1')); + + $this->assertCount( + 1, + $this->written[0]['triggers'], + '`16/26` and `dossiq/case` are ONE subscription; two rows would queue two runs per event' + ); + } + + public function testAnUnresolvableIdentifierIsWrittenAsIsNotBlanked(): void { + $this->index([$this->triggerNode('99', 'gone')])->reindex(flow: $this->flow('flow-1')); + + $this->assertSame( + [['event' => 'object.created', 'register' => '99', 'schema' => 'gone']], + $this->written[0]['triggers'], + 'blanking an unresolvable identifier would silently unsubscribe the flow' + ); + } + + /** + * 🔑 THE REPAIR PATH. `BackfillFlowTriggerIndex` (registered post-migration + * in appinfo/info.xml) calls exactly this `rebuild()`, which re-derives + * every flow's rows through the normalising writer — so an instance whose + * index still holds id-keyed rows from before the fix gets them rewritten + * as slugs on upgrade, and already-imported flows start firing without + * anyone re-saving them. + */ + public function testRebuildRewritesEveryFlowIntoTheSlugVocabulary(): void { + $index = $this->index([$this->triggerNode('16', '26')]); + + $report = $index->rebuild(flows: [$this->flow('flow-1'), $this->flow('flow-2')]); + + $this->assertSame(2, $report['indexed']); + $this->assertSame(2, $report['rows']); + foreach ($this->written as $write) { + $this->assertSame( + [['event' => 'object.created', 'register' => 'dossiq', 'schema' => 'case']], + $write['triggers'], + 'the rebuild must replace pre-fix rows with their slug form for every flow it touches' + ); + } + } +} diff --git a/tests/Unit/Service/Flow/FlowTriggerSlugsTest.php b/tests/Unit/Service/Flow/FlowTriggerSlugsTest.php new file mode 100644 index 0000000000..5be5c17090 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowTriggerSlugsTest.php @@ -0,0 +1,137 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-trigger-canonical-slugs/specs/flow-engine/spec.md + */ + +declare(strict_types=1); + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- arrange/act/assert PHPUnit conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit assertion helpers use positional args. + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Flow\FlowTriggerSlugs; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\Flow\FlowTriggerSlugs + * + * @uses \OCA\OpenRegister\Db\Register + * @uses \OCA\OpenRegister\Db\Schema + */ +class FlowTriggerSlugsTest extends TestCase { + + /** + * A resolver whose mappers know `16`/`dossiq` (register) and `26`/`case` + * (schema) — by id or by slug, the way the real mappers resolve either. + */ + private function resolver(): FlowTriggerSlugs { + $registers = $this->createMock(RegisterMapper::class); + $registers->method('find')->willReturnCallback( + static function (string|int $id): Register { + if (in_array((string)$id, ['16', 'dossiq'], true) === false) { + throw new DoesNotExistException('no such register'); + } + + $register = new Register(); + $register->setSlug('dossiq'); + + return $register; + } + ); + + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturnCallback( + static function (string|int $id): Schema { + if (in_array((string)$id, ['26', 'case'], true) === false) { + throw new DoesNotExistException('no such schema'); + } + + $schema = new Schema(); + $schema->setSlug('case'); + + return $schema; + } + ); + + return new FlowTriggerSlugs($registers, $schemas, new NullLogger()); + } + + public function testANumericIdResolvesToItsSlug(): void { + $slugs = $this->resolver(); + + $this->assertSame('dossiq', $slugs->registerSlug(identifier: '16')); + $this->assertSame('case', $slugs->schemaSlug(identifier: '26')); + } + + public function testASlugIsIdempotent(): void { + $slugs = $this->resolver(); + + $this->assertSame('dossiq', $slugs->registerSlug(identifier: 'dossiq')); + $this->assertSame('case', $slugs->schemaSlug(identifier: 'case')); + } + + public function testAnUnresolvableIdentifierPassesThroughUnchanged(): void { + $slugs = $this->resolver(); + + $this->assertSame('99', $slugs->registerSlug(identifier: '99')); + $this->assertSame('gone-register', $slugs->registerSlug(identifier: ' gone-register ')); + $this->assertSame('77', $slugs->schemaSlug(identifier: '77')); + } + + public function testAnEmptyIdentifierStaysEmptyWithoutALookup(): void { + $registers = $this->createMock(RegisterMapper::class); + $registers->expects($this->never())->method('find'); + $schemas = $this->createMock(SchemaMapper::class); + $schemas->expects($this->never())->method('find'); + + $slugs = new FlowTriggerSlugs($registers, $schemas, new NullLogger()); + + $this->assertSame('', $slugs->registerSlug(identifier: ' ')); + $this->assertSame('', $slugs->schemaSlug(identifier: '')); + } + + /** + * A register row with an EMPTY slug must not blank the identifier either: + * blank is the one value that can never match a declared trigger, so the + * caller's identifier is the better answer on every path. + */ + public function testAnEmptyStoredSlugFallsBackToTheIdentifier(): void { + $registers = $this->createMock(RegisterMapper::class); + $registers->method('find')->willReturn(new Register()); + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturn(new Schema()); + + $slugs = new FlowTriggerSlugs($registers, $schemas, new NullLogger()); + + $this->assertSame('16', $slugs->registerSlug(identifier: '16')); + $this->assertSame('26', $slugs->schemaSlug(identifier: '26')); + } +} From 7a12cf6ecd3db8995611f852d8b88531761ee8cb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 19:26:35 +0200 Subject: [PATCH 36/68] feat(flow): durable business timers, one clock for SLA, opschorting and escalation (#3272) * feat(tasks): the task store - four tables, entities and mappers The flow-task-entity storage layer (openspec/changes/flow-task-entity): openregister_tasks with the resolved 23-shape union (no overdue column, no app-named column, performer_type as an open string vocabulary so the coming external type is an append, definition_version shipped now), openregister_task_candidates as the pooled-inbox index half of the candidate pool, openregister_task_relations as the typed anchor table, and openregister_task_audit with an append-only mapper whose update and delete refuse. claim() is a conditional update so the database decides the race, and the inbox page and total share one predicate builder. Ticks tasks 1.1 and 1.2. * feat(tasks): the vocabularies, the authorization and the routing Normalisation at the boundary: TaskState publishes the one legacy-status mapping onto the six CMMN states (collapsed distinctions survive on outcome, an unmapped value is refused naming itself, and a migration may declare its source vocabulary so procest's status 'open' fails loudly); TaskPriority lands all four fleet scales on low|normal|high|urgent and refuses pipelinq's 'normaal'; TaskTemporalProjection is the ONE overdue derivation, computed and never stored. TaskAuthorizationService decides every verb fail-closed BEFORE mutation: no group backend, an unresolvable role or an unknown performer type each DENY rather than skip, and no nullable service-unavailable return exists. TaskPerformerResolver carries the five routing strategies; a strategy that finds nobody assigns nobody. Ticks tasks 2.1, 2.2, 3.1 and 3.2. * feat(tasks): the lifecycle service, cancellation propagation and the inbox TaskService: create/offer/claim/unclaim/assign/reassign/delegate/ resolve/complete/cancel, each authorized fail-closed before any mutation, each audited in the SAME transaction (an audit-write failure unwinds the completion), denials audited too. One write path maintains the candidate JSON and the candidate index rows together; state and is_terminal move in one statement; claim is the mapper's conditional update so the race's loser gets a conflict; a rejecting outcome without a comment is refused before anything moves; the template snapshot is frozen at creation. Cancellation propagation (design D-8): FlowRunMapper::update() - the one choke point every terminal run write passes - dispatches FlowRunTerminalEvent, and TaskRunTerminalListener terminates that run's open tasks with the reason recorded. Idempotent, and a task with run_uuid null is structurally out of reach. TaskInboxService answers assigned/pooled/watched/by-object in the datastore over one shared predicate set, with the total from the same predicates, subject context batched per page, a display title synthesized on read for titleless tasks (never persisted), and the derived temporal projection attached to rows only. Ticks tasks 4.1-4.5, 5.1, 6.1 and 6.2. * feat(tasks): the REST surface and the seed fixtures TaskController + /api/flow-tasks routes for the inbox, the detail and audit reads (both visibility-checked) and every lifecycle verb. The family is named for the flow-tasks CAPABILITY, not a flow requirement - /api/tasks itself already belongs to the CalDAV VTODO leaf. Every method declares its auth posture attribute AND delegates its actual authorization to TaskAuthorizationService inside the service; refusals translate uniformly (400 validation, 403 denial, 409 conflict naming the current state, 404 absence). SeedTaskFixtures installs the five seed groups from design.md (pooled municipal check with no run, delegated approval with enforcing expiry on a run, agent task with a typed checklist, one completed 'approved' and one terminated by propagation, plus audit fixtures including one DENIED entry), idempotent on uuid - and is REGISTERED in appinfo/info.xml in this same commit, because an unregistered repair step is a known fleet defect. Ticks tasks 7.1 and 8.1. * test(tasks): the guarantees, pinned - 93 tests over the task layer Table-driven legacy-status and priority tests including the live fleet defects: procest's status 'open' against a source vocabulary that does not define it is refused naming the value, and pipelinq's 'normaal' is refused on every scale. Authorization: a stranger who knows the uuid is denied on every verb, an unresolvable role denies naming the role, an absent group backend denies, an unknown performer type denies, watchers read and never act. Concurrency and transactionality: the two-claim race yields one assignee and one conflict, a rejection without a comment moves nothing, and an injected audit-write failure rolls the completion back. Derivation: overdue flips with an injected clock while the stored row stays byte-identical. Inbox: the page and the total run over the SAME criteria object (25 of 120 reported honestly), a titleless task presents a synthesized display title while its stored title stays null, and a runless task completes identically to a runful one. Mutation-checked: disabling assertMay fails 7 tests; removing the transaction rollback fails 2; both restored byte-identical (sha256-verified). Ticks tasks 9.1, 9.2 and 9.3. * test(tasks): playwright coverage for the two e2e-marked scenarios 'a stranger is refused on the task detail route': a provisioned second account gets 403 on the detail read AND on complete, and the task provably did not move - the positive control for the hole where knowing a uuid was the whole check. 'the inbox route returns tasks with subject context': one request lists the assignee's work with a display title, the subject slot, the derived overdue flag and a datastore total, and when the dev seed offers a live object the anchored subject context rides along. Both specs clean up after themselves and skip loudly when the instance cannot provision a stranger. Ticks task 9.4. * style(tasks): named parameters on every internal call, no inline ifs The repo's PHPCS standard requires named arguments on calls into OCA code and forbids ternaries. Exception constructors now name message:, the transactional/stringOrNull/arrayOrNull/respondWith calls name their parameter, the nine isset-ternaries collapse into one intOrNull helper, and the two remaining conditionals become explicit if blocks. No behaviour change: the 93 task tests are unchanged and green. * refactor(tasks): the quality pass - intake extracted, complexity earned down TaskBuilder now owns intake: boundary data in, a validated unsaved Task out, with every published vocabulary applied there once. That returns TaskService to lifecycle only and under the complexity budget. The authorization switch becomes a verb-to-rule table with static dispatch; the inbox predicate builder splits into scope, visibility and filters; the migration's indexes and the seed fixtures get methods of their own; sort and direction travel as one parameter (-dueAt) so the inbox route keeps a single-digit parameter list. The few remaining suppressions each carry their justification inline. Behaviour unchanged: the 93 task tests pass untouched except for injecting the builder. * fix(tasks): the PHPStan pass - typed mapper overrides and a DBAL constant The mapper overrides now guard their entity type with a throwing instanceof check, which is what lets the generic QBMapper contract type-check (and turns a wrong entity into a named error instead of a silent write). candidateMembershipPredicate declares the IQueryFunction it returns. The migration uses Types::DATETIME_MUTABLE, the constant the current DBAL stubs actually define. * style(tasks): import InvalidArgumentException in the three mapper guards PHPMD MissingImport on the type guards added in the PHPStan pass. No behaviour change. * fix(tasks): the review pass - offer is the requester's, and the inbox works on PostgreSQL Two blocking defects from the independent review of #3258: - offer bypassed per-task authorization and rewrote the assignee. Any authenticated user could POST offer {"routingFallback": "mallory"} on an assigned active task, become its assignee, and then complete it. offer now needs the requester (or an administrator), refuses a task that already has an assignee (unclaim or reassign instead), and is in the stranger-denied test's verb list, whose omission is how the suite stayed green. - The watchers LIKE ran against a json column, which PostgreSQL refuses (operator does not exist: json ~~ unknown): every non-admin inbox request 500ed there. The column is now cast per platform (AS TEXT on PostgreSQL, AS CHAR elsewhere), every identifier in the mapper's raw SQL goes through the platform's own quoter (no backticks), and TaskMapperTest pins the cast on a PostgreSQL platform mock. The should-fix items: authorization runs before the terminality check and a task the caller may not see answers 404 on every route, verbs included (no existence or state oracle); over HTTP the requester is pinned to the actor and a terminal creation state is refused, with import() as the trusted path for migrations and the user-task node; every state-changing write is a conditional update (WHERE is_terminal = false) so a second completion conflicts instead of overwriting; delegate refuses an empty delegate and keeps naming the original performer on re-delegation; an unexpected failure is a generic 500 whose detail goes to the log; propagation continues past a failing task; role pools are matched by the inbox EXISTS; the seed fixtures run only when app config openregister/seed_demo_tasks is true; the CSRF posture is recorded in the controller docblock; the e2e stranger scenario fails loudly instead of skipping and now covers the non-admin inbox and a watcher. Class-level @spec tags on every new class. TaskControllerTest is the contract test for all fourteen /api/flow-tasks endpoints (gate-25 PASS). Checks on this tree: PHPCS 0, PHPMD 0 (touched files), PHPStan 0, Psalm 0, 120 task tests green. * style(tasks): format the task inbox e2e spec with the Nextcloud prettier config CI's Frontend Check (format) named tests/e2e/task-inbox.spec.ts. Formatted with @nextcloud/prettier-config's options (tabs, no semicolons, single quotes, trailing commas, print width 85, operators leading). * test(tasks): cover the entities, the listener, the seed step and the builder CI's pgsql cell did not fail in PHPUnit (17860 tests, 0 failures, and app:enable succeeded); it failed the coverage guard: the changed files measured 3.85% (69/1791 statements) against a 7.32% base, because the suite runs with strict coverage metadata and collaborators exercised through TaskServiceTest were never credited. This adds the tests that were genuinely missing: the four entities' typing, hydration and serialisation (no overdue anywhere in the stored row); the terminal-run listener and event; the seed step off by default, seeding the five groups when on, idempotent on uuid, and continuing past a failing fixture; and TaskBuilder credited where TaskServiceTest exercises it. 133 task tests green. * feat(flow-timers): the durable timer store, its fire ledger and its history Three additive tables: openregister_flow_timers holds a budget and a suspension ledger rather than a target instant (design D-2), with fire_at and next_rung_at as indexed derivations for the two range scans (D-8) and no overdue column of any kind; openregister_flow_timer_fires is unique on (timer_uuid, rung_key) so the INSERT is the rung claim (D-7); and openregister_flow_timer_events is append-only evidence with no update or delete path. The fire and event mappers refuse update() and delete(). TaskMapper::update() now announces terminality through TaskTerminalEvent from the one choke point every terminal task write passes, inside the verb's transaction, so timers can be cancelled in the same operation (D-9). Tasks 1.1 and 1.2. * test(tasks): walk the mapper queries and credit every exercised class The pgsql cell's coverage guard credits only statements in classes a test names with @covers (strict coverage metadata), and the changed files measured 20.16% against a 23.46% base. Two moves: every task test class now names each class it actually exercises (TaskBuilder, TaskState and TaskPriority behind TaskServiceTest, TaskInboxCriteria behind the inbox and controller tests, the entities and exceptions everywhere they are built), and the largest untested code is now walked: TaskMapper's whole query vocabulary, the candidate, relation and audit mappers, and FlowRunMapper's terminality announcement, over a fluent query-builder double that records which predicate and column each query sets, so a conditional update without its openness guard or a pooled scope without its EXISTS would fail here. Plus the untested TaskService verbs (unclaim, assign, reassign, cancel, resolve, terminateAsMoot, relations, intake refusals), TaskInboxService's batched subject context, and the controller's filter, flag and failing-backend paths. 168 task tests. * wip: checkpoint before rate-limit pause (2026-09-01 09:45) * style(tasks): a top-level type import and sorted specifiers in the inbox e2e spec Vue Quality (eslint) on #3258 named four errors in tests/e2e/task-inbox.spec.ts: perfectionist/sort-named-imports (the specifiers now read apiRequest, expect, test) and import-extensions/ban-inline-type-imports (APIRequestContext is a top-level import type). eslint --fix plus a plain run: 0 errors; the sibling flow-engine.spec.ts as control also 0; prettier clean. * feat(flow-timers): the business clock, its ladder, its sweep and the tests that can fail One working calendar (computed rules, Easter computus, Koningsdag observed shift; an enumerated-only calendar is refused because it expires), one calculator (hours, businessDays, calendarDays as DATES so a term lands at the same wall-clock time across DST), and one lifecycle: arm with a stored anchor, suspend into a consumed-value ledger, resume by re-projection, extend once (the override is a separate operation recorded as such), supersede on a moved anchor with copy-forward of the rungs still in the past, cancel on subject terminality inside the write that made it terminal. The ladder is seeded data (14/7/2/0). A rung is claimed by the unique INSERT before its transition is raised; an expiry by a conditional UPDATE before its outcome is applied as a named task action (skip, error, dead_letter, transition: leave four distinct task states). The sweep is two index range scans on the existing 300s cadence; counts report work performed and a hit limit is logged as truncated. Overdue is never written: describe() derives it, and a suspended timer has no fire_at to be overdue by. Tests run against in-memory fakes with real semantics (range scans filter, claims are conditional, the ledger is unique), including the restart, the overlapping pass, the downtime gap and the fire_at identity after every operation. Tasks 1.3, 2.1, 2.2, 3.1-3.4, 4.1, 4.2, 5.1-5.3, 6.1, 6.2, 7.1, 7.2. * fix(flow-timers): title every property of the seeded calendar and ladder schemas Gate-51 (schema-property-titles) wants a human-friendly title beside each description; the twelve properties of working-calendar and escalation-ladder now carry one. * fix(flow-timers): resolve the checkpoint's conflict markers against the squashed task entity The host restart interrupted the merge of development (now carrying #3258 and #3259) and the checkpoint committed four files with their markers in place. Every block was an add/add where development's side is the pre-hunk text of the squashed task entity and this branch's side is the timer hunk: TaskMapper's terminality announcement on both write paths, TaskService's applyTimerOutcome(), the two listener registrations and the two repair steps. The timer side is kept; nothing of development's is dropped. * feat(flow-timers): demo data for the calendar and ladder schemas, generated from tightened schemas Gate-101 (ADR-111 rule 1) wants three schema-valid demo objects per schema in the type: mock descriptor. The generator produced rules of {} against the loose schema, so the schemas now type their items: a rule has a kind (fixed|easter|observedShift), month, day, offset and an optional observed shift; an exception is {date, name}; a rung has trigger, offset, offsetUnit, roles, priority, message and openIncident, each with its enum or bounds. The regenerated flow-timers objects are spliced into openregister_mock_register.json and every one of them is accepted by WorkingCalendar::fromArray() and EscalationLadderService::normaliseRules(). The seeded example-organisation exceptions already used {date, name}; the schema now says so. * fix(flow-timers): the CI findings - gate-51 descriptions, schema l10n, demo-data regeneration and the walk-bound edges Every nested rule and rung property now carries a description beside its title (gate-51). All 52 schema strings of working-calendar and escalation-ladder get catalogue keys, English as identity and Dutch translated, and the schema-l10n baseline ratchets down to 601. The rules items drop month/day/offset from required (a fixed rule has no Easter offset; the seeds now validate against their own schema) and the demo objects are regenerated from the tightened schema. An edge-case suite covers the walk bound, every named malformed-calendar refusal, the ladder shape refusals, tie-breaking on equal rung instants and the definition store's degradation paths, and the store now says so quietly when the descriptor file itself is absent. * test(flow-timers): list every executed class in each suite's coverage metadata Under beStrictAboutCoverageMetadata a test that executes a class outside its covers list is RISKY and its whole recorded coverage is discarded: CI measured FlowTimerService at 0 of 616 statements while the suite that exercises it passed. Each timer suite now lists every collaborator it executes (entities, events, the calculators, the fakes' parent mappers), the same discipline the task suites already follow, so the coverage that was always being exercised is credited. * test(flow-timers): the real mapper queries and the service's fallback branches The service suites run against fakes, so the mappers' actual query-builder methods never executed: the two range scans now prove their full predicate set (state, purpose, the moment bound, ORDER BY and LIMIT), the subject, run, successor and paged reads prove their filters, the terminal claim its conditional row count, and both ledgers their stamped inserts and reads, all on the fluent query-builder harness. The service gains its string and refused anchor dates, superseding a cancelled timer, an absent task subject (logged, not fatal, the timer still fires), and a suspended timer raising no rungs however late it is. * fix(flow-timers): ungroup the duplicated suppression and credit the last risky suites The development merge brought TaskService its own TooManyMethods suppression, so this branch's copy goes (it also split the docblock tag group, which was the one phpcs error). Three test methods were still discarded as risky for executing one unlisted class each: the supersession and rung suites execute FlowTimerFire, the ledger suite constructs a FlowTimer, the mapper-guard suite a FlowRun. Listed, their coverage counts again. * l10n(flow-timers): the 52 new schema strings translated into every enforced locale Parity holds every locale to every English key, and the 27 reviewed locales refuse an unjustified English-identical value, so the calendar and ladder strings are translated into all 26 remaining enforced locales (nl carried them already). Three deliberate cognates are recorded instead of faked: Date and Message in French and Message in Luxembourgish are the locale's own words. rm keeps its reviewed identical Trigger. The nine legacy locales get the English value, which parity tolerates for them. --------- Co-authored-by: Conduction Release Bot --- appinfo/info.xml | 11 + l10n/.schema-l10n-baseline.json | 2 +- l10n/be.js | 54 +- l10n/be.json | 54 +- l10n/bg.js | 54 +- l10n/bg.json | 54 +- l10n/bs.js | 54 +- l10n/bs.json | 54 +- l10n/ca.js | 54 +- l10n/ca.json | 54 +- l10n/cs.js | 54 +- l10n/cs.json | 54 +- l10n/da.js | 54 +- l10n/da.json | 54 +- l10n/de.js | 54 +- l10n/de.json | 54 +- l10n/el.js | 54 +- l10n/el.json | 54 +- l10n/en.js | 54 +- l10n/en.json | 54 +- l10n/es.js | 54 +- l10n/es.json | 54 +- l10n/et.js | 54 +- l10n/et.json | 54 +- l10n/fi.js | 54 +- l10n/fi.json | 54 +- l10n/fr.js | 54 +- l10n/fr.json | 54 +- l10n/ga.js | 54 +- l10n/ga.json | 54 +- l10n/hr.js | 54 +- l10n/hr.json | 54 +- l10n/hu.js | 54 +- l10n/hu.json | 54 +- l10n/is.js | 54 +- l10n/is.json | 54 +- l10n/it.js | 54 +- l10n/it.json | 54 +- l10n/lb.js | 54 +- l10n/lb.json | 54 +- l10n/lt.js | 54 +- l10n/lt.json | 54 +- l10n/lv.js | 54 +- l10n/lv.json | 54 +- l10n/mk.js | 54 +- l10n/mk.json | 54 +- l10n/mt.js | 54 +- l10n/mt.json | 54 +- l10n/nb.js | 54 +- l10n/nb.json | 54 +- l10n/nl.js | 54 +- l10n/nl.json | 54 +- l10n/pl.js | 54 +- l10n/pl.json | 54 +- l10n/pt.js | 54 +- l10n/pt.json | 54 +- l10n/rm.js | 54 +- l10n/rm.json | 54 +- l10n/ro.js | 54 +- l10n/ro.json | 54 +- l10n/ru.js | 54 +- l10n/ru.json | 54 +- l10n/sk.js | 54 +- l10n/sk.json | 54 +- l10n/sl.js | 54 +- l10n/sl.json | 54 +- l10n/sq.js | 54 +- l10n/sq.json | 54 +- l10n/sr.js | 54 +- l10n/sr.json | 54 +- l10n/sv.js | 54 +- l10n/sv.json | 54 +- l10n/tr.js | 54 +- l10n/tr.json | 54 +- l10n/uk.js | 54 +- l10n/uk.json | 54 +- lib/AppInfo/Application.php | 16 + lib/BackgroundJob/FlowTimerWorker.php | 138 ++ lib/Db/FlowTimer.php | 619 +++++++ lib/Db/FlowTimerEvent.php | 193 +++ lib/Db/FlowTimerEventMapper.php | 123 ++ lib/Db/FlowTimerFire.php | 152 ++ lib/Db/FlowTimerFireMapper.php | 148 ++ lib/Db/FlowTimerMapper.php | 301 ++++ lib/Event/FlowTimerFiredEvent.php | 149 ++ lib/Exception/FlowTimerStateException.php | 38 + .../FlowTimerValidationException.php | 39 + .../FlowTimerSubjectTerminalListener.php | 127 ++ lib/Migration/Version1Date20260901170000.php | 254 +++ lib/Repair/CheckFlowTimerInvariants.php | 181 ++ lib/Repair/SeedFlowTimerRegister.php | 123 ++ .../Flow/Timer/EscalationLadderService.php | 580 +++++++ .../Flow/Timer/FlowTimerDefinitionStore.php | 224 +++ lib/Service/Flow/Timer/FlowTimerService.php | 1485 +++++++++++++++++ lib/Service/Flow/Timer/FlowTimerSweep.php | 108 ++ lib/Service/Flow/Timer/SlaCalculator.php | 380 +++++ lib/Service/Flow/Timer/WorkingCalendar.php | 517 ++++++ .../Flow/Timer/WorkingCalendarService.php | 152 ++ lib/Service/Task/TaskService.php | 91 + lib/Settings/flow_timer_register.json | 525 ++++++ lib/Settings/openregister_mock_register.json | 186 +++ .../changes/flow-business-timers/tasks.md | 36 +- scripts/l10n/locales/fr.json | 164 +- scripts/l10n/locales/lb.json | 355 ++-- .../BackgroundJob/FlowTimerWorkerTest.php | 93 ++ tests/Unit/Db/FlowTimerEntitiesTest.php | 175 ++ tests/Unit/Db/FlowTimerLedgerMappersTest.php | 88 + tests/Unit/Db/FlowTimerMapperQueriesTest.php | 165 ++ .../FlowTimerSubjectTerminalListenerTest.php | 95 ++ .../Unit/Repair/FlowTimerRepairStepsTest.php | 141 ++ .../Timer/EscalationLadderServiceTest.php | 248 +++ .../Timer/FlowTimerDefinitionStoreTest.php | 85 + .../Flow/Timer/FlowTimerEdgeCasesTest.php | 186 +++ .../Flow/Timer/FlowTimerServiceTest.php | 928 ++++++++++ .../Service/Flow/Timer/FlowTimerSweepTest.php | 137 ++ .../Service/Flow/Timer/InMemoryTimerStore.php | 256 +++ .../Service/Flow/Timer/SlaCalculatorTest.php | 133 ++ .../Flow/Timer/WorkingCalendarTest.php | 177 ++ .../Task/TaskServiceTimerOutcomeTest.php | 160 ++ 119 files changed, 14129 insertions(+), 351 deletions(-) create mode 100644 lib/BackgroundJob/FlowTimerWorker.php create mode 100644 lib/Db/FlowTimer.php create mode 100644 lib/Db/FlowTimerEvent.php create mode 100644 lib/Db/FlowTimerEventMapper.php create mode 100644 lib/Db/FlowTimerFire.php create mode 100644 lib/Db/FlowTimerFireMapper.php create mode 100644 lib/Db/FlowTimerMapper.php create mode 100644 lib/Event/FlowTimerFiredEvent.php create mode 100644 lib/Exception/FlowTimerStateException.php create mode 100644 lib/Exception/FlowTimerValidationException.php create mode 100644 lib/Listener/FlowTimerSubjectTerminalListener.php create mode 100644 lib/Migration/Version1Date20260901170000.php create mode 100644 lib/Repair/CheckFlowTimerInvariants.php create mode 100644 lib/Repair/SeedFlowTimerRegister.php create mode 100644 lib/Service/Flow/Timer/EscalationLadderService.php create mode 100644 lib/Service/Flow/Timer/FlowTimerDefinitionStore.php create mode 100644 lib/Service/Flow/Timer/FlowTimerService.php create mode 100644 lib/Service/Flow/Timer/FlowTimerSweep.php create mode 100644 lib/Service/Flow/Timer/SlaCalculator.php create mode 100644 lib/Service/Flow/Timer/WorkingCalendar.php create mode 100644 lib/Service/Flow/Timer/WorkingCalendarService.php create mode 100644 lib/Settings/flow_timer_register.json create mode 100644 tests/Unit/BackgroundJob/FlowTimerWorkerTest.php create mode 100644 tests/Unit/Db/FlowTimerEntitiesTest.php create mode 100644 tests/Unit/Db/FlowTimerLedgerMappersTest.php create mode 100644 tests/Unit/Db/FlowTimerMapperQueriesTest.php create mode 100644 tests/Unit/Listener/FlowTimerSubjectTerminalListenerTest.php create mode 100644 tests/Unit/Repair/FlowTimerRepairStepsTest.php create mode 100644 tests/Unit/Service/Flow/Timer/EscalationLadderServiceTest.php create mode 100644 tests/Unit/Service/Flow/Timer/FlowTimerDefinitionStoreTest.php create mode 100644 tests/Unit/Service/Flow/Timer/FlowTimerEdgeCasesTest.php create mode 100644 tests/Unit/Service/Flow/Timer/FlowTimerServiceTest.php create mode 100644 tests/Unit/Service/Flow/Timer/FlowTimerSweepTest.php create mode 100644 tests/Unit/Service/Flow/Timer/InMemoryTimerStore.php create mode 100644 tests/Unit/Service/Flow/Timer/SlaCalculatorTest.php create mode 100644 tests/Unit/Service/Flow/Timer/WorkingCalendarTest.php create mode 100644 tests/Unit/Service/Task/TaskServiceTimerOutcomeTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index d128d24d78..f433ca9d18 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -146,6 +146,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\BackgroundJob\ArchivalRetentionTask OCA\OpenRegister\BackgroundJob\FlowRunWorker OCA\OpenRegister\BackgroundJob\FlowScheduleWorker + OCA\OpenRegister\BackgroundJob\FlowTimerWorker OCA\OpenRegister\BackgroundJob\HandoffQueueDrainJob OCA\OpenRegister\BackgroundJob\TemporalCalculationSweepJob OCA\OpenRegister\BackgroundJob\DsarDpiaDetectionJob @@ -250,6 +251,15 @@ Vrij en open source onder de EUPL-licentie. tables its own change's migration creates. --> OCA\OpenRegister\Repair\SeedTaskFixtures OCA\OpenRegister\Repair\SeedCaseFixtures + + OCA\OpenRegister\Repair\SeedFlowTimerRegister + OCA\OpenRegister\Repair\CheckFlowTimerInvariants OCA\OpenRegister\Repair\ReconcileDeclaredBackgroundJobs @@ -291,6 +301,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Repair\SeedVocabularyRegister OCA\OpenRegister\Repair\RegisterOpenRegisterWithDoriath OCA\OpenRegister\Repair\SeedZgwZakenMigrationPack + OCA\OpenRegister\Repair\SeedFlowTimerRegister diff --git a/l10n/.schema-l10n-baseline.json b/l10n/.schema-l10n-baseline.json index 3ed52820fd..4d94ca4b78 100644 --- a/l10n/.schema-l10n-baseline.json +++ b/l10n/.schema-l10n-baseline.json @@ -1,3 +1,3 @@ { - "uncovered": 634 + "uncovered": 601 } diff --git a/l10n/be.js b/l10n/be.js index c6a5ca9768..157aa19feb 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Аб'ект паспяхова выдалены","Аб'екты паспяхова выдаленыя","Аб'екты паспяхова выдаленыя","Аб'екты паспяхова выдаленыя"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Ачысціць {count} аб'ект з базы даных","Ачысціць {count} аб'екты з базы даных","Ачысціць {count} аб'ектаў з базы даных","Ачысціць {count} аб'екта з базы даных"], "_Restore {count} object_::_Restore {count} objects_": ["Аднавіць {count} аб'ект","Аднавіць {count} аб'екты","Аднавіць {count} аб'ектаў","Аднавіць {count} аб'екта"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Паспяхова адноўлены {count} аб'ект","Паспяхова адноўленыя {count} аб'екты","Паспяхова адноўлена {count} аб'ектаў","Паспяхова адноўлена {count} аб'екта"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Паспяхова адноўлены {count} аб'ект","Паспяхова адноўленыя {count} аб'екты","Паспяхова адноўлена {count} аб'ектаў","Паспяхова адноўлена {count} аб'екта"], + "A message identity the notification subsystem resolves.": "Ідэнтычнасць паведамлення, якую вырашае сістэма апавяшчэнняў.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Вылічаныя правілы непрацоўных дзён. Від fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Від easter: {offset, name}, зрух у днях ад Вялікоднай нядзелі. Від observedShift: нязменная дата з абавязковым пераносам.", + "Date": "Дата", + "Day": "Дзень", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Дзень правіла fixed або observedShift; правіла easter яго ігнаруе.", + "Days": "Дні", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Дні ад Вялікоднай нядзелі для правіла easter; іншыя віды іх ігнаруюць.", + "Distance from the deadline, in the offset unit.": "Адлегласць да тэрміну ў адзінцы зруху.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Асобныя закрыцці, пералічаныя як {date, name}. Дазволеныя побач з правіламі; адхіленыя як увесь каляндар.", + "Escalate to roles": "Ролі для эскалацыі", + "Escalation ladder": "Лесвіца эскалацыі", + "Exception dates": "Выключныя даты", + "Hours per working day": "Гадзін на працоўны дзень", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "На колькі дзён пераносіцца святочная дата, напр. -1 для папярэдняй суботы.", + "Human-readable title.": "Чытэльная назва.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO дні тыдня (1 = панядзелак, 7 = нядзеля), якія з'яўляюцца працоўнымі.", + "Kind": "Від", + "Message": "Паведамленне", + "Month": "Месяц", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Месяц правіла fixed або observedShift; правіла easter яго ігнаруе.", + "Non-working-date rules": "Правілы непрацоўных дзён", + "Notify roles": "Ролі для апавяшчэння", + "Observed shift": "Перанос свята", + "Offset": "Зрух", + "Offset unit": "Адзінка зруху", + "Open incident": "Адкрыць інцыдэнт", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Неабавязковая мапа з назвы ролі на канкрэтнага атрымальніка, напр. {\"teamleader\": \"group:teamleaders\"}. Ролі без прывязкі рухаюцца нявырашанымі як role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Неабавязкова для правіла fixed, абавязкова для observedShift: {whenWeekday: назва дня або ISO 1..7, days: цэлы лік}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Упарадкаваныя прыступкі: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Прыярытэт", + "Role bindings": "Прывязкі роляў", + "Roles the matter is escalated to at this rung.": "Ролі, да якіх справа эскалюецца на гэтай прыступцы.", + "Roles told at this rung; handler is the subject's own performer.": "Ролі, якія апавяшчаюцца на гэтай прыступцы; handler — асоба, якая вядзе справу.", + "Rungs": "Прыступкі", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Сталая ідэнтычнасць прыступкі, напр. preBreach:14:calendarDays; калі адсутнічае, выводзіцца з трыгера, зруху і адзінкі.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Сталая назва календара, на якую таймер спасылаецца праз calendar_slug.", + "The closed date, Y-m-d.": "Дата закрыцця, Y-m-d.", + "The holiday's name.": "Назва свята.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Сталая назва лесвіцы, на якую таймер спасылаецца праз ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Адзінка зруху: той самы набор, які прымае SLA.", + "The urgency the raised transition carries.": "Тэрміновасць, якую нясе выкліканы пераход.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Дзень тыдня (назва), пры якім дата пераносіцца, напр. sunday для Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID арганізацыі, для якой наладжаны гэты каляндар. Пуста для агульнага календара. Калі таймер нічога не называе, парадак вырашэння выбірае каляндар арганізацыі.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID арганізацыі, для якой гэтая лесвіца прадвызначаная. Пуста для агульнай прадвызначанай.", + "When weekday": "Калі прыпадае на", + "Whether reaching this rung opens an incident.": "Ці адкрывае дасягненне гэтай прыступкі інцыдэнт.", + "Why the day is closed.": "Чаму гэты дзень зачынены.", + "Working calendar": "Працоўны каляндар", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Працоўныя гадзіны ў адным працоўным дні. Робіць гадзіны і працоўныя дні супастаўнымі.", + "Working weekdays": "Працоўныя дні тыдня", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: месяц і дзень; easter: зрух у днях ад Вялікоднай нядзелі; observedShift: месяц і дзень з абавязковым пераносам.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach спрацоўвае да тэрміну, slaBreached у тэрмін або пасля." }, "nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);" ) diff --git a/l10n/be.json b/l10n/be.json index e787ba27bf..7a846aa95c 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -2817,7 +2817,59 @@ "Паспяхова адноўленыя {count} аб'екты", "Паспяхова адноўлена {count} аб'ектаў", "Паспяхова адноўлена {count} аб'екта" - ] + ], + "A message identity the notification subsystem resolves.": "Ідэнтычнасць паведамлення, якую вырашае сістэма апавяшчэнняў.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Вылічаныя правілы непрацоўных дзён. Від fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Від easter: {offset, name}, зрух у днях ад Вялікоднай нядзелі. Від observedShift: нязменная дата з абавязковым пераносам.", + "Date": "Дата", + "Day": "Дзень", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Дзень правіла fixed або observedShift; правіла easter яго ігнаруе.", + "Days": "Дні", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Дні ад Вялікоднай нядзелі для правіла easter; іншыя віды іх ігнаруюць.", + "Distance from the deadline, in the offset unit.": "Адлегласць да тэрміну ў адзінцы зруху.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Асобныя закрыцці, пералічаныя як {date, name}. Дазволеныя побач з правіламі; адхіленыя як увесь каляндар.", + "Escalate to roles": "Ролі для эскалацыі", + "Escalation ladder": "Лесвіца эскалацыі", + "Exception dates": "Выключныя даты", + "Hours per working day": "Гадзін на працоўны дзень", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "На колькі дзён пераносіцца святочная дата, напр. -1 для папярэдняй суботы.", + "Human-readable title.": "Чытэльная назва.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO дні тыдня (1 = панядзелак, 7 = нядзеля), якія з'яўляюцца працоўнымі.", + "Kind": "Від", + "Message": "Паведамленне", + "Month": "Месяц", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Месяц правіла fixed або observedShift; правіла easter яго ігнаруе.", + "Non-working-date rules": "Правілы непрацоўных дзён", + "Notify roles": "Ролі для апавяшчэння", + "Observed shift": "Перанос свята", + "Offset": "Зрух", + "Offset unit": "Адзінка зруху", + "Open incident": "Адкрыць інцыдэнт", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Неабавязковая мапа з назвы ролі на канкрэтнага атрымальніка, напр. {\"teamleader\": \"group:teamleaders\"}. Ролі без прывязкі рухаюцца нявырашанымі як role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Неабавязкова для правіла fixed, абавязкова для observedShift: {whenWeekday: назва дня або ISO 1..7, days: цэлы лік}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Упарадкаваныя прыступкі: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Прыярытэт", + "Role bindings": "Прывязкі роляў", + "Roles the matter is escalated to at this rung.": "Ролі, да якіх справа эскалюецца на гэтай прыступцы.", + "Roles told at this rung; handler is the subject's own performer.": "Ролі, якія апавяшчаюцца на гэтай прыступцы; handler — асоба, якая вядзе справу.", + "Rungs": "Прыступкі", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Сталая ідэнтычнасць прыступкі, напр. preBreach:14:calendarDays; калі адсутнічае, выводзіцца з трыгера, зруху і адзінкі.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Сталая назва календара, на якую таймер спасылаецца праз calendar_slug.", + "The closed date, Y-m-d.": "Дата закрыцця, Y-m-d.", + "The holiday's name.": "Назва свята.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Сталая назва лесвіцы, на якую таймер спасылаецца праз ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Адзінка зруху: той самы набор, які прымае SLA.", + "The urgency the raised transition carries.": "Тэрміновасць, якую нясе выкліканы пераход.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Дзень тыдня (назва), пры якім дата пераносіцца, напр. sunday для Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID арганізацыі, для якой наладжаны гэты каляндар. Пуста для агульнага календара. Калі таймер нічога не называе, парадак вырашэння выбірае каляндар арганізацыі.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID арганізацыі, для якой гэтая лесвіца прадвызначаная. Пуста для агульнай прадвызначанай.", + "When weekday": "Калі прыпадае на", + "Whether reaching this rung opens an incident.": "Ці адкрывае дасягненне гэтай прыступкі інцыдэнт.", + "Why the day is closed.": "Чаму гэты дзень зачынены.", + "Working calendar": "Працоўны каляндар", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Працоўныя гадзіны ў адным працоўным дні. Робіць гадзіны і працоўныя дні супастаўнымі.", + "Working weekdays": "Працоўныя дні тыдня", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: месяц і дзень; easter: зрух у днях ад Вялікоднай нядзелі; observedShift: месяц і дзень з абавязковым пераносам.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach спрацоўвае да тэрміну, slaBreached у тэрмін або пасля." }, "pluralForm": "nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);", "plurals": { diff --git a/l10n/bg.js b/l10n/bg.js index de17cf774e..36f0465a72 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Обектът е изтрит успешно","Обектите са изтрити успешно"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Изчистване на {count} обект от базата данни","Изчистване на {count} обекта от базата данни"], "_Restore {count} object_::_Restore {count} objects_": ["Възстановяване на {count} обект","Възстановяване на {count} обекта"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Успешно възстановен {count} обект","Успешно възстановени {count} обекта"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Успешно възстановен {count} обект","Успешно възстановени {count} обекта"], + "A message identity the notification subsystem resolves.": "Идентичност на съобщение, която системата за известия разрешава.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Изчислени правила за неработните дни. Вид fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Вид easter: {offset, name}, отместване в дни от Великденската неделя. Вид observedShift: фиксирана дата със задължително преместване.", + "Date": "Дата", + "Day": "Ден", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Ден на правило fixed или observedShift; правило easter го пренебрегва.", + "Days": "Дни", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Дни от Великденската неделя за правило easter; другите видове ги пренебрегват.", + "Distance from the deadline, in the offset unit.": "Разстояние до срока в единицата на отместването.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Единични затваряния, изброени като {date, name}. Позволени редом с правила; отказани като целия календар.", + "Escalate to roles": "Роли за ескалация", + "Escalation ladder": "Стълба на ескалация", + "Exception dates": "Изключителни дати", + "Hours per working day": "Часове на работен ден", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "С колко дни се премества честваната дата, напр. -1 за предишната събота.", + "Human-readable title.": "Четимо заглавие.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO дни от седмицата (1 = понеделник, 7 = неделя), които са работни.", + "Kind": "Вид", + "Message": "Съобщение", + "Month": "Месец", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Месец на правило fixed или observedShift; правило easter го пренебрегва.", + "Non-working-date rules": "Правила за неработните дни", + "Notify roles": "Роли за уведомяване", + "Observed shift": "Преместване на празника", + "Offset": "Отместване", + "Offset unit": "Единица на отместването", + "Open incident": "Отваряне на инцидент", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Незадължителна карта от име на роля към конкретен получател, напр. {\"teamleader\": \"group:teamleaders\"}. Роли без обвързване пътуват неразрешени като role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Незадължително при правило fixed, задължително при observedShift: {whenWeekday: име на ден или ISO 1..7, days: цяло число}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Подредени стъпала: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Приоритет", + "Role bindings": "Обвързвания на роли", + "Roles the matter is escalated to at this rung.": "Роли, към които преписката се ескалира на това стъпало.", + "Roles told at this rung; handler is the subject's own performer.": "Роли, уведомявани на това стъпало; handler е лицето, което обработва преписката.", + "Rungs": "Стъпала", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Постоянна идентичност на стъпалото, напр. preBreach:14:calendarDays; при липса се извежда от спусъка, отместването и единицата.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Постоянното име на календара, посочвано от calendar_slug на таймер.", + "The closed date, Y-m-d.": "Датата на затваряне, Y-m-d.", + "The holiday's name.": "Името на празника.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Постоянното име на стълбата, посочвано от ladder_slug на таймер.", + "The unit of the offset: the same set the SLA accepts.": "Единицата на отместването: същият набор, който SLA приема.", + "The urgency the raised transition carries.": "Спешността, която носи задействаният преход.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Денят от седмицата (име), при който датата се премества, напр. sunday за Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID на организацията, за която е настроен този календар. Празно при споделен календар. Ако таймерът не посочи никакъв, редът на разрешаване избира календара на организацията.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID на организацията, за която тази стълба е подразбираща се. Празно при споделената подразбираща се.", + "When weekday": "Ако се пада в", + "Whether reaching this rung opens an incident.": "Дали достигането на това стъпало отваря инцидент.", + "Why the day is closed.": "Защо денят е затворен.", + "Working calendar": "Работен календар", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Работни часове в един работен ден. Прави часовете и работните дни съизмерими.", + "Working weekdays": "Работни дни от седмицата", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: месец и ден; easter: отместване в дни от Великденската неделя; observedShift: месец и ден със задължително преместване.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach се задейства преди срока, slaBreached на срока или след него." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index b248b7bffd..54ea565226 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "Успешно възстановен {count} обект", "Успешно възстановени {count} обекта" - ] + ], + "A message identity the notification subsystem resolves.": "Идентичност на съобщение, която системата за известия разрешава.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Изчислени правила за неработните дни. Вид fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Вид easter: {offset, name}, отместване в дни от Великденската неделя. Вид observedShift: фиксирана дата със задължително преместване.", + "Date": "Дата", + "Day": "Ден", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Ден на правило fixed или observedShift; правило easter го пренебрегва.", + "Days": "Дни", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Дни от Великденската неделя за правило easter; другите видове ги пренебрегват.", + "Distance from the deadline, in the offset unit.": "Разстояние до срока в единицата на отместването.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Единични затваряния, изброени като {date, name}. Позволени редом с правила; отказани като целия календар.", + "Escalate to roles": "Роли за ескалация", + "Escalation ladder": "Стълба на ескалация", + "Exception dates": "Изключителни дати", + "Hours per working day": "Часове на работен ден", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "С колко дни се премества честваната дата, напр. -1 за предишната събота.", + "Human-readable title.": "Четимо заглавие.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO дни от седмицата (1 = понеделник, 7 = неделя), които са работни.", + "Kind": "Вид", + "Message": "Съобщение", + "Month": "Месец", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Месец на правило fixed или observedShift; правило easter го пренебрегва.", + "Non-working-date rules": "Правила за неработните дни", + "Notify roles": "Роли за уведомяване", + "Observed shift": "Преместване на празника", + "Offset": "Отместване", + "Offset unit": "Единица на отместването", + "Open incident": "Отваряне на инцидент", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Незадължителна карта от име на роля към конкретен получател, напр. {\"teamleader\": \"group:teamleaders\"}. Роли без обвързване пътуват неразрешени като role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Незадължително при правило fixed, задължително при observedShift: {whenWeekday: име на ден или ISO 1..7, days: цяло число}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Подредени стъпала: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Приоритет", + "Role bindings": "Обвързвания на роли", + "Roles the matter is escalated to at this rung.": "Роли, към които преписката се ескалира на това стъпало.", + "Roles told at this rung; handler is the subject's own performer.": "Роли, уведомявани на това стъпало; handler е лицето, което обработва преписката.", + "Rungs": "Стъпала", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Постоянна идентичност на стъпалото, напр. preBreach:14:calendarDays; при липса се извежда от спусъка, отместването и единицата.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Постоянното име на календара, посочвано от calendar_slug на таймер.", + "The closed date, Y-m-d.": "Датата на затваряне, Y-m-d.", + "The holiday's name.": "Името на празника.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Постоянното име на стълбата, посочвано от ladder_slug на таймер.", + "The unit of the offset: the same set the SLA accepts.": "Единицата на отместването: същият набор, който SLA приема.", + "The urgency the raised transition carries.": "Спешността, която носи задействаният преход.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Денят от седмицата (име), при който датата се премества, напр. sunday за Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID на организацията, за която е настроен този календар. Празно при споделен календар. Ако таймерът не посочи никакъв, редът на разрешаване избира календара на организацията.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID на организацията, за която тази стълба е подразбираща се. Празно при споделената подразбираща се.", + "When weekday": "Ако се пада в", + "Whether reaching this rung opens an incident.": "Дали достигането на това стъпало отваря инцидент.", + "Why the day is closed.": "Защо денят е затворен.", + "Working calendar": "Работен календар", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Работни часове в един работен ден. Прави часовете и работните дни съизмерими.", + "Working weekdays": "Работни дни от седмицата", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: месец и ден; easter: отместване в дни от Великденската неделя; observedShift: месец и ден със задължително преместване.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach се задейства преди срока, slaBreached на срока или след него." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/bs.js b/l10n/bs.js index 9fe988bdb6..13d79a57f6 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt je uspješno izbrisan","Objekti su uspješno izbrisani","Objekti su uspješno izbrisani"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Trajno ukloni {count} objekt iz baze podataka","Trajno ukloni {count} objekta iz baze podataka","Trajno ukloni {count} objekata iz baze podataka"], "_Restore {count} object_::_Restore {count} objects_": ["Vrati {count} objekt","Vrati {count} objekta","Vrati {count} objekata"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Uspješno vraćen {count} objekt","Uspješno vraćena {count} objekta","Uspješno vraćeno {count} objekata"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Uspješno vraćen {count} objekt","Uspješno vraćena {count} objekta","Uspješno vraćeno {count} objekata"], + "A message identity the notification subsystem resolves.": "Identitet poruke koji razrješava sustav obavijesti.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Izračunata pravila neradnih dana. Vrsta fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Vrsta easter: {offset, name}, odmak u danima od Uskršnje nedjelje. Vrsta observedShift: nepomičan datum s obaveznim pomjeranjem.", + "Date": "Datum", + "Day": "Dan", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dan pravila fixed ili observedShift; pravilo easter ga zanemaruje.", + "Days": "Dani", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dani od Uskršnje nedjelje za pravilo easter; druge ih vrste zanemaruju.", + "Distance from the deadline, in the offset unit.": "Udaljenost od roka u jedinici odmaka.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Pojedinačna zatvaranja nabrojena kao {date, name}. Dopuštena uz pravila; odbijena kao cijeli kalendar.", + "Escalate to roles": "Uloge za eskalaciju", + "Escalation ladder": "Ljestvica eskalacije", + "Exception dates": "Iznimni datumi", + "Hours per working day": "Sati po radnom danu", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Za koliko se dana slavljeni datum pomjera, npr. -1 za subotu prije.", + "Human-readable title.": "Čitljiv naslov.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dani u sedmici (1 = ponedjeljak, 7 = nedjelja) koji su radni.", + "Kind": "Vrsta", + "Message": "Poruka", + "Month": "Mjesec", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mjesec pravila fixed ili observedShift; pravilo easter ga zanemaruje.", + "Non-working-date rules": "Pravila neradnih dana", + "Notify roles": "Uloge za obavijest", + "Observed shift": "Pomjeranje praznika", + "Offset": "Odmak", + "Offset unit": "Jedinica odmaka", + "Open incident": "Otvori incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neobvezna mapa imena uloge na konkretnog primatelja, npr. {\"teamleader\": \"group:teamleaders\"}. Uloge bez poveznice putuju nerazriješene kao role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neobavezno kod pravila fixed, obavezno kod observedShift: {whenWeekday: ime dana ili ISO 1..7, days: cijeli broj}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Poredane prečke: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritet", + "Role bindings": "Poveznice uloga", + "Roles the matter is escalated to at this rung.": "Uloge na koje se predmet na ovoj prečki eskalira.", + "Roles told at this rung; handler is the subject's own performer.": "Uloge obaviještene na ovoj prečki; handler je osoba koja obrađuje predmet.", + "Rungs": "Prečke", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stalni identitet prečke, npr. preBreach:14:calendarDays; ako nedostaje, izvodi se iz okidača, odmaka i jedinice.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stalno ime kalendara na koje se tajmer poziva putem calendar_slug.", + "The closed date, Y-m-d.": "Datum zatvaranja, Y-m-d.", + "The holiday's name.": "Ime praznika.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stalno ime ljestvice na koje se tajmer poziva putem ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Jedinica odmaka: isti skup koji SLA prihvaća.", + "The urgency the raised transition carries.": "Hitnost koju nosi pokrenuti prijelaz.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Dan u sedmici (ime) na koji se datum pomjera, npr. sunday za Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizacije za koju je ovaj kalendar postavljen. Prazno za dijeljeni kalendar. Ne imenuje li tajmer nijedan, redoslijed razrješavanja bira kalendar organizacije.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizacije kojoj je ova ljestvica zadana. Prazno za dijeljenu zadanu.", + "When weekday": "Ako padne na", + "Whether reaching this rung opens an incident.": "Otvara li dosezanje ove prečke incident.", + "Why the day is closed.": "Zašto je dan zatvoren.", + "Working calendar": "Radni kalendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Radni sati u jednom radnom danu. Čini sate i radne dane usporedivima.", + "Working weekdays": "Radni dani u sedmici", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mjesec i dan; easter: odmak u danima od Uskršnje nedjelje; observedShift: mjesec i dan s obaveznim pomjeranjem.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se okida prije roka, slaBreached na rok ili poslije njega." }, "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;" ) diff --git a/l10n/bs.json b/l10n/bs.json index 03af38a5bc..f1422e54d3 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -2801,7 +2801,59 @@ "Uspješno vraćen {count} objekt", "Uspješno vraćena {count} objekta", "Uspješno vraćeno {count} objekata" - ] + ], + "A message identity the notification subsystem resolves.": "Identitet poruke koji razrješava sustav obavijesti.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Izračunata pravila neradnih dana. Vrsta fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Vrsta easter: {offset, name}, odmak u danima od Uskršnje nedjelje. Vrsta observedShift: nepomičan datum s obaveznim pomjeranjem.", + "Date": "Datum", + "Day": "Dan", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dan pravila fixed ili observedShift; pravilo easter ga zanemaruje.", + "Days": "Dani", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dani od Uskršnje nedjelje za pravilo easter; druge ih vrste zanemaruju.", + "Distance from the deadline, in the offset unit.": "Udaljenost od roka u jedinici odmaka.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Pojedinačna zatvaranja nabrojena kao {date, name}. Dopuštena uz pravila; odbijena kao cijeli kalendar.", + "Escalate to roles": "Uloge za eskalaciju", + "Escalation ladder": "Ljestvica eskalacije", + "Exception dates": "Iznimni datumi", + "Hours per working day": "Sati po radnom danu", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Za koliko se dana slavljeni datum pomjera, npr. -1 za subotu prije.", + "Human-readable title.": "Čitljiv naslov.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dani u sedmici (1 = ponedjeljak, 7 = nedjelja) koji su radni.", + "Kind": "Vrsta", + "Message": "Poruka", + "Month": "Mjesec", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mjesec pravila fixed ili observedShift; pravilo easter ga zanemaruje.", + "Non-working-date rules": "Pravila neradnih dana", + "Notify roles": "Uloge za obavijest", + "Observed shift": "Pomjeranje praznika", + "Offset": "Odmak", + "Offset unit": "Jedinica odmaka", + "Open incident": "Otvori incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neobvezna mapa imena uloge na konkretnog primatelja, npr. {\"teamleader\": \"group:teamleaders\"}. Uloge bez poveznice putuju nerazriješene kao role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neobavezno kod pravila fixed, obavezno kod observedShift: {whenWeekday: ime dana ili ISO 1..7, days: cijeli broj}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Poredane prečke: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritet", + "Role bindings": "Poveznice uloga", + "Roles the matter is escalated to at this rung.": "Uloge na koje se predmet na ovoj prečki eskalira.", + "Roles told at this rung; handler is the subject's own performer.": "Uloge obaviještene na ovoj prečki; handler je osoba koja obrađuje predmet.", + "Rungs": "Prečke", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stalni identitet prečke, npr. preBreach:14:calendarDays; ako nedostaje, izvodi se iz okidača, odmaka i jedinice.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stalno ime kalendara na koje se tajmer poziva putem calendar_slug.", + "The closed date, Y-m-d.": "Datum zatvaranja, Y-m-d.", + "The holiday's name.": "Ime praznika.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stalno ime ljestvice na koje se tajmer poziva putem ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Jedinica odmaka: isti skup koji SLA prihvaća.", + "The urgency the raised transition carries.": "Hitnost koju nosi pokrenuti prijelaz.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Dan u sedmici (ime) na koji se datum pomjera, npr. sunday za Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizacije za koju je ovaj kalendar postavljen. Prazno za dijeljeni kalendar. Ne imenuje li tajmer nijedan, redoslijed razrješavanja bira kalendar organizacije.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizacije kojoj je ova ljestvica zadana. Prazno za dijeljenu zadanu.", + "When weekday": "Ako padne na", + "Whether reaching this rung opens an incident.": "Otvara li dosezanje ove prečke incident.", + "Why the day is closed.": "Zašto je dan zatvoren.", + "Working calendar": "Radni kalendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Radni sati u jednom radnom danu. Čini sate i radne dane usporedivima.", + "Working weekdays": "Radni dani u sedmici", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mjesec i dan; easter: odmak u danima od Uskršnje nedjelje; observedShift: mjesec i dan s obaveznim pomjeranjem.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se okida prije roka, slaBreached na rok ili poslije njega." }, "pluralForm": "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;", "plurals": { diff --git a/l10n/ca.js b/l10n/ca.js index 4e22f363df..65b2e6b93d 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objecte eliminat correctament","Objectes eliminats correctament"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Purga {count} objecte de la base de dades","Purga {count} objectes de la base de dades"], "_Restore {count} object_::_Restore {count} objects_": ["Restaura {count} objecte","Restaura {count} objectes"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objecte restaurat correctament","{count} objectes restaurats correctament"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objecte restaurat correctament","{count} objectes restaurats correctament"], + "A message identity the notification subsystem resolves.": "Una identitat de missatge que resol el sistema de notificacions.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Regles calculades de dies no laborables. Tipus fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tipus easter: {offset, name}, desplaçament en dies des del Diumenge de Pasqua. Tipus observedShift: una data fixa amb trasllat obligatori.", + "Date": "Data", + "Day": "Dia", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dia d'una regla fixed o observedShift; una regla easter l'ignora.", + "Days": "Dies", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dies des del Diumenge de Pasqua per a una regla easter; els altres tipus els ignoren.", + "Distance from the deadline, in the offset unit.": "Distància al termini, en la unitat del desplaçament.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Tancaments puntuals enumerats com a {date, name}. Permesos al costat de regles; rebutjats com a calendari sencer.", + "Escalate to roles": "Rols d'escalada", + "Escalation ladder": "Escala d'escalada", + "Exception dates": "Dates d'excepció", + "Hours per working day": "Hores per dia feiner", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Quants dies es trasllada la data observada, p. ex. -1 per al dissabte anterior.", + "Human-readable title.": "Títol llegible.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Dies ISO de la setmana (1 = dilluns, 7 = diumenge) que són feiners.", + "Kind": "Tipus", + "Message": "Missatge", + "Month": "Mes", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mes d'una regla fixed o observedShift; una regla easter l'ignora.", + "Non-working-date rules": "Regles de dies no laborables", + "Notify roles": "Rols a notificar", + "Observed shift": "Trasllat del festiu", + "Offset": "Desplaçament", + "Offset unit": "Unitat del desplaçament", + "Open incident": "Obrir una incidència", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mapa opcional d'un nom de rol a un destinatari concret, p. ex. {\"teamleader\": \"group:teamleaders\"}. Els rols sense vinculació viatgen sense resoldre com a role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opcional en una regla fixed, obligatori en observedShift: {whenWeekday: nom del dia o ISO 1..7, days: enter}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Esglaons ordenats: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritat", + "Role bindings": "Vinculacions de rols", + "Roles the matter is escalated to at this rung.": "Rols als quals s'escala l'assumpte en aquest esglaó.", + "Roles told at this rung; handler is the subject's own performer.": "Rols avisats en aquest esglaó; handler és la persona encarregada de l'assumpte.", + "Rungs": "Esglaons", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identitat estable de l'esglaó, p. ex. preBreach:14:calendarDays; si falta, es deriva de l'activador, el desplaçament i la unitat.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "El nom estable del calendari, referenciat pel calendar_slug d'un temporitzador.", + "The closed date, Y-m-d.": "La data de tancament, Y-m-d.", + "The holiday's name.": "El nom del festiu.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "El nom estable de l'escala, referenciat pel ladder_slug d'un temporitzador.", + "The unit of the offset: the same set the SLA accepts.": "La unitat del desplaçament: el mateix conjunt que accepta l'SLA.", + "The urgency the raised transition carries.": "La urgència que porta la transició disparada.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "El dia de la setmana (nom) en què la data es trasllada, p. ex. sunday per a Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID de l'organització per a la qual està configurat aquest calendari. Buit per a un calendari compartit. Si el temporitzador no en nomena cap, l'ordre de resolució tria el calendari de l'organització.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID de l'organització per a la qual aquesta escala és la predeterminada. Buit per al valor compartit.", + "When weekday": "Si cau en", + "Whether reaching this rung opens an incident.": "Si assolir aquest esglaó obre una incidència.", + "Why the day is closed.": "Per què està tancat aquest dia.", + "Working calendar": "Calendari laboral", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Hores treballades en un dia feiner. Fa comparables les hores i els dies feiners.", + "Working weekdays": "Dies feiners de la setmana", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mes i dia; easter: un desplaçament en dies des del Diumenge de Pasqua; observedShift: mes i dia amb trasllat obligatori.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach es dispara abans del termini, slaBreached al termini o després." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index 4bd25d354a..d3a696c8c3 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} objecte restaurat correctament", "{count} objectes restaurats correctament" - ] + ], + "A message identity the notification subsystem resolves.": "Una identitat de missatge que resol el sistema de notificacions.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Regles calculades de dies no laborables. Tipus fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tipus easter: {offset, name}, desplaçament en dies des del Diumenge de Pasqua. Tipus observedShift: una data fixa amb trasllat obligatori.", + "Date": "Data", + "Day": "Dia", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dia d'una regla fixed o observedShift; una regla easter l'ignora.", + "Days": "Dies", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dies des del Diumenge de Pasqua per a una regla easter; els altres tipus els ignoren.", + "Distance from the deadline, in the offset unit.": "Distància al termini, en la unitat del desplaçament.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Tancaments puntuals enumerats com a {date, name}. Permesos al costat de regles; rebutjats com a calendari sencer.", + "Escalate to roles": "Rols d'escalada", + "Escalation ladder": "Escala d'escalada", + "Exception dates": "Dates d'excepció", + "Hours per working day": "Hores per dia feiner", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Quants dies es trasllada la data observada, p. ex. -1 per al dissabte anterior.", + "Human-readable title.": "Títol llegible.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Dies ISO de la setmana (1 = dilluns, 7 = diumenge) que són feiners.", + "Kind": "Tipus", + "Message": "Missatge", + "Month": "Mes", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mes d'una regla fixed o observedShift; una regla easter l'ignora.", + "Non-working-date rules": "Regles de dies no laborables", + "Notify roles": "Rols a notificar", + "Observed shift": "Trasllat del festiu", + "Offset": "Desplaçament", + "Offset unit": "Unitat del desplaçament", + "Open incident": "Obrir una incidència", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mapa opcional d'un nom de rol a un destinatari concret, p. ex. {\"teamleader\": \"group:teamleaders\"}. Els rols sense vinculació viatgen sense resoldre com a role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opcional en una regla fixed, obligatori en observedShift: {whenWeekday: nom del dia o ISO 1..7, days: enter}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Esglaons ordenats: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritat", + "Role bindings": "Vinculacions de rols", + "Roles the matter is escalated to at this rung.": "Rols als quals s'escala l'assumpte en aquest esglaó.", + "Roles told at this rung; handler is the subject's own performer.": "Rols avisats en aquest esglaó; handler és la persona encarregada de l'assumpte.", + "Rungs": "Esglaons", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identitat estable de l'esglaó, p. ex. preBreach:14:calendarDays; si falta, es deriva de l'activador, el desplaçament i la unitat.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "El nom estable del calendari, referenciat pel calendar_slug d'un temporitzador.", + "The closed date, Y-m-d.": "La data de tancament, Y-m-d.", + "The holiday's name.": "El nom del festiu.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "El nom estable de l'escala, referenciat pel ladder_slug d'un temporitzador.", + "The unit of the offset: the same set the SLA accepts.": "La unitat del desplaçament: el mateix conjunt que accepta l'SLA.", + "The urgency the raised transition carries.": "La urgència que porta la transició disparada.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "El dia de la setmana (nom) en què la data es trasllada, p. ex. sunday per a Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID de l'organització per a la qual està configurat aquest calendari. Buit per a un calendari compartit. Si el temporitzador no en nomena cap, l'ordre de resolució tria el calendari de l'organització.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID de l'organització per a la qual aquesta escala és la predeterminada. Buit per al valor compartit.", + "When weekday": "Si cau en", + "Whether reaching this rung opens an incident.": "Si assolir aquest esglaó obre una incidència.", + "Why the day is closed.": "Per què està tancat aquest dia.", + "Working calendar": "Calendari laboral", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Hores treballades en un dia feiner. Fa comparables les hores i els dies feiners.", + "Working weekdays": "Dies feiners de la setmana", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mes i dia; easter: un desplaçament en dies des del Diumenge de Pasqua; observedShift: mes i dia amb trasllat obligatori.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach es dispara abans del termini, slaBreached al termini o després." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/cs.js b/l10n/cs.js index 94b6dc71b8..891cd661b8 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt byl úspěšně smazán","Objekty byly úspěšně smazány","Objekty byly úspěšně smazány","Objekty byly úspěšně smazány"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Vyčistit {count} objekt z databáze","Vyčistit {count} objekty z databáze","Vyčistit {count} objektu z databáze","Vyčistit {count} objektů z databáze"], "_Restore {count} object_::_Restore {count} objects_": ["Obnovit {count} objekt","Obnovit {count} objekty","Obnovit {count} objektu","Obnovit {count} objektů"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Úspěšně obnoven {count} objekt","Úspěšně obnoveny {count} objekty","Úspěšně obnoveno {count} objektu","Úspěšně obnoveno {count} objektů"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Úspěšně obnoven {count} objekt","Úspěšně obnoveny {count} objekty","Úspěšně obnoveno {count} objektu","Úspěšně obnoveno {count} objektů"], + "A message identity the notification subsystem resolves.": "Identita zprávy, kterou rozliší systém upozornění.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Vypočítaná pravidla nepracovních dnů. Druh fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Druh easter: {offset, name}, odstup ve dnech od Velikonoční neděle. Druh observedShift: pevné datum s povinným přesunem.", + "Date": "Datum", + "Day": "Den", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Den pravidla fixed nebo observedShift; pravidlo easter jej ignoruje.", + "Days": "Dny", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dny od Velikonoční neděle pro pravidlo easter; ostatní druhy je ignorují.", + "Distance from the deadline, in the offset unit.": "Vzdálenost od lhůty v jednotce odstupu.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Jednorázová uzavření vyjmenovaná jako {date, name}. Povolena vedle pravidel; odmítnuta jako celý kalendář.", + "Escalate to roles": "Role pro eskalaci", + "Escalation ladder": "Eskalační žebřík", + "Exception dates": "Výjimečná data", + "Hours per working day": "Hodin na pracovní den", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "O kolik dnů se slavené datum přesune, např. -1 pro předchozí sobotu.", + "Human-readable title.": "Čitelný název.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dny v týdnu (1 = pondělí, 7 = neděle), které jsou pracovní.", + "Kind": "Druh", + "Message": "Zpráva", + "Month": "Měsíc", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Měsíc pravidla fixed nebo observedShift; pravidlo easter jej ignoruje.", + "Non-working-date rules": "Pravidla nepracovních dnů", + "Notify roles": "Role k upozornění", + "Observed shift": "Přesun svátku", + "Offset": "Odstup", + "Offset unit": "Jednotka odstupu", + "Open incident": "Otevřít incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Volitelná mapa z názvu role na konkrétního příjemce, např. {\"teamleader\": \"group:teamleaders\"}. Role bez vazby putují nerozlišené jako role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Volitelné u pravidla fixed, povinné u observedShift: {whenWeekday: název dne nebo ISO 1..7, days: celé číslo}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Seřazené příčky: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorita", + "Role bindings": "Vazby rolí", + "Roles the matter is escalated to at this rung.": "Role, na které se věc na této příčce eskaluje.", + "Roles told at this rung; handler is the subject's own performer.": "Role upozorněné na této příčce; handler je osoba, která věc vyřizuje.", + "Rungs": "Příčky", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stálá identita příčky, např. preBreach:14:calendarDays; chybí-li, odvodí se ze spouštěče, odstupu a jednotky.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stálý název kalendáře, na který časovač odkazuje přes calendar_slug.", + "The closed date, Y-m-d.": "Datum uzavření, Y-m-d.", + "The holiday's name.": "Název svátku.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stálý název žebříku, na který časovač odkazuje přes ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Jednotka odstupu: stejná množina, jakou přijímá SLA.", + "The urgency the raised transition carries.": "Naléhavost, kterou nese vyvolaný přechod.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Den v týdnu (název), při kterém se datum přesune, např. sunday pro Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizace, pro kterou je tento kalendář nastaven. Prázdné u sdíleného kalendáře. Nejmenuje-li časovač žádný, pořadí rozlišení vybere kalendář organizace.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizace, pro kterou je tento žebřík výchozí. Prázdné u sdíleného výchozího.", + "When weekday": "Připadne-li na", + "Whether reaching this rung opens an incident.": "Zda dosažení této příčky otevře incident.", + "Why the day is closed.": "Proč je den uzavřen.", + "Working calendar": "Pracovní kalendář", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Odpracované hodiny v jednom pracovním dni. Činí hodiny a pracovní dny srovnatelnými.", + "Working weekdays": "Pracovní dny v týdnu", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: měsíc a den; easter: odstup ve dnech od Velikonoční neděle; observedShift: měsíc a den s povinným přesunem.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se spouští před lhůtou, slaBreached ve lhůtě nebo po ní." }, "nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;" ) diff --git a/l10n/cs.json b/l10n/cs.json index e17ef21bad..adc3fb6cf4 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -2817,7 +2817,59 @@ "Úspěšně obnoveny {count} objekty", "Úspěšně obnoveno {count} objektu", "Úspěšně obnoveno {count} objektů" - ] + ], + "A message identity the notification subsystem resolves.": "Identita zprávy, kterou rozliší systém upozornění.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Vypočítaná pravidla nepracovních dnů. Druh fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Druh easter: {offset, name}, odstup ve dnech od Velikonoční neděle. Druh observedShift: pevné datum s povinným přesunem.", + "Date": "Datum", + "Day": "Den", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Den pravidla fixed nebo observedShift; pravidlo easter jej ignoruje.", + "Days": "Dny", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dny od Velikonoční neděle pro pravidlo easter; ostatní druhy je ignorují.", + "Distance from the deadline, in the offset unit.": "Vzdálenost od lhůty v jednotce odstupu.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Jednorázová uzavření vyjmenovaná jako {date, name}. Povolena vedle pravidel; odmítnuta jako celý kalendář.", + "Escalate to roles": "Role pro eskalaci", + "Escalation ladder": "Eskalační žebřík", + "Exception dates": "Výjimečná data", + "Hours per working day": "Hodin na pracovní den", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "O kolik dnů se slavené datum přesune, např. -1 pro předchozí sobotu.", + "Human-readable title.": "Čitelný název.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dny v týdnu (1 = pondělí, 7 = neděle), které jsou pracovní.", + "Kind": "Druh", + "Message": "Zpráva", + "Month": "Měsíc", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Měsíc pravidla fixed nebo observedShift; pravidlo easter jej ignoruje.", + "Non-working-date rules": "Pravidla nepracovních dnů", + "Notify roles": "Role k upozornění", + "Observed shift": "Přesun svátku", + "Offset": "Odstup", + "Offset unit": "Jednotka odstupu", + "Open incident": "Otevřít incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Volitelná mapa z názvu role na konkrétního příjemce, např. {\"teamleader\": \"group:teamleaders\"}. Role bez vazby putují nerozlišené jako role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Volitelné u pravidla fixed, povinné u observedShift: {whenWeekday: název dne nebo ISO 1..7, days: celé číslo}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Seřazené příčky: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorita", + "Role bindings": "Vazby rolí", + "Roles the matter is escalated to at this rung.": "Role, na které se věc na této příčce eskaluje.", + "Roles told at this rung; handler is the subject's own performer.": "Role upozorněné na této příčce; handler je osoba, která věc vyřizuje.", + "Rungs": "Příčky", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stálá identita příčky, např. preBreach:14:calendarDays; chybí-li, odvodí se ze spouštěče, odstupu a jednotky.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stálý název kalendáře, na který časovač odkazuje přes calendar_slug.", + "The closed date, Y-m-d.": "Datum uzavření, Y-m-d.", + "The holiday's name.": "Název svátku.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stálý název žebříku, na který časovač odkazuje přes ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Jednotka odstupu: stejná množina, jakou přijímá SLA.", + "The urgency the raised transition carries.": "Naléhavost, kterou nese vyvolaný přechod.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Den v týdnu (název), při kterém se datum přesune, např. sunday pro Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizace, pro kterou je tento kalendář nastaven. Prázdné u sdíleného kalendáře. Nejmenuje-li časovač žádný, pořadí rozlišení vybere kalendář organizace.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizace, pro kterou je tento žebřík výchozí. Prázdné u sdíleného výchozího.", + "When weekday": "Připadne-li na", + "Whether reaching this rung opens an incident.": "Zda dosažení této příčky otevře incident.", + "Why the day is closed.": "Proč je den uzavřen.", + "Working calendar": "Pracovní kalendář", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Odpracované hodiny v jednom pracovním dni. Činí hodiny a pracovní dny srovnatelnými.", + "Working weekdays": "Pracovní dny v týdnu", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: měsíc a den; easter: odstup ve dnech od Velikonoční neděle; observedShift: měsíc a den s povinným přesunem.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se spouští před lhůtou, slaBreached ve lhůtě nebo po ní." }, "pluralForm": "nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;", "plurals": { diff --git a/l10n/da.js b/l10n/da.js index be8ae25bd3..bf96a8a205 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt slettet","Objekter slettet"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Ryd {count} objekt fra databasen","Ryd {count} objekter fra databasen"], "_Restore {count} object_::_Restore {count} objects_": ["Gendan {count} objekt","Gendan {count} objekter"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objekt gendannet","{count} objekter gendannet"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objekt gendannet","{count} objekter gendannet"], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index e0292c88a9..f9f0a4aa3c 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} objekt gendannet", "{count} objekter gendannet" - ] + ], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/de.js b/l10n/de.js index d862152ac3..1cd2c81e05 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt erfolgreich gelöscht","Objekte erfolgreich gelöscht"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["{count} Objekt aus der Datenbank bereinigen","{count} Objekte aus der Datenbank bereinigen"], "_Restore {count} object_::_Restore {count} objects_": ["{count} Objekt wiederherstellen","{count} Objekte wiederherstellen"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} Objekt erfolgreich wiederhergestellt","{count} Objekte erfolgreich wiederhergestellt"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} Objekt erfolgreich wiederhergestellt","{count} Objekte erfolgreich wiederhergestellt"], + "A message identity the notification subsystem resolves.": "Eine Nachrichtenkennung, die das Benachrichtigungssystem auflöst.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Berechnete Regeln für arbeitsfreie Tage. Art fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Art easter: {offset, name}, Abstand in Tagen ab Ostersonntag. Art observedShift: ein festes Datum mit vorgeschriebener Verschiebung.", + "Date": "Datum", + "Day": "Tag", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Tag einer fixed- oder observedShift-Regel; eine easter-Regel ignoriert ihn.", + "Days": "Tage", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Tage ab Ostersonntag für eine easter-Regel; die anderen Arten ignorieren sie.", + "Distance from the deadline, in the offset unit.": "Abstand zur Frist, in der gewählten Einheit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Einzelne Schließtage als {date, name}. Neben Regeln zulässig; als ganzer Kalender abgelehnt.", + "Escalate to roles": "Eskalation an Rollen", + "Escalation ladder": "Eskalationsleiter", + "Exception dates": "Ausnahmetage", + "Hours per working day": "Stunden pro Arbeitstag", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Um wie viele Tage der begangene Tag verschoben wird, z. B. -1 für den Samstag davor.", + "Human-readable title.": "Lesbarer Titel.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO-Wochentage (1 = Montag, 7 = Sonntag), die Arbeitstage sind.", + "Kind": "Art", + "Message": "Nachricht", + "Month": "Monat", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Monat einer fixed- oder observedShift-Regel; eine easter-Regel ignoriert ihn.", + "Non-working-date rules": "Regeln für arbeitsfreie Tage", + "Notify roles": "Zu benachrichtigende Rollen", + "Observed shift": "Verschobener Feiertag", + "Offset": "Abstand", + "Offset unit": "Einheit des Abstands", + "Open incident": "Vorfall eröffnen", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optionale Zuordnung eines Rollennamens zu einem konkreten Empfänger, z. B. {\"teamleader\": \"group:teamleaders\"}. Rollen ohne Zuordnung reisen unaufgelöst als role: weiter.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional bei einer fixed-Regel, verpflichtend bei observedShift: {whenWeekday: Name des Wochentags oder ISO 1..7, days: ganze Zahl}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Geordnete Stufen: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorität", + "Role bindings": "Rollenzuordnungen", + "Roles the matter is escalated to at this rung.": "Rollen, an die auf dieser Stufe eskaliert wird.", + "Roles told at this rung; handler is the subject's own performer.": "Rollen, die auf dieser Stufe benachrichtigt werden; handler ist die Bearbeiterin oder der Bearbeiter des Vorgangs.", + "Rungs": "Stufen", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Feste Kennung der Stufe, z. B. preBreach:14:calendarDays; fehlt sie, wird sie aus Auslöser, Abstand und Einheit abgeleitet.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Der feste Name des Kalenders, auf den ein Timer über calendar_slug verweist.", + "The closed date, Y-m-d.": "Der geschlossene Tag, Y-m-d.", + "The holiday's name.": "Der Name des Feiertags.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Der feste Name der Leiter, auf die ein Timer über ladder_slug verweist.", + "The unit of the offset: the same set the SLA accepts.": "Die Einheit des Abstands: dieselben Werte, die das SLA akzeptiert.", + "The urgency the raised transition carries.": "Die Dringlichkeit, die der ausgelöste Übergang trägt.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Der Wochentag (Name), an dem das Datum verschoben wird, z. B. sunday für Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID der Organisation, für die dieser Kalender eingerichtet ist. Leer bei einem gemeinsamen Kalender. Nennt der Timer keinen Kalender, wählt die Auflösungsreihenfolge den Kalender der Organisation.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID der Organisation, für die diese Leiter der Standard ist. Leer beim gemeinsamen Standard.", + "When weekday": "Bei Wochentag", + "Whether reaching this rung opens an incident.": "Ob das Erreichen dieser Stufe einen Vorfall eröffnet.", + "Why the day is closed.": "Warum dieser Tag geschlossen ist.", + "Working calendar": "Werkkalender", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Arbeitsstunden eines Arbeitstags. Macht Stunden und Werktage vergleichbar.", + "Working weekdays": "Arbeitswochentage", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: Monat und Tag; easter: ein Abstand in Tagen ab Ostersonntag; observedShift: Monat und Tag mit vorgeschriebener Verschiebung.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach löst vor Ablauf der Frist aus, slaBreached bei oder nach Ablauf." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index cf8036950c..03c04d9478 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} Objekt erfolgreich wiederhergestellt", "{count} Objekte erfolgreich wiederhergestellt" - ] + ], + "A message identity the notification subsystem resolves.": "Eine Nachrichtenkennung, die das Benachrichtigungssystem auflöst.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Berechnete Regeln für arbeitsfreie Tage. Art fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Art easter: {offset, name}, Abstand in Tagen ab Ostersonntag. Art observedShift: ein festes Datum mit vorgeschriebener Verschiebung.", + "Date": "Datum", + "Day": "Tag", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Tag einer fixed- oder observedShift-Regel; eine easter-Regel ignoriert ihn.", + "Days": "Tage", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Tage ab Ostersonntag für eine easter-Regel; die anderen Arten ignorieren sie.", + "Distance from the deadline, in the offset unit.": "Abstand zur Frist, in der gewählten Einheit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Einzelne Schließtage als {date, name}. Neben Regeln zulässig; als ganzer Kalender abgelehnt.", + "Escalate to roles": "Eskalation an Rollen", + "Escalation ladder": "Eskalationsleiter", + "Exception dates": "Ausnahmetage", + "Hours per working day": "Stunden pro Arbeitstag", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Um wie viele Tage der begangene Tag verschoben wird, z. B. -1 für den Samstag davor.", + "Human-readable title.": "Lesbarer Titel.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO-Wochentage (1 = Montag, 7 = Sonntag), die Arbeitstage sind.", + "Kind": "Art", + "Message": "Nachricht", + "Month": "Monat", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Monat einer fixed- oder observedShift-Regel; eine easter-Regel ignoriert ihn.", + "Non-working-date rules": "Regeln für arbeitsfreie Tage", + "Notify roles": "Zu benachrichtigende Rollen", + "Observed shift": "Verschobener Feiertag", + "Offset": "Abstand", + "Offset unit": "Einheit des Abstands", + "Open incident": "Vorfall eröffnen", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optionale Zuordnung eines Rollennamens zu einem konkreten Empfänger, z. B. {\"teamleader\": \"group:teamleaders\"}. Rollen ohne Zuordnung reisen unaufgelöst als role: weiter.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional bei einer fixed-Regel, verpflichtend bei observedShift: {whenWeekday: Name des Wochentags oder ISO 1..7, days: ganze Zahl}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Geordnete Stufen: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorität", + "Role bindings": "Rollenzuordnungen", + "Roles the matter is escalated to at this rung.": "Rollen, an die auf dieser Stufe eskaliert wird.", + "Roles told at this rung; handler is the subject's own performer.": "Rollen, die auf dieser Stufe benachrichtigt werden; handler ist die Bearbeiterin oder der Bearbeiter des Vorgangs.", + "Rungs": "Stufen", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Feste Kennung der Stufe, z. B. preBreach:14:calendarDays; fehlt sie, wird sie aus Auslöser, Abstand und Einheit abgeleitet.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Der feste Name des Kalenders, auf den ein Timer über calendar_slug verweist.", + "The closed date, Y-m-d.": "Der geschlossene Tag, Y-m-d.", + "The holiday's name.": "Der Name des Feiertags.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Der feste Name der Leiter, auf die ein Timer über ladder_slug verweist.", + "The unit of the offset: the same set the SLA accepts.": "Die Einheit des Abstands: dieselben Werte, die das SLA akzeptiert.", + "The urgency the raised transition carries.": "Die Dringlichkeit, die der ausgelöste Übergang trägt.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Der Wochentag (Name), an dem das Datum verschoben wird, z. B. sunday für Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID der Organisation, für die dieser Kalender eingerichtet ist. Leer bei einem gemeinsamen Kalender. Nennt der Timer keinen Kalender, wählt die Auflösungsreihenfolge den Kalender der Organisation.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID der Organisation, für die diese Leiter der Standard ist. Leer beim gemeinsamen Standard.", + "When weekday": "Bei Wochentag", + "Whether reaching this rung opens an incident.": "Ob das Erreichen dieser Stufe einen Vorfall eröffnet.", + "Why the day is closed.": "Warum dieser Tag geschlossen ist.", + "Working calendar": "Werkkalender", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Arbeitsstunden eines Arbeitstags. Macht Stunden und Werktage vergleichbar.", + "Working weekdays": "Arbeitswochentage", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: Monat und Tag; easter: ein Abstand in Tagen ab Ostersonntag; observedShift: Monat und Tag mit vorgeschriebener Verschiebung.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach löst vor Ablauf der Frist aus, slaBreached bei oder nach Ablauf." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/el.js b/l10n/el.js index 4acdfbbd63..0d4c9de808 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Το Αντικείμενο διαγράφηκε με επιτυχία","Τα Αντικείμενα διαγράφηκαν με επιτυχία"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Εκκαθάριση {count} Αντικειμένου από τη βάση δεδομένων","Εκκαθάριση {count} Αντικειμένων από τη βάση δεδομένων"], "_Restore {count} object_::_Restore {count} objects_": ["Επαναφορά {count} Αντικειμένου","Επαναφορά {count} Αντικειμένων"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Αποκαταστάθηκε με επιτυχία {count} Αντικείμενο","Αποκαταστάθηκαν με επιτυχία {count} Αντικείμενα"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Αποκαταστάθηκε με επιτυχία {count} Αντικείμενο","Αποκαταστάθηκαν με επιτυχία {count} Αντικείμενα"], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index 5a21bf9a7f..243db2a706 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "Αποκαταστάθηκε με επιτυχία {count} Αντικείμενο", "Αποκαταστάθηκαν με επιτυχία {count} Αντικείμενα" - ] + ], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/en.js b/l10n/en.js index 042d84ba92..99d99ed5a0 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Object successfully deleted","Objects successfully deleted"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Purge {count} object from database","Purge {count} objects from database"], "_Restore {count} object_::_Restore {count} objects_": ["Restore {count} object","Restore {count} objects"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Successfully restored {count} object","Successfully restored {count} objects"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Successfully restored {count} object","Successfully restored {count} objects"], + "Working calendar": "Working calendar", + "Escalation ladder": "Escalation ladder", + "Working weekdays": "Working weekdays", + "Hours per working day": "Hours per working day", + "Non-working-date rules": "Non-working-date rules", + "Exception dates": "Exception dates", + "Rungs": "Rungs", + "Role bindings": "Role bindings", + "Kind": "Kind", + "Month": "Month", + "Day": "Day", + "Offset": "Offset", + "Observed shift": "Observed shift", + "When weekday": "When weekday", + "Days": "Days", + "Date": "Date", + "Offset unit": "Offset unit", + "Notify roles": "Notify roles", + "Escalate to roles": "Escalate to roles", + "Priority": "Priority", + "Message": "Message", + "Open incident": "Open incident", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "Human-readable title.": "Human-readable title.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "The holiday's name.": "The holiday's name.", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "Why the day is closed.": "Why the day is closed.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index ea7366b7ef..3b106d6174 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "Successfully restored {count} object", "Successfully restored {count} objects" - ] + ], + "Working calendar": "Working calendar", + "Escalation ladder": "Escalation ladder", + "Working weekdays": "Working weekdays", + "Hours per working day": "Hours per working day", + "Non-working-date rules": "Non-working-date rules", + "Exception dates": "Exception dates", + "Rungs": "Rungs", + "Role bindings": "Role bindings", + "Kind": "Kind", + "Month": "Month", + "Day": "Day", + "Offset": "Offset", + "Observed shift": "Observed shift", + "When weekday": "When weekday", + "Days": "Days", + "Date": "Date", + "Offset unit": "Offset unit", + "Notify roles": "Notify roles", + "Escalate to roles": "Escalate to roles", + "Priority": "Priority", + "Message": "Message", + "Open incident": "Open incident", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "Human-readable title.": "Human-readable title.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "The holiday's name.": "The holiday's name.", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "Why the day is closed.": "Why the day is closed.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/es.js b/l10n/es.js index 7c3dbfdd38..c026ca4f8e 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objeto eliminado correctamente","Objetos eliminados correctamente","Objetos eliminados correctamente"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Purgar {count} objeto de la base de datos","Purgar {count} objetos de la base de datos","Purgar {count} objetos de la base de datos"], "_Restore {count} object_::_Restore {count} objects_": ["Restaurar {count} objeto","Restaurar {count} objetos","Restaurar {count} objetos"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Se restauró {count} objeto correctamente","Se restauraron {count} objetos correctamente","Se restauraron {count} objetos correctamente"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Se restauró {count} objeto correctamente","Se restauraron {count} objetos correctamente","Se restauraron {count} objetos correctamente"], + "A message identity the notification subsystem resolves.": "Una identidad de mensaje que resuelve el sistema de notificaciones.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Reglas calculadas de días no laborables. Tipo fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tipo easter: {offset, name}, desplazamiento en días desde el Domingo de Pascua. Tipo observedShift: una fecha fija con traslado obligatorio.", + "Date": "Fecha", + "Day": "Día", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Día de una regla fixed u observedShift; una regla easter lo ignora.", + "Days": "Días", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Días desde el Domingo de Pascua para una regla easter; los demás tipos los ignoran.", + "Distance from the deadline, in the offset unit.": "Distancia al plazo, en la unidad del desplazamiento.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Cierres puntuales enumerados como {date, name}. Permitidos junto a reglas; rechazados como calendario completo.", + "Escalate to roles": "Roles de escalado", + "Escalation ladder": "Escalera de escalado", + "Exception dates": "Fechas de excepción", + "Hours per working day": "Horas por día laborable", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Cuántos días se traslada la fecha observada, p. ej. -1 para el sábado anterior.", + "Human-readable title.": "Título legible.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Días ISO de la semana (1 = lunes, 7 = domingo) que son laborables.", + "Kind": "Tipo", + "Message": "Mensaje", + "Month": "Mes", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mes de una regla fixed u observedShift; una regla easter lo ignora.", + "Non-working-date rules": "Reglas de días no laborables", + "Notify roles": "Roles a notificar", + "Observed shift": "Traslado del festivo", + "Offset": "Desplazamiento", + "Offset unit": "Unidad del desplazamiento", + "Open incident": "Abrir incidencia", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mapa opcional de un nombre de rol a un destinatario concreto, p. ej. {\"teamleader\": \"group:teamleaders\"}. Los roles sin asignación viajan sin resolver como role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opcional en una regla fixed, obligatorio en observedShift: {whenWeekday: nombre del día o ISO 1..7, days: entero}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Peldaños ordenados: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioridad", + "Role bindings": "Asignaciones de roles", + "Roles the matter is escalated to at this rung.": "Roles a los que se escala el asunto en este peldaño.", + "Roles told at this rung; handler is the subject's own performer.": "Roles avisados en este peldaño; handler es la persona encargada del asunto.", + "Rungs": "Peldaños", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identidad estable del peldaño, p. ej. preBreach:14:calendarDays; si falta, se deriva del disparador, el desplazamiento y la unidad.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "El nombre estable del calendario, referenciado por el calendar_slug de un temporizador.", + "The closed date, Y-m-d.": "La fecha de cierre, Y-m-d.", + "The holiday's name.": "El nombre del festivo.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "El nombre estable de la escalera, referenciado por el ladder_slug de un temporizador.", + "The unit of the offset: the same set the SLA accepts.": "La unidad del desplazamiento: el mismo conjunto que acepta el SLA.", + "The urgency the raised transition carries.": "La urgencia que lleva la transición disparada.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "El día de la semana (nombre) en que la fecha se traslada, p. ej. sunday para Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID de la organización para la que está configurado este calendario. Vacío para un calendario compartido. Si el temporizador no nombra ninguno, el orden de resolución elige el calendario de la organización.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID de la organización para la que esta escalera es la predeterminada. Vacío para el valor compartido.", + "When weekday": "Si cae en", + "Whether reaching this rung opens an incident.": "Si alcanzar este peldaño abre una incidencia.", + "Why the day is closed.": "Por qué está cerrado ese día.", + "Working calendar": "Calendario laboral", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Horas trabajadas en un día laborable. Hace comparables las horas y los días laborables.", + "Working weekdays": "Días laborables de la semana", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mes y día; easter: un desplazamiento en días desde el Domingo de Pascua; observedShift: mes y día con traslado obligatorio.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se dispara antes del plazo, slaBreached en el plazo o después." }, "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" ) diff --git a/l10n/es.json b/l10n/es.json index c054d39548..51877d0ef9 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -2801,7 +2801,59 @@ "Se restauró {count} objeto correctamente", "Se restauraron {count} objetos correctamente", "Se restauraron {count} objetos correctamente" - ] + ], + "A message identity the notification subsystem resolves.": "Una identidad de mensaje que resuelve el sistema de notificaciones.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Reglas calculadas de días no laborables. Tipo fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tipo easter: {offset, name}, desplazamiento en días desde el Domingo de Pascua. Tipo observedShift: una fecha fija con traslado obligatorio.", + "Date": "Fecha", + "Day": "Día", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Día de una regla fixed u observedShift; una regla easter lo ignora.", + "Days": "Días", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Días desde el Domingo de Pascua para una regla easter; los demás tipos los ignoran.", + "Distance from the deadline, in the offset unit.": "Distancia al plazo, en la unidad del desplazamiento.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Cierres puntuales enumerados como {date, name}. Permitidos junto a reglas; rechazados como calendario completo.", + "Escalate to roles": "Roles de escalado", + "Escalation ladder": "Escalera de escalado", + "Exception dates": "Fechas de excepción", + "Hours per working day": "Horas por día laborable", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Cuántos días se traslada la fecha observada, p. ej. -1 para el sábado anterior.", + "Human-readable title.": "Título legible.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Días ISO de la semana (1 = lunes, 7 = domingo) que son laborables.", + "Kind": "Tipo", + "Message": "Mensaje", + "Month": "Mes", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mes de una regla fixed u observedShift; una regla easter lo ignora.", + "Non-working-date rules": "Reglas de días no laborables", + "Notify roles": "Roles a notificar", + "Observed shift": "Traslado del festivo", + "Offset": "Desplazamiento", + "Offset unit": "Unidad del desplazamiento", + "Open incident": "Abrir incidencia", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mapa opcional de un nombre de rol a un destinatario concreto, p. ej. {\"teamleader\": \"group:teamleaders\"}. Los roles sin asignación viajan sin resolver como role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opcional en una regla fixed, obligatorio en observedShift: {whenWeekday: nombre del día o ISO 1..7, days: entero}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Peldaños ordenados: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioridad", + "Role bindings": "Asignaciones de roles", + "Roles the matter is escalated to at this rung.": "Roles a los que se escala el asunto en este peldaño.", + "Roles told at this rung; handler is the subject's own performer.": "Roles avisados en este peldaño; handler es la persona encargada del asunto.", + "Rungs": "Peldaños", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identidad estable del peldaño, p. ej. preBreach:14:calendarDays; si falta, se deriva del disparador, el desplazamiento y la unidad.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "El nombre estable del calendario, referenciado por el calendar_slug de un temporizador.", + "The closed date, Y-m-d.": "La fecha de cierre, Y-m-d.", + "The holiday's name.": "El nombre del festivo.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "El nombre estable de la escalera, referenciado por el ladder_slug de un temporizador.", + "The unit of the offset: the same set the SLA accepts.": "La unidad del desplazamiento: el mismo conjunto que acepta el SLA.", + "The urgency the raised transition carries.": "La urgencia que lleva la transición disparada.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "El día de la semana (nombre) en que la fecha se traslada, p. ej. sunday para Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID de la organización para la que está configurado este calendario. Vacío para un calendario compartido. Si el temporizador no nombra ninguno, el orden de resolución elige el calendario de la organización.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID de la organización para la que esta escalera es la predeterminada. Vacío para el valor compartido.", + "When weekday": "Si cae en", + "Whether reaching this rung opens an incident.": "Si alcanzar este peldaño abre una incidencia.", + "Why the day is closed.": "Por qué está cerrado ese día.", + "Working calendar": "Calendario laboral", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Horas trabajadas en un día laborable. Hace comparables las horas y los días laborables.", + "Working weekdays": "Días laborables de la semana", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mes y día; easter: un desplazamiento en días desde el Domingo de Pascua; observedShift: mes y día con traslado obligatorio.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se dispara antes del plazo, slaBreached en el plazo o después." }, "pluralForm": "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;", "plurals": { diff --git a/l10n/et.js b/l10n/et.js index 5e115f49eb..32644cd9cc 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt edukalt kustutatud","Objektid edukalt kustutatud"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Kustuta {count} objekt andmebaasist jäädavalt","Kustuta {count} objekti andmebaasist jäädavalt"], "_Restore {count} object_::_Restore {count} objects_": ["Taasta {count} objekt","Taasta {count} objekti"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objekt edukalt taastatud","{count} objekti edukalt taastatud"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objekt edukalt taastatud","{count} objekti edukalt taastatud"], + "A message identity the notification subsystem resolves.": "Sõnumi identiteet, mille teavitussüsteem lahendab.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Arvutatud puhkepäevade reeglid. Liik fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Liik easter: {offset, name}, nihe päevades ülestõusmispühade pühapäevast. Liik observedShift: kindel kuupäev kohustusliku ülekandega.", + "Date": "Kuupäev", + "Day": "Päev", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Reegli fixed või observedShift päev; reegel easter eirab seda.", + "Days": "Päevad", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Päevad ülestõusmispühade pühapäevast reegli easter jaoks; teised liigid eiravad neid.", + "Distance from the deadline, in the offset unit.": "Kaugus tähtajast nihke ühikus.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Ühekordsed sulgemised loeteluna {date, name}. Lubatud reeglite kõrval; keelatud terve kalendrina.", + "Escalate to roles": "Eskaleerimise rollid", + "Escalation ladder": "Eskaleerimisredel", + "Exception dates": "Erandkuupäevad", + "Hours per working day": "Tunde tööpäeva kohta", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Mitu päeva tähistatav kuupäev üle kandub, nt -1 eelmise laupäeva jaoks.", + "Human-readable title.": "Loetav pealkiri.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO nädalapäevad (1 = esmaspäev, 7 = pühapäev), mis on tööpäevad.", + "Kind": "Liik", + "Message": "Sõnum", + "Month": "Kuu", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Reegli fixed või observedShift kuu; reegel easter eirab seda.", + "Non-working-date rules": "Puhkepäevade reeglid", + "Notify roles": "Teavitatavad rollid", + "Observed shift": "Püha ülekandmine", + "Offset": "Nihe", + "Offset unit": "Nihke ühik", + "Open incident": "Ava intsident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Valikuline kaardistus rollinimest konkreetse saajani, nt {\"teamleader\": \"group:teamleaders\"}. Seoseta rollid liiguvad lahendamata kujul role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Valikuline reegli fixed puhul, kohustuslik observedShift puhul: {whenWeekday: päeva nimi või ISO 1..7, days: täisarv}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Järjestatud pulgad: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioriteet", + "Role bindings": "Rolliseosed", + "Roles the matter is escalated to at this rung.": "Rollid, kellele asi sellel pulgal eskaleeritakse.", + "Roles told at this rung; handler is the subject's own performer.": "Sellel pulgal teavitatavad rollid; handler on asja menetleja.", + "Rungs": "Pulgad", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Pulga püsiv identiteet, nt preBreach:14:calendarDays; puudumisel tuletatakse päästikust, nihkest ja ühikust.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Kalendri püsinimi, millele taimer viitab calendar_slug kaudu.", + "The closed date, Y-m-d.": "Suletud kuupäev, Y-m-d.", + "The holiday's name.": "Püha nimi.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Redeli püsinimi, millele taimer viitab ladder_slug kaudu.", + "The unit of the offset: the same set the SLA accepts.": "Nihke ühik: sama hulk, mida SLA aktsepteerib.", + "The urgency the raised transition carries.": "Käivitatud ülemineku kiireloomulisus.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Nädalapäev (nimi), mille korral kuupäev kandub üle, nt sunday Koningsdagi puhul.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "Organisatsiooni UUID, mille jaoks see kalender on seadistatud. Tühi jagatud kalendri puhul. Kui taimer ühtegi ei nimeta, valib lahendusjärjekord organisatsiooni kalendri.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "Organisatsiooni UUID, mille jaoks see redel on vaikimisi. Tühi jagatud vaikeväärtuse puhul.", + "When weekday": "Kui langeb päevale", + "Whether reaching this rung opens an incident.": "Kas selle pulga saavutamine avab intsidendi.", + "Why the day is closed.": "Miks see päev on suletud.", + "Working calendar": "Töökalender", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Töötunnid ühes tööpäevas. Muudab tunnid ja tööpäevad võrreldavaks.", + "Working weekdays": "Nädala tööpäevad", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: kuu ja päev; easter: nihe päevades ülestõusmispühade pühapäevast; observedShift: kuu ja päev kohustusliku ülekandega.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach käivitub enne tähtaega, slaBreached tähtajal või pärast seda." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 14a0fd06cd..23c4b47b50 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} objekt edukalt taastatud", "{count} objekti edukalt taastatud" - ] + ], + "A message identity the notification subsystem resolves.": "Sõnumi identiteet, mille teavitussüsteem lahendab.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Arvutatud puhkepäevade reeglid. Liik fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Liik easter: {offset, name}, nihe päevades ülestõusmispühade pühapäevast. Liik observedShift: kindel kuupäev kohustusliku ülekandega.", + "Date": "Kuupäev", + "Day": "Päev", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Reegli fixed või observedShift päev; reegel easter eirab seda.", + "Days": "Päevad", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Päevad ülestõusmispühade pühapäevast reegli easter jaoks; teised liigid eiravad neid.", + "Distance from the deadline, in the offset unit.": "Kaugus tähtajast nihke ühikus.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Ühekordsed sulgemised loeteluna {date, name}. Lubatud reeglite kõrval; keelatud terve kalendrina.", + "Escalate to roles": "Eskaleerimise rollid", + "Escalation ladder": "Eskaleerimisredel", + "Exception dates": "Erandkuupäevad", + "Hours per working day": "Tunde tööpäeva kohta", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Mitu päeva tähistatav kuupäev üle kandub, nt -1 eelmise laupäeva jaoks.", + "Human-readable title.": "Loetav pealkiri.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO nädalapäevad (1 = esmaspäev, 7 = pühapäev), mis on tööpäevad.", + "Kind": "Liik", + "Message": "Sõnum", + "Month": "Kuu", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Reegli fixed või observedShift kuu; reegel easter eirab seda.", + "Non-working-date rules": "Puhkepäevade reeglid", + "Notify roles": "Teavitatavad rollid", + "Observed shift": "Püha ülekandmine", + "Offset": "Nihe", + "Offset unit": "Nihke ühik", + "Open incident": "Ava intsident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Valikuline kaardistus rollinimest konkreetse saajani, nt {\"teamleader\": \"group:teamleaders\"}. Seoseta rollid liiguvad lahendamata kujul role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Valikuline reegli fixed puhul, kohustuslik observedShift puhul: {whenWeekday: päeva nimi või ISO 1..7, days: täisarv}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Järjestatud pulgad: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioriteet", + "Role bindings": "Rolliseosed", + "Roles the matter is escalated to at this rung.": "Rollid, kellele asi sellel pulgal eskaleeritakse.", + "Roles told at this rung; handler is the subject's own performer.": "Sellel pulgal teavitatavad rollid; handler on asja menetleja.", + "Rungs": "Pulgad", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Pulga püsiv identiteet, nt preBreach:14:calendarDays; puudumisel tuletatakse päästikust, nihkest ja ühikust.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Kalendri püsinimi, millele taimer viitab calendar_slug kaudu.", + "The closed date, Y-m-d.": "Suletud kuupäev, Y-m-d.", + "The holiday's name.": "Püha nimi.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Redeli püsinimi, millele taimer viitab ladder_slug kaudu.", + "The unit of the offset: the same set the SLA accepts.": "Nihke ühik: sama hulk, mida SLA aktsepteerib.", + "The urgency the raised transition carries.": "Käivitatud ülemineku kiireloomulisus.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Nädalapäev (nimi), mille korral kuupäev kandub üle, nt sunday Koningsdagi puhul.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "Organisatsiooni UUID, mille jaoks see kalender on seadistatud. Tühi jagatud kalendri puhul. Kui taimer ühtegi ei nimeta, valib lahendusjärjekord organisatsiooni kalendri.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "Organisatsiooni UUID, mille jaoks see redel on vaikimisi. Tühi jagatud vaikeväärtuse puhul.", + "When weekday": "Kui langeb päevale", + "Whether reaching this rung opens an incident.": "Kas selle pulga saavutamine avab intsidendi.", + "Why the day is closed.": "Miks see päev on suletud.", + "Working calendar": "Töökalender", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Töötunnid ühes tööpäevas. Muudab tunnid ja tööpäevad võrreldavaks.", + "Working weekdays": "Nädala tööpäevad", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: kuu ja päev; easter: nihe päevades ülestõusmispühade pühapäevast; observedShift: kuu ja päev kohustusliku ülekandega.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach käivitub enne tähtaega, slaBreached tähtajal või pärast seda." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/fi.js b/l10n/fi.js index ff4493ddf5..cdebd61ff5 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekti poistettu onnistuneesti","Objektit poistettu onnistuneesti"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Tyhjennä {count} objekti tietokannasta","Tyhjennä {count} objektia tietokannasta"], "_Restore {count} object_::_Restore {count} objects_": ["Palauta {count} objekti","Palauta {count} objektia"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Palautettiin onnistuneesti {count} objekti","Palautettiin onnistuneesti {count} objektia"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Palautettiin onnistuneesti {count} objekti","Palautettiin onnistuneesti {count} objektia"], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 550c749be7..4c703e6ca1 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "Palautettiin onnistuneesti {count} objekti", "Palautettiin onnistuneesti {count} objektia" - ] + ], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/fr.js b/l10n/fr.js index 4319250504..23bacb18c5 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objet supprimé avec succès","Objets supprimés avec succès","Objets supprimés avec succès"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Purger {count} objet de la base de données","Purger {count} objets de la base de données","Purger {count} objets de la base de données"], "_Restore {count} object_::_Restore {count} objects_": ["Restaurer {count} objet","Restaurer {count} objets","Restaurer {count} objets"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objet restauré avec succès","{count} objets restaurés avec succès","{count} objets restaurés avec succès"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objet restauré avec succès","{count} objets restaurés avec succès","{count} objets restaurés avec succès"], + "A message identity the notification subsystem resolves.": "Une identité de message que le système de notification résout.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Règles calculées des jours chômés. Type fixed : {month, day, name, observedShift?: {whenWeekday, days}}. Type easter : {offset, name}, décalage en jours depuis le dimanche de Pâques. Type observedShift : une date fixe avec report obligatoire.", + "Date": "Date", + "Day": "Jour", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Jour d'une règle fixed ou observedShift ; ignoré par une règle easter.", + "Days": "Jours", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Jours depuis le dimanche de Pâques pour une règle easter ; ignorés par les autres types.", + "Distance from the deadline, in the offset unit.": "Distance à l'échéance, dans l'unité du décalage.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Fermetures ponctuelles énumérées sous la forme {date, name}. Admises à côté des règles ; refusées comme calendrier entier.", + "Escalate to roles": "Rôles d'escalade", + "Escalation ladder": "Échelle d'escalade", + "Exception dates": "Dates d'exception", + "Hours per working day": "Heures par jour ouvré", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "De combien de jours la date observée est reportée, p. ex. -1 pour le samedi précédent.", + "Human-readable title.": "Titre lisible.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Jours ISO de la semaine (1 = lundi, 7 = dimanche) qui sont ouvrés.", + "Kind": "Type", + "Message": "Message", + "Month": "Mois", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mois d'une règle fixed ou observedShift ; ignoré par une règle easter.", + "Non-working-date rules": "Règles des jours chômés", + "Notify roles": "Rôles à notifier", + "Observed shift": "Report du jour férié", + "Offset": "Décalage", + "Offset unit": "Unité du décalage", + "Open incident": "Ouvrir un incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Table facultative reliant un nom de rôle à un destinataire concret, p. ex. {\"teamleader\": \"group:teamleaders\"}. Les rôles sans liaison voyagent non résolus sous la forme role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Facultatif sur une règle fixed, obligatoire sur observedShift : {whenWeekday : nom du jour ou ISO 1..7, days : entier}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Échelons ordonnés : {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorité", + "Role bindings": "Liaisons de rôles", + "Roles the matter is escalated to at this rung.": "Rôles vers lesquels le dossier est escaladé à cet échelon.", + "Roles told at this rung; handler is the subject's own performer.": "Rôles notifiés à cet échelon ; handler est la personne en charge du dossier.", + "Rungs": "Échelons", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identité stable de l'échelon, p. ex. preBreach:14:calendarDays ; dérivée du déclencheur, du décalage et de l'unité si absente.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Le nom stable du calendrier, référencé par le calendar_slug d'un minuteur.", + "The closed date, Y-m-d.": "La date de fermeture, Y-m-d.", + "The holiday's name.": "Le nom du jour férié.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Le nom stable de l'échelle, référencé par le ladder_slug d'un minuteur.", + "The unit of the offset: the same set the SLA accepts.": "L'unité du décalage : le même ensemble que celui accepté par le SLA.", + "The urgency the raised transition carries.": "L'urgence portée par la transition déclenchée.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Le jour de la semaine (nom) qui déclenche le report, p. ex. sunday pour Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID de l'organisation pour laquelle ce calendrier est configuré. Vide pour un calendrier partagé. Si le minuteur n'en nomme aucun, l'ordre de résolution choisit le calendrier de l'organisation.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID de l'organisation dont cette échelle est l'échelle par défaut. Vide pour le défaut partagé.", + "When weekday": "Si le jour tombe un", + "Whether reaching this rung opens an incident.": "Si l'atteinte de cet échelon ouvre un incident.", + "Why the day is closed.": "Pourquoi ce jour est fermé.", + "Working calendar": "Calendrier de travail", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Heures travaillées dans un jour ouvré. Rend les heures et les jours ouvrés comparables.", + "Working weekdays": "Jours ouvrés de la semaine", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed : un mois et un jour ; easter : un décalage en jours depuis le dimanche de Pâques ; observedShift : un mois et un jour avec report obligatoire.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se déclenche avant l'échéance, slaBreached à l'échéance ou après." }, "nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" ) diff --git a/l10n/fr.json b/l10n/fr.json index 2cc0ce5970..3198680616 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -2801,7 +2801,59 @@ "{count} objet restauré avec succès", "{count} objets restaurés avec succès", "{count} objets restaurés avec succès" - ] + ], + "A message identity the notification subsystem resolves.": "Une identité de message que le système de notification résout.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Règles calculées des jours chômés. Type fixed : {month, day, name, observedShift?: {whenWeekday, days}}. Type easter : {offset, name}, décalage en jours depuis le dimanche de Pâques. Type observedShift : une date fixe avec report obligatoire.", + "Date": "Date", + "Day": "Jour", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Jour d'une règle fixed ou observedShift ; ignoré par une règle easter.", + "Days": "Jours", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Jours depuis le dimanche de Pâques pour une règle easter ; ignorés par les autres types.", + "Distance from the deadline, in the offset unit.": "Distance à l'échéance, dans l'unité du décalage.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Fermetures ponctuelles énumérées sous la forme {date, name}. Admises à côté des règles ; refusées comme calendrier entier.", + "Escalate to roles": "Rôles d'escalade", + "Escalation ladder": "Échelle d'escalade", + "Exception dates": "Dates d'exception", + "Hours per working day": "Heures par jour ouvré", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "De combien de jours la date observée est reportée, p. ex. -1 pour le samedi précédent.", + "Human-readable title.": "Titre lisible.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Jours ISO de la semaine (1 = lundi, 7 = dimanche) qui sont ouvrés.", + "Kind": "Type", + "Message": "Message", + "Month": "Mois", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mois d'une règle fixed ou observedShift ; ignoré par une règle easter.", + "Non-working-date rules": "Règles des jours chômés", + "Notify roles": "Rôles à notifier", + "Observed shift": "Report du jour férié", + "Offset": "Décalage", + "Offset unit": "Unité du décalage", + "Open incident": "Ouvrir un incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Table facultative reliant un nom de rôle à un destinataire concret, p. ex. {\"teamleader\": \"group:teamleaders\"}. Les rôles sans liaison voyagent non résolus sous la forme role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Facultatif sur une règle fixed, obligatoire sur observedShift : {whenWeekday : nom du jour ou ISO 1..7, days : entier}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Échelons ordonnés : {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorité", + "Role bindings": "Liaisons de rôles", + "Roles the matter is escalated to at this rung.": "Rôles vers lesquels le dossier est escaladé à cet échelon.", + "Roles told at this rung; handler is the subject's own performer.": "Rôles notifiés à cet échelon ; handler est la personne en charge du dossier.", + "Rungs": "Échelons", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identité stable de l'échelon, p. ex. preBreach:14:calendarDays ; dérivée du déclencheur, du décalage et de l'unité si absente.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Le nom stable du calendrier, référencé par le calendar_slug d'un minuteur.", + "The closed date, Y-m-d.": "La date de fermeture, Y-m-d.", + "The holiday's name.": "Le nom du jour férié.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Le nom stable de l'échelle, référencé par le ladder_slug d'un minuteur.", + "The unit of the offset: the same set the SLA accepts.": "L'unité du décalage : le même ensemble que celui accepté par le SLA.", + "The urgency the raised transition carries.": "L'urgence portée par la transition déclenchée.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Le jour de la semaine (nom) qui déclenche le report, p. ex. sunday pour Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID de l'organisation pour laquelle ce calendrier est configuré. Vide pour un calendrier partagé. Si le minuteur n'en nomme aucun, l'ordre de résolution choisit le calendrier de l'organisation.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID de l'organisation dont cette échelle est l'échelle par défaut. Vide pour le défaut partagé.", + "When weekday": "Si le jour tombe un", + "Whether reaching this rung opens an incident.": "Si l'atteinte de cet échelon ouvre un incident.", + "Why the day is closed.": "Pourquoi ce jour est fermé.", + "Working calendar": "Calendrier de travail", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Heures travaillées dans un jour ouvré. Rend les heures et les jours ouvrés comparables.", + "Working weekdays": "Jours ouvrés de la semaine", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed : un mois et un jour ; easter : un décalage en jours depuis le dimanche de Pâques ; observedShift : un mois et un jour avec report obligatoire.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se déclenche avant l'échéance, slaBreached à l'échéance ou après." }, "pluralForm": "nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;", "plurals": { diff --git a/l10n/ga.js b/l10n/ga.js index 6680f30cee..acfe0ca944 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Scriosadh an réad go rathúil","Scriosadh na réada go rathúil","Scriosadh na réada go rathúil","Scriosadh na réada go rathúil","Scriosadh na réada go rathúil"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Glan {count} réad ón mbunachar sonraí","Glan {count} réad ón mbunachar sonraí","Glan {count} réada ón mbunachar sonraí","Glan {count} réad ón mbunachar sonraí","Glan {count} réad ón mbunachar sonraí"], "_Restore {count} object_::_Restore {count} objects_": ["Athchóirigh {count} réad","Athchóirigh {count} réad","Athchóirigh {count} réada","Athchóirigh {count} réad","Athchóirigh {count} réad"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Athchóiríodh {count} réad go rathúil","Athchóiríodh {count} réad go rathúil","Athchóiríodh {count} réada go rathúil","Athchóiríodh {count} réad go rathúil","Athchóiríodh {count} réad go rathúil"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Athchóiríodh {count} réad go rathúil","Athchóiríodh {count} réad go rathúil","Athchóiríodh {count} réada go rathúil","Athchóiríodh {count} réad go rathúil","Athchóiríodh {count} réad go rathúil"], + "A message identity the notification subsystem resolves.": "Aitheantas teachtaireachta a réitíonn an fochóras fógraí.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Rialacha ríofa na laethanta neamhoibre. Cineál fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Cineál easter: {offset, name}, fritháireamh i laethanta ó Dhomhnach Cásca. Cineál observedShift: dáta seasta le haistriú éigeantach.", + "Date": "Dáta", + "Day": "Lá", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Lá rialach fixed nó observedShift; déanann riail easter neamhaird de.", + "Days": "Laethanta", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Laethanta ó Dhomhnach Cásca do riail easter; déanann na cineálacha eile neamhaird díobh.", + "Distance from the deadline, in the offset unit.": "Fad ón spriocdháta, in aonad an fhritháirimh.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Dúntaí aonuaire liostaithe mar {date, name}. Ceadaithe in éineacht le rialacha; diúltaithe mar an féilire iomlán.", + "Escalate to roles": "Róil ghéaraithe", + "Escalation ladder": "Dréimire géaraithe", + "Exception dates": "Dátaí eisceachta", + "Hours per working day": "Uaireanta in aghaidh an lae oibre", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Cé mhéad lá a aistrítear an dáta ceiliúrtha, m.sh. -1 don Satharn roimhe.", + "Human-readable title.": "Teideal inléite.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Laethanta ISO na seachtaine (1 = Luan, 7 = Domhnach) ar laethanta oibre iad.", + "Kind": "Cineál", + "Message": "Teachtaireacht", + "Month": "Mí", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mí rialach fixed nó observedShift; déanann riail easter neamhaird de.", + "Non-working-date rules": "Rialacha na laethanta neamhoibre", + "Notify roles": "Róil le cur ar an eolas", + "Observed shift": "Aistriú na saoire", + "Offset": "Fritháireamh", + "Offset unit": "Aonad an fhritháirimh", + "Open incident": "Oscail teagmhas", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mapa roghnach ó ainm róil go faighteoir nithiúil, m.sh. {\"teamleader\": \"group:teamleaders\"}. Taistealaíonn róil gan cheangal gan réiteach mar role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Roghnach ar riail fixed, éigeantach ar observedShift: {whenWeekday: ainm an lae nó ISO 1..7, days: slánuimhir}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Runganna in ord: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Tosaíocht", + "Role bindings": "Ceangail ról", + "Roles the matter is escalated to at this rung.": "Róil a ngéaraítear an cás chucu ag an runga seo.", + "Roles told at this rung; handler is the subject's own performer.": "Róil a chuirtear ar an eolas ag an runga seo; is é handler an duine atá i mbun an cháis.", + "Rungs": "Runganna", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Aitheantas seasta an runga, m.sh. preBreach:14:calendarDays; nuair a bhíonn sé ar iarraidh, díorthaítear é ón truicear, ón bhfritháireamh agus ón aonad.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Ainm seasta an fhéilire, a dtagraíonn amadóir dó trí calendar_slug.", + "The closed date, Y-m-d.": "Dáta an dúnta, Y-m-d.", + "The holiday's name.": "Ainm na saoire.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Ainm seasta an dréimire, a dtagraíonn amadóir dó trí ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Aonad an fhritháirimh: an tacar céanna a nglacann an SLA leis.", + "The urgency the raised transition carries.": "An phráinn a iompraíonn an t-aistriú a lasadh.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Lá na seachtaine (ainm) a n-aistrítear an dáta air, m.sh. sunday do Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID na heagraíochta a bhfuil an féilire seo cumraithe di. Folamh i gcás féilire comhroinnte. Mura n-ainmníonn an t-amadóir ceann ar bith, roghnaíonn an t-ord réitigh féilire na heagraíochta.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID na heagraíochta a bhfuil an dréimire seo réamhshocraithe di. Folamh don réamhshocrú comhroinnte.", + "When weekday": "Má thiteann sé ar", + "Whether reaching this rung opens an incident.": "Cé acu an osclaíonn an runga seo a bhaint amach teagmhas nó nach n-osclaíonn.", + "Why the day is closed.": "Cén fáth a bhfuil an lá dúnta.", + "Working calendar": "Féilire oibre", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Uaireanta oibre in aon lá oibre amháin. Déanann sé uaireanta agus laethanta oibre inchomparáide.", + "Working weekdays": "Laethanta oibre na seachtaine", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mí agus lá; easter: fritháireamh i laethanta ó Dhomhnach Cásca; observedShift: mí agus lá le haistriú éigeantach.", + "preBreach fires before the deadline, slaBreached at or after it.": "Lasann preBreach roimh an spriocdháta, slaBreached ar an spriocdháta nó ina dhiaidh." }, "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4);" ) diff --git a/l10n/ga.json b/l10n/ga.json index 03331ff25b..6df9979435 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -2833,7 +2833,59 @@ "Athchóiríodh {count} réada go rathúil", "Athchóiríodh {count} réad go rathúil", "Athchóiríodh {count} réad go rathúil" - ] + ], + "A message identity the notification subsystem resolves.": "Aitheantas teachtaireachta a réitíonn an fochóras fógraí.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Rialacha ríofa na laethanta neamhoibre. Cineál fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Cineál easter: {offset, name}, fritháireamh i laethanta ó Dhomhnach Cásca. Cineál observedShift: dáta seasta le haistriú éigeantach.", + "Date": "Dáta", + "Day": "Lá", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Lá rialach fixed nó observedShift; déanann riail easter neamhaird de.", + "Days": "Laethanta", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Laethanta ó Dhomhnach Cásca do riail easter; déanann na cineálacha eile neamhaird díobh.", + "Distance from the deadline, in the offset unit.": "Fad ón spriocdháta, in aonad an fhritháirimh.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Dúntaí aonuaire liostaithe mar {date, name}. Ceadaithe in éineacht le rialacha; diúltaithe mar an féilire iomlán.", + "Escalate to roles": "Róil ghéaraithe", + "Escalation ladder": "Dréimire géaraithe", + "Exception dates": "Dátaí eisceachta", + "Hours per working day": "Uaireanta in aghaidh an lae oibre", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Cé mhéad lá a aistrítear an dáta ceiliúrtha, m.sh. -1 don Satharn roimhe.", + "Human-readable title.": "Teideal inléite.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Laethanta ISO na seachtaine (1 = Luan, 7 = Domhnach) ar laethanta oibre iad.", + "Kind": "Cineál", + "Message": "Teachtaireacht", + "Month": "Mí", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mí rialach fixed nó observedShift; déanann riail easter neamhaird de.", + "Non-working-date rules": "Rialacha na laethanta neamhoibre", + "Notify roles": "Róil le cur ar an eolas", + "Observed shift": "Aistriú na saoire", + "Offset": "Fritháireamh", + "Offset unit": "Aonad an fhritháirimh", + "Open incident": "Oscail teagmhas", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mapa roghnach ó ainm róil go faighteoir nithiúil, m.sh. {\"teamleader\": \"group:teamleaders\"}. Taistealaíonn róil gan cheangal gan réiteach mar role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Roghnach ar riail fixed, éigeantach ar observedShift: {whenWeekday: ainm an lae nó ISO 1..7, days: slánuimhir}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Runganna in ord: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Tosaíocht", + "Role bindings": "Ceangail ról", + "Roles the matter is escalated to at this rung.": "Róil a ngéaraítear an cás chucu ag an runga seo.", + "Roles told at this rung; handler is the subject's own performer.": "Róil a chuirtear ar an eolas ag an runga seo; is é handler an duine atá i mbun an cháis.", + "Rungs": "Runganna", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Aitheantas seasta an runga, m.sh. preBreach:14:calendarDays; nuair a bhíonn sé ar iarraidh, díorthaítear é ón truicear, ón bhfritháireamh agus ón aonad.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Ainm seasta an fhéilire, a dtagraíonn amadóir dó trí calendar_slug.", + "The closed date, Y-m-d.": "Dáta an dúnta, Y-m-d.", + "The holiday's name.": "Ainm na saoire.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Ainm seasta an dréimire, a dtagraíonn amadóir dó trí ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Aonad an fhritháirimh: an tacar céanna a nglacann an SLA leis.", + "The urgency the raised transition carries.": "An phráinn a iompraíonn an t-aistriú a lasadh.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Lá na seachtaine (ainm) a n-aistrítear an dáta air, m.sh. sunday do Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID na heagraíochta a bhfuil an féilire seo cumraithe di. Folamh i gcás féilire comhroinnte. Mura n-ainmníonn an t-amadóir ceann ar bith, roghnaíonn an t-ord réitigh féilire na heagraíochta.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID na heagraíochta a bhfuil an dréimire seo réamhshocraithe di. Folamh don réamhshocrú comhroinnte.", + "When weekday": "Má thiteann sé ar", + "Whether reaching this rung opens an incident.": "Cé acu an osclaíonn an runga seo a bhaint amach teagmhas nó nach n-osclaíonn.", + "Why the day is closed.": "Cén fáth a bhfuil an lá dúnta.", + "Working calendar": "Féilire oibre", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Uaireanta oibre in aon lá oibre amháin. Déanann sé uaireanta agus laethanta oibre inchomparáide.", + "Working weekdays": "Laethanta oibre na seachtaine", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mí agus lá; easter: fritháireamh i laethanta ó Dhomhnach Cásca; observedShift: mí agus lá le haistriú éigeantach.", + "preBreach fires before the deadline, slaBreached at or after it.": "Lasann preBreach roimh an spriocdháta, slaBreached ar an spriocdháta nó ina dhiaidh." }, "pluralForm": "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4);", "plurals": { diff --git a/l10n/hr.js b/l10n/hr.js index dbd3d5987b..6f1ba3f90d 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt je uspješno izbrisan","Objekti su uspješno izbrisani","Objekti su uspješno izbrisani"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Trajno ukloni {count} objekt iz baze podataka","Trajno ukloni {count} objekta iz baze podataka","Trajno ukloni {count} objekata iz baze podataka"], "_Restore {count} object_::_Restore {count} objects_": ["Vrati {count} objekt","Vrati {count} objekta","Vrati {count} objekata"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Uspješno vraćen {count} objekt","Uspješno vraćena {count} objekta","Uspješno vraćeno {count} objekata"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Uspješno vraćen {count} objekt","Uspješno vraćena {count} objekta","Uspješno vraćeno {count} objekata"], + "A message identity the notification subsystem resolves.": "Identitet poruke koji razrješava sustav obavijesti.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Izračunata pravila neradnih dana. Vrsta fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Vrsta easter: {offset, name}, odmak u danima od Uskrsne nedjelje. Vrsta observedShift: nepomičan datum s obveznim premještanjem.", + "Date": "Datum", + "Day": "Dan", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dan pravila fixed ili observedShift; pravilo easter ga zanemaruje.", + "Days": "Dani", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dani od Uskrsne nedjelje za pravilo easter; druge ih vrste zanemaruju.", + "Distance from the deadline, in the offset unit.": "Udaljenost od roka u jedinici odmaka.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Pojedinačna zatvaranja nabrojena kao {date, name}. Dopuštena uz pravila; odbijena kao cijeli kalendar.", + "Escalate to roles": "Uloge za eskalaciju", + "Escalation ladder": "Ljestvica eskalacije", + "Exception dates": "Iznimni datumi", + "Hours per working day": "Sati po radnom danu", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Za koliko se dana slavljeni datum premješta, npr. -1 za subotu prije.", + "Human-readable title.": "Čitljiv naslov.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dani u tjednu (1 = ponedjeljak, 7 = nedjelja) koji su radni.", + "Kind": "Vrsta", + "Message": "Poruka", + "Month": "Mjesec", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mjesec pravila fixed ili observedShift; pravilo easter ga zanemaruje.", + "Non-working-date rules": "Pravila neradnih dana", + "Notify roles": "Uloge za obavijest", + "Observed shift": "Premještanje blagdana", + "Offset": "Odmak", + "Offset unit": "Jedinica odmaka", + "Open incident": "Otvori incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neobvezna mapa imena uloge na konkretnog primatelja, npr. {\"teamleader\": \"group:teamleaders\"}. Uloge bez poveznice putuju nerazriješene kao role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neobvezno kod pravila fixed, obvezno kod observedShift: {whenWeekday: ime dana ili ISO 1..7, days: cijeli broj}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Poredane prečke: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritet", + "Role bindings": "Poveznice uloga", + "Roles the matter is escalated to at this rung.": "Uloge na koje se predmet na ovoj prečki eskalira.", + "Roles told at this rung; handler is the subject's own performer.": "Uloge obaviještene na ovoj prečki; handler je osoba koja rješava predmet.", + "Rungs": "Prečke", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stalni identitet prečke, npr. preBreach:14:calendarDays; ako nedostaje, izvodi se iz okidača, odmaka i jedinice.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stalno ime kalendara na koje se tajmer poziva putem calendar_slug.", + "The closed date, Y-m-d.": "Datum zatvaranja, Y-m-d.", + "The holiday's name.": "Ime blagdana.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stalno ime ljestvice na koje se tajmer poziva putem ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Jedinica odmaka: isti skup koji SLA prihvaća.", + "The urgency the raised transition carries.": "Hitnost koju nosi pokrenuti prijelaz.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Dan u tjednu (ime) na koji se datum premješta, npr. sunday za Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizacije za koju je ovaj kalendar postavljen. Prazno za dijeljeni kalendar. Ne imenuje li tajmer nijedan, redoslijed razrješavanja bira kalendar organizacije.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizacije kojoj je ova ljestvica zadana. Prazno za dijeljenu zadanu.", + "When weekday": "Padne li na", + "Whether reaching this rung opens an incident.": "Otvara li dosezanje ove prečke incident.", + "Why the day is closed.": "Zašto je dan zatvoren.", + "Working calendar": "Radni kalendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Radni sati u jednom radnom danu. Čini sate i radne dane usporedivima.", + "Working weekdays": "Radni dani u tjednu", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mjesec i dan; easter: odmak u danima od Uskrsne nedjelje; observedShift: mjesec i dan s obveznim premještanjem.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se okida prije roka, slaBreached na rok ili poslije njega." }, "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;" ) diff --git a/l10n/hr.json b/l10n/hr.json index 86f991d6e1..94b3dd4b31 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -2801,7 +2801,59 @@ "Uspješno vraćen {count} objekt", "Uspješno vraćena {count} objekta", "Uspješno vraćeno {count} objekata" - ] + ], + "A message identity the notification subsystem resolves.": "Identitet poruke koji razrješava sustav obavijesti.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Izračunata pravila neradnih dana. Vrsta fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Vrsta easter: {offset, name}, odmak u danima od Uskrsne nedjelje. Vrsta observedShift: nepomičan datum s obveznim premještanjem.", + "Date": "Datum", + "Day": "Dan", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dan pravila fixed ili observedShift; pravilo easter ga zanemaruje.", + "Days": "Dani", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dani od Uskrsne nedjelje za pravilo easter; druge ih vrste zanemaruju.", + "Distance from the deadline, in the offset unit.": "Udaljenost od roka u jedinici odmaka.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Pojedinačna zatvaranja nabrojena kao {date, name}. Dopuštena uz pravila; odbijena kao cijeli kalendar.", + "Escalate to roles": "Uloge za eskalaciju", + "Escalation ladder": "Ljestvica eskalacije", + "Exception dates": "Iznimni datumi", + "Hours per working day": "Sati po radnom danu", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Za koliko se dana slavljeni datum premješta, npr. -1 za subotu prije.", + "Human-readable title.": "Čitljiv naslov.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dani u tjednu (1 = ponedjeljak, 7 = nedjelja) koji su radni.", + "Kind": "Vrsta", + "Message": "Poruka", + "Month": "Mjesec", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mjesec pravila fixed ili observedShift; pravilo easter ga zanemaruje.", + "Non-working-date rules": "Pravila neradnih dana", + "Notify roles": "Uloge za obavijest", + "Observed shift": "Premještanje blagdana", + "Offset": "Odmak", + "Offset unit": "Jedinica odmaka", + "Open incident": "Otvori incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neobvezna mapa imena uloge na konkretnog primatelja, npr. {\"teamleader\": \"group:teamleaders\"}. Uloge bez poveznice putuju nerazriješene kao role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neobvezno kod pravila fixed, obvezno kod observedShift: {whenWeekday: ime dana ili ISO 1..7, days: cijeli broj}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Poredane prečke: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritet", + "Role bindings": "Poveznice uloga", + "Roles the matter is escalated to at this rung.": "Uloge na koje se predmet na ovoj prečki eskalira.", + "Roles told at this rung; handler is the subject's own performer.": "Uloge obaviještene na ovoj prečki; handler je osoba koja rješava predmet.", + "Rungs": "Prečke", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stalni identitet prečke, npr. preBreach:14:calendarDays; ako nedostaje, izvodi se iz okidača, odmaka i jedinice.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stalno ime kalendara na koje se tajmer poziva putem calendar_slug.", + "The closed date, Y-m-d.": "Datum zatvaranja, Y-m-d.", + "The holiday's name.": "Ime blagdana.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stalno ime ljestvice na koje se tajmer poziva putem ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Jedinica odmaka: isti skup koji SLA prihvaća.", + "The urgency the raised transition carries.": "Hitnost koju nosi pokrenuti prijelaz.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Dan u tjednu (ime) na koji se datum premješta, npr. sunday za Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizacije za koju je ovaj kalendar postavljen. Prazno za dijeljeni kalendar. Ne imenuje li tajmer nijedan, redoslijed razrješavanja bira kalendar organizacije.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizacije kojoj je ova ljestvica zadana. Prazno za dijeljenu zadanu.", + "When weekday": "Padne li na", + "Whether reaching this rung opens an incident.": "Otvara li dosezanje ove prečke incident.", + "Why the day is closed.": "Zašto je dan zatvoren.", + "Working calendar": "Radni kalendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Radni sati u jednom radnom danu. Čini sate i radne dane usporedivima.", + "Working weekdays": "Radni dani u tjednu", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mjesec i dan; easter: odmak u danima od Uskrsne nedjelje; observedShift: mjesec i dan s obveznim premještanjem.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se okida prije roka, slaBreached na rok ili poslije njega." }, "pluralForm": "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;", "plurals": { diff --git a/l10n/hu.js b/l10n/hu.js index 61aaa83545..881a7d85f6 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Az objektum sikeresen törölve","Az objektumok sikeresen törölve"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["{count} objektum végleges törlése az adatbázisból","{count} objektum végleges törlése az adatbázisból"], "_Restore {count} object_::_Restore {count} objects_": ["{count} objektum visszaállítása","{count} objektum visszaállítása"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objektum sikeresen visszaállítva","{count} objektum sikeresen visszaállítva"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objektum sikeresen visszaállítva","{count} objektum sikeresen visszaállítva"], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index f19f05a909..27df76e343 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} objektum sikeresen visszaállítva", "{count} objektum sikeresen visszaállítva" - ] + ], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/is.js b/l10n/is.js index 6a0deec75d..780cd5c4fd 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Hlutur eytt","Hlutir eyddir"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Hreinsa {count} hlut úr gagnagrunni","Hreinsa {count} hluti úr gagnagrunni"], "_Restore {count} object_::_Restore {count} objects_": ["Endurheimta {count} hlut","Endurheimta {count} hluti"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} hlutur endurheimtur","{count} hlutir endurheimtir"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} hlutur endurheimtur","{count} hlutir endurheimtir"], + "A message identity the notification subsystem resolves.": "Auðkenni skilaboða sem tilkynningakerfið leysir úr.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Reiknaðar reglur um frídaga. Tegund fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tegund easter: {offset, name}, hliðrun í dögum frá páskadegi. Tegund observedShift: föst dagsetning með skyldufærslu.", + "Date": "Dagsetning", + "Day": "Dagur", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dagur reglu af tegund fixed eða observedShift; easter-regla hunsar hann.", + "Days": "Dagar", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dagar frá páskadegi fyrir easter-reglu; aðrar tegundir hunsa þá.", + "Distance from the deadline, in the offset unit.": "Fjarlægð frá frestinum í einingu hliðrunarinnar.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Stakar lokanir taldar upp sem {date, name}. Leyfðar með reglum; hafnað sem allt dagatalið.", + "Escalate to roles": "Hlutverk stigmögnunar", + "Escalation ladder": "Stigmögnunarstigi", + "Exception dates": "Undantekningardagsetningar", + "Hours per working day": "Klukkustundir á virkan dag", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Hve marga daga hátíðardagurinn færist, t.d. -1 fyrir laugardaginn á undan.", + "Human-readable title.": "Læsilegur titill.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO vikudagar (1 = mánudagur, 7 = sunnudagur) sem eru virkir dagar.", + "Kind": "Tegund", + "Message": "Skilaboð", + "Month": "Mánuður", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mánuður reglu af tegund fixed eða observedShift; easter-regla hunsar hann.", + "Non-working-date rules": "Reglur um frídaga", + "Notify roles": "Hlutverk til að láta vita", + "Observed shift": "Færsla frídags", + "Offset": "Hliðrun", + "Offset unit": "Eining hliðrunar", + "Open incident": "Opna atvik", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Valkvæð vörpun frá heiti hlutverks í ákveðinn viðtakanda, t.d. {\"teamleader\": \"group:teamleaders\"}. Hlutverk án tengingar ferðast óleyst sem role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Valkvætt í fixed-reglu, skylda í observedShift: {whenWeekday: heiti dags eða ISO 1..7, days: heiltala}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Röðuð þrep: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Forgangur", + "Role bindings": "Hlutverkatengingar", + "Roles the matter is escalated to at this rung.": "Hlutverk sem málið er stigmagnað til á þessu þrepi.", + "Roles told at this rung; handler is the subject's own performer.": "Hlutverk sem fá tilkynningu á þessu þrepi; handler er sá sem fer með málið.", + "Rungs": "Þrep", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Fast auðkenni þrepsins, t.d. preBreach:14:calendarDays; vanti það er það leitt af gikk, hliðrun og einingu.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Fast heiti dagatalsins sem tímamælir vísar til með calendar_slug.", + "The closed date, Y-m-d.": "Lokunardagsetningin, Y-m-d.", + "The holiday's name.": "Heiti frídagsins.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Fast heiti stigans sem tímamælir vísar til með ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Eining hliðrunarinnar: sama mengi og SLA samþykkir.", + "The urgency the raised transition carries.": "Brýnin sem umskiptin sem fara af stað bera.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Vikudagurinn (heiti) sem dagsetningin færist á, t.d. sunday fyrir Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID stofnunarinnar sem þetta dagatal er stillt fyrir. Tómt fyrir sameiginlegt dagatal. Ef tímamælirinn nefnir ekkert velur úrlausnarröðin dagatal stofnunarinnar.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID stofnunarinnar sem þessi stigi er sjálfgefinn fyrir. Tómt fyrir sameiginlega sjálfgefna stigann.", + "When weekday": "Ef ber upp á", + "Whether reaching this rung opens an incident.": "Hvort það að ná þessu þrepi opni atvik.", + "Why the day is closed.": "Hvers vegna dagurinn er lokaður.", + "Working calendar": "Vinnudagatal", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Vinnustundir á einum virkum degi. Gerir klukkustundir og virka daga samanburðarhæfa.", + "Working weekdays": "Virkir dagar vikunnar", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mánuður og dagur; easter: hliðrun í dögum frá páskadegi; observedShift: mánuður og dagur með skyldufærslu.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fer af stað fyrir frestinn, slaBreached á fresti eða eftir hann." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 47676bf7ad..54f2944ece 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} hlutur endurheimtur", "{count} hlutir endurheimtir" - ] + ], + "A message identity the notification subsystem resolves.": "Auðkenni skilaboða sem tilkynningakerfið leysir úr.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Reiknaðar reglur um frídaga. Tegund fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tegund easter: {offset, name}, hliðrun í dögum frá páskadegi. Tegund observedShift: föst dagsetning með skyldufærslu.", + "Date": "Dagsetning", + "Day": "Dagur", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dagur reglu af tegund fixed eða observedShift; easter-regla hunsar hann.", + "Days": "Dagar", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dagar frá páskadegi fyrir easter-reglu; aðrar tegundir hunsa þá.", + "Distance from the deadline, in the offset unit.": "Fjarlægð frá frestinum í einingu hliðrunarinnar.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Stakar lokanir taldar upp sem {date, name}. Leyfðar með reglum; hafnað sem allt dagatalið.", + "Escalate to roles": "Hlutverk stigmögnunar", + "Escalation ladder": "Stigmögnunarstigi", + "Exception dates": "Undantekningardagsetningar", + "Hours per working day": "Klukkustundir á virkan dag", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Hve marga daga hátíðardagurinn færist, t.d. -1 fyrir laugardaginn á undan.", + "Human-readable title.": "Læsilegur titill.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO vikudagar (1 = mánudagur, 7 = sunnudagur) sem eru virkir dagar.", + "Kind": "Tegund", + "Message": "Skilaboð", + "Month": "Mánuður", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mánuður reglu af tegund fixed eða observedShift; easter-regla hunsar hann.", + "Non-working-date rules": "Reglur um frídaga", + "Notify roles": "Hlutverk til að láta vita", + "Observed shift": "Færsla frídags", + "Offset": "Hliðrun", + "Offset unit": "Eining hliðrunar", + "Open incident": "Opna atvik", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Valkvæð vörpun frá heiti hlutverks í ákveðinn viðtakanda, t.d. {\"teamleader\": \"group:teamleaders\"}. Hlutverk án tengingar ferðast óleyst sem role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Valkvætt í fixed-reglu, skylda í observedShift: {whenWeekday: heiti dags eða ISO 1..7, days: heiltala}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Röðuð þrep: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Forgangur", + "Role bindings": "Hlutverkatengingar", + "Roles the matter is escalated to at this rung.": "Hlutverk sem málið er stigmagnað til á þessu þrepi.", + "Roles told at this rung; handler is the subject's own performer.": "Hlutverk sem fá tilkynningu á þessu þrepi; handler er sá sem fer með málið.", + "Rungs": "Þrep", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Fast auðkenni þrepsins, t.d. preBreach:14:calendarDays; vanti það er það leitt af gikk, hliðrun og einingu.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Fast heiti dagatalsins sem tímamælir vísar til með calendar_slug.", + "The closed date, Y-m-d.": "Lokunardagsetningin, Y-m-d.", + "The holiday's name.": "Heiti frídagsins.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Fast heiti stigans sem tímamælir vísar til með ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Eining hliðrunarinnar: sama mengi og SLA samþykkir.", + "The urgency the raised transition carries.": "Brýnin sem umskiptin sem fara af stað bera.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Vikudagurinn (heiti) sem dagsetningin færist á, t.d. sunday fyrir Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID stofnunarinnar sem þetta dagatal er stillt fyrir. Tómt fyrir sameiginlegt dagatal. Ef tímamælirinn nefnir ekkert velur úrlausnarröðin dagatal stofnunarinnar.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID stofnunarinnar sem þessi stigi er sjálfgefinn fyrir. Tómt fyrir sameiginlega sjálfgefna stigann.", + "When weekday": "Ef ber upp á", + "Whether reaching this rung opens an incident.": "Hvort það að ná þessu þrepi opni atvik.", + "Why the day is closed.": "Hvers vegna dagurinn er lokaður.", + "Working calendar": "Vinnudagatal", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Vinnustundir á einum virkum degi. Gerir klukkustundir og virka daga samanburðarhæfa.", + "Working weekdays": "Virkir dagar vikunnar", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mánuður og dagur; easter: hliðrun í dögum frá páskadegi; observedShift: mánuður og dagur með skyldufærslu.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fer af stað fyrir frestinn, slaBreached á fresti eða eftir hann." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/it.js b/l10n/it.js index 52da4e1e18..0d96748783 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Oggetto eliminato correttamente","Oggetti eliminati correttamente","Oggetti eliminati correttamente"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Elimina definitivamente {count} oggetto dal database","Elimina definitivamente {count} oggetti dal database","Elimina definitivamente {count} oggetti dal database"], "_Restore {count} object_::_Restore {count} objects_": ["Ripristina {count} oggetto","Ripristina {count} oggetti","Ripristina {count} oggetti"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Ripristinato correttamente {count} oggetto","Ripristinati correttamente {count} oggetti","Ripristinati correttamente {count} oggetti"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Ripristinato correttamente {count} oggetto","Ripristinati correttamente {count} oggetti","Ripristinati correttamente {count} oggetti"], + "A message identity the notification subsystem resolves.": "Un'identità di messaggio risolta dal sistema di notifica.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Regole calcolate dei giorni non lavorativi. Tipo fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tipo easter: {offset, name}, scostamento in giorni dalla domenica di Pasqua. Tipo observedShift: una data fissa con spostamento obbligatorio.", + "Date": "Data", + "Day": "Giorno", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Giorno di una regola fixed o observedShift; una regola easter lo ignora.", + "Days": "Giorni", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Giorni dalla domenica di Pasqua per una regola easter; gli altri tipi li ignorano.", + "Distance from the deadline, in the offset unit.": "Distanza dalla scadenza, nell'unità dello scostamento.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Chiusure singole elencate come {date, name}. Ammesse accanto alle regole; rifiutate come intero calendario.", + "Escalate to roles": "Ruoli di escalation", + "Escalation ladder": "Scala di escalation", + "Exception dates": "Date di eccezione", + "Hours per working day": "Ore per giorno lavorativo", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Di quanti giorni si sposta la data osservata, ad es. -1 per il sabato precedente.", + "Human-readable title.": "Titolo leggibile.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Giorni ISO della settimana (1 = lunedì, 7 = domenica) che sono lavorativi.", + "Kind": "Tipo", + "Message": "Messaggio", + "Month": "Mese", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mese di una regola fixed o observedShift; una regola easter lo ignora.", + "Non-working-date rules": "Regole dei giorni non lavorativi", + "Notify roles": "Ruoli da avvisare", + "Observed shift": "Spostamento della festività", + "Offset": "Scostamento", + "Offset unit": "Unità dello scostamento", + "Open incident": "Aprire un incidente", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mappa facoltativa da un nome di ruolo a un destinatario concreto, ad es. {\"teamleader\": \"group:teamleaders\"}. I ruoli senza associazione viaggiano irrisolti come role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Facoltativo in una regola fixed, obbligatorio in observedShift: {whenWeekday: nome del giorno o ISO 1..7, days: intero}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Gradini ordinati: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorità", + "Role bindings": "Associazioni dei ruoli", + "Roles the matter is escalated to at this rung.": "Ruoli a cui la pratica viene fatta risalire a questo gradino.", + "Roles told at this rung; handler is the subject's own performer.": "Ruoli avvisati a questo gradino; handler è la persona incaricata della pratica.", + "Rungs": "Gradini", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identità stabile del gradino, ad es. preBreach:14:calendarDays; se assente è derivata da innesco, scostamento e unità.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Il nome stabile del calendario, richiamato dal calendar_slug di un timer.", + "The closed date, Y-m-d.": "La data di chiusura, Y-m-d.", + "The holiday's name.": "Il nome della festività.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Il nome stabile della scala, richiamato dal ladder_slug di un timer.", + "The unit of the offset: the same set the SLA accepts.": "L'unità dello scostamento: lo stesso insieme accettato dallo SLA.", + "The urgency the raised transition carries.": "L'urgenza portata dalla transizione innescata.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Il giorno della settimana (nome) in cui la data viene spostata, ad es. sunday per Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID dell'organizzazione per cui questo calendario è configurato. Vuoto per un calendario condiviso. Se il timer non ne indica nessuno, l'ordine di risoluzione sceglie il calendario dell'organizzazione.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID dell'organizzazione per cui questa scala è la predefinita. Vuoto per il valore condiviso.", + "When weekday": "Se cade di", + "Whether reaching this rung opens an incident.": "Se il raggiungimento di questo gradino apre un incidente.", + "Why the day is closed.": "Perché quel giorno è chiuso.", + "Working calendar": "Calendario lavorativo", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Ore lavorate in un giorno lavorativo. Rende confrontabili ore e giorni lavorativi.", + "Working weekdays": "Giorni lavorativi della settimana", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mese e giorno; easter: uno scostamento in giorni dalla domenica di Pasqua; observedShift: mese e giorno con spostamento obbligatorio.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach scatta prima della scadenza, slaBreached alla scadenza o dopo." }, "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" ) diff --git a/l10n/it.json b/l10n/it.json index 5191833a9a..42a4314df3 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -2801,7 +2801,59 @@ "Ripristinato correttamente {count} oggetto", "Ripristinati correttamente {count} oggetti", "Ripristinati correttamente {count} oggetti" - ] + ], + "A message identity the notification subsystem resolves.": "Un'identità di messaggio risolta dal sistema di notifica.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Regole calcolate dei giorni non lavorativi. Tipo fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tipo easter: {offset, name}, scostamento in giorni dalla domenica di Pasqua. Tipo observedShift: una data fissa con spostamento obbligatorio.", + "Date": "Data", + "Day": "Giorno", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Giorno di una regola fixed o observedShift; una regola easter lo ignora.", + "Days": "Giorni", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Giorni dalla domenica di Pasqua per una regola easter; gli altri tipi li ignorano.", + "Distance from the deadline, in the offset unit.": "Distanza dalla scadenza, nell'unità dello scostamento.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Chiusure singole elencate come {date, name}. Ammesse accanto alle regole; rifiutate come intero calendario.", + "Escalate to roles": "Ruoli di escalation", + "Escalation ladder": "Scala di escalation", + "Exception dates": "Date di eccezione", + "Hours per working day": "Ore per giorno lavorativo", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Di quanti giorni si sposta la data osservata, ad es. -1 per il sabato precedente.", + "Human-readable title.": "Titolo leggibile.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Giorni ISO della settimana (1 = lunedì, 7 = domenica) che sono lavorativi.", + "Kind": "Tipo", + "Message": "Messaggio", + "Month": "Mese", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mese di una regola fixed o observedShift; una regola easter lo ignora.", + "Non-working-date rules": "Regole dei giorni non lavorativi", + "Notify roles": "Ruoli da avvisare", + "Observed shift": "Spostamento della festività", + "Offset": "Scostamento", + "Offset unit": "Unità dello scostamento", + "Open incident": "Aprire un incidente", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mappa facoltativa da un nome di ruolo a un destinatario concreto, ad es. {\"teamleader\": \"group:teamleaders\"}. I ruoli senza associazione viaggiano irrisolti come role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Facoltativo in una regola fixed, obbligatorio in observedShift: {whenWeekday: nome del giorno o ISO 1..7, days: intero}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Gradini ordinati: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorità", + "Role bindings": "Associazioni dei ruoli", + "Roles the matter is escalated to at this rung.": "Ruoli a cui la pratica viene fatta risalire a questo gradino.", + "Roles told at this rung; handler is the subject's own performer.": "Ruoli avvisati a questo gradino; handler è la persona incaricata della pratica.", + "Rungs": "Gradini", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identità stabile del gradino, ad es. preBreach:14:calendarDays; se assente è derivata da innesco, scostamento e unità.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Il nome stabile del calendario, richiamato dal calendar_slug di un timer.", + "The closed date, Y-m-d.": "La data di chiusura, Y-m-d.", + "The holiday's name.": "Il nome della festività.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Il nome stabile della scala, richiamato dal ladder_slug di un timer.", + "The unit of the offset: the same set the SLA accepts.": "L'unità dello scostamento: lo stesso insieme accettato dallo SLA.", + "The urgency the raised transition carries.": "L'urgenza portata dalla transizione innescata.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Il giorno della settimana (nome) in cui la data viene spostata, ad es. sunday per Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID dell'organizzazione per cui questo calendario è configurato. Vuoto per un calendario condiviso. Se il timer non ne indica nessuno, l'ordine di risoluzione sceglie il calendario dell'organizzazione.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID dell'organizzazione per cui questa scala è la predefinita. Vuoto per il valore condiviso.", + "When weekday": "Se cade di", + "Whether reaching this rung opens an incident.": "Se il raggiungimento di questo gradino apre un incidente.", + "Why the day is closed.": "Perché quel giorno è chiuso.", + "Working calendar": "Calendario lavorativo", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Ore lavorate in un giorno lavorativo. Rende confrontabili ore e giorni lavorativi.", + "Working weekdays": "Giorni lavorativi della settimana", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mese e giorno; easter: uno scostamento in giorni dalla domenica di Pasqua; observedShift: mese e giorno con spostamento obbligatorio.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach scatta prima della scadenza, slaBreached alla scadenza o dopo." }, "pluralForm": "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;", "plurals": { diff --git a/l10n/lb.js b/l10n/lb.js index a5922f21e3..0cf3362578 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Object succesvol verwijderd","Objecten succesvol verwijderd"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Verwijder {count} object uit de database","Verwijder {count} objecten uit de database"], "_Restore {count} object_::_Restore {count} objects_": ["Herstel {count} object","Herstel {count} objecten"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} object succesvol hersteld","{count} objecten succesvol hersteld"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} object succesvol hersteld","{count} objecten succesvol hersteld"], + "A message identity the notification subsystem resolves.": "Eng Message-Identitéit, déi den Notifikatiounssystem opléist.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Berechent Reegele fir aarbechtsfräi Deeg. Zort fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Zort easter: {offset, name}, Ofstand an Deeg vum Ouschtersonndeg. Zort observedShift: e fixen Datum mat obligatorescher Verréckelung.", + "Date": "Datum", + "Day": "Dag", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Den Dag vun enger fixed- oder observedShift-Reegel; eng easter-Reegel ignoréiert en.", + "Days": "Deeg", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Deeg vum Ouschtersonndeg fir eng easter-Reegel; déi aner Zorten ignoréiere se.", + "Distance from the deadline, in the offset unit.": "Distanz bis zur Frist, an der Eenheet vum Ofstand.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Eenzel Zoumaachungen opgelëscht als {date, name}. Niewent Reegelen erlaabt; als ganze Kalenner refuséiert.", + "Escalate to roles": "Rolle fir d'Eskalatioun", + "Escalation ladder": "Eskalatiounsleeder", + "Exception dates": "Ausnamedatumer", + "Hours per working day": "Stonnen pro Aarbechtsdag", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Ëm wéi vill Deeg de gefeierten Datum verréckelt gëtt, z. B. -1 fir de Samschdeg virdrun.", + "Human-readable title.": "Liesbaren Titel.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO-Wochendeeg (1 = Méindeg, 7 = Sonndeg), déi Aarbechtsdeeg sinn.", + "Kind": "Zort", + "Message": "Message", + "Month": "Mount", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "De Mount vun enger fixed- oder observedShift-Reegel; eng easter-Reegel ignoréiert en.", + "Non-working-date rules": "Reegele fir aarbechtsfräi Deeg", + "Notify roles": "Rollen déi informéiert ginn", + "Observed shift": "Verréckele vum Feierdag", + "Offset": "Ofstand", + "Offset unit": "Eenheet vum Ofstand", + "Open incident": "Incident opmaachen", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional Zuerdnung vun engem Rollennumm op e konkreten Empfänger, z. B. {\"teamleader\": \"group:teamleaders\"}. Rollen ouni Zuerdnung reesen onopgeléist als role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional bei enger fixed-Reegel, obligatoresch bei observedShift: {whenWeekday: Numm vum Dag oder ISO 1..7, days: ganz Zuel}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Geuerdent Sprossen: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritéit", + "Role bindings": "Rollenzuerdnungen", + "Roles the matter is escalated to at this rung.": "Rollen, un déi de Fall op dëser Spross eskaléiert gëtt.", + "Roles told at this rung; handler is the subject's own performer.": "Rollen, déi op dëser Spross informéiert ginn; handler ass déi Persoun, déi de Fall behandelt.", + "Rungs": "Sprossen", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Fest Identitéit vun der Spross, z. B. preBreach:14:calendarDays; feelt se, gëtt se aus Ausléiser, Ofstand an Eenheet ofgeleet.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "De feste Numm vum Kalenner, op deen en Timer iwwer calendar_slug verweist.", + "The closed date, Y-m-d.": "Den Datum vun der Zoumaachung, Y-m-d.", + "The holiday's name.": "Den Numm vum Feierdag.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "De feste Numm vun der Leeder, op déi en Timer iwwer ladder_slug verweist.", + "The unit of the offset: the same set the SLA accepts.": "D'Eenheet vum Ofstand: déiselwecht Wäerter, déi den SLA akzeptéiert.", + "The urgency the raised transition carries.": "D'Dringlechkeet, déi den ausgeléisten Iwwergank dréit.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "De Wochendag (Numm), op deem den Datum verréckelt gëtt, z. B. sunday fir Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID vun der Organisatioun, fir déi dëse Kalenner agestallt ass. Eidel bei engem gedeelte Kalenner. Nennt den Timer kengen, wielt d'Opléisungsuerdnung de Kalenner vun der Organisatioun.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID vun der Organisatioun, fir déi dës Leeder de Standard ass. Eidel beim gedeelte Standard.", + "When weekday": "Wann e fällt op", + "Whether reaching this rung opens an incident.": "Ob d'Erreeche vun dëser Spross en Incident opmécht.", + "Why the day is closed.": "Firwat dësen Dag zou ass.", + "Working calendar": "Aarbechtskalenner", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Aarbechtsstonnen an engem Aarbechtsdag. Mécht Stonnen an Aarbechtsdeeg vergläichbar.", + "Working weekdays": "Aarbechtsdeeg vun der Woch", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: Mount an Dag; easter: en Ofstand an Deeg vum Ouschtersonndeg; observedShift: Mount an Dag mat obligatorescher Verréckelung.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach léist virun der Frist aus, slaBreached op der Frist oder duerno." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 6be7a459f9..1bf1c2f039 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} object succesvol hersteld", "{count} objecten succesvol hersteld" - ] + ], + "A message identity the notification subsystem resolves.": "Eng Message-Identitéit, déi den Notifikatiounssystem opléist.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Berechent Reegele fir aarbechtsfräi Deeg. Zort fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Zort easter: {offset, name}, Ofstand an Deeg vum Ouschtersonndeg. Zort observedShift: e fixen Datum mat obligatorescher Verréckelung.", + "Date": "Datum", + "Day": "Dag", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Den Dag vun enger fixed- oder observedShift-Reegel; eng easter-Reegel ignoréiert en.", + "Days": "Deeg", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Deeg vum Ouschtersonndeg fir eng easter-Reegel; déi aner Zorten ignoréiere se.", + "Distance from the deadline, in the offset unit.": "Distanz bis zur Frist, an der Eenheet vum Ofstand.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Eenzel Zoumaachungen opgelëscht als {date, name}. Niewent Reegelen erlaabt; als ganze Kalenner refuséiert.", + "Escalate to roles": "Rolle fir d'Eskalatioun", + "Escalation ladder": "Eskalatiounsleeder", + "Exception dates": "Ausnamedatumer", + "Hours per working day": "Stonnen pro Aarbechtsdag", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Ëm wéi vill Deeg de gefeierten Datum verréckelt gëtt, z. B. -1 fir de Samschdeg virdrun.", + "Human-readable title.": "Liesbaren Titel.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO-Wochendeeg (1 = Méindeg, 7 = Sonndeg), déi Aarbechtsdeeg sinn.", + "Kind": "Zort", + "Message": "Message", + "Month": "Mount", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "De Mount vun enger fixed- oder observedShift-Reegel; eng easter-Reegel ignoréiert en.", + "Non-working-date rules": "Reegele fir aarbechtsfräi Deeg", + "Notify roles": "Rollen déi informéiert ginn", + "Observed shift": "Verréckele vum Feierdag", + "Offset": "Ofstand", + "Offset unit": "Eenheet vum Ofstand", + "Open incident": "Incident opmaachen", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional Zuerdnung vun engem Rollennumm op e konkreten Empfänger, z. B. {\"teamleader\": \"group:teamleaders\"}. Rollen ouni Zuerdnung reesen onopgeléist als role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional bei enger fixed-Reegel, obligatoresch bei observedShift: {whenWeekday: Numm vum Dag oder ISO 1..7, days: ganz Zuel}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Geuerdent Sprossen: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritéit", + "Role bindings": "Rollenzuerdnungen", + "Roles the matter is escalated to at this rung.": "Rollen, un déi de Fall op dëser Spross eskaléiert gëtt.", + "Roles told at this rung; handler is the subject's own performer.": "Rollen, déi op dëser Spross informéiert ginn; handler ass déi Persoun, déi de Fall behandelt.", + "Rungs": "Sprossen", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Fest Identitéit vun der Spross, z. B. preBreach:14:calendarDays; feelt se, gëtt se aus Ausléiser, Ofstand an Eenheet ofgeleet.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "De feste Numm vum Kalenner, op deen en Timer iwwer calendar_slug verweist.", + "The closed date, Y-m-d.": "Den Datum vun der Zoumaachung, Y-m-d.", + "The holiday's name.": "Den Numm vum Feierdag.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "De feste Numm vun der Leeder, op déi en Timer iwwer ladder_slug verweist.", + "The unit of the offset: the same set the SLA accepts.": "D'Eenheet vum Ofstand: déiselwecht Wäerter, déi den SLA akzeptéiert.", + "The urgency the raised transition carries.": "D'Dringlechkeet, déi den ausgeléisten Iwwergank dréit.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "De Wochendag (Numm), op deem den Datum verréckelt gëtt, z. B. sunday fir Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID vun der Organisatioun, fir déi dëse Kalenner agestallt ass. Eidel bei engem gedeelte Kalenner. Nennt den Timer kengen, wielt d'Opléisungsuerdnung de Kalenner vun der Organisatioun.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID vun der Organisatioun, fir déi dës Leeder de Standard ass. Eidel beim gedeelte Standard.", + "When weekday": "Wann e fällt op", + "Whether reaching this rung opens an incident.": "Ob d'Erreeche vun dëser Spross en Incident opmécht.", + "Why the day is closed.": "Firwat dësen Dag zou ass.", + "Working calendar": "Aarbechtskalenner", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Aarbechtsstonnen an engem Aarbechtsdag. Mécht Stonnen an Aarbechtsdeeg vergläichbar.", + "Working weekdays": "Aarbechtsdeeg vun der Woch", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: Mount an Dag; easter: en Ofstand an Deeg vum Ouschtersonndeg; observedShift: Mount an Dag mat obligatorescher Verréckelung.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach léist virun der Frist aus, slaBreached op der Frist oder duerno." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/lt.js b/l10n/lt.js index c36d576439..9926d35249 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objektas sėkmingai ištrintas","Objektai sėkmingai ištrinti","Objektai sėkmingai ištrinti"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Išvalyti {count} objektą iš duomenų bazės","Išvalyti {count} objektus iš duomenų bazės","Išvalyti {count} objektų iš duomenų bazės"], "_Restore {count} object_::_Restore {count} objects_": ["Atkurti {count} objektą","Atkurti {count} objektus","Atkurti {count} objektų"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Sėkmingai atkurtas {count} objektas","Sėkmingai atkurti {count} objektai","Sėkmingai atkurta {count} objektų"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Sėkmingai atkurtas {count} objektas","Sėkmingai atkurti {count} objektai","Sėkmingai atkurta {count} objektų"], + "A message identity the notification subsystem resolves.": "Žinutės tapatybė, kurią išsprendžia pranešimų sistema.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Apskaičiuotos nedarbo dienų taisyklės. Rūšis fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Rūšis easter: {offset, name}, poslinkis dienomis nuo Velykų sekmadienio. Rūšis observedShift: fiksuota data su privalomu perkėlimu.", + "Date": "Data", + "Day": "Diena", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Taisyklės fixed arba observedShift diena; taisyklė easter ją ignoruoja.", + "Days": "Dienos", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dienos nuo Velykų sekmadienio taisyklei easter; kitos rūšys jas ignoruoja.", + "Distance from the deadline, in the offset unit.": "Atstumas iki termino poslinkio vienetu.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Pavieniai uždarymai, išvardyti kaip {date, name}. Leidžiami šalia taisyklių; atmesti kaip visas kalendorius.", + "Escalate to roles": "Eskalavimo vaidmenys", + "Escalation ladder": "Eskalavimo kopėčios", + "Exception dates": "Išimčių datos", + "Hours per working day": "Valandų per darbo dieną", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Keliomis dienomis švenčiama data perkeliama, pvz. -1 ankstesniam šeštadieniui.", + "Human-readable title.": "Įskaitomas pavadinimas.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO savaitės dienos (1 = pirmadienis, 7 = sekmadienis), kurios yra darbo dienos.", + "Kind": "Rūšis", + "Message": "Žinutė", + "Month": "Mėnuo", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Taisyklės fixed arba observedShift mėnuo; taisyklė easter jį ignoruoja.", + "Non-working-date rules": "Nedarbo dienų taisyklės", + "Notify roles": "Informuojami vaidmenys", + "Observed shift": "Šventės perkėlimas", + "Offset": "Poslinkis", + "Offset unit": "Poslinkio vienetas", + "Open incident": "Atidaryti incidentą", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neprivalomas žemėlapis iš vaidmens pavadinimo į konkretų gavėją, pvz. {\"teamleader\": \"group:teamleaders\"}. Nesusieti vaidmenys keliauja neišspręsti kaip role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neprivaloma taisyklei fixed, privaloma observedShift: {whenWeekday: dienos pavadinimas arba ISO 1..7, days: sveikasis skaičius}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Surikiuotos pakopos: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritetas", + "Role bindings": "Vaidmenų susiejimai", + "Roles the matter is escalated to at this rung.": "Vaidmenys, kuriems byla šioje pakopoje eskaluojama.", + "Roles told at this rung; handler is the subject's own performer.": "Šioje pakopoje informuojami vaidmenys; handler yra bylą tvarkantis asmuo.", + "Rungs": "Pakopos", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Pastovi pakopos tapatybė, pvz. preBreach:14:calendarDays; jos nesant, išvedama iš trigerio, poslinkio ir vieneto.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Pastovus kalendoriaus pavadinimas, į kurį laikmatis rodo per calendar_slug.", + "The closed date, Y-m-d.": "Uždarymo data, Y-m-d.", + "The holiday's name.": "Šventės pavadinimas.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Pastovus kopėčių pavadinimas, į kurį laikmatis rodo per ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Poslinkio vienetas: ta pati aibė, kurią priima SLA.", + "The urgency the raised transition carries.": "Skuba, kurią neša sukeltas perėjimas.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Savaitės diena (pavadinimas), kurią data perkeliama, pvz. sunday Koningsdag atveju.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "Organizacijos, kuriai šis kalendorius nustatytas, UUID. Tuščia bendram kalendoriui. Jei laikmatis nenurodo jokio, sprendimo tvarka parenka organizacijos kalendorių.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "Organizacijos, kuriai šios kopėčios yra numatytosios, UUID. Tuščia bendram numatytajam.", + "When weekday": "Jei išpuola", + "Whether reaching this rung opens an incident.": "Ar šios pakopos pasiekimas atidaro incidentą.", + "Why the day is closed.": "Kodėl ta diena uždaryta.", + "Working calendar": "Darbo kalendorius", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Darbo valandos vienoje darbo dienoje. Padaro valandas ir darbo dienas palyginamas.", + "Working weekdays": "Savaitės darbo dienos", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mėnuo ir diena; easter: poslinkis dienomis nuo Velykų sekmadienio; observedShift: mėnuo ir diena su privalomu perkėlimu.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach suveikia prieš terminą, slaBreached terminą pasiekus arba po jo." }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/lt.json b/l10n/lt.json index 778ebb2550..639320a28f 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -2801,7 +2801,59 @@ "Sėkmingai atkurtas {count} objektas", "Sėkmingai atkurti {count} objektai", "Sėkmingai atkurta {count} objektų" - ] + ], + "A message identity the notification subsystem resolves.": "Žinutės tapatybė, kurią išsprendžia pranešimų sistema.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Apskaičiuotos nedarbo dienų taisyklės. Rūšis fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Rūšis easter: {offset, name}, poslinkis dienomis nuo Velykų sekmadienio. Rūšis observedShift: fiksuota data su privalomu perkėlimu.", + "Date": "Data", + "Day": "Diena", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Taisyklės fixed arba observedShift diena; taisyklė easter ją ignoruoja.", + "Days": "Dienos", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dienos nuo Velykų sekmadienio taisyklei easter; kitos rūšys jas ignoruoja.", + "Distance from the deadline, in the offset unit.": "Atstumas iki termino poslinkio vienetu.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Pavieniai uždarymai, išvardyti kaip {date, name}. Leidžiami šalia taisyklių; atmesti kaip visas kalendorius.", + "Escalate to roles": "Eskalavimo vaidmenys", + "Escalation ladder": "Eskalavimo kopėčios", + "Exception dates": "Išimčių datos", + "Hours per working day": "Valandų per darbo dieną", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Keliomis dienomis švenčiama data perkeliama, pvz. -1 ankstesniam šeštadieniui.", + "Human-readable title.": "Įskaitomas pavadinimas.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO savaitės dienos (1 = pirmadienis, 7 = sekmadienis), kurios yra darbo dienos.", + "Kind": "Rūšis", + "Message": "Žinutė", + "Month": "Mėnuo", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Taisyklės fixed arba observedShift mėnuo; taisyklė easter jį ignoruoja.", + "Non-working-date rules": "Nedarbo dienų taisyklės", + "Notify roles": "Informuojami vaidmenys", + "Observed shift": "Šventės perkėlimas", + "Offset": "Poslinkis", + "Offset unit": "Poslinkio vienetas", + "Open incident": "Atidaryti incidentą", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neprivalomas žemėlapis iš vaidmens pavadinimo į konkretų gavėją, pvz. {\"teamleader\": \"group:teamleaders\"}. Nesusieti vaidmenys keliauja neišspręsti kaip role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neprivaloma taisyklei fixed, privaloma observedShift: {whenWeekday: dienos pavadinimas arba ISO 1..7, days: sveikasis skaičius}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Surikiuotos pakopos: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritetas", + "Role bindings": "Vaidmenų susiejimai", + "Roles the matter is escalated to at this rung.": "Vaidmenys, kuriems byla šioje pakopoje eskaluojama.", + "Roles told at this rung; handler is the subject's own performer.": "Šioje pakopoje informuojami vaidmenys; handler yra bylą tvarkantis asmuo.", + "Rungs": "Pakopos", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Pastovi pakopos tapatybė, pvz. preBreach:14:calendarDays; jos nesant, išvedama iš trigerio, poslinkio ir vieneto.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Pastovus kalendoriaus pavadinimas, į kurį laikmatis rodo per calendar_slug.", + "The closed date, Y-m-d.": "Uždarymo data, Y-m-d.", + "The holiday's name.": "Šventės pavadinimas.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Pastovus kopėčių pavadinimas, į kurį laikmatis rodo per ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Poslinkio vienetas: ta pati aibė, kurią priima SLA.", + "The urgency the raised transition carries.": "Skuba, kurią neša sukeltas perėjimas.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Savaitės diena (pavadinimas), kurią data perkeliama, pvz. sunday Koningsdag atveju.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "Organizacijos, kuriai šis kalendorius nustatytas, UUID. Tuščia bendram kalendoriui. Jei laikmatis nenurodo jokio, sprendimo tvarka parenka organizacijos kalendorių.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "Organizacijos, kuriai šios kopėčios yra numatytosios, UUID. Tuščia bendram numatytajam.", + "When weekday": "Jei išpuola", + "Whether reaching this rung opens an incident.": "Ar šios pakopos pasiekimas atidaro incidentą.", + "Why the day is closed.": "Kodėl ta diena uždaryta.", + "Working calendar": "Darbo kalendorius", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Darbo valandos vienoje darbo dienoje. Padaro valandas ir darbo dienas palyginamas.", + "Working weekdays": "Savaitės darbo dienos", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mėnuo ir diena; easter: poslinkis dienomis nuo Velykų sekmadienio; observedShift: mėnuo ir diena su privalomu perkėlimu.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach suveikia prieš terminą, slaBreached terminą pasiekus arba po jo." }, "pluralForm": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);", "plurals": { diff --git a/l10n/lv.js b/l10n/lv.js index c4a0b4d4d4..2d5b5f1959 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekts veiksmīgi dzēsts","Objekti veiksmīgi dzēsti","Objekti veiksmīgi dzēsti"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Iztīrīt {count} objektu no datubāzes","Iztīrīt {count} objektus no datubāzes","Iztīrīt {count} objektu no datubāzes"], "_Restore {count} object_::_Restore {count} objects_": ["Atjaunot {count} objektu","Atjaunot {count} objektus","Atjaunot {count} objektu"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Veiksmīgi atjaunots {count} objekts","Veiksmīgi atjaunoti {count} objekti","Veiksmīgi atjaunoti {count} objekti"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Veiksmīgi atjaunots {count} objekts","Veiksmīgi atjaunoti {count} objekti","Veiksmīgi atjaunoti {count} objekti"], + "A message identity the notification subsystem resolves.": "Ziņojuma identitāte, ko atrisina paziņojumu sistēma.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Aprēķinātie brīvdienu noteikumi. Veids fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Veids easter: {offset, name}, nobīde dienās no Lieldienu svētdienas. Veids observedShift: fiksēts datums ar obligātu pārcelšanu.", + "Date": "Datums", + "Day": "Diena", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Noteikuma fixed vai observedShift diena; noteikums easter to ignorē.", + "Days": "Dienas", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dienas no Lieldienu svētdienas noteikumam easter; pārējie veidi tās ignorē.", + "Distance from the deadline, in the offset unit.": "Attālums līdz termiņam nobīdes vienībā.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Atsevišķas slēgšanas, uzskaitītas kā {date, name}. Atļautas līdzās noteikumiem; noraidītas kā viss kalendārs.", + "Escalate to roles": "Eskalācijas lomas", + "Escalation ladder": "Eskalācijas kāpnes", + "Exception dates": "Izņēmuma datumi", + "Hours per working day": "Stundas darba dienā", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Par cik dienām svinamais datums tiek pārcelts, piem. -1 iepriekšējai sestdienai.", + "Human-readable title.": "Salasāms virsraksts.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO nedēļas dienas (1 = pirmdiena, 7 = svētdiena), kas ir darba dienas.", + "Kind": "Veids", + "Message": "Ziņojums", + "Month": "Mēnesis", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Noteikuma fixed vai observedShift mēnesis; noteikums easter to ignorē.", + "Non-working-date rules": "Brīvdienu noteikumi", + "Notify roles": "Informējamās lomas", + "Observed shift": "Svētku pārcelšana", + "Offset": "Nobīde", + "Offset unit": "Nobīdes vienība", + "Open incident": "Atvērt incidentu", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neobligāta karte no lomas nosaukuma uz konkrētu saņēmēju, piem. {\"teamleader\": \"group:teamleaders\"}. Lomas bez sasaistes ceļo neatrisinātas kā role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neobligāts noteikumam fixed, obligāts observedShift: {whenWeekday: dienas nosaukums vai ISO 1..7, days: vesels skaitlis}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Sakārtoti pakāpieni: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritāte", + "Role bindings": "Lomu sasaistes", + "Roles the matter is escalated to at this rung.": "Lomas, kurām lieta šajā pakāpienā tiek eskalēta.", + "Roles told at this rung; handler is the subject's own performer.": "Šajā pakāpienā informētās lomas; handler ir lietas kārtotājs.", + "Rungs": "Pakāpieni", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Pakāpiena pastāvīgā identitāte, piem. preBreach:14:calendarDays; ja tās nav, to atvasina no trigera, nobīdes un vienības.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Kalendāra pastāvīgais nosaukums, uz kuru taimeris atsaucas ar calendar_slug.", + "The closed date, Y-m-d.": "Slēgšanas datums, Y-m-d.", + "The holiday's name.": "Svētku nosaukums.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Kāpņu pastāvīgais nosaukums, uz kuru taimeris atsaucas ar ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Nobīdes vienība: tā pati kopa, ko pieņem SLA.", + "The urgency the raised transition carries.": "Steidzamība, ko nes izraisītā pāreja.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Nedēļas diena (nosaukums), kurā datums tiek pārcelts, piem. sunday Koningsdag gadījumā.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "Organizācijas UUID, kurai šis kalendārs ir iestatīts. Tukšs koplietotam kalendāram. Ja taimeris nevienu nenosauc, atrisināšanas secība izvēlas organizācijas kalendāru.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "Organizācijas UUID, kurai šīs kāpnes ir noklusējums. Tukšs koplietotajam noklusējumam.", + "When weekday": "Ja iekrīt", + "Whether reaching this rung opens an incident.": "Vai šī pakāpiena sasniegšana atver incidentu.", + "Why the day is closed.": "Kāpēc šī diena ir slēgta.", + "Working calendar": "Darba kalendārs", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Darba stundas vienā darba dienā. Padara stundas un darba dienas salīdzināmas.", + "Working weekdays": "Nedēļas darba dienas", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mēnesis un diena; easter: nobīde dienās no Lieldienu svētdienas; observedShift: mēnesis un diena ar obligātu pārcelšanu.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach nostrādā pirms termiņa, slaBreached termiņā vai pēc tā." }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);" ) diff --git a/l10n/lv.json b/l10n/lv.json index e3d69d4336..6a6a0ca49d 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -2801,7 +2801,59 @@ "Veiksmīgi atjaunots {count} objekts", "Veiksmīgi atjaunoti {count} objekti", "Veiksmīgi atjaunoti {count} objekti" - ] + ], + "A message identity the notification subsystem resolves.": "Ziņojuma identitāte, ko atrisina paziņojumu sistēma.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Aprēķinātie brīvdienu noteikumi. Veids fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Veids easter: {offset, name}, nobīde dienās no Lieldienu svētdienas. Veids observedShift: fiksēts datums ar obligātu pārcelšanu.", + "Date": "Datums", + "Day": "Diena", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Noteikuma fixed vai observedShift diena; noteikums easter to ignorē.", + "Days": "Dienas", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dienas no Lieldienu svētdienas noteikumam easter; pārējie veidi tās ignorē.", + "Distance from the deadline, in the offset unit.": "Attālums līdz termiņam nobīdes vienībā.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Atsevišķas slēgšanas, uzskaitītas kā {date, name}. Atļautas līdzās noteikumiem; noraidītas kā viss kalendārs.", + "Escalate to roles": "Eskalācijas lomas", + "Escalation ladder": "Eskalācijas kāpnes", + "Exception dates": "Izņēmuma datumi", + "Hours per working day": "Stundas darba dienā", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Par cik dienām svinamais datums tiek pārcelts, piem. -1 iepriekšējai sestdienai.", + "Human-readable title.": "Salasāms virsraksts.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO nedēļas dienas (1 = pirmdiena, 7 = svētdiena), kas ir darba dienas.", + "Kind": "Veids", + "Message": "Ziņojums", + "Month": "Mēnesis", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Noteikuma fixed vai observedShift mēnesis; noteikums easter to ignorē.", + "Non-working-date rules": "Brīvdienu noteikumi", + "Notify roles": "Informējamās lomas", + "Observed shift": "Svētku pārcelšana", + "Offset": "Nobīde", + "Offset unit": "Nobīdes vienība", + "Open incident": "Atvērt incidentu", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neobligāta karte no lomas nosaukuma uz konkrētu saņēmēju, piem. {\"teamleader\": \"group:teamleaders\"}. Lomas bez sasaistes ceļo neatrisinātas kā role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neobligāts noteikumam fixed, obligāts observedShift: {whenWeekday: dienas nosaukums vai ISO 1..7, days: vesels skaitlis}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Sakārtoti pakāpieni: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritāte", + "Role bindings": "Lomu sasaistes", + "Roles the matter is escalated to at this rung.": "Lomas, kurām lieta šajā pakāpienā tiek eskalēta.", + "Roles told at this rung; handler is the subject's own performer.": "Šajā pakāpienā informētās lomas; handler ir lietas kārtotājs.", + "Rungs": "Pakāpieni", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Pakāpiena pastāvīgā identitāte, piem. preBreach:14:calendarDays; ja tās nav, to atvasina no trigera, nobīdes un vienības.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Kalendāra pastāvīgais nosaukums, uz kuru taimeris atsaucas ar calendar_slug.", + "The closed date, Y-m-d.": "Slēgšanas datums, Y-m-d.", + "The holiday's name.": "Svētku nosaukums.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Kāpņu pastāvīgais nosaukums, uz kuru taimeris atsaucas ar ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Nobīdes vienība: tā pati kopa, ko pieņem SLA.", + "The urgency the raised transition carries.": "Steidzamība, ko nes izraisītā pāreja.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Nedēļas diena (nosaukums), kurā datums tiek pārcelts, piem. sunday Koningsdag gadījumā.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "Organizācijas UUID, kurai šis kalendārs ir iestatīts. Tukšs koplietotam kalendāram. Ja taimeris nevienu nenosauc, atrisināšanas secība izvēlas organizācijas kalendāru.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "Organizācijas UUID, kurai šīs kāpnes ir noklusējums. Tukšs koplietotajam noklusējumam.", + "When weekday": "Ja iekrīt", + "Whether reaching this rung opens an incident.": "Vai šī pakāpiena sasniegšana atver incidentu.", + "Why the day is closed.": "Kāpēc šī diena ir slēgta.", + "Working calendar": "Darba kalendārs", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Darba stundas vienā darba dienā. Padara stundas un darba dienas salīdzināmas.", + "Working weekdays": "Nedēļas darba dienas", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mēnesis un diena; easter: nobīde dienās no Lieldienu svētdienas; observedShift: mēnesis un diena ar obligātu pārcelšanu.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach nostrādā pirms termiņa, slaBreached termiņā vai pēc tā." }, "pluralForm": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);", "plurals": { diff --git a/l10n/mk.js b/l10n/mk.js index 0d4620d45d..7364adf892 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Објектот е успешно избришан","Објектите се успешно избришани"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Исчисти {count} објект од базата на податоци","Исчисти {count} објекти од базата на податоци"], "_Restore {count} object_::_Restore {count} objects_": ["Врати {count} објект","Врати {count} објекти"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Успешно вратен {count} објект","Успешно вратени {count} објекти"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Успешно вратен {count} објект","Успешно вратени {count} објекти"], + "A message identity the notification subsystem resolves.": "Идентитет на порака што го разрешува системот за известувања.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Пресметани правила за неработните денови. Вид fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Вид easter: {offset, name}, поместување во денови од Велигденската недела. Вид observedShift: фиксен датум со задолжително преместување.", + "Date": "Датум", + "Day": "Ден", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Ден на правило fixed или observedShift; правилото easter го игнорира.", + "Days": "Денови", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Денови од Велигденската недела за правило easter; другите видови ги игнорираат.", + "Distance from the deadline, in the offset unit.": "Растојание до рокот, во единицата на поместувањето.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Поединечни затворања наброени како {date, name}. Дозволени покрај правила; одбиени како цел календар.", + "Escalate to roles": "Улоги за ескалација", + "Escalation ladder": "Скала на ескалација", + "Exception dates": "Исклучителни датуми", + "Hours per working day": "Часови по работен ден", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "За колку денови се преместува празнуваниот датум, на пр. -1 за претходната сабота.", + "Human-readable title.": "Читлив наслов.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO денови во неделата (1 = понеделник, 7 = недела) што се работни.", + "Kind": "Вид", + "Message": "Порака", + "Month": "Месец", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Месец на правило fixed или observedShift; правилото easter го игнорира.", + "Non-working-date rules": "Правила за неработните денови", + "Notify roles": "Улоги за известување", + "Observed shift": "Преместување на празникот", + "Offset": "Поместување", + "Offset unit": "Единица на поместувањето", + "Open incident": "Отвори инцидент", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Незадолжителна мапа од име на улога кон конкретен примач, на пр. {\"teamleader\": \"group:teamleaders\"}. Улогите без врзување патуваат неразрешени како role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Незадолжително кај правило fixed, задолжително кај observedShift: {whenWeekday: име на ден или ISO 1..7, days: цел број}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Подредени скалила: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Приоритет", + "Role bindings": "Врзувања на улоги", + "Roles the matter is escalated to at this rung.": "Улоги кон кои предметот се ескалира на ова скалило.", + "Roles told at this rung; handler is the subject's own performer.": "Улоги известени на ова скалило; handler е лицето што го обработува предметот.", + "Rungs": "Скалила", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Постојан идентитет на скалилото, на пр. preBreach:14:calendarDays; ако недостига, се изведува од активаторот, поместувањето и единицата.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Постојаното име на календарот, посочено преку calendar_slug на тајмерот.", + "The closed date, Y-m-d.": "Датумот на затворање, Y-m-d.", + "The holiday's name.": "Името на празникот.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Постојаното име на скалата, посочено преку ladder_slug на тајмерот.", + "The unit of the offset: the same set the SLA accepts.": "Единицата на поместувањето: истото множество што го прифаќа SLA.", + "The urgency the raised transition carries.": "Итноста што ја носи активираниот премин.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Денот од неделата (име) на кој датумот се преместува, на пр. sunday за Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID на организацијата за која е поставен овој календар. Празно за споделен календар. Ако тајмерот не именува ниеден, редоследот на разрешување го избира календарот на организацијата.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID на организацијата за која оваа скала е стандардна. Празно за споделената стандардна.", + "When weekday": "Ако падне во", + "Whether reaching this rung opens an incident.": "Дали достигнувањето на ова скалило отвора инцидент.", + "Why the day is closed.": "Зошто денот е затворен.", + "Working calendar": "Работен календар", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Работни часови во еден работен ден. Ги прави часовите и работните денови споредливи.", + "Working weekdays": "Работни денови во неделата", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: месец и ден; easter: поместување во денови од Велигденската недела; observedShift: месец и ден со задолжително преместување.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach се активира пред рокот, slaBreached на рокот или потоа." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 484cc82bd3..0fca870ab0 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "Успешно вратен {count} објект", "Успешно вратени {count} објекти" - ] + ], + "A message identity the notification subsystem resolves.": "Идентитет на порака што го разрешува системот за известувања.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Пресметани правила за неработните денови. Вид fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Вид easter: {offset, name}, поместување во денови од Велигденската недела. Вид observedShift: фиксен датум со задолжително преместување.", + "Date": "Датум", + "Day": "Ден", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Ден на правило fixed или observedShift; правилото easter го игнорира.", + "Days": "Денови", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Денови од Велигденската недела за правило easter; другите видови ги игнорираат.", + "Distance from the deadline, in the offset unit.": "Растојание до рокот, во единицата на поместувањето.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Поединечни затворања наброени како {date, name}. Дозволени покрај правила; одбиени како цел календар.", + "Escalate to roles": "Улоги за ескалација", + "Escalation ladder": "Скала на ескалација", + "Exception dates": "Исклучителни датуми", + "Hours per working day": "Часови по работен ден", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "За колку денови се преместува празнуваниот датум, на пр. -1 за претходната сабота.", + "Human-readable title.": "Читлив наслов.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO денови во неделата (1 = понеделник, 7 = недела) што се работни.", + "Kind": "Вид", + "Message": "Порака", + "Month": "Месец", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Месец на правило fixed или observedShift; правилото easter го игнорира.", + "Non-working-date rules": "Правила за неработните денови", + "Notify roles": "Улоги за известување", + "Observed shift": "Преместување на празникот", + "Offset": "Поместување", + "Offset unit": "Единица на поместувањето", + "Open incident": "Отвори инцидент", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Незадолжителна мапа од име на улога кон конкретен примач, на пр. {\"teamleader\": \"group:teamleaders\"}. Улогите без врзување патуваат неразрешени како role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Незадолжително кај правило fixed, задолжително кај observedShift: {whenWeekday: име на ден или ISO 1..7, days: цел број}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Подредени скалила: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Приоритет", + "Role bindings": "Врзувања на улоги", + "Roles the matter is escalated to at this rung.": "Улоги кон кои предметот се ескалира на ова скалило.", + "Roles told at this rung; handler is the subject's own performer.": "Улоги известени на ова скалило; handler е лицето што го обработува предметот.", + "Rungs": "Скалила", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Постојан идентитет на скалилото, на пр. preBreach:14:calendarDays; ако недостига, се изведува од активаторот, поместувањето и единицата.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Постојаното име на календарот, посочено преку calendar_slug на тајмерот.", + "The closed date, Y-m-d.": "Датумот на затворање, Y-m-d.", + "The holiday's name.": "Името на празникот.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Постојаното име на скалата, посочено преку ladder_slug на тајмерот.", + "The unit of the offset: the same set the SLA accepts.": "Единицата на поместувањето: истото множество што го прифаќа SLA.", + "The urgency the raised transition carries.": "Итноста што ја носи активираниот премин.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Денот од неделата (име) на кој датумот се преместува, на пр. sunday за Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID на организацијата за која е поставен овој календар. Празно за споделен календар. Ако тајмерот не именува ниеден, редоследот на разрешување го избира календарот на организацијата.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID на организацијата за која оваа скала е стандардна. Празно за споделената стандардна.", + "When weekday": "Ако падне во", + "Whether reaching this rung opens an incident.": "Дали достигнувањето на ова скалило отвора инцидент.", + "Why the day is closed.": "Зошто денот е затворен.", + "Working calendar": "Работен календар", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Работни часови во еден работен ден. Ги прави часовите и работните денови споредливи.", + "Working weekdays": "Работни денови во неделата", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: месец и ден; easter: поместување во денови од Велигденската недела; observedShift: месец и ден со задолжително преместување.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach се активира пред рокот, slaBreached на рокот или потоа." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/mt.js b/l10n/mt.js index bafeacfbbc..a26f4d964c 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["L-oġġett tħassar b'suċċess","L-oġġetti tħassru b'suċċess","L-oġġetti tħassru b'suċċess","L-oġġetti tħassru b'suċċess"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Neħħi {count} oġġett mid-database","Neħħi {count} oġġetti mid-database","Neħħi {count} oġġett mid-database","Neħħi {count} oġġett mid-database"], "_Restore {count} object_::_Restore {count} objects_": ["Irrestawra {count} oġġett","Irrestawra {count} oġġetti","Irrestawra {count} oġġett","Irrestawra {count} oġġett"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} oġġett ġie rrestawrat b'suċċess","{count} oġġetti ġew irrestawrati b'suċċess","{count} oġġett ġie rrestawrat b'suċċess","{count} oġġett ġie rrestawrat b'suċċess"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} oġġett ġie rrestawrat b'suċċess","{count} oġġetti ġew irrestawrati b'suċċess","{count} oġġett ġie rrestawrat b'suċċess","{count} oġġett ġie rrestawrat b'suċċess"], + "A message identity the notification subsystem resolves.": "Identità ta' messaġġ li s-sottosistema tan-notifiki tirrisolvi.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Regoli kkalkulati tal-ġranet mhux tax-xogħol. Tip fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tip easter: {offset, name}, spostament f'ġranet minn Ħadd il-Għid. Tip observedShift: data fissa bi trasferiment obbligatorju.", + "Date": "Data", + "Day": "Jum", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Il-jum ta' regola fixed jew observedShift; regola easter tinjorah.", + "Days": "Ġranet", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Ġranet minn Ħadd il-Għid għal regola easter; it-tipi l-oħra jinjorawhom.", + "Distance from the deadline, in the offset unit.": "Id-distanza mill-iskadenza, fl-unità tal-ispostament.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Għeluq ta' darba elenkati bħala {date, name}. Permessi ħdejn ir-regoli; miċħuda bħala l-kalendarju kollu.", + "Escalate to roles": "Rwoli tal-eskalazzjoni", + "Escalation ladder": "Sellum tal-eskalazzjoni", + "Exception dates": "Dati ta' eċċezzjoni", + "Hours per working day": "Sigħat għal kull jum tax-xogħol", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Kemm-il jum tiġi trasferita d-data ċċelebrata, eż. -1 għas-Sibt ta' qabel.", + "Human-readable title.": "Titlu li jinqara.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Il-ġranet ISO tal-ġimgħa (1 = it-Tnejn, 7 = il-Ħadd) li huma ġranet tax-xogħol.", + "Kind": "Tip", + "Message": "Messaġġ", + "Month": "Xahar", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Ix-xahar ta' regola fixed jew observedShift; regola easter tinjorah.", + "Non-working-date rules": "Regoli tal-ġranet mhux tax-xogħol", + "Notify roles": "Rwoli x'jiġu notifikati", + "Observed shift": "Trasferiment tal-festa", + "Offset": "Spostament", + "Offset unit": "Unità tal-ispostament", + "Open incident": "Iftaħ inċident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mappa fakultattiva minn isem ta' rwol għal riċevitur konkret, eż. {\"teamleader\": \"group:teamleaders\"}. Rwoli mingħajr rabta jivvjaġġaw mhux riżolti bħala role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Fakultattiv fuq regola fixed, obbligatorju fuq observedShift: {whenWeekday: isem il-jum jew ISO 1..7, days: numru sħiħ}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Tarġiet ordnati: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prijorità", + "Role bindings": "Rabtiet tar-rwoli", + "Roles the matter is escalated to at this rung.": "Ir-rwoli li lilhom il-każ jiġi eskalat f'din it-tarġa.", + "Roles told at this rung; handler is the subject's own performer.": "Ir-rwoli notifikati f'din it-tarġa; handler huwa l-persuna li tieħu ħsieb il-każ.", + "Rungs": "Tarġiet", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identità stabbli tat-tarġa, eż. preBreach:14:calendarDays; meta tkun nieqsa, tiġi derivata mill-grillu, mill-ispostament u mill-unità.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "L-isem stabbli tal-kalendarju, li timer jirreferi għalih permezz ta' calendar_slug.", + "The closed date, Y-m-d.": "Id-data tal-għeluq, Y-m-d.", + "The holiday's name.": "L-isem tal-festa.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "L-isem stabbli tas-sellum, li timer jirreferi għalih permezz ta' ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "L-unità tal-ispostament: l-istess sett li jaċċetta l-SLA.", + "The urgency the raised transition carries.": "L-urġenza li ġġorr it-tranżizzjoni skattata.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Il-jum tal-ġimgħa (isem) li fih id-data tiġi trasferita, eż. sunday għal Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID tal-organizzazzjoni li għaliha huwa kkonfigurat dan il-kalendarju. Vojt għal kalendarju kondiviż. Jekk it-timer ma jsemmi xejn, l-ordni tar-riżoluzzjoni tagħżel il-kalendarju tal-organizzazzjoni.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID tal-organizzazzjoni li għaliha dan is-sellum huwa l-predefinit. Vojt għall-predefinit kondiviż.", + "When weekday": "Jekk taħbat", + "Whether reaching this rung opens an incident.": "Jekk il-wasla f'din it-tarġa tiftaħx inċident.", + "Why the day is closed.": "Għaliex dak il-jum huwa magħluq.", + "Working calendar": "Kalendarju tax-xogħol", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Is-sigħat tax-xogħol f'jum tax-xogħol wieħed. Jagħmel is-sigħat u l-ġranet tax-xogħol komparabbli.", + "Working weekdays": "Il-ġranet tax-xogħol tal-ġimgħa", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: xahar u jum; easter: spostament f'ġranet minn Ħadd il-Għid; observedShift: xahar u jum bi trasferiment obbligatorju.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach jiskatta qabel l-iskadenza, slaBreached fl-iskadenza jew warajha." }, "nplurals=4; plural=(n==1 ? 0 : n==0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 2f4bd7ba12..c1204ab298 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -2817,7 +2817,59 @@ "{count} oġġetti ġew irrestawrati b'suċċess", "{count} oġġett ġie rrestawrat b'suċċess", "{count} oġġett ġie rrestawrat b'suċċess" - ] + ], + "A message identity the notification subsystem resolves.": "Identità ta' messaġġ li s-sottosistema tan-notifiki tirrisolvi.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Regoli kkalkulati tal-ġranet mhux tax-xogħol. Tip fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tip easter: {offset, name}, spostament f'ġranet minn Ħadd il-Għid. Tip observedShift: data fissa bi trasferiment obbligatorju.", + "Date": "Data", + "Day": "Jum", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Il-jum ta' regola fixed jew observedShift; regola easter tinjorah.", + "Days": "Ġranet", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Ġranet minn Ħadd il-Għid għal regola easter; it-tipi l-oħra jinjorawhom.", + "Distance from the deadline, in the offset unit.": "Id-distanza mill-iskadenza, fl-unità tal-ispostament.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Għeluq ta' darba elenkati bħala {date, name}. Permessi ħdejn ir-regoli; miċħuda bħala l-kalendarju kollu.", + "Escalate to roles": "Rwoli tal-eskalazzjoni", + "Escalation ladder": "Sellum tal-eskalazzjoni", + "Exception dates": "Dati ta' eċċezzjoni", + "Hours per working day": "Sigħat għal kull jum tax-xogħol", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Kemm-il jum tiġi trasferita d-data ċċelebrata, eż. -1 għas-Sibt ta' qabel.", + "Human-readable title.": "Titlu li jinqara.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Il-ġranet ISO tal-ġimgħa (1 = it-Tnejn, 7 = il-Ħadd) li huma ġranet tax-xogħol.", + "Kind": "Tip", + "Message": "Messaġġ", + "Month": "Xahar", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Ix-xahar ta' regola fixed jew observedShift; regola easter tinjorah.", + "Non-working-date rules": "Regoli tal-ġranet mhux tax-xogħol", + "Notify roles": "Rwoli x'jiġu notifikati", + "Observed shift": "Trasferiment tal-festa", + "Offset": "Spostament", + "Offset unit": "Unità tal-ispostament", + "Open incident": "Iftaħ inċident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mappa fakultattiva minn isem ta' rwol għal riċevitur konkret, eż. {\"teamleader\": \"group:teamleaders\"}. Rwoli mingħajr rabta jivvjaġġaw mhux riżolti bħala role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Fakultattiv fuq regola fixed, obbligatorju fuq observedShift: {whenWeekday: isem il-jum jew ISO 1..7, days: numru sħiħ}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Tarġiet ordnati: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prijorità", + "Role bindings": "Rabtiet tar-rwoli", + "Roles the matter is escalated to at this rung.": "Ir-rwoli li lilhom il-każ jiġi eskalat f'din it-tarġa.", + "Roles told at this rung; handler is the subject's own performer.": "Ir-rwoli notifikati f'din it-tarġa; handler huwa l-persuna li tieħu ħsieb il-każ.", + "Rungs": "Tarġiet", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identità stabbli tat-tarġa, eż. preBreach:14:calendarDays; meta tkun nieqsa, tiġi derivata mill-grillu, mill-ispostament u mill-unità.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "L-isem stabbli tal-kalendarju, li timer jirreferi għalih permezz ta' calendar_slug.", + "The closed date, Y-m-d.": "Id-data tal-għeluq, Y-m-d.", + "The holiday's name.": "L-isem tal-festa.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "L-isem stabbli tas-sellum, li timer jirreferi għalih permezz ta' ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "L-unità tal-ispostament: l-istess sett li jaċċetta l-SLA.", + "The urgency the raised transition carries.": "L-urġenza li ġġorr it-tranżizzjoni skattata.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Il-jum tal-ġimgħa (isem) li fih id-data tiġi trasferita, eż. sunday għal Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID tal-organizzazzjoni li għaliha huwa kkonfigurat dan il-kalendarju. Vojt għal kalendarju kondiviż. Jekk it-timer ma jsemmi xejn, l-ordni tar-riżoluzzjoni tagħżel il-kalendarju tal-organizzazzjoni.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID tal-organizzazzjoni li għaliha dan is-sellum huwa l-predefinit. Vojt għall-predefinit kondiviż.", + "When weekday": "Jekk taħbat", + "Whether reaching this rung opens an incident.": "Jekk il-wasla f'din it-tarġa tiftaħx inċident.", + "Why the day is closed.": "Għaliex dak il-jum huwa magħluq.", + "Working calendar": "Kalendarju tax-xogħol", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Is-sigħat tax-xogħol f'jum tax-xogħol wieħed. Jagħmel is-sigħat u l-ġranet tax-xogħol komparabbli.", + "Working weekdays": "Il-ġranet tax-xogħol tal-ġimgħa", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: xahar u jum; easter: spostament f'ġranet minn Ħadd il-Għid; observedShift: xahar u jum bi trasferiment obbligatorju.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach jiskatta qabel l-iskadenza, slaBreached fl-iskadenza jew warajha." }, "pluralForm": "nplurals=4; plural=(n==1 ? 0 : n==0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3);", "plurals": { diff --git a/l10n/nb.js b/l10n/nb.js index c000aef543..90725722f9 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt vellykket slettet","Objekter vellykket slettet"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Tøm {count} objekt fra database","Tøm {count} objekter fra database"], "_Restore {count} object_::_Restore {count} objects_": ["Gjenopprett {count} objekt","Gjenopprett {count} objekter"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Vellykket gjenopprettet {count} objekt","Vellykket gjenopprettet {count} objekter"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Vellykket gjenopprettet {count} objekt","Vellykket gjenopprettet {count} objekter"], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 9183612726..29d44bf4bf 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "Vellykket gjenopprettet {count} objekt", "Vellykket gjenopprettet {count} objekter" - ] + ], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/nl.js b/l10n/nl.js index 8b30f307d0..cc37e4e546 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2795,7 +2795,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Object succesvol verwijderd","Objecten succesvol verwijderd"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["{count} object uit de database wissen","{count} objecten uit de database wissen"], "_Restore {count} object_::_Restore {count} objects_": ["{count} object herstellen","{count} objecten herstellen"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} object succesvol hersteld","{count} objecten succesvol hersteld"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} object succesvol hersteld","{count} objecten succesvol hersteld"], + "Working calendar": "Werkkalender", + "Escalation ladder": "Escalatieladder", + "Working weekdays": "Werkdagen", + "Hours per working day": "Uren per werkdag", + "Non-working-date rules": "Regels voor niet-werkdagen", + "Exception dates": "Uitzonderingsdagen", + "Rungs": "Tredes", + "Role bindings": "Rolkoppelingen", + "Kind": "Soort", + "Month": "Maand", + "Day": "Dag", + "Offset": "Afstand", + "Observed shift": "Verschoven viering", + "When weekday": "Bij weekdag", + "Days": "Dagen", + "Date": "Datum", + "Offset unit": "Eenheid van de afstand", + "Notify roles": "Te informeren rollen", + "Escalate to roles": "Escaleren naar rollen", + "Priority": "Prioriteit", + "Message": "Bericht", + "Open incident": "Incident openen", + "The calendar's stable name, referenced by a timer's calendar_slug.": "De vaste naam van de kalender, waarnaar een timer verwijst via calendar_slug.", + "Human-readable title.": "Leesbare titel.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID van de organisatie waarvoor deze kalender is ingesteld. Leeg voor een gedeelde kalender. Noemt de timer geen kalender, dan kiest de resolutievolgorde de kalender van de organisatie.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO-weekdagen (1 = maandag, 7 = zondag) die werkdagen zijn.", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Werkuren in een werkdag. Maakt uren en werkdagen vergelijkbaar.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Berekende regels voor niet-werkdagen. Soort fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Soort easter: {offset, name}, afstand in dagen vanaf eerste paasdag. Soort observedShift: een vaste datum met een verplichte verschuiving.", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: een maand en dag; easter: een afstand in dagen vanaf eerste paasdag; observedShift: een maand en dag met een verplichte verschuiving.", + "The holiday's name.": "De naam van de feestdag.", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Maand van een fixed- of observedShift-regel; een easter-regel negeert dit.", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dag van een fixed- of observedShift-regel; een easter-regel negeert dit.", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dagen vanaf eerste paasdag voor een easter-regel; de andere soorten negeren dit.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optioneel bij een fixed-regel, verplicht bij observedShift: {whenWeekday: naam van de weekdag of ISO 1..7, days: geheel getal}.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "De weekdag (naam) waarop de datum verschuift, bijvoorbeeld sunday voor Koningsdag.", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Hoeveel dagen de gevierde datum verschuift, bijvoorbeeld -1 voor de zaterdag ervoor.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Losse sluitingsdagen als {date, name}. Toegestaan naast regels; geweigerd als de hele kalender.", + "The closed date, Y-m-d.": "De gesloten datum, Y-m-d.", + "Why the day is closed.": "Waarom deze dag gesloten is.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "De vaste naam van de ladder, waarnaar een timer verwijst via ladder_slug.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID van de organisatie waarvoor deze ladder de standaard is. Leeg voor de gedeelde standaard.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Geordende tredes: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Vaste identiteit van de trede, bijvoorbeeld preBreach:14:calendarDays; afgeleid uit aanleiding, afstand en eenheid als die ontbreekt.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach gaat af voor de termijn verstrijkt, slaBreached op of na dat moment.", + "Distance from the deadline, in the offset unit.": "Afstand tot de deadline, in de gekozen eenheid.", + "The unit of the offset: the same set the SLA accepts.": "De eenheid van de afstand: dezelfde set die de SLA accepteert.", + "Roles told at this rung; handler is the subject's own performer.": "Rollen die bij deze trede worden geinformeerd; handler is de behandelaar van het onderwerp zelf.", + "Roles the matter is escalated to at this rung.": "Rollen waarnaar de zaak bij deze trede wordt geescaleerd.", + "The urgency the raised transition carries.": "De urgentie die de afgegeven overgang meekrijgt.", + "A message identity the notification subsystem resolves.": "Een berichtidentiteit die het notificatiesysteem vertaalt.", + "Whether reaching this rung opens an incident.": "Of het bereiken van deze trede een incident opent.", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optionele koppeling van een rolnaam naar een concrete ontvanger, bijvoorbeeld {\"teamleader\": \"group:teamleaders\"}. Rollen zonder koppeling reizen onvertaald mee als role:." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 376401b669..501625692f 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -2842,7 +2842,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} object succesvol hersteld", "{count} objecten succesvol hersteld" - ] + ], + "Working calendar": "Werkkalender", + "Escalation ladder": "Escalatieladder", + "Working weekdays": "Werkdagen", + "Hours per working day": "Uren per werkdag", + "Non-working-date rules": "Regels voor niet-werkdagen", + "Exception dates": "Uitzonderingsdagen", + "Rungs": "Tredes", + "Role bindings": "Rolkoppelingen", + "Kind": "Soort", + "Month": "Maand", + "Day": "Dag", + "Offset": "Afstand", + "Observed shift": "Verschoven viering", + "When weekday": "Bij weekdag", + "Days": "Dagen", + "Date": "Datum", + "Offset unit": "Eenheid van de afstand", + "Notify roles": "Te informeren rollen", + "Escalate to roles": "Escaleren naar rollen", + "Priority": "Prioriteit", + "Message": "Bericht", + "Open incident": "Incident openen", + "The calendar's stable name, referenced by a timer's calendar_slug.": "De vaste naam van de kalender, waarnaar een timer verwijst via calendar_slug.", + "Human-readable title.": "Leesbare titel.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID van de organisatie waarvoor deze kalender is ingesteld. Leeg voor een gedeelde kalender. Noemt de timer geen kalender, dan kiest de resolutievolgorde de kalender van de organisatie.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO-weekdagen (1 = maandag, 7 = zondag) die werkdagen zijn.", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Werkuren in een werkdag. Maakt uren en werkdagen vergelijkbaar.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Berekende regels voor niet-werkdagen. Soort fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Soort easter: {offset, name}, afstand in dagen vanaf eerste paasdag. Soort observedShift: een vaste datum met een verplichte verschuiving.", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: een maand en dag; easter: een afstand in dagen vanaf eerste paasdag; observedShift: een maand en dag met een verplichte verschuiving.", + "The holiday's name.": "De naam van de feestdag.", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Maand van een fixed- of observedShift-regel; een easter-regel negeert dit.", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dag van een fixed- of observedShift-regel; een easter-regel negeert dit.", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dagen vanaf eerste paasdag voor een easter-regel; de andere soorten negeren dit.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optioneel bij een fixed-regel, verplicht bij observedShift: {whenWeekday: naam van de weekdag of ISO 1..7, days: geheel getal}.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "De weekdag (naam) waarop de datum verschuift, bijvoorbeeld sunday voor Koningsdag.", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Hoeveel dagen de gevierde datum verschuift, bijvoorbeeld -1 voor de zaterdag ervoor.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Losse sluitingsdagen als {date, name}. Toegestaan naast regels; geweigerd als de hele kalender.", + "The closed date, Y-m-d.": "De gesloten datum, Y-m-d.", + "Why the day is closed.": "Waarom deze dag gesloten is.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "De vaste naam van de ladder, waarnaar een timer verwijst via ladder_slug.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID van de organisatie waarvoor deze ladder de standaard is. Leeg voor de gedeelde standaard.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Geordende tredes: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Vaste identiteit van de trede, bijvoorbeeld preBreach:14:calendarDays; afgeleid uit aanleiding, afstand en eenheid als die ontbreekt.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach gaat af voor de termijn verstrijkt, slaBreached op of na dat moment.", + "Distance from the deadline, in the offset unit.": "Afstand tot de deadline, in de gekozen eenheid.", + "The unit of the offset: the same set the SLA accepts.": "De eenheid van de afstand: dezelfde set die de SLA accepteert.", + "Roles told at this rung; handler is the subject's own performer.": "Rollen die bij deze trede worden geinformeerd; handler is de behandelaar van het onderwerp zelf.", + "Roles the matter is escalated to at this rung.": "Rollen waarnaar de zaak bij deze trede wordt geescaleerd.", + "The urgency the raised transition carries.": "De urgentie die de afgegeven overgang meekrijgt.", + "A message identity the notification subsystem resolves.": "Een berichtidentiteit die het notificatiesysteem vertaalt.", + "Whether reaching this rung opens an incident.": "Of het bereiken van deze trede een incident opent.", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optionele koppeling van een rolnaam naar een concrete ontvanger, bijvoorbeeld {\"teamleader\": \"group:teamleaders\"}. Rollen zonder koppeling reizen onvertaald mee als role:." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/pl.js b/l10n/pl.js index 712ff4b855..0dc60ee8dd 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Obiekt został pomyślnie usunięty","Obiekty zostały pomyślnie usunięte","Obiekty zostały pomyślnie usunięte","Obiekty zostały pomyślnie usunięte"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Wyczyść {count} obiekt z bazy danych","Wyczyść {count} obiekty z bazy danych","Wyczyść {count} obiektów z bazy danych","Wyczyść {count} obiektu z bazy danych"], "_Restore {count} object_::_Restore {count} objects_": ["Przywróć {count} obiekt","Przywróć {count} obiekty","Przywróć {count} obiektów","Przywróć {count} obiektu"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Pomyślnie przywrócono {count} obiekt","Pomyślnie przywrócono {count} obiekty","Pomyślnie przywrócono {count} obiektów","Pomyślnie przywrócono {count} obiektu"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Pomyślnie przywrócono {count} obiekt","Pomyślnie przywrócono {count} obiekty","Pomyślnie przywrócono {count} obiektów","Pomyślnie przywrócono {count} obiektu"], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);" ) diff --git a/l10n/pl.json b/l10n/pl.json index e16d8a644b..6a1b523625 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -2817,7 +2817,59 @@ "Pomyślnie przywrócono {count} obiekty", "Pomyślnie przywrócono {count} obiektów", "Pomyślnie przywrócono {count} obiektu" - ] + ], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "pluralForm": "nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);", "plurals": { diff --git a/l10n/pt.js b/l10n/pt.js index 6c9ece4def..3437cbc2e3 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objeto eliminado com sucesso","Objetos eliminados com sucesso"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Expurgar {count} objeto da base de dados","Expurgar {count} objetos da base de dados"], "_Restore {count} object_::_Restore {count} objects_": ["Restaurar {count} objeto","Restaurar {count} objetos"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objeto restaurado com sucesso","{count} objetos restaurados com sucesso"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objeto restaurado com sucesso","{count} objetos restaurados com sucesso"], + "A message identity the notification subsystem resolves.": "Uma identidade de mensagem que o sistema de notificações resolve.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Regras calculadas de dias não úteis. Tipo fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tipo easter: {offset, name}, deslocamento em dias a partir do Domingo de Páscoa. Tipo observedShift: uma data fixa com transferência obrigatória.", + "Date": "Data", + "Day": "Dia", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dia de uma regra fixed ou observedShift; uma regra easter ignora-o.", + "Days": "Dias", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dias a partir do Domingo de Páscoa para uma regra easter; os outros tipos ignoram-nos.", + "Distance from the deadline, in the offset unit.": "Distância ao prazo, na unidade do deslocamento.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Encerramentos pontuais enumerados como {date, name}. Permitidos ao lado de regras; recusados como calendário inteiro.", + "Escalate to roles": "Papéis de escalonamento", + "Escalation ladder": "Escada de escalonamento", + "Exception dates": "Datas de exceção", + "Hours per working day": "Horas por dia útil", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Quantos dias a data observada é transferida, p. ex. -1 para o sábado anterior.", + "Human-readable title.": "Título legível.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Dias ISO da semana (1 = segunda-feira, 7 = domingo) que são úteis.", + "Kind": "Tipo", + "Message": "Mensagem", + "Month": "Mês", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mês de uma regra fixed ou observedShift; uma regra easter ignora-o.", + "Non-working-date rules": "Regras de dias não úteis", + "Notify roles": "Papéis a notificar", + "Observed shift": "Transferência do feriado", + "Offset": "Deslocamento", + "Offset unit": "Unidade do deslocamento", + "Open incident": "Abrir incidente", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mapa opcional de um nome de papel para um destinatário concreto, p. ex. {\"teamleader\": \"group:teamleaders\"}. Papéis sem associação seguem por resolver como role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opcional numa regra fixed, obrigatório em observedShift: {whenWeekday: nome do dia ou ISO 1..7, days: inteiro}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Degraus ordenados: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioridade", + "Role bindings": "Associações de papéis", + "Roles the matter is escalated to at this rung.": "Papéis para os quais o assunto é escalonado neste degrau.", + "Roles told at this rung; handler is the subject's own performer.": "Papéis avisados neste degrau; handler é a pessoa responsável pelo assunto.", + "Rungs": "Degraus", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identidade estável do degrau, p. ex. preBreach:14:calendarDays; quando ausente, deriva do gatilho, do deslocamento e da unidade.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "O nome estável do calendário, referenciado pelo calendar_slug de um temporizador.", + "The closed date, Y-m-d.": "A data de encerramento, Y-m-d.", + "The holiday's name.": "O nome do feriado.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "O nome estável da escada, referenciado pelo ladder_slug de um temporizador.", + "The unit of the offset: the same set the SLA accepts.": "A unidade do deslocamento: o mesmo conjunto que o SLA aceita.", + "The urgency the raised transition carries.": "A urgência transportada pela transição disparada.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "O dia da semana (nome) em que a data é transferida, p. ex. sunday para Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID da organização para a qual este calendário está configurado. Vazio para um calendário partilhado. Se o temporizador não nomear nenhum, a ordem de resolução escolhe o calendário da organização.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID da organização para a qual esta escada é a predefinida. Vazio para a predefinição partilhada.", + "When weekday": "Se calhar a", + "Whether reaching this rung opens an incident.": "Se alcançar este degrau abre um incidente.", + "Why the day is closed.": "Porque é que o dia está encerrado.", + "Working calendar": "Calendário de trabalho", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Horas trabalhadas num dia útil. Torna comparáveis horas e dias úteis.", + "Working weekdays": "Dias úteis da semana", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mês e dia; easter: um deslocamento em dias a partir do Domingo de Páscoa; observedShift: mês e dia com transferência obrigatória.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach dispara antes do prazo, slaBreached no prazo ou depois." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 46ef40223a..9ed942ddf8 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} objeto restaurado com sucesso", "{count} objetos restaurados com sucesso" - ] + ], + "A message identity the notification subsystem resolves.": "Uma identidade de mensagem que o sistema de notificações resolve.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Regras calculadas de dias não úteis. Tipo fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tipo easter: {offset, name}, deslocamento em dias a partir do Domingo de Páscoa. Tipo observedShift: uma data fixa com transferência obrigatória.", + "Date": "Data", + "Day": "Dia", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dia de uma regra fixed ou observedShift; uma regra easter ignora-o.", + "Days": "Dias", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dias a partir do Domingo de Páscoa para uma regra easter; os outros tipos ignoram-nos.", + "Distance from the deadline, in the offset unit.": "Distância ao prazo, na unidade do deslocamento.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Encerramentos pontuais enumerados como {date, name}. Permitidos ao lado de regras; recusados como calendário inteiro.", + "Escalate to roles": "Papéis de escalonamento", + "Escalation ladder": "Escada de escalonamento", + "Exception dates": "Datas de exceção", + "Hours per working day": "Horas por dia útil", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Quantos dias a data observada é transferida, p. ex. -1 para o sábado anterior.", + "Human-readable title.": "Título legível.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Dias ISO da semana (1 = segunda-feira, 7 = domingo) que são úteis.", + "Kind": "Tipo", + "Message": "Mensagem", + "Month": "Mês", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mês de uma regra fixed ou observedShift; uma regra easter ignora-o.", + "Non-working-date rules": "Regras de dias não úteis", + "Notify roles": "Papéis a notificar", + "Observed shift": "Transferência do feriado", + "Offset": "Deslocamento", + "Offset unit": "Unidade do deslocamento", + "Open incident": "Abrir incidente", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Mapa opcional de um nome de papel para um destinatário concreto, p. ex. {\"teamleader\": \"group:teamleaders\"}. Papéis sem associação seguem por resolver como role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opcional numa regra fixed, obrigatório em observedShift: {whenWeekday: nome do dia ou ISO 1..7, days: inteiro}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Degraus ordenados: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioridade", + "Role bindings": "Associações de papéis", + "Roles the matter is escalated to at this rung.": "Papéis para os quais o assunto é escalonado neste degrau.", + "Roles told at this rung; handler is the subject's own performer.": "Papéis avisados neste degrau; handler é a pessoa responsável pelo assunto.", + "Rungs": "Degraus", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identidade estável do degrau, p. ex. preBreach:14:calendarDays; quando ausente, deriva do gatilho, do deslocamento e da unidade.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "O nome estável do calendário, referenciado pelo calendar_slug de um temporizador.", + "The closed date, Y-m-d.": "A data de encerramento, Y-m-d.", + "The holiday's name.": "O nome do feriado.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "O nome estável da escada, referenciado pelo ladder_slug de um temporizador.", + "The unit of the offset: the same set the SLA accepts.": "A unidade do deslocamento: o mesmo conjunto que o SLA aceita.", + "The urgency the raised transition carries.": "A urgência transportada pela transição disparada.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "O dia da semana (nome) em que a data é transferida, p. ex. sunday para Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID da organização para a qual este calendário está configurado. Vazio para um calendário partilhado. Se o temporizador não nomear nenhum, a ordem de resolução escolhe o calendário da organização.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID da organização para a qual esta escada é a predefinida. Vazio para a predefinição partilhada.", + "When weekday": "Se calhar a", + "Whether reaching this rung opens an incident.": "Se alcançar este degrau abre um incidente.", + "Why the day is closed.": "Porque é que o dia está encerrado.", + "Working calendar": "Calendário de trabalho", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Horas trabalhadas num dia útil. Torna comparáveis horas e dias úteis.", + "Working weekdays": "Dias úteis da semana", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mês e dia; easter: um deslocamento em dias a partir do Domingo de Páscoa; observedShift: mês e dia com transferência obrigatória.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach dispara antes do prazo, slaBreached no prazo ou depois." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/rm.js b/l10n/rm.js index 4cb1605430..c216602bc0 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objeto eliminado com sucesso","Objetos eliminados com sucesso"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Expurgar {count} objeto da base de dados","Expurgar {count} objetos da base de dados"], "_Restore {count} object_::_Restore {count} objects_": ["Restaurar {count} objeto","Restaurar {count} objetos"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objeto restaurado com sucesso","{count} objetos restaurados com sucesso"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objeto restaurado com sucesso","{count} objetos restaurados com sucesso"], + "A message identity the notification subsystem resolves.": "In'identitad da messadi che il sistem da notificaziuns resolva.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Reglas calculadas dals dis betg da lavur. Gener fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Gener easter: {offset, name}, spostament en dis a partir da la dumengia da Pasca. Gener observedShift: ina data fixa cun spostament obligatoric.", + "Date": "Data", + "Day": "Di", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Il di d'ina regla fixed u observedShift; ina regla easter al ignorescha.", + "Days": "Dis", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dis a partir da la dumengia da Pasca per ina regla easter; ils auters geners als ignoreschan.", + "Distance from the deadline, in the offset unit.": "Distanza dal termin, en l'unitad dal spostament.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Serradas singulas enumeradas sco {date, name}. Lubidas sper las reglas; refusadas sco l'entir chalender.", + "Escalate to roles": "Rollas d'escalaziun", + "Escalation ladder": "S-chala d'escalaziun", + "Exception dates": "Datas d'excepziun", + "Hours per working day": "Uras per di da lavur", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Quants dis che la data festivada vegn spustada, p.ex. -1 per la sonda avant.", + "Human-readable title.": "Titel legibel.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Dis ISO da l'emna (1 = glindesdi, 7 = dumengia) che èn dis da lavur.", + "Kind": "Gener", + "Message": "Messadi", + "Month": "Mais", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Il mais d'ina regla fixed u observedShift; ina regla easter al ignorescha.", + "Non-working-date rules": "Reglas dals dis betg da lavur", + "Notify roles": "Rollas d'infurmar", + "Observed shift": "Spostament da la festa", + "Offset": "Spostament", + "Offset unit": "Unitad dal spostament", + "Open incident": "Avrir in incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Charta facultativa d'in num da rolla ad in retschavider concret, p.ex. {\"teamleader\": \"group:teamleaders\"}. Rollas senza colliaziun viageschan betg resolvidas sco role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Facultativ tar ina regla fixed, obligatoric tar observedShift: {whenWeekday: num dal di u ISO 1..7, days: dumber entir}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "S-chalims ordinads: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritad", + "Role bindings": "Colliaziuns da rollas", + "Roles the matter is escalated to at this rung.": "Rollas a las qualas il cas vegn escalà sin quest s-chalim.", + "Roles told at this rung; handler is the subject's own performer.": "Rollas infurmadas sin quest s-chalim; handler è la persuna che tracta il cas.", + "Rungs": "S-chalims", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identitad stabila dal s-chalim, p.ex. preBreach:14:calendarDays; sche ella manca, vegn ella derivada da l'impuls, dal spostament e da l'unitad.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Il num stabil dal chalender, al qual in timer sa referescha cun calendar_slug.", + "The closed date, Y-m-d.": "La data da la serrada, Y-m-d.", + "The holiday's name.": "Il num da la festa.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Il num stabil da la s-chala, a la quala in timer sa referescha cun ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "L'unitad dal spostament: il medem set che il SLA accepta.", + "The urgency the raised transition carries.": "L'urgenza che la transiziun activada porta.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Il di da l'emna (num) cura che la data vegn spustada, p.ex. sunday per Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID da l'organisaziun per la quala quest chalender è configurà. Vid per in chalender cundivis. Sch'il timer na numna nagin, tscherna l'urden da resoluziun il chalender da l'organisaziun.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID da l'organisaziun per la quala questa s-chala è il standard. Vid per il standard cundivis.", + "When weekday": "Sche la data croda sin", + "Whether reaching this rung opens an incident.": "Sche cuntanscher quest s-chalim avra in incident.", + "Why the day is closed.": "Pertge che quest di è serrà.", + "Working calendar": "Chalender da lavur", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Uras da lavur en in di da lavur. Renda uras e dis da lavur cumparegliabels.", + "Working weekdays": "Dis da lavur da l'emna", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mais e di; easter: in spostament en dis a partir da la dumengia da Pasca; observedShift: mais e di cun spostament obligatoric.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach vegn activà avant il termin, slaBreached al termin u suenter." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 7179c58875..51fcf0c9e7 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} objeto restaurado com sucesso", "{count} objetos restaurados com sucesso" - ] + ], + "A message identity the notification subsystem resolves.": "In'identitad da messadi che il sistem da notificaziuns resolva.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Reglas calculadas dals dis betg da lavur. Gener fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Gener easter: {offset, name}, spostament en dis a partir da la dumengia da Pasca. Gener observedShift: ina data fixa cun spostament obligatoric.", + "Date": "Data", + "Day": "Di", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Il di d'ina regla fixed u observedShift; ina regla easter al ignorescha.", + "Days": "Dis", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dis a partir da la dumengia da Pasca per ina regla easter; ils auters geners als ignoreschan.", + "Distance from the deadline, in the offset unit.": "Distanza dal termin, en l'unitad dal spostament.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Serradas singulas enumeradas sco {date, name}. Lubidas sper las reglas; refusadas sco l'entir chalender.", + "Escalate to roles": "Rollas d'escalaziun", + "Escalation ladder": "S-chala d'escalaziun", + "Exception dates": "Datas d'excepziun", + "Hours per working day": "Uras per di da lavur", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Quants dis che la data festivada vegn spustada, p.ex. -1 per la sonda avant.", + "Human-readable title.": "Titel legibel.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Dis ISO da l'emna (1 = glindesdi, 7 = dumengia) che èn dis da lavur.", + "Kind": "Gener", + "Message": "Messadi", + "Month": "Mais", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Il mais d'ina regla fixed u observedShift; ina regla easter al ignorescha.", + "Non-working-date rules": "Reglas dals dis betg da lavur", + "Notify roles": "Rollas d'infurmar", + "Observed shift": "Spostament da la festa", + "Offset": "Spostament", + "Offset unit": "Unitad dal spostament", + "Open incident": "Avrir in incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Charta facultativa d'in num da rolla ad in retschavider concret, p.ex. {\"teamleader\": \"group:teamleaders\"}. Rollas senza colliaziun viageschan betg resolvidas sco role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Facultativ tar ina regla fixed, obligatoric tar observedShift: {whenWeekday: num dal di u ISO 1..7, days: dumber entir}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "S-chalims ordinads: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritad", + "Role bindings": "Colliaziuns da rollas", + "Roles the matter is escalated to at this rung.": "Rollas a las qualas il cas vegn escalà sin quest s-chalim.", + "Roles told at this rung; handler is the subject's own performer.": "Rollas infurmadas sin quest s-chalim; handler è la persuna che tracta il cas.", + "Rungs": "S-chalims", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identitad stabila dal s-chalim, p.ex. preBreach:14:calendarDays; sche ella manca, vegn ella derivada da l'impuls, dal spostament e da l'unitad.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Il num stabil dal chalender, al qual in timer sa referescha cun calendar_slug.", + "The closed date, Y-m-d.": "La data da la serrada, Y-m-d.", + "The holiday's name.": "Il num da la festa.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Il num stabil da la s-chala, a la quala in timer sa referescha cun ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "L'unitad dal spostament: il medem set che il SLA accepta.", + "The urgency the raised transition carries.": "L'urgenza che la transiziun activada porta.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Il di da l'emna (num) cura che la data vegn spustada, p.ex. sunday per Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID da l'organisaziun per la quala quest chalender è configurà. Vid per in chalender cundivis. Sch'il timer na numna nagin, tscherna l'urden da resoluziun il chalender da l'organisaziun.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID da l'organisaziun per la quala questa s-chala è il standard. Vid per il standard cundivis.", + "When weekday": "Sche la data croda sin", + "Whether reaching this rung opens an incident.": "Sche cuntanscher quest s-chalim avra in incident.", + "Why the day is closed.": "Pertge che quest di è serrà.", + "Working calendar": "Chalender da lavur", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Uras da lavur en in di da lavur. Renda uras e dis da lavur cumparegliabels.", + "Working weekdays": "Dis da lavur da l'emna", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mais e di; easter: in spostament en dis a partir da la dumengia da Pasca; observedShift: mais e di cun spostament obligatoric.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach vegn activà avant il termin, slaBreached al termin u suenter." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/ro.js b/l10n/ro.js index 9c7193534d..69c738fe46 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Obiectul a fost șters cu succes","Obiectele au fost șterse cu succes","Obiectele au fost șterse cu succes"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Epurați {count} obiect din baza de date","Epurați {count} obiecte din baza de date","Epurați {count} de obiecte din baza de date"], "_Restore {count} object_::_Restore {count} objects_": ["Restaurați {count} obiect","Restaurați {count} obiecte","Restaurați {count} de obiecte"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["S-a restaurat cu succes {count} obiect","S-au restaurat cu succes {count} obiecte","S-au restaurat cu succes {count} de obiecte"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["S-a restaurat cu succes {count} obiect","S-au restaurat cu succes {count} obiecte","S-au restaurat cu succes {count} de obiecte"], + "A message identity the notification subsystem resolves.": "O identitate de mesaj pe care o rezolvă sistemul de notificări.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Reguli calculate pentru zilele nelucrătoare. Fel fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Fel easter: {offset, name}, decalaj în zile de la Duminica Paștelui. Fel observedShift: o dată fixă cu mutare obligatorie.", + "Date": "Dată", + "Day": "Zi", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Ziua unei reguli fixed sau observedShift; o regulă easter o ignoră.", + "Days": "Zile", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Zile de la Duminica Paștelui pentru o regulă easter; celelalte feluri le ignoră.", + "Distance from the deadline, in the offset unit.": "Distanța până la termen, în unitatea decalajului.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Închideri punctuale enumerate ca {date, name}. Permise pe lângă reguli; refuzate drept întregul calendar.", + "Escalate to roles": "Roluri de escaladare", + "Escalation ladder": "Scară de escaladare", + "Exception dates": "Date de excepție", + "Hours per working day": "Ore pe zi lucrătoare", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Cu câte zile se mută data sărbătorită, de ex. -1 pentru sâmbăta dinainte.", + "Human-readable title.": "Titlu lizibil.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Zilele ISO ale săptămânii (1 = luni, 7 = duminică) ce sunt lucrătoare.", + "Kind": "Fel", + "Message": "Mesaj", + "Month": "Lună", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Luna unei reguli fixed sau observedShift; o regulă easter o ignoră.", + "Non-working-date rules": "Reguli pentru zilele nelucrătoare", + "Notify roles": "Roluri de anunțat", + "Observed shift": "Mutarea sărbătorii", + "Offset": "Decalaj", + "Offset unit": "Unitatea decalajului", + "Open incident": "Deschide un incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Hartă opțională de la un nume de rol la un destinatar concret, de ex. {\"teamleader\": \"group:teamleaders\"}. Rolurile fără legătură călătoresc nerezolvate ca role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opțional la o regulă fixed, obligatoriu la observedShift: {whenWeekday: numele zilei sau ISO 1..7, days: număr întreg}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Trepte ordonate: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritate", + "Role bindings": "Legături de roluri", + "Roles the matter is escalated to at this rung.": "Rolurile către care se escaladează dosarul la această treaptă.", + "Roles told at this rung; handler is the subject's own performer.": "Rolurile anunțate la această treaptă; handler este persoana care se ocupă de dosar.", + "Rungs": "Trepte", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identitatea stabilă a treptei, de ex. preBreach:14:calendarDays; când lipsește, e derivată din declanșator, decalaj și unitate.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Numele stabil al calendarului, referit prin calendar_slug al unui cronometru.", + "The closed date, Y-m-d.": "Data închiderii, Y-m-d.", + "The holiday's name.": "Numele sărbătorii.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Numele stabil al scării, referit prin ladder_slug al unui cronometru.", + "The unit of the offset: the same set the SLA accepts.": "Unitatea decalajului: același set pe care îl acceptă SLA-ul.", + "The urgency the raised transition carries.": "Urgența purtată de tranziția declanșată.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Ziua săptămânii (nume) la care data se mută, de ex. sunday pentru Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID-ul organizației pentru care este configurat acest calendar. Gol pentru un calendar partajat. Dacă cronometrul nu numește niciunul, ordinea de rezolvare alege calendarul organizației.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID-ul organizației pentru care această scară este implicită. Gol pentru implicitul partajat.", + "When weekday": "Dacă pică într-o zi de", + "Whether reaching this rung opens an incident.": "Dacă atingerea acestei trepte deschide un incident.", + "Why the day is closed.": "De ce este închisă ziua.", + "Working calendar": "Calendar de lucru", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Ore lucrate într-o zi lucrătoare. Face comparabile orele și zilele lucrătoare.", + "Working weekdays": "Zilele lucrătoare ale săptămânii", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: lună și zi; easter: un decalaj în zile de la Duminica Paștelui; observedShift: lună și zi cu mutare obligatorie.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se declanșează înaintea termenului, slaBreached la termen sau după." }, "nplurals=3; plural=(n==1?0:(((n%100>19)||((n%100==0)&&(n!=0)))?2:1));" ) diff --git a/l10n/ro.json b/l10n/ro.json index 4d4072b2a3..7c6f1095de 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -2801,7 +2801,59 @@ "S-a restaurat cu succes {count} obiect", "S-au restaurat cu succes {count} obiecte", "S-au restaurat cu succes {count} de obiecte" - ] + ], + "A message identity the notification subsystem resolves.": "O identitate de mesaj pe care o rezolvă sistemul de notificări.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Reguli calculate pentru zilele nelucrătoare. Fel fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Fel easter: {offset, name}, decalaj în zile de la Duminica Paștelui. Fel observedShift: o dată fixă cu mutare obligatorie.", + "Date": "Dată", + "Day": "Zi", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Ziua unei reguli fixed sau observedShift; o regulă easter o ignoră.", + "Days": "Zile", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Zile de la Duminica Paștelui pentru o regulă easter; celelalte feluri le ignoră.", + "Distance from the deadline, in the offset unit.": "Distanța până la termen, în unitatea decalajului.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Închideri punctuale enumerate ca {date, name}. Permise pe lângă reguli; refuzate drept întregul calendar.", + "Escalate to roles": "Roluri de escaladare", + "Escalation ladder": "Scară de escaladare", + "Exception dates": "Date de excepție", + "Hours per working day": "Ore pe zi lucrătoare", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Cu câte zile se mută data sărbătorită, de ex. -1 pentru sâmbăta dinainte.", + "Human-readable title.": "Titlu lizibil.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Zilele ISO ale săptămânii (1 = luni, 7 = duminică) ce sunt lucrătoare.", + "Kind": "Fel", + "Message": "Mesaj", + "Month": "Lună", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Luna unei reguli fixed sau observedShift; o regulă easter o ignoră.", + "Non-working-date rules": "Reguli pentru zilele nelucrătoare", + "Notify roles": "Roluri de anunțat", + "Observed shift": "Mutarea sărbătorii", + "Offset": "Decalaj", + "Offset unit": "Unitatea decalajului", + "Open incident": "Deschide un incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Hartă opțională de la un nume de rol la un destinatar concret, de ex. {\"teamleader\": \"group:teamleaders\"}. Rolurile fără legătură călătoresc nerezolvate ca role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opțional la o regulă fixed, obligatoriu la observedShift: {whenWeekday: numele zilei sau ISO 1..7, days: număr întreg}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Trepte ordonate: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prioritate", + "Role bindings": "Legături de roluri", + "Roles the matter is escalated to at this rung.": "Rolurile către care se escaladează dosarul la această treaptă.", + "Roles told at this rung; handler is the subject's own performer.": "Rolurile anunțate la această treaptă; handler este persoana care se ocupă de dosar.", + "Rungs": "Trepte", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identitatea stabilă a treptei, de ex. preBreach:14:calendarDays; când lipsește, e derivată din declanșator, decalaj și unitate.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Numele stabil al calendarului, referit prin calendar_slug al unui cronometru.", + "The closed date, Y-m-d.": "Data închiderii, Y-m-d.", + "The holiday's name.": "Numele sărbătorii.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Numele stabil al scării, referit prin ladder_slug al unui cronometru.", + "The unit of the offset: the same set the SLA accepts.": "Unitatea decalajului: același set pe care îl acceptă SLA-ul.", + "The urgency the raised transition carries.": "Urgența purtată de tranziția declanșată.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Ziua săptămânii (nume) la care data se mută, de ex. sunday pentru Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID-ul organizației pentru care este configurat acest calendar. Gol pentru un calendar partajat. Dacă cronometrul nu numește niciunul, ordinea de rezolvare alege calendarul organizației.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID-ul organizației pentru care această scară este implicită. Gol pentru implicitul partajat.", + "When weekday": "Dacă pică într-o zi de", + "Whether reaching this rung opens an incident.": "Dacă atingerea acestei trepte deschide un incident.", + "Why the day is closed.": "De ce este închisă ziua.", + "Working calendar": "Calendar de lucru", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Ore lucrate într-o zi lucrătoare. Face comparabile orele și zilele lucrătoare.", + "Working weekdays": "Zilele lucrătoare ale săptămânii", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: lună și zi; easter: un decalaj în zile de la Duminica Paștelui; observedShift: lună și zi cu mutare obligatorie.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se declanșează înaintea termenului, slaBreached la termen sau după." }, "pluralForm": "nplurals=3; plural=(n==1?0:(((n%100>19)||((n%100==0)&&(n!=0)))?2:1));", "plurals": { diff --git a/l10n/ru.js b/l10n/ru.js index 635bb23f80..9fe648cc02 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Объект успешно удалён","Объекты успешно удалены","Объекты успешно удалены","Объекты успешно удалены"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Очистить {count} объект из базы данных","Очистить {count} объекта из базы данных","Очистить {count} объектов из базы данных","Очистить {count} объекта из базы данных"], "_Restore {count} object_::_Restore {count} objects_": ["Восстановить {count} объект","Восстановить {count} объекта","Восстановить {count} объектов","Восстановить {count} объекта"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Успешно восстановлен {count} объект","Успешно восстановлено {count} объекта","Успешно восстановлено {count} объектов","Успешно восстановлено {count} объекта"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Успешно восстановлен {count} объект","Успешно восстановлено {count} объекта","Успешно восстановлено {count} объектов","Успешно восстановлено {count} объекта"], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);" ) diff --git a/l10n/ru.json b/l10n/ru.json index 8d388540df..1f61994fee 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -2817,7 +2817,59 @@ "Успешно восстановлено {count} объекта", "Успешно восстановлено {count} объектов", "Успешно восстановлено {count} объекта" - ] + ], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "pluralForm": "nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);", "plurals": { diff --git a/l10n/sk.js b/l10n/sk.js index f4330cc41d..d5713ad55f 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt bol úspešne odstránený","Objekty boli úspešne odstránené","Objekty boli úspešne odstránené","Objekty boli úspešne odstránené"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Vymazať {count} objekt z databázy","Vymazať {count} objekty z databázy","Vymazať {count} objektu z databázy","Vymazať {count} objektov z databázy"], "_Restore {count} object_::_Restore {count} objects_": ["Obnoviť {count} objekt","Obnoviť {count} objekty","Obnoviť {count} objektu","Obnoviť {count} objektov"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Úspešne obnovený {count} objekt","Úspešne obnovené {count} objekty","Úspešne obnovených {count} objektu","Úspešne obnovených {count} objektov"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Úspešne obnovený {count} objekt","Úspešne obnovené {count} objekty","Úspešne obnovených {count} objektu","Úspešne obnovených {count} objektov"], + "A message identity the notification subsystem resolves.": "Identita správy, ktorú rozlíši systém upozornení.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Vypočítané pravidlá nepracovných dní. Druh fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Druh easter: {offset, name}, odstup v dňoch od Veľkonočnej nedele. Druh observedShift: pevný dátum s povinným presunom.", + "Date": "Dátum", + "Day": "Deň", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Deň pravidla fixed alebo observedShift; pravidlo easter ho ignoruje.", + "Days": "Dni", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dni od Veľkonočnej nedele pre pravidlo easter; ostatné druhy ich ignorujú.", + "Distance from the deadline, in the offset unit.": "Vzdialenosť od lehoty v jednotke odstupu.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Jednorazové zatvorenia vymenované ako {date, name}. Povolené popri pravidlách; odmietnuté ako celý kalendár.", + "Escalate to roles": "Roly pre eskaláciu", + "Escalation ladder": "Eskalačný rebrík", + "Exception dates": "Výnimočné dátumy", + "Hours per working day": "Hodín na pracovný deň", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "O koľko dní sa slávený dátum presunie, napr. -1 pre predchádzajúcu sobotu.", + "Human-readable title.": "Čitateľný názov.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dni v týždni (1 = pondelok, 7 = nedeľa), ktoré sú pracovné.", + "Kind": "Druh", + "Message": "Správa", + "Month": "Mesiac", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mesiac pravidla fixed alebo observedShift; pravidlo easter ho ignoruje.", + "Non-working-date rules": "Pravidlá nepracovných dní", + "Notify roles": "Roly na upozornenie", + "Observed shift": "Presun sviatku", + "Offset": "Odstup", + "Offset unit": "Jednotka odstupu", + "Open incident": "Otvoriť incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Voliteľná mapa z názvu roly na konkrétneho príjemcu, napr. {\"teamleader\": \"group:teamleaders\"}. Roly bez väzby putujú nerozlíšené ako role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Voliteľné pri pravidle fixed, povinné pri observedShift: {whenWeekday: názov dňa alebo ISO 1..7, days: celé číslo}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Zoradené priečky: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorita", + "Role bindings": "Väzby rolí", + "Roles the matter is escalated to at this rung.": "Roly, na ktoré sa vec na tejto priečke eskaluje.", + "Roles told at this rung; handler is the subject's own performer.": "Roly upozornené na tejto priečke; handler je osoba, ktorá vec vybavuje.", + "Rungs": "Priečky", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stála identita priečky, napr. preBreach:14:calendarDays; ak chýba, odvodí sa zo spúšťača, odstupu a jednotky.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stály názov kalendára, na ktorý časovač odkazuje cez calendar_slug.", + "The closed date, Y-m-d.": "Dátum zatvorenia, Y-m-d.", + "The holiday's name.": "Názov sviatku.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stály názov rebríka, na ktorý časovač odkazuje cez ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Jednotka odstupu: rovnaká množina, akú prijíma SLA.", + "The urgency the raised transition carries.": "Naliehavosť, ktorú nesie vyvolaný prechod.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Deň v týždni (názov), pri ktorom sa dátum presunie, napr. sunday pre Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizácie, pre ktorú je tento kalendár nastavený. Prázdne pri zdieľanom kalendári. Ak časovač žiadny nemenuje, poradie rozlíšenia vyberie kalendár organizácie.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizácie, pre ktorú je tento rebrík predvolený. Prázdne pri zdieľanom predvolenom.", + "When weekday": "Ak pripadne na", + "Whether reaching this rung opens an incident.": "Či dosiahnutie tejto priečky otvorí incident.", + "Why the day is closed.": "Prečo je deň zatvorený.", + "Working calendar": "Pracovný kalendár", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Odpracované hodiny v jednom pracovnom dni. Robí hodiny a pracovné dni porovnateľnými.", + "Working weekdays": "Pracovné dni v týždni", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mesiac a deň; easter: odstup v dňoch od Veľkonočnej nedele; observedShift: mesiac a deň s povinným presunom.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach sa spúšťa pred lehotou, slaBreached v lehote alebo po nej." }, "nplurals=4; plural=(n % 1 == 0 && n == 1 ? 0 : n % 1 == 0 && n >= 2 && n <= 4 ? 1 : n % 1 != 0 ? 2: 3);" ) diff --git a/l10n/sk.json b/l10n/sk.json index fd364af032..85b8e4f801 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -2817,7 +2817,59 @@ "Úspešne obnovené {count} objekty", "Úspešne obnovených {count} objektu", "Úspešne obnovených {count} objektov" - ] + ], + "A message identity the notification subsystem resolves.": "Identita správy, ktorú rozlíši systém upozornení.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Vypočítané pravidlá nepracovných dní. Druh fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Druh easter: {offset, name}, odstup v dňoch od Veľkonočnej nedele. Druh observedShift: pevný dátum s povinným presunom.", + "Date": "Dátum", + "Day": "Deň", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Deň pravidla fixed alebo observedShift; pravidlo easter ho ignoruje.", + "Days": "Dni", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dni od Veľkonočnej nedele pre pravidlo easter; ostatné druhy ich ignorujú.", + "Distance from the deadline, in the offset unit.": "Vzdialenosť od lehoty v jednotke odstupu.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Jednorazové zatvorenia vymenované ako {date, name}. Povolené popri pravidlách; odmietnuté ako celý kalendár.", + "Escalate to roles": "Roly pre eskaláciu", + "Escalation ladder": "Eskalačný rebrík", + "Exception dates": "Výnimočné dátumy", + "Hours per working day": "Hodín na pracovný deň", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "O koľko dní sa slávený dátum presunie, napr. -1 pre predchádzajúcu sobotu.", + "Human-readable title.": "Čitateľný názov.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dni v týždni (1 = pondelok, 7 = nedeľa), ktoré sú pracovné.", + "Kind": "Druh", + "Message": "Správa", + "Month": "Mesiac", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mesiac pravidla fixed alebo observedShift; pravidlo easter ho ignoruje.", + "Non-working-date rules": "Pravidlá nepracovných dní", + "Notify roles": "Roly na upozornenie", + "Observed shift": "Presun sviatku", + "Offset": "Odstup", + "Offset unit": "Jednotka odstupu", + "Open incident": "Otvoriť incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Voliteľná mapa z názvu roly na konkrétneho príjemcu, napr. {\"teamleader\": \"group:teamleaders\"}. Roly bez väzby putujú nerozlíšené ako role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Voliteľné pri pravidle fixed, povinné pri observedShift: {whenWeekday: názov dňa alebo ISO 1..7, days: celé číslo}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Zoradené priečky: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priorita", + "Role bindings": "Väzby rolí", + "Roles the matter is escalated to at this rung.": "Roly, na ktoré sa vec na tejto priečke eskaluje.", + "Roles told at this rung; handler is the subject's own performer.": "Roly upozornené na tejto priečke; handler je osoba, ktorá vec vybavuje.", + "Rungs": "Priečky", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stála identita priečky, napr. preBreach:14:calendarDays; ak chýba, odvodí sa zo spúšťača, odstupu a jednotky.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stály názov kalendára, na ktorý časovač odkazuje cez calendar_slug.", + "The closed date, Y-m-d.": "Dátum zatvorenia, Y-m-d.", + "The holiday's name.": "Názov sviatku.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stály názov rebríka, na ktorý časovač odkazuje cez ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Jednotka odstupu: rovnaká množina, akú prijíma SLA.", + "The urgency the raised transition carries.": "Naliehavosť, ktorú nesie vyvolaný prechod.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Deň v týždni (názov), pri ktorom sa dátum presunie, napr. sunday pre Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizácie, pre ktorú je tento kalendár nastavený. Prázdne pri zdieľanom kalendári. Ak časovač žiadny nemenuje, poradie rozlíšenia vyberie kalendár organizácie.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizácie, pre ktorú je tento rebrík predvolený. Prázdne pri zdieľanom predvolenom.", + "When weekday": "Ak pripadne na", + "Whether reaching this rung opens an incident.": "Či dosiahnutie tejto priečky otvorí incident.", + "Why the day is closed.": "Prečo je deň zatvorený.", + "Working calendar": "Pracovný kalendár", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Odpracované hodiny v jednom pracovnom dni. Robí hodiny a pracovné dni porovnateľnými.", + "Working weekdays": "Pracovné dni v týždni", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mesiac a deň; easter: odstup v dňoch od Veľkonočnej nedele; observedShift: mesiac a deň s povinným presunom.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach sa spúšťa pred lehotou, slaBreached v lehote alebo po nej." }, "pluralForm": "nplurals=4; plural=(n % 1 == 0 && n == 1 ? 0 : n % 1 == 0 && n >= 2 && n <= 4 ? 1 : n % 1 != 0 ? 2: 3);", "plurals": { diff --git a/l10n/sl.js b/l10n/sl.js index 9b919c67d5..a4ec11d71a 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt je uspešno izbrisan","Objekta sta uspešno izbrisana","Objekti so uspešno izbrisani","Objekti so uspešno izbrisani"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Trajno odstrani {count} objekt iz baze podatkov","Trajno odstrani {count} objekta iz baze podatkov","Trajno odstrani {count} objekte iz baze podatkov","Trajno odstrani {count} objektov iz baze podatkov"], "_Restore {count} object_::_Restore {count} objects_": ["Obnovi {count} objekt","Obnovi {count} objekta","Obnovi {count} objekte","Obnovi {count} objektov"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Uspešno obnovljen {count} objekt","Uspešno obnovljena {count} objekta","Uspešno obnovljeni {count} objekti","Uspešno obnovljenih {count} objektov"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Uspešno obnovljen {count} objekt","Uspešno obnovljena {count} objekta","Uspešno obnovljeni {count} objekti","Uspešno obnovljenih {count} objektov"], + "A message identity the notification subsystem resolves.": "Istovetnost sporočila, ki jo razreši sistem obvestil.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Izračunana pravila nedelovnih dni. Vrsta fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Vrsta easter: {offset, name}, odmik v dneh od velikonočne nedelje. Vrsta observedShift: nespremenljiv datum z obvezno prestavitvijo.", + "Date": "Datum", + "Day": "Dan", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dan pravila fixed ali observedShift; pravilo easter ga prezre.", + "Days": "Dnevi", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dnevi od velikonočne nedelje za pravilo easter; druge vrste jih prezrejo.", + "Distance from the deadline, in the offset unit.": "Razdalja do roka v enoti odmika.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Posamezna zaprtja, našteta kot {date, name}. Dovoljena ob pravilih; zavrnjena kot celoten koledar.", + "Escalate to roles": "Vloge za stopnjevanje", + "Escalation ladder": "Stopnjevalna lestev", + "Exception dates": "Izjemni datumi", + "Hours per working day": "Ur na delovni dan", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Za koliko dni se praznovani datum prestavi, npr. -1 za soboto prej.", + "Human-readable title.": "Berljiv naslov.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dnevi v tednu (1 = ponedeljek, 7 = nedelja), ki so delovni.", + "Kind": "Vrsta", + "Message": "Sporočilo", + "Month": "Mesec", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mesec pravila fixed ali observedShift; pravilo easter ga prezre.", + "Non-working-date rules": "Pravila nedelovnih dni", + "Notify roles": "Vloge za obveščanje", + "Observed shift": "Prestavitev praznika", + "Offset": "Odmik", + "Offset unit": "Enota odmika", + "Open incident": "Odpri incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neobvezna preslikava imena vloge v konkretnega prejemnika, npr. {\"teamleader\": \"group:teamleaders\"}. Vloge brez vezave potujejo nerazrešene kot role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neobvezno pri pravilu fixed, obvezno pri observedShift: {whenWeekday: ime dneva ali ISO 1..7, days: celo število}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Urejene prečke: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prednost", + "Role bindings": "Vezave vlog", + "Roles the matter is escalated to at this rung.": "Vloge, na katere se zadeva na tej prečki stopnjuje.", + "Roles told at this rung; handler is the subject's own performer.": "Vloge, obveščene na tej prečki; handler je oseba, ki zadevo obravnava.", + "Rungs": "Prečke", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stalna istovetnost prečke, npr. preBreach:14:calendarDays; če manjka, se izpelje iz sprožilca, odmika in enote.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stalno ime koledarja, na katero se časovnik sklicuje prek calendar_slug.", + "The closed date, Y-m-d.": "Datum zaprtja, Y-m-d.", + "The holiday's name.": "Ime praznika.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stalno ime lestve, na katero se časovnik sklicuje prek ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Enota odmika: ista množica, kot jo sprejme SLA.", + "The urgency the raised transition carries.": "Nujnost, ki jo nosi sproženi prehod.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Dan v tednu (ime), ob katerem se datum prestavi, npr. sunday za Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizacije, za katero je ta koledar nastavljen. Prazno pri skupnem koledarju. Če časovnik ne imenuje nobenega, vrstni red razreševanja izbere koledar organizacije.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizacije, za katero je ta lestev privzeta. Prazno pri skupni privzeti.", + "When weekday": "Če pade na", + "Whether reaching this rung opens an incident.": "Ali dosežena prečka odpre incident.", + "Why the day is closed.": "Zakaj je dan zaprt.", + "Working calendar": "Delovni koledar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Delovne ure v enem delovnem dnevu. Naredi ure in delovne dni primerljive.", + "Working weekdays": "Delovni dnevi v tednu", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mesec in dan; easter: odmik v dneh od velikonočne nedelje; observedShift: mesec in dan z obvezno prestavitvijo.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se sproži pred rokom, slaBreached ob roku ali po njem." }, "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);" ) diff --git a/l10n/sl.json b/l10n/sl.json index 7d98c6b9b2..bcad122dfd 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -2817,7 +2817,59 @@ "Uspešno obnovljena {count} objekta", "Uspešno obnovljeni {count} objekti", "Uspešno obnovljenih {count} objektov" - ] + ], + "A message identity the notification subsystem resolves.": "Istovetnost sporočila, ki jo razreši sistem obvestil.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Izračunana pravila nedelovnih dni. Vrsta fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Vrsta easter: {offset, name}, odmik v dneh od velikonočne nedelje. Vrsta observedShift: nespremenljiv datum z obvezno prestavitvijo.", + "Date": "Datum", + "Day": "Dan", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dan pravila fixed ali observedShift; pravilo easter ga prezre.", + "Days": "Dnevi", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Dnevi od velikonočne nedelje za pravilo easter; druge vrste jih prezrejo.", + "Distance from the deadline, in the offset unit.": "Razdalja do roka v enoti odmika.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Posamezna zaprtja, našteta kot {date, name}. Dovoljena ob pravilih; zavrnjena kot celoten koledar.", + "Escalate to roles": "Vloge za stopnjevanje", + "Escalation ladder": "Stopnjevalna lestev", + "Exception dates": "Izjemni datumi", + "Hours per working day": "Ur na delovni dan", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Za koliko dni se praznovani datum prestavi, npr. -1 za soboto prej.", + "Human-readable title.": "Berljiv naslov.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO dnevi v tednu (1 = ponedeljek, 7 = nedelja), ki so delovni.", + "Kind": "Vrsta", + "Message": "Sporočilo", + "Month": "Mesec", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Mesec pravila fixed ali observedShift; pravilo easter ga prezre.", + "Non-working-date rules": "Pravila nedelovnih dni", + "Notify roles": "Vloge za obveščanje", + "Observed shift": "Prestavitev praznika", + "Offset": "Odmik", + "Offset unit": "Enota odmika", + "Open incident": "Odpri incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Neobvezna preslikava imena vloge v konkretnega prejemnika, npr. {\"teamleader\": \"group:teamleaders\"}. Vloge brez vezave potujejo nerazrešene kot role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Neobvezno pri pravilu fixed, obvezno pri observedShift: {whenWeekday: ime dneva ali ISO 1..7, days: celo število}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Urejene prečke: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Prednost", + "Role bindings": "Vezave vlog", + "Roles the matter is escalated to at this rung.": "Vloge, na katere se zadeva na tej prečki stopnjuje.", + "Roles told at this rung; handler is the subject's own performer.": "Vloge, obveščene na tej prečki; handler je oseba, ki zadevo obravnava.", + "Rungs": "Prečke", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stalna istovetnost prečke, npr. preBreach:14:calendarDays; če manjka, se izpelje iz sprožilca, odmika in enote.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Stalno ime koledarja, na katero se časovnik sklicuje prek calendar_slug.", + "The closed date, Y-m-d.": "Datum zaprtja, Y-m-d.", + "The holiday's name.": "Ime praznika.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Stalno ime lestve, na katero se časovnik sklicuje prek ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Enota odmika: ista množica, kot jo sprejme SLA.", + "The urgency the raised transition carries.": "Nujnost, ki jo nosi sproženi prehod.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Dan v tednu (ime), ob katerem se datum prestavi, npr. sunday za Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID organizacije, za katero je ta koledar nastavljen. Prazno pri skupnem koledarju. Če časovnik ne imenuje nobenega, vrstni red razreševanja izbere koledar organizacije.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID organizacije, za katero je ta lestev privzeta. Prazno pri skupni privzeti.", + "When weekday": "Če pade na", + "Whether reaching this rung opens an incident.": "Ali dosežena prečka odpre incident.", + "Why the day is closed.": "Zakaj je dan zaprt.", + "Working calendar": "Delovni koledar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Delovne ure v enem delovnem dnevu. Naredi ure in delovne dni primerljive.", + "Working weekdays": "Delovni dnevi v tednu", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: mesec in dan; easter: odmik v dneh od velikonočne nedelje; observedShift: mesec in dan z obvezno prestavitvijo.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach se sproži pred rokom, slaBreached ob roku ali po njem." }, "pluralForm": "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);", "plurals": { diff --git a/l10n/sq.js b/l10n/sq.js index d8bc753a4f..6d975af9e8 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekti u fshi me sukses","Objektet u fshinë me sukses"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Pastro {count} objekt nga baza e të dhënave","Pastro {count} objekte nga baza e të dhënave"], "_Restore {count} object_::_Restore {count} objects_": ["Ripristino {count} objekti","Ripristino {count} objekte"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objekt u ripristinua me sukses","{count} objekte u ripristinuan me sukses"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objekt u ripristinua me sukses","{count} objekte u ripristinuan me sukses"], + "A message identity the notification subsystem resolves.": "Një identitet mesazhi që e zgjidh sistemi i njoftimeve.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Rregulla të llogaritura të ditëve jo të punës. Lloji fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Lloji easter: {offset, name}, zhvendosje në ditë nga e diela e Pashkëve. Lloji observedShift: një datë fikse me shtyrje të detyrueshme.", + "Date": "Data", + "Day": "Dita", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dita e një rregulli fixed ose observedShift; rregulli easter e shpërfill.", + "Days": "Ditë", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Ditë nga e diela e Pashkëve për një rregull easter; llojet e tjera i shpërfillin.", + "Distance from the deadline, in the offset unit.": "Largësia nga afati, në njësinë e zhvendosjes.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Mbyllje të njëhershme të renditura si {date, name}. Të lejuara krahas rregullave; të refuzuara si kalendari i tërë.", + "Escalate to roles": "Rolet e përshkallëzimit", + "Escalation ladder": "Shkalla e përshkallëzimit", + "Exception dates": "Datat e përjashtimit", + "Hours per working day": "Orë për ditë pune", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Sa ditë shtyhet data e festuar, p.sh. -1 për të shtunën përpara.", + "Human-readable title.": "Titull i lexueshëm.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Ditët ISO të javës (1 = e hënë, 7 = e diel) që janë ditë pune.", + "Kind": "Lloji", + "Message": "Mesazhi", + "Month": "Muaji", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Muaji i një rregulli fixed ose observedShift; rregulli easter e shpërfill.", + "Non-working-date rules": "Rregullat e ditëve jo të punës", + "Notify roles": "Rolet për njoftim", + "Observed shift": "Shtyrja e festës", + "Offset": "Zhvendosja", + "Offset unit": "Njësia e zhvendosjes", + "Open incident": "Hap incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Hartë opsionale nga emri i një roli te një marrës konkret, p.sh. {\"teamleader\": \"group:teamleaders\"}. Rolet pa lidhje udhëtojnë të pazgjidhura si role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opsionale te rregulli fixed, e detyrueshme te observedShift: {whenWeekday: emri i ditës ose ISO 1..7, days: numër i plotë}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Shkallë të renditura: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Përparësia", + "Role bindings": "Lidhjet e roleve", + "Roles the matter is escalated to at this rung.": "Rolet te të cilat çështja përshkallëzohet në këtë shkallë.", + "Roles told at this rung; handler is the subject's own performer.": "Rolet e njoftuara në këtë shkallë; handler është personi që trajton çështjen.", + "Rungs": "Shkallët", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identiteti i qëndrueshëm i shkallës, p.sh. preBreach:14:calendarDays; kur mungon, rrjedh nga shkrehësi, zhvendosja dhe njësia.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Emri i qëndrueshëm i kalendarit, të cilit një kohëmatës i referohet me calendar_slug.", + "The closed date, Y-m-d.": "Data e mbylljes, Y-m-d.", + "The holiday's name.": "Emri i festës.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Emri i qëndrueshëm i shkallës, të cilit një kohëmatës i referohet me ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Njësia e zhvendosjes: i njëjti grup që pranon SLA.", + "The urgency the raised transition carries.": "Urgjenca që mbart kalimi i shkrehur.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Dita e javës (emri) kur data shtyhet, p.sh. sunday për Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID i organizatës për të cilën është konfiguruar ky kalendar. Bosh për një kalendar të përbashkët. Nëse kohëmatësi nuk emërton asnjë, radha e zgjidhjes zgjedh kalendarin e organizatës.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID i organizatës për të cilën kjo shkallë është parazgjedhja. Bosh për parazgjedhjen e përbashkët.", + "When weekday": "Nëse bie ditën e", + "Whether reaching this rung opens an incident.": "Nëse arritja e kësaj shkalle hap një incident.", + "Why the day is closed.": "Pse është e mbyllur ajo ditë.", + "Working calendar": "Kalendari i punës", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Orët e punës në një ditë pune. I bën orët dhe ditët e punës të krahasueshme.", + "Working weekdays": "Ditët e punës të javës", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: muaj dhe ditë; easter: një zhvendosje në ditë nga e diela e Pashkëve; observedShift: muaj dhe ditë me shtyrje të detyrueshme.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach shkrehet para afatit, slaBreached në afat ose pas tij." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index 05b9eb2618..9e7e61193f 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} objekt u ripristinua me sukses", "{count} objekte u ripristinuan me sukses" - ] + ], + "A message identity the notification subsystem resolves.": "Një identitet mesazhi që e zgjidh sistemi i njoftimeve.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Rregulla të llogaritura të ditëve jo të punës. Lloji fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Lloji easter: {offset, name}, zhvendosje në ditë nga e diela e Pashkëve. Lloji observedShift: një datë fikse me shtyrje të detyrueshme.", + "Date": "Data", + "Day": "Dita", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Dita e një rregulli fixed ose observedShift; rregulli easter e shpërfill.", + "Days": "Ditë", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Ditë nga e diela e Pashkëve për një rregull easter; llojet e tjera i shpërfillin.", + "Distance from the deadline, in the offset unit.": "Largësia nga afati, në njësinë e zhvendosjes.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Mbyllje të njëhershme të renditura si {date, name}. Të lejuara krahas rregullave; të refuzuara si kalendari i tërë.", + "Escalate to roles": "Rolet e përshkallëzimit", + "Escalation ladder": "Shkalla e përshkallëzimit", + "Exception dates": "Datat e përjashtimit", + "Hours per working day": "Orë për ditë pune", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Sa ditë shtyhet data e festuar, p.sh. -1 për të shtunën përpara.", + "Human-readable title.": "Titull i lexueshëm.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "Ditët ISO të javës (1 = e hënë, 7 = e diel) që janë ditë pune.", + "Kind": "Lloji", + "Message": "Mesazhi", + "Month": "Muaji", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Muaji i një rregulli fixed ose observedShift; rregulli easter e shpërfill.", + "Non-working-date rules": "Rregullat e ditëve jo të punës", + "Notify roles": "Rolet për njoftim", + "Observed shift": "Shtyrja e festës", + "Offset": "Zhvendosja", + "Offset unit": "Njësia e zhvendosjes", + "Open incident": "Hap incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Hartë opsionale nga emri i një roli te një marrës konkret, p.sh. {\"teamleader\": \"group:teamleaders\"}. Rolet pa lidhje udhëtojnë të pazgjidhura si role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Opsionale te rregulli fixed, e detyrueshme te observedShift: {whenWeekday: emri i ditës ose ISO 1..7, days: numër i plotë}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Shkallë të renditura: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Përparësia", + "Role bindings": "Lidhjet e roleve", + "Roles the matter is escalated to at this rung.": "Rolet te të cilat çështja përshkallëzohet në këtë shkallë.", + "Roles told at this rung; handler is the subject's own performer.": "Rolet e njoftuara në këtë shkallë; handler është personi që trajton çështjen.", + "Rungs": "Shkallët", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Identiteti i qëndrueshëm i shkallës, p.sh. preBreach:14:calendarDays; kur mungon, rrjedh nga shkrehësi, zhvendosja dhe njësia.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Emri i qëndrueshëm i kalendarit, të cilit një kohëmatës i referohet me calendar_slug.", + "The closed date, Y-m-d.": "Data e mbylljes, Y-m-d.", + "The holiday's name.": "Emri i festës.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Emri i qëndrueshëm i shkallës, të cilit një kohëmatës i referohet me ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Njësia e zhvendosjes: i njëjti grup që pranon SLA.", + "The urgency the raised transition carries.": "Urgjenca që mbart kalimi i shkrehur.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Dita e javës (emri) kur data shtyhet, p.sh. sunday për Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID i organizatës për të cilën është konfiguruar ky kalendar. Bosh për një kalendar të përbashkët. Nëse kohëmatësi nuk emërton asnjë, radha e zgjidhjes zgjedh kalendarin e organizatës.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID i organizatës për të cilën kjo shkallë është parazgjedhja. Bosh për parazgjedhjen e përbashkët.", + "When weekday": "Nëse bie ditën e", + "Whether reaching this rung opens an incident.": "Nëse arritja e kësaj shkalle hap një incident.", + "Why the day is closed.": "Pse është e mbyllur ajo ditë.", + "Working calendar": "Kalendari i punës", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Orët e punës në një ditë pune. I bën orët dhe ditët e punës të krahasueshme.", + "Working weekdays": "Ditët e punës të javës", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: muaj dhe ditë; easter: një zhvendosje në ditë nga e diela e Pashkëve; observedShift: muaj dhe ditë me shtyrje të detyrueshme.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach shkrehet para afatit, slaBreached në afat ose pas tij." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/sr.js b/l10n/sr.js index 41cc91ce44..9d8acd86ba 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt je uspješno izbrisan","Objekti su uspješno izbrisani","Objekti su uspješno izbrisani"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Trajno ukloni {count} objekt iz baze podataka","Trajno ukloni {count} objekta iz baze podataka","Trajno ukloni {count} objekata iz baze podataka"], "_Restore {count} object_::_Restore {count} objects_": ["Vrati {count} objekt","Vrati {count} objekta","Vrati {count} objekata"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Uspješno vraćen {count} objekt","Uspješno vraćena {count} objekta","Uspješno vraćeno {count} objekata"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Uspješno vraćen {count} objekt","Uspješno vraćena {count} objekta","Uspješno vraćeno {count} objekata"], + "A message identity the notification subsystem resolves.": "Идентитет поруке који разрешава систем обавештења.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Израчуната правила нерадних дана. Врста fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Врста easter: {offset, name}, помак у данима од Ускршње недеље. Врста observedShift: непомичан датум са обавезним померањем.", + "Date": "Датум", + "Day": "Дан", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Дан правила fixed или observedShift; правило easter га занемарује.", + "Days": "Дани", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Дани од Ускршње недеље за правило easter; друге их врсте занемарују.", + "Distance from the deadline, in the offset unit.": "Удаљеност од рока у јединици помака.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Појединачна затварања набројана као {date, name}. Дозвољена уз правила; одбијена као цео календар.", + "Escalate to roles": "Улоге за ескалацију", + "Escalation ladder": "Лествица ескалације", + "Exception dates": "Изузетни датуми", + "Hours per working day": "Сати по радном дану", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "За колико се дана слављени датум помера, нпр. -1 за суботу пре.", + "Human-readable title.": "Читљив наслов.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO дани у недељи (1 = понедељак, 7 = недеља) који су радни.", + "Kind": "Врста", + "Message": "Порука", + "Month": "Месец", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Месец правила fixed или observedShift; правило easter га занемарује.", + "Non-working-date rules": "Правила нерадних дана", + "Notify roles": "Улоге за обавештење", + "Observed shift": "Померање празника", + "Offset": "Помак", + "Offset unit": "Јединица помака", + "Open incident": "Отвори инцидент", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Необавезна мапа имена улоге на конкретног примаоца, нпр. {\"teamleader\": \"group:teamleaders\"}. Улоге без везе путују неразрешене као role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Необавезно код правила fixed, обавезно код observedShift: {whenWeekday: име дана или ISO 1..7, days: цео број}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Поређане пречке: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Приоритет", + "Role bindings": "Везе улога", + "Roles the matter is escalated to at this rung.": "Улоге на које се предмет на овој пречки ескалира.", + "Roles told at this rung; handler is the subject's own performer.": "Улоге обавештене на овој пречки; handler је особа која обрађује предмет.", + "Rungs": "Пречке", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Стални идентитет пречке, нпр. preBreach:14:calendarDays; ако недостаје, изводи се из окидача, помака и јединице.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Стално име календара на које се тајмер позива преко calendar_slug.", + "The closed date, Y-m-d.": "Датум затварања, Y-m-d.", + "The holiday's name.": "Име празника.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Стално име лествице на које се тајмер позива преко ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Јединица помака: исти скуп који SLA прихвата.", + "The urgency the raised transition carries.": "Хитност коју носи покренути прелаз.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Дан у недељи (име) на који се датум помера, нпр. sunday за Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID организације за коју је овај календар подешен. Празно за дељени календар. Ако тајмер не именује ниједан, редослед разрешавања бира календар организације.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID организације којој је ова лествица подразумевана. Празно за дељену подразумевану.", + "When weekday": "Ако падне у", + "Whether reaching this rung opens an incident.": "Да ли досезање ове пречке отвара инцидент.", + "Why the day is closed.": "Зашто је дан затворен.", + "Working calendar": "Радни календар", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Радни сати у једном радном дану. Чини сате и радне дане упоредивима.", + "Working weekdays": "Радни дани у недељи", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: месец и дан; easter: помак у данима од Ускршње недеље; observedShift: месец и дан са обавезним померањем.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach се окида пре рока, slaBreached на рок или после њега." }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/sr.json b/l10n/sr.json index 6f7c4b6337..da79e5138a 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -2801,7 +2801,59 @@ "Uspješno vraćen {count} objekt", "Uspješno vraćena {count} objekta", "Uspješno vraćeno {count} objekata" - ] + ], + "A message identity the notification subsystem resolves.": "Идентитет поруке који разрешава систем обавештења.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Израчуната правила нерадних дана. Врста fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Врста easter: {offset, name}, помак у данима од Ускршње недеље. Врста observedShift: непомичан датум са обавезним померањем.", + "Date": "Датум", + "Day": "Дан", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Дан правила fixed или observedShift; правило easter га занемарује.", + "Days": "Дани", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Дани од Ускршње недеље за правило easter; друге их врсте занемарују.", + "Distance from the deadline, in the offset unit.": "Удаљеност од рока у јединици помака.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Појединачна затварања набројана као {date, name}. Дозвољена уз правила; одбијена као цео календар.", + "Escalate to roles": "Улоге за ескалацију", + "Escalation ladder": "Лествица ескалације", + "Exception dates": "Изузетни датуми", + "Hours per working day": "Сати по радном дану", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "За колико се дана слављени датум помера, нпр. -1 за суботу пре.", + "Human-readable title.": "Читљив наслов.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO дани у недељи (1 = понедељак, 7 = недеља) који су радни.", + "Kind": "Врста", + "Message": "Порука", + "Month": "Месец", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Месец правила fixed или observedShift; правило easter га занемарује.", + "Non-working-date rules": "Правила нерадних дана", + "Notify roles": "Улоге за обавештење", + "Observed shift": "Померање празника", + "Offset": "Помак", + "Offset unit": "Јединица помака", + "Open incident": "Отвори инцидент", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Необавезна мапа имена улоге на конкретног примаоца, нпр. {\"teamleader\": \"group:teamleaders\"}. Улоге без везе путују неразрешене као role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Необавезно код правила fixed, обавезно код observedShift: {whenWeekday: име дана или ISO 1..7, days: цео број}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Поређане пречке: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Приоритет", + "Role bindings": "Везе улога", + "Roles the matter is escalated to at this rung.": "Улоге на које се предмет на овој пречки ескалира.", + "Roles told at this rung; handler is the subject's own performer.": "Улоге обавештене на овој пречки; handler је особа која обрађује предмет.", + "Rungs": "Пречке", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Стални идентитет пречке, нпр. preBreach:14:calendarDays; ако недостаје, изводи се из окидача, помака и јединице.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Стално име календара на које се тајмер позива преко calendar_slug.", + "The closed date, Y-m-d.": "Датум затварања, Y-m-d.", + "The holiday's name.": "Име празника.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Стално име лествице на које се тајмер позива преко ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "Јединица помака: исти скуп који SLA прихвата.", + "The urgency the raised transition carries.": "Хитност коју носи покренути прелаз.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Дан у недељи (име) на који се датум помера, нпр. sunday за Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID организације за коју је овај календар подешен. Празно за дељени календар. Ако тајмер не именује ниједан, редослед разрешавања бира календар организације.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID организације којој је ова лествица подразумевана. Празно за дељену подразумевану.", + "When weekday": "Ако падне у", + "Whether reaching this rung opens an incident.": "Да ли досезање ове пречке отвара инцидент.", + "Why the day is closed.": "Зашто је дан затворен.", + "Working calendar": "Радни календар", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Радни сати у једном радном дану. Чини сате и радне дане упоредивима.", + "Working weekdays": "Радни дани у недељи", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: месец и дан; easter: помак у данима од Ускршње недеље; observedShift: месец и дан са обавезним померањем.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach се окида пре рока, slaBreached на рок или после њега." }, "pluralForm": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "plurals": { diff --git a/l10n/sv.js b/l10n/sv.js index 4e1d8bafb0..ef5a56091d 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Objekt togs bort","Objekt togs bort"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Rensa {count} objekt från databasen","Rensa {count} objekt från databasen"], "_Restore {count} object_::_Restore {count} objects_": ["Återställ {count} objekt","Återställ {count} objekt"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objekt återställdes","{count} objekt återställdes"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} objekt återställdes","{count} objekt återställdes"], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index e5f0e7cc06..a4105885f0 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} objekt återställdes", "{count} objekt återställdes" - ] + ], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/tr.js b/l10n/tr.js index cfd5b26051..0b1cf4892b 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Nesne başarıyla silindi","Nesneler başarıyla silindi"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Veritabanından {count} nesneyi temizle","Veritabanından {count} nesneyi temizle"], "_Restore {count} object_::_Restore {count} objects_": ["{count} nesneyi geri yükle","{count} nesneyi geri yükle"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} nesne başarıyla geri yüklendi","{count} nesne başarıyla geri yüklendi"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["{count} nesne başarıyla geri yüklendi","{count} nesne başarıyla geri yüklendi"], + "A message identity the notification subsystem resolves.": "Bildirim sisteminin çözdüğü bir ileti kimliği.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Hesaplanan tatil günü kuralları. Tür fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tür easter: {offset, name}, Paskalya pazarından gün olarak kaydırma. Tür observedShift: zorunlu aktarması olan sabit bir tarih.", + "Date": "Tarih", + "Day": "Gün", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "fixed veya observedShift kuralının günü; easter kuralı bunu yok sayar.", + "Days": "Günler", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "easter kuralı için Paskalya pazarından gün sayısı; diğer türler bunu yok sayar.", + "Distance from the deadline, in the offset unit.": "Kaydırma biriminde süreye olan uzaklık.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "{date, name} biçiminde sıralanan tek seferlik kapanışlar. Kuralların yanında izinli; takvimin tamamı olarak reddedilir.", + "Escalate to roles": "Yükseltilecek roller", + "Escalation ladder": "Yükseltme merdiveni", + "Exception dates": "İstisna tarihleri", + "Hours per working day": "İş günü başına saat", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Kutlanan tarihin kaç gün aktarıldığı, örn. önceki cumartesi için -1.", + "Human-readable title.": "Okunabilir başlık.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "İş günü olan ISO hafta günleri (1 = pazartesi, 7 = pazar).", + "Kind": "Tür", + "Message": "İleti", + "Month": "Ay", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "fixed veya observedShift kuralının ayı; easter kuralı bunu yok sayar.", + "Non-working-date rules": "Tatil günü kuralları", + "Notify roles": "Bildirilecek roller", + "Observed shift": "Tatilin aktarılması", + "Offset": "Kaydırma", + "Offset unit": "Kaydırma birimi", + "Open incident": "Olay aç", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Rol adından somut bir alıcıya isteğe bağlı eşleme, örn. {\"teamleader\": \"group:teamleaders\"}. Bağlaması olmayan roller role: olarak çözülmeden ilerler.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "fixed kuralında isteğe bağlı, observedShift kuralında zorunlu: {whenWeekday: gün adı veya ISO 1..7, days: tam sayı}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Sıralı basamaklar: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Öncelik", + "Role bindings": "Rol bağlamaları", + "Roles the matter is escalated to at this rung.": "Konunun bu basamakta yükseltildiği roller.", + "Roles told at this rung; handler is the subject's own performer.": "Bu basamakta bildirilen roller; handler, işi yürüten kişidir.", + "Rungs": "Basamaklar", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Basamağın kalıcı kimliği, örn. preBreach:14:calendarDays; yoksa tetikleyici, kaydırma ve birimden türetilir.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Takvimin kalıcı adı; bir zamanlayıcı ona calendar_slug ile başvurur.", + "The closed date, Y-m-d.": "Kapalı tarih, Y-m-d.", + "The holiday's name.": "Tatilin adı.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Merdivenin kalıcı adı; bir zamanlayıcı ona ladder_slug ile başvurur.", + "The unit of the offset: the same set the SLA accepts.": "Kaydırmanın birimi: SLA'nın kabul ettiği aynı küme.", + "The urgency the raised transition carries.": "Tetiklenen geçişin taşıdığı aciliyet.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Tarihin aktarıldığı hafta günü (ad), örn. Koningsdag için sunday.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "Bu takvimin yapılandırıldığı kuruluşun UUID değeri. Paylaşılan takvimde boş. Zamanlayıcı bir takvim adı vermezse çözüm sırası kuruluşun takvimini seçer.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "Bu merdivenin varsayılan olduğu kuruluşun UUID değeri. Paylaşılan varsayılanda boş.", + "When weekday": "Şu güne denk gelirse", + "Whether reaching this rung opens an incident.": "Bu basamağa ulaşmanın bir olay açıp açmadığı.", + "Why the day is closed.": "Günün neden kapalı olduğu.", + "Working calendar": "Çalışma takvimi", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Bir iş günündeki çalışma saatleri. Saatler ile iş günlerini karşılaştırılabilir kılar.", + "Working weekdays": "Haftanın iş günleri", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: ay ve gün; easter: Paskalya pazarından gün olarak kaydırma; observedShift: zorunlu aktarması olan ay ve gün.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach süre dolmadan önce, slaBreached süre dolduğunda veya sonrasında tetiklenir." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index 09c952f238..eb133ada45 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -2785,7 +2785,59 @@ "_Successfully restored {count} object_::_Successfully restored {count} objects_": [ "{count} nesne başarıyla geri yüklendi", "{count} nesne başarıyla geri yüklendi" - ] + ], + "A message identity the notification subsystem resolves.": "Bildirim sisteminin çözdüğü bir ileti kimliği.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Hesaplanan tatil günü kuralları. Tür fixed: {month, day, name, observedShift?: {whenWeekday, days}}. Tür easter: {offset, name}, Paskalya pazarından gün olarak kaydırma. Tür observedShift: zorunlu aktarması olan sabit bir tarih.", + "Date": "Tarih", + "Day": "Gün", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "fixed veya observedShift kuralının günü; easter kuralı bunu yok sayar.", + "Days": "Günler", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "easter kuralı için Paskalya pazarından gün sayısı; diğer türler bunu yok sayar.", + "Distance from the deadline, in the offset unit.": "Kaydırma biriminde süreye olan uzaklık.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "{date, name} biçiminde sıralanan tek seferlik kapanışlar. Kuralların yanında izinli; takvimin tamamı olarak reddedilir.", + "Escalate to roles": "Yükseltilecek roller", + "Escalation ladder": "Yükseltme merdiveni", + "Exception dates": "İstisna tarihleri", + "Hours per working day": "İş günü başına saat", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "Kutlanan tarihin kaç gün aktarıldığı, örn. önceki cumartesi için -1.", + "Human-readable title.": "Okunabilir başlık.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "İş günü olan ISO hafta günleri (1 = pazartesi, 7 = pazar).", + "Kind": "Tür", + "Message": "İleti", + "Month": "Ay", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "fixed veya observedShift kuralının ayı; easter kuralı bunu yok sayar.", + "Non-working-date rules": "Tatil günü kuralları", + "Notify roles": "Bildirilecek roller", + "Observed shift": "Tatilin aktarılması", + "Offset": "Kaydırma", + "Offset unit": "Kaydırma birimi", + "Open incident": "Olay aç", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Rol adından somut bir alıcıya isteğe bağlı eşleme, örn. {\"teamleader\": \"group:teamleaders\"}. Bağlaması olmayan roller role: olarak çözülmeden ilerler.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "fixed kuralında isteğe bağlı, observedShift kuralında zorunlu: {whenWeekday: gün adı veya ISO 1..7, days: tam sayı}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Sıralı basamaklar: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Öncelik", + "Role bindings": "Rol bağlamaları", + "Roles the matter is escalated to at this rung.": "Konunun bu basamakta yükseltildiği roller.", + "Roles told at this rung; handler is the subject's own performer.": "Bu basamakta bildirilen roller; handler, işi yürüten kişidir.", + "Rungs": "Basamaklar", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Basamağın kalıcı kimliği, örn. preBreach:14:calendarDays; yoksa tetikleyici, kaydırma ve birimden türetilir.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "Takvimin kalıcı adı; bir zamanlayıcı ona calendar_slug ile başvurur.", + "The closed date, Y-m-d.": "Kapalı tarih, Y-m-d.", + "The holiday's name.": "Tatilin adı.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "Merdivenin kalıcı adı; bir zamanlayıcı ona ladder_slug ile başvurur.", + "The unit of the offset: the same set the SLA accepts.": "Kaydırmanın birimi: SLA'nın kabul ettiği aynı küme.", + "The urgency the raised transition carries.": "Tetiklenen geçişin taşıdığı aciliyet.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "Tarihin aktarıldığı hafta günü (ad), örn. Koningsdag için sunday.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "Bu takvimin yapılandırıldığı kuruluşun UUID değeri. Paylaşılan takvimde boş. Zamanlayıcı bir takvim adı vermezse çözüm sırası kuruluşun takvimini seçer.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "Bu merdivenin varsayılan olduğu kuruluşun UUID değeri. Paylaşılan varsayılanda boş.", + "When weekday": "Şu güne denk gelirse", + "Whether reaching this rung opens an incident.": "Bu basamağa ulaşmanın bir olay açıp açmadığı.", + "Why the day is closed.": "Günün neden kapalı olduğu.", + "Working calendar": "Çalışma takvimi", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Bir iş günündeki çalışma saatleri. Saatler ile iş günlerini karşılaştırılabilir kılar.", + "Working weekdays": "Haftanın iş günleri", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: ay ve gün; easter: Paskalya pazarından gün olarak kaydırma; observedShift: zorunlu aktarması olan ay ve gün.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach süre dolmadan önce, slaBreached süre dolduğunda veya sonrasında tetiklenir." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/uk.js b/l10n/uk.js index 9b37cb19c4..3c39b83828 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -2738,7 +2738,59 @@ OC.L10N.register( "_Object successfully deleted_::_Objects successfully deleted_": ["Об'єкт успішно видалено","Об'єкти успішно видалено","Об'єкти успішно видалено","Об'єкти успішно видалено"], "_Purge {count} object from database_::_Purge {count} objects from database_": ["Очистити {count} об'єкт з бази даних","Очистити {count} об'єкти з бази даних","Очистити {count} об'єктів з бази даних","Очистити {count} об'єкта з бази даних"], "_Restore {count} object_::_Restore {count} objects_": ["Відновити {count} об'єкт","Відновити {count} об'єкти","Відновити {count} об'єктів","Відновити {count} об'єкта"], - "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Успішно відновлено {count} об'єкт","Успішно відновлено {count} об'єкти","Успішно відновлено {count} об'єктів","Успішно відновлено {count} об'єкта"] + "_Successfully restored {count} object_::_Successfully restored {count} objects_": ["Успішно відновлено {count} об'єкт","Успішно відновлено {count} об'єкти","Успішно відновлено {count} об'єктів","Успішно відновлено {count} об'єкта"], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != 11 ? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || (n % 100 >=11 && n % 100 <=14 )) ? 2: 3);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 4b5e9117f8..71ed989120 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -2817,7 +2817,59 @@ "Успішно відновлено {count} об'єкти", "Успішно відновлено {count} об'єктів", "Успішно відновлено {count} об'єкта" - ] + ], + "A message identity the notification subsystem resolves.": "A message identity the notification subsystem resolves.", + "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "Date": "Date", + "Day": "Day", + "Day of a fixed or observedShift rule; ignored by an easter rule.": "Day of a fixed or observedShift rule; ignored by an easter rule.", + "Days": "Days", + "Days from Easter Sunday for an easter rule; ignored by the other kinds.": "Days from Easter Sunday for an easter rule; ignored by the other kinds.", + "Distance from the deadline, in the offset unit.": "Distance from the deadline, in the offset unit.", + "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "Escalate to roles": "Escalate to roles", + "Escalation ladder": "Escalation ladder", + "Exception dates": "Exception dates", + "Hours per working day": "Hours per working day", + "How many days the observed date shifts, e.g. -1 for the Saturday before.": "How many days the observed date shifts, e.g. -1 for the Saturday before.", + "Human-readable title.": "Human-readable title.", + "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "Kind": "Kind", + "Message": "Message", + "Month": "Month", + "Month of a fixed or observedShift rule; ignored by an easter rule.": "Month of a fixed or observedShift rule; ignored by an easter rule.", + "Non-working-date rules": "Non-working-date rules", + "Notify roles": "Notify roles", + "Observed shift": "Observed shift", + "Offset": "Offset", + "Offset unit": "Offset unit", + "Open incident": "Open incident", + "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:.", + "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "Priority": "Priority", + "Role bindings": "Role bindings", + "Roles the matter is escalated to at this rung.": "Roles the matter is escalated to at this rung.", + "Roles told at this rung; handler is the subject's own performer.": "Roles told at this rung; handler is the subject's own performer.", + "Rungs": "Rungs", + "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent.", + "The calendar's stable name, referenced by a timer's calendar_slug.": "The calendar's stable name, referenced by a timer's calendar_slug.", + "The closed date, Y-m-d.": "The closed date, Y-m-d.", + "The holiday's name.": "The holiday's name.", + "The ladder's stable name, referenced by a timer's ladder_slug.": "The ladder's stable name, referenced by a timer's ladder_slug.", + "The unit of the offset: the same set the SLA accepts.": "The unit of the offset: the same set the SLA accepts.", + "The urgency the raised transition carries.": "The urgency the raised transition carries.", + "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag.", + "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none.", + "UUID of the organisation this ladder is the default for. Empty for the shared default.": "UUID of the organisation this ladder is the default for. Empty for the shared default.", + "When weekday": "When weekday", + "Whether reaching this rung opens an incident.": "Whether reaching this rung opens an incident.", + "Why the day is closed.": "Why the day is closed.", + "Working calendar": "Working calendar", + "Working hours in one working day. Makes hours and businessDays commensurable.": "Working hours in one working day. Makes hours and businessDays commensurable.", + "Working weekdays": "Working weekdays", + "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift.", + "preBreach fires before the deadline, slaBreached at or after it.": "preBreach fires before the deadline, slaBreached at or after it." }, "pluralForm": "nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != 11 ? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || (n % 100 >=11 && n % 100 <=14 )) ? 2: 3);", "plurals": { diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 99c329bf05..eb63e8d143 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -2660,6 +2660,22 @@ private function registerEventListeners(IRegistrationContext $context): void { \OCA\OpenRegister\Listener\FlowTriggerListener::class ); + // Business-timer cancellation propagation (flow-business-timers, design + // D-9): the SAME terminal event, and the run-terminal one, also cancel + // the subject's open business timers. TaskTerminalEvent fires right + // after the terminal write commits (see TaskService::transactional), + // FlowRunTerminalEvent inside the run's own write; the listener is + // idempotent and never deletes, and the invariant repair step counts + // anything a crash window leaves behind. + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTerminalEvent::class, + \OCA\OpenRegister\Listener\FlowTimerSubjectTerminalListener::class + ); + $context->registerEventListener( + \OCA\OpenRegister\Event\FlowRunTerminalEvent::class, + \OCA\OpenRegister\Listener\FlowTimerSubjectTerminalListener::class + ); + // Lifecycle annotation listeners — see x-openregister-lifecycle. // Order matters: initial state runs on creating; validation runs on updating. $context->registerEventListener(ObjectCreatingEvent::class, LifecycleInitialStateListener::class); diff --git a/lib/BackgroundJob/FlowTimerWorker.php b/lib/BackgroundJob/FlowTimerWorker.php new file mode 100644 index 0000000000..ff584d42d7 --- /dev/null +++ b/lib/BackgroundJob/FlowTimerWorker.php @@ -0,0 +1,138 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Cron + * @package OCA\OpenRegister\BackgroundJob + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\BackgroundJob; + +use DateTimeImmutable; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerSweep; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Runs one bounded sweep pass per tick. + * + * @SuppressWarnings(PHPMD.StaticAccess) DateTimeImmutable::createFromInterface + * is PHP's own conversion of the job clock. + */ +class FlowTimerWorker extends TimedJob { + + /** + * The sweep interval, matching FlowScheduleWorker. A business timer's + * resolution is days; 300s is already finer than anything it measures. + * + * @var int + */ + public const INTERVAL_SECONDS = 300; + + /** + * App-config key for the per-pass batch limit, and its default. + */ + public const CONFIG_BATCH = 'flow_timer_batch'; + + public const DEFAULT_BATCH = 200; + + /** + * Constructor. + * + * @param ITimeFactory $time Job scheduling clock (kept by the base Job as $this->time). + * @param FlowTimerSweep $sweep The two range scans and what they fire. + * @param IAppConfig $appConfig Holds the batch limit override. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + ITimeFactory $time, + private readonly FlowTimerSweep $sweep, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: self::INTERVAL_SECONDS); + + }//end __construct() + + /** + * Run one sweep pass. + * + * Counts logged are work PERFORMED (timers fired, rungs raised), not rows + * examined, and a pass that hit the batch limit says `truncated: true` so + * a backlog is visible instead of looking like a clean sweep. + * + * @param mixed $argument The job argument (unused). + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) `$argument` is TimedJob's signature. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + protected function run($argument): void { + try { + $now = DateTimeImmutable::createFromInterface($this->time->getDateTime()); + $result = $this->sweep->run(now: $now, batch: $this->batchLimit()); + + if ($result['expiriesFired'] > 0 || $result['rungsFired'] > 0 || $result['truncated'] === true || $result['errors'] > 0) { + $this->logger->info( + message: sprintf( + '[FlowTimerWorker] Fired %d expiry timer(s) and %d escalation rung(s); truncated: %s; errors: %d', + $result['expiriesFired'], + $result['rungsFired'], + var_export($result['truncated'], true), + $result['errors'] + ), + context: ['file' => __FILE__, 'line' => __LINE__] + $result + ); + } + } catch (Throwable $e) { + $this->logger->error( + message: '[FlowTimerWorker] Sweep pass failed: ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'exception' => $e] + ); + }//end try + + }//end run() + + /** + * The batch limit: configured, else the default; never below one. + * + * @return int The limit. + */ + private function batchLimit(): int { + $configured = (int)$this->appConfig->getValueString( + app: 'openregister', + key: self::CONFIG_BATCH, + default: (string)self::DEFAULT_BATCH + ); + + return max(1, $configured); + }//end batchLimit() +}//end class diff --git a/lib/Db/FlowTimer.php b/lib/Db/FlowTimer.php new file mode 100644 index 0000000000..cafb5654f4 --- /dev/null +++ b/lib/Db/FlowTimer.php @@ -0,0 +1,619 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Entity + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * The business-timer row. + * + * @SuppressWarnings(PHPMD.TooManyFields) One property per column, same as + * {@see Task} and {@see FlowRun}: the column count IS the field count. + * @SuppressWarnings(PHPMD.ExcessiveClassLength) The typed properties and the + * field-by-field constructor scale linearly with the column count. + * @SuppressWarnings(PHPMD.ExcessivePublicCount) Entity getters/setters are + * the column surface, not an API design choice. + * @SuppressWarnings(PHPMD.LongVariable) `suspendedTotalSeconds` IS the column + * `suspended_total_seconds`; the entity property name is the column mapping. + * + * @method string|null getUuid() + * @method void setUuid(?string $uuid) + * @method string|null getTitle() + * @method void setTitle(?string $title) + * @method array|null getMetadata() + * @method void setMetadata(?array $metadata) + * @method string|null getSubjectType() + * @method void setSubjectType(?string $subjectType) + * @method string|null getSubjectUuid() + * @method void setSubjectUuid(?string $subjectUuid) + * @method string|null getOrganisation() + * @method void setOrganisation(?string $organisation) + * @method string|null getRunUuid() + * @method void setRunUuid(?string $runUuid) + * @method string|null getNodeId() + * @method void setNodeId(?string $nodeId) + * @method string|null getAppId() + * @method void setAppId(?string $appId) + * @method string|null getPurpose() + * @method void setPurpose(?string $purpose) + * @method string|null getLegalEffect() + * @method void setLegalEffect(?string $legalEffect) + * @method string|null getOnExpiry() + * @method void setOnExpiry(?string $onExpiry) + * @method string|null getAnchorEvent() + * @method void setAnchorEvent(?string $anchorEvent) + * @method integer|null getAnchorOffset() + * @method void setAnchorOffset(?int $anchorOffset) + * @method string|null getAnchorOffsetUnit() + * @method void setAnchorOffsetUnit(?string $anchorOffsetUnit) + * @method DateTime|null getAnchorAt() + * @method void setAnchorAt(?DateTime $anchorAt) + * @method float|null getBudgetValue() + * @method void setBudgetValue(?float $budgetValue) + * @method string|null getBudgetUnit() + * @method void setBudgetUnit(?string $budgetUnit) + * @method float|null getConsumedValue() + * @method void setConsumedValue(?float $consumedValue) + * @method DateTime|null getRunningSince() + * @method void setRunningSince(?DateTime $runningSince) + * @method DateTime|null getFireAt() + * @method void setFireAt(?DateTime $fireAt) + * @method DateTime|null getNextRungAt() + * @method void setNextRungAt(?DateTime $nextRungAt) + * @method string|null getCalendarSlug() + * @method void setCalendarSlug(?string $calendarSlug) + * @method string|null getLadderSlug() + * @method void setLadderSlug(?string $ladderSlug) + * @method array|null getEscalationRules() + * @method void setEscalationRules(?array $escalationRules) + * @method DateTime|null getSuspendedSince() + * @method void setSuspendedSince(?DateTime $suspendedSince) + * @method string|null getSuspendReason() + * @method void setSuspendReason(?string $suspendReason) + * @method integer|null getSuspendedTotalSeconds() + * @method void setSuspendedTotalSeconds(?int $suspendedTotalSeconds) + * @method integer|null getExtensionCount() + * @method void setExtensionCount(?int $extensionCount) + * @method integer|null getExtensionMax() + * @method void setExtensionMax(?int $extensionMax) + * @method string|null getState() + * @method void setState(?string $state) + * @method string|null getSupersedesUuid() + * @method void setSupersedesUuid(?string $supersedesUuid) + * @method DateTime|null getFiredAt() + * @method void setFiredAt(?DateTime $firedAt) + * @method bool|null getBreached() + * @method void setBreached(?bool $breached) + * @method DateTime|null getCancelledAt() + * @method void setCancelledAt(?DateTime $cancelledAt) + * @method string|null getCancelReason() + * @method void setCancelReason(?string $cancelReason) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * @method DateTime|null getUpdated() + * @method void setUpdated(?DateTime $updated) + * @method string|null getCreatedBy() + * @method void setCreatedBy(?string $createdBy) + */ +class FlowTimer extends Entity implements JsonSerializable { + + /** + * Lifecycle states. Nothing else is persistable; there is NO overdue state. + */ + public const STATE_ARMED = 'armed'; + + public const STATE_SUSPENDED = 'suspended'; + + public const STATE_FIRED = 'fired'; + + public const STATE_CANCELLED = 'cancelled'; + + public const STATE_SUPERSEDED = 'superseded'; + + /** + * Every persistable state. + * + * @var array + */ + public const STATES = [ + self::STATE_ARMED, + self::STATE_SUSPENDED, + self::STATE_FIRED, + self::STATE_CANCELLED, + self::STATE_SUPERSEDED, + ]; + + /** + * States from which a timer never moves again. + * + * @var array + */ + public const TERMINAL_STATES = [ + self::STATE_FIRED, + self::STATE_CANCELLED, + self::STATE_SUPERSEDED, + ]; + + /** + * Purposes: `due` advises, `expiry` enforces. + */ + public const PURPOSE_DUE = 'due'; + + public const PURPOSE_EXPIRY = 'expiry'; + + /** + * The two purposes. + * + * @var array + */ + public const PURPOSES = [self::PURPOSE_DUE, self::PURPOSE_EXPIRY]; + + /** + * Legal effect. Only `wettelijk` may carry an enforcing outcome. + */ + public const LEGAL_NONE = 'none'; + + public const LEGAL_SERVICENORM = 'servicenorm'; + + public const LEGAL_WETTELIJK = 'wettelijk'; + + /** + * The legal-effect vocabulary. + * + * @var array + */ + public const LEGAL_EFFECTS = [self::LEGAL_NONE, self::LEGAL_SERVICENORM, self::LEGAL_WETTELIJK]; + + /** + * Subject types a timer can be bound to. + * + * @var array + */ + public const SUBJECT_TYPES = ['task', 'object', 'run']; + + /** + * The three reserved enforcing outcomes; `transition:` is the fourth shape. + * + * @var array + */ + public const RESERVED_OUTCOMES = ['skip', 'error', 'dead_letter']; + + /** + * Public identifier. + * + * @var string|null + */ + protected ?string $uuid = null; + + /** + * Human title. + * + * @var string|null + */ + protected ?string $title = null; + + /** + * Free-form metadata. + * + * @var array|null + */ + protected ?array $metadata = null; + + /** + * Subject type: task, object or run. + * + * @var string|null + */ + protected ?string $subjectType = null; + + /** + * Subject uuid. + * + * @var string|null + */ + protected ?string $subjectUuid = null; + + /** + * Owning organisation. + * + * @var string|null + */ + protected ?string $organisation = null; + + /** + * Originating run (provenance, optional). + * + * @var string|null + */ + protected ?string $runUuid = null; + + /** + * Originating node (provenance, optional). + * + * @var string|null + */ + protected ?string $nodeId = null; + + /** + * Owning app id. + * + * @var string|null + */ + protected ?string $appId = null; + + /** + * Purpose: due or expiry. + * + * @var string|null + */ + protected ?string $purpose = null; + + /** + * Legal effect. + * + * @var string|null + */ + protected ?string $legalEffect = null; + + /** + * Enforcing outcome (expiry timers with legal effect wettelijk only). + * + * @var string|null + */ + protected ?string $onExpiry = null; + + /** + * Named anchoring event. + * + * @var string|null + */ + protected ?string $anchorEvent = null; + + /** + * Offset from the anchoring event. + * + * @var integer|null + */ + protected ?int $anchorOffset = null; + + /** + * Unit of the anchor offset. + * + * @var string|null + */ + protected ?string $anchorOffsetUnit = null; + + /** + * The resolved instant the term runs from. + * + * @var DateTime|null + */ + protected ?DateTime $anchorAt = null; + + /** + * The term, in its own unit. + * + * @var float|null + */ + protected ?float $budgetValue = null; + + /** + * Unit of the budget. + * + * @var string|null + */ + protected ?string $budgetUnit = null; + + /** + * Completed running time, in the budget unit. + * + * @var float|null + */ + protected ?float $consumedValue = 0.0; + + /** + * Start of the current running segment; NULL while suspended. + * + * @var DateTime|null + */ + protected ?DateTime $runningSince = null; + + /** + * Projected fire instant; NULL while suspended. Derived. + * + * @var DateTime|null + */ + protected ?DateTime $fireAt = null; + + /** + * Instant of the next unfired rung; NULL while suspended. Derived. + * + * @var DateTime|null + */ + protected ?DateTime $nextRungAt = null; + + /** + * Named working calendar. + * + * @var string|null + */ + protected ?string $calendarSlug = null; + + /** + * Named escalation ladder. + * + * @var string|null + */ + protected ?string $ladderSlug = null; + + /** + * Inline escalation rules. + * + * @var array>|null + */ + protected ?array $escalationRules = null; + + /** + * When the current suspension began. + * + * @var DateTime|null + */ + protected ?DateTime $suspendedSince = null; + + /** + * Why the timer is suspended. + * + * @var string|null + */ + protected ?string $suspendReason = null; + + /** + * Total seconds spent suspended (reporting only). + * + * @var integer|null + */ + protected ?int $suspendedTotalSeconds = 0; + + /** + * Extensions granted. + * + * @var integer|null + */ + protected ?int $extensionCount = 0; + + /** + * Extension bound. + * + * @var integer|null + */ + protected ?int $extensionMax = 1; + + /** + * Lifecycle state. + * + * @var string|null + */ + protected ?string $state = null; + + /** + * The timer this one supersedes. + * + * @var string|null + */ + protected ?string $supersedesUuid = null; + + /** + * When the timer fired. + * + * @var DateTime|null + */ + protected ?DateTime $firedAt = null; + + /** + * Whether the deadline was breached. Permanent once set. + * + * @var boolean|null + */ + protected ?bool $breached = false; + + /** + * When the timer was cancelled. + * + * @var DateTime|null + */ + protected ?DateTime $cancelledAt = null; + + /** + * Why the timer was cancelled. + * + * @var string|null + */ + protected ?string $cancelReason = null; + + /** + * Creation stamp. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Update stamp. + * + * @var DateTime|null + */ + protected ?DateTime $updated = null; + + /** + * Creating identity. + * + * @var string|null + */ + protected ?string $createdBy = null; + + /** + * Constructor: declares the column types. + */ + public function __construct() { + $this->addType(fieldName: 'uuid', type: 'string'); + $this->addType(fieldName: 'title', type: 'string'); + $this->addType(fieldName: 'metadata', type: 'json'); + $this->addType(fieldName: 'subjectType', type: 'string'); + $this->addType(fieldName: 'subjectUuid', type: 'string'); + $this->addType(fieldName: 'organisation', type: 'string'); + $this->addType(fieldName: 'runUuid', type: 'string'); + $this->addType(fieldName: 'nodeId', type: 'string'); + $this->addType(fieldName: 'appId', type: 'string'); + $this->addType(fieldName: 'purpose', type: 'string'); + $this->addType(fieldName: 'legalEffect', type: 'string'); + $this->addType(fieldName: 'onExpiry', type: 'string'); + $this->addType(fieldName: 'anchorEvent', type: 'string'); + $this->addType(fieldName: 'anchorOffset', type: 'integer'); + $this->addType(fieldName: 'anchorOffsetUnit', type: 'string'); + $this->addType(fieldName: 'anchorAt', type: 'datetime'); + $this->addType(fieldName: 'budgetValue', type: 'float'); + $this->addType(fieldName: 'budgetUnit', type: 'string'); + $this->addType(fieldName: 'consumedValue', type: 'float'); + $this->addType(fieldName: 'runningSince', type: 'datetime'); + $this->addType(fieldName: 'fireAt', type: 'datetime'); + $this->addType(fieldName: 'nextRungAt', type: 'datetime'); + $this->addType(fieldName: 'calendarSlug', type: 'string'); + $this->addType(fieldName: 'ladderSlug', type: 'string'); + $this->addType(fieldName: 'escalationRules', type: 'json'); + $this->addType(fieldName: 'suspendedSince', type: 'datetime'); + $this->addType(fieldName: 'suspendReason', type: 'string'); + $this->addType(fieldName: 'suspendedTotalSeconds', type: 'integer'); + $this->addType(fieldName: 'extensionCount', type: 'integer'); + $this->addType(fieldName: 'extensionMax', type: 'integer'); + $this->addType(fieldName: 'state', type: 'string'); + $this->addType(fieldName: 'supersedesUuid', type: 'string'); + $this->addType(fieldName: 'firedAt', type: 'datetime'); + $this->addType(fieldName: 'breached', type: 'boolean'); + $this->addType(fieldName: 'cancelledAt', type: 'datetime'); + $this->addType(fieldName: 'cancelReason', type: 'string'); + $this->addType(fieldName: 'created', type: 'datetime'); + $this->addType(fieldName: 'updated', type: 'datetime'); + $this->addType(fieldName: 'createdBy', type: 'string'); + + }//end __construct() + + /** + * Whether the timer can still fire or be mutated. + * + * @return boolean True for armed and suspended. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function isOpen(): bool { + return in_array((string)$this->state, self::TERMINAL_STATES, true) === false; + }//end isOpen() + + /** + * Whether the timer enforces (applies an outcome) when it fires. + * + * @return boolean True for an expiry timer carrying an outcome. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-advisory-due-date-notifies-an-enforcing-expiry-transitions + */ + public function isEnforcing(): bool { + return $this->purpose === self::PURPOSE_EXPIRY && $this->onExpiry !== null && $this->onExpiry !== ''; + }//end isEnforcing() + + /** + * Serialise for the API. Carries NO overdue flag: that is derived by the reader. + * + * @return array The timer. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-overdue-is-derived-from-the-clock-and-never-stored + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'title' => $this->title, + 'metadata' => $this->metadata, + 'subjectType' => $this->subjectType, + 'subjectUuid' => $this->subjectUuid, + 'organisation' => $this->organisation, + 'runUuid' => $this->runUuid, + 'nodeId' => $this->nodeId, + 'appId' => $this->appId, + 'purpose' => $this->purpose, + 'legalEffect' => $this->legalEffect, + 'onExpiry' => $this->onExpiry, + 'anchorEvent' => $this->anchorEvent, + 'anchorOffset' => $this->anchorOffset, + 'anchorOffsetUnit' => $this->anchorOffsetUnit, + 'anchorAt' => $this->format(value: $this->anchorAt), + 'budgetValue' => $this->budgetValue, + 'budgetUnit' => $this->budgetUnit, + 'consumedValue' => $this->consumedValue, + 'runningSince' => $this->format(value: $this->runningSince), + 'fireAt' => $this->format(value: $this->fireAt), + 'nextRungAt' => $this->format(value: $this->nextRungAt), + 'calendarSlug' => $this->calendarSlug, + 'ladderSlug' => $this->ladderSlug, + 'escalationRules' => $this->escalationRules, + 'suspendedSince' => $this->format(value: $this->suspendedSince), + 'suspendReason' => $this->suspendReason, + 'suspendedTotalSeconds' => $this->suspendedTotalSeconds, + 'extensionCount' => $this->extensionCount, + 'extensionMax' => $this->extensionMax, + 'state' => $this->state, + 'supersedesUuid' => $this->supersedesUuid, + 'firedAt' => $this->format(value: $this->firedAt), + 'breached' => $this->breached, + 'cancelledAt' => $this->format(value: $this->cancelledAt), + 'cancelReason' => $this->cancelReason, + 'created' => $this->format(value: $this->created), + 'updated' => $this->format(value: $this->updated), + 'createdBy' => $this->createdBy, + ]; + }//end jsonSerialize() + + /** + * ISO-8601 or null. + * + * @param DateTime|null $value The moment. + * + * @return string|null The formatted moment. + */ + private function format(?DateTime $value): ?string { + if ($value === null) { + return null; + } + + return $value->format('c'); + }//end format() +}//end class diff --git a/lib/Db/FlowTimerEvent.php b/lib/Db/FlowTimerEvent.php new file mode 100644 index 0000000000..b7f5fa8770 --- /dev/null +++ b/lib/Db/FlowTimerEvent.php @@ -0,0 +1,193 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Entity + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * The timer history row. + * + * @method string|null getTimerUuid() + * @method void setTimerUuid(?string $timerUuid) + * @method string|null getType() + * @method void setType(?string $type) + * @method string|null getActor() + * @method void setActor(?string $actor) + * @method string|null getReason() + * @method void setReason(?string $reason) + * @method DateTime|null getPriorFireAt() + * @method void setPriorFireAt(?DateTime $priorFireAt) + * @method DateTime|null getNewFireAt() + * @method void setNewFireAt(?DateTime $newFireAt) + * @method float|null getDaysImpact() + * @method void setDaysImpact(?float $daysImpact) + * @method string|null getBasis() + * @method void setBasis(?string $basis) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + */ +class FlowTimerEvent extends Entity implements JsonSerializable { + + /** + * Event types. + */ + public const TYPE_ARMED = 'armed'; + + public const TYPE_SUSPENDED = 'suspended'; + + public const TYPE_RESUMED = 'resumed'; + + public const TYPE_EXTENDED = 'extended'; + + public const TYPE_SUPERSEDED = 'superseded'; + + public const TYPE_FIRED = 'fired'; + + public const TYPE_BREACHED = 'breached'; + + public const TYPE_CANCELLED = 'cancelled'; + + /** + * The timer this event belongs to. + * + * @var string|null + */ + protected ?string $timerUuid = null; + + /** + * The event type. + * + * @var string|null + */ + protected ?string $type = null; + + /** + * The acting identity. + * + * @var string|null + */ + protected ?string $actor = null; + + /** + * The recorded reason. + * + * @var string|null + */ + protected ?string $reason = null; + + /** + * The fire moment before the event. + * + * @var DateTime|null + */ + protected ?DateTime $priorFireAt = null; + + /** + * The fire moment after the event. + * + * @var DateTime|null + */ + protected ?DateTime $newFireAt = null; + + /** + * The impact in the timer's budget unit. + * + * @var float|null + */ + protected ?float $daysImpact = null; + + /** + * The legal ground, e.g. `Awb 4:15`. + * + * @var string|null + */ + protected ?string $basis = null; + + /** + * Creation stamp: the moment of the event. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Constructor: declares the column types. + */ + public function __construct() { + $this->addType(fieldName: 'timerUuid', type: 'string'); + $this->addType(fieldName: 'type', type: 'string'); + $this->addType(fieldName: 'actor', type: 'string'); + $this->addType(fieldName: 'reason', type: 'string'); + $this->addType(fieldName: 'priorFireAt', type: 'datetime'); + $this->addType(fieldName: 'newFireAt', type: 'datetime'); + $this->addType(fieldName: 'daysImpact', type: 'float'); + $this->addType(fieldName: 'basis', type: 'string'); + $this->addType(fieldName: 'created', type: 'datetime'); + + }//end __construct() + + /** + * Serialise for the API. + * + * @return array The event. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'timerUuid' => $this->timerUuid, + 'type' => $this->type, + 'actor' => $this->actor, + 'reason' => $this->reason, + 'priorFireAt' => $this->format(value: $this->priorFireAt), + 'newFireAt' => $this->format(value: $this->newFireAt), + 'daysImpact' => $this->daysImpact, + 'basis' => $this->basis, + 'created' => $this->format(value: $this->created), + ]; + }//end jsonSerialize() + + /** + * ISO-8601 or null. + * + * @param DateTime|null $value The moment. + * + * @return string|null The formatted moment. + */ + private function format(?DateTime $value): ?string { + if ($value === null) { + return null; + } + + return $value->format('c'); + }//end format() +}//end class diff --git a/lib/Db/FlowTimerEventMapper.php b/lib/Db/FlowTimerEventMapper.php new file mode 100644 index 0000000000..05a887c08b --- /dev/null +++ b/lib/Db/FlowTimerEventMapper.php @@ -0,0 +1,123 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Mapper + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use InvalidArgumentException; +use LogicException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Mapper for `openregister_flow_timer_events`. + * + * @template-extends QBMapper + */ +class FlowTimerEventMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_flow_timer_events', entityClass: FlowTimerEvent::class); + + }//end __construct() + + /** + * Insert, stamping `created`. + * + * @param Entity $entity The event row. + * + * @return FlowTimerEvent The inserted row. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + public function insert(Entity $entity): FlowTimerEvent { + if ($entity instanceof FlowTimerEvent === false) { + throw new InvalidArgumentException('FlowTimerEventMapper persists FlowTimerEvent entities only.'); + } + + if ($entity->getCreated() === null) { + $entity->setCreated(new DateTime()); + } + + return parent::insert(entity: $entity); + }//end insert() + + /** + * Updates are refused: the history is append-only. + * + * @param Entity $entity Ignored. + * + * @return FlowTimerEvent Never returns. + * + * @throws \LogicException Always. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + public function update(Entity $entity): FlowTimerEvent { + throw new LogicException('The timer history is append-only; event ' . (string)$entity->getId() . ' cannot be updated.'); + }//end update() + + /** + * Deletes are refused: the history is append-only. + * + * @param Entity $entity Ignored. + * + * @return FlowTimerEvent Never returns. + * + * @throws \LogicException Always. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + public function delete(Entity $entity): FlowTimerEvent { + throw new LogicException('The timer history is append-only; event ' . (string)$entity->getId() . ' cannot be deleted.'); + }//end delete() + + /** + * The history of a timer, oldest first. + * + * @param string $timerUuid The timer uuid. + * + * @return array The events. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + public function findByTimer(string $timerUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('timer_uuid', $qb->createNamedParameter($timerUuid))) + ->orderBy('created', 'ASC') + ->addOrderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findByTimer() +}//end class diff --git a/lib/Db/FlowTimerFire.php b/lib/Db/FlowTimerFire.php new file mode 100644 index 0000000000..94cbc15b5c --- /dev/null +++ b/lib/Db/FlowTimerFire.php @@ -0,0 +1,152 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Entity + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * The rung-fire ledger row. + * + * @method string|null getTimerUuid() + * @method void setTimerUuid(?string $timerUuid) + * @method string|null getRungKey() + * @method void setRungKey(?string $rungKey) + * @method DateTime|null getFiredAt() + * @method void setFiredAt(?DateTime $firedAt) + * @method string|null getTransitionAction() + * @method void setTransitionAction(?string $transitionAction) + * @method array|null getRecipientRoles() + * @method void setRecipientRoles(?array $recipientRoles) + * @method string|null getPriority() + * @method void setPriority(?string $priority) + * @method bool|null getInherited() + * @method void setInherited(?bool $inherited) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + */ +class FlowTimerFire extends Entity implements JsonSerializable { + + /** + * The timer the rung belongs to. + * + * @var string|null + */ + protected ?string $timerUuid = null; + + /** + * The rung's stable identity, e.g. `preBreach:14:calendarDays`. + * + * @var string|null + */ + protected ?string $rungKey = null; + + /** + * When the rung fired (or was inherited). + * + * @var DateTime|null + */ + protected ?DateTime $firedAt = null; + + /** + * The named transition raised. + * + * @var string|null + */ + protected ?string $transitionAction = null; + + /** + * The roles the rung addressed. + * + * @var array|null + */ + protected ?array $recipientRoles = null; + + /** + * The rung's priority. + * + * @var string|null + */ + protected ?string $priority = null; + + /** + * True when copied forward from a superseded timer rather than fired. + * + * @var boolean|null + */ + protected ?bool $inherited = false; + + /** + * Creation stamp. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Constructor: declares the column types. + */ + public function __construct() { + $this->addType(fieldName: 'timerUuid', type: 'string'); + $this->addType(fieldName: 'rungKey', type: 'string'); + $this->addType(fieldName: 'firedAt', type: 'datetime'); + $this->addType(fieldName: 'transitionAction', type: 'string'); + $this->addType(fieldName: 'recipientRoles', type: 'json'); + $this->addType(fieldName: 'priority', type: 'string'); + $this->addType(fieldName: 'inherited', type: 'boolean'); + $this->addType(fieldName: 'created', type: 'datetime'); + + }//end __construct() + + /** + * Serialise for the API. + * + * @return array The fire row. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function jsonSerialize(): array { + $firedAt = null; + if ($this->firedAt !== null) { + $firedAt = $this->firedAt->format('c'); + } + + return [ + 'id' => $this->id, + 'timerUuid' => $this->timerUuid, + 'rungKey' => $this->rungKey, + 'firedAt' => $firedAt, + 'transitionAction' => $this->transitionAction, + 'recipientRoles' => $this->recipientRoles, + 'priority' => $this->priority, + 'inherited' => $this->inherited, + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/FlowTimerFireMapper.php b/lib/Db/FlowTimerFireMapper.php new file mode 100644 index 0000000000..26266a6958 --- /dev/null +++ b/lib/Db/FlowTimerFireMapper.php @@ -0,0 +1,148 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Mapper + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use InvalidArgumentException; +use LogicException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception as DbException; +use OCP\IDBConnection; + +/** + * Mapper for `openregister_flow_timer_fires`. + * + * @template-extends QBMapper + */ +class FlowTimerFireMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_flow_timer_fires', entityClass: FlowTimerFire::class); + + }//end __construct() + + /** + * Insert, stamping `created`. + * + * @param Entity $entity The fire row. + * + * @return FlowTimerFire The inserted row. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function insert(Entity $entity): FlowTimerFire { + if ($entity instanceof FlowTimerFire === false) { + throw new InvalidArgumentException('FlowTimerFireMapper persists FlowTimerFire entities only.'); + } + + if ($entity->getCreated() === null) { + $entity->setCreated(new DateTime()); + } + + return parent::insert(entity: $entity); + }//end insert() + + /** + * Updates are refused: the ledger is append-only. + * + * @param Entity $entity Ignored. + * + * @return FlowTimerFire Never returns. + * + * @throws \LogicException Always. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function update(Entity $entity): FlowTimerFire { + throw new LogicException('The rung-fire ledger is append-only; row ' . (string)$entity->getId() . ' cannot be updated.'); + }//end update() + + /** + * Deletes are refused: the ledger is append-only. + * + * @param Entity $entity Ignored. + * + * @return FlowTimerFire Never returns. + * + * @throws \LogicException Always. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function delete(Entity $entity): FlowTimerFire { + throw new LogicException('The rung-fire ledger is append-only; row ' . (string)$entity->getId() . ' cannot be deleted.'); + }//end delete() + + /** + * CLAIM a rung: insert its ledger row, or report that another pass owns it. + * + * @param FlowTimerFire $fire The row to insert; its (timer_uuid, rung_key) is the claim. + * + * @return FlowTimerFire|null The inserted row, or null when the unique index refused it. + * + * @throws DbException On any failure other than the unique-constraint violation. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function claim(FlowTimerFire $fire): ?FlowTimerFire { + try { + return $this->insert(entity: $fire); + } catch (DbException $failure) { + if ($failure->getReason() === DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + // Another pass owns this rung. Not an error: at-most-once is the contract. + return null; + } + + throw $failure; + } + }//end claim() + + /** + * Every fire row of a timer, oldest first. + * + * @param string $timerUuid The timer uuid. + * + * @return array The fire rows. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function findByTimer(string $timerUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('timer_uuid', $qb->createNamedParameter($timerUuid))) + ->orderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findByTimer() +}//end class diff --git a/lib/Db/FlowTimerMapper.php b/lib/Db/FlowTimerMapper.php new file mode 100644 index 0000000000..8bfbfe064c --- /dev/null +++ b/lib/Db/FlowTimerMapper.php @@ -0,0 +1,301 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Mapper + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use DateTimeInterface; +use InvalidArgumentException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Mapper for `openregister_flow_timers`. + * + * @template-extends QBMapper + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) Eleven: each is a distinct + * question the sweep, the lifecycle or the invariant check asks of the table, + * with its own predicate set, exactly as {@see FlowRunMapper} argues. + */ +class FlowTimerMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_flow_timers', entityClass: FlowTimer::class); + + }//end __construct() + + /** + * Insert, stamping `created`. + * + * @param Entity $entity The timer to insert. + * + * @return FlowTimer The inserted timer, with its id. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function insert(Entity $entity): FlowTimer { + if ($entity instanceof FlowTimer === false) { + throw new InvalidArgumentException('FlowTimerMapper persists FlowTimer entities only.'); + } + + if ($entity->getCreated() === null) { + $entity->setCreated(new DateTime()); + } + + return parent::insert(entity: $entity); + }//end insert() + + /** + * Update, stamping `updated`. + * + * @param Entity $entity The timer to update. + * + * @return FlowTimer The updated timer. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function update(Entity $entity): FlowTimer { + if ($entity instanceof FlowTimer === false) { + throw new InvalidArgumentException('FlowTimerMapper persists FlowTimer entities only.'); + } + + $entity->setUpdated(new DateTime()); + + return parent::update(entity: $entity); + }//end update() + + /** + * Find a timer by its public uuid. + * + * @param string $uuid The timer uuid. + * + * @return FlowTimer The timer. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException When no such timer exists. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function findByUuid(string $uuid): FlowTimer { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))); + + return $this->findEntity(query: $qb); + }//end findByUuid() + + /** + * The expiry range scan: armed ENFORCING-PURPOSE timers whose fire moment + * has passed, oldest first, bounded. + * + * `purpose = expiry` is part of the predicate because a `due` timer stays + * ARMED past its fire moment by design — that is what makes it overdue on + * read — and must not be re-selected every pass. + * + * @param DateTimeInterface $now The sweep instant. + * @param int $limit The batch limit. + * + * @return array The due expiry timers. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + public function findDueExpiries(DateTimeInterface $now, int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('state', $qb->createNamedParameter(FlowTimer::STATE_ARMED))) + ->andWhere($qb->expr()->eq('purpose', $qb->createNamedParameter(FlowTimer::PURPOSE_EXPIRY))) + ->andWhere($qb->expr()->isNotNull('fire_at')) + ->andWhere($qb->expr()->lte('fire_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE))) + ->orderBy('fire_at', 'ASC') + ->setMaxResults($limit); + + return $this->findEntities(query: $qb); + }//end findDueExpiries() + + /** + * The rung range scan: armed timers whose next unfired rung is due, bounded. + * + * @param DateTimeInterface $now The sweep instant. + * @param int $limit The batch limit. + * + * @return array The timers with a due rung. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + public function findDueRungs(DateTimeInterface $now, int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('state', $qb->createNamedParameter(FlowTimer::STATE_ARMED))) + ->andWhere($qb->expr()->isNotNull('next_rung_at')) + ->andWhere($qb->expr()->lte('next_rung_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE))) + ->orderBy('next_rung_at', 'ASC') + ->setMaxResults($limit); + + return $this->findEntities(query: $qb); + }//end findDueRungs() + + /** + * Every timer bound to a subject, optionally restricted to a state set. + * + * @param string $subjectType The subject type. + * @param string $subjectUuid The subject uuid. + * @param array $states Restrict to these states; empty means all. + * + * @return array The subject's timers, oldest first. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function findBySubject(string $subjectType, string $subjectUuid, array $states = []): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('subject_type', $qb->createNamedParameter($subjectType))) + ->andWhere($qb->expr()->eq('subject_uuid', $qb->createNamedParameter($subjectUuid))) + ->orderBy('id', 'ASC'); + + if ($states !== []) { + $qb->andWhere($qb->expr()->in('state', $qb->createNamedParameter($states, IQueryBuilder::PARAM_STR_ARRAY))); + } + + return $this->findEntities(query: $qb); + }//end findBySubject() + + /** + * The OPEN timers a run terminality reaches: bound to the run as subject, + * or carrying it as provenance. + * + * @param string $runUuid The run uuid. + * + * @return array The open timers. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function findOpenByRun(string $runUuid): array { + $qb = $this->db->getQueryBuilder(); + $runParam = $qb->createNamedParameter($runUuid); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->orX( + $qb->expr()->eq('run_uuid', $runParam), + $qb->expr()->andX( + $qb->expr()->eq('subject_type', $qb->createNamedParameter('run')), + $qb->expr()->eq('subject_uuid', $runParam) + ) + ) + ) + ->andWhere( + $qb->expr()->in( + 'state', + $qb->createNamedParameter([FlowTimer::STATE_ARMED, FlowTimer::STATE_SUSPENDED], IQueryBuilder::PARAM_STR_ARRAY) + ) + ) + ->orderBy('id', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findOpenByRun() + + /** + * The successors of a timer: rows that supersede it, newest first. + * + * @param string $uuid The superseded timer's uuid. + * + * @return array The successors. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-deadlines-anchor-is-stored-so-a-moved-anchor-re-arms-the-timer + */ + public function findSuccessors(string $uuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('supersedes_uuid', $qb->createNamedParameter($uuid))) + ->orderBy('id', 'DESC'); + + return $this->findEntities(query: $qb); + }//end findSuccessors() + + /** + * Every timer in a state, paged by id, for the invariant check. + * + * @param string $state The state. + * @param int $afterId Return rows with an id above this. + * @param int $limit The page size. + * + * @return array The page. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function findByStatePaged(string $state, int $afterId, int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('state', $qb->createNamedParameter($state))) + ->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($afterId, IQueryBuilder::PARAM_INT))) + ->orderBy('id', 'ASC') + ->setMaxResults($limit); + + return $this->findEntities(query: $qb); + }//end findByStatePaged() + + /** + * CLAIM the terminal fire of a timer: `SET state = 'fired' WHERE uuid = ? + * AND state = 'armed'`. Zero affected rows means another pass owns it, so + * the outcome is applied at most once (design D-8). + * + * @param string $uuid The timer uuid. + * @param DateTimeInterface $firedAt The claim instant. + * + * @return boolean True when this caller won the claim. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + public function claimFired(string $uuid, DateTimeInterface $firedAt): bool { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('state', $qb->createNamedParameter(FlowTimer::STATE_FIRED)) + ->set('fired_at', $qb->createNamedParameter($firedAt, IQueryBuilder::PARAM_DATETIME_MUTABLE)) + ->set('updated', $qb->createNamedParameter(new DateTime(), IQueryBuilder::PARAM_DATETIME_MUTABLE)) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))) + ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter(FlowTimer::STATE_ARMED))); + + return $qb->executeStatement() === 1; + }//end claimFired() +}//end class diff --git a/lib/Event/FlowTimerFiredEvent.php b/lib/Event/FlowTimerFiredEvent.php new file mode 100644 index 0000000000..af7931e0fb --- /dev/null +++ b/lib/Event/FlowTimerFiredEvent.php @@ -0,0 +1,149 @@ +`, `group:`, or an unresolved + * `role:`), the priority and the message identity. It sends nothing + * and knows no channel: a subscriber that delivers is downstream of it. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Event + * @package OCA\OpenRegister\Event + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCA\OpenRegister\Db\FlowTimer; +use OCP\EventDispatcher\Event; + +/** + * Carries the timer, the transition raised and its addressees. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ +class FlowTimerFiredEvent extends Event { + + /** + * Kinds of fire. + */ + public const KIND_RUNG = 'rung'; + + public const KIND_EXPIRY = 'expiry'; + + /** + * Constructor. + * + * @param FlowTimer $timer The timer that fired. + * @param string $kind `rung` or `expiry`. + * @param string $transition The named transition raised. + * @param string|null $rungKey The rung's stable key, for a rung fire. + * @param array $recipients The resolved addressees. + * @param string|null $priority The rung's priority. + * @param string|null $message The message identity, resolved downstream. + */ + public function __construct( + private readonly FlowTimer $timer, + private readonly string $kind, + private readonly string $transition, + private readonly ?string $rungKey, + private readonly array $recipients, + private readonly ?string $priority, + private readonly ?string $message, + ) { + parent::__construct(); + + }//end __construct() + + /** + * The timer that fired. + * + * @return FlowTimer The timer. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function getTimer(): FlowTimer { + return $this->timer; + }//end getTimer() + + /** + * Whether this was a rung or the expiry. + * + * @return string `rung` or `expiry`. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function getKind(): string { + return $this->kind; + }//end getKind() + + /** + * The named transition raised. + * + * @return string The transition. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function getTransition(): string { + return $this->transition; + }//end getTransition() + + /** + * The rung key, for a rung fire. + * + * @return string|null The key. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function getRungKey(): ?string { + return $this->rungKey; + }//end getRungKey() + + /** + * The resolved addressees. + * + * @return array The recipients. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function getRecipients(): array { + return $this->recipients; + }//end getRecipients() + + /** + * The rung's priority. + * + * @return string|null The priority. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function getPriority(): ?string { + return $this->priority; + }//end getPriority() + + /** + * The message identity. + * + * @return string|null The message identity. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function getMessage(): ?string { + return $this->message; + }//end getMessage() +}//end class diff --git a/lib/Exception/FlowTimerStateException.php b/lib/Exception/FlowTimerStateException.php new file mode 100644 index 0000000000..1b2c33bdab --- /dev/null +++ b/lib/Exception/FlowTimerStateException.php @@ -0,0 +1,38 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-extension-is-bounded-and-may-only-be-granted-before-expiry + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * A state-refused timer operation, with the state in the message. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-extension-is-bounded-and-may-only-be-granted-before-expiry + */ +class FlowTimerStateException extends RuntimeException { +}//end class diff --git a/lib/Exception/FlowTimerValidationException.php b/lib/Exception/FlowTimerValidationException.php new file mode 100644 index 0000000000..63f48ce230 --- /dev/null +++ b/lib/Exception/FlowTimerValidationException.php @@ -0,0 +1,39 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use InvalidArgumentException; + +/** + * A refused timer value, named in the message. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ +class FlowTimerValidationException extends InvalidArgumentException { +}//end class diff --git a/lib/Listener/FlowTimerSubjectTerminalListener.php b/lib/Listener/FlowTimerSubjectTerminalListener.php new file mode 100644 index 0000000000..bba3127505 --- /dev/null +++ b/lib/Listener/FlowTimerSubjectTerminalListener.php @@ -0,0 +1,127 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Cancels a terminal subject's open timers. + * + * @template-implements IEventListener + */ +class FlowTimerSubjectTerminalListener implements IEventListener { + + /** + * Constructor. + * + * @param FlowTimerService $timers The timer lifecycle. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly FlowTimerService $timers, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle the event. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function handle(Event $event): void { + try { + if ($event instanceof TaskTerminalEvent === true) { + $task = $event->getTask(); + $uuid = (string)$task->getUuid(); + $cancelled = $this->timers->cancelForSubject( + subjectType: 'task', + subjectUuid: $uuid, + reason: sprintf( + "Task '%s' reached terminal state '%s' (outcome '%s').", + $uuid, + (string)$task->getState(), + (string)$task->getOutcome() + ), + actor: sprintf('task:%s', $uuid) + ); + $this->report(count: $cancelled, subject: 'task ' . $uuid); + return; + } + + if ($event instanceof FlowRunTerminalEvent === true) { + $cancelled = $this->timers->cancelForRun( + runUuid: $event->getRunUuid(), + reason: sprintf("Run '%s' reached terminal status '%s'.", $event->getRunUuid(), $event->getStatus()), + actor: sprintf('flow-run:%s', $event->getRunUuid()) + ); + $this->report(count: $cancelled, subject: 'run ' . $event->getRunUuid()); + } + } catch (Throwable $failure) { + $this->logger->error( + '[FlowTimerSubjectTerminalListener] Timer cancellation failed: ' . $failure->getMessage(), + ['exception' => $failure] + ); + }//end try + }//end handle() + + /** + * Log a non-zero cancellation count. + * + * @param int $count How many timers were cancelled. + * @param string $subject The subject, for the message. + * + * @return void + */ + private function report(int $count, string $subject): void { + if ($count > 0) { + $this->logger->info(sprintf('[FlowTimerSubjectTerminalListener] Cancelled %d timer(s) of %s.', $count, $subject)); + } + }//end report() +}//end class diff --git a/lib/Migration/Version1Date20260901170000.php b/lib/Migration/Version1Date20260901170000.php new file mode 100644 index 0000000000..866188b35f --- /dev/null +++ b/lib/Migration/Version1Date20260901170000.php @@ -0,0 +1,254 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the three business-timer tables. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ +class Version1Date20260901170000 extends SimpleMigrationStep { + /** + * The timer table. + * + * @var string + */ + public const TABLE_TIMERS = 'openregister_flow_timers'; + + /** + * The rung dedup ledger. + * + * @var string + */ + public const TABLE_FIRES = 'openregister_flow_timer_fires'; + + /** + * The append-only evidence log. + * + * @var string + */ + public const TABLE_EVENTS = 'openregister_flow_timer_events'; + + /** + * Change the database schema. + * + * @param IOutput $output Output for the migration process. + * @param Closure $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + + $schema = $schemaClosure(); + $changed = false; + + if ($schema->hasTable(self::TABLE_TIMERS) === false) { + $this->createTimers(schema: $schema); + $changed = true; + } + + if ($schema->hasTable(self::TABLE_FIRES) === false) { + $this->createFires(schema: $schema); + $changed = true; + } + + if ($schema->hasTable(self::TABLE_EVENTS) === false) { + $this->createEvents(schema: $schema); + $changed = true; + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() + + /** + * The timer table (design.md, Data model). + * + * @param ISchemaWrapper $schema The schema being changed. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + private function createTimers(ISchemaWrapper $schema): void { + $table = $schema->createTable(self::TABLE_TIMERS); + + // Identity. + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('title', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('metadata', Types::JSON, ['notnull' => false]); + + // Subject: the row the timer measures. Provenance is optional. + $table->addColumn('subject_type', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('subject_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('organisation', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('run_uuid', Types::STRING, ['notnull' => false, 'length' => 36]); + $table->addColumn('node_id', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('app_id', Types::STRING, ['notnull' => false, 'length' => 64]); + + // Purpose: due advises, expiry enforces, and only wettelijk may enforce. + $table->addColumn('purpose', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('legal_effect', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('on_expiry', Types::STRING, ['notnull' => false, 'length' => 128]); + + // Anchor: stored, so a moved anchor can re-arm (design D-4). + $table->addColumn('anchor_event', Types::STRING, ['notnull' => false, 'length' => 128]); + $table->addColumn('anchor_offset', Types::INTEGER, ['notnull' => false]); + $table->addColumn('anchor_offset_unit', Types::STRING, ['notnull' => false, 'length' => 16]); + $table->addColumn('anchor_at', Types::DATETIME, ['notnull' => true]); + + // Budget and the suspension ledger (design D-2). + $table->addColumn('budget_value', Types::DECIMAL, ['notnull' => true, 'precision' => 12, 'scale' => 4]); + $table->addColumn('budget_unit', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('consumed_value', Types::DECIMAL, ['notnull' => true, 'precision' => 12, 'scale' => 4, 'default' => 0]); + $table->addColumn('running_since', Types::DATETIME, ['notnull' => false]); + + // Derived, maintained by ONE private method (design D-2, D-8). + $table->addColumn('fire_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('next_rung_at', Types::DATETIME, ['notnull' => false]); + + // Calendar and ladder. + $table->addColumn('calendar_slug', Types::STRING, ['notnull' => false, 'length' => 128]); + $table->addColumn('ladder_slug', Types::STRING, ['notnull' => false, 'length' => 128]); + $table->addColumn('escalation_rules', Types::JSON, ['notnull' => false]); + + // Suspension evidence (reporting, NOT arithmetic inputs). + $table->addColumn('suspended_since', Types::DATETIME, ['notnull' => false]); + $table->addColumn('suspend_reason', Types::STRING, ['notnull' => false, 'length' => 512]); + $table->addColumn('suspended_total_seconds', Types::BIGINT, ['notnull' => true, 'default' => 0]); + + // Extension bound. + $table->addColumn('extension_count', Types::INTEGER, ['notnull' => true, 'default' => 0]); + $table->addColumn('extension_max', Types::INTEGER, ['notnull' => true, 'default' => 1]); + + // Lifecycle. No overdue column, by requirement. + $table->addColumn('state', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('supersedes_uuid', Types::STRING, ['notnull' => false, 'length' => 36]); + $table->addColumn('fired_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('breached', Types::BOOLEAN, ['notnull' => true, 'default' => false]); + $table->addColumn('cancelled_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('cancel_reason', Types::STRING, ['notnull' => false, 'length' => 512]); + + // Stamps. + $table->addColumn('created', Types::DATETIME, ['notnull' => true]); + $table->addColumn('updated', Types::DATETIME, ['notnull' => false]); + $table->addColumn('created_by', Types::STRING, ['notnull' => false, 'length' => 64]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['uuid'], 'or_flowtimer_uuid_idx'); + // The two bounded range scans of the sweep. + $table->addIndex(['state', 'fire_at'], 'or_flowtimer_due_idx'); + $table->addIndex(['state', 'next_rung_at'], 'or_flowtimer_rung_idx'); + // Cancellation by subject, and the run provenance read. + $table->addIndex(['subject_type', 'subject_uuid', 'state'], 'or_flowtimer_subj_idx'); + $table->addIndex(['run_uuid'], 'or_flowtimer_run_idx'); + }//end createTimers() + + /** + * The rung dedup ledger, unique per (timer, rung). + * + * @param ISchemaWrapper $schema The schema being changed. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + private function createFires(ISchemaWrapper $schema): void { + $table = $schema->createTable(self::TABLE_FIRES); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('timer_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('rung_key', Types::STRING, ['notnull' => true, 'length' => 128]); + $table->addColumn('fired_at', Types::DATETIME, ['notnull' => true]); + $table->addColumn('transition_action', Types::STRING, ['notnull' => false, 'length' => 128]); + $table->addColumn('recipient_roles', Types::JSON, ['notnull' => false]); + $table->addColumn('priority', Types::STRING, ['notnull' => false, 'length' => 16]); + $table->addColumn('inherited', Types::BOOLEAN, ['notnull' => true, 'default' => false]); + $table->addColumn('created', Types::DATETIME, ['notnull' => true]); + + $table->setPrimaryKey(['id']); + // The constraint the at-most-once argument rests on. + $table->addUniqueIndex(['timer_uuid', 'rung_key'], 'or_flowtimfire_uq'); + }//end createFires() + + /** + * The append-only evidence log. + * + * @param ISchemaWrapper $schema The schema being changed. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + private function createEvents(ISchemaWrapper $schema): void { + $table = $schema->createTable(self::TABLE_EVENTS); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('timer_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('type', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('actor', Types::STRING, ['notnull' => false, 'length' => 128]); + $table->addColumn('reason', Types::STRING, ['notnull' => false, 'length' => 1024]); + $table->addColumn('prior_fire_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('new_fire_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('days_impact', Types::DECIMAL, ['notnull' => false, 'precision' => 12, 'scale' => 4]); + $table->addColumn('basis', Types::STRING, ['notnull' => false, 'length' => 128]); + $table->addColumn('created', Types::DATETIME, ['notnull' => true]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['timer_uuid', 'created'], 'or_flowtimev_timer_idx'); + }//end createEvents() +}//end class diff --git a/lib/Repair/CheckFlowTimerInvariants.php b/lib/Repair/CheckFlowTimerInvariants.php new file mode 100644 index 0000000000..8b09eb89a9 --- /dev/null +++ b/lib/Repair/CheckFlowTimerInvariants.php @@ -0,0 +1,181 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Repair + * @package OCA\OpenRegister\Repair + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Repair; + +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Reports armed-without-fire_at, suspended-with-fire_at and orphaned timers. + */ +class CheckFlowTimerInvariants implements IRepairStep { + + /** + * Rows read per page. + * + * @var int + */ + private const PAGE = 500; + + /** + * Constructor. + * + * @param FlowTimerMapper $timers The timer table. + * @param TaskMapper $tasks The task table, for subject existence and terminality. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly FlowTimerMapper $timers, + private readonly TaskMapper $tasks, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Get the name of this repair step. + * + * @return string The step name. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function getName(): string { + return 'Check business-timer invariants (armed fire_at, suspended NULLs, orphaned timers) and report defects'; + }//end getName() + + /** + * Run the check. Reports counts; mutates nothing. + * + * @param IOutput $output Output interface for status messages. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function run(IOutput $output): void { + try { + $counts = $this->measure(); + } catch (Throwable $failure) { + // A fresh install has no table yet when this runs pre-seed; say so rather than fail the upgrade. + $this->logger->warning('[CheckFlowTimerInvariants] Check skipped: ' . $failure->getMessage()); + $output->warning('Business-timer invariant check skipped: ' . $failure->getMessage()); + + return; + } + + $defects = array_sum($counts); + $message = sprintf( + 'Business-timer invariants: %d armed without fire_at, %d suspended with fire_at or running_since, %d orphaned (subject terminal or absent).', + $counts['armedWithoutFireAt'], + $counts['suspendedWithClock'], + $counts['orphaned'] + ); + + if ($defects === 0) { + $output->info($message); + + return; + } + + $output->warning($message . ' These are DEFECTS in whoever completed the subject; they are reported, not cancelled.'); + $this->logger->warning('[CheckFlowTimerInvariants] ' . $message, $counts); + }//end run() + + /** + * Count the defects across the armed and suspended timers. + * + * @return array{armedWithoutFireAt: int, suspendedWithClock: int, orphaned: int} The counts. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function measure(): array { + $counts = ['armedWithoutFireAt' => 0, 'suspendedWithClock' => 0, 'orphaned' => 0]; + + foreach ([FlowTimer::STATE_ARMED, FlowTimer::STATE_SUSPENDED] as $state) { + $afterId = 0; + $more = true; + while ($more === true) { + $page = $this->timers->findByStatePaged(state: $state, afterId: $afterId, limit: self::PAGE); + foreach ($page as $timer) { + $afterId = (int)$timer->getId(); + $this->inspect(timer: $timer, counts: $counts); + } + + $more = (count($page) === self::PAGE); + } + } + + return $counts; + }//end measure() + + /** + * Inspect one open timer against the invariants. + * + * @param FlowTimer $timer The timer. + * @param array{armedWithoutFireAt: int, suspendedWithClock: int, orphaned: int} $counts The running counts, by reference. + * + * @return void + */ + private function inspect(FlowTimer $timer, array &$counts): void { + if ($timer->getState() === FlowTimer::STATE_ARMED && $timer->getFireAt() === null) { + $counts['armedWithoutFireAt']++; + } + + if ($timer->getState() === FlowTimer::STATE_SUSPENDED && ($timer->getFireAt() !== null || $timer->getRunningSince() !== null)) { + $counts['suspendedWithClock']++; + } + + if ($timer->getSubjectType() === 'task' && $this->taskIsTerminalOrAbsent(uuid: (string)$timer->getSubjectUuid()) === true) { + $counts['orphaned']++; + } + }//end inspect() + + /** + * Whether a task subject is terminal or does not exist. + * + * @param string $uuid The task uuid. + * + * @return boolean True when the timer is orphaned. + */ + private function taskIsTerminalOrAbsent(string $uuid): bool { + try { + return $this->tasks->findByUuid(uuid: $uuid)->isInTerminalState(); + } catch (DoesNotExistException) { + return true; + } + }//end taskIsTerminalOrAbsent() +}//end class diff --git a/lib/Repair/SeedFlowTimerRegister.php b/lib/Repair/SeedFlowTimerRegister.php new file mode 100644 index 0000000000..2e5101ab4f --- /dev/null +++ b/lib/Repair/SeedFlowTimerRegister.php @@ -0,0 +1,123 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Repair + * @package OCA\OpenRegister\Repair + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Repair; + +use OCA\OpenRegister\Service\ConfigurationService; +use OCP\App\IAppManager; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Imports the flow-timers register descriptor idempotently on upgrade/install. + */ +class SeedFlowTimerRegister implements IRepairStep { + /** + * App-relative path to the register descriptor imported by this step. + * + * @var string + */ + public const REGISTER_PATH = '/lib/Settings/flow_timer_register.json'; + + /** + * Descriptor version passed to the importer's version_compare gate. + * + * @var string + */ + private const REGISTER_VERSION = '1.0.0'; + + /** + * Constructor. + * + * @param ConfigurationService $configurationService The OR configuration importer. + * @param IAppManager $appManager Resolves the openregister app path on disk. + * @param LoggerInterface $logger Logger for import diagnostics. + */ + public function __construct( + private readonly ConfigurationService $configurationService, + private readonly IAppManager $appManager, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Get the name of this repair step. + * + * @return string The step name. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function getName(): string { + return 'Seed the flow-timers register (working calendars + escalation ladders)'; + }//end getName() + + /** + * Run the repair step, importing the flow-timers register descriptor. + * + * @param IOutput $output Output interface for status messages. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function run(IOutput $output): void { + try { + $path = $this->appManager->getAppPath('openregister') . self::REGISTER_PATH; + if (is_file($path) === false) { + $output->warning('Flow-timers register descriptor not found: ' . $path); + return; + } + + $data = json_decode((string)file_get_contents($path), true); + if (is_array($data) === false) { + $output->warning('Flow-timers register descriptor is not valid JSON: ' . $path); + return; + } + + // The importer takes the DECODED descriptor; force: false keeps + // the import idempotent and leaves administrator edits in place. + $this->configurationService->importFromApp( + appId: 'openregister', + data: $data, + version: self::REGISTER_VERSION, + force: false + ); + + $output->info('Flow-timers register imported (working-calendar + escalation-ladder schemas and seeded defaults)'); + } catch (Throwable $e) { + $this->logger->warning('[SeedFlowTimerRegister] import failed: ' . $e->getMessage()); + $output->warning('Flow-timers register import skipped: ' . $e->getMessage()); + }//end try + }//end run() +}//end class diff --git a/lib/Service/Flow/Timer/EscalationLadderService.php b/lib/Service/Flow/Timer/EscalationLadderService.php new file mode 100644 index 0000000000..c6324dffb0 --- /dev/null +++ b/lib/Service/Flow/Timer/EscalationLadderService.php @@ -0,0 +1,580 @@ += anchor_at` — never the two raw + * integers (design D-6). A rule without an SLA is refused. + * + * This service decides WHEN a rung is due and WHO it names. It claims nothing + * and sends nothing: {@see FlowTimerService} owns the fire ledger, and the + * recipients it resolves are DESCRIPTORS (`user:`, `group:`, `role:`) for a + * downstream subscriber, not uids looked up in Nextcloud. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Timer; + +use DateTimeImmutable; +use DateTimeInterface; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\FlowTimerValidationException; + +/** + * Rung resolution, timeline validation and recipient descriptors. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of the rule + * validator's refusal branches (trigger, offset, unit, priority, role lists, + * each named in its own message) and the three recipient resolution paths. + * Each method is small; the class total is the vocabulary it guards. + */ +class EscalationLadderService { + + /** + * The seeded default ladder, 14/7/2/0. + * + * @var string + */ + public const DEFAULT_LADDER = 'nl-termijn-default'; + + /** + * Triggers. + */ + public const TRIGGER_PRE_BREACH = 'preBreach'; + + public const TRIGGER_BREACHED = 'slaBreached'; + + /** + * The trigger vocabulary. + * + * @var array + */ + public const TRIGGERS = [self::TRIGGER_PRE_BREACH, self::TRIGGER_BREACHED]; + + /** + * The rung priority scale. + * + * @var array + */ + public const PRIORITIES = ['low', 'medium', 'high', 'critical']; + + /** + * The role a ladder uses for the subject's own performer. + * + * @var string + */ + public const ROLE_HANDLER = 'handler'; + + /** + * Constructor. + * + * @param FlowTimerDefinitionStore $definitions The seeded ladder definitions. + * @param SlaCalculator $calculator Timeline arithmetic. + */ + public function __construct( + private readonly FlowTimerDefinitionStore $definitions, + private readonly SlaCalculator $calculator, + ) { + + }//end __construct() + + /** + * The rungs a timer carries, normalised, plus the role bindings that apply. + * + * @param FlowTimer $timer The timer. + * + * @return array{rungs: array>, roleBindings: array} The ladder. + * + * @throws FlowTimerValidationException When `ladder_slug` names no ladder. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function resolveLadder(FlowTimer $timer): array { + $sla = ['value' => (int)round((float)$timer->getBudgetValue()), 'unit' => (string)$timer->getBudgetUnit()]; + $rules = $timer->getEscalationRules(); + if (is_array($rules) === true && $rules !== []) { + return ['rungs' => $this->normaliseRules(rules: $rules, sla: $sla), 'roleBindings' => []]; + } + + $slug = trim((string)$timer->getLadderSlug()); + if ($slug === '') { + $slug = (string)$this->organisationLadderSlug(organisation: $timer->getOrganisation()); + } + + if ($slug === '') { + return ['rungs' => [], 'roleBindings' => []]; + } + + $ladder = $this->ladderBySlug(slug: $slug); + $bindings = ($ladder['roleBindings'] ?? []); + if (is_array($bindings) === false) { + $bindings = []; + } + + return [ + 'rungs' => $this->normaliseRules(rules: ($ladder['rungs'] ?? []), sla: $sla), + 'roleBindings' => $bindings, + ]; + }//end resolveLadder() + + /** + * Validate and normalise escalation rules. + * + * Shape `{trigger, offset, offsetUnit, notifyRole, escalateToRole, + * openIncident}` plus `key`, `priority` and `message`. `offsetUnit` + * accepts the SAME set the SLA does, `calendarDays` included. Refused + * without an SLA: a warning before a breach is meaningless without the + * term it warns about. + * + * @param mixed $rules The declared rules. + * @param array{value: int, unit: string}|null $sla The SLA on the same configuration. + * + * @return array> The normalised rungs, in declared order. + * + * @throws FlowTimerValidationException On any refused shape or value. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-escalation-rule-is-validated-against-its-sla-in-commensurable-units + */ + public function normaliseRules(mixed $rules, ?array $sla): array { + if (is_array($rules) === false) { + throw new FlowTimerValidationException(message: 'Escalation rules must be an array of rules.'); + } + + if ($rules === []) { + return []; + } + + if ($sla === null) { + throw new FlowTimerValidationException( + message: 'An escalation rule is refused without an SLA: a warning before a breach is meaningless without the term it warns about.' + ); + } + + $normalised = []; + foreach ($rules as $index => $rule) { + if (is_array($rule) === false) { + throw new FlowTimerValidationException(message: sprintf('Escalation rule #%d is not an object.', (int)$index)); + } + + $normalised[] = $this->normaliseRule(rule: $rule, index: (int)$index); + } + + return $normalised; + }//end normaliseRules() + + /** + * The AUTHORITATIVE preBreach check, on the timeline: every preBreach rung + * must resolve at or after the anchor. + * + * @param array> $rungs The normalised rungs. + * @param DateTimeInterface $anchorAt The instant the term runs from. + * @param DateTimeInterface $fireAt The instant the term ends. + * @param WorkingCalendar $calendar The resolved calendar. + * + * @return void + * + * @throws FlowTimerValidationException When an offset exceeds the SLA, naming the anchor. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-escalation-rule-is-validated-against-its-sla-in-commensurable-units + */ + public function validateAgainstTimeline(array $rungs, DateTimeInterface $anchorAt, DateTimeInterface $fireAt, WorkingCalendar $calendar): void { + foreach ($rungs as $rung) { + if ($rung['trigger'] !== self::TRIGGER_PRE_BREACH) { + continue; + } + + $instant = $this->rungInstant(rung: $rung, fireAt: $fireAt, calendar: $calendar); + if ($instant < $anchorAt) { + throw new FlowTimerValidationException( + message: sprintf( + "Escalation rule '%s': the preBreach offset of %d %s exceeds the SLA — it resolves to %s, before the anchor %s (calendar '%s').", + (string)$rung['key'], + (int)$rung['offset'], + (string)$rung['offsetUnit'], + $instant->format('c'), + $anchorAt->format('c'), + $calendar->getSlug() + ) + ); + } + } + }//end validateAgainstTimeline() + + /** + * The instant a rung falls on: `preBreach` is offset BEFORE the fire + * moment, `slaBreached` is offset AFTER it. + * + * @param array $rung The normalised rung. + * @param DateTimeInterface $fireAt The fire moment. + * @param WorkingCalendar $calendar The resolved calendar. + * + * @return DateTimeImmutable The rung's instant. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function rungInstant(array $rung, DateTimeInterface $fireAt, WorkingCalendar $calendar): DateTimeImmutable { + $offset = (float)$rung['offset']; + $unit = (string)$rung['offsetUnit']; + if ($rung['trigger'] === self::TRIGGER_PRE_BREACH) { + return $this->calculator->sub(from: $fireAt, value: $offset, unit: $unit, calendar: $calendar); + } + + return $this->calculator->add(from: $fireAt, value: $offset, unit: $unit, calendar: $calendar); + }//end rungInstant() + + /** + * The instant of the earliest UNFIRED rung — the `next_rung_at` derivation. + * + * @param array> $rungs The normalised rungs. + * @param DateTimeInterface $fireAt The fire moment. + * @param array $firedKeys Rung keys already in the ledger. + * @param WorkingCalendar $calendar The resolved calendar. + * + * @return DateTimeImmutable|null The instant, or null when every rung has fired. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + public function nextRungAt(array $rungs, DateTimeInterface $fireAt, array $firedKeys, WorkingCalendar $calendar): ?DateTimeImmutable { + $earliest = null; + foreach ($this->ordered(rungs: $rungs, fireAt: $fireAt, calendar: $calendar) as $entry) { + if (in_array((string)$entry['rung']['key'], $firedKeys, true) === true) { + continue; + } + + if ($earliest === null || $entry['at'] < $earliest) { + $earliest = $entry['at']; + } + } + + return $earliest; + }//end nextRungAt() + + /** + * The rungs whose instant has passed and which have no fire row, in + * LADDER ORDER (earliest instant first), each to fire once. A gap fires + * every passed rung; nothing collapses them into the most severe. + * + * @param array> $rungs The normalised rungs. + * @param DateTimeInterface $fireAt The fire moment. + * @param DateTimeInterface $now The sweep instant. + * @param array $firedKeys Rung keys already in the ledger. + * @param WorkingCalendar $calendar The resolved calendar. + * + * @return array, at: DateTimeImmutable}> The due rungs. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function dueRungs(array $rungs, DateTimeInterface $fireAt, DateTimeInterface $now, array $firedKeys, WorkingCalendar $calendar): array { + $due = []; + foreach ($this->ordered(rungs: $rungs, fireAt: $fireAt, calendar: $calendar) as $entry) { + if (in_array((string)$entry['rung']['key'], $firedKeys, true) === true || $entry['at'] > $now) { + continue; + } + + $due[] = $entry; + } + + return $due; + }//end dueRungs() + + /** + * Resolve a rung's roles to recipient DESCRIPTORS against the subject. + * + * `handler` is the subject's own performer: the assignee typed by the + * task's performer type (a group performer yields `group:`), else the + * candidate groups, else the candidate users. Any role with a binding in + * the ladder's `roleBindings` resolves to that binding. Every other role + * travels unresolved as `role:` for the downstream subscriber. No + * uid is looked up here. + * + * @param array $rung The normalised rung. + * @param Task|null $subject The subject task, when the subject is a task. + * @param array $roleBindings Role → `type:id` bindings from the ladder. + * + * @return array The recipients, deduplicated. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function resolveRecipients(array $rung, ?Task $subject, array $roleBindings): array { + $roles = array_values(array_unique(array_merge($rung['notifyRole'], $rung['escalateToRole']))); + $recipients = []; + foreach ($roles as $role) { + foreach ($this->recipientsForRole(role: (string)$role, subject: $subject, roleBindings: $roleBindings) as $recipient) { + $recipients[$recipient['type'] . ':' . $recipient['id']] = $recipient; + } + } + + return array_values($recipients); + }//end resolveRecipients() + + /** + * A ladder definition by slug. + * + * @param string $slug The ladder name. + * + * @return array The definition. + * + * @throws FlowTimerValidationException When no ladder carries that name. + */ + private function ladderBySlug(string $slug): array { + $ladders = $this->definitions->ladders(); + if (array_key_exists($slug, $ladders) === false) { + throw new FlowTimerValidationException( + message: sprintf("Escalation ladder '%s' does not exist; known ladders: %s.", $slug, implode(', ', array_keys($ladders))) + ); + } + + return $ladders[$slug]; + }//end ladderBySlug() + + /** + * The ladder configured as an organisation's default, when one is. + * + * @param string|null $organisation The organisation uuid. + * + * @return string|null The slug. + */ + private function organisationLadderSlug(?string $organisation): ?string { + $organisation = trim((string)$organisation); + if ($organisation === '') { + return null; + } + + foreach ($this->definitions->ladders() as $slug => $definition) { + if (trim((string)($definition['organisation'] ?? '')) === $organisation) { + return (string)$slug; + } + } + + return null; + }//end organisationLadderSlug() + + /** + * Rungs paired with their instants, earliest first (declared order breaks ties). + * + * @param array> $rungs The normalised rungs. + * @param DateTimeInterface $fireAt The fire moment. + * @param WorkingCalendar $calendar The resolved calendar. + * + * @return array, at: DateTimeImmutable}> The ordered rungs. + */ + private function ordered(array $rungs, DateTimeInterface $fireAt, WorkingCalendar $calendar): array { + $entries = []; + foreach (array_values($rungs) as $index => $rung) { + $entries[] = ['rung' => $rung, 'at' => $this->rungInstant(rung: $rung, fireAt: $fireAt, calendar: $calendar), 'index' => $index]; + } + + usort( + $entries, + static function (array $left, array $right): int { + if ($left['at']->getTimestamp() === $right['at']->getTimestamp()) { + return ($left['index'] <=> $right['index']); + } + + return ($left['at'] <=> $right['at']); + } + ); + + return array_map( + static fn (array $entry): array => ['rung' => $entry['rung'], 'at' => $entry['at']], + $entries + ); + }//end ordered() + + /** + * Validate and normalise one rule. + * + * @param array $rule The rule. + * @param int $index Its position, for messages. + * + * @return array The normalised rung. + * + * @throws FlowTimerValidationException On a refused trigger, offset, unit, priority or role list. + */ + private function normaliseRule(array $rule, int $index): array { + $trigger = (string)($rule['trigger'] ?? ''); + if (in_array($trigger, self::TRIGGERS, true) === false) { + throw new FlowTimerValidationException( + message: sprintf("Escalation rule #%d has trigger '%s'; use one of %s.", $index, $trigger, implode(', ', self::TRIGGERS)) + ); + } + + $offset = ($rule['offset'] ?? null); + if (is_int($offset) === false || $offset < 0) { + throw new FlowTimerValidationException( + message: sprintf("Escalation rule #%d has offset '%s'; it must be an integer >= 0.", $index, var_export($offset, true)) + ); + } + + $unit = ($rule['offsetUnit'] ?? null); + if (is_string($unit) === false || in_array($unit, SlaCalculator::UNITS, true) === false) { + throw new FlowTimerValidationException( + message: sprintf( + "Escalation rule #%d has offsetUnit '%s'; use one of %s (the same units the SLA accepts).", + $index, + var_export($unit, true), + implode(', ', SlaCalculator::UNITS) + ) + ); + } + + $priority = (string)($rule['priority'] ?? 'medium'); + if (in_array($priority, self::PRIORITIES, true) === false) { + throw new FlowTimerValidationException( + message: sprintf("Escalation rule #%d has priority '%s'; use one of %s.", $index, $priority, implode(', ', self::PRIORITIES)) + ); + } + + $key = trim((string)($rule['key'] ?? '')); + if ($key === '') { + $key = sprintf('%s:%d:%s', $trigger, $offset, $unit); + } + + return [ + 'key' => $key, + 'trigger' => $trigger, + 'offset' => $offset, + 'offsetUnit' => $unit, + 'notifyRole' => $this->roleList(value: ($rule['notifyRole'] ?? []), field: 'notifyRole', index: $index), + 'escalateToRole' => $this->roleList(value: ($rule['escalateToRole'] ?? []), field: 'escalateToRole', index: $index), + 'priority' => $priority, + 'message' => $this->stringOrNull(value: ($rule['message'] ?? null)), + 'openIncident' => (($rule['openIncident'] ?? false) === true), + ]; + }//end normaliseRule() + + /** + * A role list: a string or a list of strings. + * + * @param mixed $value The declared roles. + * @param string $field The field name, for messages. + * @param int $index The rule position, for messages. + * + * @return array The roles. + * + * @throws FlowTimerValidationException On a non-string entry. + */ + private function roleList(mixed $value, string $field, int $index): array { + if (is_string($value) === true) { + $value = [$value]; + } + + if (is_array($value) === false) { + throw new FlowTimerValidationException( + message: sprintf('Escalation rule #%d: %s must be a role name or a list of role names.', $index, $field) + ); + } + + $roles = []; + foreach ($value as $role) { + if (is_string($role) === false || trim($role) === '') { + throw new FlowTimerValidationException( + message: sprintf('Escalation rule #%d: %s contains an empty or non-string role.', $index, $field) + ); + } + + $roles[] = trim($role); + } + + return array_values(array_unique($roles)); + }//end roleList() + + /** + * A non-empty string, or null. + * + * @param mixed $value The value. + * + * @return string|null The string. + */ + private function stringOrNull(mixed $value): ?string { + if (is_string($value) === false || trim($value) === '') { + return null; + } + + return trim($value); + }//end stringOrNull() + + /** + * The recipients one role resolves to. + * + * @param string $role The role. + * @param Task|null $subject The subject task. + * @param array $roleBindings The ladder's bindings. + * + * @return array The recipients. + */ + private function recipientsForRole(string $role, ?Task $subject, array $roleBindings): array { + $binding = ($roleBindings[$role] ?? null); + if (is_string($binding) === true && str_contains($binding, ':') === true) { + [$type, $id] = explode(':', $binding, 2); + + return [['type' => trim($type), 'id' => trim($id), 'role' => $role]]; + } + + if ($role === self::ROLE_HANDLER && $subject !== null) { + $performer = $this->performerRecipients(subject: $subject, role: $role); + if ($performer !== []) { + return $performer; + } + } + + return [['type' => 'role', 'id' => $role, 'role' => $role]]; + }//end recipientsForRole() + + /** + * The subject task's own performer as recipients: assignee, else candidate groups, else candidate users. + * + * @param Task $subject The task. + * @param string $role The role being resolved. + * + * @return array The recipients; empty when the task names nobody. + */ + private function performerRecipients(Task $subject, string $role): array { + $assignee = trim((string)$subject->getAssignee()); + if ($assignee !== '') { + $type = 'user'; + if ((string)$subject->getPerformerType() !== Task::PERFORMER_USER) { + $type = (string)$subject->getPerformerType(); + } + + return [['type' => $type, 'id' => $assignee, 'role' => $role]]; + } + + $recipients = []; + foreach (($subject->getCandidateGroups() ?? []) as $gid) { + $recipients[] = ['type' => 'group', 'id' => (string)$gid, 'role' => $role]; + } + + if ($recipients !== []) { + return $recipients; + } + + foreach (($subject->getCandidateUsers() ?? []) as $uid) { + $recipients[] = ['type' => 'user', 'id' => (string)$uid, 'role' => $role]; + } + + return $recipients; + }//end performerRecipients() +}//end class diff --git a/lib/Service/Flow/Timer/FlowTimerDefinitionStore.php b/lib/Service/Flow/Timer/FlowTimerDefinitionStore.php new file mode 100644 index 0000000000..7873312d8f --- /dev/null +++ b/lib/Service/Flow/Timer/FlowTimerDefinitionStore.php @@ -0,0 +1,224 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Timer; + +use OCA\OpenRegister\Repair\SeedFlowTimerRegister; +use OCA\OpenRegister\Service\ObjectService; +use OCP\App\IAppManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Slug-keyed access to the seeded calendar and ladder definitions. + */ +class FlowTimerDefinitionStore { + + /** + * The register the definitions live in. + * + * @var string + */ + public const REGISTER = 'flow-timers'; + + /** + * The two schemas. + */ + public const SCHEMA_CALENDAR = 'working-calendar'; + + public const SCHEMA_LADDER = 'escalation-ladder'; + + /** + * Definitions per schema, keyed by slug; null until loaded. + * + * @var array>|null> + */ + private array $cache = [ + self::SCHEMA_CALENDAR => null, + self::SCHEMA_LADDER => null, + ]; + + /** + * Constructor. + * + * @param ObjectService $objects Reads the seeded objects. + * @param IAppManager $appManager Locates the shipped descriptor. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly ObjectService $objects, + private readonly IAppManager $appManager, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Every working-calendar definition, keyed by slug. + * + * @return array> The definitions. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function calendars(): array { + return $this->definitions(schema: self::SCHEMA_CALENDAR); + }//end calendars() + + /** + * Every escalation-ladder definition, keyed by slug. + * + * @return array> The definitions. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function ladders(): array { + return $this->definitions(schema: self::SCHEMA_LADDER); + }//end ladders() + + /** + * Forget the memoised definitions (called once per sweep pass). + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function reset(): void { + $this->cache = [ + self::SCHEMA_CALENDAR => null, + self::SCHEMA_LADDER => null, + ]; + }//end reset() + + /** + * Load one schema's definitions: the register's objects, else the shipped descriptor. + * + * @param string $schema The schema slug. + * + * @return array> Definitions keyed by slug. + */ + private function definitions(string $schema): array { + if ($this->cache[$schema] !== null) { + return $this->cache[$schema]; + } + + $loaded = $this->fromRegister(schema: $schema); + if ($loaded === []) { + $loaded = $this->fromDescriptor(schema: $schema); + } + + $this->cache[$schema] = $loaded; + + return $loaded; + }//end definitions() + + /** + * The seeded objects of a schema, as stored. + * + * @param string $schema The schema slug. + * + * @return array> Definitions keyed by slug; empty when the register is absent. + */ + private function fromRegister(string $schema): array { + try { + $result = $this->objects->searchObjectsBySlug( + registerSlug: self::REGISTER, + schemaSlug: $schema, + filters: [], + _rbac: false, + _multitenancy: false + ); + } catch (Throwable $failure) { + $this->logger->debug( + '[FlowTimerDefinitionStore] Register read failed, falling back to the shipped descriptor: ' . $failure->getMessage(), + ['schema' => $schema] + ); + + return []; + } + + if (is_array($result) === false) { + return []; + } + + $definitions = []; + foreach ($result as $entity) { + $data = $entity->getObject(); + $slug = trim((string)($data['slug'] ?? '')); + if ($slug !== '') { + $definitions[$slug] = $data; + } + } + + return $definitions; + }//end fromRegister() + + /** + * The shipped defaults, read from the descriptor the repair step imports. + * + * @param string $schema The schema slug. + * + * @return array> Definitions keyed by slug. + */ + private function fromDescriptor(string $schema): array { + try { + $path = $this->appManager->getAppPath('openregister') . SeedFlowTimerRegister::REGISTER_PATH; + if (is_file($path) === false) { + $this->logger->warning('[FlowTimerDefinitionStore] Descriptor absent: ' . $path); + + return []; + } + + $data = json_decode((string)file_get_contents($path), true); + } catch (Throwable $failure) { + $this->logger->warning('[FlowTimerDefinitionStore] Descriptor unreadable: ' . $failure->getMessage()); + + return []; + } + + $definitions = []; + foreach (($data['components']['objects'] ?? []) as $object) { + if (is_array($object) === false || (string)($object['@self']['schema'] ?? '') !== $schema) { + continue; + } + + $slug = trim((string)($object['slug'] ?? '')); + if ($slug === '') { + continue; + } + + unset($object['@self']); + $definitions[$slug] = $object; + } + + return $definitions; + }//end fromDescriptor() +}//end class diff --git a/lib/Service/Flow/Timer/FlowTimerService.php b/lib/Service/Flow/Timer/FlowTimerService.php new file mode 100644 index 0000000000..673e94cb7a --- /dev/null +++ b/lib/Service/Flow/Timer/FlowTimerService.php @@ -0,0 +1,1485 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Timer; + +use DateTime; +use DateTimeImmutable; +use DateTimeInterface; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerEvent; +use OCA\OpenRegister\Db\FlowTimerEventMapper; +use OCA\OpenRegister\Db\FlowTimerFire; +use OCA\OpenRegister\Db\FlowTimerFireMapper; +use OCA\OpenRegister\Db\FlowTimerMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Event\FlowTimerFiredEvent; +use OCA\OpenRegister\Exception\FlowTimerStateException; +use OCA\OpenRegister\Exception\FlowTimerValidationException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; +use Throwable; + +/** + * Arm, suspend, resume, extend, supersede, cancel, fire. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One method per lifecycle + * operation plus the derived reads; every operation is a distinct decision + * with its own refusal rules, and splitting them across classes would + * scatter the one recompute() they all end in. + * @SuppressWarnings(PHPMD.TooManyMethods) The private helpers exist so every + * operation shares ONE recompute, ONE projection and ONE event writer. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The service owns the three + * timer mappers, the calendar, the calculator, the ladder, the task + * projection and the task service: that is the lifecycle, not a design choice. + * @SuppressWarnings(PHPMD.ExcessiveClassLength) Scales with the operation + * count; the docblocks record the refusal rules the spec requires. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of the operations' + * refusal branches; each method is small. + * @SuppressWarnings(PHPMD.StaticAccess) Uuid::v4() is the codebase's uuid + * factory, as in TaskBuilder, and DateTime::createFromInterface is PHP's own. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Eleven constructor + * dependencies: three timer mappers, the task table and service, the + * calendar, the calculator, the ladder, the connection, the dispatcher and + * the logger. Each is a distinct collaborator the lifecycle needs; bundling + * them into a holder object would hide the coupling, not remove it. + */ +class FlowTimerService { + + /** + * The actor recorded for sweep-originated writes. + * + * @var string + */ + public const ACTOR_SWEEP = 'flow-timer-sweep'; + + /** + * Constructor. + * + * @param FlowTimerMapper $timers The timer table. + * @param FlowTimerFireMapper $fires The rung dedup ledger. + * @param FlowTimerEventMapper $events The append-only history. + * @param TaskMapper $tasks The task table, for the projection and the subject read. + * @param TaskService $taskService Applies enforcing outcomes as named task actions. + * @param WorkingCalendarService $calendars Calendar resolution. + * @param SlaCalculator $calculator Business-time arithmetic. + * @param EscalationLadderService $ladder Rung resolution and validation. + * @param IDBConnection $db Holds the one transaction per operation. + * @param IEventDispatcher $dispatcher Raises the fired transitions. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly FlowTimerMapper $timers, + private readonly FlowTimerFireMapper $fires, + private readonly FlowTimerEventMapper $events, + private readonly TaskMapper $tasks, + private readonly TaskService $taskService, + private readonly WorkingCalendarService $calendars, + private readonly SlaCalculator $calculator, + private readonly EscalationLadderService $ladder, + private readonly IDBConnection $db, + private readonly IEventDispatcher $dispatcher, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Arm a timer. + * + * Config keys: `subjectType`, `subjectUuid`, `organisation`, `runUuid`, + * `nodeId`, `appId`, `title`, `metadata`, `purpose` (due|expiry), + * `legalEffect` (none|servicenorm|wettelijk), `onExpiry`, `sla` {value, + * unit}, `calendar`, `ladder`, `escalationRules`, `extensionMax`, and the + * anchor: `anchorEvent`, `anchorEventAt` (the instant the named event + * happened; defaults to now), `anchorOffset`, `anchorOffsetUnit`. + * + * Refused, naming the constraint: an unknown calendar (never downgraded), + * an `onExpiry` on any timer whose `legalEffect` is not `wettelijk` or + * whose purpose is not `expiry`, an SLA outside the vocabulary, and an + * escalation rule whose preBreach offset resolves before the anchor. + * + * @param array $config The timer configuration. + * @param string|null $actor The arming identity. + * @param DateTimeInterface|null $now The clock; null means the real clock. + * + * @return FlowTimer The armed timer. + * + * @throws FlowTimerValidationException On any refused value. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-deadline-declares-its-legal-effect-and-only-a-legal-one-enforces + */ + public function arm(array $config, ?string $actor, ?DateTimeInterface $now = null): FlowTimer { + $now = $this->instant(now: $now); + $timer = $this->build(config: $config, actor: $actor, now: $now); + $calendar = $this->calendars->resolve(calendarSlug: $timer->getCalendarSlug(), organisation: $timer->getOrganisation()); + + // Arm-time validation is AUTHORITATIVE: resolve the ladder and check + // every preBreach rung on the timeline before anything is written. + $this->recompute(timer: $timer, calendar: $calendar, firedKeys: []); + $this->ladder->validateAgainstTimeline( + rungs: $this->ladder->resolveLadder(timer: $timer)['rungs'], + anchorAt: $timer->getAnchorAt(), + fireAt: $timer->getFireAt(), + calendar: $calendar + ); + + return $this->transactional( + mutation: function () use ($timer, $actor, $now): FlowTimer { + $persisted = $this->timers->insert($timer); + $this->record( + timer: $persisted, + type: FlowTimerEvent::TYPE_ARMED, + actor: $actor, + reason: sprintf('Armed from anchor %s.', $persisted->getAnchorAt()->format('c')), + priorFireAt: null, + newFireAt: $persisted->getFireAt(), + basis: (string)($persisted->getMetadata()['basis'] ?? ''), + moment: $now + ); + $this->project(timer: $persisted); + + return $persisted; + } + ); + }//end arm() + + /** + * Suspend a running timer (opschorting). + * + * `consumed_value += measure(running_since, now, budget_unit)`; + * `running_since`, `fire_at` and `next_rung_at` go NULL; `suspended_since` + * is set. Business days spent suspended over a weekend add nothing to + * consumption by construction. Evidenced with actor, moment, reason and basis. + * + * @param string $uuid The timer uuid. + * @param string $reason Why the term is suspended (required). + * @param DateTimeInterface|null $until The expected end, display-only. + * @param string|null $actor The acting identity. + * @param string|null $basis The legal ground, e.g. `Awb 4:15`. + * @param DateTimeInterface|null $now The clock. + * + * @return FlowTimer The suspended timer. + * + * @throws FlowTimerStateException When the timer is not armed. + * @throws FlowTimerValidationException When the reason is empty. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + public function suspend( + string $uuid, + string $reason, + ?DateTimeInterface $until, + ?string $actor, + ?string $basis = null, + ?DateTimeInterface $now = null, + ): FlowTimer { + $now = $this->instant(now: $now); + if (trim($reason) === '') { + throw new FlowTimerValidationException( + message: 'Suspending a term requires a non-empty reason: the suspension is itself a decision that has to be evidenced.' + ); + } + + $timer = $this->timers->findByUuid(uuid: $uuid); + if ($timer->getState() !== FlowTimer::STATE_ARMED) { + throw new FlowTimerStateException( + message: sprintf("Timer '%s' cannot be suspended: its state is '%s', not 'armed'.", $uuid, (string)$timer->getState()) + ); + } + + $calendar = $this->calendars->resolve(calendarSlug: $timer->getCalendarSlug(), organisation: $timer->getOrganisation()); + $priorFireAt = $timer->getFireAt(); + $consumed = $this->calculator->measure( + from: $timer->getRunningSince(), + to: $now, + unit: (string)$timer->getBudgetUnit(), + calendar: $calendar + ); + + return $this->transactional( + mutation: function () use ($timer, $calendar, $consumed, $reason, $until, $actor, $basis, $now, $priorFireAt): FlowTimer { + $timer->setConsumedValue((float)$timer->getConsumedValue() + max(0.0, $consumed)); + $timer->setRunningSince(null); + $timer->setSuspendedSince($this->mutable(value: $now)); + $timer->setSuspendReason($reason); + $timer->setState(FlowTimer::STATE_SUSPENDED); + $this->recompute(timer: $timer, calendar: $calendar, firedKeys: $this->firedKeys(timerUuid: (string)$timer->getUuid())); + $persisted = $this->timers->update($timer); + + $untilNote = ''; + if ($until !== null) { + $untilNote = sprintf(' Expected until %s.', $until->format('c')); + } + + $this->record( + timer: $persisted, + type: FlowTimerEvent::TYPE_SUSPENDED, + actor: $actor, + reason: $reason . $untilNote, + priorFireAt: $priorFireAt, + newFireAt: null, + basis: (string)$basis, + moment: $now + ); + $this->project(timer: $persisted, suspendedUntil: $until); + + return $persisted; + } + ); + }//end suspend() + + /** + * Resume a suspended timer: `running_since = now` and the fire moment is + * re-projected from now across the calendar by the UNCONSUMED remainder. + * + * @param string $uuid The timer uuid. + * @param string|null $reason Why the term resumes. + * @param string|null $actor The acting identity. + * @param DateTimeInterface|null $now The clock. + * + * @return FlowTimer The running timer. + * + * @throws FlowTimerStateException When the timer is not suspended. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + public function resume(string $uuid, ?string $reason, ?string $actor, ?DateTimeInterface $now = null): FlowTimer { + $now = $this->instant(now: $now); + $timer = $this->timers->findByUuid(uuid: $uuid); + if ($timer->getState() !== FlowTimer::STATE_SUSPENDED) { + throw new FlowTimerStateException( + message: sprintf("Timer '%s' cannot be resumed: its state is '%s', not 'suspended'.", $uuid, (string)$timer->getState()) + ); + } + + $calendar = $this->calendars->resolve(calendarSlug: $timer->getCalendarSlug(), organisation: $timer->getOrganisation()); + + return $this->transactional( + mutation: function () use ($timer, $calendar, $reason, $actor, $now): FlowTimer { + $suspendedSince = $timer->getSuspendedSince(); + if ($suspendedSince !== null) { + $timer->setSuspendedTotalSeconds( + (int)$timer->getSuspendedTotalSeconds() + max(0, ($now->getTimestamp() - $suspendedSince->getTimestamp())) + ); + } + + $timer->setRunningSince($this->mutable(value: $now)); + $timer->setSuspendedSince(null); + $timer->setSuspendReason(null); + $timer->setState(FlowTimer::STATE_ARMED); + $this->recompute(timer: $timer, calendar: $calendar, firedKeys: $this->firedKeys(timerUuid: (string)$timer->getUuid())); + $persisted = $this->timers->update($timer); + $this->record( + timer: $persisted, + type: FlowTimerEvent::TYPE_RESUMED, + actor: $actor, + reason: (string)$reason, + priorFireAt: null, + newFireAt: $persisted->getFireAt(), + basis: '', + moment: $now + ); + $this->project(timer: $persisted); + + return $persisted; + } + ); + }//end resume() + + /** + * Extend a timer (verdaging) through the STANDARD path, bounded by `extension_max`. + * + * @param string $uuid The timer uuid. + * @param int $amount The extension amount. + * @param string $unit Its unit; converted into the budget unit when they differ. + * @param string $rationale Why (required, non-empty). + * @param string|null $actor The acting identity. + * @param DateTimeInterface|null $now The clock. + * + * @return FlowTimer The extended timer. + * + * @throws FlowTimerStateException When fired/expired, or when the bound is reached (naming it). + * @throws FlowTimerValidationException When the rationale is empty or the amount is not positive. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-extension-is-bounded-and-may-only-be-granted-before-expiry + */ + public function extend( + string $uuid, + int $amount, + string $unit, + string $rationale, + ?string $actor, + ?DateTimeInterface $now = null, + ): FlowTimer { + $now = $this->instant(now: $now); + $timer = $this->timers->findByUuid(uuid: $uuid); + $this->assertExtendable(timer: $timer, now: $now); + + if ((int)$timer->getExtensionCount() >= (int)$timer->getExtensionMax()) { + throw new FlowTimerStateException( + message: sprintf( + "Timer '%s' has reached its extension bound of %d; a further extension requires the separately authorized override.", + $uuid, + (int)$timer->getExtensionMax() + ) + ); + } + + return $this->applyExtension(timer: $timer, amount: $amount, unit: $unit, rationale: $rationale, actor: $actor, now: $now, basis: 'Awb 4:14'); + }//end extend() + + /** + * Extend BEYOND the bound: a distinct, separately authorized operation, + * recorded as an override. Not a flag on {@see extend()}, because the flag + * becomes the default caller within a release. + * + * @param string $uuid The timer uuid. + * @param int $amount The extension amount. + * @param string $unit Its unit. + * @param string $rationale Why (required, non-empty). + * @param string $actor The authorizing identity (required). + * @param DateTimeInterface|null $now The clock. + * + * @return FlowTimer The extended timer. + * + * @throws FlowTimerStateException When fired/expired. + * @throws FlowTimerValidationException When the rationale or actor is empty. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-extension-is-bounded-and-may-only-be-granted-before-expiry + */ + public function extendWithOverride( + string $uuid, + int $amount, + string $unit, + string $rationale, + string $actor, + ?DateTimeInterface $now = null, + ): FlowTimer { + $now = $this->instant(now: $now); + if (trim($actor) === '') { + throw new FlowTimerValidationException(message: 'An extension override requires an authorizing identity.'); + } + + $timer = $this->timers->findByUuid(uuid: $uuid); + $this->assertExtendable(timer: $timer, now: $now); + + return $this->applyExtension(timer: $timer, amount: $amount, unit: $unit, rationale: $rationale, actor: $actor, now: $now, basis: 'override'); + }//end extendWithOverride() + + /** + * The anchoring event moved: mark this timer superseded and arm a + * successor from the new anchor, carrying `consumed_value` forward and + * inheriting a fire row for every rung still in the past under the NEW + * deadline (design D-4). The superseded row never fires. + * + * @param string $uuid The timer to supersede. + * @param DateTimeInterface $anchorEventAt The new instant of the anchoring event. + * @param string $reason Why the anchor moved. + * @param string|null $actor The acting identity. + * @param DateTimeInterface|null $now The clock. + * + * @return FlowTimer The successor. + * + * @throws FlowTimerStateException When the timer is not open. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-deadlines-anchor-is-stored-so-a-moved-anchor-re-arms-the-timer + */ + public function supersede( + string $uuid, + DateTimeInterface $anchorEventAt, + string $reason, + ?string $actor, + ?DateTimeInterface $now = null, + ): FlowTimer { + $now = $this->instant(now: $now); + $prior = $this->timers->findByUuid(uuid: $uuid); + if ($prior->isOpen() === false) { + throw new FlowTimerStateException( + message: sprintf("Timer '%s' cannot be superseded: its state is '%s'.", $uuid, (string)$prior->getState()) + ); + } + + $calendar = $this->calendars->resolve(calendarSlug: $prior->getCalendarSlug(), organisation: $prior->getOrganisation()); + $successor = $this->cloneForSuccession(prior: $prior, anchorEventAt: $anchorEventAt, calendar: $calendar, now: $now); + $priorFired = $this->fires->findByTimer(timerUuid: $uuid); + + return $this->transactional( + mutation: function () use ($prior, $successor, $priorFired, $calendar, $reason, $actor, $now): FlowTimer { + $priorFireAt = $prior->getFireAt(); + $prior->setState(FlowTimer::STATE_SUPERSEDED); + $this->recompute(timer: $prior, calendar: $calendar, firedKeys: []); + $this->timers->update($prior); + $this->record( + timer: $prior, + type: FlowTimerEvent::TYPE_SUPERSEDED, + actor: $actor, + reason: $reason, + priorFireAt: $priorFireAt, + newFireAt: $successor->getFireAt(), + basis: '', + moment: $now + ); + + // The successor must exist before its inherited fire rows. + $persisted = $this->timers->insert($successor); + $inherited = $this->inheritFires(successor: $persisted, priorFired: $priorFired, calendar: $calendar, now: $now); + $this->recompute(timer: $persisted, calendar: $calendar, firedKeys: $inherited); + $persisted = $this->timers->update($persisted); + $this->record( + timer: $persisted, + type: FlowTimerEvent::TYPE_ARMED, + actor: $actor, + reason: sprintf("Re-armed from moved anchor; supersedes '%s'.", (string)$prior->getUuid()), + priorFireAt: $priorFireAt, + newFireAt: $persisted->getFireAt(), + basis: '', + moment: $now + ); + $this->project(timer: $persisted); + + return $persisted; + } + ); + }//end supersede() + + /** + * Cancel every OPEN timer of a subject, with a reason. Idempotent; never + * deletes; a recorded breach stays recorded. Runs in the CALLER's + * transaction so a terminality listener lands it in the same operation. + * + * @param string $subjectType The subject type. + * @param string $subjectUuid The subject uuid. + * @param string $reason Why, recorded on each timer. + * @param string|null $actor The propagation source recorded as actor. + * @param DateTimeInterface|null $now The clock. + * + * @return int How many timers were cancelled. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function cancelForSubject( + string $subjectType, + string $subjectUuid, + string $reason, + ?string $actor, + ?DateTimeInterface $now = null, + ): int { + $open = $this->timers->findBySubject( + subjectType: $subjectType, + subjectUuid: $subjectUuid, + states: [FlowTimer::STATE_ARMED, FlowTimer::STATE_SUSPENDED] + ); + + return $this->cancelAll(timers: $open, reason: $reason, actor: $actor, now: $this->instant(now: $now)); + }//end cancelForSubject() + + /** + * Cancel every OPEN timer a run terminality reaches: the run as subject, + * or the run as provenance. + * + * @param string $runUuid The run uuid. + * @param string $reason Why, recorded on each timer. + * @param string|null $actor The propagation source recorded as actor. + * @param DateTimeInterface|null $now The clock. + * + * @return int How many timers were cancelled. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function cancelForRun(string $runUuid, string $reason, ?string $actor, ?DateTimeInterface $now = null): int { + if (trim($runUuid) === '') { + return 0; + } + + return $this->cancelAll( + timers: $this->timers->findOpenByRun(runUuid: $runUuid), + reason: $reason, + actor: $actor, + now: $this->instant(now: $now) + ); + }//end cancelForRun() + + /** + * The derived read API: overdue, remaining and overdue-by, computed from + * the clock and the stored budget. Correct with the sweep disabled; a + * suspended timer is never overdue and still answers its remainder. + * + * @param FlowTimer $timer The timer. + * @param DateTimeInterface|null $now The clock. + * + * @return array{overdue: bool, remaining: float, unit: string, overdueBy: float|null, fireAt: string|null, state: string} The derivation. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-overdue-is-derived-from-the-clock-and-never-stored + */ + public function describe(FlowTimer $timer, ?DateTimeInterface $now = null): array { + $now = $this->instant(now: $now); + $calendar = $this->calendars->resolve(calendarSlug: $timer->getCalendarSlug(), organisation: $timer->getOrganisation()); + $unit = (string)$timer->getBudgetUnit(); + $remaining = $this->remaining(timer: $timer, calendar: $calendar, now: $now); + $fireAt = $timer->getFireAt(); + $overdue = ($timer->getState() === FlowTimer::STATE_ARMED && $fireAt !== null && $fireAt < $now); + + $overdueBy = null; + if ($overdue === true) { + $overdueBy = $this->calculator->measure(from: $fireAt, to: $now, unit: $unit, calendar: $calendar); + } + + $fireAtText = null; + if ($fireAt !== null) { + $fireAtText = $fireAt->format('c'); + } + + return [ + 'overdue' => $overdue, + 'remaining' => $remaining, + 'unit' => $unit, + 'overdueBy' => $overdueBy, + 'fireAt' => $fireAtText, + 'state' => (string)$timer->getState(), + ]; + }//end describe() + + /** + * The history of a timer, oldest first. + * + * @param string $uuid The timer uuid. + * + * @return array The events. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + public function history(string $uuid): array { + return $this->events->findByTimer(timerUuid: $uuid); + }//end history() + + /** + * Fire an EXPIRY timer whose moment has passed: claim it conditionally, + * record the breach, apply the enforcing outcome as a named task action, + * raise the transition. Zero affected rows on the claim means another + * pass owns it and nothing is applied. A task that completed in the + * meantime (a lost race, surfacing as {@see TaskConflictException}) is + * "nothing to do", not an error. + * + * @param FlowTimer $timer The due timer, as read by the sweep. + * @param DateTimeInterface $now The sweep instant. + * + * @return boolean True when THIS call fired the timer. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-advisory-due-date-notifies-an-enforcing-expiry-transitions + */ + public function fireExpiry(FlowTimer $timer, DateTimeInterface $now): bool { + $uuid = (string)$timer->getUuid(); + if ($this->timers->claimFired(uuid: $uuid, firedAt: $now) === false) { + return false; + } + + $timer->setState(FlowTimer::STATE_FIRED); + $timer->setFiredAt($this->mutable(value: $now)); + $timer->setBreached(true); + $timer->setNextRungAt(null); + $this->timers->update($timer); + + $type = FlowTimerEvent::TYPE_FIRED; + if ($timer->getLegalEffect() === FlowTimer::LEGAL_WETTELIJK) { + $type = FlowTimerEvent::TYPE_BREACHED; + } + + $this->record( + timer: $timer, + type: $type, + actor: self::ACTOR_SWEEP, + reason: sprintf("Expiry reached; outcome '%s'.", (string)$timer->getOnExpiry()), + priorFireAt: $timer->getFireAt(), + newFireAt: null, + basis: '', + moment: $now + ); + + $this->applyOutcome(timer: $timer); + $this->dispatcher->dispatchTyped( + new FlowTimerFiredEvent( + timer: $timer, + kind: FlowTimerFiredEvent::KIND_EXPIRY, + transition: 'expiry:' . (string)($timer->getOnExpiry() ?? 'none'), + rungKey: null, + recipients: [], + priority: 'critical', + message: null + ) + ); + $this->project(timer: $timer); + + return true; + }//end fireExpiry() + + /** + * Fire every due, unfired rung of a timer, in ladder order, each claimed + * by its ledger INSERT before the transition is raised (design D-7). A + * duplicate key means another pass owns the rung. Ends by recomputing + * `next_rung_at`. + * + * @param FlowTimer $timer The timer with a due rung, as read by the sweep. + * @param DateTimeInterface $now The sweep instant. + * + * @return int How many rungs THIS call fired. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-each-escalation-rung-fires-exactly-once + */ + public function fireRungs(FlowTimer $timer, DateTimeInterface $now): int { + if ($timer->getState() !== FlowTimer::STATE_ARMED || $timer->getFireAt() === null) { + return 0; + } + + $calendar = $this->calendars->resolve(calendarSlug: $timer->getCalendarSlug(), organisation: $timer->getOrganisation()); + $ladder = $this->ladder->resolveLadder(timer: $timer); + $firedKeys = $this->firedKeys(timerUuid: (string)$timer->getUuid()); + $subject = $this->subjectTask(timer: $timer); + $fired = 0; + + $due = $this->ladder->dueRungs( + rungs: $ladder['rungs'], + fireAt: $timer->getFireAt(), + now: $now, + firedKeys: $firedKeys, + calendar: $calendar + ); + foreach ($due as $entry) { + $rung = $entry['rung']; + $recipients = $this->ladder->resolveRecipients(rung: $rung, subject: $subject, roleBindings: $ladder['roleBindings']); + $transition = 'escalation:' . (string)$rung['key']; + + $row = new FlowTimerFire(); + $row->setTimerUuid((string)$timer->getUuid()); + $row->setRungKey((string)$rung['key']); + $row->setFiredAt($this->mutable(value: $now)); + $row->setTransitionAction($transition); + $row->setRecipientRoles(array_values(array_unique(array_merge($rung['notifyRole'], $rung['escalateToRole'])))); + $row->setPriority((string)$rung['priority']); + $row->setInherited(false); + + // The INSERT is the claim; null means another pass owns this rung. + if ($this->fires->claim(fire: $row) === null) { + $firedKeys[] = (string)$rung['key']; + continue; + } + + $firedKeys[] = (string)$rung['key']; + $fired++; + $this->dispatcher->dispatchTyped( + new FlowTimerFiredEvent( + timer: $timer, + kind: FlowTimerFiredEvent::KIND_RUNG, + transition: $transition, + rungKey: (string)$rung['key'], + recipients: $recipients, + priority: (string)$rung['priority'], + message: $rung['message'] + ) + ); + }//end foreach + + $this->recompute(timer: $timer, calendar: $calendar, firedKeys: $firedKeys); + $this->timers->update($timer); + + return $fired; + }//end fireRungs() + + /** + * Build and validate a timer from its configuration. Writes nothing. + * + * @param array $config The configuration. + * @param string|null $actor The arming identity. + * @param DateTimeImmutable $now The clock. + * + * @return FlowTimer The unsaved timer. + * + * @throws FlowTimerValidationException On any refused value. + */ + private function build(array $config, ?string $actor, DateTimeImmutable $now): FlowTimer { + $timer = new FlowTimer(); + $timer->setUuid(Uuid::v4()->toRfc4122()); + + $subjectType = (string)($config['subjectType'] ?? ''); + $subjectUuid = trim((string)($config['subjectUuid'] ?? '')); + if (in_array($subjectType, FlowTimer::SUBJECT_TYPES, true) === false || $subjectUuid === '') { + throw new FlowTimerValidationException( + message: sprintf( + "A timer requires a subjectType in [%s] and a subjectUuid; got '%s' / '%s'.", + implode(', ', FlowTimer::SUBJECT_TYPES), + $subjectType, + $subjectUuid + ) + ); + } + + $purpose = (string)($config['purpose'] ?? FlowTimer::PURPOSE_DUE); + if (in_array($purpose, FlowTimer::PURPOSES, true) === false) { + throw new FlowTimerValidationException(message: sprintf("Timer purpose '%s' is refused: use due or expiry.", $purpose)); + } + + $legalEffect = (string)($config['legalEffect'] ?? FlowTimer::LEGAL_NONE); + if (in_array($legalEffect, FlowTimer::LEGAL_EFFECTS, true) === false) { + throw new FlowTimerValidationException( + message: sprintf("Timer legalEffect '%s' is refused: use none, servicenorm or wettelijk.", $legalEffect) + ); + } + + $onExpiry = $this->validOnExpiry(config: $config, purpose: $purpose, legalEffect: $legalEffect); + $sla = $this->calculator->validateSla(sla: ($config['sla'] ?? null)); + + $timer->setSubjectType($subjectType); + $timer->setSubjectUuid($subjectUuid); + $timer->setOrganisation($this->stringOrNull(value: ($config['organisation'] ?? null))); + $timer->setRunUuid($this->stringOrNull(value: ($config['runUuid'] ?? null))); + $timer->setNodeId($this->stringOrNull(value: ($config['nodeId'] ?? null))); + $timer->setAppId($this->stringOrNull(value: ($config['appId'] ?? null))); + $timer->setTitle($this->stringOrNull(value: ($config['title'] ?? null))); + $timer->setMetadata($this->arrayOrNull(value: ($config['metadata'] ?? null))); + $timer->setPurpose($purpose); + $timer->setLegalEffect($legalEffect); + $timer->setOnExpiry($onExpiry); + $timer->setBudgetValue((float)$sla['value']); + $timer->setBudgetUnit($sla['unit']); + $timer->setConsumedValue(0.0); + $timer->setCalendarSlug($this->stringOrNull(value: ($config['calendar'] ?? null))); + $timer->setLadderSlug($this->stringOrNull(value: ($config['ladder'] ?? null))); + $timer->setEscalationRules($this->ladder->normaliseRules(rules: ($config['escalationRules'] ?? []), sla: $sla)); + $timer->setExtensionCount(0); + $timer->setExtensionMax(max(0, (int)($config['extensionMax'] ?? 1))); + $timer->setState(FlowTimer::STATE_ARMED); + $timer->setBreached(false); + $timer->setCreatedBy($actor); + $timer->setSuspendedTotalSeconds(0); + + $this->applyAnchor(timer: $timer, config: $config, now: $now); + $timer->setRunningSince($timer->getAnchorAt()); + + return $timer; + }//end build() + + /** + * The enforcing outcome, permitted ONLY on an expiry timer with legal effect wettelijk. + * + * @param array $config The configuration. + * @param string $purpose The validated purpose. + * @param string $legalEffect The validated legal effect. + * + * @return string|null The outcome, or null for an advisory timer. + * + * @throws FlowTimerValidationException When an outcome is given where none may be. + */ + private function validOnExpiry(array $config, string $purpose, string $legalEffect): ?string { + $onExpiry = $this->stringOrNull(value: ($config['onExpiry'] ?? null)); + if ($onExpiry === null) { + return null; + } + + if ($purpose !== FlowTimer::PURPOSE_EXPIRY) { + throw new FlowTimerValidationException( + message: sprintf("onExpiry '%s' is refused on a '%s' timer: only an expiry timer enforces.", $onExpiry, $purpose) + ); + } + + if ($legalEffect !== FlowTimer::LEGAL_WETTELIJK) { + throw new FlowTimerValidationException( + message: sprintf( + "onExpiry '%s' is refused: legal effect '%s' is advisory, and only a 'wettelijk' timer may carry an enforcing outcome.", + $onExpiry, + $legalEffect + ) + ); + } + + if (in_array($onExpiry, FlowTimer::RESERVED_OUTCOMES, true) === false && str_starts_with($onExpiry, 'transition:') === false) { + throw new FlowTimerValidationException( + message: sprintf("onExpiry '%s' is refused: use skip, error, dead_letter or transition:.", $onExpiry) + ); + } + + return $onExpiry; + }//end validOnExpiry() + + /** + * Store the anchor and resolve `anchor_at` = anchorEventAt + offset. + * + * @param FlowTimer $timer The timer being built. + * @param array $config The configuration. + * @param DateTimeImmutable $now The clock, the default anchor. + * + * @return void + * + * @throws FlowTimerValidationException On a malformed offset. + */ + private function applyAnchor(FlowTimer $timer, array $config, DateTimeImmutable $now): void { + $eventAt = $this->dateOrNull(value: ($config['anchorEventAt'] ?? null), field: 'anchorEventAt') ?? $now; + $offset = ($config['anchorOffset'] ?? null); + $offsetUnit = null; + $anchorAt = $eventAt; + + if ($offset !== null) { + if (is_int($offset) === false) { + throw new FlowTimerValidationException(message: sprintf("anchorOffset '%s' must be an integer.", var_export($offset, true))); + } + + $offsetUnit = $this->calculator->validateUnit(unit: ($config['anchorOffsetUnit'] ?? SlaCalculator::UNIT_CALENDAR_DAYS)); + $calendar = $this->calendars->resolve( + calendarSlug: $this->stringOrNull(value: ($config['calendar'] ?? null)), + organisation: $this->stringOrNull(value: ($config['organisation'] ?? null)) + ); + $anchorAt = $this->calculator->add(from: $eventAt, value: (float)$offset, unit: $offsetUnit, calendar: $calendar); + } + + $timer->setAnchorEvent($this->stringOrNull(value: ($config['anchorEvent'] ?? null))); + $timer->setAnchorOffset($offset); + $timer->setAnchorOffsetUnit($offsetUnit); + $timer->setAnchorAt($this->mutable(value: $anchorAt)); + }//end applyAnchor() + + /** + * THE one derivation of `fire_at` and `next_rung_at`. + * + * Armed: `fire_at = add(running_since, budget - consumed)` and + * `next_rung_at` = the earliest unfired rung. Any other state: both NULL. + * Every mutating operation ends here and nothing else writes these columns. + * + * @param FlowTimer $timer The timer, mutated in place. + * @param WorkingCalendar $calendar The resolved calendar. + * @param array $firedKeys Rung keys already in the ledger. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + private function recompute(FlowTimer $timer, WorkingCalendar $calendar, array $firedKeys): void { + if ($timer->getState() !== FlowTimer::STATE_ARMED || $timer->getRunningSince() === null) { + $timer->setFireAt(null); + $timer->setNextRungAt(null); + + return; + } + + $remaining = ((float)$timer->getBudgetValue() - (float)$timer->getConsumedValue()); + $fireAt = $this->calculator->add( + from: $timer->getRunningSince(), + value: max(0.0, $remaining), + unit: (string)$timer->getBudgetUnit(), + calendar: $calendar + ); + $timer->setFireAt($this->mutable(value: $fireAt)); + + $rungs = $this->ladder->resolveLadder(timer: $timer)['rungs']; + $next = $this->ladder->nextRungAt(rungs: $rungs, fireAt: $fireAt, firedKeys: $firedKeys, calendar: $calendar); + $timer->setNextRungAt($this->mutableOrNull(value: $next)); + }//end recompute() + + /** + * Remaining budget in the timer's own unit, answerable while suspended. + * + * @param FlowTimer $timer The timer. + * @param WorkingCalendar $calendar The resolved calendar. + * @param DateTimeImmutable $now The clock. + * + * @return float The remainder; negative once overdue. + */ + private function remaining(FlowTimer $timer, WorkingCalendar $calendar, DateTimeImmutable $now): float { + $running = 0.0; + if ($timer->getRunningSince() !== null && $timer->getState() === FlowTimer::STATE_ARMED) { + $running = $this->calculator->measure( + from: $timer->getRunningSince(), + to: $now, + unit: (string)$timer->getBudgetUnit(), + calendar: $calendar + ); + } + + return ((float)$timer->getBudgetValue() - (float)$timer->getConsumedValue() - $running); + }//end remaining() + + /** + * Refuse an extension on a timer that is not open or whose term has run out. + * + * @param FlowTimer $timer The timer. + * @param DateTimeImmutable $now The clock. + * + * @return void + * + * @throws FlowTimerStateException When fired, cancelled, superseded or past its fire moment. + */ + private function assertExtendable(FlowTimer $timer, DateTimeImmutable $now): void { + if ($timer->isOpen() === false) { + throw new FlowTimerStateException( + message: sprintf( + "Timer '%s' cannot be extended: its state is '%s'. A term that has run out cannot be lengthened retroactively; the recorded breach stands.", + (string)$timer->getUuid(), + (string)$timer->getState() + ) + ); + } + + if ($timer->getFireAt() !== null && $timer->getFireAt() <= $now) { + throw new FlowTimerStateException( + message: sprintf( + "Timer '%s' cannot be extended: its fire moment %s has passed. A term that has run out cannot be lengthened retroactively.", + (string)$timer->getUuid(), + $timer->getFireAt()->format('c') + ) + ); + } + }//end assertExtendable() + + /** + * The shared body of extend() and extendWithOverride(). + * + * @param FlowTimer $timer The open timer. + * @param int $amount The extension amount. + * @param string $unit Its unit. + * @param string $rationale Why. + * @param string|null $actor The acting identity. + * @param DateTimeImmutable $now The clock. + * @param string $basis The recorded basis (`Awb 4:14` or `override`). + * + * @return FlowTimer The extended timer. + * + * @throws FlowTimerValidationException When the rationale is empty or the amount is not positive. + */ + private function applyExtension( + FlowTimer $timer, + int $amount, + string $unit, + string $rationale, + ?string $actor, + DateTimeImmutable $now, + string $basis, + ): FlowTimer { + if (trim($rationale) === '') { + throw new FlowTimerValidationException(message: 'An extension requires a non-empty rationale.'); + } + + if ($amount <= 0) { + throw new FlowTimerValidationException(message: sprintf('An extension amount must be positive; got %d.', $amount)); + } + + $calendar = $this->calendars->resolve(calendarSlug: $timer->getCalendarSlug(), organisation: $timer->getOrganisation()); + $added = $this->calculator->convert( + value: (float)$amount, + fromUnit: $this->calculator->validateUnit(unit: $unit), + toUnit: (string)$timer->getBudgetUnit(), + calendar: $calendar + ); + + return $this->transactional( + mutation: function () use ($timer, $calendar, $added, $rationale, $actor, $now, $basis, $amount, $unit): FlowTimer { + $priorFireAt = $timer->getFireAt(); + $timer->setBudgetValue((float)$timer->getBudgetValue() + $added); + $timer->setExtensionCount((int)$timer->getExtensionCount() + 1); + $this->recompute(timer: $timer, calendar: $calendar, firedKeys: $this->firedKeys(timerUuid: (string)$timer->getUuid())); + $persisted = $this->timers->update($timer); + $this->record( + timer: $persisted, + type: FlowTimerEvent::TYPE_EXTENDED, + actor: $actor, + reason: sprintf('%s (+%d %s)', $rationale, $amount, $unit), + priorFireAt: $priorFireAt, + newFireAt: $persisted->getFireAt(), + basis: $basis, + moment: $now, + impact: $added + ); + $this->project(timer: $persisted); + + return $persisted; + } + ); + }//end applyExtension() + + /** + * The successor row of a supersession, unsaved. + * + * @param FlowTimer $prior The timer being superseded. + * @param DateTimeInterface $anchorEventAt The new anchoring instant. + * @param WorkingCalendar $calendar The resolved calendar. + * @param DateTimeImmutable $now The clock. + * + * @return FlowTimer The successor. + */ + private function cloneForSuccession( + FlowTimer $prior, + DateTimeInterface $anchorEventAt, + WorkingCalendar $calendar, + DateTimeImmutable $now, + ): FlowTimer { + $successor = new FlowTimer(); + foreach ($prior->jsonSerialize() as $field => $value) { + if (in_array($field, ['id', 'uuid', 'created', 'updated'], true) === true || str_ends_with($field, 'At') === true) { + continue; + } + + $setter = 'set' . ucfirst((string)$field); + $successor->$setter($value); + } + + $anchorAt = DateTimeImmutable::createFromInterface($anchorEventAt); + if ($prior->getAnchorOffset() !== null) { + $anchorAt = $this->calculator->add( + from: $anchorAt, + value: (float)$prior->getAnchorOffset(), + unit: (string)$prior->getAnchorOffsetUnit(), + calendar: $calendar + ); + } + + $successor->setUuid(Uuid::v4()->toRfc4122()); + $successor->setSupersedesUuid((string)$prior->getUuid()); + $successor->setState(FlowTimer::STATE_ARMED); + $successor->setAnchorAt($this->mutable(value: $anchorAt)); + $successor->setRunningSince($this->mutable(value: $anchorAt)); + $successor->setSuspendedSince(null); + $successor->setSuspendReason(null); + $successor->setFiredAt(null); + $successor->setCancelledAt(null); + $successor->setCancelReason(null); + $successor->setBreached(false); + $successor->setCreated($this->mutable(value: $now)); + // The predecessor's CONSUMED time (its completed segments) carries + // forward; its running segment does not, because the term now runs + // from the new anchor and that segment is re-measured from there. + $successor->setConsumedValue((float)$prior->getConsumedValue()); + + return $successor; + }//end cloneForSuccession() + + /** + * Copy forward a fire row for every predecessor rung whose instant is + * still in the past under the successor's deadline; none for rungs pushed + * back into the future. + * + * @param FlowTimer $successor The persisted successor. + * @param array $priorFired The predecessor's fire rows. + * @param WorkingCalendar $calendar The resolved calendar. + * @param DateTimeImmutable $now The clock. + * + * @return array The inherited rung keys. + */ + private function inheritFires(FlowTimer $successor, array $priorFired, WorkingCalendar $calendar, DateTimeImmutable $now): array { + if ($priorFired === []) { + return []; + } + + // The successor's fire moment, without the ladder (no ledger yet). + $remaining = max(0.0, ((float)$successor->getBudgetValue() - (float)$successor->getConsumedValue())); + $fireAt = $this->calculator->add( + from: $successor->getRunningSince(), + value: $remaining, + unit: (string)$successor->getBudgetUnit(), + calendar: $calendar + ); + $byKey = []; + foreach ($this->ladder->resolveLadder(timer: $successor)['rungs'] as $rung) { + $byKey[(string)$rung['key']] = $rung; + } + + $inherited = []; + foreach ($priorFired as $fire) { + $key = (string)$fire->getRungKey(); + if (array_key_exists($key, $byKey) === false) { + continue; + } + + if ($this->ladder->rungInstant(rung: $byKey[$key], fireAt: $fireAt, calendar: $calendar) > $now) { + // Pushed back into the future: it will fire again, legitimately. + continue; + } + + $row = new FlowTimerFire(); + $row->setTimerUuid((string)$successor->getUuid()); + $row->setRungKey($key); + $row->setFiredAt($fire->getFiredAt()); + $row->setTransitionAction($fire->getTransitionAction()); + $row->setRecipientRoles($fire->getRecipientRoles()); + $row->setPriority($fire->getPriority()); + $row->setInherited(true); + if ($this->fires->claim(fire: $row) !== null) { + $inherited[] = $key; + } + } + + return $inherited; + }//end inheritFires() + + /** + * Cancel a set of open timers with a reason; never deletes. + * + * @param array $timers The open timers. + * @param string $reason Why. + * @param string|null $actor The propagation source. + * @param DateTimeImmutable $now The clock. + * + * @return int How many were cancelled. + */ + private function cancelAll(array $timers, string $reason, ?string $actor, DateTimeImmutable $now): int { + $cancelled = 0; + foreach ($timers as $timer) { + if ($timer->isOpen() === false) { + continue; + } + + $priorFireAt = $timer->getFireAt(); + $timer->setState(FlowTimer::STATE_CANCELLED); + $timer->setCancelledAt($this->mutable(value: $now)); + $timer->setCancelReason($reason); + $timer->setRunningSince(null); + $timer->setFireAt(null); + $timer->setNextRungAt(null); + $this->timers->update($timer); + $this->record( + timer: $timer, + type: FlowTimerEvent::TYPE_CANCELLED, + actor: $actor, + reason: $reason, + priorFireAt: $priorFireAt, + newFireAt: null, + basis: '', + moment: $now + ); + $cancelled++; + } + + if ($cancelled > 0 && isset($timer) === true) { + $this->project(timer: $timer); + } + + return $cancelled; + }//end cancelAll() + + /** + * Apply an expiry timer's enforcing outcome to a task subject. + * + * @param FlowTimer $timer The fired timer. + * + * @return void + */ + private function applyOutcome(FlowTimer $timer): void { + if ($timer->isEnforcing() === false || $timer->getSubjectType() !== 'task') { + return; + } + + try { + $this->taskService->applyTimerOutcome( + uuid: (string)$timer->getSubjectUuid(), + outcome: (string)$timer->getOnExpiry(), + source: 'flow-timer:' . (string)$timer->getUuid(), + reason: sprintf("Expiry timer '%s' (%s) reached its deadline.", (string)$timer->getUuid(), (string)$timer->getLegalEffect()) + ); + } catch (TaskConflictException $race) { + // The task closed concurrently: nothing to do, the timer is cancelled by that close. + $this->logger->info('[FlowTimerService] Expiry outcome not applied, task closed concurrently: ' . $race->getMessage()); + } catch (DoesNotExistException) { + $this->logger->warning( + '[FlowTimerService] Expiry timer fired for an absent task subject.', + ['timer' => $timer->getUuid(), 'subject' => $timer->getSubjectUuid()] + ); + } + }//end applyOutcome() + + /** + * Maintain the task's `due_at`/`expires_at` projection (design D-10): + * the earliest OPEN due timer and the earliest OPEN expiry timer of the + * subject. Only task subjects have a projection. + * + * @param FlowTimer $timer A timer of the subject just mutated. + * @param DateTimeInterface|null $suspendedUntil Display-only expected resume, when suspending. + * + * @return void + */ + private function project(FlowTimer $timer, ?DateTimeInterface $suspendedUntil = null): void { + if ($timer->getSubjectType() !== 'task') { + return; + } + + try { + $task = $this->tasks->findByUuid(uuid: (string)$timer->getSubjectUuid()); + } catch (DoesNotExistException) { + $this->logger->warning('[FlowTimerService] No task to project onto.', ['subject' => $timer->getSubjectUuid()]); + + return; + } + + $earliest = $this->earliestOpenDeadlines(subjectUuid: (string)$timer->getSubjectUuid()); + $task->setDueAt($this->mutableOrNull(value: $earliest[FlowTimer::PURPOSE_DUE])); + $task->setExpiresAt($this->mutableOrNull(value: $earliest[FlowTimer::PURPOSE_EXPIRY])); + $task->setSuspendedUntil($this->mutableOrNull(value: $suspendedUntil)); + $this->tasks->update($task); + }//end project() + + /** + * The earliest fire moment per purpose across a task's OPEN timers. + * + * @param string $subjectUuid The task uuid. + * + * @return array Keyed by purpose; null when no open timer of that purpose has a fire moment. + */ + private function earliestOpenDeadlines(string $subjectUuid): array { + $earliest = [FlowTimer::PURPOSE_DUE => null, FlowTimer::PURPOSE_EXPIRY => null]; + $open = $this->timers->findBySubject( + subjectType: 'task', + subjectUuid: $subjectUuid, + states: [FlowTimer::STATE_ARMED, FlowTimer::STATE_SUSPENDED] + ); + foreach ($open as $candidate) { + $fireAt = $candidate->getFireAt(); + $purpose = (string)$candidate->getPurpose(); + if ($fireAt === null || array_key_exists($purpose, $earliest) === false) { + continue; + } + + if ($earliest[$purpose] === null || $fireAt < $earliest[$purpose]) { + $earliest[$purpose] = $fireAt; + } + } + + return $earliest; + }//end earliestOpenDeadlines() + + /** + * The subject task, when the subject is a task that exists. + * + * @param FlowTimer $timer The timer. + * + * @return Task|null The task. + */ + private function subjectTask(FlowTimer $timer): ?Task { + if ($timer->getSubjectType() !== 'task') { + return null; + } + + try { + return $this->tasks->findByUuid(uuid: (string)$timer->getSubjectUuid()); + } catch (DoesNotExistException) { + return null; + } + }//end subjectTask() + + /** + * Rung keys already in a timer's ledger. + * + * @param string $timerUuid The timer uuid. + * + * @return array The keys. + */ + private function firedKeys(string $timerUuid): array { + $keys = []; + foreach ($this->fires->findByTimer(timerUuid: $timerUuid) as $fire) { + $keys[] = (string)$fire->getRungKey(); + } + + return $keys; + }//end firedKeys() + + /** + * Append one history row. + * + * @param FlowTimer $timer The timer. + * @param string $type The event type. + * @param string|null $actor The acting identity. + * @param string $reason The reason. + * @param DateTime|null $priorFireAt The fire moment before. + * @param DateTime|null $newFireAt The fire moment after. + * @param string $basis The legal ground. + * @param DateTimeInterface $moment The moment. + * @param float|null $impact The impact in the budget unit. + * + * @return void + */ + private function record( + FlowTimer $timer, + string $type, + ?string $actor, + string $reason, + ?DateTime $priorFireAt, + ?DateTime $newFireAt, + string $basis, + DateTimeInterface $moment, + ?float $impact = null, + ): void { + $event = new FlowTimerEvent(); + $event->setTimerUuid((string)$timer->getUuid()); + $event->setType($type); + $event->setActor($actor); + $event->setReason($reason); + $event->setPriorFireAt($priorFireAt); + $event->setNewFireAt($newFireAt); + $event->setDaysImpact($impact); + $event->setBasis($this->stringOrNull(value: $basis)); + $event->setCreated($this->mutable(value: $moment)); + $this->events->insert($event); + }//end record() + + /** + * Run a mutation in one transaction. + * + * @param callable(): FlowTimer $mutation The mutation. + * + * @return FlowTimer The result. + * + * @throws Throwable The mutation's failure, after rollback. + */ + private function transactional(callable $mutation): FlowTimer { + $this->db->beginTransaction(); + try { + $result = $mutation(); + $this->db->commit(); + + return $result; + } catch (Throwable $failure) { + $this->db->rollBack(); + throw $failure; + } + }//end transactional() + + /** + * The clock instant as an immutable. + * + * @param DateTimeInterface|null $now The injected clock, or null for the real one. + * + * @return DateTimeImmutable Now. + */ + private function instant(?DateTimeInterface $now): DateTimeImmutable { + if ($now === null) { + return new DateTimeImmutable(); + } + + return DateTimeImmutable::createFromInterface($now); + }//end instant() + + /** + * A mutable copy, as the entities store. + * + * @param DateTimeInterface $value The instant. + * + * @return DateTime The copy. + */ + private function mutable(DateTimeInterface $value): DateTime { + return DateTime::createFromInterface($value); + }//end mutable() + + /** + * A mutable copy or null. + * + * @param DateTimeInterface|null $value The instant. + * + * @return DateTime|null The copy. + */ + private function mutableOrNull(?DateTimeInterface $value): ?DateTime { + if ($value === null) { + return null; + } + + return $this->mutable(value: $value); + }//end mutableOrNull() + + /** + * A parsed date or null. + * + * @param mixed $value An ISO string, a DateTimeInterface, or null. + * @param string $field The field name, for the message. + * + * @return DateTimeImmutable|null The instant. + * + * @throws FlowTimerValidationException On an unparseable value. + */ + private function dateOrNull(mixed $value, string $field): ?DateTimeImmutable { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof DateTimeInterface) { + return DateTimeImmutable::createFromInterface($value); + } + + if (is_string($value) === false) { + throw new FlowTimerValidationException( + message: sprintf('%s must be a date string or a DateTime; got %s.', $field, get_debug_type($value)) + ); + } + + try { + return new DateTimeImmutable($value); + } catch (Throwable) { + throw new FlowTimerValidationException(message: sprintf("%s '%s' is not a parseable date.", $field, $value)); + } + }//end dateOrNull() + + /** + * A non-empty string or null. + * + * @param mixed $value The value. + * + * @return string|null The string. + */ + private function stringOrNull(mixed $value): ?string { + if (is_string($value) === false || trim($value) === '') { + return null; + } + + return trim($value); + }//end stringOrNull() + + /** + * An array or null. + * + * @param mixed $value The value. + * + * @return array|null The array. + */ + private function arrayOrNull(mixed $value): ?array { + if (is_array($value) === false || $value === []) { + return null; + } + + return $value; + }//end arrayOrNull() +}//end class diff --git a/lib/Service/Flow/Timer/FlowTimerSweep.php b/lib/Service/Flow/Timer/FlowTimerSweep.php new file mode 100644 index 0000000000..2626a66007 --- /dev/null +++ b/lib/Service/Flow/Timer/FlowTimerSweep.php @@ -0,0 +1,108 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Timer; + +use DateTimeInterface; +use OCA\OpenRegister\Db\FlowTimerMapper; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The bounded, re-entrant sweep. + */ +class FlowTimerSweep { + + /** + * Constructor. + * + * @param FlowTimerMapper $timers The two range scans. + * @param FlowTimerService $service Fires expiries and rungs. + * @param WorkingCalendarService $calendars Reset once per pass so memoisation is per pass. + * @param LoggerInterface $logger Per-timer failure reporting. + */ + public function __construct( + private readonly FlowTimerMapper $timers, + private readonly FlowTimerService $service, + private readonly WorkingCalendarService $calendars, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Run one pass. + * + * @param DateTimeInterface $now The sweep instant. + * @param int $batch The per-scan batch limit. + * + * @return array{expiriesFired: int, rungsFired: int, truncated: bool, errors: int} Work performed. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-the-sweep-is-bounded-to-due-work-by-index-not-by-a-page-of-candidates + */ + public function run(DateTimeInterface $now, int $batch): array { + $batch = max(1, $batch); + $this->calendars->reset(); + $result = ['expiriesFired' => 0, 'rungsFired' => 0, 'truncated' => false, 'errors' => 0]; + + $expiries = $this->timers->findDueExpiries(now: $now, limit: $batch); + $result['truncated'] = (count($expiries) >= $batch); + foreach ($expiries as $timer) { + try { + if ($this->service->fireExpiry(timer: $timer, now: $now) === true) { + $result['expiriesFired']++; + } + } catch (Throwable $failure) { + $result['errors']++; + $this->logger->error( + '[FlowTimerSweep] Expiry fire failed: ' . $failure->getMessage(), + ['timer' => $timer->getUuid(), 'exception' => $failure] + ); + } + } + + $rungs = $this->timers->findDueRungs(now: $now, limit: $batch); + $result['truncated'] = ($result['truncated'] === true || count($rungs) >= $batch); + foreach ($rungs as $timer) { + try { + $result['rungsFired'] += $this->service->fireRungs(timer: $timer, now: $now); + } catch (Throwable $failure) { + $result['errors']++; + $this->logger->error( + '[FlowTimerSweep] Rung fire failed: ' . $failure->getMessage(), + ['timer' => $timer->getUuid(), 'exception' => $failure] + ); + } + } + + return $result; + }//end run() +}//end class diff --git a/lib/Service/Flow/Timer/SlaCalculator.php b/lib/Service/Flow/Timer/SlaCalculator.php new file mode 100644 index 0000000000..6d499c9749 --- /dev/null +++ b/lib/Service/Flow/Timer/SlaCalculator.php @@ -0,0 +1,380 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Timer; + +use DateTimeImmutable; +use DateTimeInterface; +use OCA\OpenRegister\Exception\FlowTimerValidationException; + +/** + * SLA arithmetic: measure, add and subtract in hours, business days or calendar days. + * + * @SuppressWarnings(PHPMD.StaticAccess) DateTimeImmutable::createFromInterface + * is PHP's own conversion; there is no instance form. + */ +final class SlaCalculator { + + /** + * The unit vocabulary, shared by the SLA and the escalation offsets. + */ + public const UNIT_HOURS = 'hours'; + + public const UNIT_BUSINESS_DAYS = 'businessDays'; + + public const UNIT_CALENDAR_DAYS = 'calendarDays'; + + /** + * Every accepted unit. + * + * @var array + */ + public const UNITS = [self::UNIT_HOURS, self::UNIT_BUSINESS_DAYS, self::UNIT_CALENDAR_DAYS]; + + /** + * The accepted SLA value range, inclusive. + */ + public const MIN_VALUE = 1; + + public const MAX_VALUE = 10000; + + /** + * Seconds in a day, the fraction base for business days. + * + * @var int + */ + private const DAY = 86400; + + /** + * Upper bound on calendar days walked in one operation. 10000 business + * days over a five-day week is ~14000 calendar days; anything past this + * is a bug, not a term. + * + * @var int + */ + private const MAX_WALK_DAYS = 20000; + + /** + * Tolerance on fractional-day comparisons. + * + * @var float + */ + private const EPSILON = 0.0000001; + + /** + * Validate an SLA of shape `{value, unit}`. + * + * @param mixed $sla The declared SLA. + * + * @return array{value: int, unit: string} The normalised SLA. + * + * @throws FlowTimerValidationException When the shape, the range or the unit is refused. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function validateSla(mixed $sla): array { + if (is_array($sla) === false || array_key_exists('value', $sla) === false || array_key_exists('unit', $sla) === false) { + throw new FlowTimerValidationException(message: 'An SLA must have the shape {value, unit}.'); + } + + $value = $sla['value']; + if (is_string($value) === true && preg_match('/^\d+$/', $value) === 1) { + $value = (int)$value; + } + + if (is_int($value) === false || $value < self::MIN_VALUE || $value > self::MAX_VALUE) { + throw new FlowTimerValidationException( + message: sprintf( + "SLA value '%s' is refused: it must be an integer from %d to %d.", + var_export($sla['value'], true), + self::MIN_VALUE, + self::MAX_VALUE + ) + ); + } + + return ['value' => $value, 'unit' => $this->validateUnit(unit: $sla['unit'])]; + }//end validateSla() + + /** + * Validate a unit name. + * + * @param mixed $unit The declared unit. + * + * @return string The unit. + * + * @throws FlowTimerValidationException On an unknown unit. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function validateUnit(mixed $unit): string { + if (is_string($unit) === false || in_array($unit, self::UNITS, true) === false) { + throw new FlowTimerValidationException( + message: sprintf("Unit '%s' is refused: use one of %s.", var_export($unit, true), implode(', ', self::UNITS)) + ); + } + + return $unit; + }//end validateUnit() + + /** + * Add an amount of business time to an instant. + * + * @param DateTimeInterface $from The start instant. + * @param float $value The amount; negative subtracts. + * @param string $unit The unit. + * @param WorkingCalendar $calendar The resolved calendar. + * + * @return DateTimeImmutable The resulting instant. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function add(DateTimeInterface $from, float $value, string $unit, WorkingCalendar $calendar): DateTimeImmutable { + $start = DateTimeImmutable::createFromInterface($from); + $this->validateUnit(unit: $unit); + + if ($unit === self::UNIT_HOURS) { + return $this->shift(moment: $start, modifier: sprintf('%+d seconds', (int)round($value * 3600))); + } + + if ($unit === self::UNIT_CALENDAR_DAYS) { + // Calendar days are DATES, not 86400-second spans: a term of N days + // lands at the same wall-clock time across a DST change. + $whole = (int)floor(abs($value)); + $fraction = (abs($value) - $whole); + $sign = 1; + if ($value < 0) { + $sign = -1; + } + + $landed = $this->shift(moment: $start, modifier: sprintf('%+d days', $sign * $whole)); + + return $this->shift(moment: $landed, modifier: sprintf('%+d seconds', $sign * (int)round($fraction * self::DAY))); + } + + if ($value >= 0) { + return $this->walkForward(start: $start, days: $value, calendar: $calendar); + } + + return $this->walkBackward(start: $start, days: -$value, calendar: $calendar); + }//end add() + + /** + * Subtract an amount of business time from an instant. + * + * @param DateTimeInterface $from The start instant. + * @param float $value The amount. + * @param string $unit The unit. + * @param WorkingCalendar $calendar The resolved calendar. + * + * @return DateTimeImmutable The resulting instant. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-escalation-rule-is-validated-against-its-sla-in-commensurable-units + */ + public function sub(DateTimeInterface $from, float $value, string $unit, WorkingCalendar $calendar): DateTimeImmutable { + return $this->add(from: $from, value: -$value, unit: $unit, calendar: $calendar); + }//end sub() + + /** + * How much business time lies between two instants, signed. + * + * @param DateTimeInterface $from The start. + * @param DateTimeInterface $to The end; before the start yields a negative amount. + * @param string $unit The unit. + * @param WorkingCalendar $calendar The resolved calendar. + * + * @return float The amount in the unit. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-suspended-deadline-holds-elapsed-time-not-a-moment + */ + public function measure(DateTimeInterface $from, DateTimeInterface $to, string $unit, WorkingCalendar $calendar): float { + $this->validateUnit(unit: $unit); + $seconds = ($to->getTimestamp() - $from->getTimestamp()); + + if ($unit === self::UNIT_HOURS) { + return ($seconds / 3600); + } + + if ($unit === self::UNIT_CALENDAR_DAYS) { + // Wall-clock difference: whole dates plus the fraction of a day. + $diff = $from->diff($to); + $days = ((int)$diff->days + (($diff->h * 3600 + $diff->i * 60 + $diff->s) / self::DAY)); + if ($diff->invert === 1) { + return -$days; + } + + return $days; + } + + if ($seconds < 0) { + return -$this->measure(from: $to, to: $from, unit: $unit, calendar: $calendar); + } + + $cursor = DateTimeImmutable::createFromInterface($from); + $end = DateTimeImmutable::createFromInterface($to); + $total = 0.0; + for ($walked = 0; $walked <= self::MAX_WALK_DAYS; $walked++) { + if ($cursor >= $end) { + return $total; + } + + $nextMidnight = $this->shift(moment: $cursor->setTime(0, 0, 0), modifier: '+1 day'); + $segmentEnd = $nextMidnight; + if ($end < $nextMidnight) { + $segmentEnd = $end; + } + + if ($calendar->isWorkingDay($cursor) === true) { + $total += (($segmentEnd->getTimestamp() - $cursor->getTimestamp()) / self::DAY); + } + + $cursor = $nextMidnight; + } + + throw new FlowTimerValidationException( + message: sprintf('Measuring business days between %s and %s exceeds %d calendar days.', $from->format('c'), $to->format('c'), self::MAX_WALK_DAYS) + ); + }//end measure() + + /** + * Convert an amount between units, through hours as the pivot: one business + * day is the calendar's working hours, one calendar day is 24 hours. + * + * @param float $value The amount. + * @param string $fromUnit The unit it is in. + * @param string $toUnit The unit wanted. + * @param WorkingCalendar $calendar Supplies hoursPerWorkingDay. + * + * @return float The converted amount. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-extension-is-bounded-and-may-only-be-granted-before-expiry + */ + public function convert(float $value, string $fromUnit, string $toUnit, WorkingCalendar $calendar): float { + $this->validateUnit(unit: $fromUnit); + $this->validateUnit(unit: $toUnit); + if ($fromUnit === $toUnit) { + return $value; + } + + $hoursPer = [ + self::UNIT_HOURS => 1.0, + self::UNIT_BUSINESS_DAYS => $calendar->getHoursPerWorkingDay(), + self::UNIT_CALENDAR_DAYS => 24.0, + ]; + + return (($value * $hoursPer[$fromUnit]) / $hoursPer[$toUnit]); + }//end convert() + + /** + * Walk forward over working days, consuming fractions of each. + * + * @param DateTimeImmutable $start The start. + * @param float $days Business days to add (>= 0). + * @param WorkingCalendar $calendar The calendar. + * + * @return DateTimeImmutable The landing instant. + */ + private function walkForward(DateTimeImmutable $start, float $days, WorkingCalendar $calendar): DateTimeImmutable { + $cursor = $start; + $remaining = $days; + for ($walked = 0; $walked <= self::MAX_WALK_DAYS; $walked++) { + $nextMidnight = $this->shift(moment: $cursor->setTime(0, 0, 0), modifier: '+1 day'); + if ($calendar->isWorkingDay($cursor) === true) { + $available = (($nextMidnight->getTimestamp() - $cursor->getTimestamp()) / self::DAY); + if ($remaining <= ($available + self::EPSILON)) { + return $this->shift(moment: $cursor, modifier: sprintf('%+d seconds', (int)round($remaining * self::DAY))); + } + + $remaining -= $available; + } + + $cursor = $nextMidnight; + } + + throw new FlowTimerValidationException( + message: sprintf('Adding %s business days from %s exceeds %d calendar days.', (string)$days, $start->format('c'), self::MAX_WALK_DAYS) + ); + }//end walkForward() + + /** + * Walk backward over working days, consuming fractions of each. + * + * @param DateTimeImmutable $start The start. + * @param float $days Business days to subtract (>= 0). + * @param WorkingCalendar $calendar The calendar. + * + * @return DateTimeImmutable The landing instant. + */ + private function walkBackward(DateTimeImmutable $start, float $days, WorkingCalendar $calendar): DateTimeImmutable { + $cursor = $start; + $remaining = $days; + for ($walked = 0; $walked <= self::MAX_WALK_DAYS; $walked++) { + $dayStart = $cursor->setTime(0, 0, 0); + // An instant exactly at midnight belongs to the END of the previous day when walking back. + if ($cursor->getTimestamp() === $dayStart->getTimestamp()) { + $dayStart = $this->shift(moment: $dayStart, modifier: '-1 day'); + } + + if ($calendar->isWorkingDay($dayStart) === true) { + $available = (($cursor->getTimestamp() - $dayStart->getTimestamp()) / self::DAY); + if ($remaining <= ($available + self::EPSILON)) { + return $this->shift(moment: $cursor, modifier: sprintf('%+d seconds', -(int)round($remaining * self::DAY))); + } + + $remaining -= $available; + } + + $cursor = $dayStart; + } + + throw new FlowTimerValidationException( + message: sprintf('Subtracting %s business days from %s exceeds %d calendar days.', (string)$days, $start->format('c'), self::MAX_WALK_DAYS) + ); + }//end walkBackward() + + /** + * Apply a relative modifier, refusing PHP's silent `false`. + * + * @param DateTimeImmutable $moment The instant. + * @param string $modifier A relative modifier such as `+1 day`. + * + * @return DateTimeImmutable The shifted instant. + * + * @throws FlowTimerValidationException When the modifier is unparseable. + */ + private function shift(DateTimeImmutable $moment, string $modifier): DateTimeImmutable { + $shifted = $moment->modify($modifier); + if ($shifted === false) { + throw new FlowTimerValidationException(message: sprintf("Date modifier '%s' is not parseable.", $modifier)); + } + + return $shifted; + }//end shift() +}//end class diff --git a/lib/Service/Flow/Timer/WorkingCalendar.php b/lib/Service/Flow/Timer/WorkingCalendar.php new file mode 100644 index 0000000000..da87494975 --- /dev/null +++ b/lib/Service/Flow/Timer/WorkingCalendar.php @@ -0,0 +1,517 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Timer; + +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use OCA\OpenRegister\Exception\FlowTimerValidationException; + +/** + * A validated, memoising working calendar. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The sum of the definition + * validators' refusal branches: every malformed rule shape is named in its own + * message rather than absorbed, which is the point of refusing rather than + * downgrading. + * @SuppressWarnings(PHPMD.ShortVariable) {@see easterSunday()} uses the + * single-letter names of the published anonymous Gregorian computus so the + * code can be checked line by line against its reference. + */ +final class WorkingCalendar { + + /** + * The seeded national default. + * + * @var string + */ + public const DEFAULT_SLUG = 'nl-national'; + + /** + * Rule kinds. + * + * @var array + */ + public const RULE_KINDS = ['fixed', 'easter', 'observedShift']; + + /** + * Weekday names accepted by an observed-shift rule, ISO numbered. + * + * @var array + */ + private const WEEKDAYS = [ + 'monday' => 1, + 'tuesday' => 2, + 'wednesday' => 3, + 'thursday' => 4, + 'friday' => 5, + 'saturday' => 6, + 'sunday' => 7, + ]; + + /** + * Non-working dates per year, memoised for the life of this instance. + * + * @var array> + */ + private array $yearCache = []; + + /** + * Constructor. Use {@see fromArray()}. + * + * @param string $slug The calendar's name. + * @param string|null $organisation The organisation it is configured for, when any. + * @param array $workingWeekdays ISO weekdays (1 = Monday) that are working days. + * @param float $hoursPerWorkingDay Working hours in one working day. + * @param array> $rules The computed non-working-date rules. + * @param array $exceptions Enumerated one-off closures, `Y-m-d` => name. + */ + private function __construct( + private readonly string $slug, + private readonly ?string $organisation, + private readonly array $workingWeekdays, + private readonly float $hoursPerWorkingDay, + private readonly array $rules, + private readonly array $exceptions, + ) { + + }//end __construct() + + /** + * Build and VALIDATE a calendar from its stored definition. + * + * @param array $definition The `working-calendar` object data. + * + * @return self The calendar. + * + * @throws FlowTimerValidationException On any refused definition, naming the fault. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public static function fromArray(array $definition): self { + $slug = trim((string)($definition['slug'] ?? '')); + if ($slug === '') { + throw new FlowTimerValidationException(message: 'A working calendar requires a non-empty slug.'); + } + + $hours = ($definition['hoursPerWorkingDay'] ?? null); + if (is_numeric($hours) === false || (float)$hours <= 0 || (float)$hours > 24) { + throw new FlowTimerValidationException( + message: sprintf( + "Working calendar '%s' requires hoursPerWorkingDay between 0 and 24; without it hours and businessDays are not commensurable.", + $slug + ) + ); + } + + $weekdays = self::validWeekdays(slug: $slug, value: ($definition['workingWeekdays'] ?? null)); + $rules = self::validRules(slug: $slug, value: ($definition['rules'] ?? [])); + $exceptions = self::validExceptions(slug: $slug, value: ($definition['exceptions'] ?? [])); + + if ($rules === [] && $exceptions !== []) { + throw new FlowTimerValidationException( + message: sprintf( + "Working calendar '%s' consists only of enumerated dates and would expire after '%s'; declare computed rules.", + $slug, + (string)array_key_last($exceptions) + ) + ); + } + + $organisation = null; + if (trim((string)($definition['organisation'] ?? '')) !== '') { + $organisation = trim((string)$definition['organisation']); + } + + return new self( + slug: $slug, + organisation: $organisation, + workingWeekdays: $weekdays, + hoursPerWorkingDay: (float)$hours, + rules: $rules, + exceptions: $exceptions + ); + }//end fromArray() + + /** + * The calendar's name. + * + * @return string The slug. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function getSlug(): string { + return $this->slug; + }//end getSlug() + + /** + * The organisation this calendar is configured for. + * + * @return string|null The organisation, or null for a shared calendar. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function getOrganisation(): ?string { + return $this->organisation; + }//end getOrganisation() + + /** + * Working hours in one working day. + * + * @return float The hours. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function getHoursPerWorkingDay(): float { + return $this->hoursPerWorkingDay; + }//end getHoursPerWorkingDay() + + /** + * Whether the calendar day containing this instant is a working day. + * + * @param DateTimeInterface $moment Any instant on the day. + * + * @return boolean True on a working weekday that is not a non-working date. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function isWorkingDay(DateTimeInterface $moment): bool { + if (in_array((int)$moment->format('N'), $this->workingWeekdays, true) === false) { + return false; + } + + $nonWorking = $this->nonWorkingDates(year: (int)$moment->format('Y')); + + return array_key_exists($moment->format('Y-m-d'), $nonWorking) === false; + }//end isWorkingDay() + + /** + * The non-working dates of a year, computed from the rules and memoised. + * + * @param int $year The year. + * + * @return array `Y-m-d` => name. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function nonWorkingDates(int $year): array { + if (array_key_exists($year, $this->yearCache) === true) { + return $this->yearCache[$year]; + } + + $dates = []; + $easter = self::easterSunday(year: $year); + foreach ($this->rules as $rule) { + $date = $this->ruleDate(rule: $rule, year: $year, easter: $easter); + $dates[$date->format('Y-m-d')] = (string)($rule['name'] ?? $rule['kind']); + } + + foreach ($this->exceptions as $date => $name) { + if (str_starts_with($date, (string)$year . '-') === true) { + $dates[$date] = $name; + } + } + + ksort($dates); + $this->yearCache[$year] = $dates; + + return $dates; + }//end nonWorkingDates() + + /** + * Easter Sunday (Gregorian), by the anonymous algorithm — computed, so no year runs out. + * + * @param int $year The year. + * + * @return DateTimeImmutable Easter Sunday at midnight UTC. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public static function easterSunday(int $year): DateTimeImmutable { + $a = ($year % 19); + $b = intdiv($year, 100); + $c = ($year % 100); + $d = intdiv($b, 4); + $e = ($b % 4); + $f = intdiv(($b + 8), 25); + $g = intdiv(($b - $f + 1), 3); + $h = ((19 * $a + $b - $d - $g + 15) % 30); + $i = intdiv($c, 4); + $k = ($c % 4); + $l = ((32 + 2 * $e + 2 * $i - $h - $k) % 7); + $m = intdiv(($a + 11 * $h + 22 * $l), 451); + $month = intdiv(($h + $l - 7 * $m + 114), 31); + $day = ((($h + $l - 7 * $m + 114) % 31) + 1); + + return new DateTimeImmutable(sprintf('%04d-%02d-%02d 00:00:00', $year, $month, $day), new DateTimeZone('UTC')); + }//end easterSunday() + + /** + * Resolve one rule to its date in a year. + * + * @param array $rule The validated rule. + * @param int $year The year. + * @param DateTimeImmutable $easter Easter Sunday of that year. + * + * @return DateTimeImmutable The date. + */ + private function ruleDate(array $rule, int $year, DateTimeImmutable $easter): DateTimeImmutable { + if ($rule['kind'] === 'easter') { + return self::shift(moment: $easter, modifier: sprintf('%+d days', (int)$rule['offset'])); + } + + $date = new DateTimeImmutable( + sprintf('%04d-%02d-%02d 00:00:00', $year, (int)$rule['month'], (int)$rule['day']), + new DateTimeZone('UTC') + ); + $shift = ($rule['observedShift'] ?? null); + if (is_array($shift) === true && (int)$date->format('N') === (int)$shift['whenWeekday']) { + return self::shift(moment: $date, modifier: sprintf('%+d days', (int)$shift['days'])); + } + + return $date; + }//end ruleDate() + + /** + * Validate the working weekdays. + * + * @param string $slug The calendar, for the message. + * @param mixed $value The declared weekdays. + * + * @return array ISO weekdays. + * + * @throws FlowTimerValidationException When absent, empty or out of range. + */ + private static function validWeekdays(string $slug, mixed $value): array { + if (is_array($value) === false || $value === []) { + throw new FlowTimerValidationException( + message: sprintf("Working calendar '%s' must declare its workingWeekdays (ISO 1..7).", $slug) + ); + } + + $weekdays = []; + foreach ($value as $weekday) { + if (is_int($weekday) === false || $weekday < 1 || $weekday > 7) { + throw new FlowTimerValidationException( + message: sprintf("Working calendar '%s' has an invalid weekday '%s'; use ISO 1..7.", $slug, var_export($weekday, true)) + ); + } + + $weekdays[] = $weekday; + } + + return array_values(array_unique($weekdays)); + }//end validWeekdays() + + /** + * Validate the rules. + * + * @param string $slug The calendar, for the message. + * @param mixed $value The declared rules. + * + * @return array> The normalised rules. + * + * @throws FlowTimerValidationException On an unknown kind or a malformed rule. + */ + private static function validRules(string $slug, mixed $value): array { + if (is_array($value) === false) { + throw new FlowTimerValidationException(message: sprintf("Working calendar '%s' rules must be an array.", $slug)); + } + + $rules = []; + foreach ($value as $rule) { + $kind = (string)($rule['kind'] ?? ''); + if (is_array($rule) === false || in_array($kind, self::RULE_KINDS, true) === false) { + throw new FlowTimerValidationException( + message: sprintf("Working calendar '%s' has a rule of unknown kind '%s'; known kinds: %s.", $slug, $kind, implode(', ', self::RULE_KINDS)) + ); + } + + if ($kind === 'easter') { + if (is_int($rule['offset'] ?? null) === false) { + throw new FlowTimerValidationException( + message: sprintf("Working calendar '%s': an easter rule requires an integer offset.", $slug) + ); + } + + $rules[] = ['kind' => 'easter', 'offset' => (int)$rule['offset'], 'name' => (string)($rule['name'] ?? 'easter')]; + continue; + } + + $rules[] = self::validFixedRule(slug: $slug, rule: $rule, kind: $kind); + } + + return $rules; + }//end validRules() + + /** + * Validate a fixed or observed-shift rule. + * + * @param string $slug The calendar, for the message. + * @param array $rule The rule. + * @param string $kind `fixed` or `observedShift`. + * + * @return array The normalised rule. + * + * @throws FlowTimerValidationException On a malformed month, day or shift. + */ + private static function validFixedRule(string $slug, array $rule, string $kind): array { + $month = ($rule['month'] ?? null); + $day = ($rule['day'] ?? null); + if (is_int($month) === false || $month < 1 || $month > 12 || is_int($day) === false || $day < 1 || $day > 31) { + throw new FlowTimerValidationException( + message: sprintf("Working calendar '%s': a %s rule requires an integer month 1..12 and day 1..31.", $slug, $kind) + ); + } + + $normalised = ['kind' => $kind, 'month' => $month, 'day' => $day, 'name' => (string)($rule['name'] ?? $kind)]; + $shift = self::declaredShift(rule: $rule, kind: $kind); + if ($shift === null) { + return $normalised; + } + + $normalised['observedShift'] = self::validShift(slug: $slug, shift: $shift); + + return $normalised; + }//end validFixedRule() + + /** + * The shift a rule declares: `observedShift` on a fixed rule, or the flat + * `whenWeekday`/`days` shorthand of an `observedShift` rule. + * + * @param array $rule The rule. + * @param string $kind `fixed` or `observedShift`. + * + * @return mixed The declared shift, or null when the rule has none. + */ + private static function declaredShift(array $rule, string $kind): mixed { + $shift = ($rule['observedShift'] ?? null); + if ($kind === 'observedShift' && is_array($shift) === false) { + return ['whenWeekday' => ($rule['whenWeekday'] ?? null), 'days' => ($rule['days'] ?? null)]; + } + + return $shift; + }//end declaredShift() + + /** + * Validate an observed shift: a weekday (name or ISO number) and a day delta. + * + * @param string $slug The calendar, for the message. + * @param mixed $shift The declared shift. + * + * @return array{whenWeekday: int, days: int} The normalised shift. + * + * @throws FlowTimerValidationException On a malformed shift. + */ + private static function validShift(string $slug, mixed $shift): array { + if (is_array($shift) === false || is_int($shift['days'] ?? null) === false) { + throw new FlowTimerValidationException( + message: sprintf("Working calendar '%s': an observed shift requires whenWeekday and an integer days.", $slug) + ); + } + + $weekday = ($shift['whenWeekday'] ?? null); + if (is_string($weekday) === true) { + $weekday = (self::WEEKDAYS[strtolower($weekday)] ?? null); + } + + if (is_int($weekday) === false || $weekday < 1 || $weekday > 7) { + throw new FlowTimerValidationException( + message: sprintf("Working calendar '%s': observed shift whenWeekday must be a weekday name or ISO 1..7.", $slug) + ); + } + + return ['whenWeekday' => $weekday, 'days' => (int)$shift['days']]; + }//end validShift() + + /** + * Apply a relative modifier, refusing PHP's silent `false`. + * + * @param DateTimeImmutable $moment The instant. + * @param string $modifier A relative modifier such as `+39 days`. + * + * @return DateTimeImmutable The shifted instant. + * + * @throws FlowTimerValidationException When the modifier is unparseable. + */ + private static function shift(DateTimeImmutable $moment, string $modifier): DateTimeImmutable { + $shifted = $moment->modify($modifier); + if ($shifted === false) { + throw new FlowTimerValidationException(message: sprintf("Date modifier '%s' is not parseable.", $modifier)); + } + + return $shifted; + }//end shift() + + /** + * Validate the enumerated exceptions. + * + * @param string $slug The calendar, for the message. + * @param mixed $value The declared exceptions: `Y-m-d` strings or `{date, name}` objects. + * + * @return array `Y-m-d` => name, sorted. + * + * @throws FlowTimerValidationException On a malformed date. + */ + private static function validExceptions(string $slug, mixed $value): array { + if (is_array($value) === false) { + throw new FlowTimerValidationException(message: sprintf("Working calendar '%s' exceptions must be an array.", $slug)); + } + + $exceptions = []; + foreach ($value as $entry) { + $date = $entry; + $name = 'exception'; + if (is_array($entry) === true) { + $date = ($entry['date'] ?? null); + $name = (string)($entry['name'] ?? 'exception'); + } + + if (is_string($date) === false || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) { + throw new FlowTimerValidationException( + message: sprintf("Working calendar '%s' has an exception that is not a Y-m-d date: '%s'.", $slug, var_export($date, true)) + ); + } + + $exceptions[$date] = $name; + } + + ksort($exceptions); + + return $exceptions; + }//end validExceptions() +}//end class diff --git a/lib/Service/Flow/Timer/WorkingCalendarService.php b/lib/Service/Flow/Timer/WorkingCalendarService.php new file mode 100644 index 0000000000..754060f956 --- /dev/null +++ b/lib/Service/Flow/Timer/WorkingCalendarService.php @@ -0,0 +1,152 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Timer; + +use OCA\OpenRegister\Exception\FlowTimerValidationException; + +/** + * Timer → organisation → seeded default, refusing rather than downgrading. + * + * @SuppressWarnings(PHPMD.StaticAccess) WorkingCalendar::fromArray() is the + * value object's validating named constructor. + */ +class WorkingCalendarService { + + /** + * Resolved calendars, keyed by slug. + * + * @var array + */ + private array $resolved = []; + + /** + * Constructor. + * + * @param FlowTimerDefinitionStore $definitions The seeded calendar definitions. + */ + public function __construct( + private readonly FlowTimerDefinitionStore $definitions, + ) { + + }//end __construct() + + /** + * Resolve the calendar for a timer. + * + * @param string|null $calendarSlug The calendar named on the timer, when any. + * @param string|null $organisation The subject's organisation, when any. + * + * @return WorkingCalendar The resolved calendar. + * + * @throws FlowTimerValidationException When the named calendar, or the + * default, does not exist — naming the missing calendar. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function resolve(?string $calendarSlug, ?string $organisation): WorkingCalendar { + $named = trim((string)$calendarSlug); + if ($named !== '') { + return $this->bySlug(slug: $named); + } + + $organisationSlug = $this->organisationCalendarSlug(organisation: $organisation); + if ($organisationSlug !== null) { + return $this->bySlug(slug: $organisationSlug); + } + + return $this->bySlug(slug: WorkingCalendar::DEFAULT_SLUG); + }//end resolve() + + /** + * Forget resolved calendars (once per sweep pass). + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-business-time-is-measured-against-one-resolvable-working-calendar + */ + public function reset(): void { + $this->resolved = []; + $this->definitions->reset(); + }//end reset() + + /** + * A calendar by name, validated and memoised. + * + * @param string $slug The calendar name. + * + * @return WorkingCalendar The calendar. + * + * @throws FlowTimerValidationException When no calendar carries that name. + */ + private function bySlug(string $slug): WorkingCalendar { + if (array_key_exists($slug, $this->resolved) === true) { + return $this->resolved[$slug]; + } + + $definitions = $this->definitions->calendars(); + if (array_key_exists($slug, $definitions) === false) { + throw new FlowTimerValidationException( + message: sprintf( + "Working calendar '%s' does not exist; known calendars: %s. No weekday-only substitute is made.", + $slug, + implode(', ', array_keys($definitions)) + ) + ); + } + + $calendar = WorkingCalendar::fromArray(definition: $definitions[$slug]); + $this->resolved[$slug] = $calendar; + + return $calendar; + }//end bySlug() + + /** + * The slug of the calendar configured for an organisation, when one is. + * + * @param string|null $organisation The organisation uuid. + * + * @return string|null The slug, or null when the organisation has no calendar of its own. + */ + private function organisationCalendarSlug(?string $organisation): ?string { + $organisation = trim((string)$organisation); + if ($organisation === '') { + return null; + } + + foreach ($this->definitions->calendars() as $slug => $definition) { + if (trim((string)($definition['organisation'] ?? '')) === $organisation) { + return (string)$slug; + } + } + + return null; + }//end organisationCalendarSlug() +}//end class diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php index 170021759a..cf2af321a4 100644 --- a/lib/Service/Task/TaskService.php +++ b/lib/Service/Task/TaskService.php @@ -700,6 +700,97 @@ public function terminateAsMoot(string $uuid, string $reason, string $source): T }//end terminateAsMoot() + /** + * A business timer's ENFORCING outcome, applied as a named task action. + * + * The four outcomes leave the subject in four DISTINCT observable states + * (flow-business-timers design D-3): `skip` completes the task with + * outcome `skipped` so the process continues past the step; `error` + * terminates it with outcome `failed`; `dead_letter` disables it with + * outcome `dead_letter`, parked for an operator; `transition:` + * completes it with `` as outcome and audited action. One code + * path, one audit trail, and the audit names the timer as actor. + * + * Idempotent on an already-terminal task, and a lost race against a + * concurrent completion surfaces as {@see TaskConflictException}, which + * the sweep treats as "nothing to do". + * + * @param string $uuid The task uuid. + * @param string $outcome `skip`, `error`, `dead_letter` or `transition:`. + * @param string $source The timer identity recorded as actor (`flow-timer:`). + * @param string $reason Why, recorded on the audit. + * + * @return Task The task as left by the outcome, or untouched when already terminal. + * + * @throws TaskValidationException On an outcome outside the vocabulary. + * @throws TaskConflictException When the row was closed concurrently. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-advisory-due-date-notifies-an-enforcing-expiry-transitions + */ + public function applyTimerOutcome(string $uuid, string $outcome, string $source, string $reason): Task { + [$state, $recorded, $action] = $this->timerOutcomeTarget(outcome: $outcome); + + $task = $this->tasks->findByUuid(uuid: $uuid); + if ($task->isInTerminalState() === true) { + // Idempotent: already-terminal stays as it ended. + return $task; + } + + return $this->transactional( + mutation: function () use ($task, $state, $recorded, $action, $source, $reason): Task { + $task->setOutcome($recorded); + $task->setBlockedReason(null); + if ($state === Task::STATE_COMPLETED) { + $task->setCompletedAt(new DateTime()); + $task->setCompletedBy($source); + } + + $this->applyState(task: $task, state: $state, action: $action); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit(task: $persisted, action: $action, actor: $source, reason: $reason); + + return $persisted; + } + ); + }//end applyTimerOutcome() + + /** + * The (state, outcome, action) an enforcing outcome maps to. + * + * @param string $outcome The declared outcome. + * + * @return array{0: string, 1: string, 2: string} Target state, recorded outcome, audited action. + * + * @throws TaskValidationException On an outcome outside the vocabulary. + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-an-advisory-due-date-notifies-an-enforcing-expiry-transitions + */ + private function timerOutcomeTarget(string $outcome): array { + $reserved = [ + 'skip' => [Task::STATE_COMPLETED, 'skipped', 'skip'], + 'error' => [Task::STATE_TERMINATED, 'failed', 'error'], + 'dead_letter' => [Task::STATE_DISABLED, 'dead_letter', 'dead_letter'], + ]; + if (array_key_exists($outcome, $reserved) === true) { + return $reserved[$outcome]; + } + + if (str_starts_with($outcome, 'transition:') === true) { + $action = trim(substr($outcome, strlen('transition:'))); + if ($action === '' || strlen($action) > 32 || array_key_exists($action, $reserved) === true) { + throw new TaskValidationException( + message: sprintf("Timer outcome '%s' names no usable action: it must be 1..32 characters and not a reserved outcome.", $outcome) + ); + } + + return [Task::STATE_COMPLETED, $action, $action]; + } + + throw new TaskValidationException( + message: sprintf("Timer outcome '%s' is refused: use skip, error, dead_letter or transition:.", $outcome) + ); + }//end timerOutcomeTarget() + /** * Fetch a task by uuid. * diff --git a/lib/Settings/flow_timer_register.json b/lib/Settings/flow_timer_register.json new file mode 100644 index 0000000000..ca5b33ba52 --- /dev/null +++ b/lib/Settings/flow_timer_register.json @@ -0,0 +1,525 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Flow Timers", + "description": "Configuration data for the business-timer store (flow-business-timers): named working calendars whose non-working dates are COMPUTED from rules so they never expire, and escalation ladders whose rungs are editable data rather than a compiled-in constant (ADR-001, ADR-031). Materialised by the SeedFlowTimerRegister repair step; OpenRegister does not self-import its own register JSON at boot (ADR-037).", + "version": "1.0.0" + }, + "x-openregister": { + "type": "core", + "app": "openregister", + "openregister": "^v0.2.10", + "description": "Working calendars and escalation ladders for the durable business-timer store. Nothing here sends: a ladder rung names roles, a priority and a message identity that the notification subsystem resolves." + }, + "paths": {}, + "components": { + "registers": { + "flow-timers": { + "slug": "flow-timers", + "title": "Flow Timers", + "version": "1.0.0", + "description": "Working calendars and escalation ladders consumed by the business-timer store. The timers themselves live in openregister_flow_timers, not here.", + "published": "2026-09-01T00:00:00+00:00", + "schemas": [ + "working-calendar", + "escalation-ladder" + ], + "folder": "Open Registers/Flow Timers" + } + }, + "schemas": { + "working-calendar": { + "slug": "working-calendar", + "title": "Working calendar", + "version": "1.0.0", + "published": "2026-09-01T00:00:00+00:00", + "summary": "Which weekdays work, which dates do not (computed from rules), and how many hours a working day holds.", + "description": "A named working calendar resolved by the business-timer store in the order: the calendar named on the timer, the calendar configured for the subject's organisation, the seeded national default. Non-working dates are computed from rules of kind fixed, easter or observedShift, so the calendar does not expire; enumerated exceptions may accompany rules but a calendar consisting only of enumerated dates is refused. hoursPerWorkingDay is required because without it hours and businessDays are not commensurable.", + "required": [ + "slug", + "workingWeekdays", + "hoursPerWorkingDay", + "rules" + ], + "properties": { + "slug": { + "title": "Slug", + "type": "string", + "description": "The calendar's stable name, referenced by a timer's calendar_slug." + }, + "title": { + "title": "Title", + "type": "string", + "description": "Human-readable title." + }, + "organisation": { + "title": "Organisation", + "type": "string", + "description": "UUID of the organisation this calendar is configured for. Empty for a shared calendar. The resolution order picks an organisation's calendar when the timer names none." + }, + "workingWeekdays": { + "title": "Working weekdays", + "type": "array", + "description": "ISO weekdays (1 = Monday, 7 = Sunday) that are working days.", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 7 + } + }, + "hoursPerWorkingDay": { + "title": "Hours per working day", + "type": "number", + "description": "Working hours in one working day. Makes hours and businessDays commensurable.", + "minimum": 0.25, + "maximum": 24 + }, + "rules": { + "title": "Non-working-date rules", + "type": "array", + "description": "Computed non-working-date rules. kind fixed: {month, day, name, observedShift?: {whenWeekday, days}}. kind easter: {offset, name}, offset in days from Easter Sunday. kind observedShift: a fixed date with a required shift.", + "items": { + "type": "object", + "required": [ + "kind", + "name" + ], + "properties": { + "kind": { + "title": "Kind", + "type": "string", + "enum": [ + "fixed", + "easter", + "observedShift" + ], + "description": "fixed: a month/day; easter: an offset in days from Easter Sunday; observedShift: a month/day with a required observed shift." + }, + "name": { + "title": "Name", + "type": "string", + "description": "The holiday's name." + }, + "month": { + "title": "Month", + "type": "integer", + "minimum": 1, + "maximum": 12, + "description": "Month of a fixed or observedShift rule; ignored by an easter rule." + }, + "day": { + "title": "Day", + "type": "integer", + "minimum": 1, + "maximum": 31, + "description": "Day of a fixed or observedShift rule; ignored by an easter rule." + }, + "offset": { + "title": "Offset", + "type": "integer", + "minimum": -100, + "maximum": 100, + "description": "Days from Easter Sunday for an easter rule; ignored by the other kinds." + }, + "observedShift": { + "title": "Observed shift", + "type": "object", + "description": "Optional on a fixed rule, required on observedShift: {whenWeekday: weekday name or ISO 1..7, days: integer}.", + "properties": { + "whenWeekday": { + "title": "When weekday", + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ], + "description": "The weekday (name) on which the date shifts, e.g. sunday for Koningsdag." + }, + "days": { + "title": "Days", + "type": "integer", + "minimum": -6, + "maximum": 6, + "description": "How many days the observed date shifts, e.g. -1 for the Saturday before." + } + } + } + } + } + }, + "exceptions": { + "title": "Exception dates", + "type": "array", + "description": "Enumerated one-off closures as {date, name}. Allowed alongside rules; refused as the whole calendar.", + "items": { + "type": "object", + "required": [ + "date", + "name" + ], + "properties": { + "date": { + "title": "Date", + "type": "string", + "format": "date", + "description": "The closed date, Y-m-d." + }, + "name": { + "title": "Name", + "type": "string", + "description": "Why the day is closed." + } + } + } + } + } + }, + "escalation-ladder": { + "slug": "escalation-ladder", + "title": "Escalation ladder", + "version": "1.0.0", + "published": "2026-09-01T00:00:00+00:00", + "summary": "An ordered set of rungs, each a distance from the deadline with the roles, priority and message identity to raise.", + "description": "Editable escalation data for the business-timer store. Each rung fires at most once per timer, decided by the unique fire ledger. The rung's roles are resolved against the subject's performer (handler = the assignee) or through roleBindings; the message value is an identity the notification subsystem resolves. Nothing here renders or sends.", + "required": [ + "slug", + "rungs" + ], + "properties": { + "slug": { + "title": "Slug", + "type": "string", + "description": "The ladder's stable name, referenced by a timer's ladder_slug." + }, + "title": { + "title": "Title", + "type": "string", + "description": "Human-readable title." + }, + "organisation": { + "title": "Organisation", + "type": "string", + "description": "UUID of the organisation this ladder is the default for. Empty for the shared default." + }, + "rungs": { + "title": "Rungs", + "type": "array", + "description": "Ordered rungs: {key, trigger (preBreach|slaBreached), offset, offsetUnit (hours|businessDays|calendarDays), notifyRole[], escalateToRole[], priority, message, openIncident}.", + "items": { + "type": "object", + "required": [ + "trigger", + "offset", + "offsetUnit", + "notifyRole", + "priority" + ], + "properties": { + "key": { + "title": "Key", + "type": "string", + "description": "Stable rung identity, e.g. preBreach:14:calendarDays; derived from trigger, offset and unit when absent." + }, + "trigger": { + "title": "Trigger", + "type": "string", + "enum": [ + "preBreach", + "slaBreached" + ], + "description": "preBreach fires before the deadline, slaBreached at or after it." + }, + "offset": { + "title": "Offset", + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "Distance from the deadline, in the offset unit." + }, + "offsetUnit": { + "title": "Offset unit", + "type": "string", + "enum": [ + "hours", + "businessDays", + "calendarDays" + ], + "description": "The unit of the offset: the same set the SLA accepts." + }, + "notifyRole": { + "title": "Notify roles", + "type": "array", + "items": { + "type": "string" + }, + "description": "Roles told at this rung; handler is the subject's own performer." + }, + "escalateToRole": { + "title": "Escalate to roles", + "type": "array", + "items": { + "type": "string" + }, + "description": "Roles the matter is escalated to at this rung." + }, + "priority": { + "title": "Priority", + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "description": "The urgency the raised transition carries." + }, + "message": { + "title": "Message", + "type": "string", + "description": "A message identity the notification subsystem resolves." + }, + "openIncident": { + "title": "Open incident", + "type": "boolean", + "description": "Whether reaching this rung opens an incident." + } + } + } + }, + "roleBindings": { + "title": "Role bindings", + "type": "object", + "description": "Optional map from a role name to a concrete recipient descriptor, e.g. {\"teamleader\": \"group:teamleaders\"}. Roles without a binding travel unresolved as role:." + } + } + } + }, + "objects": [ + { + "@self": { + "register": "flow-timers", + "schema": "working-calendar", + "slug": "nl-national", + "uuid": "00000000-0000-0000-0000-000000000101" + }, + "slug": "nl-national", + "title": "Nederland, nationale feestdagen", + "workingWeekdays": [ + 1, + 2, + 3, + 4, + 5 + ], + "hoursPerWorkingDay": 8, + "rules": [ + { + "kind": "fixed", + "month": 1, + "day": 1, + "name": "Nieuwjaarsdag" + }, + { + "kind": "easter", + "offset": -2, + "name": "Goede Vrijdag" + }, + { + "kind": "easter", + "offset": 1, + "name": "Tweede Paasdag" + }, + { + "kind": "fixed", + "month": 4, + "day": 27, + "name": "Koningsdag", + "observedShift": { + "whenWeekday": "sunday", + "days": -1 + } + }, + { + "kind": "easter", + "offset": 39, + "name": "Hemelvaartsdag" + }, + { + "kind": "easter", + "offset": 50, + "name": "Tweede Pinksterdag" + }, + { + "kind": "fixed", + "month": 12, + "day": 25, + "name": "Eerste Kerstdag" + }, + { + "kind": "fixed", + "month": 12, + "day": 26, + "name": "Tweede Kerstdag" + } + ], + "exceptions": [] + }, + { + "@self": { + "register": "flow-timers", + "schema": "working-calendar", + "slug": "example-organisation", + "uuid": "00000000-0000-0000-0000-000000000103" + }, + "slug": "example-organisation", + "title": "Gemeente Example, lokale sluitingsdag", + "organisation": "00000000-0000-0000-0000-000000000000", + "workingWeekdays": [ + 1, + 2, + 3, + 4, + 5 + ], + "hoursPerWorkingDay": 7, + "rules": [ + { + "kind": "fixed", + "month": 1, + "day": 1, + "name": "Nieuwjaarsdag" + }, + { + "kind": "easter", + "offset": -2, + "name": "Goede Vrijdag" + }, + { + "kind": "easter", + "offset": 1, + "name": "Tweede Paasdag" + }, + { + "kind": "fixed", + "month": 4, + "day": 27, + "name": "Koningsdag", + "observedShift": { + "whenWeekday": "sunday", + "days": -1 + } + }, + { + "kind": "easter", + "offset": 39, + "name": "Hemelvaartsdag" + }, + { + "kind": "easter", + "offset": 50, + "name": "Tweede Pinksterdag" + }, + { + "kind": "fixed", + "month": 12, + "day": 25, + "name": "Eerste Kerstdag" + }, + { + "kind": "fixed", + "month": 12, + "day": 26, + "name": "Tweede Kerstdag" + } + ], + "exceptions": [ + { + "date": "2026-10-05", + "name": "Lokale sluitingsdag (voorbeeld)" + } + ] + }, + { + "@self": { + "register": "flow-timers", + "schema": "escalation-ladder", + "slug": "nl-termijn-default", + "uuid": "00000000-0000-0000-0000-000000000102" + }, + "slug": "nl-termijn-default", + "title": "Termijnbewaking, standaardladder 14/7/2/0", + "rungs": [ + { + "key": "preBreach:14:calendarDays", + "trigger": "preBreach", + "offset": 14, + "offsetUnit": "calendarDays", + "notifyRole": [ + "handler" + ], + "escalateToRole": [], + "priority": "low", + "message": "termijn-14d", + "openIncident": false + }, + { + "key": "preBreach:7:calendarDays", + "trigger": "preBreach", + "offset": 7, + "offsetUnit": "calendarDays", + "notifyRole": [ + "handler", + "teamleader" + ], + "escalateToRole": [ + "teamleader" + ], + "priority": "medium", + "message": "termijn-7d", + "openIncident": false + }, + { + "key": "preBreach:2:calendarDays", + "trigger": "preBreach", + "offset": 2, + "offsetUnit": "calendarDays", + "notifyRole": [ + "handler", + "teamleader", + "manager" + ], + "escalateToRole": [ + "manager" + ], + "priority": "high", + "message": "termijn-2d", + "openIncident": false + }, + { + "key": "slaBreached:0", + "trigger": "slaBreached", + "offset": 0, + "offsetUnit": "calendarDays", + "notifyRole": [ + "handler", + "teamleader", + "manager" + ], + "escalateToRole": [ + "manager" + ], + "priority": "critical", + "message": "termijn-overschreden", + "openIncident": true + } + ], + "roleBindings": {} + } + ] + } +} diff --git a/lib/Settings/openregister_mock_register.json b/lib/Settings/openregister_mock_register.json index ee95ae6fcd..b285e2d8b4 100644 --- a/lib/Settings/openregister_mock_register.json +++ b/lib/Settings/openregister_mock_register.json @@ -60,6 +60,12 @@ "title": "Vocabulary (demo)", "version": "1.0.0", "description": "Demo data for Vocabulary. Generated from the register's own schemas — see hydra-gates/scripts/lib/generate_mock_register.py." + }, + "flow-timers": { + "slug": "flow-timers", + "title": "Flow Timers (demo)", + "version": "1.0.0", + "description": "Demo data for Flow Timers. Generated from the register's own schemas — see hydra-gates/scripts/lib/generate_mock_register.py." } }, "schemas": { @@ -2918,6 +2924,186 @@ "version": "Voorbeeld Version 3", "source": "https://example.invalid/resource/2", "description": "Voorbeeld Description 3" + }, + { + "@self": { + "register": "flow-timers", + "schema": "escalation-ladder", + "slug": "escalation-ladder-voorbeeld-title-1-1" + }, + "slug": "Voorbeeld Slug 1", + "rungs": [ + { + "trigger": "preBreach", + "offset": 0, + "offsetUnit": "hours", + "notifyRole": [ + "Voorbeeld Notifyrole 1" + ], + "priority": "low", + "key": "Voorbeeld Key 1", + "escalateToRole": [ + "Voorbeeld Escalatetorole 1" + ], + "message": "Voorbeeld Message 1" + } + ], + "title": "Voorbeeld Title 1", + "organisation": "Voorbeeld Organisation 1", + "roleBindings": {} + }, + { + "@self": { + "register": "flow-timers", + "schema": "escalation-ladder", + "slug": "escalation-ladder-voorbeeld-title-2-2" + }, + "slug": "Voorbeeld Slug 2", + "rungs": [ + { + "trigger": "slaBreached", + "offset": 1, + "offsetUnit": "businessDays", + "notifyRole": [ + "Voorbeeld Notifyrole 2" + ], + "priority": "medium", + "key": "Voorbeeld Key 2", + "escalateToRole": [ + "Voorbeeld Escalatetorole 2" + ], + "message": "Voorbeeld Message 2" + } + ], + "title": "Voorbeeld Title 2", + "organisation": "Voorbeeld Organisation 2", + "roleBindings": {} + }, + { + "@self": { + "register": "flow-timers", + "schema": "escalation-ladder", + "slug": "escalation-ladder-voorbeeld-title-3-3" + }, + "slug": "Voorbeeld Slug 3", + "rungs": [ + { + "trigger": "preBreach", + "offset": 2, + "offsetUnit": "calendarDays", + "notifyRole": [ + "Voorbeeld Notifyrole 3" + ], + "priority": "high", + "key": "Voorbeeld Key 3", + "escalateToRole": [ + "Voorbeeld Escalatetorole 3" + ], + "message": "Voorbeeld Message 3" + } + ], + "title": "Voorbeeld Title 3", + "organisation": "Voorbeeld Organisation 3", + "roleBindings": {} + }, + { + "@self": { + "register": "flow-timers", + "schema": "working-calendar", + "slug": "working-calendar-voorbeeld-title-1-1" + }, + "slug": "Voorbeeld Slug 1", + "workingWeekdays": [ + 1 + ], + "hoursPerWorkingDay": 0.25, + "rules": [ + { + "kind": "fixed", + "name": "Voorbeeld Name 1", + "month": 1, + "day": 1, + "offset": -100, + "observedShift": { + "whenWeekday": "monday", + "days": -6 + } + } + ], + "title": "Voorbeeld Title 1", + "organisation": "Voorbeeld Organisation 1", + "exceptions": [ + { + "date": "2026-03-01", + "name": "Voorbeeld Name 1" + } + ] + }, + { + "@self": { + "register": "flow-timers", + "schema": "working-calendar", + "slug": "working-calendar-voorbeeld-title-2-2" + }, + "slug": "Voorbeeld Slug 2", + "workingWeekdays": [ + 2 + ], + "hoursPerWorkingDay": 1.25, + "rules": [ + { + "kind": "easter", + "name": "Voorbeeld Name 2", + "month": 2, + "day": 2, + "offset": -99, + "observedShift": { + "whenWeekday": "tuesday", + "days": -5 + } + } + ], + "title": "Voorbeeld Title 2", + "organisation": "Voorbeeld Organisation 2", + "exceptions": [ + { + "date": "2026-03-02", + "name": "Voorbeeld Name 2" + } + ] + }, + { + "@self": { + "register": "flow-timers", + "schema": "working-calendar", + "slug": "working-calendar-voorbeeld-title-3-3" + }, + "slug": "Voorbeeld Slug 3", + "workingWeekdays": [ + 3 + ], + "hoursPerWorkingDay": 2.25, + "rules": [ + { + "kind": "observedShift", + "name": "Voorbeeld Name 3", + "month": 3, + "day": 3, + "offset": -98, + "observedShift": { + "whenWeekday": "wednesday", + "days": -4 + } + } + ], + "title": "Voorbeeld Title 3", + "organisation": "Voorbeeld Organisation 3", + "exceptions": [ + { + "date": "2026-03-03", + "name": "Voorbeeld Name 3" + } + ] } ] } diff --git a/openspec/changes/flow-business-timers/tasks.md b/openspec/changes/flow-business-timers/tasks.md index 539dbd4de6..9da6966bed 100644 --- a/openspec/changes/flow-business-timers/tasks.md +++ b/openspec/changes/flow-business-timers/tasks.md @@ -2,7 +2,7 @@ ## 1. Storage -- [ ] 1.1 Migration creating `openregister_flow_timers`, +- [x] 1.1 Migration creating `openregister_flow_timers`, `openregister_flow_timer_fires` and `openregister_flow_timer_events` with the columns in design.md — Data model. Indexes `or_flowtimer_due_idx (state, fire_at)`, @@ -14,12 +14,12 @@ matching `or_flowtrig_match_idx` (`lib/Migration/Version1Date20260810140000.php:98`). Additive only. Verify NO `overdue`, `is_overdue` or `days_overdue` column exists. -- [ ] 1.2 `lib/Db/FlowTimer.php` + `FlowTimerMapper`, `FlowTimerFire.php` + +- [x] 1.2 `lib/Db/FlowTimer.php` + `FlowTimerMapper`, `FlowTimerFire.php` + `FlowTimerFireMapper`, `FlowTimerEvent.php` + `FlowTimerEventMapper`. Mapper finders: due-by-`fire_at`, due-by-`next_rung_at`, by subject, by run, by lineage. The fire and event mappers expose insert and read only — no update, no delete path. -- [ ] 1.3 Seed descriptor under `lib/Settings/` + a `lib/Repair/SeedFlowTimerRegister` +- [x] 1.3 Seed descriptor under `lib/Settings/` + a `lib/Repair/SeedFlowTimerRegister` step registered in `appinfo/info.xml` beside the existing `Seed*` steps (`:159-170`), decoding the JSON and calling `ConfigurationService::importFromApp(force: false)` — NOT @@ -31,7 +31,7 @@ ## 2. Working calendar and SLA arithmetic -- [ ] 2.1 `lib/Service/Flow/Timer/WorkingCalendarService.php` — resolution +- [x] 2.1 `lib/Service/Flow/Timer/WorkingCalendarService.php` — resolution order timer → organisation → seeded default, throwing at arm time on an unknown name with the name in the message, and NO weekday-only fallback on any path. Rule kinds `fixed`, `easter` (computed, as @@ -41,7 +41,7 @@ `../shillinq/lib/Lifecycle/SubmissionWindowGuard.php:74-104`, which ends at `2027-12-26` and then degrades silently. `hoursPerWorkingDay` required. -- [ ] 2.2 `lib/Service/Flow/Timer/SlaCalculator.php` — `measure(from, to, unit)`, +- [x] 2.2 `lib/Service/Flow/Timer/SlaCalculator.php` — `measure(from, to, unit)`, `add(from, value, unit)`, `sub(from, value, unit)` over a resolved calendar for `hours`, `businessDays` and `calendarDays`; `{value, unit}` accepted only for integer `value` 1..10000. Non-working dates memoised @@ -49,14 +49,14 @@ ## 3. Timer lifecycle -- [ ] 3.1 `FlowTimerService::arm()` — resolves `anchor_event` + +- [x] 3.1 `FlowTimerService::arm()` — resolves `anchor_event` + `anchor_offset` to `anchor_at`, stores the anchor alongside the computed moment, validates the SLA and escalation rules, and REFUSES an `on_expiry` outcome on any timer whose `legal_effect` is not `wettelijk`. One private `recompute()` derives `fire_at` and `next_rung_at`; every mutating operation calls it and nothing else writes those two columns. -- [ ] 3.2 `suspend(reason, until?)` / `resume()` — `consumed_value += +- [x] 3.2 `suspend(reason, until?)` / `resume()` — `consumed_value += calendar.measure(running_since, now, budget_unit)`, `running_since` and `fire_at` to NULL, `suspended_since` set; resume re-projects from the resume instant. Both write an event row with actor, moment, reason and @@ -64,14 +64,14 @@ overdue. Do NOT copy procest's pre-extension model (`../procest/lib/Service/DeadlinePauseService.php:145-153`) — its `min($durationDays, $diff)` at `:148` silently eats an over-run pause. -- [ ] 3.3 `extend(amount, unit, rationale)` — increases `budget_value`, +- [x] 3.3 `extend(amount, unit, rationale)` — increases `budget_value`, requires a non-empty rationale, bounded by `extension_max` (default 1) with an error naming the bound, and REFUSED once `state` is `fired`, `cancelled` or `superseded`. The override is a separate, separately authorized method recorded as an override, mirroring `../procest/lib/Service/DeadlineExtensionService.php:126,228` — not a flag on `extend()`. -- [ ] 3.4 `supersede()` on a moved anchor — the prior row goes to +- [x] 3.4 `supersede()` on a moved anchor — the prior row goes to `superseded` and never fires; a successor carries `supersedes_uuid`, the recomputed `anchor_at`/`fire_at` and the predecessor's `consumed_value`, and inherits a fire row (marked `inherited`) for every @@ -80,14 +80,14 @@ ## 4. Escalation -- [ ] 4.1 `lib/Service/Flow/Timer/EscalationLadderService.php` — resolves the +- [x] 4.1 `lib/Service/Flow/Timer/EscalationLadderService.php` — resolves the ladder, computes each rung's instant, and CLAIMS a rung by inserting `(timer_uuid, rung_key)` BEFORE raising the transition; a duplicate key means another pass owns it. The fire row records the transition raised and its roles/priority, never "notified". A gap fires every passed unfired rung in ladder order, each once, and never collapses them into the most severe. -- [ ] 4.2 Escalation-rule validation — shape `{trigger, offset, offsetUnit, +- [x] 4.2 Escalation-rule validation — shape `{trigger, offset, offsetUnit, notifyRole, escalateToRole, openIncident}`, `offsetUnit` accepting `calendarDays` as well (`../procest/lib/Service/StepConfig/EscalationRuleValidator.php:53` is `['hours', 'businessDays']` today), refused without an SLA, and @@ -99,7 +99,7 @@ ## 5. Sweep and outcomes -- [ ] 5.1 `lib/BackgroundJob/FlowTimerWorker.php` extending `TimedJob` at +- [x] 5.1 `lib/BackgroundJob/FlowTimerWorker.php` extending `TimedJob` at `setInterval(seconds: 300)`, matching `lib/BackgroundJob/FlowScheduleWorker.php:59`. Two bounded range scans — `(state, fire_at)` for expiries and `(state, next_rung_at)` for rungs — each `LIMIT` batch (default 200), @@ -107,7 +107,7 @@ `../openconnector/lib/Service/ApprovalService.php:638-658` does under a docblock claiming the opposite (`:628-631`). Logged counts report work performed; a pass hitting the limit logs `truncated: true`. -- [ ] 5.2 Expiry outcomes applied as NAMED TASK ACTIONS through the task +- [x] 5.2 Expiry outcomes applied as NAMED TASK ACTIONS through the task service, claimed by a conditional `SET state='fired' WHERE uuid=? AND state='armed'` so zero affected rows means another pass owns it. All four of `skip`, `error`, `dead_letter`, `transition:` produce @@ -115,7 +115,7 @@ `error` fails it; the collapse at `ApprovalService.php:662` is the defect being corrected, not the behaviour being copied. A `wettelijk` breach is recorded permanently and survives completion. -- [ ] 5.3 Cancellation inside the transaction that makes the subject terminal, +- [x] 5.3 Cancellation inside the transaction that makes the subject terminal, extending `flow-task-entity`'s run-terminality listener — idempotent, recording `cancel_reason` and `cancelled_at`, never deleting. Plus a repair check that COUNTS armed timers whose subject is terminal or @@ -123,13 +123,13 @@ ## 6. Projection and derivation -- [ ] 6.1 `openregister_tasks.due_at` / `expires_at` maintained as a +- [x] 6.1 `openregister_tasks.due_at` / `expires_at` maintained as a projection inside every timer mutation — earliest non-cancelled `due` timer and earliest enforcing timer — so the inbox index `(assignee, is_terminal, due_at)` stays an index hit. The task write surface REFUSES those fields once a timer owns the subject; writing them never creates a timer. `suspended_until` is display-only. -- [ ] 6.2 Derived read API — overdue, time-remaining and time-overdue computed +- [x] 6.2 Derived read API — overdue, time-remaining and time-overdue computed on read as `state = 'armed' AND fire_at < now`, correct with the sweep disabled, with no field anywhere accepting an overdue write. `remaining = budget_value - consumed_value - (running_since ? @@ -138,14 +138,14 @@ ## 7. Tests and verification -- [ ] 7.1 Arithmetic and calendar unit tests: the 8-week / 19-days-elapsed / +- [x] 7.1 Arithmetic and calendar unit tests: the 8-week / 19-days-elapsed / 6-of-14-day-hersteltermijn case returning the remainder intact; a business-day term suspended over a weekend resuming with the same business days left; a 3-businessDays SLA armed on a Thursday landing on Tuesday; `nl-national` correct for several future years including a Koningsdag falling on a Sunday; both cross-unit `preBreach` scenarios; one case per `on_expiry` value asserting the four states differ. -- [ ] 7.2 Sweep, concurrency and invariant tests: a due timer beyond the batch +- [x] 7.2 Sweep, concurrency and invariant tests: a due timer beyond the batch size still processed; two overlapping passes firing a rung and an expiry exactly once each; a downtime gap firing the skipped rungs in order; a six-week timer surviving a restart; completion cancelling both timers diff --git a/scripts/l10n/locales/fr.json b/scripts/l10n/locales/fr.json index a407537cad..1853cf79e2 100644 --- a/scripts/l10n/locales/fr.json +++ b/scripts/l10n/locales/fr.json @@ -1,83 +1,85 @@ { - "registerNotMeasured": "Not measured. This file is a cognate record, not a register pass: there is no scripts/l10n/detectors/fr.js and nothing here measured the formal/informal register of the fr bundle, so selfcheck must not report a register verdict for fr.", - "scopeNote": "COGNATE RECORD for fr. Every value in l10n/fr.json that was byte-identical to the English source has been reviewed one by one: each is either recorded below with a written reason, or was replaced by a real translation in the same pass. The list is therefore closed - a NEW identical value in fr means a new judgement, not an existing licence.", - "cognates": { - "3": "Numeric literal; identical in every locale.", - "30": "Numeric literal; identical in every locale.", - "Action": "Same spelling and meaning in French (une action).", - "Actions": "Same spelling; the French plural is also 'Actions'.", - "Agents": "Same spelling and meaning in French (des agents).", - "Applications": "Same spelling and meaning in French.", - "Articles": "Same spelling and meaning in French.", - "Avatar": "International term, spelled and used identically in French.", - "CSV": "File-format acronym, invariant in French.", - "Code": "Same spelling and meaning in French (le code).", - "ConfigSet": "Apache SOLR's own object name; used untranslated in French SOLR documentation.", - "Configuration": "Same spelling and meaning in French.", - "Configurations": "Same spelling; the French plural is also 'Configurations'.", - "Contacts": "Same spelling and meaning in French.", - "Conversations": "Same spelling and meaning in French.", - "DSAR": "Acronym (Data Subject Access Request); used untranslated in French privacy practice.", - "Dates": "Same spelling and meaning in French.", - "Deck": "Nextcloud app name; kept untranslated in every finished locale.", - "Description": "Same spelling and meaning in French.", - "Documents": "Same spelling and meaning in French.", - "Excel (.xlsx)": "Product name plus a file extension; nothing translatable.", - "Expiration": "Same spelling and meaning in French.", - "Extensions": "Same spelling and meaning in French.", - "Extraction": "Same spelling and meaning in French.", - "Format": "Same spelling and meaning in French (le format).", - "ID": "Acronym, invariant in French.", - "Id": "The same acronym in the casing the source uses.", - "Index": "Same spelling and meaning in French (un index).", - "Local": "Same spelling in French as an adjective/label.", - "Maximum": "Same spelling and meaning in French (le maximum).", - "Minimum": "Same spelling and meaning in French (le minimum).", - "N/A": "Not-applicable abbreviation, kept as-is in every locale of this app.", - "Notes": "Same spelling and meaning in French (des notes).", - "Notifications": "Same spelling and meaning in French.", - "OpenCorporates": "Name of an external data source; a proper noun.", - "OpenDocument (.ods)": "Format name plus a file extension; nothing translatable.", - "OpenRegister": "The application's own name; a proper noun.", - "Organisation": "Same spelling in French with this orthography.", - "Organisations": "Same spelling; the French plural is also 'Organisations'.", - "PDF": "File-format acronym, invariant in French.", - "Photos": "Same spelling and meaning in French.", - "Port": "Same spelling and meaning in French (le port, réseau).", - "Public": "Same spelling and meaning in French (public).", - "Quota": "Same spelling and meaning in French (un quota).", - "RAG": "Retrieval-Augmented Generation acronym; used untranslated in French technical writing.", - "RBAC": "Access-control acronym; used untranslated in French technical writing.", - "Relations": "Same spelling and meaning in French.", - "Score": "Established loan in French; 'le score' is the standard term.", - "Secret": "Same spelling and meaning in French (un secret).", - "Simple": "Same spelling and meaning in French.", - "Slug": "URL-slug term, kept untranslated in French CMS and API tooling.", - "Source": "Same spelling and meaning in French (une source).", - "Sources": "Same spelling; the French plural is also 'Sources'.", - "Total": "Same spelling and meaning in French (le total).", - "Type": "Same spelling and meaning in French (le type).", - "URL": "Acronym, invariant in French.", - "Url": "The same acronym in the casing the source uses.", - "Version": "Same spelling and meaning in French (la version).", - "Webhook": "Established loan; the standard term in French technical UI language.", - "Webhooks": "Established loan; the French plural is also 'Webhooks'.", - "X-Custom-Header: value\nAuthorization: Bearer token": "Literal example of raw HTTP headers; header names are protocol tokens.", - "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal example of raw HTTP headers, with the newline written out; protocol tokens.", - "configuration(s)": "Same spelling in French, including the optional-plural parenthesis.", - "fw_...": "Literal example of an API-key prefix; not prose.", - "http://localhost:11434": "Literal example URL; not prose.", - "https://api.fireworks.ai/inference/v1": "Literal example URL; not prose.", - "https://api.your-dolphin-instance.com": "Literal example URL; not prose.", - "https://example.com/webhook": "Literal example URL; not prose.", - "messages": "Same spelling and meaning in French, in the casing the source uses.", - "myapp": "Literal example value for an app identifier; not prose.", - "objectType: object\naction: created": "Literal example of a YAML/header payload; the keys are protocol tokens.", - "objectType: object\\naction: created": "Literal example of a YAML/header payload, with the newline written out; protocol tokens.", - "org-...": "Literal example of an API-key prefix; not prose.", - "public": "Same spelling and meaning in French, in the casing the source uses.", - "sk-...": "Literal example of an API-key prefix; not prose.", - "{count} widget(s)": "'widget' is the French term too; the count and the optional-plural parenthesis are unchanged.", - "{property} - {other}": "Two placeholders and a separating dash; nothing translatable." - } + "registerNotMeasured": "Not measured. This file is a cognate record, not a register pass: there is no scripts/l10n/detectors/fr.js and nothing here measured the formal/informal register of the fr bundle, so selfcheck must not report a register verdict for fr.", + "scopeNote": "COGNATE RECORD for fr. Every value in l10n/fr.json that was byte-identical to the English source has been reviewed one by one: each is either recorded below with a written reason, or was replaced by a real translation in the same pass. The list is therefore closed - a NEW identical value in fr means a new judgement, not an existing licence.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "Action": "Same spelling and meaning in French (une action).", + "Actions": "Same spelling; the French plural is also 'Actions'.", + "Agents": "Same spelling and meaning in French (des agents).", + "Applications": "Same spelling and meaning in French.", + "Articles": "Same spelling and meaning in French.", + "Avatar": "International term, spelled and used identically in French.", + "CSV": "File-format acronym, invariant in French.", + "Code": "Same spelling and meaning in French (le code).", + "ConfigSet": "Apache SOLR's own object name; used untranslated in French SOLR documentation.", + "Configuration": "Same spelling and meaning in French.", + "Configurations": "Same spelling; the French plural is also 'Configurations'.", + "Contacts": "Same spelling and meaning in French.", + "Conversations": "Same spelling and meaning in French.", + "DSAR": "Acronym (Data Subject Access Request); used untranslated in French privacy practice.", + "Dates": "Same spelling and meaning in French.", + "Deck": "Nextcloud app name; kept untranslated in every finished locale.", + "Description": "Same spelling and meaning in French.", + "Documents": "Same spelling and meaning in French.", + "Excel (.xlsx)": "Product name plus a file extension; nothing translatable.", + "Expiration": "Same spelling and meaning in French.", + "Extensions": "Same spelling and meaning in French.", + "Extraction": "Same spelling and meaning in French.", + "Format": "Same spelling and meaning in French (le format).", + "ID": "Acronym, invariant in French.", + "Id": "The same acronym in the casing the source uses.", + "Index": "Same spelling and meaning in French (un index).", + "Local": "Same spelling in French as an adjective/label.", + "Maximum": "Same spelling and meaning in French (le maximum).", + "Minimum": "Same spelling and meaning in French (le minimum).", + "N/A": "Not-applicable abbreviation, kept as-is in every locale of this app.", + "Notes": "Same spelling and meaning in French (des notes).", + "Notifications": "Same spelling and meaning in French.", + "OpenCorporates": "Name of an external data source; a proper noun.", + "OpenDocument (.ods)": "Format name plus a file extension; nothing translatable.", + "OpenRegister": "The application's own name; a proper noun.", + "Organisation": "Same spelling in French with this orthography.", + "Organisations": "Same spelling; the French plural is also 'Organisations'.", + "PDF": "File-format acronym, invariant in French.", + "Photos": "Same spelling and meaning in French.", + "Port": "Same spelling and meaning in French (le port, réseau).", + "Public": "Same spelling and meaning in French (public).", + "Quota": "Same spelling and meaning in French (un quota).", + "RAG": "Retrieval-Augmented Generation acronym; used untranslated in French technical writing.", + "RBAC": "Access-control acronym; used untranslated in French technical writing.", + "Relations": "Same spelling and meaning in French.", + "Score": "Established loan in French; 'le score' is the standard term.", + "Secret": "Same spelling and meaning in French (un secret).", + "Simple": "Same spelling and meaning in French.", + "Slug": "URL-slug term, kept untranslated in French CMS and API tooling.", + "Source": "Same spelling and meaning in French (une source).", + "Sources": "Same spelling; the French plural is also 'Sources'.", + "Total": "Same spelling and meaning in French (le total).", + "Type": "Same spelling and meaning in French (le type).", + "URL": "Acronym, invariant in French.", + "Url": "The same acronym in the casing the source uses.", + "Version": "Same spelling and meaning in French (la version).", + "Webhook": "Established loan; the standard term in French technical UI language.", + "Webhooks": "Established loan; the French plural is also 'Webhooks'.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal example of raw HTTP headers; header names are protocol tokens.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal example of raw HTTP headers, with the newline written out; protocol tokens.", + "configuration(s)": "Same spelling in French, including the optional-plural parenthesis.", + "fw_...": "Literal example of an API-key prefix; not prose.", + "http://localhost:11434": "Literal example URL; not prose.", + "https://api.fireworks.ai/inference/v1": "Literal example URL; not prose.", + "https://api.your-dolphin-instance.com": "Literal example URL; not prose.", + "https://example.com/webhook": "Literal example URL; not prose.", + "messages": "Same spelling and meaning in French, in the casing the source uses.", + "myapp": "Literal example value for an app identifier; not prose.", + "objectType: object\naction: created": "Literal example of a YAML/header payload; the keys are protocol tokens.", + "objectType: object\\naction: created": "Literal example of a YAML/header payload, with the newline written out; protocol tokens.", + "org-...": "Literal example of an API-key prefix; not prose.", + "public": "Same spelling and meaning in French, in the casing the source uses.", + "sk-...": "Literal example of an API-key prefix; not prose.", + "{count} widget(s)": "'widget' is the French term too; the count and the optional-plural parenthesis are unchanged.", + "{property} - {other}": "Two placeholders and a separating dash; nothing translatable.", + "Date": "French 'date' is spelled identically; Nextcloud core fr renders it unchanged.", + "Message": "French 'message' is spelled identically; Nextcloud core fr renders it unchanged." + } } diff --git a/scripts/l10n/locales/lb.json b/scripts/l10n/locales/lb.json index 7b3d4a5d07..4d635d4f00 100644 --- a/scripts/l10n/locales/lb.json +++ b/scripts/l10n/locales/lb.json @@ -1,179 +1,180 @@ { - "register": "formal", - "registerEvidence": "FORMAL, 200 polite markers against 9 informal over 3321 translated values. Core is NOT a usable source for this locale and this is the dangerous shape rather than the obvious one: Nextcloud ships exactly ONE lb catalogue in the scanned roots (server/lib/l10n/lb.json, 72 values), so coreCatalogues('lb') does NOT throw the way it does for rm and mt — §5 step 2 appears to run and would report a verdict computed from zero markers. Measured: that catalogue contains 0 formal and 0 informal markers. So this is the §6.4 fallback, widened to the sibling apps' FRONTEND .js bundles as the mt pass did: openregister 1012 values (42 formal / 0 informal), opencatalogi 767 (62/0), openconnector 487 (26/2), launchpad 1055 (70/7). openregister's own bundle carries ZERO informal markers. All 4 informal values live in two OTHER apps and are real register slips there, not here: 3 in launchpad ('Du hues d'Limitt vun {limit} Dashboards erreecht' and two siblings) and 1 in openconnector ('Du hues nach keng vermëttelt Zougangsdaten'). Luxembourgish builds its V-form from the 2pl on the German Sie model — Dir / Iech / Ären — and it is live, current, ordinary usage: NOT archaic (contrast is, where yðar was abandoned in the 20th century) and NOT merely available-but-unused (contrast mt, where intom exists and is declined). A fifth distinct situation behind the same 'formal' label.", - "registerDetectorNote": "detectors/lb.js is the FIRST detector in the set whose fold() does not lowercase, and that is the whole design rather than an optimisation. Case is the only thing separating the two polarities here: lowercase `dir` is the informal 2sg DATIVE while capitalised `Dir` is the polite 2pl NOMINATIVE, and both are attested in this corpus (1 and 82 times respectively). A case-folding detector merges all 83 into one bucket. This is the da/nb De/Dem/Deres situation (§8.2) with a sharper edge, because there the collision is with a third-person pronoun rather than with the familiar form of the same paradigm. The residual hole is recorded in UNDETECTABLE: a value that OPENS with the informal dative takes a sentence-initial capital and is then indistinguishable. All 11 sentence-initial `Dir` in the corpus are polite, each followed by a 2pl verb, so the cost is currently theoretical.", - "registerTraps": "Two exclusions, both measured rather than reasoned. (1) THE MODALS SYNCRETISE 1sg/2sg/3sg, so bare `muss` and `weess` carry no address information whatever — 'du muss' and 'hie muss' are spelled identically. All 15 occurrences of bare `muss` in the corpus are 3sg ('De Slug muss…', 'D'Tabell muss…', 'LLM muss…', 'publishAt muss…'); not one is 2sg. Both are excluded while the regularly inflected 2sg of the SAME verbs (kanns, wëlls, sollst) is kept, which makes this a PARTIALLY-detectable paradigm in the bg/is sense — but split by LEXICAL class (the modals) rather than by conjugation class, which is a third way for that split to fall. (2) `-t` is the 2pl ending AND the 3sg present ending, so `kënnt` ('you can' / 'he comes', 3sg of kommen) and `braucht` ('you need' / 'he needs') are both excluded from the formal list; the 2pl forms kept are only those whose 3sg is spelled differently — hutt/huet, sidd/ass, musst/muss, gitt/gëtt, maacht/mécht. The 2sg IMPERATIVE is excluded wholesale, and note this is NOT forced by the label convention the way it is in ca/et/hr/sl/sr/ga/mt: §6.5 test 1 comes out NO here because labels are infinitives. It fails test 2 alone — the bare stem is also an ordinary noun for the productive verbs (`Späicher` = storage/loft, `Filter`, `Test`). Cheap to give up: every informal slip actually shipped in this app family is a 2sg INDICATIVE, never an imperative.", - "politenessFormulaNote": "Checked for the free signal mt's `jekk jogħġbok` provided, and it is NOT there. Luxembourgish `wann ech glift` / `w.e.g.` ('please') is literally 'if I please' and inflects for the SPEAKER, not the addressee, so it is register-neutral despite 21 occurrences. Same negative as is `vinsamlegast` (an adverb) and ga `le do thoil`. Three of the four locales checked so far come out empty — keep checking, since the payoff when it lands is large, but do not expect it.", - "buttons": "INFINITIVE, register-neutral — 64 infinitives against 0 imperatives (2sg or 2pl) and 0 verbal nouns, resolved over 59 bare action keys across all four app bundles plus core's single lb catalogue. Späicheren, Läschen, Ofbriechen, Beaarbechten, Erstellen, Aktualiséieren, Ewechhuelen, Kopéieren, Eroflueden, Eroplueden, Verëffentlechen, Deelen, Validéieren. Luxembourgish distinguishes the four candidate forms cleanly — infinitive -en, 2pl imperative -t, 2sg imperative bare stem, verbal noun -ung — so this is a real measurement and not an artefact of syncretism. The remainder are legitimately not verbs: adverbs (Zréck, Weider, Virdrun) and nouns (Test, Usiicht, Import, Export, Filter), the same shape as cs's Zpět/Storno. Joins cs lt lv sk rm is in the infinitive row (§7.3). Infinitive buttons must NOT be 'corrected' to imperatives.", - "pluralNote": "nplurals=2, plural=(n != 1). NO disagreement of any kind: runtime-check confirms the header and @nextcloud/l10n's getPlural agree on which index every count selects, and both declared forms are reachable. This is the plain `n != 1` shape §7.1 predicts to be safe, and it is — unlike is and mk, whose two-form headers are MODULAR and therefore disagree with the library's coarse `number === 1` grouping. The one pre-existing array is correct and instructive: '_%n entry has no hash yet_' -> ['%n Antrag huet nach kee Hash', '%n Anträg hunn nach kee Hash'] agrees in BOTH the noun (Antrag/Anträg, umlaut plural) and the verb (huet/hunn), so an array here cannot be built by swapping the noun alone.", - "orthographyNote": "A FIFTH §8.10 outcome, and the first where the answer is that there is no choice to make: Luxembourgish capitalises ALL nouns as a rule of orthography, exactly as German does, so domain-term capitalisation is GRAMMATICALLY FORCED rather than a house convention. Measured anyway and it is unanimous — Objet 49:0, Datei 27:0, Usiicht 24:0, Schema 25:1, Webhook 23:0, Register 20:1, Eegeschaft 15:0, Benotzer 12:0, Flux 11:0, Entitéit 9:0, Andréi 14:0. Per §8.10 the CONDITIONED measurement was run as well even though the unconditioned one already looked one-sided, and it changes nothing: under title-cased English keys the terms capitalise 39:1, under prose keys 172:2, so this is not ga's mirror-the-source convention wearing a disguise. The four apparent exceptions are all false positives, which is worth recording so nobody 'fixes' them: `{register}` and `{schema}` are PLACEHOLDERS, not words, and the 14 lowercase `filteren` are the VERB (the noun Filter is capitalised 25:0). So the outcomes now seen are: a list of capitalised terms (sr, rm, mt), mirror the source (ga), flat lowercase (is), and forced-by-orthography (lb). Flat uppercase remains unobserved.", - "typographyNote": "Heavy compound hyphenation is the dominant convention: 234 values carry an internal Letter-hyphen-Capital compound against 3 in the English source — Audit-Trail-Andréi, Sich-Trail-Detailer, Webhook-Liwwerungsprotokoller, Metadonnéeën-Filteren, Objet-Usiichten. Luxembourgish compounds like German but hyphenates where a loanword or proper noun is a member, which is most of this app's vocabulary. The elided article `d'` takes a STRAIGHT apostrophe (51 values, 0 typographic ’) — d'Objeten, d'Datei, d'Astellungen. Ellipsis follows the English source per key rather than a house rule (38 '...' against 1 '…', mirroring en's 77 and 2); note the separate Loading... / Loading… duplicate-key defect in §10. No en dash; 6 em dashes, all mirroring the source. No double quotes at all where en has 9.", - "eifelerNote": "THE EIFELER REGEL (n-deletion) is the single biggest grammatical hazard in writing this locale and no gate can see it. A word-final -n/-nn is DELETED before a consonant other than n, d, t, z, h, and KEPT before those five and before any vowel. It is genuinely in force in this bundle — Keng Usiichte fonnt, Eegeschafte filteren, Statistike filteren, Mëll geläscht Artikele filteren — so it must be applied to every value written. Measured against the bundle's OWN practice per lemma rather than asserted from the grammar (the §8.10 method applied to morphology), which is what makes it actionable: 16 lemmas keep the -n before a delete-consonant and 5 do it BOTH ways. Two exemption classes must be encoded or the check is pure noise: (a) stem-final -n is not inflectional, so the -ioun/-ion/-oun nouns keep it always — Aktioun kann, Konfiguratioun gespäichert, Applikatioun läschen and Restriktioun feelt are all CORRECT, and they were 25 of the first 95 raw hits; (b) non-integrated loans (Token, JSON, Login, Session) and the short function words (wann, dann, schonn, kann, sinn, hunn, ginn). The rule applies to the nominalised INFINITIVE exactly as it does to plural nouns, and getting that wrong cost this pass a whole round. MEASUREMENT TRAP, recorded because it is the reusable part: the first check compared each lemma ONLY AGAINST ITSELF, so `Lueden` (which only ever appears with the -n) and `Deele` (which only ever appears without it) each looked internally consistent, and the cross-lemma picture never surfaced. That artefact was then read as evidence of a grammatical exception for infinitives, and 26 values were left uncorrected on the strength of a '16:0 with no counter-example' count that was really one copy-pasted phrase. Measuring the WORD CLASS across all lemmas instead shows the family does both — 118 kept against 108 deleted — with directly parallel pairs: `Lueden vum` and `Späicheren feelgeschloen` keep it, while `Deele vun`, `Erofsetze vun`, `Migréiere vum`, `bäisetze wëllt`, `ausféiere kënnt` and `zesummeféiere wann` delete it. So a per-lemma consistency check is necessary but NOT sufficient: a lemma that occurs in only one environment carries no information, and a pile of such lemmas looks like a convention. Aggregate by word class before concluding anything. TRUE RESIDUE: 2 values, both pre-existing, both a bare `sinn`/`ginn` infinitive governed by a preceding modal ('muss aktivéiert sinn mat', 'kënne erëmgewonne ginn wann'), where reducing to `si`/`gi` collides with the pronouns and the family supplies no directly parallel example either way. §3.8 governs those two. Everything else was corrected: 75 violations in the pre-existing half, 44 more in the half written during this pass, 48 in the follow-up round that this note exists to explain.", - "lexiconNote": "Files -> Datei/Dateien, a DELIBERATE divergence from core, recorded because an unrecorded 'left alone' is indistinguishable from 'never looked' (§6.9). core-diff flags it: core lb renders Files as `Fichieren`, the French-derived form, in its single catalogue. The app family is unanimous the other way — 111 uses of Datei* across openregister (48), opencatalogi (36), launchpad (23) and openconnector (4), against 0 of Fichier* anywhere. §3.5 settles it decisively and no owner ask was needed: this is not the is `skrá` case, where the app's primary noun collided with itself and produced byte-identical renderings of distinct keys. The other core-diff disagreement goes the same way: Email -> `E-Mail` (bundle) against core's `Email`, and the hyphenated form is correct Luxembourgish orthography.", - "cognates": { - "3": "Numeric literal; identical in every locale.", - "30": "Numeric literal; identical in every locale.", - "All": "Luxembourgish 'all' is spelled exactly like the English quantifier and is the form this bundle already uses in All Aktiounen / All Kategorien / All Dateien. 30 distinct values across the 37 locales, so translatable and merely identical here.", - "All Webhooks": "Both members are unchanged in Luxembourgish: 'all' is the native quantifier and 'Webhook' is the borrowed term this app family uses throughout. 34 distinct values across the locales.", - "Avatar": "Standard in Luxembourgish as in its neighbours. 14 distinct values across the locales, so translatable in principle — but §8.4 warns core et ships a two-word gloss unfit for a label, which is what a forced translation would produce here.", - "Code": "Luxembourgish 'Code' is the ordinary word, borrowed from French. 19 distinct values across the locales. Distinct from Status-Code, which the bundle compounds.", - "CSV": "A standardised format acronym, unchanged in Luxembourgish. This is the one entry whose §3.3 measurement returns a SINGLE distinct value across all 37 locales, which is normally the signal to UNWRAP rather than record — but §3.2 names CSV as the canonical example of a genuine cognate, and it renders as a user-facing format choice rather than a placeholder or product name, so it stays wrapped.", - "Deck": "The proper name of the Nextcloud Deck app, used as a section label. §3.3 returns a SINGLE distinct value across all 37 locales, i.e. nobody translates it — the §8.5 'product names keep the English' rule rather than a translation decision.", - "Driver": "Luxembourgish IT register borrows 'Driver'; German Treiber has no established Luxembourgish form and 'Dreiwer' would be a coinage nobody writes. Recorded with the tension visible: §3.3 finds 21 distinct values, and the cs pass classified its own identical 'Driver' as FILLER because core cs renders it ovladače databází. This is therefore the weakest entry in this list — challenge it if a native speaker disagrees.", - "DSAR": "An established acronym (Data Subject Access Request), kept as-is in Luxembourgish GDPR practice. §3.3 returns a single distinct value across all 37 locales.", - "Excel (.xlsx)": "A product name plus its file extension, offered as an export-format choice alongside CSV. Single distinct value across all 37 locales.", - "Status": "Identical in Luxembourgish. 18 distinct values across the locales, and the bundle compounds the same stem as Extraktiounsstatus, Erfollegsstatus, Qualitéitsstatus and pluralises it as 'All Statusen'.", - "Total": "Identical in Luxembourgish. 26 distinct values across the locales; the bundle already ships Total Objeten, Total Memberen, Total Gréisst and the plural Totaler.", - "Total:": "As 'Total', with the colon the source supplies. 27 distinct values across the locales.", - "URL": "A standardised acronym, unchanged in Luxembourgish. Only 2 distinct values across all 37 locales. The bundle compounds it as Datebank-URL, Basis-URL and GitLab-URL. Note the sibling key 'Url' is NOT a cognate — it is normalised to 'URL', the same casing defect the cs pass corrected.", - "UUID:": "One of the two strings §3.3 names as a trap for looking untranslatable while not being so — French inserts a space before the colon, so the key genuinely varies (2 distinct values across the locales). Luxembourgish follows German spacing and takes no space, hence identical.", - "Webhook": "The app family's borrowed term, unchanged in Luxembourgish. 12 distinct values across the locales, and this bundle already carries 23 occurrences in compounds such as Webhook-Liwwerung and Webhook-Protokoller.", - "Webhooks": "Plural of the borrowed 'Webhook', which Luxembourgish forms without a suffix here. 21 distinct values across the locales. Already shipped in 'Zréck zu de Webhooks' and 'Webhooks fir evenementgesteiert Integratioune verwalten'.", - "Workflows": "The app family's borrowed term. 28 of the 37 locales translate it, but the bundle already ships 'Verfügbar Workflows', 'Workflows aktualiséieren' and n8n-Workflow-Integratioun — §3.5, the file wins on lexicon.", - "{property} - {other}": "The second string §3.3 names as a trap: it looks like pure punctuation but genuinely varies, because ru substitutes an em dash and hr an en dash (3 distinct values across the locales). Luxembourgish keeps the plain hyphen the source has — the §8.10 typography measurement of this bundle found NO en dash anywhere and only source-mirroring em dashes.", - "OpenDocument (.ods)": "A product name plus its file extension, offered as an export-format choice. Single distinct value across all 37 locales.", - "Parallel:": "Luxembourgish 'parallel' is spelled identically; the colon is supplied by the source. 25 distinct values across the locales. The bundle already ships 'Parallelle Modus'.", - "PDF": "A standardised format acronym, unchanged in Luxembourgish. Single distinct value across all 37 locales, the same shape as CSV.", - "Port": "Luxembourgish keeps 'Port' for a network port, as German and French do. 13 distinct values across the locales.", - "RBAC": "An established acronym (role-based access control), kept as-is. Single distinct value across all 37 locales.", - "Register": "The app's primary noun, and genuinely identical in Luxembourgish. 24 of the 37 locales translate it, so this is a §3.5 lexicon decision resting on the bundle's own unanimous practice — Register appears capitalised mid-sentence 20:0 and compounds as Registerdonnéeën, Registerstatistiken, Dateregistere and Registeriwwerbléck. NOT the is `skrá` situation: nothing else in this bundle renders as Register, so no two distinct English keys collide.", - "Register / Schema": "Both members are cognates in Luxembourgish (see the separate Register and Schema entries); the slash is the source's. 27 distinct values across the locales.", - "Register:": "As 'Register', with the colon the source supplies. 21 distinct values across the locales.", - "Repository": "Luxembourgish IT register keeps 'Repository'. 28 distinct values across the locales, but the bundle already ships 'Pad am Repository' and 'E Repository auswielen', so this is the established term here.", - "Schema": "Identical in Luxembourgish, as in German. 19 of the 37 locales translate it; the bundle's own practice is unanimous — Schema capitalised mid-sentence 25:1 (the single exception being the {schema} PLACEHOLDER, not a word), compounding as Schemadonnéeë, Schemastatistiken, Schematitelen and Dateschemaen. Plural Schemaen.", - "Schema:": "As 'Schema', with the colon the source supplies. 17 distinct values across the locales.", - "Score": "Luxembourgish borrows 'Score' (masculine). 25 distinct values across the locales, and the bundle already compounds it as Vertrauensscore, Qualitéitsscore and 'Duerchschnëttleche Score'.", - "Host *": "Luxembourgish IT register keeps 'Host' for a server host, as German and French do. 20 distinct values across the locales, most of them transliterations rather than real translations. The trailing ' *' is the required-field marker the source supplies.", - "ID": "The standard abbreviation, unchanged in Luxembourgish. 9 distinct values across the locales, essentially ID / Id / a Cyrillic transliteration. Note the sibling key 'Id' is NOT a cognate here — it is normalised to 'ID', which is the casing defect the cs pass also found.", - "ID:": "As 'ID', with the colon the source supplies. 10 distinct values across the locales.", - "ID: {id}": "As 'ID', with the colon and placeholder the source supplies. 6 distinct values across the locales.", - "Linear": "Luxembourgish 'linear' is spelled identically; capitalised here because it is a standalone option label and Luxembourgish capitalises the nominalised form. The bundle already ships the adverb in 'D'Verzögerunge klamme linear'. 27 distinct values across the locales.", - "Maximum": "A Latin loan, identical in Luxembourgish. 15 distinct values across the locales. Corroborated by the bundle's own 'Exklusivt Maximum' and 'Maximalwäert', which inflect the same stem.", - "Method": "Luxembourgish 'Method' (feminine) is spelled identically to the English. Well evidenced inside this bundle rather than assumed: it already ships HTTP-Method, Sichmethod and 'Aktiv Method: {active}'. 24 distinct values across the locales.", - "Minimum": "A Latin loan, identical in Luxembourgish. 15 distinct values across the locales. Corroborated by the bundle's own 'Exklusivt Minimum' and 'Minimalwäert'.", - "Multitenancy": "The app family's term for the feature, and this bundle already compounds it as Multitenancy-Astellungen. 28 of the 37 locales translate it, so this is a §3.5 lexicon decision rather than an untranslatable string.", - "Format": "Luxembourgish 'Format' is the ordinary word, identical to the English. 16 distinct values across the locales, so translatable in principle and merely identical here. The bundle already compounds it as Exportformat and Uweisungsformat.", - "Dashboard": "Kept deliberately against the majority: 31 of the 37 locales translate this, but the whole app family's Luxembourgish vocabulary is built on the borrowed 'Dashboard' (launchpad's lb bundle uses it throughout, and openregister already ships Dashboard aktualiséieren). §3.5 — the file wins on lexicon.", - "N/A": "Abbreviation shown as-is; not translated in this UI.", - "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", - "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", - "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", - "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", - "OpenCorporates": "Proper noun — a third-party service name.", - "OpenRegister": "Proper noun — this application's own name.", - "http://localhost:11434": "A literal example URL shown verbatim; not prose.", - "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", - "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", - "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", - "fw_...": "A literal API-key prefix example shown verbatim; not prose.", - "sk-...": "A literal API-key prefix example shown verbatim; not prose.", - "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", - "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", - "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", - "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", - "Host": "borrowed from English unchanged in Luxembourgish IT usage, the same class as the bundle's Token, Webhook and Login. No native form exists and the German Wirt is not used in this sense.", - "Index": "Index is an ordinary Luxembourgish noun (plural Indexen) and, like every noun in this locale, is capitalised — so the value coincides with the English source rather than being untranslated.", - "Provider": "borrowed unchanged; Luxembourgish uses Provider for a service provider in IT contexts, and the bundle already carries it in the Provider-prefixed compound keys.", - "Register #{id}": "Register is the Luxembourgish word for a register and is capitalised as every noun is, and the rest of the value is a literal # plus a placeholder — so nothing in it is translatable. The sibling key Object #{id} does differ, because Object becomes Objet.", - "Shards": "a SOLR-specific English term with no Luxembourgish equivalent, borrowed unchanged alongside the bundle's other search-engine vocabulary.", - "Uptime:": "borrowed unchanged; Luxembourgish IT usage takes Uptime from English and the trailing colon is part of the label, so the whole value coincides with the source.", - "App": "App is the ordinary Luxembourgish noun for an application (plural Appen) and is capitalised as every noun is, so the value coincides with the English source." - }, - "correctionCodes": "EIFELER-DEL = a word-final -n that must be DELETED before the following consonant and was not. EIFELER-KEEP = a word-final -n that must be KEPT (before a vowel, or n/d/t/z/h) and was wrongly dropped; the bundle got the rule wrong in BOTH directions, which is why there are two codes. CAPITAL-POLITE = the polite possessive Är-/Ären written lowercase, against the sibling bundles' 50:0. AGREEMENT = wrong gender agreement. TYPO. GERMANISM = a German form where this bundle's own vocabulary is Luxembourgish. TERM-AUDIT = Auditprotokoll for *audit trail*, against 24 uses of Audit-Trail. CONSISTENCY = breaks a pattern the bundle otherwise holds unanimously. GRAMMAR = a malformed clause. 77 corrections over 1011 pre-existing translated values (7.6%), which sits between cs (5.5%) and ca (6.2%) rather than near is (22%) — this is a healthy locale whose single systematic weakness is the Eifeler Regel.", - "corrections": { - "Configure parameters for object vectorization. This process will generate vector embeddings for all objects matching your view filters.": "CAPITAL-POLITE: ären -> Ären", - "There are no audit trail entries matching your current filters.": "CAPITAL-POLITE: ären -> Ären", - "There are no search trail entries matching your current filters.": "CAPITAL-POLITE: ären -> Ären", - "There are no deleted items matching your current filters.": "CAPITAL-POLITE: ären -> Ären", - "There are no webhook log entries matching your filters.": "CAPITAL-POLITE: äre -> Äre", - "Vectors will be stored in your existing object and file collections": "CAPITAL-POLITE: ären -> Ären", - "Manage and restore soft deleted items from your registers": "CAPITAL-POLITE: äre -> Äre", - "No properties match your filters.": "CAPITAL-POLITE äre -> Äre, and EIFELER-KEEP: Eegeschafte -> Eegeschaften before the vowel of entspriechen", - "Load advanced filters with live data from your search index": "CAPITAL-POLITE: ärem -> Ärem", - "Choose which views to include in the vectorization process. Leave empty to process all views based on your configuration.": "CAPITAL-POLITE: ärer -> Ärer", - "No views match your search": "CAPITAL-POLITE ärer -> Ärer, and EIFELER-KEEP: Usiichte -> Usiichten before the vowel of entspriechen", - "Custom HTTP headers (one per line, format: Header-Name: value)": "EIFELER-DEL: een -> ee before pro", - "Filter webhook triggers by payload properties (one per line, format: key: value)": "EIFELER-DEL: een -> ee before pro", - "menu or contact someone with permission to create agents.": "EIFELER-DEL: een -> ee before mat", - "Defaults for every flow on this instance. A flow can override each of these for itself; a flow that overrides none of them follows the values set here, including later changes.": "EIFELER-DEL: relative pronoun deen -> dee before keen", - "Use filters to narrow down deleted items by register, schema, deletion date, or user who deleted them.": "EIFELER-DEL: Filteren -> Filtere before fir, and relative pronoun deen -> dee before se", - "Failed to load register data": "EIFELER-DEL: den -> de before Registerdonnéeë; and in the second round Lueden -> Luede before vun", - "File vectorization started. Check the statistics section for progress.": "EIFELER-DEL: den -> de before Statistik-Beräich", - "Choose which file types to include in the vectorization process. Only files with extracted text and chunks will be processed.": "EIFELER-DEL: Dateien -> Dateie before mat, and finite ginn -> gi before verschafft", - "No filters are currently active. This will delete ALL audit trail entries!": "EIFELER-DEL: finite sinn -> si before keng", - "Objects will be soft-deleted (marked as deleted but kept in database). They can be recovered later if needed.": "EIFELER-DEL: finite ginn -> gi before mëll. The second ginn is a passive INFINITIVE after kënne and correctly keeps its -n", - "Text chunks are generated during file extraction and stored in the database. Vectorization reads these pre-chunked files and converts them to embeddings.": "EIFELER-DEL: finite ginn -> gi before während, and conjunction an -> a before wandelt", - "Counting objects...": "EIFELER-DEL: Objeten -> Objete and finite ginn -> gi before gezielt. Two adjacent violations in one three-word value", - "Configure how database objects are converted into vector embeddings for semantic search. Objects are directly vectorized without needing text extraction.": "EIFELER-DEL: preposition an -> a before Vektor-Embeddings, Objeten -> Objete, finite ginn -> gi before direkt", - "Cannot delete: objects are still attached": "EIFELER-DEL: Objeten -> Objete before sinn", - "Clear all filters": "EIFELER-DEL: Filteren -> Filtere before läschen", - "Configure how objects are converted to text before vectorization. These settings affect search quality and context.": "EIFELER-DEL: Objeten -> Objete before viru", - "Delete Objects": "EIFELER-DEL: Objeten -> Objete before läschen", - "Filter Objects": "EIFELER-DEL: Objeten -> Objete before filteren", - "Validate Objects": "EIFELER-DEL: Objeten -> Objete before validéieren", - "objects processed": "EIFELER-DEL: Objeten -> Objete before verschafft", - "Failed to load Nextcloud groups": "EIFELER-DEL: Gruppen -> Gruppe before feelgeschloen; and in the second round Lueden -> Luede before vun. den correctly keeps its -n before Nextcloud", - "Save changes": "EIFELER-DEL: Ännerungen -> Ännerunge before späicheren, matching the bundle's own Ännerunge kopéieren", - "Calculate Sizes": "EIFELER-DEL: Gréissten -> Gréisste before berechnen", - "Hide Filters": "EIFELER-DEL: Filteren -> Filtere before verstoppen", - "Hide in forms": "EIFELER-DEL: Formularen -> Formulare before verstoppen", - "Loading schemas...": "EIFELER-DEL: Schemaen -> Schemae before gi", - "Loading sources...": "EIFELER-DEL: Quellen -> Quelle before gi", - "Manage document templates and themes": "EIFELER-DEL: Themen -> Theme before verwalten", - "Members of selected groups can access this view": "EIFELER-DEL: Memberen -> Membere before vun", - "Generate recommendations and confidence scores": "EIFELER-DEL: Vertrauensscoren -> Vertrauensscore before generéieren", - "Use filters to narrow down audit trail entries by register, schema, action type, user, date range, or object ID.": "EIFELER-DEL: Filteren -> Filtere before fir", - "Use filters to narrow down search trail entries by register, schema, success status, user, date range, search terms, or performance metrics.": "EIFELER-DEL: Filteren -> Filtere before fir", - "Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.": "EIFELER-DEL: Clienten -> Cliente before kréien", - "Failed to load entities": "EIFELER-DEL: Entitéiten -> Entitéite before feelgeschloen; and in the second round Lueden -> Luede before vun", - "Failed to load files": "EIFELER-DEL: Dateien -> Dateie before feelgeschloen; and in the second round Lueden -> Luede before vun", - "Auto-retry failed vectorizations": "EIFELER-DEL: participle Feelgeschloen -> Feelgeschloe before Vektoriséierungen", - "Automatically retry failed vectorization attempts (max 3 retries)": "EIFELER-DEL: participle Feelgeschloen -> Feelgeschloe before Vektoriséierungsversuche", - "Optional webhook secret for signature verification": "AGREEMENT: Geheimnis is neuter, so the attributive adjective takes -t; Optionalen -> Optionalt (which also resolves the EIFELER-DEL before Webhook)", - "Serial Mode (Safer, slower)": "EIFELER-DEL: Seriellen -> Serielle before Modus", - "Maximum number of retry attempts for failed deliveries": "EIFELER-DEL: participle feelgeschloen -> feelgeschloe before Liwwerungen", - "How to handle retries for failed webhook deliveries": "GRAMMAR: 'Wéi mat den Neiversuche ... fonctionéiert gëtt' is not a well-formed clause and fonctionéiert is the FRENCH spelling (GERMANISM's mirror image) where this bundle writes funktionéieren. Rewritten with verfuer gëtt, plus EIFELER-DEL feelgeschloen -> feelgeschloe", - "View and analyze search trail logs with advanced filtering and analytics capabilities": "EIFELER-DEL: erweiderten -> erweiderte before Filter-, matching the sibling key which already has erweiderte Filterfäegkeeten", - "Add schema titles, descriptions, and register information to provide richer context for search": "EIFELER-DEL: räicheren -> räichere before Kontext", - "Delays double with each attempt (2, 4, 8 minutes...)": "EIFELER-DEL: finite verduebelen -> verduebele before sech", - "How deep to traverse nested object properties (1-20). Higher values capture more detail but increase vector size.": "EIFELER-DEL: finite erfaassen -> erfaasse before méi", - "LLM settings updated successfully": "EIFELER-KEEP: Astellunge -> Astellungen before the vowel of erfollegräich", - "No entities have been detected yet": "EIFELER-KEEP: goufe -> goufen before nach (n is a keep-consonant) and Entitéite -> Entitéiten before the vowel of erkannt. The sibling key 'No files have been extracted yet' already writes goufen nach correctly", - "No views found. Create views first before configuring vectorization.": "EIFELER-KEEP: the second Usiichte -> Usiichten before ier. The first correctly deletes before fonnt", - "Select registers and schemas to save a view": "EIFELER-KEEP Schemae -> Schemaen before the vowel of auswielen; and in the second round EIFELER-DEL auswielen -> auswiele before fir", - "Update vectors when object data changes (recommended for accurate search)": "EIFELER-KEEP: Vektore -> Vektoren before the vowel of aktualiséiere", - "No webhooks have been configured yet": "EIFELER-KEEP: goufe -> goufen before nach", - "No request body available": "EIFELER-KEEP: Kee -> Keen before the vowel of Ufro-Kierper. The sibling key 'No response body available' already writes Keen Äntwert-Kierper correctly", - "Include related object references": "EIFELER-KEEP: Verbonne -> Verbonnen before the vowel of Objet-Referenzen", - "Write an audit-trail entry for every step": "EIFELER-KEEP E -> En before the vowel, and TERM-AUDIT: Auditprotokollantrag -> Audit-Trail-Andrag, the form used by 'This audit trail entry does not contain any change information'", - "Your OpenAI API key. Get one at": "EIFELER-KEEP: Äre -> Ären before the vowel of OpenAI. The Fireworks sibling key correctly writes Äre before a consonant", - "Generate vectors immediately when new objects are created": "EIFELER-DEL: Vektoren -> Vektore before wann", - "Estimated Duration:": "AGREEMENT: Dauer is feminine, so Geschätzten -> Geschätzte, matching the sibling Geschätzte Batchen / Geschätzte Käschten", - "Old Value": "AGREEMENT: Wäert is masculine, not neuter, so the neuter Aalt is wrong; Ale Wäert, matching the sibling key New Value -> Neie Wäert", - "Exclusive Maximum": "TYPO: Exklusiivt -> Exklusivt. The doubled i is not Luxembourgish orthography and the bundle writes Aktiv with one i", - "Exclusive Minimum": "TYPO: Exklusiivt -> Exklusivt", - "Most Active Objects": "TYPO: Aktiivst -> Aktivst, same doubled i, against the bundle's own Aktiv", - "This change breaks the existing data model:": "GERMANISM: bestehend is German; this bundle's word for 'existing' is existéierend (Existéierend Verbesserungen, existéierend an zukënfteg Usiichten, ären existéierenden Objet-Sammlunge)", - "Could not read the seal coverage of the audit trail.": "TERM-AUDIT: Auditprotokoll -> Audit-Trail", - "Recomputes every hash and compares it with the one stored. This reads the whole audit trail, so it is run on request rather than each time this page opens.": "TERM-AUDIT: Auditprotokoll -> Audit-Trail", - "An entry with no hash is one the chain cannot vouch for. A background job sweeps these every five minutes and seals the oldest first, so a backlog after heavy write activity is normal and drains on its own. A backlog that never shrinks is not — it means sealing is failing on both the write path and the sweep.": "EIFELER-DEL (second round): buergen -> buerge before kann, Schreiwen -> Schreiwe before wéi, Duerchgoen -> Duerchgoe before feelschléit", - "Automatically create a default organisation if none exists when the app is initialized": "EIFELER-DEL (second round): erstellen -> erstelle before wann", - "Error loading application": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Error loading audit trails": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Error loading entity": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Error loading search trails": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Failed to download API specification": "EIFELER-DEL (second round): Eroflueden -> Erofluede before vun", - "Failed to load entity": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Failed to load extraction data": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Failed to load LLM configuration": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Failed to load organisations": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Failed to load templates": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Failed to load webhooks": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Failed to load workflows": "EIFELER-DEL (second round): Lueden -> Luede before vun", - "Failed to test webhook": "EIFELER-DEL (second round): Testen -> Teste before vum", - "Select a Nextcloud group to add": "EIFELER-DEL (second round): auswielen -> auswiele before fir", - "This has two possible causes and they are not equally serious: the entry was altered after it was written, or it was sealed before the seal lock existed and chained onto the wrong predecessor. Read the entry before deciding. Repair is a deliberate operator action — it rewrites stored hashes, which is exactly the event this chain exists to make visible — so it is only available as a command:": "EIFELER-DEL (second round): Schreiwen -> Schreiwe before geännert", - "Starting...": "CONSISTENCY: the bare noun Start... against nine sibling progressives that all use 'Gëtt ...' (Gëtt gespäichert, Gëtt getest, Gëtt gelueden, Gëtt analyséiert, Gëtt erstallt, Gëtt geläscht, Gëtt verschafft, Gëtt verëffentlecht, Gëtt nei probéiert). Sole outlier of ten", - "Loading organisations...": "CONSISTENCY: used U+2026 … where the other 38 values in this bundle use three dots, mirroring the English source" - } + "register": "formal", + "registerEvidence": "FORMAL, 200 polite markers against 9 informal over 3321 translated values. Core is NOT a usable source for this locale and this is the dangerous shape rather than the obvious one: Nextcloud ships exactly ONE lb catalogue in the scanned roots (server/lib/l10n/lb.json, 72 values), so coreCatalogues('lb') does NOT throw the way it does for rm and mt — §5 step 2 appears to run and would report a verdict computed from zero markers. Measured: that catalogue contains 0 formal and 0 informal markers. So this is the §6.4 fallback, widened to the sibling apps' FRONTEND .js bundles as the mt pass did: openregister 1012 values (42 formal / 0 informal), opencatalogi 767 (62/0), openconnector 487 (26/2), launchpad 1055 (70/7). openregister's own bundle carries ZERO informal markers. All 4 informal values live in two OTHER apps and are real register slips there, not here: 3 in launchpad ('Du hues d'Limitt vun {limit} Dashboards erreecht' and two siblings) and 1 in openconnector ('Du hues nach keng vermëttelt Zougangsdaten'). Luxembourgish builds its V-form from the 2pl on the German Sie model — Dir / Iech / Ären — and it is live, current, ordinary usage: NOT archaic (contrast is, where yðar was abandoned in the 20th century) and NOT merely available-but-unused (contrast mt, where intom exists and is declined). A fifth distinct situation behind the same 'formal' label.", + "registerDetectorNote": "detectors/lb.js is the FIRST detector in the set whose fold() does not lowercase, and that is the whole design rather than an optimisation. Case is the only thing separating the two polarities here: lowercase `dir` is the informal 2sg DATIVE while capitalised `Dir` is the polite 2pl NOMINATIVE, and both are attested in this corpus (1 and 82 times respectively). A case-folding detector merges all 83 into one bucket. This is the da/nb De/Dem/Deres situation (§8.2) with a sharper edge, because there the collision is with a third-person pronoun rather than with the familiar form of the same paradigm. The residual hole is recorded in UNDETECTABLE: a value that OPENS with the informal dative takes a sentence-initial capital and is then indistinguishable. All 11 sentence-initial `Dir` in the corpus are polite, each followed by a 2pl verb, so the cost is currently theoretical.", + "registerTraps": "Two exclusions, both measured rather than reasoned. (1) THE MODALS SYNCRETISE 1sg/2sg/3sg, so bare `muss` and `weess` carry no address information whatever — 'du muss' and 'hie muss' are spelled identically. All 15 occurrences of bare `muss` in the corpus are 3sg ('De Slug muss…', 'D'Tabell muss…', 'LLM muss…', 'publishAt muss…'); not one is 2sg. Both are excluded while the regularly inflected 2sg of the SAME verbs (kanns, wëlls, sollst) is kept, which makes this a PARTIALLY-detectable paradigm in the bg/is sense — but split by LEXICAL class (the modals) rather than by conjugation class, which is a third way for that split to fall. (2) `-t` is the 2pl ending AND the 3sg present ending, so `kënnt` ('you can' / 'he comes', 3sg of kommen) and `braucht` ('you need' / 'he needs') are both excluded from the formal list; the 2pl forms kept are only those whose 3sg is spelled differently — hutt/huet, sidd/ass, musst/muss, gitt/gëtt, maacht/mécht. The 2sg IMPERATIVE is excluded wholesale, and note this is NOT forced by the label convention the way it is in ca/et/hr/sl/sr/ga/mt: §6.5 test 1 comes out NO here because labels are infinitives. It fails test 2 alone — the bare stem is also an ordinary noun for the productive verbs (`Späicher` = storage/loft, `Filter`, `Test`). Cheap to give up: every informal slip actually shipped in this app family is a 2sg INDICATIVE, never an imperative.", + "politenessFormulaNote": "Checked for the free signal mt's `jekk jogħġbok` provided, and it is NOT there. Luxembourgish `wann ech glift` / `w.e.g.` ('please') is literally 'if I please' and inflects for the SPEAKER, not the addressee, so it is register-neutral despite 21 occurrences. Same negative as is `vinsamlegast` (an adverb) and ga `le do thoil`. Three of the four locales checked so far come out empty — keep checking, since the payoff when it lands is large, but do not expect it.", + "buttons": "INFINITIVE, register-neutral — 64 infinitives against 0 imperatives (2sg or 2pl) and 0 verbal nouns, resolved over 59 bare action keys across all four app bundles plus core's single lb catalogue. Späicheren, Läschen, Ofbriechen, Beaarbechten, Erstellen, Aktualiséieren, Ewechhuelen, Kopéieren, Eroflueden, Eroplueden, Verëffentlechen, Deelen, Validéieren. Luxembourgish distinguishes the four candidate forms cleanly — infinitive -en, 2pl imperative -t, 2sg imperative bare stem, verbal noun -ung — so this is a real measurement and not an artefact of syncretism. The remainder are legitimately not verbs: adverbs (Zréck, Weider, Virdrun) and nouns (Test, Usiicht, Import, Export, Filter), the same shape as cs's Zpět/Storno. Joins cs lt lv sk rm is in the infinitive row (§7.3). Infinitive buttons must NOT be 'corrected' to imperatives.", + "pluralNote": "nplurals=2, plural=(n != 1). NO disagreement of any kind: runtime-check confirms the header and @nextcloud/l10n's getPlural agree on which index every count selects, and both declared forms are reachable. This is the plain `n != 1` shape §7.1 predicts to be safe, and it is — unlike is and mk, whose two-form headers are MODULAR and therefore disagree with the library's coarse `number === 1` grouping. The one pre-existing array is correct and instructive: '_%n entry has no hash yet_' -> ['%n Antrag huet nach kee Hash', '%n Anträg hunn nach kee Hash'] agrees in BOTH the noun (Antrag/Anträg, umlaut plural) and the verb (huet/hunn), so an array here cannot be built by swapping the noun alone.", + "orthographyNote": "A FIFTH §8.10 outcome, and the first where the answer is that there is no choice to make: Luxembourgish capitalises ALL nouns as a rule of orthography, exactly as German does, so domain-term capitalisation is GRAMMATICALLY FORCED rather than a house convention. Measured anyway and it is unanimous — Objet 49:0, Datei 27:0, Usiicht 24:0, Schema 25:1, Webhook 23:0, Register 20:1, Eegeschaft 15:0, Benotzer 12:0, Flux 11:0, Entitéit 9:0, Andréi 14:0. Per §8.10 the CONDITIONED measurement was run as well even though the unconditioned one already looked one-sided, and it changes nothing: under title-cased English keys the terms capitalise 39:1, under prose keys 172:2, so this is not ga's mirror-the-source convention wearing a disguise. The four apparent exceptions are all false positives, which is worth recording so nobody 'fixes' them: `{register}` and `{schema}` are PLACEHOLDERS, not words, and the 14 lowercase `filteren` are the VERB (the noun Filter is capitalised 25:0). So the outcomes now seen are: a list of capitalised terms (sr, rm, mt), mirror the source (ga), flat lowercase (is), and forced-by-orthography (lb). Flat uppercase remains unobserved.", + "typographyNote": "Heavy compound hyphenation is the dominant convention: 234 values carry an internal Letter-hyphen-Capital compound against 3 in the English source — Audit-Trail-Andréi, Sich-Trail-Detailer, Webhook-Liwwerungsprotokoller, Metadonnéeën-Filteren, Objet-Usiichten. Luxembourgish compounds like German but hyphenates where a loanword or proper noun is a member, which is most of this app's vocabulary. The elided article `d'` takes a STRAIGHT apostrophe (51 values, 0 typographic ’) — d'Objeten, d'Datei, d'Astellungen. Ellipsis follows the English source per key rather than a house rule (38 '...' against 1 '…', mirroring en's 77 and 2); note the separate Loading... / Loading… duplicate-key defect in §10. No en dash; 6 em dashes, all mirroring the source. No double quotes at all where en has 9.", + "eifelerNote": "THE EIFELER REGEL (n-deletion) is the single biggest grammatical hazard in writing this locale and no gate can see it. A word-final -n/-nn is DELETED before a consonant other than n, d, t, z, h, and KEPT before those five and before any vowel. It is genuinely in force in this bundle — Keng Usiichte fonnt, Eegeschafte filteren, Statistike filteren, Mëll geläscht Artikele filteren — so it must be applied to every value written. Measured against the bundle's OWN practice per lemma rather than asserted from the grammar (the §8.10 method applied to morphology), which is what makes it actionable: 16 lemmas keep the -n before a delete-consonant and 5 do it BOTH ways. Two exemption classes must be encoded or the check is pure noise: (a) stem-final -n is not inflectional, so the -ioun/-ion/-oun nouns keep it always — Aktioun kann, Konfiguratioun gespäichert, Applikatioun läschen and Restriktioun feelt are all CORRECT, and they were 25 of the first 95 raw hits; (b) non-integrated loans (Token, JSON, Login, Session) and the short function words (wann, dann, schonn, kann, sinn, hunn, ginn). The rule applies to the nominalised INFINITIVE exactly as it does to plural nouns, and getting that wrong cost this pass a whole round. MEASUREMENT TRAP, recorded because it is the reusable part: the first check compared each lemma ONLY AGAINST ITSELF, so `Lueden` (which only ever appears with the -n) and `Deele` (which only ever appears without it) each looked internally consistent, and the cross-lemma picture never surfaced. That artefact was then read as evidence of a grammatical exception for infinitives, and 26 values were left uncorrected on the strength of a '16:0 with no counter-example' count that was really one copy-pasted phrase. Measuring the WORD CLASS across all lemmas instead shows the family does both — 118 kept against 108 deleted — with directly parallel pairs: `Lueden vum` and `Späicheren feelgeschloen` keep it, while `Deele vun`, `Erofsetze vun`, `Migréiere vum`, `bäisetze wëllt`, `ausféiere kënnt` and `zesummeféiere wann` delete it. So a per-lemma consistency check is necessary but NOT sufficient: a lemma that occurs in only one environment carries no information, and a pile of such lemmas looks like a convention. Aggregate by word class before concluding anything. TRUE RESIDUE: 2 values, both pre-existing, both a bare `sinn`/`ginn` infinitive governed by a preceding modal ('muss aktivéiert sinn mat', 'kënne erëmgewonne ginn wann'), where reducing to `si`/`gi` collides with the pronouns and the family supplies no directly parallel example either way. §3.8 governs those two. Everything else was corrected: 75 violations in the pre-existing half, 44 more in the half written during this pass, 48 in the follow-up round that this note exists to explain.", + "lexiconNote": "Files -> Datei/Dateien, a DELIBERATE divergence from core, recorded because an unrecorded 'left alone' is indistinguishable from 'never looked' (§6.9). core-diff flags it: core lb renders Files as `Fichieren`, the French-derived form, in its single catalogue. The app family is unanimous the other way — 111 uses of Datei* across openregister (48), opencatalogi (36), launchpad (23) and openconnector (4), against 0 of Fichier* anywhere. §3.5 settles it decisively and no owner ask was needed: this is not the is `skrá` case, where the app's primary noun collided with itself and produced byte-identical renderings of distinct keys. The other core-diff disagreement goes the same way: Email -> `E-Mail` (bundle) against core's `Email`, and the hyphenated form is correct Luxembourgish orthography.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "All": "Luxembourgish 'all' is spelled exactly like the English quantifier and is the form this bundle already uses in All Aktiounen / All Kategorien / All Dateien. 30 distinct values across the 37 locales, so translatable and merely identical here.", + "All Webhooks": "Both members are unchanged in Luxembourgish: 'all' is the native quantifier and 'Webhook' is the borrowed term this app family uses throughout. 34 distinct values across the locales.", + "Avatar": "Standard in Luxembourgish as in its neighbours. 14 distinct values across the locales, so translatable in principle — but §8.4 warns core et ships a two-word gloss unfit for a label, which is what a forced translation would produce here.", + "Code": "Luxembourgish 'Code' is the ordinary word, borrowed from French. 19 distinct values across the locales. Distinct from Status-Code, which the bundle compounds.", + "CSV": "A standardised format acronym, unchanged in Luxembourgish. This is the one entry whose §3.3 measurement returns a SINGLE distinct value across all 37 locales, which is normally the signal to UNWRAP rather than record — but §3.2 names CSV as the canonical example of a genuine cognate, and it renders as a user-facing format choice rather than a placeholder or product name, so it stays wrapped.", + "Deck": "The proper name of the Nextcloud Deck app, used as a section label. §3.3 returns a SINGLE distinct value across all 37 locales, i.e. nobody translates it — the §8.5 'product names keep the English' rule rather than a translation decision.", + "Driver": "Luxembourgish IT register borrows 'Driver'; German Treiber has no established Luxembourgish form and 'Dreiwer' would be a coinage nobody writes. Recorded with the tension visible: §3.3 finds 21 distinct values, and the cs pass classified its own identical 'Driver' as FILLER because core cs renders it ovladače databází. This is therefore the weakest entry in this list — challenge it if a native speaker disagrees.", + "DSAR": "An established acronym (Data Subject Access Request), kept as-is in Luxembourgish GDPR practice. §3.3 returns a single distinct value across all 37 locales.", + "Excel (.xlsx)": "A product name plus its file extension, offered as an export-format choice alongside CSV. Single distinct value across all 37 locales.", + "Status": "Identical in Luxembourgish. 18 distinct values across the locales, and the bundle compounds the same stem as Extraktiounsstatus, Erfollegsstatus, Qualitéitsstatus and pluralises it as 'All Statusen'.", + "Total": "Identical in Luxembourgish. 26 distinct values across the locales; the bundle already ships Total Objeten, Total Memberen, Total Gréisst and the plural Totaler.", + "Total:": "As 'Total', with the colon the source supplies. 27 distinct values across the locales.", + "URL": "A standardised acronym, unchanged in Luxembourgish. Only 2 distinct values across all 37 locales. The bundle compounds it as Datebank-URL, Basis-URL and GitLab-URL. Note the sibling key 'Url' is NOT a cognate — it is normalised to 'URL', the same casing defect the cs pass corrected.", + "UUID:": "One of the two strings §3.3 names as a trap for looking untranslatable while not being so — French inserts a space before the colon, so the key genuinely varies (2 distinct values across the locales). Luxembourgish follows German spacing and takes no space, hence identical.", + "Webhook": "The app family's borrowed term, unchanged in Luxembourgish. 12 distinct values across the locales, and this bundle already carries 23 occurrences in compounds such as Webhook-Liwwerung and Webhook-Protokoller.", + "Webhooks": "Plural of the borrowed 'Webhook', which Luxembourgish forms without a suffix here. 21 distinct values across the locales. Already shipped in 'Zréck zu de Webhooks' and 'Webhooks fir evenementgesteiert Integratioune verwalten'.", + "Workflows": "The app family's borrowed term. 28 of the 37 locales translate it, but the bundle already ships 'Verfügbar Workflows', 'Workflows aktualiséieren' and n8n-Workflow-Integratioun — §3.5, the file wins on lexicon.", + "{property} - {other}": "The second string §3.3 names as a trap: it looks like pure punctuation but genuinely varies, because ru substitutes an em dash and hr an en dash (3 distinct values across the locales). Luxembourgish keeps the plain hyphen the source has — the §8.10 typography measurement of this bundle found NO en dash anywhere and only source-mirroring em dashes.", + "OpenDocument (.ods)": "A product name plus its file extension, offered as an export-format choice. Single distinct value across all 37 locales.", + "Parallel:": "Luxembourgish 'parallel' is spelled identically; the colon is supplied by the source. 25 distinct values across the locales. The bundle already ships 'Parallelle Modus'.", + "PDF": "A standardised format acronym, unchanged in Luxembourgish. Single distinct value across all 37 locales, the same shape as CSV.", + "Port": "Luxembourgish keeps 'Port' for a network port, as German and French do. 13 distinct values across the locales.", + "RBAC": "An established acronym (role-based access control), kept as-is. Single distinct value across all 37 locales.", + "Register": "The app's primary noun, and genuinely identical in Luxembourgish. 24 of the 37 locales translate it, so this is a §3.5 lexicon decision resting on the bundle's own unanimous practice — Register appears capitalised mid-sentence 20:0 and compounds as Registerdonnéeën, Registerstatistiken, Dateregistere and Registeriwwerbléck. NOT the is `skrá` situation: nothing else in this bundle renders as Register, so no two distinct English keys collide.", + "Register / Schema": "Both members are cognates in Luxembourgish (see the separate Register and Schema entries); the slash is the source's. 27 distinct values across the locales.", + "Register:": "As 'Register', with the colon the source supplies. 21 distinct values across the locales.", + "Repository": "Luxembourgish IT register keeps 'Repository'. 28 distinct values across the locales, but the bundle already ships 'Pad am Repository' and 'E Repository auswielen', so this is the established term here.", + "Schema": "Identical in Luxembourgish, as in German. 19 of the 37 locales translate it; the bundle's own practice is unanimous — Schema capitalised mid-sentence 25:1 (the single exception being the {schema} PLACEHOLDER, not a word), compounding as Schemadonnéeë, Schemastatistiken, Schematitelen and Dateschemaen. Plural Schemaen.", + "Schema:": "As 'Schema', with the colon the source supplies. 17 distinct values across the locales.", + "Score": "Luxembourgish borrows 'Score' (masculine). 25 distinct values across the locales, and the bundle already compounds it as Vertrauensscore, Qualitéitsscore and 'Duerchschnëttleche Score'.", + "Host *": "Luxembourgish IT register keeps 'Host' for a server host, as German and French do. 20 distinct values across the locales, most of them transliterations rather than real translations. The trailing ' *' is the required-field marker the source supplies.", + "ID": "The standard abbreviation, unchanged in Luxembourgish. 9 distinct values across the locales, essentially ID / Id / a Cyrillic transliteration. Note the sibling key 'Id' is NOT a cognate here — it is normalised to 'ID', which is the casing defect the cs pass also found.", + "ID:": "As 'ID', with the colon the source supplies. 10 distinct values across the locales.", + "ID: {id}": "As 'ID', with the colon and placeholder the source supplies. 6 distinct values across the locales.", + "Linear": "Luxembourgish 'linear' is spelled identically; capitalised here because it is a standalone option label and Luxembourgish capitalises the nominalised form. The bundle already ships the adverb in 'D'Verzögerunge klamme linear'. 27 distinct values across the locales.", + "Maximum": "A Latin loan, identical in Luxembourgish. 15 distinct values across the locales. Corroborated by the bundle's own 'Exklusivt Maximum' and 'Maximalwäert', which inflect the same stem.", + "Method": "Luxembourgish 'Method' (feminine) is spelled identically to the English. Well evidenced inside this bundle rather than assumed: it already ships HTTP-Method, Sichmethod and 'Aktiv Method: {active}'. 24 distinct values across the locales.", + "Minimum": "A Latin loan, identical in Luxembourgish. 15 distinct values across the locales. Corroborated by the bundle's own 'Exklusivt Minimum' and 'Minimalwäert'.", + "Multitenancy": "The app family's term for the feature, and this bundle already compounds it as Multitenancy-Astellungen. 28 of the 37 locales translate it, so this is a §3.5 lexicon decision rather than an untranslatable string.", + "Format": "Luxembourgish 'Format' is the ordinary word, identical to the English. 16 distinct values across the locales, so translatable in principle and merely identical here. The bundle already compounds it as Exportformat and Uweisungsformat.", + "Dashboard": "Kept deliberately against the majority: 31 of the 37 locales translate this, but the whole app family's Luxembourgish vocabulary is built on the borrowed 'Dashboard' (launchpad's lb bundle uses it throughout, and openregister already ships Dashboard aktualiséieren). §3.5 — the file wins on lexicon.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "Host": "borrowed from English unchanged in Luxembourgish IT usage, the same class as the bundle's Token, Webhook and Login. No native form exists and the German Wirt is not used in this sense.", + "Index": "Index is an ordinary Luxembourgish noun (plural Indexen) and, like every noun in this locale, is capitalised — so the value coincides with the English source rather than being untranslated.", + "Provider": "borrowed unchanged; Luxembourgish uses Provider for a service provider in IT contexts, and the bundle already carries it in the Provider-prefixed compound keys.", + "Register #{id}": "Register is the Luxembourgish word for a register and is capitalised as every noun is, and the rest of the value is a literal # plus a placeholder — so nothing in it is translatable. The sibling key Object #{id} does differ, because Object becomes Objet.", + "Shards": "a SOLR-specific English term with no Luxembourgish equivalent, borrowed unchanged alongside the bundle's other search-engine vocabulary.", + "Uptime:": "borrowed unchanged; Luxembourgish IT usage takes Uptime from English and the trailing colon is part of the label, so the whole value coincides with the source.", + "App": "App is the ordinary Luxembourgish noun for an application (plural Appen) and is capitalised as every noun is, so the value coincides with the English source.", + "Message": "Luxembourgish uses the French loanword 'Message', spelled identically." + }, + "correctionCodes": "EIFELER-DEL = a word-final -n that must be DELETED before the following consonant and was not. EIFELER-KEEP = a word-final -n that must be KEPT (before a vowel, or n/d/t/z/h) and was wrongly dropped; the bundle got the rule wrong in BOTH directions, which is why there are two codes. CAPITAL-POLITE = the polite possessive Är-/Ären written lowercase, against the sibling bundles' 50:0. AGREEMENT = wrong gender agreement. TYPO. GERMANISM = a German form where this bundle's own vocabulary is Luxembourgish. TERM-AUDIT = Auditprotokoll for *audit trail*, against 24 uses of Audit-Trail. CONSISTENCY = breaks a pattern the bundle otherwise holds unanimously. GRAMMAR = a malformed clause. 77 corrections over 1011 pre-existing translated values (7.6%), which sits between cs (5.5%) and ca (6.2%) rather than near is (22%) — this is a healthy locale whose single systematic weakness is the Eifeler Regel.", + "corrections": { + "Configure parameters for object vectorization. This process will generate vector embeddings for all objects matching your view filters.": "CAPITAL-POLITE: ären -> Ären", + "There are no audit trail entries matching your current filters.": "CAPITAL-POLITE: ären -> Ären", + "There are no search trail entries matching your current filters.": "CAPITAL-POLITE: ären -> Ären", + "There are no deleted items matching your current filters.": "CAPITAL-POLITE: ären -> Ären", + "There are no webhook log entries matching your filters.": "CAPITAL-POLITE: äre -> Äre", + "Vectors will be stored in your existing object and file collections": "CAPITAL-POLITE: ären -> Ären", + "Manage and restore soft deleted items from your registers": "CAPITAL-POLITE: äre -> Äre", + "No properties match your filters.": "CAPITAL-POLITE äre -> Äre, and EIFELER-KEEP: Eegeschafte -> Eegeschaften before the vowel of entspriechen", + "Load advanced filters with live data from your search index": "CAPITAL-POLITE: ärem -> Ärem", + "Choose which views to include in the vectorization process. Leave empty to process all views based on your configuration.": "CAPITAL-POLITE: ärer -> Ärer", + "No views match your search": "CAPITAL-POLITE ärer -> Ärer, and EIFELER-KEEP: Usiichte -> Usiichten before the vowel of entspriechen", + "Custom HTTP headers (one per line, format: Header-Name: value)": "EIFELER-DEL: een -> ee before pro", + "Filter webhook triggers by payload properties (one per line, format: key: value)": "EIFELER-DEL: een -> ee before pro", + "menu or contact someone with permission to create agents.": "EIFELER-DEL: een -> ee before mat", + "Defaults for every flow on this instance. A flow can override each of these for itself; a flow that overrides none of them follows the values set here, including later changes.": "EIFELER-DEL: relative pronoun deen -> dee before keen", + "Use filters to narrow down deleted items by register, schema, deletion date, or user who deleted them.": "EIFELER-DEL: Filteren -> Filtere before fir, and relative pronoun deen -> dee before se", + "Failed to load register data": "EIFELER-DEL: den -> de before Registerdonnéeë; and in the second round Lueden -> Luede before vun", + "File vectorization started. Check the statistics section for progress.": "EIFELER-DEL: den -> de before Statistik-Beräich", + "Choose which file types to include in the vectorization process. Only files with extracted text and chunks will be processed.": "EIFELER-DEL: Dateien -> Dateie before mat, and finite ginn -> gi before verschafft", + "No filters are currently active. This will delete ALL audit trail entries!": "EIFELER-DEL: finite sinn -> si before keng", + "Objects will be soft-deleted (marked as deleted but kept in database). They can be recovered later if needed.": "EIFELER-DEL: finite ginn -> gi before mëll. The second ginn is a passive INFINITIVE after kënne and correctly keeps its -n", + "Text chunks are generated during file extraction and stored in the database. Vectorization reads these pre-chunked files and converts them to embeddings.": "EIFELER-DEL: finite ginn -> gi before während, and conjunction an -> a before wandelt", + "Counting objects...": "EIFELER-DEL: Objeten -> Objete and finite ginn -> gi before gezielt. Two adjacent violations in one three-word value", + "Configure how database objects are converted into vector embeddings for semantic search. Objects are directly vectorized without needing text extraction.": "EIFELER-DEL: preposition an -> a before Vektor-Embeddings, Objeten -> Objete, finite ginn -> gi before direkt", + "Cannot delete: objects are still attached": "EIFELER-DEL: Objeten -> Objete before sinn", + "Clear all filters": "EIFELER-DEL: Filteren -> Filtere before läschen", + "Configure how objects are converted to text before vectorization. These settings affect search quality and context.": "EIFELER-DEL: Objeten -> Objete before viru", + "Delete Objects": "EIFELER-DEL: Objeten -> Objete before läschen", + "Filter Objects": "EIFELER-DEL: Objeten -> Objete before filteren", + "Validate Objects": "EIFELER-DEL: Objeten -> Objete before validéieren", + "objects processed": "EIFELER-DEL: Objeten -> Objete before verschafft", + "Failed to load Nextcloud groups": "EIFELER-DEL: Gruppen -> Gruppe before feelgeschloen; and in the second round Lueden -> Luede before vun. den correctly keeps its -n before Nextcloud", + "Save changes": "EIFELER-DEL: Ännerungen -> Ännerunge before späicheren, matching the bundle's own Ännerunge kopéieren", + "Calculate Sizes": "EIFELER-DEL: Gréissten -> Gréisste before berechnen", + "Hide Filters": "EIFELER-DEL: Filteren -> Filtere before verstoppen", + "Hide in forms": "EIFELER-DEL: Formularen -> Formulare before verstoppen", + "Loading schemas...": "EIFELER-DEL: Schemaen -> Schemae before gi", + "Loading sources...": "EIFELER-DEL: Quellen -> Quelle before gi", + "Manage document templates and themes": "EIFELER-DEL: Themen -> Theme before verwalten", + "Members of selected groups can access this view": "EIFELER-DEL: Memberen -> Membere before vun", + "Generate recommendations and confidence scores": "EIFELER-DEL: Vertrauensscoren -> Vertrauensscore before generéieren", + "Use filters to narrow down audit trail entries by register, schema, action type, user, date range, or object ID.": "EIFELER-DEL: Filteren -> Filtere before fir", + "Use filters to narrow down search trail entries by register, schema, success status, user, date range, search terms, or performance metrics.": "EIFELER-DEL: Filteren -> Filtere before fir", + "Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.": "EIFELER-DEL: Clienten -> Cliente before kréien", + "Failed to load entities": "EIFELER-DEL: Entitéiten -> Entitéite before feelgeschloen; and in the second round Lueden -> Luede before vun", + "Failed to load files": "EIFELER-DEL: Dateien -> Dateie before feelgeschloen; and in the second round Lueden -> Luede before vun", + "Auto-retry failed vectorizations": "EIFELER-DEL: participle Feelgeschloen -> Feelgeschloe before Vektoriséierungen", + "Automatically retry failed vectorization attempts (max 3 retries)": "EIFELER-DEL: participle Feelgeschloen -> Feelgeschloe before Vektoriséierungsversuche", + "Optional webhook secret for signature verification": "AGREEMENT: Geheimnis is neuter, so the attributive adjective takes -t; Optionalen -> Optionalt (which also resolves the EIFELER-DEL before Webhook)", + "Serial Mode (Safer, slower)": "EIFELER-DEL: Seriellen -> Serielle before Modus", + "Maximum number of retry attempts for failed deliveries": "EIFELER-DEL: participle feelgeschloen -> feelgeschloe before Liwwerungen", + "How to handle retries for failed webhook deliveries": "GRAMMAR: 'Wéi mat den Neiversuche ... fonctionéiert gëtt' is not a well-formed clause and fonctionéiert is the FRENCH spelling (GERMANISM's mirror image) where this bundle writes funktionéieren. Rewritten with verfuer gëtt, plus EIFELER-DEL feelgeschloen -> feelgeschloe", + "View and analyze search trail logs with advanced filtering and analytics capabilities": "EIFELER-DEL: erweiderten -> erweiderte before Filter-, matching the sibling key which already has erweiderte Filterfäegkeeten", + "Add schema titles, descriptions, and register information to provide richer context for search": "EIFELER-DEL: räicheren -> räichere before Kontext", + "Delays double with each attempt (2, 4, 8 minutes...)": "EIFELER-DEL: finite verduebelen -> verduebele before sech", + "How deep to traverse nested object properties (1-20). Higher values capture more detail but increase vector size.": "EIFELER-DEL: finite erfaassen -> erfaasse before méi", + "LLM settings updated successfully": "EIFELER-KEEP: Astellunge -> Astellungen before the vowel of erfollegräich", + "No entities have been detected yet": "EIFELER-KEEP: goufe -> goufen before nach (n is a keep-consonant) and Entitéite -> Entitéiten before the vowel of erkannt. The sibling key 'No files have been extracted yet' already writes goufen nach correctly", + "No views found. Create views first before configuring vectorization.": "EIFELER-KEEP: the second Usiichte -> Usiichten before ier. The first correctly deletes before fonnt", + "Select registers and schemas to save a view": "EIFELER-KEEP Schemae -> Schemaen before the vowel of auswielen; and in the second round EIFELER-DEL auswielen -> auswiele before fir", + "Update vectors when object data changes (recommended for accurate search)": "EIFELER-KEEP: Vektore -> Vektoren before the vowel of aktualiséiere", + "No webhooks have been configured yet": "EIFELER-KEEP: goufe -> goufen before nach", + "No request body available": "EIFELER-KEEP: Kee -> Keen before the vowel of Ufro-Kierper. The sibling key 'No response body available' already writes Keen Äntwert-Kierper correctly", + "Include related object references": "EIFELER-KEEP: Verbonne -> Verbonnen before the vowel of Objet-Referenzen", + "Write an audit-trail entry for every step": "EIFELER-KEEP E -> En before the vowel, and TERM-AUDIT: Auditprotokollantrag -> Audit-Trail-Andrag, the form used by 'This audit trail entry does not contain any change information'", + "Your OpenAI API key. Get one at": "EIFELER-KEEP: Äre -> Ären before the vowel of OpenAI. The Fireworks sibling key correctly writes Äre before a consonant", + "Generate vectors immediately when new objects are created": "EIFELER-DEL: Vektoren -> Vektore before wann", + "Estimated Duration:": "AGREEMENT: Dauer is feminine, so Geschätzten -> Geschätzte, matching the sibling Geschätzte Batchen / Geschätzte Käschten", + "Old Value": "AGREEMENT: Wäert is masculine, not neuter, so the neuter Aalt is wrong; Ale Wäert, matching the sibling key New Value -> Neie Wäert", + "Exclusive Maximum": "TYPO: Exklusiivt -> Exklusivt. The doubled i is not Luxembourgish orthography and the bundle writes Aktiv with one i", + "Exclusive Minimum": "TYPO: Exklusiivt -> Exklusivt", + "Most Active Objects": "TYPO: Aktiivst -> Aktivst, same doubled i, against the bundle's own Aktiv", + "This change breaks the existing data model:": "GERMANISM: bestehend is German; this bundle's word for 'existing' is existéierend (Existéierend Verbesserungen, existéierend an zukënfteg Usiichten, ären existéierenden Objet-Sammlunge)", + "Could not read the seal coverage of the audit trail.": "TERM-AUDIT: Auditprotokoll -> Audit-Trail", + "Recomputes every hash and compares it with the one stored. This reads the whole audit trail, so it is run on request rather than each time this page opens.": "TERM-AUDIT: Auditprotokoll -> Audit-Trail", + "An entry with no hash is one the chain cannot vouch for. A background job sweeps these every five minutes and seals the oldest first, so a backlog after heavy write activity is normal and drains on its own. A backlog that never shrinks is not — it means sealing is failing on both the write path and the sweep.": "EIFELER-DEL (second round): buergen -> buerge before kann, Schreiwen -> Schreiwe before wéi, Duerchgoen -> Duerchgoe before feelschléit", + "Automatically create a default organisation if none exists when the app is initialized": "EIFELER-DEL (second round): erstellen -> erstelle before wann", + "Error loading application": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Error loading audit trails": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Error loading entity": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Error loading search trails": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to download API specification": "EIFELER-DEL (second round): Eroflueden -> Erofluede before vun", + "Failed to load entity": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load extraction data": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load LLM configuration": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load organisations": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load templates": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load webhooks": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load workflows": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to test webhook": "EIFELER-DEL (second round): Testen -> Teste before vum", + "Select a Nextcloud group to add": "EIFELER-DEL (second round): auswielen -> auswiele before fir", + "This has two possible causes and they are not equally serious: the entry was altered after it was written, or it was sealed before the seal lock existed and chained onto the wrong predecessor. Read the entry before deciding. Repair is a deliberate operator action — it rewrites stored hashes, which is exactly the event this chain exists to make visible — so it is only available as a command:": "EIFELER-DEL (second round): Schreiwen -> Schreiwe before geännert", + "Starting...": "CONSISTENCY: the bare noun Start... against nine sibling progressives that all use 'Gëtt ...' (Gëtt gespäichert, Gëtt getest, Gëtt gelueden, Gëtt analyséiert, Gëtt erstallt, Gëtt geläscht, Gëtt verschafft, Gëtt verëffentlecht, Gëtt nei probéiert). Sole outlier of ten", + "Loading organisations...": "CONSISTENCY: used U+2026 … where the other 38 values in this bundle use three dots, mirroring the English source" + } } diff --git a/tests/Unit/BackgroundJob/FlowTimerWorkerTest.php b/tests/Unit/BackgroundJob/FlowTimerWorkerTest.php new file mode 100644 index 0000000000..be7332209a --- /dev/null +++ b/tests/Unit/BackgroundJob/FlowTimerWorkerTest.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\BackgroundJob + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\BackgroundJob; + +use DateTime; +use DateTimeImmutable; +use OCA\OpenRegister\BackgroundJob\FlowTimerWorker; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerSweep; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; +use RuntimeException; + +/** + * @covers \OCA\OpenRegister\BackgroundJob\FlowTimerWorker + */ +class FlowTimerWorkerTest extends TestCase { + + private FlowTimerSweep&MockObject $sweep; + + private IAppConfig&MockObject $appConfig; + + private LoggerInterface&MockObject $logger; + + private FlowTimerWorker $worker; + + protected function setUp(): void { + parent::setUp(); + $this->sweep = $this->createMock(FlowTimerSweep::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + $time = $this->createMock(ITimeFactory::class); + $time->method('getDateTime')->willReturn(new DateTime('2026-09-01 10:00:00')); + $this->worker = new FlowTimerWorker(time: $time, sweep: $this->sweep, appConfig: $this->appConfig, logger: $this->logger); + }//end setUp() + + private function tick(): void { + $method = new ReflectionMethod(FlowTimerWorker::class, 'run'); + $method->invoke($this->worker, null); + }//end tick() + + public function testIntervalMatchesTheScheduleWorker(): void { + self::assertSame(300, FlowTimerWorker::INTERVAL_SECONDS); + self::assertSame(300, $this->worker->getInterval()); + }//end testIntervalMatchesTheScheduleWorker() + + public function testRunsOnePassWithTheConfiguredBatchAndLogsWorkPerformed(): void { + $this->appConfig->method('getValueString')->with('openregister', FlowTimerWorker::CONFIG_BATCH, '200')->willReturn('50'); + $this->sweep->expects(self::once())->method('run') + ->with(self::callback(static fn (DateTimeImmutable $now): bool => $now->format('Y-m-d H:i') === '2026-09-01 10:00'), 50) + ->willReturn(['expiriesFired' => 3, 'rungsFired' => 2, 'truncated' => true, 'errors' => 0]); + $this->logger->expects(self::once())->method('info') + ->with(self::stringContains('Fired 3 expiry timer(s) and 2 escalation rung(s); truncated: true'), self::anything()); + $this->tick(); + }//end testRunsOnePassWithTheConfiguredBatchAndLogsWorkPerformed() + + public function testAQuietPassLogsNothingAndABadBatchIsFlooredAtOne(): void { + $this->appConfig->method('getValueString')->willReturn('-5'); + $this->sweep->expects(self::once())->method('run')->with(self::anything(), 1) + ->willReturn(['expiriesFired' => 0, 'rungsFired' => 0, 'truncated' => false, 'errors' => 0]); + $this->logger->expects(self::never())->method('info'); + $this->tick(); + }//end testAQuietPassLogsNothingAndABadBatchIsFlooredAtOne() + + public function testAFailingPassIsLoggedNotThrown(): void { + $this->appConfig->method('getValueString')->willReturn('200'); + $this->sweep->method('run')->willThrowException(new RuntimeException('db gone')); + $this->logger->expects(self::once())->method('error')->with(self::stringContains('db gone'), self::anything()); + $this->tick(); + }//end testAFailingPassIsLoggedNotThrown() +}//end class diff --git a/tests/Unit/Db/FlowTimerEntitiesTest.php b/tests/Unit/Db/FlowTimerEntitiesTest.php new file mode 100644 index 0000000000..7a717a988d --- /dev/null +++ b/tests/Unit/Db/FlowTimerEntitiesTest.php @@ -0,0 +1,175 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use DateTime; +use LogicException; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerEvent; +use OCA\OpenRegister\Db\FlowTimerEventMapper; +use OCA\OpenRegister\Db\FlowTimerFire; +use OCA\OpenRegister\Db\FlowTimerFireMapper; +use OCA\OpenRegister\Event\FlowTimerFiredEvent; +use OCP\AppFramework\Db\Entity; +use OCP\DB\Exception as DbException; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Db\FlowTimer + * @covers \OCA\OpenRegister\Db\FlowTimerFire + * @covers \OCA\OpenRegister\Db\FlowTimerEvent + * @covers \OCA\OpenRegister\Db\FlowTimerFireMapper + * @covers \OCA\OpenRegister\Db\FlowTimerEventMapper + * @covers \OCA\OpenRegister\Event\FlowTimerFiredEvent + */ +class FlowTimerEntitiesTest extends TestCase { + + public function testTimerStatesPurposesAndOpenness(): void { + $timer = new FlowTimer(); + $timer->setState(FlowTimer::STATE_ARMED); + self::assertTrue($timer->isOpen()); + $timer->setState(FlowTimer::STATE_SUSPENDED); + self::assertTrue($timer->isOpen()); + foreach (FlowTimer::TERMINAL_STATES as $state) { + $timer->setState($state); + self::assertFalse($timer->isOpen(), $state); + } + + self::assertNotContains('overdue', FlowTimer::STATES, 'no overdue state value exists'); + self::assertSame(['due', 'expiry'], FlowTimer::PURPOSES); + self::assertSame(['none', 'servicenorm', 'wettelijk'], FlowTimer::LEGAL_EFFECTS); + }//end testTimerStatesPurposesAndOpenness() + + public function testEnforcingNeedsAnExpiryPurposeAndAnOutcome(): void { + $timer = new FlowTimer(); + $timer->setPurpose(FlowTimer::PURPOSE_DUE); + $timer->setOnExpiry('skip'); + self::assertFalse($timer->isEnforcing()); + $timer->setPurpose(FlowTimer::PURPOSE_EXPIRY); + self::assertTrue($timer->isEnforcing()); + $timer->setOnExpiry(null); + self::assertFalse($timer->isEnforcing()); + }//end testEnforcingNeedsAnExpiryPurposeAndAnOutcome() + + public function testSerialisationCarriesNoOverdueField(): void { + $timer = new FlowTimer(); + $timer->setUuid('t-1'); + $timer->setAnchorAt(new DateTime('2026-09-01 10:00:00')); + $timer->setFireAt(null); + $json = $timer->jsonSerialize(); + self::assertSame('t-1', $json['uuid']); + self::assertSame('2026-09-01T10:00:00+00:00', substr($json['anchorAt'], 0, 19) . substr($json['anchorAt'], 19)); + self::assertNull($json['fireAt']); + foreach (array_keys($json) as $key) { + self::assertStringNotContainsStringIgnoringCase('overdue', (string)$key); + } + + $fire = new FlowTimerFire(); + $fire->setRungKey('preBreach:14:calendarDays'); + $fire->setFiredAt(new DateTime('2026-09-01 10:00:00')); + self::assertSame('preBreach:14:calendarDays', $fire->jsonSerialize()['rungKey']); + self::assertNotNull($fire->jsonSerialize()['firedAt']); + + $event = new FlowTimerEvent(); + $event->setType(FlowTimerEvent::TYPE_SUSPENDED); + $event->setBasis('Awb 4:15'); + self::assertSame('Awb 4:15', $event->jsonSerialize()['basis']); + self::assertNull($event->jsonSerialize()['created']); + }//end testSerialisationCarriesNoOverdueField() + + public function testTheFireLedgerClaimLosesQuietlyOnTheUniqueIndexAndRethrowsAnythingElse(): void { + $unique = $this->createMock(DbException::class); + $unique->method('getReason')->willReturn(DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION); + $other = $this->createMock(DbException::class); + $other->method('getReason')->willReturn(DbException::REASON_CONNECTION_LOST); + + $mapper = new class($this->createMock(IDBConnection::class), $unique, $other) extends FlowTimerFireMapper { + public int $calls = 0; + + public function __construct(IDBConnection $db, private readonly DbException $unique, private readonly DbException $other) { + parent::__construct(db: $db); + } + + public function insert(Entity $entity): FlowTimerFire { + $this->calls++; + if ($this->calls === 1) { + return $entity; + } + + if ($this->calls === 2) { + throw $this->unique; + } + + throw $this->other; + } + }; + + $fire = new FlowTimerFire(); + $fire->setTimerUuid('t-1'); + $fire->setRungKey('k'); + self::assertSame($fire, $mapper->claim(fire: $fire), 'the first insert wins the claim'); + self::assertNull($mapper->claim(fire: $fire), 'a duplicate key means another pass owns the rung'); + $this->expectException(DbException::class); + $mapper->claim(fire: $fire); + }//end testTheFireLedgerClaimLosesQuietlyOnTheUniqueIndexAndRethrowsAnythingElse() + + public function testTheLedgersAreAppendOnly(): void { + $db = $this->createMock(IDBConnection::class); + $fires = new FlowTimerFireMapper(db: $db); + $events = new FlowTimerEventMapper(db: $db); + $fire = new FlowTimerFire(); + $event = new FlowTimerEvent(); + + foreach ([[$fires, $fire], [$events, $event]] as [$mapper, $row]) { + foreach (['update', 'delete'] as $verb) { + try { + $mapper->$verb($row); + self::fail($verb . ' was accepted'); + } catch (LogicException $refused) { + self::assertStringContainsString('append-only', $refused->getMessage()); + } + } + } + }//end testTheLedgersAreAppendOnly() + + public function testTheFiredEventCarriesTheTransitionAndItsAddressees(): void { + $timer = new FlowTimer(); + $timer->setUuid('t-1'); + $event = new FlowTimerFiredEvent( + timer: $timer, + kind: FlowTimerFiredEvent::KIND_RUNG, + transition: 'escalation:preBreach:7:calendarDays', + rungKey: 'preBreach:7:calendarDays', + recipients: [['type' => 'group', 'id' => 'g', 'role' => 'handler']], + priority: 'medium', + message: 'termijn-7d' + ); + self::assertSame($timer, $event->getTimer()); + self::assertSame('rung', $event->getKind()); + self::assertSame('escalation:preBreach:7:calendarDays', $event->getTransition()); + self::assertSame('preBreach:7:calendarDays', $event->getRungKey()); + self::assertSame('g', $event->getRecipients()[0]['id']); + self::assertSame('medium', $event->getPriority()); + self::assertSame('termijn-7d', $event->getMessage()); + }//end testTheFiredEventCarriesTheTransitionAndItsAddressees() +}//end class diff --git a/tests/Unit/Db/FlowTimerLedgerMappersTest.php b/tests/Unit/Db/FlowTimerLedgerMappersTest.php new file mode 100644 index 0000000000..0e6989bddf --- /dev/null +++ b/tests/Unit/Db/FlowTimerLedgerMappersTest.php @@ -0,0 +1,88 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use DateTime; +use InvalidArgumentException; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerEvent; +use OCA\OpenRegister\Db\FlowTimerEventMapper; +use OCA\OpenRegister\Db\FlowTimerFire; +use OCA\OpenRegister\Db\FlowTimerFireMapper; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Db\FlowTimerFireMapper + * @covers \OCA\OpenRegister\Db\FlowTimerEventMapper + * @covers \OCA\OpenRegister\Db\FlowTimerFire + * @covers \OCA\OpenRegister\Db\FlowTimerEvent + * @covers \OCA\OpenRegister\Db\FlowTimer + */ +class FlowTimerLedgerMappersTest extends TestCase { + use FluentQueryBuilderTrait; + + public function testTheFireLedgerInsertsWithAStampAndReadsByTimer(): void { + $mapper = new FlowTimerFireMapper(db: $this->connectionWith(affectedRows: 1)); + $fire = new FlowTimerFire(); + $fire->setTimerUuid('t-1'); + $fire->setRungKey('preBreach:7:calendarDays'); + $inserted = $mapper->claim(fire: $fire); + self::assertNotNull($inserted->getCreated()); + self::assertSame(77, $inserted->getId()); + + $row = ['id' => 5, 'timer_uuid' => 't-1', 'rung_key' => 'slaBreached:0', 'fired_at' => '2026-10-27 09:00:00', 'recipient_roles' => '["handler"]', 'inherited' => 1, 'created' => '2026-10-27 09:00:00']; + $mapper = new FlowTimerFireMapper(db: $this->connectionWith(rows: [$row])); + $fires = $mapper->findByTimer(timerUuid: 't-1'); + self::assertCount(1, $fires); + self::assertSame(['handler'], $fires[0]->getRecipientRoles()); + self::assertTrue($fires[0]->getInherited()); + self::assertTrue($this->saw('expr.eq', 'timer_uuid')); + + $this->expectException(InvalidArgumentException::class); + $mapper->insert(new FlowTimer()); + }//end testTheFireLedgerInsertsWithAStampAndReadsByTimer() + + public function testTheHistoryInsertsWithAStampAndReadsOldestFirst(): void { + $mapper = new FlowTimerEventMapper(db: $this->connectionWith(affectedRows: 1)); + $event = new FlowTimerEvent(); + $event->setTimerUuid('t-1'); + $event->setType(FlowTimerEvent::TYPE_ARMED); + self::assertNotNull($mapper->insert($event)->getCreated()); + + $event2 = new FlowTimerEvent(); + $event2->setTimerUuid('t-1'); + $event2->setType(FlowTimerEvent::TYPE_SUSPENDED); + $event2->setCreated(new DateTime('2026-01-01')); + self::assertSame('2026-01-01', $mapper->insert($event2)->getCreated()->format('Y-m-d')); + + $row = ['id' => 6, 'timer_uuid' => 't-1', 'type' => 'suspended', 'actor' => 'bob', 'basis' => 'Awb 4:15', 'created' => '2026-09-20 10:00:00']; + $mapper = new FlowTimerEventMapper(db: $this->connectionWith(rows: [$row])); + $events = $mapper->findByTimer(timerUuid: 't-1'); + self::assertSame('Awb 4:15', $events[0]->getBasis()); + self::assertTrue($this->saw('orderBy', 'created')); + self::assertTrue($this->saw('addOrderBy', 'id')); + + $this->expectException(InvalidArgumentException::class); + $mapper->insert(new FlowTimerFire()); + }//end testTheHistoryInsertsWithAStampAndReadsOldestFirst() +}//end class diff --git a/tests/Unit/Db/FlowTimerMapperQueriesTest.php b/tests/Unit/Db/FlowTimerMapperQueriesTest.php new file mode 100644 index 0000000000..e1bdb3f433 --- /dev/null +++ b/tests/Unit/Db/FlowTimerMapperQueriesTest.php @@ -0,0 +1,165 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use DateTime; +use InvalidArgumentException; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Db\FlowTimerMapper + * @covers \OCA\OpenRegister\Db\FlowTimer + * @covers \OCA\OpenRegister\Db\FlowRun + */ +class FlowTimerMapperQueriesTest extends TestCase { + use FluentQueryBuilderTrait; + + /** + * A stored timer row as the database returns it. + * + * @return array The row. + */ + private function row(): array { + return [ + 'id' => 11, + 'uuid' => 'timer-11', + 'subject_type' => 'task', + 'subject_uuid' => 'task-1', + 'purpose' => 'expiry', + 'legal_effect' => 'wettelijk', + 'state' => 'armed', + 'budget_value' => '56.0000', + 'budget_unit' => 'calendarDays', + 'consumed_value' => '19.0000', + 'anchor_at' => '2026-09-01 09:00:00', + 'fire_at' => '2026-10-27 09:00:00', + 'escalation_rules' => '[]', + 'breached' => 0, + 'created' => '2026-09-01 09:00:00', + ]; + }//end row() + + public function testInsertStampsCreatedAndGuardsTheEntityType(): void { + $mapper = new FlowTimerMapper(db: $this->connectionWith(affectedRows: 1)); + $timer = new FlowTimer(); + $timer->setUuid('t-1'); + $inserted = $mapper->insert($timer); + self::assertNotNull($inserted->getCreated(), 'created is stamped'); + self::assertSame(77, $inserted->getId()); + + $stamped = new FlowTimer(); + $stamped->setCreated(new DateTime('2026-01-01')); + self::assertSame('2026-01-01', $mapper->insert($stamped)->getCreated()->format('Y-m-d'), 'an explicit stamp is kept'); + + $this->expectException(InvalidArgumentException::class); + $mapper->insert(new FlowRun()); + }//end testInsertStampsCreatedAndGuardsTheEntityType() + + public function testUpdateStampsUpdatedAndGuardsTheEntityType(): void { + $mapper = new FlowTimerMapper(db: $this->connectionWith(affectedRows: 1)); + $timer = new FlowTimer(); + $timer->setId(11); + $timer->setUuid('t-1'); + $timer->setState(FlowTimer::STATE_ARMED); + self::assertNotNull($mapper->update($timer)->getUpdated()); + + $this->expectException(InvalidArgumentException::class); + $mapper->update(new FlowRun()); + }//end testUpdateStampsUpdatedAndGuardsTheEntityType() + + public function testFindByUuidMapsARowAndThrowsOnNone(): void { + $mapper = new FlowTimerMapper(db: $this->connectionWith(rows: [$this->row()])); + $timer = $mapper->findByUuid(uuid: 'timer-11'); + self::assertSame('timer-11', $timer->getUuid()); + self::assertSame('expiry', $timer->getPurpose()); + self::assertSame(56.0, $timer->getBudgetValue()); + self::assertSame('2026-10-27', $timer->getFireAt()->format('Y-m-d')); + self::assertTrue($this->saw('expr.eq', 'uuid')); + + $this->expectException(DoesNotExistException::class); + (new FlowTimerMapper(db: $this->connectionWith(rows: [])))->findByUuid(uuid: 'absent'); + }//end testFindByUuidMapsARowAndThrowsOnNone() + + public function testTheExpiryScanBoundsOnStatePurposeAndMoment(): void { + $mapper = new FlowTimerMapper(db: $this->connectionWith(rows: [$this->row()])); + $due = $mapper->findDueExpiries(now: new DateTime('2026-10-28'), limit: 25); + self::assertCount(1, $due); + self::assertTrue($this->saw('expr.eq', 'state')); + self::assertTrue($this->saw('expr.eq', 'purpose')); + self::assertTrue($this->saw('expr.isNotNull', 'fire_at')); + self::assertTrue($this->saw('orderBy', 'fire_at')); + self::assertTrue($this->saw('setMaxResults', 25), 'the scan is bounded, never a page filtered in PHP'); + }//end testTheExpiryScanBoundsOnStatePurposeAndMoment() + + public function testTheRungScanBoundsOnStateAndNextRung(): void { + $mapper = new FlowTimerMapper(db: $this->connectionWith(rows: [])); + self::assertSame([], $mapper->findDueRungs(now: new DateTime('2026-10-28'), limit: 10)); + self::assertTrue($this->saw('expr.eq', 'state')); + self::assertTrue($this->saw('expr.isNotNull', 'next_rung_at')); + self::assertTrue($this->saw('orderBy', 'next_rung_at')); + self::assertTrue($this->saw('setMaxResults', 10)); + }//end testTheRungScanBoundsOnStateAndNextRung() + + public function testSubjectRunSuccessorAndPagedReadsFilterWhatTheyClaim(): void { + $mapper = new FlowTimerMapper(db: $this->connectionWith(rows: [$this->row()])); + $mapper->findBySubject(subjectType: 'task', subjectUuid: 'task-1', states: [FlowTimer::STATE_ARMED]); + self::assertTrue($this->saw('expr.eq', 'subject_type')); + self::assertTrue($this->saw('expr.eq', 'subject_uuid')); + self::assertTrue($this->saw('expr.in', 'state')); + + $mapper = new FlowTimerMapper(db: $this->connectionWith(rows: [])); + $mapper->findOpenByRun(runUuid: 'run-1'); + self::assertTrue($this->saw('expr.in', 'state'), 'a run read only reaches open timers'); + + $mapper = new FlowTimerMapper(db: $this->connectionWith(rows: [])); + $mapper->findSuccessors(uuid: 'timer-11'); + self::assertTrue($this->saw('expr.eq', 'supersedes_uuid')); + + $mapper = new FlowTimerMapper(db: $this->connectionWith(rows: [])); + $mapper->findByStatePaged(state: FlowTimer::STATE_ARMED, afterId: 40, limit: 500); + self::assertTrue($this->saw('expr.gt', 'id')); + self::assertTrue($this->saw('setMaxResults', 500)); + }//end testSubjectRunSuccessorAndPagedReadsFilterWhatTheyClaim() + + public function testAnUnrestrictedSubjectReadCarriesNoStatePredicate(): void { + $mapper = new FlowTimerMapper(db: $this->connectionWith(rows: [])); + $mapper->findBySubject(subjectType: 'task', subjectUuid: 'task-1'); + self::assertTrue($this->saw('expr.eq', 'subject_type')); + self::assertFalse($this->saw('expr.in', 'state'), 'no state restriction when none is asked'); + }//end testAnUnrestrictedSubjectReadCarriesNoStatePredicate() + + public function testClaimFiredIsDecidedByTheAffectedRowCount(): void { + $mapper = new FlowTimerMapper(db: $this->connectionWith(affectedRows: 1)); + self::assertTrue($mapper->claimFired(uuid: 'timer-11', firedAt: new DateTime('2026-10-28'))); + self::assertTrue($this->saw('set', 'state')); + self::assertTrue($this->saw('set', 'fired_at')); + self::assertTrue($this->saw('expr.eq', 'state'), 'the claim is conditional on armed'); + + self::assertFalse((new FlowTimerMapper(db: $this->connectionWith(affectedRows: 0)))->claimFired(uuid: 'timer-11', firedAt: new DateTime()), 'zero rows means another pass owns it'); + }//end testClaimFiredIsDecidedByTheAffectedRowCount() +}//end class diff --git a/tests/Unit/Listener/FlowTimerSubjectTerminalListenerTest.php b/tests/Unit/Listener/FlowTimerSubjectTerminalListenerTest.php new file mode 100644 index 0000000000..a715033a20 --- /dev/null +++ b/tests/Unit/Listener/FlowTimerSubjectTerminalListenerTest.php @@ -0,0 +1,95 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Listener\FlowTimerSubjectTerminalListener; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use OCP\EventDispatcher\Event; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * @covers \OCA\OpenRegister\Listener\FlowTimerSubjectTerminalListener + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Event\FlowRunTerminalEvent + * @covers \OCA\OpenRegister\Event\TaskTerminalEvent + */ +class FlowTimerSubjectTerminalListenerTest extends TestCase { + + private FlowTimerService&MockObject $timers; + + private LoggerInterface&MockObject $logger; + + private FlowTimerSubjectTerminalListener $listener; + + protected function setUp(): void { + parent::setUp(); + $this->timers = $this->createMock(FlowTimerService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->listener = new FlowTimerSubjectTerminalListener(timers: $this->timers, logger: $this->logger); + }//end setUp() + + private function terminalTask(string $uuid, string $state, ?string $outcome): TaskTerminalEvent { + $task = new Task(); + $task->setUuid($uuid); + $task->setState($state); + $task->setOutcome($outcome); + + return new TaskTerminalEvent(task: $task); + }//end terminalTask() + + public function testATerminalTaskCancelsItsTimersWithTheReasonRecorded(): void { + $event = $this->terminalTask(uuid: 'task-1', state: 'completed', outcome: 'approved'); + self::assertSame('task-1', (string)$event->getTask()->getUuid()); + + $this->timers->expects(self::once())->method('cancelForSubject') + ->with('task', 'task-1', "Task 'task-1' reached terminal state 'completed' (outcome 'approved').", 'task:task-1') + ->willReturn(2); + $this->logger->expects(self::once())->method('info')->with(self::stringContains('Cancelled 2 timer(s) of task task-1')); + $this->listener->handle($event); + }//end testATerminalTaskCancelsItsTimersWithTheReasonRecorded() + + public function testATerminalRunCancelsItsTimers(): void { + $this->timers->expects(self::once())->method('cancelForRun') + ->with('run-1', "Run 'run-1' reached terminal status 'failed'.", 'flow-run:run-1') + ->willReturn(0); + $this->logger->expects(self::never())->method('info'); + $this->listener->handle(new FlowRunTerminalEvent(runUuid: 'run-1', status: 'failed')); + }//end testATerminalRunCancelsItsTimers() + + public function testAnUnrelatedEventIsIgnored(): void { + $this->timers->expects(self::never())->method('cancelForSubject'); + $this->timers->expects(self::never())->method('cancelForRun'); + $this->listener->handle(new Event()); + }//end testAnUnrelatedEventIsIgnored() + + public function testAFailureIsLoggedNotRethrown(): void { + $this->timers->method('cancelForSubject')->willThrowException(new RuntimeException('boom')); + $this->logger->expects(self::once())->method('error')->with(self::stringContains('boom'), self::anything()); + $this->listener->handle($this->terminalTask(uuid: 'task-1', state: 'terminated', outcome: null)); + }//end testAFailureIsLoggedNotRethrown() +}//end class diff --git a/tests/Unit/Repair/FlowTimerRepairStepsTest.php b/tests/Unit/Repair/FlowTimerRepairStepsTest.php new file mode 100644 index 0000000000..0ba661f7c8 --- /dev/null +++ b/tests/Unit/Repair/FlowTimerRepairStepsTest.php @@ -0,0 +1,141 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Repair + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Repair; + +use DateTime; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Repair\CheckFlowTimerInvariants; +use OCA\OpenRegister\Repair\SeedFlowTimerRegister; +use OCA\OpenRegister\Service\ConfigurationService; +use OCA\OpenRegister\Tests\Unit\Service\Flow\Timer\InMemoryTimerStore; +use OCP\App\IAppManager; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * @covers \OCA\OpenRegister\Repair\SeedFlowTimerRegister + * @covers \OCA\OpenRegister\Repair\CheckFlowTimerInvariants + * @covers \OCA\OpenRegister\Db\FlowTimer + * @covers \OCA\OpenRegister\Db\FlowTimerMapper + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Db\TaskMapper + */ +class FlowTimerRepairStepsTest extends TestCase { + + public function testTheSeedImportsTheDecodedDescriptorWithoutForce(): void { + $configuration = $this->createMock(ConfigurationService::class); + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getAppPath')->with('openregister')->willReturn(realpath(__DIR__ . '/../../..')); + $configuration->expects(self::once())->method('importFromApp') + ->with('openregister', self::callback(static fn (array $data): bool => count($data['components']['objects']) === 3 && isset($data['components']['schemas']['working-calendar'])), '1.0.0', false) + ->willReturn([]); + $output = $this->createMock(IOutput::class); + $output->expects(self::once())->method('info')->with(self::stringContains('Flow-timers register imported')); + + $step = new SeedFlowTimerRegister(configurationService: $configuration, appManager: $appManager, logger: new NullLogger()); + self::assertStringContainsString('flow-timers register', $step->getName()); + $step->run($output); + }//end testTheSeedImportsTheDecodedDescriptorWithoutForce() + + public function testTheSeedWarnsAndNeverThrows(): void { + $configuration = $this->createMock(ConfigurationService::class); + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getAppPath')->willReturn('/nowhere'); + $output = $this->createMock(IOutput::class); + $output->expects(self::once())->method('warning')->with(self::stringContains('not found')); + (new SeedFlowTimerRegister(configurationService: $configuration, appManager: $appManager, logger: new NullLogger()))->run($output); + + $failing = $this->createMock(ConfigurationService::class); + $failing->method('importFromApp')->willThrowException(new RuntimeException('db down')); + $real = $this->createMock(IAppManager::class); + $real->method('getAppPath')->willReturn(realpath(__DIR__ . '/../../..')); + $output2 = $this->createMock(IOutput::class); + $output2->expects(self::once())->method('warning')->with(self::stringContains('db down')); + (new SeedFlowTimerRegister(configurationService: $failing, appManager: $real, logger: new NullLogger()))->run($output2); + }//end testTheSeedWarnsAndNeverThrows() + + public function testTheInvariantCheckCountsDefectsAndCancelsNothing(): void { + $store = new InMemoryTimerStore(db: $this->createMock(IDBConnection::class)); + $timers = $store->timerMapper(); + + $healthy = $this->timer('ok', FlowTimer::STATE_ARMED, 'task-open'); + $healthy->setFireAt(new DateTime('2026-10-01')); + $timers->insert($healthy); + + $noFireAt = $this->timer('no-fire', FlowTimer::STATE_ARMED, 'task-open'); + $timers->insert($noFireAt); + + $suspendedWithClock = $this->timer('susp', FlowTimer::STATE_SUSPENDED, 'task-open'); + $suspendedWithClock->setRunningSince(new DateTime('2026-09-01')); + $timers->insert($suspendedWithClock); + + $orphanTerminal = $this->timer('orphan-1', FlowTimer::STATE_ARMED, 'task-done'); + $orphanTerminal->setFireAt(new DateTime('2026-10-01')); + $timers->insert($orphanTerminal); + + $orphanAbsent = $this->timer('orphan-2', FlowTimer::STATE_SUSPENDED, 'task-gone'); + $timers->insert($orphanAbsent); + + $fired = $this->timer('fired', FlowTimer::STATE_FIRED, 'task-gone'); + $timers->insert($fired); + + $open = new Task(); + $open->setUuid('task-open'); + $open->setState(Task::STATE_ACTIVE); + $store->tasks['task-open'] = $open; + $done = new Task(); + $done->setUuid('task-done'); + $done->setState(Task::STATE_COMPLETED); + $store->tasks['task-done'] = $done; + + $check = new CheckFlowTimerInvariants(timers: $timers, tasks: $store->taskMapper(), logger: new NullLogger()); + self::assertSame(['armedWithoutFireAt' => 1, 'suspendedWithClock' => 1, 'orphaned' => 2], $check->measure()); + + $output = $this->createMock(IOutput::class); + $output->expects(self::once())->method('warning')->with(self::stringContains('1 armed without fire_at, 1 suspended with fire_at or running_since, 2 orphaned')); + $check->run($output); + + self::assertSame(FlowTimer::STATE_ARMED, $store->timers['orphan-1']->getState(), 'reported, not cancelled'); + self::assertStringContainsString('report', $check->getName()); + }//end testTheInvariantCheckCountsDefectsAndCancelsNothing() + + public function testACleanStoreReportsInfo(): void { + $store = new InMemoryTimerStore(db: $this->createMock(IDBConnection::class)); + $output = $this->createMock(IOutput::class); + $output->expects(self::once())->method('info')->with(self::stringContains('0 armed without fire_at, 0 suspended with fire_at or running_since, 0 orphaned')); + (new CheckFlowTimerInvariants(timers: $store->timerMapper(), tasks: $store->taskMapper(), logger: new NullLogger()))->run($output); + }//end testACleanStoreReportsInfo() + + private function timer(string $uuid, string $state, string $subject): FlowTimer { + $timer = new FlowTimer(); + $timer->setUuid($uuid); + $timer->setState($state); + $timer->setSubjectType('task'); + $timer->setSubjectUuid($subject); + + return $timer; + }//end timer() +}//end class diff --git a/tests/Unit/Service/Flow/Timer/EscalationLadderServiceTest.php b/tests/Unit/Service/Flow/Timer/EscalationLadderServiceTest.php new file mode 100644 index 0000000000..05c22ac2ee --- /dev/null +++ b/tests/Unit/Service/Flow/Timer/EscalationLadderServiceTest.php @@ -0,0 +1,248 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Timer; + +use DateTime; +use DateTimeImmutable; +use DateTimeZone; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Exception\FlowTimerValidationException; +use OCA\OpenRegister\Service\Flow\Timer\EscalationLadderService; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerDefinitionStore; +use OCA\OpenRegister\Service\Flow\Timer\SlaCalculator; +use OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\Flow\Timer\EscalationLadderService + * @covers \OCA\OpenRegister\Db\FlowTimer + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Service\Flow\Timer\SlaCalculator + * @covers \OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar + */ +class EscalationLadderServiceTest extends TestCase { + + private FlowTimerDefinitionStore&MockObject $definitions; + + private EscalationLadderService $ladder; + + private WorkingCalendar $calendar; + + private DateTimeZone $tz; + + protected function setUp(): void { + parent::setUp(); + $this->definitions = $this->createMock(FlowTimerDefinitionStore::class); + $this->definitions->method('ladders')->willReturn(self::seededLadders()); + $this->ladder = new EscalationLadderService(definitions: $this->definitions, calculator: new SlaCalculator()); + $this->calendar = WorkingCalendar::fromArray(definition: WorkingCalendarTest::nlNational()); + $this->tz = new DateTimeZone('Europe/Amsterdam'); + }//end setUp() + + /** + * The seeded ladders from the shipped descriptor, keyed by slug, plus an organisation default. + * + * @return array> The ladders. + */ + public static function seededLadders(): array { + $data = json_decode((string)file_get_contents(__DIR__ . '/../../../../../lib/Settings/flow_timer_register.json'), true); + $ladders = []; + foreach ($data['components']['objects'] as $object) { + if (($object['@self']['schema'] ?? '') === 'escalation-ladder') { + unset($object['@self']); + $ladders[$object['slug']] = $object; + } + } + + $org = $ladders['nl-termijn-default']; + $org['slug'] = 'org-ladder'; + $org['organisation'] = 'org-1'; + $org['roleBindings'] = ['teamleader' => 'group:teamleaders']; + $ladders['org-ladder'] = $org; + + return $ladders; + }//end seededLadders() + + private function at(string $when): DateTimeImmutable { + return new DateTimeImmutable($when, $this->tz); + }//end at() + + private function timer(?string $ladderSlug = 'nl-termijn-default', ?array $rules = null, ?string $organisation = null): FlowTimer { + $timer = new FlowTimer(); + $timer->setUuid('t-1'); + $timer->setBudgetValue(56.0); + $timer->setBudgetUnit('calendarDays'); + $timer->setLadderSlug($ladderSlug); + $timer->setEscalationRules($rules); + $timer->setOrganisation($organisation); + + return $timer; + }//end timer() + + public function testSeededDefaultLadderIsFourteenSevenTwoZero(): void { + $rungs = $this->ladder->resolveLadder(timer: $this->timer())['rungs']; + self::assertSame([14, 7, 2, 0], array_column($rungs, 'offset')); + self::assertSame(['low', 'medium', 'high', 'critical'], array_column($rungs, 'priority')); + self::assertSame(['handler'], $rungs[0]['notifyRole']); + self::assertSame(['handler', 'teamleader'], $rungs[1]['notifyRole']); + self::assertSame(['handler', 'teamleader', 'manager'], $rungs[2]['notifyRole']); + self::assertTrue($rungs[3]['openIncident']); + self::assertSame('slaBreached', $rungs[3]['trigger']); + }//end testSeededDefaultLadderIsFourteenSevenTwoZero() + + public function testInlineRulesWinAndOrganisationDefaultIsUsedWhenNothingIsNamed(): void { + $inline = $this->ladder->resolveLadder( + timer: $this->timer(ladderSlug: 'nl-termijn-default', rules: [['trigger' => 'preBreach', 'offset' => 3, 'offsetUnit' => 'businessDays', 'notifyRole' => 'handler']]) + ); + self::assertCount(1, $inline['rungs']); + self::assertSame('preBreach:3:businessDays', $inline['rungs'][0]['key']); + + $org = $this->ladder->resolveLadder(timer: $this->timer(ladderSlug: null, organisation: 'org-1')); + self::assertCount(4, $org['rungs']); + self::assertSame(['teamleader' => 'group:teamleaders'], $org['roleBindings']); + + self::assertSame([], $this->ladder->resolveLadder(timer: $this->timer(ladderSlug: null))['rungs']); + }//end testInlineRulesWinAndOrganisationDefaultIsUsedWhenNothingIsNamed() + + public function testUnknownLadderIsRefused(): void { + $this->expectException(FlowTimerValidationException::class); + $this->expectExceptionMessage("Escalation ladder 'nope' does not exist"); + $this->ladder->resolveLadder(timer: $this->timer(ladderSlug: 'nope')); + }//end testUnknownLadderIsRefused() + + public function testARuleWithoutAnSlaIsRefused(): void { + $this->expectException(FlowTimerValidationException::class); + $this->expectExceptionMessage('refused without an SLA'); + $this->ladder->normaliseRules(rules: [['trigger' => 'preBreach', 'offset' => 1, 'offsetUnit' => 'hours']], sla: null); + }//end testARuleWithoutAnSlaIsRefused() + + public function testOffsetUnitAcceptsCalendarDaysAndRefusesTheRest(): void { + $sla = ['value' => 2, 'unit' => 'calendarDays']; + $rungs = $this->ladder->normaliseRules(rules: [['trigger' => 'preBreach', 'offset' => 1, 'offsetUnit' => 'calendarDays']], sla: $sla); + self::assertSame('calendarDays', $rungs[0]['offsetUnit']); + + foreach ([['trigger' => 'onBreach', 'offset' => 1, 'offsetUnit' => 'hours'], ['trigger' => 'preBreach', 'offset' => -1, 'offsetUnit' => 'hours'], ['trigger' => 'preBreach', 'offset' => 1, 'offsetUnit' => 'weeks'], ['trigger' => 'preBreach', 'offset' => 1, 'offsetUnit' => 'hours', 'priority' => 'urgent'], ['trigger' => 'preBreach', 'offset' => 1, 'offsetUnit' => 'hours', 'notifyRole' => [1]]] as $bad) { + try { + $this->ladder->normaliseRules(rules: [$bad], sla: $sla); + self::fail('accepted ' . json_encode($bad)); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('Escalation rule #0', $refused->getMessage()); + } + } + }//end testOffsetUnitAcceptsCalendarDaysAndRefusesTheRest() + + public function testAShortWarningOnALongerSlaIsAcceptedAcrossUnits(): void { + // SLA 2 calendarDays, preBreach 24 hours: 24h is inside 2 days. The raw + // integer comparison (24 > 2) would have refused this. + $anchor = $this->at('2026-09-03 10:00'); + $fireAt = (new SlaCalculator())->add(from: $anchor, value: 2, unit: 'calendarDays', calendar: $this->calendar); + $rungs = $this->ladder->normaliseRules(rules: [['trigger' => 'preBreach', 'offset' => 24, 'offsetUnit' => 'hours']], sla: ['value' => 2, 'unit' => 'calendarDays']); + $this->ladder->validateAgainstTimeline(rungs: $rungs, anchorAt: $anchor, fireAt: $fireAt, calendar: $this->calendar); + self::assertSame('2026-09-04 10:00', $this->ladder->rungInstant(rung: $rungs[0], fireAt: $fireAt, calendar: $this->calendar)->format('Y-m-d H:i')); + }//end testAShortWarningOnALongerSlaIsAcceptedAcrossUnits() + + public function testALongWarningOnAShorterSlaIsRefusedAcrossUnits(): void { + // SLA 48 hours, preBreach 5 businessDays: the raw comparison (5 > 48 is + // false) would have ACCEPTED this. + $anchor = $this->at('2026-09-03 10:00'); + $fireAt = (new SlaCalculator())->add(from: $anchor, value: 48, unit: 'hours', calendar: $this->calendar); + $rungs = $this->ladder->normaliseRules(rules: [['trigger' => 'preBreach', 'offset' => 5, 'offsetUnit' => 'businessDays']], sla: ['value' => 48, 'unit' => 'hours']); + $this->expectException(FlowTimerValidationException::class); + $this->expectExceptionMessage('exceeds the SLA'); + $this->ladder->validateAgainstTimeline(rungs: $rungs, anchorAt: $anchor, fireAt: $fireAt, calendar: $this->calendar); + }//end testALongWarningOnAShorterSlaIsRefusedAcrossUnits() + + public function testDueRungsFireEveryPassedRungInLadderOrderOnceEach(): void { + $rungs = $this->ladder->resolveLadder(timer: $this->timer())['rungs']; + $fireAt = $this->at('2026-09-20 12:00'); + // Deadline 5 days away: the 14-day and 7-day rungs have both passed unfired. + $now = $this->at('2026-09-15 12:00'); + $due = $this->ladder->dueRungs(rungs: $rungs, fireAt: $fireAt, now: $now, firedKeys: [], calendar: $this->calendar); + self::assertSame(['preBreach:14:calendarDays', 'preBreach:7:calendarDays'], array_map(static fn (array $entry): string => $entry['rung']['key'], $due)); + self::assertSame('2026-09-06 12:00', $due[0]['at']->format('Y-m-d H:i')); + + // Yesterday's 7-day rung fired: today, still more than 2 days out, nothing is due. + $none = $this->ladder->dueRungs(rungs: $rungs, fireAt: $fireAt, now: $now, firedKeys: ['preBreach:14:calendarDays', 'preBreach:7:calendarDays'], calendar: $this->calendar); + self::assertSame([], $none); + + // next_rung_at is the earliest unfired rung: the 2-day one. + $next = $this->ladder->nextRungAt(rungs: $rungs, fireAt: $fireAt, firedKeys: ['preBreach:14:calendarDays', 'preBreach:7:calendarDays'], calendar: $this->calendar); + self::assertSame('2026-09-18 12:00', $next->format('Y-m-d H:i')); + self::assertNull($this->ladder->nextRungAt(rungs: $rungs, fireAt: $fireAt, firedKeys: array_column($rungs, 'key'), calendar: $this->calendar)); + }//end testDueRungsFireEveryPassedRungInLadderOrderOnceEach() + + public function testHandlerResolvesToTheAssigneeUserOrGroup(): void { + $rung = $this->ladder->resolveLadder(timer: $this->timer())['rungs'][1]; + + $user = new Task(); + $user->setPerformerType(Task::PERFORMER_USER); + $user->setAssignee('alice'); + self::assertSame( + [['type' => 'user', 'id' => 'alice', 'role' => 'handler'], ['type' => 'role', 'id' => 'teamleader', 'role' => 'teamleader']], + $this->ladder->resolveRecipients(rung: $rung, subject: $user, roleBindings: []) + ); + + // The GROUP path: a group performer's assignee is a gid. + $group = new Task(); + $group->setPerformerType(Task::PERFORMER_GROUP); + $group->setAssignee('vergunningen'); + self::assertSame( + [['type' => 'group', 'id' => 'vergunningen', 'role' => 'handler'], ['type' => 'group', 'id' => 'teamleaders', 'role' => 'teamleader']], + $this->ladder->resolveRecipients(rung: $rung, subject: $group, roleBindings: ['teamleader' => 'group:teamleaders']) + ); + }//end testHandlerResolvesToTheAssigneeUserOrGroup() + + public function testUnassignedHandlerFallsBackToCandidateGroupsThenUsersThenRole(): void { + $rung = $this->ladder->resolveLadder(timer: $this->timer())['rungs'][0]; + + $pooled = new Task(); + $pooled->setPerformerType(Task::PERFORMER_USER); + $pooled->setCandidateGroups(['team-a', 'team-b']); + $pooled->setCandidateUsers(['bob']); + self::assertSame( + [['type' => 'group', 'id' => 'team-a', 'role' => 'handler'], ['type' => 'group', 'id' => 'team-b', 'role' => 'handler']], + $this->ladder->resolveRecipients(rung: $rung, subject: $pooled, roleBindings: []) + ); + + $users = new Task(); + $users->setPerformerType(Task::PERFORMER_USER); + $users->setCandidateUsers(['bob', 'carol']); + self::assertSame( + [['type' => 'user', 'id' => 'bob', 'role' => 'handler'], ['type' => 'user', 'id' => 'carol', 'role' => 'handler']], + $this->ladder->resolveRecipients(rung: $rung, subject: $users, roleBindings: []) + ); + + self::assertSame([['type' => 'role', 'id' => 'handler', 'role' => 'handler']], $this->ladder->resolveRecipients(rung: $rung, subject: null, roleBindings: [])); + }//end testUnassignedHandlerFallsBackToCandidateGroupsThenUsersThenRole() + + public function testSlaBreachedRungFallsAfterTheDeadline(): void { + $rung = $this->ladder->normaliseRules(rules: [['trigger' => 'slaBreached', 'offset' => 2, 'offsetUnit' => 'calendarDays']], sla: ['value' => 5, 'unit' => 'calendarDays'])[0]; + self::assertSame('slaBreached:2:calendarDays', $rung['key']); + self::assertSame('2026-09-22 12:00', $this->ladder->rungInstant(rung: $rung, fireAt: $this->at('2026-09-20 12:00'), calendar: $this->calendar)->format('Y-m-d H:i')); + // A slaBreached rung is never checked against the anchor. + $this->ladder->validateAgainstTimeline(rungs: [$rung], anchorAt: $this->at('2026-09-19 12:00'), fireAt: $this->at('2026-09-20 12:00'), calendar: $this->calendar); + self::assertInstanceOf(DateTime::class, new DateTime()); + }//end testSlaBreachedRungFallsAfterTheDeadline() +}//end class diff --git a/tests/Unit/Service/Flow/Timer/FlowTimerDefinitionStoreTest.php b/tests/Unit/Service/Flow/Timer/FlowTimerDefinitionStoreTest.php new file mode 100644 index 0000000000..5a5d1726c0 --- /dev/null +++ b/tests/Unit/Service/Flow/Timer/FlowTimerDefinitionStoreTest.php @@ -0,0 +1,85 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Timer; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerDefinitionStore; +use OCA\OpenRegister\Service\ObjectService; +use OCP\App\IAppManager; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\Flow\Timer\FlowTimerDefinitionStore + */ +class FlowTimerDefinitionStoreTest extends TestCase { + + private ObjectService&MockObject $objects; + + private FlowTimerDefinitionStore $store; + + protected function setUp(): void { + parent::setUp(); + $this->objects = $this->createMock(ObjectService::class); + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getAppPath')->with('openregister')->willReturn(realpath(__DIR__ . '/../../../../..')); + $this->store = new FlowTimerDefinitionStore(objects: $this->objects, appManager: $appManager, logger: new NullLogger()); + }//end setUp() + + public function testSeededObjectsAreReadBySlugFromTheRegister(): void { + $entity = $this->createMock(ObjectEntity::class); + $entity->method('getObject')->willReturn(['slug' => 'custom', 'hoursPerWorkingDay' => 6]); + $nameless = $this->createMock(ObjectEntity::class); + $nameless->method('getObject')->willReturn(['hoursPerWorkingDay' => 6]); + $this->objects->expects(self::once())->method('searchObjectsBySlug') + ->with('flow-timers', 'working-calendar', [], false, false) + ->willReturn([$entity, $nameless]); + + $calendars = $this->store->calendars(); + self::assertSame(['custom'], array_keys($calendars), 'an object without a slug is not addressable'); + self::assertSame($calendars, $this->store->calendars(), 'memoised: the register is read once'); + }//end testSeededObjectsAreReadBySlugFromTheRegister() + + public function testTheShippedDescriptorAnswersWhenTheRegisterIsAbsentOrEmpty(): void { + $this->objects->method('searchObjectsBySlug')->willReturnCallback(static function (string $register, string $schema): array { + if ($schema === 'working-calendar') { + throw new DoesNotExistException('not seeded yet'); + } + + return []; + }); + + self::assertSame(['nl-national', 'example-organisation'], array_keys($this->store->calendars())); + self::assertArrayNotHasKey('@self', $this->store->calendars()['nl-national']); + self::assertSame(['nl-termijn-default'], array_keys($this->store->ladders())); + self::assertSame([14, 7, 2, 0], array_column($this->store->ladders()['nl-termijn-default']['rungs'], 'offset')); + }//end testTheShippedDescriptorAnswersWhenTheRegisterIsAbsentOrEmpty() + + public function testResetForgetsTheMemoisedDefinitions(): void { + $this->objects->expects(self::exactly(2))->method('searchObjectsBySlug')->willReturn([]); + $this->store->ladders(); + $this->store->reset(); + $this->store->ladders(); + }//end testResetForgetsTheMemoisedDefinitions() +}//end class diff --git a/tests/Unit/Service/Flow/Timer/FlowTimerEdgeCasesTest.php b/tests/Unit/Service/Flow/Timer/FlowTimerEdgeCasesTest.php new file mode 100644 index 0000000000..31c58e9092 --- /dev/null +++ b/tests/Unit/Service/Flow/Timer/FlowTimerEdgeCasesTest.php @@ -0,0 +1,186 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Timer; + +use DateTimeImmutable; +use DateTimeZone; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Exception\FlowTimerStateException; +use OCA\OpenRegister\Exception\FlowTimerValidationException; +use OCA\OpenRegister\Service\Flow\Timer\EscalationLadderService; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerDefinitionStore; +use OCA\OpenRegister\Service\Flow\Timer\SlaCalculator; +use OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar; +use OCA\OpenRegister\Service\ObjectService; +use OCP\App\IAppManager; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\Flow\Timer\SlaCalculator + * @covers \OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar + * @covers \OCA\OpenRegister\Service\Flow\Timer\EscalationLadderService + * @covers \OCA\OpenRegister\Service\Flow\Timer\FlowTimerDefinitionStore + * @covers \OCA\OpenRegister\Db\FlowTimer + * @covers \OCA\OpenRegister\Exception\FlowTimerStateException + * @covers \OCA\OpenRegister\Exception\FlowTimerValidationException + */ +class FlowTimerEdgeCasesTest extends TestCase { + + private WorkingCalendar $calendar; + + private SlaCalculator $calculator; + + protected function setUp(): void { + parent::setUp(); + $this->calendar = WorkingCalendar::fromArray(definition: WorkingCalendarTest::nlNational()); + $this->calculator = new SlaCalculator(); + }//end setUp() + + private function at(string $when): DateTimeImmutable { + return new DateTimeImmutable($when, new DateTimeZone('Europe/Amsterdam')); + }//end at() + + public function testTheBusinessDayWalkIsBounded(): void { + $noWork = WorkingCalendar::fromArray( + definition: ['slug' => 'sundays-only', 'workingWeekdays' => [7], 'hoursPerWorkingDay' => 8, 'rules' => [['kind' => 'fixed', 'month' => 1, 'day' => 1, 'name' => 'x']]] + ); + try { + $this->calculator->add(from: $this->at('2026-09-01 09:00'), value: 9000, unit: 'businessDays', calendar: $noWork); + self::fail('an unbounded walk terminated'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('exceeds 20000 calendar days', $refused->getMessage()); + } + + try { + $this->calculator->sub(from: $this->at('2026-09-01 09:00'), value: 9000, unit: 'businessDays', calendar: $noWork); + self::fail('an unbounded backward walk terminated'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('exceeds 20000 calendar days', $refused->getMessage()); + } + + try { + $this->calculator->measure(from: $this->at('1926-01-01 00:00'), to: $this->at('2026-09-01 00:00'), unit: 'businessDays', calendar: $noWork); + self::fail('an unbounded measure terminated'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('exceeds 20000 calendar days', $refused->getMessage()); + } + }//end testTheBusinessDayWalkIsBounded() + + public function testEveryMalformedCalendarShapeIsNamed(): void { + $base = WorkingCalendarTest::nlNational(); + $cases = [ + ['slug' => ''], + ['hoursPerWorkingDay' => 0], + ['hoursPerWorkingDay' => 25], + ['workingWeekdays' => [0]], + ['workingWeekdays' => ['monday']], + ['rules' => 'weekdays'], + ['rules' => [['kind' => 'easter', 'offset' => 'friday']]], + ['rules' => [['kind' => 'fixed', 'month' => 13, 'day' => 1]]], + ['rules' => [['kind' => 'observedShift', 'month' => 4, 'day' => 27, 'whenWeekday' => 7, 'days' => 'one']]], + ['exceptions' => 'none'], + ['exceptions' => [20261225]], + ['exceptions' => [['name' => 'no date']]], + ]; + foreach ($cases as $broken) { + try { + WorkingCalendar::fromArray(definition: array_merge($base, $broken)); + self::fail('accepted ' . json_encode($broken)); + } catch (FlowTimerValidationException $refused) { + self::assertNotSame('', $refused->getMessage()); + } + } + }//end testEveryMalformedCalendarShapeIsNamed() + + public function testAnEasterRuleAndAnExceptionOnlyApplyToTheirOwnYear(): void { + $definition = WorkingCalendarTest::nlNational(); + $definition['exceptions'] = ['2026-10-05']; + $calendar = WorkingCalendar::fromArray(definition: $definition); + self::assertArrayHasKey('2026-10-05', $calendar->nonWorkingDates(year: 2026)); + self::assertSame('exception', $calendar->nonWorkingDates(year: 2026)['2026-10-05']); + self::assertArrayNotHasKey('2026-10-05', $calendar->nonWorkingDates(year: 2027)); + }//end testAnEasterRuleAndAnExceptionOnlyApplyToTheirOwnYear() + + public function testLadderRefusalsNameTheRule(): void { + $definitions = $this->createMock(FlowTimerDefinitionStore::class); + $definitions->method('ladders')->willReturn(EscalationLadderServiceTest::seededLadders()); + $ladder = new EscalationLadderService(definitions: $definitions, calculator: $this->calculator); + + try { + $ladder->normaliseRules(rules: 'daily', sla: ['value' => 1, 'unit' => 'hours']); + self::fail('accepted a non-array rule set'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('array of rules', $refused->getMessage()); + } + + try { + $ladder->normaliseRules(rules: ['soon'], sla: ['value' => 1, 'unit' => 'hours']); + self::fail('accepted a non-object rule'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('not an object', $refused->getMessage()); + } + + try { + $ladder->normaliseRules( + rules: [['trigger' => 'preBreach', 'offset' => 1, 'offsetUnit' => 'hours', 'notifyRole' => 42]], + sla: ['value' => 1, 'unit' => 'hours'] + ); + self::fail('accepted a numeric role list'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('notifyRole', $refused->getMessage()); + } + + // A single role as a bare string is normalised to a list; equal rung + // instants keep their declared order. + $rungs = $ladder->normaliseRules( + rules: [ + ['key' => 'b', 'trigger' => 'slaBreached', 'offset' => 0, 'offsetUnit' => 'hours', 'notifyRole' => 'handler'], + ['key' => 'a', 'trigger' => 'slaBreached', 'offset' => 0, 'offsetUnit' => 'calendarDays', 'notifyRole' => 'handler'], + ], + sla: ['value' => 1, 'unit' => 'hours'] + ); + self::assertSame(['handler'], $rungs[0]['notifyRole']); + $fireAt = $this->at('2026-09-20 12:00'); + $due = $ladder->dueRungs(rungs: $rungs, fireAt: $fireAt, now: $fireAt, firedKeys: [], calendar: $this->calendar); + self::assertSame(['b', 'a'], array_map(static fn (array $entry): string => (string)$entry['rung']['key'], $due)); + }//end testLadderRefusalsNameTheRule() + + public function testTheDefinitionStoreDegradesLoudlyAndQuietly(): void { + $objects = $this->createMock(ObjectService::class); + $objects->method('searchObjectsBySlug')->willReturn(7); + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getAppPath')->willReturn('/nowhere-at-all'); + $store = new FlowTimerDefinitionStore(objects: $objects, appManager: $appManager, logger: new NullLogger()); + self::assertSame([], $store->calendars(), 'a count result and an unreadable descriptor answer empty, not wrongly'); + self::assertSame([], $store->ladders()); + }//end testTheDefinitionStoreDegradesLoudlyAndQuietly() + + public function testSupersededAndCancelledTimersRefuseSuspension(): void { + $timer = new FlowTimer(); + $timer->setState(FlowTimer::STATE_SUPERSEDED); + self::assertFalse($timer->isOpen()); + self::assertInstanceOf(FlowTimerStateException::class, new FlowTimerStateException(message: 'x')); + }//end testSupersededAndCancelledTimersRefuseSuspension() +}//end class diff --git a/tests/Unit/Service/Flow/Timer/FlowTimerServiceTest.php b/tests/Unit/Service/Flow/Timer/FlowTimerServiceTest.php new file mode 100644 index 0000000000..c1d4e36a95 --- /dev/null +++ b/tests/Unit/Service/Flow/Timer/FlowTimerServiceTest.php @@ -0,0 +1,928 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Timer; + +use DateTimeImmutable; +use DateTimeZone; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerEvent; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Event\FlowTimerFiredEvent; +use OCA\OpenRegister\Exception\FlowTimerStateException; +use OCA\OpenRegister\Exception\FlowTimerValidationException; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Service\Flow\Timer\EscalationLadderService; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerDefinitionStore; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use OCA\OpenRegister\Service\Flow\Timer\SlaCalculator; +use OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar; +use OCA\OpenRegister\Service\Flow\Timer\WorkingCalendarService; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\Flow\Timer\FlowTimerService + * @covers \OCA\OpenRegister\Service\Flow\Timer\WorkingCalendarService + * @covers \OCA\OpenRegister\Db\FlowTimer + * @covers \OCA\OpenRegister\Db\FlowTimerEvent + * @covers \OCA\OpenRegister\Db\FlowTimerEventMapper + * @covers \OCA\OpenRegister\Db\FlowTimerFireMapper + * @covers \OCA\OpenRegister\Db\FlowTimerMapper + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Db\TaskMapper + * @covers \OCA\OpenRegister\Event\FlowTimerFiredEvent + * @covers \OCA\OpenRegister\Exception\TaskConflictException + * @covers \OCA\OpenRegister\Service\Flow\Timer\EscalationLadderService + * @covers \OCA\OpenRegister\Service\Flow\Timer\SlaCalculator + * @covers \OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar + * @covers \OCA\OpenRegister\Db\FlowTimerFire + */ +class FlowTimerServiceTest extends TestCase { + + private InMemoryTimerStore $store; + + private TaskService&MockObject $taskService; + + private WorkingCalendarService $calendars; + + private SlaCalculator $calculator; + + /** + * @var array + */ + private array $dispatched = []; + + private FlowTimerService $service; + + private DateTimeZone $tz; + + protected function setUp(): void { + parent::setUp(); + $this->tz = new DateTimeZone('Europe/Amsterdam'); + $db = $this->createMock(IDBConnection::class); + $this->store = new InMemoryTimerStore(db: $db); + $this->taskService = $this->createMock(TaskService::class); + $this->calculator = new SlaCalculator(); + $this->service = $this->buildService(); + }//end setUp() + + /** + * A service over the SAME store: a "restart" keeps the rows and loses everything else. + */ + private function buildService(): FlowTimerService { + $definitions = $this->createMock(FlowTimerDefinitionStore::class); + $definitions->method('calendars')->willReturn(self::calendars()); + $definitions->method('ladders')->willReturn(EscalationLadderServiceTest::seededLadders()); + $this->calendars = new WorkingCalendarService(definitions: $definitions); + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback(function (object $event): void { + $this->dispatched[] = $event; + }); + + return new FlowTimerService( + timers: $this->store->timerMapper(), + fires: $this->store->fireMapper(), + events: $this->store->eventMapper(), + tasks: $this->store->taskMapper(), + taskService: $this->taskService, + calendars: $this->calendars, + calculator: $this->calculator, + ladder: new EscalationLadderService(definitions: $definitions, calculator: $this->calculator), + db: $this->createMock(IDBConnection::class), + dispatcher: $dispatcher, + logger: new NullLogger() + ); + }//end buildService() + + /** + * @return array> + */ + private static function calendars(): array { + $data = json_decode((string)file_get_contents(__DIR__ . '/../../../../../lib/Settings/flow_timer_register.json'), true); + $calendars = []; + foreach ($data['components']['objects'] as $object) { + if (($object['@self']['schema'] ?? '') === 'working-calendar') { + unset($object['@self']); + $calendars[$object['slug']] = $object; + } + } + + return $calendars; + }//end calendars() + + private function at(string $when): DateTimeImmutable { + return new DateTimeImmutable($when, $this->tz); + }//end at() + + private function task(string $uuid = 'task-1', string $performerType = Task::PERFORMER_USER, ?string $assignee = 'alice'): Task { + $task = new Task(); + $task->setId(1); + $task->setUuid($uuid); + $task->setState(Task::STATE_ACTIVE); + $task->setPerformerType($performerType); + $task->setAssignee($assignee); + $this->store->tasks[$uuid] = $task; + + return $task; + }//end task() + + /** + * @param array $overrides + */ + private function config(array $overrides = []): array { + return array_merge( + [ + 'subjectType' => 'task', + 'subjectUuid' => 'task-1', + 'purpose' => 'due', + 'legalEffect' => 'servicenorm', + 'sla' => ['value' => 56, 'unit' => 'calendarDays'], + 'anchorEventAt' => $this->at('2026-09-01 09:00'), + 'ladder' => 'nl-termijn-default', + ], + $overrides + ); + }//end config() + + /** + * The identity every armed timer satisfies, and NULLs every suspended one has. + */ + private function assertInvariants(FlowTimer $timer): void { + $calendar = $this->calendars->resolve(calendarSlug: $timer->getCalendarSlug(), organisation: $timer->getOrganisation()); + if ($timer->getState() === FlowTimer::STATE_ARMED) { + $expected = $this->calculator->add( + from: $timer->getRunningSince(), + value: (float)$timer->getBudgetValue() - (float)$timer->getConsumedValue(), + unit: (string)$timer->getBudgetUnit(), + calendar: $calendar + ); + self::assertNotNull($timer->getFireAt()); + self::assertEqualsWithDelta($expected->getTimestamp(), $timer->getFireAt()->getTimestamp(), 1, 'fire_at = add(running_since, budget - consumed)'); + } + + if ($timer->getState() === FlowTimer::STATE_SUSPENDED) { + self::assertNull($timer->getFireAt()); + self::assertNull($timer->getRunningSince()); + self::assertNull($timer->getNextRungAt()); + } + + if ($timer->getSubjectType() === 'task' && isset($this->store->tasks[(string)$timer->getSubjectUuid()]) === true) { + $task = $this->store->tasks[(string)$timer->getSubjectUuid()]; + $open = array_filter( + $this->store->timers, + static fn (FlowTimer $t): bool => $t->getSubjectUuid() === $timer->getSubjectUuid() && $t->isOpen() && $t->getFireAt() !== null + ); + $due = array_filter($open, static fn (FlowTimer $t): bool => $t->getPurpose() === 'due'); + $expiry = array_filter($open, static fn (FlowTimer $t): bool => $t->getPurpose() === 'expiry'); + self::assertSame( + self::earliest($due), + ($task->getDueAt() === null) ? null : $task->getDueAt()->getTimestamp(), + 'task.due_at is the earliest open due timer' + ); + self::assertSame( + self::earliest($expiry), + ($task->getExpiresAt() === null) ? null : $task->getExpiresAt()->getTimestamp(), + 'task.expires_at is the earliest open expiry timer' + ); + } + }//end assertInvariants() + + /** + * @param array $timers + */ + private static function earliest(array $timers): ?int { + $min = null; + foreach ($timers as $timer) { + $ts = $timer->getFireAt()->getTimestamp(); + if ($min === null || $ts < $min) { + $min = $ts; + } + } + + return $min; + }//end earliest() + + public function testArmStoresTheAnchorAndProjectsOntoTheTask(): void { + $this->task(); + $timer = $this->service->arm(config: $this->config(), actor: 'alice', now: $this->at('2026-09-01 09:00')); + + self::assertSame(FlowTimer::STATE_ARMED, $timer->getState()); + self::assertSame('2026-09-01 09:00', $timer->getAnchorAt()->format('Y-m-d H:i')); + self::assertSame('2026-10-27 09:00', $timer->getFireAt()->format('Y-m-d H:i'), '56 calendar days'); + self::assertSame('2026-10-13 09:00', $timer->getNextRungAt()->format('Y-m-d H:i'), 'the 14-day rung'); + self::assertSame('2026-10-27 09:00', $this->store->tasks['task-1']->getDueAt()->format('Y-m-d H:i')); + self::assertNull($this->store->tasks['task-1']->getExpiresAt()); + self::assertCount(1, $this->store->events); + self::assertSame(FlowTimerEvent::TYPE_ARMED, $this->store->events[0]->getType()); + self::assertSame('alice', $this->store->events[0]->getActor()); + $this->assertInvariants($timer); + }//end testArmStoresTheAnchorAndProjectsOntoTheTask() + + public function testTheClockStartsTheDayAfterTheWindowCloses(): void { + // Objection received on the 3rd, window closes on the 20th, term anchored to window_closed + 1 calendar day. + $timer = $this->service->arm( + config: $this->config([ + 'subjectType' => 'object', + 'subjectUuid' => 'bezwaar-1', + 'anchorEvent' => 'window_closed', + 'anchorEventAt' => $this->at('2026-03-20 00:00'), + 'anchorOffset' => 1, + 'anchorOffsetUnit' => 'calendarDays', + 'sla' => ['value' => 6, 'unit' => 'businessDays'], + 'ladder' => null, + ]), + actor: null, + now: $this->at('2026-03-03 10:00') + ); + self::assertSame('2026-03-21', $timer->getAnchorAt()->format('Y-m-d'), 'the term starts on the 21st, not the 3rd'); + self::assertSame('window_closed', $timer->getAnchorEvent()); + self::assertSame(1, $timer->getAnchorOffset()); + self::assertSame('calendarDays', $timer->getAnchorOffsetUnit()); + self::assertNull($timer->getRunUuid(), 'a run is not required'); + self::assertNull($timer->getNextRungAt(), 'no ladder, no rung'); + $this->assertInvariants($timer); + }//end testTheClockStartsTheDayAfterTheWindowCloses() + + public function testAnUnknownCalendarIsRefusedNotDowngraded(): void { + $this->task(); + try { + $this->service->arm(config: $this->config(['calendar' => 'moon-phases']), actor: null, now: $this->at('2026-09-01 09:00')); + self::fail('armed against a calendar that does not exist'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString("Working calendar 'moon-phases' does not exist", $refused->getMessage()); + } + + self::assertSame([], $this->store->timers, 'no timer was created with a substituted calendar'); + }//end testAnUnknownCalendarIsRefusedNotDowngraded() + + public function testOnlyAWettelijkExpiryTimerMayEnforce(): void { + $this->task(); + try { + $this->service->arm(config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'servicenorm', 'onExpiry' => 'skip']), actor: null); + self::fail('a servicenorm timer accepted an enforcing outcome'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString("only a 'wettelijk' timer may carry an enforcing outcome", $refused->getMessage()); + } + + try { + $this->service->arm(config: $this->config(['purpose' => 'due', 'legalEffect' => 'wettelijk', 'onExpiry' => 'skip']), actor: null); + self::fail('a due timer accepted an enforcing outcome'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('only an expiry timer enforces', $refused->getMessage()); + } + + try { + $this->service->arm(config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'vanish']), actor: null); + self::fail('an unknown outcome was accepted'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('use skip, error, dead_letter or transition:', $refused->getMessage()); + } + + $ok = $this->service->arm(config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'transition:approve']), actor: null); + self::assertTrue($ok->isEnforcing()); + self::assertSame([], array_filter($this->store->timers, static fn (FlowTimer $t): bool => $t->getOnExpiry() === 'skip')); + }//end testOnlyAWettelijkExpiryTimerMayEnforce() + + public function testAPreBreachRuleBeyondTheSlaIsRefusedAtArmTimeNamingTheAnchor(): void { + $this->task(); + $this->expectException(FlowTimerValidationException::class); + $this->expectExceptionMessage('before the anchor 2026-09-01T09:00:00+02:00'); + $this->service->arm( + config: $this->config([ + 'sla' => ['value' => 48, 'unit' => 'hours'], + 'ladder' => null, + 'escalationRules' => [['trigger' => 'preBreach', 'offset' => 5, 'offsetUnit' => 'businessDays', 'notifyRole' => 'handler']], + ]), + actor: null, + now: $this->at('2026-09-01 09:00') + ); + }//end testAPreBreachRuleBeyondTheSlaIsRefusedAtArmTimeNamingTheAnchor() + + public function testAHersteltermijnPauseReturnsTheRemainderIntact(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm(config: $this->config(), actor: 'alice', now: $start); + $uuid = (string)$timer->getUuid(); + + // Day 19: suspended for a 14-day hersteltermijn. + $suspended = $this->service->suspend( + uuid: $uuid, + reason: 'Hersteltermijn: aanvulling gevraagd', + until: $start->modify('+33 days'), + actor: 'bob', + basis: 'Awb 4:15', + now: $start->modify('+19 days') + ); + self::assertSame(FlowTimer::STATE_SUSPENDED, $suspended->getState()); + self::assertEqualsWithDelta(19.0, $suspended->getConsumedValue(), 0.001); + self::assertNull($suspended->getFireAt()); + $this->assertInvariants($suspended); + self::assertNull($this->store->tasks['task-1']->getDueAt(), 'a suspended term projects no due date'); + self::assertSame('2026-10-04', $this->store->tasks['task-1']->getSuspendedUntil()->format('Y-m-d')); + + // Remaining is answerable while suspended, and the term is not overdue even past the original fire moment. + $read = $this->service->describe(timer: $suspended, now: $start->modify('+70 days')); + self::assertEqualsWithDelta(37.0, $read['remaining'], 0.001); + self::assertFalse($read['overdue']); + self::assertNull($read['overdueBy']); + + // The applicant responds on day 6 of the suspension. + $resumed = $this->service->resume(uuid: $uuid, reason: 'Aanvulling ontvangen', actor: 'bob', now: $start->modify('+25 days')); + self::assertSame(FlowTimer::STATE_ARMED, $resumed->getState()); + self::assertEqualsWithDelta(37.0, $this->service->describe(timer: $resumed, now: $start->modify('+25 days'))['remaining'], 0.001); + self::assertSame($start->modify('+62 days')->format('Y-m-d H:i'), $resumed->getFireAt()->format('Y-m-d H:i'), '8 weeks minus 19 days, from the resume instant'); + self::assertSame(6 * 86400, $resumed->getSuspendedTotalSeconds()); + $this->assertInvariants($resumed); + + // Evidenced: actor, moment, reason and basis on both events. + $history = $this->service->history(uuid: $uuid); + self::assertSame([FlowTimerEvent::TYPE_ARMED, FlowTimerEvent::TYPE_SUSPENDED, FlowTimerEvent::TYPE_RESUMED], array_map(static fn (FlowTimerEvent $e): string => $e->getType(), $history)); + self::assertSame('bob', $history[1]->getActor()); + self::assertSame('Awb 4:15', $history[1]->getBasis()); + self::assertStringContainsString('Hersteltermijn', $history[1]->getReason()); + self::assertSame($start->modify('+19 days')->getTimestamp(), $history[1]->getCreated()->getTimestamp()); + self::assertSame('Aanvulling ontvangen', $history[2]->getReason()); + }//end testAHersteltermijnPauseReturnsTheRemainderIntact() + + public function testABusinessDayTermSuspendedOverAWeekendKeepsItsBusinessDays(): void { + $this->task(); + // Armed Monday 09:00 with 10 business days. + $timer = $this->service->arm( + config: $this->config(['sla' => ['value' => 10, 'unit' => 'businessDays'], 'anchorEventAt' => $this->at('2026-08-31 09:00')]), + actor: null, + now: $this->at('2026-08-31 09:00') + ); + $uuid = (string)$timer->getUuid(); + $friday = $this->at('2026-09-04 17:00'); + $monday = $this->at('2026-09-07 09:00'); + + $before = $this->service->describe(timer: $timer, now: $friday)['remaining']; + $this->service->suspend(uuid: $uuid, reason: 'weekend closure test', until: null, actor: null, now: $friday); + $resumed = $this->service->resume(uuid: $uuid, reason: null, actor: null, now: $monday); + $after = $this->service->describe(timer: $resumed, now: $monday)['remaining']; + + self::assertEqualsWithDelta($before, $after, 0.0001, 'the weekend consumed nothing'); + self::assertEqualsWithDelta(10 - (4 + (8 / 24)), $after, 0.0001, 'four full days and 8 hours of Friday ran'); + $this->assertInvariants($resumed); + }//end testABusinessDayTermSuspendedOverAWeekendKeepsItsBusinessDays() + + public function testSuspendAndResumeRefuseTheWrongState(): void { + $this->task(); + $timer = $this->service->arm(config: $this->config(), actor: null, now: $this->at('2026-09-01 09:00')); + try { + $this->service->resume(uuid: (string)$timer->getUuid(), reason: null, actor: null); + self::fail('resumed a running timer'); + } catch (FlowTimerStateException $refused) { + self::assertStringContainsString("'armed', not 'suspended'", $refused->getMessage()); + } + + try { + $this->service->suspend(uuid: (string)$timer->getUuid(), reason: ' ', until: null, actor: null); + self::fail('suspended without a reason'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('non-empty reason', $refused->getMessage()); + } + }//end testSuspendAndResumeRefuseTheWrongState() + + public function testTheSecondExtensionIsRefusedNamingTheBoundAndTheOverrideIsRecordedAsOne(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm(config: $this->config(), actor: null, now: $start); + $uuid = (string)$timer->getUuid(); + + $extended = $this->service->extend(uuid: $uuid, amount: 7, unit: 'calendarDays', rationale: 'Verdaging: advies derde nodig', actor: 'carol', now: $start->modify('+10 days')); + self::assertSame(1, $extended->getExtensionCount()); + self::assertSame('2026-11-03 09:00', $extended->getFireAt()->format('Y-m-d H:i')); + $this->assertInvariants($extended); + $events = $this->service->history(uuid: $uuid); + self::assertSame(FlowTimerEvent::TYPE_EXTENDED, $events[1]->getType()); + self::assertSame('Awb 4:14', $events[1]->getBasis()); + self::assertSame('2026-10-27', $events[1]->getPriorFireAt()->format('Y-m-d')); + self::assertSame('2026-11-03', $events[1]->getNewFireAt()->format('Y-m-d')); + + try { + $this->service->extend(uuid: $uuid, amount: 7, unit: 'calendarDays', rationale: 'nog een keer', actor: 'carol', now: $start->modify('+11 days')); + self::fail('a second extension went through the standard path'); + } catch (FlowTimerStateException $refused) { + self::assertStringContainsString('extension bound of 1', $refused->getMessage()); + } + + self::assertSame('2026-11-03 09:00', $this->store->timers[$uuid]->getFireAt()->format('Y-m-d H:i'), 'the fire moment is unchanged'); + + // Cross-unit extension: 2 business days on a calendar-day budget is 16 hours = 2/3 day. + $overridden = $this->service->extendWithOverride(uuid: $uuid, amount: 2, unit: 'businessDays', rationale: 'Supervisor override', actor: 'supervisor', now: $start->modify('+12 days')); + self::assertSame(2, $overridden->getExtensionCount()); + self::assertEqualsWithDelta(63 + (16 / 24), $overridden->getBudgetValue(), 0.0001); + self::assertSame('override', $this->service->history(uuid: $uuid)[2]->getBasis()); + $this->assertInvariants($overridden); + + try { + $this->service->extendWithOverride(uuid: $uuid, amount: 1, unit: 'hours', rationale: '', actor: 'supervisor', now: $start->modify('+12 days')); + self::fail('extended without a rationale'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('non-empty rationale', $refused->getMessage()); + } + + try { + $this->service->extendWithOverride(uuid: $uuid, amount: 1, unit: 'hours', rationale: 'x', actor: ' ', now: $start->modify('+12 days')); + self::fail('an override went through without an authorizing identity'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('authorizing identity', $refused->getMessage()); + } + }//end testTheSecondExtensionIsRefusedNamingTheBoundAndTheOverrideIsRecordedAsOne() + + public function testExtendingAfterExpiryIsRefusedAndTheBreachStays(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm( + config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'error', 'ladder' => null]), + actor: null, + now: $start + ); + $uuid = (string)$timer->getUuid(); + + // Past the fire moment but not yet swept: refused because the term has run out. + try { + $this->service->extend(uuid: $uuid, amount: 1, unit: 'calendarDays', rationale: 'te laat', actor: null, now: $start->modify('+57 days')); + self::fail('extended a term that had run out'); + } catch (FlowTimerStateException $refused) { + self::assertStringContainsString('has passed', $refused->getMessage()); + } + + self::assertTrue($this->service->fireExpiry(timer: $this->store->timers[$uuid], now: $start->modify('+57 days'))); + self::assertTrue($this->store->timers[$uuid]->getBreached()); + try { + $this->service->extend(uuid: $uuid, amount: 1, unit: 'calendarDays', rationale: 'te laat', actor: null, now: $start->modify('+58 days')); + self::fail('extended a fired timer'); + } catch (FlowTimerStateException $refused) { + self::assertStringContainsString("its state is 'fired'", $refused->getMessage()); + } + + self::assertTrue($this->store->timers[$uuid]->getBreached(), 'the recorded breach remains recorded'); + self::assertSame(FlowTimerEvent::TYPE_BREACHED, $this->service->history(uuid: $uuid)[1]->getType()); + }//end testExtendingAfterExpiryIsRefusedAndTheBreachStays() + + public function testAMovedAnchorSupersedesTheTimerAndInheritsOnlyTheRungsStillInThePast(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm(config: $this->config(), actor: null, now: $start); + $uuid = (string)$timer->getUuid(); + + // Day 50: the 14-day and 7-day rungs have both fired. + self::assertSame(2, $this->service->fireRungs(timer: $this->store->timers[$uuid], now: $start->modify('+50 days'))); + self::assertCount(2, $this->store->fires); + + // The window is extended by 3 days: the anchoring event moves. + $successor = $this->service->supersede(uuid: $uuid, anchorEventAt: $start->modify('+3 days'), reason: 'Bezwaartermijn verlengd', actor: 'carol', now: $start->modify('+50 days')); + $prior = $this->store->timers[$uuid]; + + self::assertSame(FlowTimer::STATE_SUPERSEDED, $prior->getState()); + self::assertNull($prior->getFireAt()); + self::assertSame($uuid, $successor->getSupersedesUuid()); + self::assertSame('2026-09-04 09:00', $successor->getAnchorAt()->format('Y-m-d H:i')); + self::assertSame('2026-10-30 09:00', $successor->getFireAt()->format('Y-m-d H:i')); + $this->assertInvariants($successor); + + // The 14-day rung (16 Oct) is still in the past under the new deadline: inherited. + // The 7-day rung (23 Oct) moved back into the future: NOT inherited, it fires again legitimately. + $inherited = array_filter($this->store->fires, static fn ($f): bool => $f->getTimerUuid() === $successor->getUuid()); + self::assertCount(1, $inherited); + $row = array_values($inherited)[0]; + self::assertSame('preBreach:14:calendarDays', $row->getRungKey()); + self::assertTrue($row->getInherited()); + self::assertSame('2026-10-23 09:00', $successor->getNextRungAt()->format('Y-m-d H:i')); + + // The superseded row never fires: no scan returns it. + $mapper = $this->store->timerMapper(); + self::assertSame([], $mapper->findDueExpiries(now: $start->modify('+400 days'), limit: 100)); + self::assertSame([$successor], $mapper->findDueRungs(now: $start->modify('+400 days'), limit: 100)); + self::assertSame([$successor], $mapper->findSuccessors(uuid: $uuid)); + self::assertSame(FlowTimerEvent::TYPE_SUPERSEDED, $this->service->history(uuid: $uuid)[1]->getType()); + }//end testAMovedAnchorSupersedesTheTimerAndInheritsOnlyTheRungsStillInThePast() + + public function testCompletingTheWorkCancelsBothTimersAndRaisesNothing(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $due = $this->service->arm(config: $this->config(), actor: null, now: $start); + $expiry = $this->service->arm(config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'skip', 'sla' => ['value' => 70, 'unit' => 'calendarDays']]), actor: null, now: $start); + self::assertSame('2026-11-10 09:00', $this->store->tasks['task-1']->getExpiresAt()->format('Y-m-d H:i')); + + $cancelled = $this->service->cancelForSubject(subjectType: 'task', subjectUuid: 'task-1', reason: "Task 'task-1' reached terminal state 'completed'.", actor: 'task:task-1', now: $start->modify('+10 days')); + self::assertSame(2, $cancelled); + foreach ([$due, $expiry] as $timer) { + $row = $this->store->timers[(string)$timer->getUuid()]; + self::assertSame(FlowTimer::STATE_CANCELLED, $row->getState()); + self::assertStringContainsString('completed', (string)$row->getCancelReason()); + self::assertNotNull($row->getCancelledAt()); + self::assertNull($row->getFireAt()); + } + + self::assertNull($this->store->tasks['task-1']->getDueAt()); + self::assertNull($this->store->tasks['task-1']->getExpiresAt()); + self::assertSame(0, $this->service->cancelForSubject(subjectType: 'task', subjectUuid: 'task-1', reason: 'again', actor: null), 'idempotent'); + + // Long after both would have been due: nothing is selected, nothing raised, nothing applied. + $mapper = $this->store->timerMapper(); + $later = $start->modify('+200 days'); + self::assertSame([], $mapper->findDueExpiries(now: $later, limit: 10)); + self::assertSame([], $mapper->findDueRungs(now: $later, limit: 10)); + self::assertSame([], $this->dispatched); + $this->taskService->expects(self::never())->method('applyTimerOutcome'); + self::assertCount(4, array_filter($this->store->events, static fn (FlowTimerEvent $e): bool => $e->getType() === FlowTimerEvent::TYPE_CANCELLED || $e->getType() === FlowTimerEvent::TYPE_ARMED)); + }//end testCompletingTheWorkCancelsBothTimersAndRaisesNothing() + + public function testARunTerminalityCancelsSubjectAndProvenanceTimers(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $this->service->arm(config: $this->config(['runUuid' => 'run-1']), actor: null, now: $start); + $this->service->arm(config: $this->config(['subjectType' => 'run', 'subjectUuid' => 'run-1']), actor: null, now: $start); + $this->service->arm(config: $this->config(['subjectType' => 'object', 'subjectUuid' => 'o-1']), actor: null, now: $start); + + self::assertSame(2, $this->service->cancelForRun(runUuid: 'run-1', reason: 'run failed', actor: 'flow-run:run-1', now: $start)); + self::assertSame(0, $this->service->cancelForRun(runUuid: '', reason: 'x', actor: null)); + self::assertCount(1, array_filter($this->store->timers, static fn (FlowTimer $t): bool => $t->getState() === FlowTimer::STATE_ARMED)); + }//end testARunTerminalityCancelsSubjectAndProvenanceTimers() + + public function testTheFourExpiryOutcomesAreAppliedAsDistinctNamedTaskActions(): void { + $start = $this->at('2026-09-01 09:00'); + $applied = []; + $this->taskService->method('applyTimerOutcome')->willReturnCallback( + function (string $uuid, string $outcome, string $source, string $reason) use (&$applied): Task { + $applied[$uuid] = [$outcome, $source]; + + return $this->store->tasks[$uuid]; + } + ); + + $outcomes = ['skip', 'error', 'dead_letter', 'transition:approve']; + foreach ($outcomes as $index => $outcome) { + $this->task(uuid: 'task-' . $index); + $this->service->arm( + config: $this->config(['subjectUuid' => 'task-' . $index, 'purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => $outcome, 'ladder' => null]), + actor: null, + now: $start + ); + } + + $due = $this->store->timerMapper()->findDueExpiries(now: $start->modify('+57 days'), limit: 10); + self::assertCount(4, $due); + foreach ($due as $timer) { + self::assertTrue($this->service->fireExpiry(timer: $timer, now: $start->modify('+57 days'))); + } + + self::assertSame( + ['task-0' => 'skip', 'task-1' => 'error', 'task-2' => 'dead_letter', 'task-3' => 'transition:approve'], + array_map(static fn (array $pair): string => $pair[0], $applied), + 'each outcome reaches the task service by its own name' + ); + self::assertCount(4, array_unique(array_map(static fn (FlowTimerFiredEvent $e): string => $e->getTransition(), $this->dispatched))); + foreach ($applied as $pair) { + self::assertStringStartsWith('flow-timer:', $pair[1], 'the audit names the timer as actor'); + } + }//end testTheFourExpiryOutcomesAreAppliedAsDistinctNamedTaskActions() + + public function testADueTimerPastItsMomentStaysArmedAndIsOverdueOnRead(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm(config: $this->config(), actor: null, now: $start); + $later = $start->modify('+60 days'); + + self::assertSame([], $this->store->timerMapper()->findDueExpiries(now: $later, limit: 10), 'a due timer is never an expiry'); + $read = $this->service->describe(timer: $timer, now: $later); + self::assertTrue($read['overdue']); + self::assertEqualsWithDelta(4.0, $read['overdueBy'], 0.001); + self::assertEqualsWithDelta(-4.0, $read['remaining'], 0.001); + self::assertSame(FlowTimer::STATE_ARMED, $read['state']); + // The work stays open: the task's state was never touched. + self::assertSame(Task::STATE_ACTIVE, $this->store->tasks['task-1']->getState()); + }//end testADueTimerPastItsMomentStaysArmedAndIsOverdueOnRead() + + public function testASixWeekTimerSurvivesARestartAndFiresExactlyOnce(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $this->service->arm( + config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'skip', 'sla' => ['value' => 42, 'unit' => 'calendarDays'], 'ladder' => null]), + actor: null, + now: $start + ); + $this->taskService->method('applyTimerOutcome')->willReturnCallback(fn (string $uuid): Task => $this->store->tasks[$uuid]); + + // Before the moment: nothing to do. + self::assertSame([], $this->store->timerMapper()->findDueExpiries(now: $start->modify('+41 days'), limit: 10)); + + // "Restart": a fresh service over the same rows, first sweep after the moment. + $this->service = $this->buildService(); + $due = $this->store->timerMapper()->findDueExpiries(now: $start->modify('+43 days'), limit: 10); + self::assertCount(1, $due); + self::assertTrue($this->service->fireExpiry(timer: $due[0], now: $start->modify('+43 days'))); + + // Every later sweep: the row is fired, the scan is empty, a stale copy cannot re-fire it. + self::assertSame([], $this->store->timerMapper()->findDueExpiries(now: $start->modify('+44 days'), limit: 10)); + self::assertFalse($this->service->fireExpiry(timer: $due[0], now: $start->modify('+44 days')), 'the conditional claim lost'); + self::assertCount(1, $this->dispatched); + }//end testASixWeekTimerSurvivesARestartAndFiresExactlyOnce() + + public function testTwoOverlappingPassesFireAnExpiryOnce(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm(config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'error', 'ladder' => null]), actor: null, now: $start); + $this->taskService->expects(self::once())->method('applyTimerOutcome')->willReturn($this->store->tasks['task-1']); + + // Both passes read the same armed row before either claims it. + $copyA = $this->store->timerMapper()->findDueExpiries(now: $start->modify('+57 days'), limit: 10)[0]; + $copyB = clone $copyA; + self::assertTrue($this->service->fireExpiry(timer: $copyA, now: $start->modify('+57 days'))); + self::assertFalse($this->service->fireExpiry(timer: $copyB, now: $start->modify('+57 days'))); + self::assertCount(1, $this->dispatched); + self::assertSame(FlowTimer::STATE_FIRED, $this->store->timers[(string)$timer->getUuid()]->getState()); + }//end testTwoOverlappingPassesFireAnExpiryOnce() + + public function testATaskClosedConcurrentlyIsNothingToDo(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm(config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'error', 'ladder' => null]), actor: null, now: $start); + $this->taskService->method('applyTimerOutcome')->willThrowException(new TaskConflictException('closed concurrently')); + self::assertTrue($this->service->fireExpiry(timer: $timer, now: $start->modify('+57 days'))); + self::assertSame(FlowTimer::STATE_FIRED, $timer->getState()); + }//end testATaskClosedConcurrentlyIsNothingToDo() + + public function testADowntimeGapFiresTheSkippedRungsInOrderOnceEachAndAddressesTheGroup(): void { + $this->task(performerType: Task::PERFORMER_GROUP, assignee: 'vergunningen'); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm(config: $this->config(), actor: null, now: $start); + + // Deadline 5 days away, nothing fired yet: the 14-day and 7-day rungs both fire, in ladder order. + $now = $start->modify('+51 days'); + self::assertSame([$timer], $this->store->timerMapper()->findDueRungs(now: $now, limit: 10)); + self::assertSame(2, $this->service->fireRungs(timer: $timer, now: $now)); + self::assertSame( + ['escalation:preBreach:14:calendarDays', 'escalation:preBreach:7:calendarDays'], + array_map(static fn (FlowTimerFiredEvent $e): string => $e->getTransition(), $this->dispatched) + ); + self::assertSame([['type' => 'group', 'id' => 'vergunningen', 'role' => 'handler']], $this->dispatched[0]->getRecipients(), 'the group path'); + self::assertSame('low', $this->dispatched[0]->getPriority()); + self::assertSame('termijn-7d', $this->dispatched[1]->getMessage()); + self::assertSame(['handler', 'teamleader'], $this->store->fires[1]->getRecipientRoles()); + self::assertSame('2026-10-25 09:00', $timer->getNextRungAt()->format('Y-m-d H:i'), 'next: the 2-day rung'); + $this->assertInvariants($timer); + + // The daily sweep does not repeat a rung. + self::assertSame(0, $this->service->fireRungs(timer: $timer, now: $now->modify('+1 day'))); + self::assertCount(2, $this->dispatched); + + // Two concurrent passes on the 2-day rung: one claim wins. + $rungDay = $start->modify('+54 days'); + $copy = clone $timer; + self::assertSame(1, $this->service->fireRungs(timer: $timer, now: $rungDay)); + self::assertSame(0, $this->service->fireRungs(timer: $copy, now: $rungDay)); + self::assertCount(3, $this->dispatched); + self::assertCount(3, $this->store->fires, 'one ledger row per rung, decided by the unique key'); + }//end testADowntimeGapFiresTheSkippedRungsInOrderOnceEachAndAddressesTheGroup() + + public function testTheOrganisationCalendarIsResolvedWhenTheTimerNamesNone(): void { + // The seeded example-organisation calendar has 7 working hours and a local closure on 2026-10-05. + $org = $this->calendars->resolve(calendarSlug: null, organisation: '00000000-0000-0000-0000-000000000000'); + self::assertSame('example-organisation', $org->getSlug()); + self::assertSame(7.0, $org->getHoursPerWorkingDay()); + self::assertFalse($org->isWorkingDay($this->at('2026-10-05 10:00'))); + + self::assertSame(WorkingCalendar::DEFAULT_SLUG, $this->calendars->resolve(calendarSlug: null, organisation: 'unknown-org')->getSlug()); + self::assertSame(WorkingCalendar::DEFAULT_SLUG, $this->calendars->resolve(calendarSlug: null, organisation: null)->getSlug()); + self::assertSame('example-organisation', $this->calendars->resolve(calendarSlug: 'example-organisation', organisation: null)->getSlug()); + $this->calendars->reset(); + self::assertSame(WorkingCalendar::DEFAULT_SLUG, $this->calendars->resolve(calendarSlug: null, organisation: null)->getSlug()); + }//end testTheOrganisationCalendarIsResolvedWhenTheTimerNamesNone() + + public function testAnchorDatesArriveAsStringsOrDateTimesAndBadOnesAreNamed(): void { + $this->task(); + $timer = $this->service->arm( + config: $this->config([ + 'anchorEventAt' => '2026-09-01T09:00:00+02:00', + 'title' => 'Beslistermijn', + 'metadata' => ['basis' => 'Awb 4:13'], + 'ladder' => null, + ]), + actor: null, + now: $this->at('2026-09-01 09:00') + ); + self::assertSame('Beslistermijn', $timer->getTitle()); + self::assertSame(['basis' => 'Awb 4:13'], $timer->getMetadata()); + self::assertSame('2026-09-01 09:00', $timer->getAnchorAt()->format('Y-m-d H:i')); + + foreach ([['anchorEventAt' => 'not-a-date'], ['anchorEventAt' => 42], ['anchorOffset' => 'three'], ['anchorOffset' => 1, 'anchorOffsetUnit' => 'weeks']] as $bad) { + try { + $this->service->arm(config: $this->config($bad + ['ladder' => null]), actor: null, now: $this->at('2026-09-01 09:00')); + self::fail('accepted ' . json_encode($bad)); + } catch (FlowTimerValidationException $refused) { + self::assertNotSame('', $refused->getMessage()); + } + } + }//end testAnchorDatesArriveAsStringsOrDateTimesAndBadOnesAreNamed() + + public function testATerminalTimerCannotBeSuperseded(): void { + $this->task(); + $timer = $this->service->arm(config: $this->config(['ladder' => null]), actor: null, now: $this->at('2026-09-01 09:00')); + $this->service->cancelForSubject(subjectType: 'task', subjectUuid: 'task-1', reason: 'done', actor: null, now: $this->at('2026-09-02 09:00')); + $this->expectException(FlowTimerStateException::class); + $this->expectExceptionMessage("cannot be superseded: its state is 'cancelled'"); + $this->service->supersede(uuid: (string)$timer->getUuid(), anchorEventAt: $this->at('2026-09-03 09:00'), reason: 'moved', actor: null, now: $this->at('2026-09-02 10:00')); + }//end testATerminalTimerCannotBeSuperseded() + + public function testAnAbsentTaskSubjectIsLoggedNotFatal(): void { + // No task row exists for this subject: arming still works (the store is + // subject-agnostic), the projection warns, the expiry outcome warns. + $timer = $this->service->arm( + config: $this->config(['subjectUuid' => 'task-gone', 'purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'skip', 'ladder' => null]), + actor: null, + now: $this->at('2026-09-01 09:00') + ); + $this->taskService->expects(self::once())->method('applyTimerOutcome') + ->willThrowException(new \OCP\AppFramework\Db\DoesNotExistException('gone')); + self::assertTrue($this->service->fireExpiry(timer: $timer, now: $this->at('2026-10-28 09:00'))); + self::assertSame(FlowTimer::STATE_FIRED, $timer->getState()); + }//end testAnAbsentTaskSubjectIsLoggedNotFatal() + + public function testRungsFireOnlyOnAnArmedTimerWithAFireMoment(): void { + $this->task(); + $timer = $this->service->arm(config: $this->config(), actor: null, now: $this->at('2026-09-01 09:00')); + $this->service->suspend(uuid: (string)$timer->getUuid(), reason: 'pauze', until: null, actor: null, now: $this->at('2026-09-02 09:00')); + self::assertSame(0, $this->service->fireRungs(timer: $this->store->timers[(string)$timer->getUuid()], now: $this->at('2026-12-01 09:00')), 'a suspended timer neither fires nor escalates'); + self::assertSame([], $this->dispatched); + }//end testRungsFireOnlyOnAnArmedTimerWithAFireMoment() + + public function testTheRemainingBuildRefusalsAreNamed(): void { + $this->task(); + $cases = [ + ['subjectType' => 'invoice'], + ['subjectUuid' => ' '], + ['purpose' => 'someday'], + ['legalEffect' => 'contractueel'], + ]; + foreach ($cases as $bad) { + try { + $this->service->arm(config: $this->config($bad + ['ladder' => null]), actor: null, now: $this->at('2026-09-01 09:00')); + self::fail('accepted ' . json_encode($bad)); + } catch (FlowTimerValidationException $refused) { + self::assertNotSame('', $refused->getMessage()); + } + } + + try { + $this->service->extend(uuid: 'missing', amount: 0, unit: 'hours', rationale: 'x', actor: null); + self::fail('accepted a zero extension'); + } catch (\OCP\AppFramework\Db\DoesNotExistException) { + // The uuid is read first; the amount guard needs a real timer. + } + + $timer = $this->service->arm(config: $this->config(['ladder' => null]), actor: null, now: $this->at('2026-09-01 09:00')); + try { + $this->service->extend(uuid: (string)$timer->getUuid(), amount: 0, unit: 'hours', rationale: 'x', actor: null, now: $this->at('2026-09-02 09:00')); + self::fail('accepted a zero extension'); + } catch (FlowTimerValidationException $refused) { + self::assertStringContainsString('must be positive', $refused->getMessage()); + } + + try { + $this->service->suspend(uuid: (string)$timer->getUuid(), reason: 'twice', until: null, actor: null, now: $this->at('2026-09-02 09:00')); + $this->service->suspend(uuid: (string)$timer->getUuid(), reason: 'twice', until: null, actor: null, now: $this->at('2026-09-03 09:00')); + self::fail('suspended a suspended timer'); + } catch (FlowTimerStateException $refused) { + self::assertStringContainsString("'suspended', not 'armed'", $refused->getMessage()); + } + }//end testTheRemainingBuildRefusalsAreNamed() + + public function testAnAnchorlessTimerRunsFromNow(): void { + $this->task(); + $config = $this->config(['ladder' => null]); + unset($config['anchorEventAt']); + $timer = $this->service->arm(config: $config, actor: null, now: $this->at('2026-09-01 09:00')); + self::assertSame('2026-09-01 09:00', $timer->getAnchorAt()->format('Y-m-d H:i')); + }//end testAnAnchorlessTimerRunsFromNow() + + public function testANonTaskSubjectEnforcesNothingAndEscalatesToRoles(): void { + // An enforcing timer on an OBJECT subject fires and records, but no + // task action is applied and the rung recipients stay role descriptors. + $timer = $this->service->arm( + config: $this->config(['subjectType' => 'object', 'subjectUuid' => 'o-1', 'purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'skip']), + actor: null, + now: $this->at('2026-09-01 09:00') + ); + $this->taskService->expects(self::never())->method('applyTimerOutcome'); + self::assertSame(1, $this->service->fireRungs(timer: $timer, now: $this->at('2026-10-14 09:00')), 'the 14-day rung'); + self::assertSame([['type' => 'role', 'id' => 'handler', 'role' => 'handler']], $this->dispatched[0]->getRecipients()); + self::assertTrue($this->service->fireExpiry(timer: $timer, now: $this->at('2026-10-28 09:00'))); + }//end testANonTaskSubjectEnforcesNothingAndEscalatesToRoles() + + public function testAFailingWriteRollsTheOperationBack(): void { + $db = $this->createMock(IDBConnection::class); + $db->expects(self::once())->method('beginTransaction'); + $db->expects(self::once())->method('rollBack'); + $db->expects(self::never())->method('commit'); + $events = $this->createMock(\OCA\OpenRegister\Db\FlowTimerEventMapper::class); + $events->method('insert')->willThrowException(new \RuntimeException('event table gone')); + $definitions = $this->createMock(FlowTimerDefinitionStore::class); + $definitions->method('calendars')->willReturn(self::calendars()); + $definitions->method('ladders')->willReturn([]); + $calendars = new WorkingCalendarService(definitions: $definitions); + $service = new FlowTimerService( + timers: $this->store->timerMapper(), + fires: $this->store->fireMapper(), + events: $events, + tasks: $this->store->taskMapper(), + taskService: $this->taskService, + calendars: $calendars, + calculator: $this->calculator, + ladder: new EscalationLadderService(definitions: $definitions, calculator: $this->calculator), + db: $db, + dispatcher: $this->createMock(IEventDispatcher::class), + logger: new NullLogger() + ); + $this->expectException(\RuntimeException::class); + $service->arm(config: $this->config(['ladder' => null]), actor: null, now: $this->at('2026-09-01 09:00')); + }//end testAFailingWriteRollsTheOperationBack() + + public function testAMapperHandingBackAClosedTimerIsSkippedDefensively(): void { + $closed = new FlowTimer(); + $closed->setUuid('t-closed'); + $closed->setState(FlowTimer::STATE_FIRED); + $timers = $this->createMock(\OCA\OpenRegister\Db\FlowTimerMapper::class); + $timers->method('findBySubject')->willReturn([$closed]); + $timers->expects(self::never())->method('update'); + $definitions = $this->createMock(FlowTimerDefinitionStore::class); + $definitions->method('calendars')->willReturn(self::calendars()); + $definitions->method('ladders')->willReturn([]); + $service = new FlowTimerService( + timers: $timers, + fires: $this->store->fireMapper(), + events: $this->store->eventMapper(), + tasks: $this->store->taskMapper(), + taskService: $this->taskService, + calendars: new WorkingCalendarService(definitions: $definitions), + calculator: $this->calculator, + ladder: new EscalationLadderService(definitions: $definitions, calculator: $this->calculator), + db: $this->createMock(IDBConnection::class), + dispatcher: $this->createMock(IEventDispatcher::class), + logger: new NullLogger() + ); + self::assertSame(0, $service->cancelForSubject(subjectType: 'task', subjectUuid: 't', reason: 'r', actor: null)); + }//end testAMapperHandingBackAClosedTimerIsSkippedDefensively() + + public function testARungFireOnAnAbsentTaskFallsBackToRoleDescriptors(): void { + $timer = $this->service->arm(config: $this->config(['subjectUuid' => 'task-gone']), actor: null, now: $this->at('2026-09-01 09:00')); + self::assertSame(1, $this->service->fireRungs(timer: $timer, now: $this->at('2026-10-14 09:00'))); + self::assertSame('role', $this->dispatched[0]->getRecipients()[0]['type'], 'no task to resolve the handler against'); + }//end testARungFireOnAnAbsentTaskFallsBackToRoleDescriptors() + + public function testASubjectMayCarryThreeDeadlinesWithDifferentMomentsIndependently(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $planned = $this->service->arm(config: $this->config(['legalEffect' => 'none', 'sla' => ['value' => 20, 'unit' => 'calendarDays'], 'ladder' => null]), actor: null, now: $start); + $service = $this->service->arm(config: $this->config(['legalEffect' => 'servicenorm', 'sla' => ['value' => 40, 'unit' => 'calendarDays']]), actor: null, now: $start); + $legal = $this->service->arm(config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'wettelijk', 'onExpiry' => 'skip', 'sla' => ['value' => 56, 'unit' => 'calendarDays'], 'ladder' => null]), actor: null, now: $start); + $this->taskService->method('applyTimerOutcome')->willReturn($this->store->tasks['task-1']); + + self::assertSame($planned->getFireAt()->getTimestamp(), $this->store->tasks['task-1']->getDueAt()->getTimestamp(), 'the earliest due timer projects'); + self::assertSame($legal->getFireAt()->getTimestamp(), $this->store->tasks['task-1']->getExpiresAt()->getTimestamp()); + + // Each reached in turn: the planned date is overdue and silent, the service norm escalates, the legal term enforces. + $mapper = $this->store->timerMapper(); + self::assertTrue($this->service->describe(timer: $planned, now: $start->modify('+21 days'))['overdue']); + self::assertSame([$service], $mapper->findDueRungs(now: $start->modify('+27 days'), limit: 10), 'only the ladder-bearing timer has a rung due'); + self::assertSame(1, $this->service->fireRungs(timer: $service, now: $start->modify('+27 days'))); + self::assertSame([$legal], $mapper->findDueExpiries(now: $start->modify('+57 days'), limit: 10)); + self::assertTrue($this->service->fireExpiry(timer: $legal, now: $start->modify('+57 days'))); + + self::assertSame(FlowTimer::STATE_ARMED, $this->store->timers[(string)$planned->getUuid()]->getState()); + self::assertSame(FlowTimer::STATE_ARMED, $this->store->timers[(string)$service->getUuid()]->getState()); + self::assertSame(FlowTimer::STATE_FIRED, $this->store->timers[(string)$legal->getUuid()]->getState()); + foreach ([$planned, $service] as $timer) { + $this->assertInvariants($timer); + } + }//end testASubjectMayCarryThreeDeadlinesWithDifferentMomentsIndependently() +}//end class diff --git a/tests/Unit/Service/Flow/Timer/FlowTimerSweepTest.php b/tests/Unit/Service/Flow/Timer/FlowTimerSweepTest.php new file mode 100644 index 0000000000..0308427a59 --- /dev/null +++ b/tests/Unit/Service/Flow/Timer/FlowTimerSweepTest.php @@ -0,0 +1,137 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Timer; + +use DateTime; +use DateTimeImmutable; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerMapper; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerSweep; +use OCA\OpenRegister\Service\Flow\Timer\WorkingCalendarService; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * @covers \OCA\OpenRegister\Service\Flow\Timer\FlowTimerSweep + * @covers \OCA\OpenRegister\Db\FlowTimer + * @covers \OCA\OpenRegister\Db\FlowTimerMapper + */ +class FlowTimerSweepTest extends TestCase { + + private InMemoryTimerStore $store; + + private FlowTimerMapper $mapper; + + private FlowTimerService&MockObject $service; + + private WorkingCalendarService&MockObject $calendars; + + private LoggerInterface&MockObject $logger; + + private FlowTimerSweep $sweep; + + protected function setUp(): void { + parent::setUp(); + $this->store = new InMemoryTimerStore(db: $this->createMock(IDBConnection::class)); + $this->mapper = $this->store->timerMapper(); + $this->service = $this->createMock(FlowTimerService::class); + $this->calendars = $this->createMock(WorkingCalendarService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->sweep = new FlowTimerSweep(timers: $this->mapper, service: $this->service, calendars: $this->calendars, logger: $this->logger); + }//end setUp() + + private function seed(string $uuid, string $purpose, string $fireAt, ?string $nextRungAt = null, string $state = FlowTimer::STATE_ARMED): FlowTimer { + $timer = new FlowTimer(); + $timer->setUuid($uuid); + $timer->setPurpose($purpose); + $timer->setState($state); + $timer->setFireAt(new DateTime($fireAt)); + if ($nextRungAt !== null) { + $timer->setNextRungAt(new DateTime($nextRungAt)); + } + + return $this->mapper->insert($timer); + }//end seed() + + public function testADueTimerBeyondTheBatchSizeIsStillReached(): void { + // 250 armed expiry timers that are NOT due, and one that is, with a batch of 200. + for ($i = 0; $i < 250; $i++) { + $this->seed('later-' . $i, 'expiry', '2027-01-01 00:00:00'); + } + + $due = $this->seed('due-now', 'expiry', '2026-08-31 00:00:00'); + $this->service->expects(self::once())->method('fireExpiry')->with($due, self::anything())->willReturn(true); + $this->calendars->expects(self::once())->method('reset'); + + $result = $this->sweep->run(now: new DateTimeImmutable('2026-09-01 10:00:00'), batch: 200); + self::assertSame(['expiriesFired' => 1, 'rungsFired' => 0, 'truncated' => false, 'errors' => 0], $result); + }//end testADueTimerBeyondTheBatchSizeIsStillReached() + + public function testCountsReportWorkNotReadsAndTruncationIsVisible(): void { + foreach (['a', 'b', 'c'] as $name) { + $this->seed('exp-' . $name, 'expiry', '2026-08-30 00:00:00'); + } + + $this->seed('due-x', 'due', '2026-08-30 00:00:00', '2026-08-30 00:00:00'); + $this->seed('due-suspended', 'due', '2026-08-30 00:00:00', '2026-08-30 00:00:00', FlowTimer::STATE_SUSPENDED); + + // One of the three expiries was claimed by another pass: work performed is two. + $this->service->method('fireExpiry')->willReturnOnConsecutiveCalls(true, false, true); + $this->service->method('fireRungs')->willReturn(2); + + $result = $this->sweep->run(now: new DateTimeImmutable('2026-09-01 10:00:00'), batch: 3); + self::assertSame(2, $result['expiriesFired']); + self::assertSame(2, $result['rungsFired']); + self::assertTrue($result['truncated'], 'the expiry scan hit the batch limit'); + self::assertSame(0, $result['errors']); + }//end testCountsReportWorkNotReadsAndTruncationIsVisible() + + public function testASuspendedTimerIsNeverSelected(): void { + $this->seed('s-1', 'expiry', '2026-08-30 00:00:00', '2026-08-30 00:00:00', FlowTimer::STATE_SUSPENDED); + $this->service->expects(self::never())->method('fireExpiry'); + $this->service->expects(self::never())->method('fireRungs'); + self::assertSame(['expiriesFired' => 0, 'rungsFired' => 0, 'truncated' => false, 'errors' => 0], $this->sweep->run(now: new DateTimeImmutable('2026-09-01'), batch: 10)); + }//end testASuspendedTimerIsNeverSelected() + + public function testOneFailureIsCountedAndDoesNotStopThePass(): void { + $this->seed('exp-1', 'expiry', '2026-08-30 00:00:00'); + $this->seed('exp-2', 'expiry', '2026-08-30 01:00:00'); + $this->seed('rung-1', 'due', '2026-10-30 00:00:00', '2026-08-30 00:00:00'); + $this->service->method('fireExpiry')->willReturnCallback(static function (FlowTimer $timer): bool { + if ($timer->getUuid() === 'exp-1') { + throw new RuntimeException('outcome failed'); + } + + return true; + }); + $this->service->method('fireRungs')->willThrowException(new RuntimeException('ladder failed')); + $this->logger->expects(self::exactly(2))->method('error'); + + $result = $this->sweep->run(now: new DateTimeImmutable('2026-09-01'), batch: 10); + self::assertSame(['expiriesFired' => 1, 'rungsFired' => 0, 'truncated' => false, 'errors' => 2], $result); + }//end testOneFailureIsCountedAndDoesNotStopThePass() +}//end class diff --git a/tests/Unit/Service/Flow/Timer/InMemoryTimerStore.php b/tests/Unit/Service/Flow/Timer/InMemoryTimerStore.php new file mode 100644 index 0000000000..22207d61b8 --- /dev/null +++ b/tests/Unit/Service/Flow/Timer/InMemoryTimerStore.php @@ -0,0 +1,256 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Timer; + +use DateTime; +use DateTimeInterface; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerEvent; +use OCA\OpenRegister\Db\FlowTimerEventMapper; +use OCA\OpenRegister\Db\FlowTimerFire; +use OCA\OpenRegister\Db\FlowTimerFireMapper; +use OCA\OpenRegister\Db\FlowTimerMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; +use OCP\IDBConnection; + +/** + * Builds the four fakes over one shared state. + */ +final class InMemoryTimerStore { + + /** + * @var array + */ + public array $timers = []; + + /** + * @var array + */ + public array $fires = []; + + /** + * @var array + */ + public array $events = []; + + /** + * @var array + */ + public array $tasks = []; + + private int $nextId = 1; + + public function __construct(private readonly IDBConnection $db) { + }//end __construct() + + public function nextId(): int { + return $this->nextId++; + }//end nextId() + + public function timerMapper(): FlowTimerMapper { + $store = $this; + + return new class($this->db, $store) extends FlowTimerMapper { + public function __construct(IDBConnection $db, private readonly InMemoryTimerStore $store) { + parent::__construct(db: $db); + } + + public function insert(Entity $entity): FlowTimer { + assert($entity instanceof FlowTimer); + if ($entity->getId() === null) { + $entity->setId($this->store->nextId()); + } + + if ($entity->getCreated() === null) { + $entity->setCreated(new DateTime()); + } + + $this->store->timers[(string)$entity->getUuid()] = $entity; + + return $entity; + } + + public function update(Entity $entity): FlowTimer { + assert($entity instanceof FlowTimer); + $entity->setUpdated(new DateTime()); + $this->store->timers[(string)$entity->getUuid()] = $entity; + + return $entity; + } + + public function findByUuid(string $uuid): FlowTimer { + if (isset($this->store->timers[$uuid]) === false) { + throw new DoesNotExistException('timer ' . $uuid); + } + + return $this->store->timers[$uuid]; + } + + public function findDueExpiries(DateTimeInterface $now, int $limit): array { + $due = array_filter( + $this->store->timers, + static fn (FlowTimer $t): bool => $t->getState() === FlowTimer::STATE_ARMED + && $t->getPurpose() === FlowTimer::PURPOSE_EXPIRY + && $t->getFireAt() !== null && $t->getFireAt() <= $now + ); + usort($due, static fn (FlowTimer $a, FlowTimer $b): int => $a->getFireAt() <=> $b->getFireAt()); + + return array_slice(array_values($due), 0, $limit); + } + + public function findDueRungs(DateTimeInterface $now, int $limit): array { + $due = array_filter( + $this->store->timers, + static fn (FlowTimer $t): bool => $t->getState() === FlowTimer::STATE_ARMED + && $t->getNextRungAt() !== null && $t->getNextRungAt() <= $now + ); + usort($due, static fn (FlowTimer $a, FlowTimer $b): int => $a->getNextRungAt() <=> $b->getNextRungAt()); + + return array_slice(array_values($due), 0, $limit); + } + + public function findBySubject(string $subjectType, string $subjectUuid, array $states = []): array { + return array_values(array_filter( + $this->store->timers, + static fn (FlowTimer $t): bool => $t->getSubjectType() === $subjectType + && $t->getSubjectUuid() === $subjectUuid + && ($states === [] || in_array($t->getState(), $states, true)) + )); + } + + public function findOpenByRun(string $runUuid): array { + return array_values(array_filter( + $this->store->timers, + static fn (FlowTimer $t): bool => in_array($t->getState(), [FlowTimer::STATE_ARMED, FlowTimer::STATE_SUSPENDED], true) + && ($t->getRunUuid() === $runUuid || ($t->getSubjectType() === 'run' && $t->getSubjectUuid() === $runUuid)) + )); + } + + public function findSuccessors(string $uuid): array { + return array_values(array_filter($this->store->timers, static fn (FlowTimer $t): bool => $t->getSupersedesUuid() === $uuid)); + } + + public function findByStatePaged(string $state, int $afterId, int $limit): array { + $rows = array_filter($this->store->timers, static fn (FlowTimer $t): bool => $t->getState() === $state && (int)$t->getId() > $afterId); + usort($rows, static fn (FlowTimer $a, FlowTimer $b): int => (int)$a->getId() <=> (int)$b->getId()); + + return array_slice(array_values($rows), 0, $limit); + } + + public function claimFired(string $uuid, DateTimeInterface $firedAt): bool { + $timer = ($this->store->timers[$uuid] ?? null); + if ($timer === null || $timer->getState() !== FlowTimer::STATE_ARMED) { + return false; + } + + $timer->setState(FlowTimer::STATE_FIRED); + $timer->setFiredAt(DateTime::createFromInterface($firedAt)); + + return true; + } + }; + }//end timerMapper() + + public function fireMapper(): FlowTimerFireMapper { + $store = $this; + + return new class($this->db, $store) extends FlowTimerFireMapper { + public function __construct(IDBConnection $db, private readonly InMemoryTimerStore $store) { + parent::__construct(db: $db); + } + + public function insert(Entity $entity): FlowTimerFire { + assert($entity instanceof FlowTimerFire); + $entity->setId($this->store->nextId()); + $this->store->fires[] = $entity; + + return $entity; + } + + public function claim(FlowTimerFire $fire): ?FlowTimerFire { + foreach ($this->store->fires as $existing) { + if ($existing->getTimerUuid() === $fire->getTimerUuid() && $existing->getRungKey() === $fire->getRungKey()) { + return null; + } + } + + return $this->insert(entity: $fire); + } + + public function findByTimer(string $timerUuid): array { + return array_values(array_filter($this->store->fires, static fn (FlowTimerFire $f): bool => $f->getTimerUuid() === $timerUuid)); + } + }; + }//end fireMapper() + + public function eventMapper(): FlowTimerEventMapper { + $store = $this; + + return new class($this->db, $store) extends FlowTimerEventMapper { + public function __construct(IDBConnection $db, private readonly InMemoryTimerStore $store) { + parent::__construct(db: $db); + } + + public function insert(Entity $entity): FlowTimerEvent { + assert($entity instanceof FlowTimerEvent); + $entity->setId($this->store->nextId()); + $this->store->events[] = $entity; + + return $entity; + } + + public function findByTimer(string $timerUuid): array { + return array_values(array_filter($this->store->events, static fn (FlowTimerEvent $e): bool => $e->getTimerUuid() === $timerUuid)); + } + }; + }//end eventMapper() + + public function taskMapper(): TaskMapper { + $store = $this; + + return new class($this->db, $store) extends TaskMapper { + public function __construct(IDBConnection $db, private readonly InMemoryTimerStore $store) { + parent::__construct(db: $db); + } + + public function findByUuid(string $uuid): Task { + if (isset($this->store->tasks[$uuid]) === false) { + throw new DoesNotExistException('task ' . $uuid); + } + + return $this->store->tasks[$uuid]; + } + + public function update(Entity $entity): Task { + assert($entity instanceof Task); + $this->store->tasks[(string)$entity->getUuid()] = $entity; + + return $entity; + } + }; + }//end taskMapper() +}//end class diff --git a/tests/Unit/Service/Flow/Timer/SlaCalculatorTest.php b/tests/Unit/Service/Flow/Timer/SlaCalculatorTest.php new file mode 100644 index 0000000000..4de4155f01 --- /dev/null +++ b/tests/Unit/Service/Flow/Timer/SlaCalculatorTest.php @@ -0,0 +1,133 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Timer; + +use DateTimeImmutable; +use DateTimeZone; +use OCA\OpenRegister\Exception\FlowTimerValidationException; +use OCA\OpenRegister\Service\Flow\Timer\SlaCalculator; +use OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\Flow\Timer\SlaCalculator + * @covers \OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar + */ +class SlaCalculatorTest extends TestCase { + + private SlaCalculator $calculator; + + private WorkingCalendar $calendar; + + private DateTimeZone $tz; + + protected function setUp(): void { + parent::setUp(); + $this->calculator = new SlaCalculator(); + $this->calendar = WorkingCalendar::fromArray(definition: WorkingCalendarTest::nlNational()); + $this->tz = new DateTimeZone('Europe/Amsterdam'); + }//end setUp() + + private function at(string $when): DateTimeImmutable { + return new DateTimeImmutable($when, $this->tz); + }//end at() + + public function testThreeBusinessDaysFromThursdayLandOnTuesday(): void { + // 3 September 2026 is a Thursday. + $landing = $this->calculator->add(from: $this->at('2026-09-03 10:00'), value: 3, unit: 'businessDays', calendar: $this->calendar); + self::assertSame('2026-09-08 10:00 Tuesday', $landing->format('Y-m-d H:i l')); + }//end testThreeBusinessDaysFromThursdayLandOnTuesday() + + public function testSubtractionIsTheInverseOfAddition(): void { + $back = $this->calculator->sub(from: $this->at('2026-09-08 10:00'), value: 3, unit: 'businessDays', calendar: $this->calendar); + self::assertSame('2026-09-03 10:00', $back->format('Y-m-d H:i')); + }//end testSubtractionIsTheInverseOfAddition() + + public function testHolidaysAreSkippedLikeWeekends(): void { + // Thursday 24 December 2026 + 1 business day skips Kerst (25, 26 falls on Saturday) and the weekend. + $landing = $this->calculator->add(from: $this->at('2026-12-24 09:00'), value: 1, unit: 'businessDays', calendar: $this->calendar); + self::assertSame('2026-12-28 09:00 Monday', $landing->format('Y-m-d H:i l')); + }//end testHolidaysAreSkippedLikeWeekends() + + public function testAWeekendMeasuresZeroBusinessDays(): void { + self::assertSame(0.0, $this->calculator->measure(from: $this->at('2026-09-05 10:00'), to: $this->at('2026-09-06 18:00'), unit: 'businessDays', calendar: $this->calendar)); + // Friday 17:00 to Monday 09:00: 7 hours of Friday plus 9 hours of Monday, as fractions of a day. + $span = $this->calculator->measure(from: $this->at('2026-09-04 17:00'), to: $this->at('2026-09-07 09:00'), unit: 'businessDays', calendar: $this->calendar); + self::assertEqualsWithDelta((7 + 9) / 24, $span, 0.0001); + // Negative direction is signed. + self::assertEqualsWithDelta(-((7 + 9) / 24), $this->calculator->measure(from: $this->at('2026-09-07 09:00'), to: $this->at('2026-09-04 17:00'), unit: 'businessDays', calendar: $this->calendar), 0.0001); + }//end testAWeekendMeasuresZeroBusinessDays() + + public function testMeasureAndAddAgreeAcrossAWeekend(): void { + $from = $this->at('2026-09-04 17:00'); + $to = $this->calculator->add(from: $from, value: 1, unit: 'businessDays', calendar: $this->calendar); + self::assertSame('2026-09-07 17:00', $to->format('Y-m-d H:i')); + self::assertEqualsWithDelta(1.0, $this->calculator->measure(from: $from, to: $to, unit: 'businessDays', calendar: $this->calendar), 0.0001); + }//end testMeasureAndAddAgreeAcrossAWeekend() + + public function testHoursAndCalendarDaysIgnoreTheCalendar(): void { + $from = $this->at('2026-09-04 17:00'); + self::assertSame('2026-09-06 17:00', $this->calculator->add(from: $from, value: 48, unit: 'hours', calendar: $this->calendar)->format('Y-m-d H:i')); + self::assertSame('2026-09-06 17:00', $this->calculator->add(from: $from, value: 2, unit: 'calendarDays', calendar: $this->calendar)->format('Y-m-d H:i')); + self::assertSame(48.0, $this->calculator->measure(from: $from, to: $this->at('2026-09-06 17:00'), unit: 'hours', calendar: $this->calendar)); + self::assertSame(2.0, $this->calculator->measure(from: $from, to: $this->at('2026-09-06 17:00'), unit: 'calendarDays', calendar: $this->calendar)); + }//end testHoursAndCalendarDaysIgnoreTheCalendar() + + public function testCalendarDaysAreDatesAcrossADstChange(): void { + // DST ends on 25 October 2026 in Europe/Amsterdam: 60 calendar days still land at 09:00. + $from = $this->at('2026-09-01 09:00'); + $to = $this->calculator->add(from: $from, value: 60, unit: 'calendarDays', calendar: $this->calendar); + self::assertSame('2026-10-31 09:00', $to->format('Y-m-d H:i')); + self::assertSame(60.0, $this->calculator->measure(from: $from, to: $to, unit: 'calendarDays', calendar: $this->calendar)); + self::assertSame(-60.0, $this->calculator->measure(from: $to, to: $from, unit: 'calendarDays', calendar: $this->calendar)); + self::assertSame(1441.0, $this->calculator->measure(from: $from, to: $to, unit: 'hours', calendar: $this->calendar), 'hours are elapsed time'); + // A fractional calendar day is the fraction of a day in seconds. + self::assertSame('2026-09-02 21:00', $this->calculator->add(from: $from, value: 1.5, unit: 'calendarDays', calendar: $this->calendar)->format('Y-m-d H:i')); + self::assertSame('2026-08-30 21:00', $this->calculator->sub(from: $from, value: 1.5, unit: 'calendarDays', calendar: $this->calendar)->format('Y-m-d H:i')); + }//end testCalendarDaysAreDatesAcrossADstChange() + + public function testConversionPivotsOnWorkingHours(): void { + self::assertSame(16.0, $this->calculator->convert(value: 2, fromUnit: 'businessDays', toUnit: 'hours', calendar: $this->calendar)); + self::assertSame(1.0, $this->calculator->convert(value: 24, fromUnit: 'hours', toUnit: 'calendarDays', calendar: $this->calendar)); + self::assertSame(3.0, $this->calculator->convert(value: 1, fromUnit: 'calendarDays', toUnit: 'businessDays', calendar: $this->calendar)); + self::assertSame(5.0, $this->calculator->convert(value: 5, fromUnit: 'hours', toUnit: 'hours', calendar: $this->calendar)); + }//end testConversionPivotsOnWorkingHours() + + public function testSlaShapeIsValidated(): void { + self::assertSame(['value' => 5, 'unit' => 'businessDays'], $this->calculator->validateSla(sla: ['value' => '5', 'unit' => 'businessDays'])); + self::assertSame(['value' => 10000, 'unit' => 'hours'], $this->calculator->validateSla(sla: ['value' => 10000, 'unit' => 'hours'])); + + foreach ([['value' => 0, 'unit' => 'hours'], ['value' => 10001, 'unit' => 'hours'], ['value' => 1.5, 'unit' => 'hours'], ['value' => 2, 'unit' => 'weeks'], ['value' => 2], 'nope'] as $bad) { + try { + $this->calculator->validateSla(sla: $bad); + self::fail('accepted ' . json_encode($bad)); + } catch (FlowTimerValidationException $refused) { + self::assertNotSame('', $refused->getMessage()); + } + } + }//end testSlaShapeIsValidated() + + public function testUnknownUnitIsRefusedEverywhere(): void { + $this->expectException(FlowTimerValidationException::class); + $this->calculator->add(from: $this->at('2026-09-04 17:00'), value: 1, unit: 'fortnights', calendar: $this->calendar); + }//end testUnknownUnitIsRefusedEverywhere() +}//end class diff --git a/tests/Unit/Service/Flow/Timer/WorkingCalendarTest.php b/tests/Unit/Service/Flow/Timer/WorkingCalendarTest.php new file mode 100644 index 0000000000..9c5aa81e4b --- /dev/null +++ b/tests/Unit/Service/Flow/Timer/WorkingCalendarTest.php @@ -0,0 +1,177 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Timer + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Timer; + +use DateTimeImmutable; +use DateTimeZone; +use OCA\OpenRegister\Exception\FlowTimerValidationException; +use OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\Flow\Timer\WorkingCalendar + */ +class WorkingCalendarTest extends TestCase { + + /** + * The seeded `nl-national` definition, read from the shipped descriptor. + * + * @return array The definition. + */ + public static function nlNational(): array { + $data = json_decode((string)file_get_contents(__DIR__ . '/../../../../../lib/Settings/flow_timer_register.json'), true); + foreach ($data['components']['objects'] as $object) { + if (($object['slug'] ?? '') === 'nl-national') { + unset($object['@self']); + return $object; + } + } + + self::fail('nl-national is not in the descriptor'); + }//end nlNational() + + /** + * Easter Sunday for years well past any table anyone wrote. + * + * @return array Year and expected date. + */ + public static function easterProvider(): array { + return [ + '2026' => [2026, '2026-04-05'], + '2030' => [2030, '2030-04-21'], + '2035' => [2035, '2035-03-25'], + '2038' => [2038, '2038-04-25'], + '2049' => [2049, '2049-04-18'], + ]; + }//end easterProvider() + + /** + * @dataProvider easterProvider + */ + public function testEasterIsComputedForFutureYears(int $year, string $expected): void { + self::assertSame($expected, WorkingCalendar::easterSunday(year: $year)->format('Y-m-d')); + }//end testEasterIsComputedForFutureYears() + + public function testNationalCalendarIsCorrectMoreThanFiveYearsOut(): void { + $calendar = WorkingCalendar::fromArray(definition: self::nlNational()); + $dates = $calendar->nonWorkingDates(year: 2035); + + // Easter 2035 is 25 March: Goede Vrijdag 23 March, Tweede Paasdag 26 March, + // Hemelvaart 3 May, Tweede Pinksterdag 14 May. + self::assertArrayHasKey('2035-03-23', $dates); + self::assertArrayHasKey('2035-03-26', $dates); + self::assertArrayHasKey('2035-05-03', $dates); + self::assertArrayHasKey('2035-05-14', $dates); + self::assertArrayHasKey('2035-01-01', $dates); + self::assertArrayHasKey('2035-04-27', $dates); + self::assertArrayHasKey('2035-12-25', $dates); + self::assertArrayHasKey('2035-12-26', $dates); + self::assertCount(8, $dates, 'no year resolves to weekends-only through an exhausted table'); + }//end testNationalCalendarIsCorrectMoreThanFiveYearsOut() + + public function testKoningsdagOnASundayIsObservedOnTheSaturday(): void { + $calendar = WorkingCalendar::fromArray(definition: self::nlNational()); + // 27 April 2025 is a Sunday. + self::assertSame('7', (new DateTimeImmutable('2025-04-27'))->format('N')); + $dates = $calendar->nonWorkingDates(year: 2025); + self::assertArrayHasKey('2025-04-26', $dates); + self::assertSame('Koningsdag', $dates['2025-04-26']); + self::assertArrayNotHasKey('2025-04-27', $dates); + + // 27 April 2026 is a Monday: observed on the day itself. + self::assertArrayHasKey('2026-04-27', $calendar->nonWorkingDates(year: 2026)); + }//end testKoningsdagOnASundayIsObservedOnTheSaturday() + + public function testWorkingDayHonoursWeekendsAndHolidays(): void { + $calendar = WorkingCalendar::fromArray(definition: self::nlNational()); + $tz = new DateTimeZone('Europe/Amsterdam'); + self::assertTrue($calendar->isWorkingDay(new DateTimeImmutable('2026-09-01 10:00', $tz)), 'Tuesday'); + self::assertFalse($calendar->isWorkingDay(new DateTimeImmutable('2026-09-05 10:00', $tz)), 'Saturday'); + self::assertFalse($calendar->isWorkingDay(new DateTimeImmutable('2026-09-06 10:00', $tz)), 'Sunday'); + self::assertFalse($calendar->isWorkingDay(new DateTimeImmutable('2026-12-25 10:00', $tz)), 'Eerste Kerstdag'); + self::assertFalse($calendar->isWorkingDay(new DateTimeImmutable('2026-05-14 10:00', $tz)), 'Hemelvaart 2026'); + self::assertSame(8.0, $calendar->getHoursPerWorkingDay()); + self::assertSame('nl-national', $calendar->getSlug()); + self::assertNull($calendar->getOrganisation()); + }//end testWorkingDayHonoursWeekendsAndHolidays() + + public function testEnumeratedOnlyCalendarIsRefused(): void { + $this->expectException(FlowTimerValidationException::class); + $this->expectExceptionMessage('would expire after \'2027-12-26\''); + WorkingCalendar::fromArray( + definition: [ + 'slug' => 'tabulated', + 'workingWeekdays' => [1, 2, 3, 4, 5], + 'hoursPerWorkingDay' => 8, + 'rules' => [], + 'exceptions' => ['2027-12-25', '2027-12-26', '2026-01-01'], + ] + ); + }//end testEnumeratedOnlyCalendarIsRefused() + + public function testExceptionsAlongsideRulesAreAccepted(): void { + $definition = self::nlNational(); + $definition['exceptions'] = [['date' => '2026-10-05', 'name' => 'Lokale sluitingsdag']]; + $definition['organisation'] = 'org-1'; + $calendar = WorkingCalendar::fromArray(definition: $definition); + self::assertArrayHasKey('2026-10-05', $calendar->nonWorkingDates(year: 2026)); + self::assertArrayNotHasKey('2026-10-05', $calendar->nonWorkingDates(year: 2027)); + self::assertSame('org-1', $calendar->getOrganisation()); + }//end testExceptionsAlongsideRulesAreAccepted() + + public function testHoursPerWorkingDayIsRequired(): void { + $definition = self::nlNational(); + unset($definition['hoursPerWorkingDay']); + $this->expectException(FlowTimerValidationException::class); + $this->expectExceptionMessage('hoursPerWorkingDay'); + WorkingCalendar::fromArray(definition: $definition); + }//end testHoursPerWorkingDayIsRequired() + + public function testUnknownRuleKindIsRefused(): void { + $definition = self::nlNational(); + $definition['rules'][] = ['kind' => 'lunar', 'name' => 'x']; + $this->expectException(FlowTimerValidationException::class); + $this->expectExceptionMessage("unknown kind 'lunar'"); + WorkingCalendar::fromArray(definition: $definition); + }//end testUnknownRuleKindIsRefused() + + public function testWeekdaysMustBeDeclared(): void { + $definition = self::nlNational(); + unset($definition['workingWeekdays']); + $this->expectException(FlowTimerValidationException::class); + $this->expectExceptionMessage('workingWeekdays'); + WorkingCalendar::fromArray(definition: $definition); + }//end testWeekdaysMustBeDeclared() + + public function testObservedShiftKindAndMalformedShiftAreHandled(): void { + $definition = self::nlNational(); + $definition['rules'] = [['kind' => 'observedShift', 'month' => 4, 'day' => 27, 'whenWeekday' => 7, 'days' => -1, 'name' => 'K']]; + $calendar = WorkingCalendar::fromArray(definition: $definition); + self::assertArrayHasKey('2025-04-26', $calendar->nonWorkingDates(year: 2025)); + + $definition['rules'] = [['kind' => 'fixed', 'month' => 4, 'day' => 27, 'observedShift' => ['whenWeekday' => 'funday', 'days' => -1]]]; + $this->expectException(FlowTimerValidationException::class); + WorkingCalendar::fromArray(definition: $definition); + }//end testObservedShiftKindAndMalformedShiftAreHandled() +}//end class diff --git a/tests/Unit/Service/Task/TaskServiceTimerOutcomeTest.php b/tests/Unit/Service/Task/TaskServiceTimerOutcomeTest.php new file mode 100644 index 0000000000..3985d9efd4 --- /dev/null +++ b/tests/Unit/Service/Task/TaskServiceTimerOutcomeTest.php @@ -0,0 +1,160 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Task + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAudit; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Exception\TaskConflictException; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCA\OpenRegister\Service\Task\TaskPerformerResolver; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\Task\TaskService + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Db\TaskAudit + * @covers \OCA\OpenRegister\Service\Task\TaskState + */ +class TaskServiceTimerOutcomeTest extends TestCase { + + private TaskMapper&MockObject $tasks; + + private TaskAuditMapper&MockObject $audits; + + /** + * @var array + */ + private array $audited = []; + + protected function setUp(): void { + parent::setUp(); + $this->tasks = $this->createMock(TaskMapper::class); + $this->audits = $this->createMock(TaskAuditMapper::class); + $this->audits->method('insert')->willReturnCallback(function (TaskAudit $entry): TaskAudit { + $this->audited[] = $entry; + + return $entry; + }); + }//end setUp() + + private function service(): TaskService { + return new TaskService( + tasks: $this->tasks, + candidates: $this->createMock(TaskCandidateMapper::class), + relations: $this->createMock(TaskRelationMapper::class), + audits: $this->audits, + authorization: $this->createMock(TaskAuthorizationService::class), + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->createMock(IDBConnection::class), + logger: new NullLogger(), + builder: new TaskBuilder() + ); + }//end service() + + private function openTask(string $uuid): Task { + $task = new Task(); + $task->setId(7); + $task->setUuid($uuid); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setPerformerType(Task::PERFORMER_USER); + $task->setAssignee('alice'); + + return $task; + }//end openTask() + + public function testTheFourOutcomesLeaveFourDistinguishableStates(): void { + $tasks = []; + $this->tasks->method('findByUuid')->willReturnCallback(function (string $uuid) use (&$tasks): Task { + $tasks[$uuid] = $this->openTask($uuid); + + return $tasks[$uuid]; + }); + $this->tasks->method('updateIfOpen')->willReturn(true); + $service = $this->service(); + + $results = []; + foreach (['skip', 'error', 'dead_letter', 'transition:approve'] as $outcome) { + $task = $service->applyTimerOutcome(uuid: 't-' . $outcome, outcome: $outcome, source: 'flow-timer:abc', reason: 'deadline reached'); + $results[$outcome] = $task->getState() . '/' . $task->getOutcome(); + self::assertTrue($task->getIsTerminal()); + } + + self::assertSame( + ['skip' => 'completed/skipped', 'error' => 'terminated/failed', 'dead_letter' => 'disabled/dead_letter', 'transition:approve' => 'completed/approve'], + $results + ); + self::assertCount(4, array_unique($results), 'skip and error are different outcomes'); + self::assertSame('flow-timer:abc', $tasks['t-skip']->getCompletedBy()); + self::assertNotNull($tasks['t-skip']->getCompletedAt()); + self::assertNull($tasks['t-error']->getCompletedAt(), 'a failure is not a completion'); + + self::assertSame(['skip', 'error', 'dead_letter', 'approve'], array_map(static fn (TaskAudit $a): string => (string)$a->getAction(), $this->audited)); + self::assertSame('flow-timer:abc', $this->audited[0]->getActor()); + self::assertSame('deadline reached', $this->audited[0]->getReason()); + }//end testTheFourOutcomesLeaveFourDistinguishableStates() + + public function testAnAlreadyTerminalTaskIsLeftAsItEnded(): void { + $done = $this->openTask('t-1'); + $done->setState(Task::STATE_COMPLETED); + $done->setIsTerminal(true); + $done->setOutcome('approved'); + $this->tasks->method('findByUuid')->willReturn($done); + $this->tasks->expects(self::never())->method('updateIfOpen'); + + $result = $this->service()->applyTimerOutcome(uuid: 't-1', outcome: 'error', source: 'flow-timer:abc', reason: 'x'); + self::assertSame('approved', $result->getOutcome()); + self::assertSame([], $this->audited); + }//end testAnAlreadyTerminalTaskIsLeftAsItEnded() + + public function testUnknownAndMalformedOutcomesAreRefusedBeforeAnyRead(): void { + $this->tasks->expects(self::never())->method('findByUuid'); + $service = $this->service(); + foreach (['expire', 'transition:', 'transition:skip', 'transition:' . str_repeat('x', 33)] as $bad) { + try { + $service->applyTimerOutcome(uuid: 't-1', outcome: $bad, source: 's', reason: 'r'); + self::fail('accepted ' . $bad); + } catch (TaskValidationException $refused) { + self::assertStringContainsString("'" . $bad . "'", $refused->getMessage()); + } + } + }//end testUnknownAndMalformedOutcomesAreRefusedBeforeAnyRead() + + public function testALostRaceSurfacesAsAConflict(): void { + $this->tasks->method('findByUuid')->willReturn($this->openTask('t-1')); + $this->tasks->method('updateIfOpen')->willReturn(false); + $this->expectException(TaskConflictException::class); + $this->service()->applyTimerOutcome(uuid: 't-1', outcome: 'skip', source: 's', reason: 'r'); + }//end testALostRaceSurfacesAsAConflict() +}//end class From 44843c6a613a5fa70f7f5f872cfc7732159c27d9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 21:49:55 +0200 Subject: [PATCH 37/68] fix(e2e): give occ a maxBuffer so the worker scenarios stop self-skipping (#3304) background-job:list prints every job's serialized argument in one table; on a live instance that is 1.8 MiB, past Node's 1 MiB execSync default. The overflow threw, runWorkerJobId() caught it into null, and the worker-driven scenarios skipped as 'occ not reachable' while occ was fine. With the buffer raised, 'completing a task from the inbox advances its flow run' runs and passes on the dossiq proof instance. Co-authored-by: Ruben van der Linde --- tests/e2e/api-direct/flow-user-task.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e/api-direct/flow-user-task.spec.ts b/tests/e2e/api-direct/flow-user-task.spec.ts index b93fd9a5ca..31454d1e3e 100644 --- a/tests/e2e/api-direct/flow-user-task.spec.ts +++ b/tests/e2e/api-direct/flow-user-task.spec.ts @@ -63,6 +63,13 @@ function occ(args: string): string { } return execSync(`docker exec -u www-data ${CONTAINER} php occ ${args}`, { encoding: 'utf8', + // `background-job:list` prints every job's serialized argument in one + // table; on a live instance that is well past Node's 1 MiB execSync + // default. The overflow throws ENOBUFS, the catch in runWorkerJobId() + // returns null, and the worker-driven scenarios self-skip as "occ not + // reachable" while occ is fine. Measured 1.8 MiB on a fresh proof + // instance, 2026-09-01. + maxBuffer: 32 * 1024 * 1024, }) } From 027ed41b512170ecc3c6ee8816cc2ed03e0840a2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 22:07:42 +0200 Subject: [PATCH 38/68] fix(e2e): follow the router off hash routing in the three specs that lagged it (#3306) #3270 moved openregister to createWebHistory. Three places still assumed the hash form and produced the two E2E failures on development: - flow-controls waited for '#/flows/' while the app navigated to '/apps/openregister/flows/', so the wait always timed out. - manifest-shell walks eighteen routes in one test. Under hash routing all but the first were same-document changes; now every one is a full document load, so the app boots eighteen times and the default budget runs out around the eleventh. It surfaced as 'session closed' against whichever route it reached, which reads like a crash rather than a timeout. - docs-screenshots built one '/#/tables' route while every other call in the same file already used the path form, and its helper comment still claimed the router ran in hash mode. Test titles in core-crud and ui-navigation still read '#/registers' and the like. Those navigate by path already, so the names are merely stale; renaming them would move test ids that spec-coverage keys on, so they are left alone. Co-authored-by: Conduction Release Bot --- tests/e2e/ci/flow-controls.spec.ts | 2 +- tests/e2e/docs-screenshots.spec.ts | 7 ++++--- tests/e2e/manifest-shell.spec.ts | 7 +++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/e2e/ci/flow-controls.spec.ts b/tests/e2e/ci/flow-controls.spec.ts index 48e900e3b4..a91aaaff44 100644 --- a/tests/e2e/ci/flow-controls.spec.ts +++ b/tests/e2e/ci/flow-controls.spec.ts @@ -504,7 +504,7 @@ test('flow controls render, and a flow can be built, saved and run', async ({ // The route still has to catch up, or a reload lands back on `new`. With // the create already asserted above this is a pure router assertion with // no round-trip left in it, so it needs a fraction of the old budget. - await page.waitForURL(new RegExp(`#/flows/${uuid}$`), { + await page.waitForURL(new RegExp(`/flows/${uuid}$`), { timeout: 5_000, }) diff --git a/tests/e2e/docs-screenshots.spec.ts b/tests/e2e/docs-screenshots.spec.ts index 22b0ba14dc..8f167c59c3 100644 --- a/tests/e2e/docs-screenshots.spec.ts +++ b/tests/e2e/docs-screenshots.spec.ts @@ -112,8 +112,9 @@ async function dismissOverlays(page: Page): Promise { /** Navigate to an OR (or absolute) route and settle. */ async function go(page: Page, route: string): Promise { - // OR routes use HASH form — the router runs in hash mode (src/main.js); - // path-form deep-links render the dashboard instead of the target page. + // OR routes use PATH form. They used to be hash routes, and a path deep-link + // then rendered the dashboard instead of the target page; #3270 moved the + // router to createWebHistory and inverted that. const url = route.startsWith('/apps/') || route.startsWith('/settings/') ? `/index.php${route}` @@ -295,7 +296,7 @@ test.describe('docs: user track', () => { } const route = - reg && sch ? `/#/tables?register=${reg}&schema=${sch}` : '/#/tables' + reg && sch ? `/tables?register=${reg}&schema=${sch}` : '/tables' await go(page, route) // Wait for the deep-linked register/schema selection to actually apply // (SearchSideBar.applyQueryParamsFromRoute retries while the register diff --git a/tests/e2e/manifest-shell.spec.ts b/tests/e2e/manifest-shell.spec.ts index 733dbc8bf8..21b90aaf4b 100644 --- a/tests/e2e/manifest-shell.spec.ts +++ b/tests/e2e/manifest-shell.spec.ts @@ -182,6 +182,13 @@ test.describe('openregister-app-manifest — registry dispatch', () => { page, }) => { requireAuth() + // Eighteen routes, and since `feat(router): move openregister off hash + // routing` (#3270) each one is a full document load rather than a + // same-document hash change — the app boots eighteen times, not once. + // The default budget was sized for the cheap version and now runs out + // around the eleventh route, which surfaces as a teardown error + // ("session closed") against whichever route it happened to reach. + test.setTimeout(180_000) // One representative route per top-level manifest destination. Each must // resolve through CnPageRenderer → registry kind:"page" entry and render // the app-content shell (lists may be empty against a fresh instance). From d0756945ad588b30430058a7f6792bce4c224e0a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 22:19:06 +0200 Subject: [PATCH 39/68] build(guard): fail the build when two Dexie versions ship in one chunk set (#3307) Dexie throws "Two different versions of Dexie loaded in the same app" at module init when two copies at different versions meet in one page, and the SPA never mounts. The 2026-09-01 acceptance run on a clean rig hit exactly that: integration-global at 4.4.5 beside the vendor chunk at 4.4.4, and every app SPA on the instance rendered as bare chrome, because Nextcloud injects integration-global on every page. The version alignment landed in #3099 and in nextcloud-vue#825, which stopped the library vendoring its own dexie. Nothing guarded it: the next dexie bump on either side reintroduces the throw with every instrument green. This adds scripts/check-single-dexie.js as a postbuild step, so it runs wherever npm run build runs, including the shared release workflow. It fails when two built chunks embed different Dexie versions, or when the embedded version is not the one package-lock.json resolves. Verified against a fresh production build (one version, 4.4.5, matching the lockfile, exit 0) and against a planted 4.4.4 chunk (exit 1). --- package.json | 2 + scripts/check-single-dexie.js | 131 ++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100755 scripts/check-single-dexie.js diff --git a/package.json b/package.json index 51cba34c8d..987dcf0ca1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "prepare": "git config core.hooksPath .githooks || true", "build": "webpack --config webpack.config.js --progress --mode production", + "postbuild": "node scripts/check-single-dexie.js", "dev": "NODE_ENV=development webpack --config webpack.config.js --progress", "watch": "NODE_ENV=development webpack --config webpack.config.js --progress --watch", "lint": "eslint src tests scripts", @@ -29,6 +30,7 @@ "test:l10n:write": "node tests/l10n/check-l10n.js --write", "test:l10n:parity": "node tests/l10n/check-l10n-parity.js", "check:l10n": "node scripts/check-l10n.js", + "check:dexie": "node scripts/check-single-dexie.js", "clean:l10n": "node scripts/clean-l10n.js", "find:unwrapped": "node scripts/find-unwrapped.js", "l10n:status": "node scripts/l10n/batch.js status", diff --git a/scripts/check-single-dexie.js b/scripts/check-single-dexie.js new file mode 100755 index 0000000000..a8ce230e64 --- /dev/null +++ b/scripts/check-single-dexie.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +// +// check-single-dexie.js: the Dexie singleton guard. +// +// WHY THIS EXISTS +// +// Dexie refuses to initialise twice in one page: when a second copy loads +// at a different version it throws "Two different versions of Dexie loaded +// in the same app" at module init, before the SPA mounts. Nextcloud loads +// this app's integration-global script on EVERY page of the instance, next +// to whichever app's own bundles, so a version drift between any two built +// chunks blanks every app SPA on the instance. That is not hypothetical: +// the 2026-09-01 acceptance run on a clean rig found integration-global at +// dexie 4.4.5 beside vendor chunks at 4.4.4, and every app rendered as +// bare chrome. Two things have to hold, and this script checks both: +// +// 1. every built chunk that embeds a Dexie copy embeds the SAME version; +// 2. that version is the one package-lock.json resolves, so a stale +// chunk left over from an earlier build (or a dependency that vendors +// its own copy, as @conduction/nextcloud-vue did before its build +// externalised dexie) cannot ship unnoticed. +// +// HOW IT DETECTS A COPY +// +// Dexie's own duplicate check ships in every copy of the library, so a +// built chunk embeds Dexie exactly when it contains the error string +// "Two different versions of Dexie". Inside such a chunk the version +// literal survives minification as `semVer:"x.y.z"` (Dexie.semVer). Both +// markers were verified against the released production bundles. +// +// WHEN IT RUNS +// +// As `postbuild`, so it runs wherever `npm run build` runs: locally, in +// code quality CI, and in the release build that packages js/ into the +// App Store tarball. Standalone via `npm run check:dexie`. When js/ does +// not exist yet it skips loudly instead of failing, matching the +// check-integration-parity.sh convention. +// +// Exit codes: +// 0: zero or one Dexie version across js/, matching the lockfile +// 1: two or more versions, or a version the lockfile does not resolve + +const fs = require('fs') +const path = require('path') + +const repoRoot = path.join(__dirname, '..') +const jsDir = path.join(repoRoot, 'js') + +const SENTINEL = 'Two different versions of Dexie' +const SEMVER_RE = /semVer\s*[:=]\s*["']([0-9][0-9A-Za-z.+-]*)["']/g + +if (!fs.existsSync(jsDir)) { + console.log( + 'i dexie singleton: js/ not built yet, skipping (run npm run build first)', + ) + process.exit(0) +} + +let expected = null +try { + const lock = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'), + ) + expected = + (lock.packages + && lock.packages['node_modules/dexie'] + && lock.packages['node_modules/dexie'].version) + || null +} catch (e) { + console.log( + `i dexie singleton: could not read package-lock.json (${e.message}); checking chunk agreement only`, + ) +} + +const findings = [] +for (const name of fs.readdirSync(jsDir).sort()) { + if (!name.endsWith('.js')) { + continue + } + const text = fs.readFileSync(path.join(jsDir, name), 'utf8') + if (!text.includes(SENTINEL)) { + continue + } + const versions = new Set() + for (const m of text.matchAll(SEMVER_RE)) { + versions.add(m[1]) + } + if (versions.size === 0) { + // A chunk carries Dexie's error string but no recognisable version + // literal. The marker contract changed, so the guard can no longer + // see, and a guard that cannot see must say so rather than pass. + console.error( + `x dexie singleton: js/${name} embeds Dexie (sentinel found) but no semVer literal matched; update SEMVER_RE in ${path.basename(__filename)}`, + ) + process.exit(1) + } + for (const v of versions) { + findings.push({ file: name, version: v }) + } +} + +if (findings.length === 0) { + console.log('+ dexie singleton: no built chunk embeds Dexie') + process.exit(0) +} + +const distinct = [...new Set(findings.map((f) => f.version))].sort() + +for (const f of findings) { + console.log(` js/${f.file}: dexie ${f.version}`) +} + +if (distinct.length > 1) { + console.error( + `x dexie singleton: ${distinct.length} different Dexie versions in one chunk set (${distinct.join(', ')}). Loading any two of these chunks in one page throws at module init and the SPA never mounts. Rebuild from a clean js/ with a single resolved dexie.`, + ) + process.exit(1) +} + +if (expected && distinct[0] !== expected) { + console.error( + `x dexie singleton: built chunks carry dexie ${distinct[0]} but package-lock.json resolves ${expected}. A chunk is stale, or a dependency vendors its own copy. Rebuild from a clean js/.`, + ) + process.exit(1) +} + +console.log( + `+ dexie singleton: one Dexie version (${distinct[0]}) across ${findings.length} chunk(s)${expected ? ', matching the lockfile' : ''}`, +) From 82ed32288132ce146849b8336246fa80e1c3a9fe Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 22:27:29 +0200 Subject: [PATCH 40/68] feat(approval): consolidate the approval engine onto ordered task sequences (flow-approval-consolidation) (#3302) * feat(tasks): the task store - four tables, entities and mappers The flow-task-entity storage layer (openspec/changes/flow-task-entity): openregister_tasks with the resolved 23-shape union (no overdue column, no app-named column, performer_type as an open string vocabulary so the coming external type is an append, definition_version shipped now), openregister_task_candidates as the pooled-inbox index half of the candidate pool, openregister_task_relations as the typed anchor table, and openregister_task_audit with an append-only mapper whose update and delete refuse. claim() is a conditional update so the database decides the race, and the inbox page and total share one predicate builder. Ticks tasks 1.1 and 1.2. * feat(tasks): the vocabularies, the authorization and the routing Normalisation at the boundary: TaskState publishes the one legacy-status mapping onto the six CMMN states (collapsed distinctions survive on outcome, an unmapped value is refused naming itself, and a migration may declare its source vocabulary so procest's status 'open' fails loudly); TaskPriority lands all four fleet scales on low|normal|high|urgent and refuses pipelinq's 'normaal'; TaskTemporalProjection is the ONE overdue derivation, computed and never stored. TaskAuthorizationService decides every verb fail-closed BEFORE mutation: no group backend, an unresolvable role or an unknown performer type each DENY rather than skip, and no nullable service-unavailable return exists. TaskPerformerResolver carries the five routing strategies; a strategy that finds nobody assigns nobody. Ticks tasks 2.1, 2.2, 3.1 and 3.2. * feat(tasks): the lifecycle service, cancellation propagation and the inbox TaskService: create/offer/claim/unclaim/assign/reassign/delegate/ resolve/complete/cancel, each authorized fail-closed before any mutation, each audited in the SAME transaction (an audit-write failure unwinds the completion), denials audited too. One write path maintains the candidate JSON and the candidate index rows together; state and is_terminal move in one statement; claim is the mapper's conditional update so the race's loser gets a conflict; a rejecting outcome without a comment is refused before anything moves; the template snapshot is frozen at creation. Cancellation propagation (design D-8): FlowRunMapper::update() - the one choke point every terminal run write passes - dispatches FlowRunTerminalEvent, and TaskRunTerminalListener terminates that run's open tasks with the reason recorded. Idempotent, and a task with run_uuid null is structurally out of reach. TaskInboxService answers assigned/pooled/watched/by-object in the datastore over one shared predicate set, with the total from the same predicates, subject context batched per page, a display title synthesized on read for titleless tasks (never persisted), and the derived temporal projection attached to rows only. Ticks tasks 4.1-4.5, 5.1, 6.1 and 6.2. * feat(tasks): the REST surface and the seed fixtures TaskController + /api/flow-tasks routes for the inbox, the detail and audit reads (both visibility-checked) and every lifecycle verb. The family is named for the flow-tasks CAPABILITY, not a flow requirement - /api/tasks itself already belongs to the CalDAV VTODO leaf. Every method declares its auth posture attribute AND delegates its actual authorization to TaskAuthorizationService inside the service; refusals translate uniformly (400 validation, 403 denial, 409 conflict naming the current state, 404 absence). SeedTaskFixtures installs the five seed groups from design.md (pooled municipal check with no run, delegated approval with enforcing expiry on a run, agent task with a typed checklist, one completed 'approved' and one terminated by propagation, plus audit fixtures including one DENIED entry), idempotent on uuid - and is REGISTERED in appinfo/info.xml in this same commit, because an unregistered repair step is a known fleet defect. Ticks tasks 7.1 and 8.1. * test(tasks): the guarantees, pinned - 93 tests over the task layer Table-driven legacy-status and priority tests including the live fleet defects: procest's status 'open' against a source vocabulary that does not define it is refused naming the value, and pipelinq's 'normaal' is refused on every scale. Authorization: a stranger who knows the uuid is denied on every verb, an unresolvable role denies naming the role, an absent group backend denies, an unknown performer type denies, watchers read and never act. Concurrency and transactionality: the two-claim race yields one assignee and one conflict, a rejection without a comment moves nothing, and an injected audit-write failure rolls the completion back. Derivation: overdue flips with an injected clock while the stored row stays byte-identical. Inbox: the page and the total run over the SAME criteria object (25 of 120 reported honestly), a titleless task presents a synthesized display title while its stored title stays null, and a runless task completes identically to a runful one. Mutation-checked: disabling assertMay fails 7 tests; removing the transaction rollback fails 2; both restored byte-identical (sha256-verified). Ticks tasks 9.1, 9.2 and 9.3. * test(tasks): playwright coverage for the two e2e-marked scenarios 'a stranger is refused on the task detail route': a provisioned second account gets 403 on the detail read AND on complete, and the task provably did not move - the positive control for the hole where knowing a uuid was the whole check. 'the inbox route returns tasks with subject context': one request lists the assignee's work with a display title, the subject slot, the derived overdue flag and a datastore total, and when the dev seed offers a live object the anchored subject context rides along. Both specs clean up after themselves and skip loudly when the instance cannot provision a stranger. Ticks task 9.4. * style(tasks): named parameters on every internal call, no inline ifs The repo's PHPCS standard requires named arguments on calls into OCA code and forbids ternaries. Exception constructors now name message:, the transactional/stringOrNull/arrayOrNull/respondWith calls name their parameter, the nine isset-ternaries collapse into one intOrNull helper, and the two remaining conditionals become explicit if blocks. No behaviour change: the 93 task tests are unchanged and green. * refactor(tasks): the quality pass - intake extracted, complexity earned down TaskBuilder now owns intake: boundary data in, a validated unsaved Task out, with every published vocabulary applied there once. That returns TaskService to lifecycle only and under the complexity budget. The authorization switch becomes a verb-to-rule table with static dispatch; the inbox predicate builder splits into scope, visibility and filters; the migration's indexes and the seed fixtures get methods of their own; sort and direction travel as one parameter (-dueAt) so the inbox route keeps a single-digit parameter list. The few remaining suppressions each carry their justification inline. Behaviour unchanged: the 93 task tests pass untouched except for injecting the builder. * fix(tasks): the PHPStan pass - typed mapper overrides and a DBAL constant The mapper overrides now guard their entity type with a throwing instanceof check, which is what lets the generic QBMapper contract type-check (and turns a wrong entity into a named error instead of a silent write). candidateMembershipPredicate declares the IQueryFunction it returns. The migration uses Types::DATETIME_MUTABLE, the constant the current DBAL stubs actually define. * style(tasks): import InvalidArgumentException in the three mapper guards PHPMD MissingImport on the type guards added in the PHPStan pass. No behaviour change. * feat(flow): the openregister.user-task node puts a person into the graph The node creates ONE task through TaskService on its first firing with items, stores the uuid in its own resume slot, and suspends with a heartbeat that is never null (findAbandonedSignals reaps null and would fail slow approvals at 14 days). Later firings read the TASK for terminality, never context.signal, so two user-task nodes in one flow keep independent answers. The outcome bag lands on every item's json under outcomeKey (default task), with decided/rejected separating a person's decision from a task that merely ended; failOnReject is opt-in. The advance budget (ADR-098 D9) is 0 | N | "all", null refused by name (FlowAdvanceBudget). TaskService announces every committed terminal transition (TaskTerminalEvent, after the transaction); the listener wakes the run with an empty signal and, per the node's stored budget, continues it through FlowRunAdvancer with a per-walk ceiling the engine consumes (CONTEXT_ADVANCE_BUDGET). A spent budget parks the run as due. Branch mootness: the engine reports pruned exits to FlowTaskMootness, which terminates the task of a user-task node standing on a cleared place. Run terminality stays with TaskRunTerminalListener from #3258. Ticks tasks 1.1-1.4, 2.1-2.3, 3.1-3.2, 4.1-4.3, 5.1-5.2. * fix(tasks): the review pass - offer is the requester's, and the inbox works on PostgreSQL Two blocking defects from the independent review of #3258: - offer bypassed per-task authorization and rewrote the assignee. Any authenticated user could POST offer {"routingFallback": "mallory"} on an assigned active task, become its assignee, and then complete it. offer now needs the requester (or an administrator), refuses a task that already has an assignee (unclaim or reassign instead), and is in the stranger-denied test's verb list, whose omission is how the suite stayed green. - The watchers LIKE ran against a json column, which PostgreSQL refuses (operator does not exist: json ~~ unknown): every non-admin inbox request 500ed there. The column is now cast per platform (AS TEXT on PostgreSQL, AS CHAR elsewhere), every identifier in the mapper's raw SQL goes through the platform's own quoter (no backticks), and TaskMapperTest pins the cast on a PostgreSQL platform mock. The should-fix items: authorization runs before the terminality check and a task the caller may not see answers 404 on every route, verbs included (no existence or state oracle); over HTTP the requester is pinned to the actor and a terminal creation state is refused, with import() as the trusted path for migrations and the user-task node; every state-changing write is a conditional update (WHERE is_terminal = false) so a second completion conflicts instead of overwriting; delegate refuses an empty delegate and keeps naming the original performer on re-delegation; an unexpected failure is a generic 500 whose detail goes to the log; propagation continues past a failing task; role pools are matched by the inbox EXISTS; the seed fixtures run only when app config openregister/seed_demo_tasks is true; the CSRF posture is recorded in the controller docblock; the e2e stranger scenario fails loudly instead of skipping and now covers the non-admin inbox and a watcher. Class-level @spec tags on every new class. TaskControllerTest is the contract test for all fourteen /api/flow-tasks endpoints (gate-25 PASS). Checks on this tree: PHPCS 0, PHPMD 0 (touched files), PHPStan 0, Psalm 0, 120 task tests green. * style(tasks): format the task inbox e2e spec with the Nextcloud prettier config CI's Frontend Check (format) named tests/e2e/task-inbox.spec.ts. Formatted with @nextcloud/prettier-config's options (tabs, no semicolons, single quotes, trailing commas, print width 85, operators leading). * test(tasks): cover the entities, the listener, the seed step and the builder CI's pgsql cell did not fail in PHPUnit (17860 tests, 0 failures, and app:enable succeeded); it failed the coverage guard: the changed files measured 3.85% (69/1791 statements) against a 7.32% base, because the suite runs with strict coverage metadata and collaborators exercised through TaskServiceTest were never credited. This adds the tests that were genuinely missing: the four entities' typing, hydration and serialisation (no overdue anywhere in the stored row); the terminal-run listener and event; the seed step off by default, seeding the five groups when on, idempotent on uuid, and continuing past a failing fixture; and TaskBuilder credited where TaskServiceTest exercises it. 133 task tests green. * feat(flow-timers): the durable timer store, its fire ledger and its history Three additive tables: openregister_flow_timers holds a budget and a suspension ledger rather than a target instant (design D-2), with fire_at and next_rung_at as indexed derivations for the two range scans (D-8) and no overdue column of any kind; openregister_flow_timer_fires is unique on (timer_uuid, rung_key) so the INSERT is the rung claim (D-7); and openregister_flow_timer_events is append-only evidence with no update or delete path. The fire and event mappers refuse update() and delete(). TaskMapper::update() now announces terminality through TaskTerminalEvent from the one choke point every terminal task write passes, inside the verb's transaction, so timers can be cancelled in the same operation (D-9). Tasks 1.1 and 1.2. * test(flow): the user-task node, its budget, its bridge and its propagation Node: one task per node per run across a heartbeat wake, empty firing creates nothing and does not suspend, a claim is not a completion, the signal slot cannot answer for a performer, askedAt is not restamped, two nodes need two answers, outcome placement under json., rejection as a branch with failOnReject opt-in, a terminated task is not a rejection, the config-validation table (advance 0/3/all accepted; null, '', -1, 'unlimited' refused naming the value; no performer refused). Budget: FlowAdvanceBudget shapes; the engine parks at a spent ceiling and the worker walk runs the remainder; an oversight veto still applies in-request; pruned exits reach the mootness collaborator. Bridge: the provenance stamp, offer-on-strategy, 0/N/all continuation, and a failed continuation leaving the run due. Propagation: a run stopped with two tasks terminates both once across two observations; a losing branch takes its task; a run-less task is never touched. TaskService announces terminality once, after commit, and a listener failure cannot undo it. Mutation-checked: dropping the run-less guard, the performer guard or the signal-ignore each turns the suite red. Splits the node's config intake into UserTaskConfig and groups the form so the node stays under the PHPMD complexity and method-length ceilings. Ticks tasks 6.1-6.4. * test(e2e): the eight user-task scenarios over the live API Authored through POST /api/flows, run through the synchronous test endpoint, driven through the flow-tasks verbs. Two scenarios need the worker (the default budget parks for it; a run has no stop verb, so the operator's kill switch is the stop) and drive it through occ in the dev container, skipping loudly where occ is not reachable. Each test carries its scenario slug for gate-19. Ticks task 6.5 (19/19). * docs(flow): spec anchors on the three public UserTaskConfig readers gate-16 named assignee(), outcomeKey() and renderedTitle(). * test(tasks): walk the mapper queries and credit every exercised class The pgsql cell's coverage guard credits only statements in classes a test names with @covers (strict coverage metadata), and the changed files measured 20.16% against a 23.46% base. Two moves: every task test class now names each class it actually exercises (TaskBuilder, TaskState and TaskPriority behind TaskServiceTest, TaskInboxCriteria behind the inbox and controller tests, the entities and exceptions everywhere they are built), and the largest untested code is now walked: TaskMapper's whole query vocabulary, the candidate, relation and audit mappers, and FlowRunMapper's terminality announcement, over a fluent query-builder double that records which predicate and column each query sets, so a conditional update without its openness guard or a pooled scope without its EXISTS would fail here. Plus the untested TaskService verbs (unclaim, assign, reassign, cancel, resolve, terminateAsMoot, relations, intake refusals), TaskInboxService's batched subject context, and the controller's filter, flag and failing-backend paths. 168 task tests. * wip: checkpoint before rate-limit pause (2026-09-01 09:45) * wip: checkpoint before rate-limit pause (2026-09-01 09:45) * chore: drop the node_modules symlink the wip checkpoint committed An absolute path into another checkout has no business in the tree; the symlink only served a local Playwright --list and stays untracked. * style(tasks): a top-level type import and sorted specifiers in the inbox e2e spec Vue Quality (eslint) on #3258 named four errors in tests/e2e/task-inbox.spec.ts: perfectionist/sort-named-imports (the specifiers now read apiRequest, expect, test) and import-extensions/ban-inline-type-imports (APIRequestContext is a top-level import type). eslint --fix plus a plain run: 0 errors; the sibling flow-engine.spec.ts as control also 0; prettier clean. * feat(flow-timers): the business clock, its ladder, its sweep and the tests that can fail One working calendar (computed rules, Easter computus, Koningsdag observed shift; an enumerated-only calendar is refused because it expires), one calculator (hours, businessDays, calendarDays as DATES so a term lands at the same wall-clock time across DST), and one lifecycle: arm with a stored anchor, suspend into a consumed-value ledger, resume by re-projection, extend once (the override is a separate operation recorded as such), supersede on a moved anchor with copy-forward of the rungs still in the past, cancel on subject terminality inside the write that made it terminal. The ladder is seeded data (14/7/2/0). A rung is claimed by the unique INSERT before its transition is raised; an expiry by a conditional UPDATE before its outcome is applied as a named task action (skip, error, dead_letter, transition: leave four distinct task states). The sweep is two index range scans on the existing 300s cadence; counts report work performed and a hit limit is logged as truncated. Overdue is never written: describe() derives it, and a suspended timer has no fire_at to be overdue by. Tests run against in-memory fakes with real semantics (range scans filter, claims are conditional, the ledger is unique), including the restart, the overlapping pass, the downtime gap and the fire_at identity after every operation. Tasks 1.3, 2.1, 2.2, 3.1-3.4, 4.1, 4.2, 5.1-5.3, 6.1, 6.2, 7.1, 7.2. * style(e2e): lint and format the user-task spec under the tests/ lint scope A top-level type import, sorted specifiers, the .ts extension on the relative import, and the repo's prettier config. Also carries #3258's matching fix for task-inbox.spec.ts through the merge. * fix(flow-timers): title every property of the seeded calendar and ladder schemas Gate-51 (schema-property-titles) wants a human-friendly title beside each description; the twelve properties of working-calendar and escalation-ladder now carry one. * fix(merge): unify TaskTerminalEvent across the user-task-node and business-timers branches One event class, two dispatch points told apart by a committed flag: the mapper announces terminality inside the verb's transaction for timer cancellation (D-9), the task service announces it after commit for the run continuation (D-5). UserTaskTerminalListener skips uncommitted dispatches. * feat(approval): consolidate the approval engine onto ordered task sequences The chain and step runtime retires onto the task service (ADR-098 D1, full migration, no facade): ApprovalChain/ApprovalStep, their mappers, the approval service and controller, the four step events, the nine routes and the two Vue panels are removed. The declarative surface stays byte-identical and re-points: the installer compiles x-openregister-approval-chains into a derived task template, the gate listener refuses through the same two error codes against the SEQUENCE, and a rejected cycle is closed and kept instead of deleted. Separation of duties moves into a pre-decision guard, evaluated against the actor AND on_behalf_of, before the performer check. New: openregister_task_sequences + TaskSequence(Service|Mapper), a trusted enable verb, TaskSequenceCompletedEvent, sequence columns on tasks, the correlation key on await-signal suspensions with a fail-closed /api/flow-run-signals/{key} route, the in-flight data migration repair step (registered in info.xml, verifying loudly, idempotent) and the operator-run rollback command. Tasks 1.1, 1.2, 2.1, 2.2, 2.3, 3.1, 3.2, 3.3, 4.1, 4.2, 5.1, 6.1, 6.2, 6.3. * wip: checkpoint after rate-limit cut (18:00) * test(approval): cover the sequence, the guard, the gate, correlation and the data migration Gate and advance listeners re-tested against the sequence store; sequence provisioning, in-request advance, rejection propagation and termination; separation of duties refused before the performer check, delegated self-decisions included; enable and consume verbs (consume refuses a second authorization); correlation key resolved from the item and fail-closed delivery (404 not buffered, 409 wakes nothing); the data migration over a seeded in-memory database, idempotent on a second run, failing loudly and naming chain, object and step on an unreconcilable row. The HITL retirement inventory and retired-surface fixtures land beside them. Tasks 7.1 (fixtures), 8.1, 8.2 (unit half). * test(approval): retirement surface, inventory completeness, rollback and contract coverage The no-shim assertions walk lib/ for retired class bindings and legacy-table readers; the HITL inventory test fails on any approval_request property with no named home; the rollback command writes decisions back and reports the facts the legacy schema cannot hold; the sequence mapper's access paths are exercised; the retired approval requests leave the Newman collection and the signal-by-key contract joins it (gate-25); the event replacement mapping is published as migration documentation for filinq. Tasks 4.3, 6.3 (test half), 7.1, 7.2 done; all 19 tasks ticked. * style(quality): clear every analyzer finding the consolidation raised phpcs named-argument and line-length fixes; PHPMD complexity splits in the migration step, the rollback command and the compiler; suppressions moved onto the class docblocks where PHPMD reads them; the duplicated TooManyMethods tag from the timers merge dropped; the pre-existing else in the pg_trgm re-migration simplified while passing by. * test(approval): complete the @covers and @uses rosters; format the sequence panel Every new suite lists the collaborators it executes, so the coverage cell keeps what the tests earn; prettier settles the panel. * style(db): drop the duplicated complexity suppression the timers merge left on TaskMapper * style(db): justify the sequence read joining TaskMapper's query vocabulary * l10n(approval): translate the sequence panel's ten strings across all 36 locales test:l10n named the ten TaskSequencePanel strings missing from en.js; they land in en plus every required locale, inserted in each file's own style so nothing is re-serialised. Czech 'Role {role}' is recorded as a justified cognate (role IS the Czech word). All six frontend-check legs pass locally: test:l10n, test:l10n:parity, check:l10n-js, check:schema-l10n, check:specs and format. --------- Co-authored-by: Conduction Release Bot --- appinfo/info.xml | 21 + appinfo/routes.php | 16 +- docs/development/approval-events-migration.md | 81 ++ l10n/be.js | 10 + l10n/be.json | 10 + l10n/bg.js | 10 + l10n/bg.json | 10 + l10n/bs.js | 10 + l10n/bs.json | 10 + l10n/ca.js | 10 + l10n/ca.json | 10 + l10n/cs.js | 10 + l10n/cs.json | 10 + l10n/da.js | 10 + l10n/da.json | 10 + l10n/de.js | 10 + l10n/de.json | 10 + l10n/el.js | 10 + l10n/el.json | 10 + l10n/en.js | 10 + l10n/en.json | 10 + l10n/es.js | 10 + l10n/es.json | 10 + l10n/et.js | 10 + l10n/et.json | 10 + l10n/fi.js | 10 + l10n/fi.json | 10 + l10n/fr.js | 10 + l10n/fr.json | 10 + l10n/ga.js | 10 + l10n/ga.json | 10 + l10n/hr.js | 10 + l10n/hr.json | 10 + l10n/hu.js | 10 + l10n/hu.json | 10 + l10n/is.js | 10 + l10n/is.json | 10 + l10n/it.js | 10 + l10n/it.json | 10 + l10n/lb.js | 10 + l10n/lb.json | 10 + l10n/lt.js | 10 + l10n/lt.json | 10 + l10n/lv.js | 10 + l10n/lv.json | 10 + l10n/mk.js | 10 + l10n/mk.json | 10 + l10n/mt.js | 10 + l10n/mt.json | 10 + l10n/nb.js | 10 + l10n/nb.json | 10 + l10n/nl.js | 10 + l10n/nl.json | 10 + l10n/pl.js | 10 + l10n/pl.json | 10 + l10n/pt.js | 10 + l10n/pt.json | 10 + l10n/rm.js | 10 + l10n/rm.json | 10 + l10n/ro.js | 10 + l10n/ro.json | 10 + l10n/ru.js | 10 + l10n/ru.json | 10 + l10n/sk.js | 10 + l10n/sk.json | 10 + l10n/sl.js | 10 + l10n/sl.json | 10 + l10n/sq.js | 10 + l10n/sq.json | 10 + l10n/sr.js | 10 + l10n/sr.json | 10 + l10n/sv.js | 10 + l10n/sv.json | 10 + l10n/tr.js | 10 + l10n/tr.json | 10 + l10n/uk.js | 10 + l10n/uk.json | 10 + lib/AppInfo/Application.php | 45 +- .../RollbackApprovalMigrationCommand.php | 234 ++++++ lib/Controller/ApprovalController.php | 361 --------- lib/Controller/FlowRunController.php | 81 ++ lib/Db/ApprovalChain.php | 185 ----- lib/Db/ApprovalChainMapper.php | 189 ----- lib/Db/ApprovalStep.php | 208 ----- lib/Db/ApprovalStepMapper.php | 268 ------- lib/Db/FlowRun.php | 14 + lib/Db/FlowRunMapper.php | 27 + lib/Db/Task.php | 37 + lib/Db/TaskMapper.php | 77 +- lib/Db/TaskSequence.php | 321 ++++++++ lib/Db/TaskSequenceMapper.php | 141 ++++ lib/Event/ApprovalStepApprovedEvent.php | 127 --- lib/Event/ApprovalStepCompletedEvent.php | 110 --- lib/Event/ApprovalStepInitiatedEvent.php | 86 -- lib/Event/ApprovalStepRejectedEvent.php | 105 --- lib/Event/TaskSequenceCompletedEvent.php | 109 +++ lib/Event/TaskTerminalEvent.php | 91 ++- .../TaskSeparationOfDutiesException.php | 45 ++ lib/Listener/ApprovalChainAdvanceListener.php | 43 +- lib/Listener/ApprovalChainGateListener.php | 184 +++-- lib/Listener/TaskSequenceProgressListener.php | 89 +++ lib/Listener/UserTaskTerminalListener.php | 2 +- lib/Migration/Version1Date20260901180000.php | 206 +++++ lib/Repair/MigrateApprovalChainsToTasks.php | 749 ++++++++++++++++++ .../ApprovalChainAnnotationInstaller.php | 245 +++--- lib/Service/ApprovalService.php | 464 ----------- lib/Service/Flow/FlowRunService.php | 47 ++ lib/Service/Flow/Nodes/AwaitSignalNode.php | 61 +- lib/Service/Task/TaskBuilder.php | 6 + .../Task/TaskSequenceDecisionGuard.php | 143 ++++ lib/Service/Task/TaskSequenceService.php | 415 ++++++++++ lib/Service/Task/TaskService.php | 138 ++++ .../flow-approval-consolidation/tasks.md | 38 +- scripts/l10n/locales/cs.json | 3 +- .../workflow/ApprovalChainPanel.vue | 133 ---- src/components/workflow/ApprovalStepList.vue | 167 ---- src/components/workflow/TaskSequencePanel.vue | 160 ++++ src/views/schemas/SchemaWorkflowTab.vue | 6 +- .../ApprovalRetirementSurfaceTest.php | 135 ++++ .../Contract/HitlRetirementInventoryTest.php | 101 +++ .../Controller/ApprovalControllerTest.php | 108 --- .../Controller/FlowRunSignalByKeyTest.php | 149 ++++ tests/Unit/Db/ApprovalChainTest.php | 74 -- tests/Unit/Db/ApprovalStepTest.php | 88 -- tests/Unit/Db/TaskMapperTerminalityTest.php | 76 ++ .../Unit/Db/TaskSequenceMapperQueriesTest.php | 97 +++ tests/Unit/Event/ApprovalStepEventsTest.php | 96 --- .../ApprovalChainAdvanceListenerTest.php | 124 +-- .../ApprovalChainGateListenerTest.php | 205 ++--- .../TaskSequenceProgressListenerTest.php | 86 ++ .../MigrateApprovalChainsToTasksTest.php | 364 +++++++++ .../RollbackApprovalMigrationCommandTest.php | 154 ++++ tests/Unit/Service/ApprovalServiceTest.php | 397 ---------- .../Flow/AwaitSignalCorrelationTest.php | 105 +++ .../Task/TaskSequenceDecisionGuardTest.php | 144 ++++ .../Task/TaskSequenceLifecycleVerbsTest.php | 215 +++++ .../Service/Task/TaskSequenceServiceTest.php | 310 ++++++++ .../hitl-approval-rule-action.json | 244 ++++++ .../hitl-retirement-inventory.json | 94 +++ .../retired-approval-surface.json | 55 ++ ...ister-integrations.postman_collection.json | 464 +++++++---- 141 files changed, 7136 insertions(+), 3767 deletions(-) create mode 100644 docs/development/approval-events-migration.md create mode 100644 lib/Command/RollbackApprovalMigrationCommand.php delete mode 100644 lib/Controller/ApprovalController.php delete mode 100644 lib/Db/ApprovalChain.php delete mode 100644 lib/Db/ApprovalChainMapper.php delete mode 100644 lib/Db/ApprovalStep.php delete mode 100644 lib/Db/ApprovalStepMapper.php create mode 100644 lib/Db/TaskSequence.php create mode 100644 lib/Db/TaskSequenceMapper.php delete mode 100644 lib/Event/ApprovalStepApprovedEvent.php delete mode 100644 lib/Event/ApprovalStepCompletedEvent.php delete mode 100644 lib/Event/ApprovalStepInitiatedEvent.php delete mode 100644 lib/Event/ApprovalStepRejectedEvent.php create mode 100644 lib/Event/TaskSequenceCompletedEvent.php create mode 100644 lib/Exception/TaskSeparationOfDutiesException.php create mode 100644 lib/Listener/TaskSequenceProgressListener.php create mode 100644 lib/Migration/Version1Date20260901180000.php create mode 100644 lib/Repair/MigrateApprovalChainsToTasks.php delete mode 100644 lib/Service/ApprovalService.php create mode 100644 lib/Service/Task/TaskSequenceDecisionGuard.php create mode 100644 lib/Service/Task/TaskSequenceService.php delete mode 100644 src/components/workflow/ApprovalChainPanel.vue delete mode 100644 src/components/workflow/ApprovalStepList.vue create mode 100644 src/components/workflow/TaskSequencePanel.vue create mode 100644 tests/Unit/Contract/ApprovalRetirementSurfaceTest.php create mode 100644 tests/Unit/Contract/HitlRetirementInventoryTest.php delete mode 100644 tests/Unit/Controller/ApprovalControllerTest.php create mode 100644 tests/Unit/Controller/FlowRunSignalByKeyTest.php delete mode 100644 tests/Unit/Db/ApprovalChainTest.php delete mode 100644 tests/Unit/Db/ApprovalStepTest.php create mode 100644 tests/Unit/Db/TaskMapperTerminalityTest.php create mode 100644 tests/Unit/Db/TaskSequenceMapperQueriesTest.php delete mode 100644 tests/Unit/Event/ApprovalStepEventsTest.php create mode 100644 tests/Unit/Listener/TaskSequenceProgressListenerTest.php create mode 100644 tests/Unit/Repair/MigrateApprovalChainsToTasksTest.php create mode 100644 tests/Unit/Repair/RollbackApprovalMigrationCommandTest.php delete mode 100644 tests/Unit/Service/ApprovalServiceTest.php create mode 100644 tests/Unit/Service/Flow/AwaitSignalCorrelationTest.php create mode 100644 tests/Unit/Service/Task/TaskSequenceDecisionGuardTest.php create mode 100644 tests/Unit/Service/Task/TaskSequenceLifecycleVerbsTest.php create mode 100644 tests/Unit/Service/Task/TaskSequenceServiceTest.php create mode 100644 tests/fixtures/approval-consolidation/hitl-approval-rule-action.json create mode 100644 tests/fixtures/approval-consolidation/hitl-retirement-inventory.json create mode 100644 tests/fixtures/approval-consolidation/retired-approval-surface.json diff --git a/appinfo/info.xml b/appinfo/info.xml index f433ca9d18..b953e182d2 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -250,6 +250,24 @@ Vrij en open source onder de EUPL-licentie. Post-migration because the step needs the openregister_tasks tables its own change's migration creates. --> OCA\OpenRegister\Repair\SeedTaskFixtures + + OCA\OpenRegister\Repair\SeedFlowTimerRegister + OCA\OpenRegister\Repair\CheckFlowTimerInvariants + + OCA\OpenRegister\Repair\MigrateApprovalChainsToTasks OCA\OpenRegister\Repair\SeedCaseFixtures OCA\OpenRegister\Command\MigrateSchemaApplicationCommand + + OCA\OpenRegister\Command\RollbackApprovalMigrationCommand OCA\OpenRegister\Command\RechainAuditTrailCommand + + + + + diff --git a/tests/Unit/Controller/TaskControllerTest.php b/tests/Unit/Controller/TaskControllerTest.php index aa4c84535a..bc438d49ba 100644 --- a/tests/Unit/Controller/TaskControllerTest.php +++ b/tests/Unit/Controller/TaskControllerTest.php @@ -43,6 +43,7 @@ use OCA\OpenRegister\Service\Task\TaskTemporalProjection; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\TemplateResponse; use OCP\IGroupManager; use OCP\IRequest; use OCP\IUser; @@ -650,4 +651,28 @@ public function testIndexWithoutASessionIs401(): void { $this->assertSame(Http::STATUS_UNAUTHORIZED, $controller->index()->getStatus()); }//end testIndexWithoutASessionIs401() + + /** + * GET /flow-tasks/{uuid} serves the SPA shell, never a hash redirect. + * + * The router runs in history mode, so a redirect to `/#/flow-tasks/…` + * rendered the dashboard and silently swallowed every notification + * button and VTODO URL. The shell answer is what lets the manifest's + * `flow-task-detail` page resolve the path; the visibility-checked + * read stays with show(), so the shell itself may be identical for + * every uuid. + * + * @return void + */ + public function testOpenServesTheSpaShell(): void { + $response = $this->controller->open('t-1'); + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('index', $response->getTemplateName()); + // The SPA calls the API from this page, so the shell must carry the + // same connect-domain relaxation the dashboard shell does. + $policy = $response->getContentSecurityPolicy(); + $this->assertNotNull($policy); + $this->assertStringContainsString('connect-src', $policy->buildPolicy()); + }//end testOpenServesTheSpaShell() }//end class diff --git a/tests/e2e/_page-routes.ts b/tests/e2e/_page-routes.ts index 36cd92716e..b0dd475290 100644 --- a/tests/e2e/_page-routes.ts +++ b/tests/e2e/_page-routes.ts @@ -144,3 +144,19 @@ export function IntegrationsView( ): string { return `/integrations/${register}/${schema}/${objectId}` } + +/** + * `src/views/task/FlowTaskDetail.vue` — one task at the fleet's one stable + * task address. Also the deep link every VTODO URL and notification button + * carries, so the FULL-PAGE load of this route is itself a scenario: + * `tests/e2e/task-inbox-page.spec.ts` reloads it cold and asserts the task + * renders (not the dashboard, which is where the old hash redirect landed). + */ +export const FlowTaskDetail = (uuid: string): string => `/flow-tasks/${uuid}` + +/** + * The task inbox (manifest page `flow-task-inbox`, `type: "index"` over the + * named `tasks` entity source — no app component of its own, so the binding + * here names the DETAIL component's sibling route rather than a view file). + */ +export const FlowTaskInbox = '/flow-tasks' diff --git a/tests/e2e/task-inbox-page.spec.ts b/tests/e2e/task-inbox-page.spec.ts new file mode 100644 index 0000000000..0bb9c1b9e8 --- /dev/null +++ b/tests/e2e/task-inbox-page.spec.ts @@ -0,0 +1,174 @@ +/* + * SPDX-FileCopyrightText: 2026 Open Register Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * Task inbox UI e2e — the inbox page, the deep link, and the widget + * (flow-task-inbox-projections 5.1). + * + * 1. The inbox page at /flow-tasks renders rows served by the flow-tasks + * API: a task seeded through the API appears in the list. + * 2. The per-uuid deep link resolves on a COLD full-page load. The reload + * IS the test: TaskController::open() used to redirect to a hash path + * the history-mode router never resolved, so every notification button + * and VTODO URL rendered the dashboard. The assertion is on the task's + * own title, plus the absence of the dashboard heading. + * 3. The dashboard carries the tasks widget (CnTasksWidget, the `tasks` + * dashboard widget type from nextcloud-vue 2.30.0). + * + * ⚠️ Needs a build carrying nextcloud-vue >= 2.30.0 (the `tasks` entity + * source and CnTasksWidget shipped with nextcloud-vue#910). Against an + * older release build the inbox renders an empty index and the widget + * cell reads unavailable — a red run there is the dependency, not a + * regression. + * + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-the-inbox-surfaces-read-the-inbox-and-count-from-its-total + * @spec openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md#requirement-an-assigned-task-appears-in-the-assignees-own-calendar + */ +import type { APIRequestContext, Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' +import { FlowTaskDetail, FlowTaskInbox } from './_page-routes.ts' + +const APP_BASE = '/index.php/apps/openregister' +const API_BASE = `${APP_BASE}/api/flow-tasks` +const STORAGE_STATE = path.resolve(__dirname, '.auth/admin.json') +const RUN_ID = `e2e-inbox-ui-${Date.now().toString(36)}` + +// Same reasoning as task-inbox.spec.ts: seed and clean over the REST API +// with Basic auth so no CSRF token is demanded. The BROWSER tests use the +// logged-in storage state instead — the page under test is a real SPA page. +const ADMIN_HEADERS = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from( + `${process.env.OR_USER || 'admin'}:${process.env.OR_PASS || 'admin'}`, + ).toString('base64')}`, +} + +/** + * Seed one task for the admin through the API and hand back its row. + * + * @param request The Playwright API context. + * @param title The task title to seed. + */ +async function seedTask(request: APIRequestContext, title: string) { + const response = await request.post(API_BASE, { + headers: ADMIN_HEADERS, + data: { + title, + state: 'active', + performerType: 'user', + assignee: process.env.OR_USER || 'admin', + requester: process.env.OR_USER || 'admin', + priority: 'normal', + }, + }) + expect(response.status(), await response.text()).toBe(201) + return response.json() +} + +/** + * Cancel a seeded task; never fails the suite — cleanup is not a verdict. + * + * @param request The Playwright API context. + * @param uuid The task to cancel. + */ +async function cancelQuietly(request: APIRequestContext, uuid: string) { + try { + await request.post(`${API_BASE}/${uuid}/cancel`, { + headers: ADMIN_HEADERS, + data: { reason: `${RUN_ID} cleanup` }, + }) + } catch (error) { + console.warn('[task-inbox-page] cleanup failed:', error) + } +} + +/** + * Skip when the logged-in storage state is absent (the full suite's + * global-setup writes it); a UI spec without a session can only test the + * login page. + * + * @param page The page fixture (unused; present for signature clarity). + */ +function requireSession(page: Page) { + void page + if (!fs.existsSync(STORAGE_STATE)) { + test.skip(true, 'storageState not present — run the full suite first') + } +} + +test.describe('flow-task inbox UI — page, deep link, widget', () => { + test.use({ storageState: STORAGE_STATE }) + + test('the inbox page renders rows from the flow-tasks API', async ({ + page, + request, + }) => { + requireSession(page) + const title = `${RUN_ID} inbox row` + const task = await seedTask(request, title) + + try { + await page.goto(`${APP_BASE}${FlowTaskInbox}`, { + waitUntil: 'domcontentloaded', + }) + // The seeded task's title is a rendered row, not just a payload. + await expect(page.getByText(title).first()).toBeVisible({ + timeout: 25_000, + }) + } finally { + await cancelQuietly(request, String(task.uuid)) + } + }) + + test('the per-uuid deep link survives a cold reload and lands on the task', async ({ + page, + request, + }) => { + requireSession(page) + const title = `${RUN_ID} deep link` + const task = await seedTask(request, title) + + try { + // The FULL-PAGE load is the scenario: this is the URL a VTODO and + // a notification button carry, opened with no SPA state at all. + await page.goto(`${APP_BASE}${FlowTaskDetail(String(task.uuid))}`, { + waitUntil: 'domcontentloaded', + }) + + // The task's own surface, not the dashboard the old hash + // redirect fell back to. + await expect(page.getByTestId('task-title')).toHaveText( + new RegExp(title), + { timeout: 25_000 }, + ) + expect(page.url()).toContain(`/flow-tasks/${task.uuid}`) + } finally { + await cancelQuietly(request, String(task.uuid)) + } + }) + + test('the dashboard renders the tasks widget', async ({ page, request }) => { + requireSession(page) + const title = `${RUN_ID} widget row` + const task = await seedTask(request, title) + + try { + await page.goto(`${APP_BASE}/`, { waitUntil: 'domcontentloaded' }) + // CnTasksWidget's own root class: scoped, so the menu's Tasks + // entry can never satisfy this assertion by accident. + await expect(page.locator('.cn-tasks-widget')).toBeVisible({ + timeout: 25_000, + }) + // The seeded assigned task is one of the widget's rows. + await expect( + page.locator('.cn-tasks-widget').getByText(title).first(), + ).toBeVisible({ timeout: 15_000 }) + } finally { + await cancelQuietly(request, String(task.uuid)) + } + }) +}) From eeedb7b08203de153cf5d8b15b9f8ba50863c97c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 05:52:32 +0200 Subject: [PATCH 45/68] fix(flow): a resume answers only the node whose slot it answers, and the kill switch actually stops runs (#3325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(flow): scope the resume payload to the node whose slot it answers A resume signal wakes the RUN but answers one NODE. The payload landed in the shared context key 'signal', which every wait node re-entered in the same walk could read — so after any wait-state resolution, every LATER wait node consumed the same payload as its own answer and completed in 0ms instead of suspending, and the run raced to its end. Observed live on 2.0.15-unstable (runs f8996ccc and ca50c56c): a decision node's outcome held an applicant task's completion payload, and a second decision node inherited the first decision's decisionRef because it never suspended. The dispatcher now withholds the signal from every node except the one whose held FlowNodeResumeState slot marks it as the node that suspended — the same enforcement point that already scopes the resume slots. The answered node reads its payload; every other wait node the resumed walk enters suspends fresh, creating its own task or decision. WaitNode rode the same seam through the run-wide 'resuming' flag: a second wait in a resumed walk read the flag as 'my wait is over' and passed through in zero seconds. It now writes its own slot when it suspends and passes through only when that slot is held. Regressions: FlowEngineResumeScopeTest (engine-level, two sequential waits + the decision-correlation variant), FlowRunServiceTest:: testAnsweringOneWaitDoesNotAnswerTheNext (the worker path) and testAFreshRunsFirstAskSeesNoSignal, RegistryStepDispatcherResumeTest signal-scoping quartet, WaitNodeTest second-wait pair. * fix(flow): the oversight registry collects its checks, so the kill switch actually stops runs Listeners for RegisterFlowOversightEvent were registered on every boot, but nothing ever dispatched the event: FlowNodeRegistry dispatches its own registration event lazily, and the oversight registry had no equivalent. The gate consulted before every hop therefore iterated an empty check list and consented forever — flow_kill_switch=1 was decorative. Observed on the 2026-09-01 acceptance run: kill switch + resume(200) + a worker pass left the run suspended instead of stopped, so its tasks were never terminated (the terminal write is what fires FlowRunTerminalEvent -> terminateForRun). The registry now dispatches RegisterFlowOversightEvent once, lazily, before answering firstRefusal() or all(), guarded against re-entry the same way FlowNodeRegistry::load() is. The dispatcher parameter is nullable and defaulted last, so hand-built registries (tests) keep meaning what they meant. Regressions: FlowOversightRegistryTest:: testTheRegistryCollectsContributedChecksBeforeAnswering and testAllCollectsContributionsAsWell, plus FlowRunServiceTest:: testAKillSwitchVetoLandsTheStopOnTheNextObservation — a suspended run, switch thrown, nudged, next observation ends it 'stopped' with the vetoing checkId in its log. The previously red e2e (flow-user-task.spec.ts 'stopping a run removes its tasks') rides this same path. * test(e2e): a resume answers only the node that asked Two sequential await-signal gates through the resume endpoint and the worker: the first answer must leave the run suspended on the second gate's own fresh question, and each gate's item key must hold ITS answer — the exact race the 2026-09-01 acceptance run caught, where the second gate consumed the first answer out of the shared signal key and the run completed on one approval. --------- Co-authored-by: Conduction Release Bot --- lib/Service/Flow/FlowOversightRegistry.php | 56 ++++ lib/Service/Flow/Nodes/WaitNode.php | 31 +- lib/Service/Flow/RegistryStepDispatcher.php | 57 ++++ .../Flow/FlowEngineResumeScopeTest.php | 301 ++++++++++++++++++ .../Flow/FlowOversightRegistryTest.php | 54 ++++ .../Unit/Service/Flow/FlowRunServiceTest.php | 252 +++++++++++++++ .../Flow/RegistryStepDispatcherResumeTest.php | 117 +++++++ tests/Unit/Service/Flow/WaitNodeTest.php | 67 ++++ tests/e2e/api-direct/flow-user-task.spec.ts | 79 +++++ 9 files changed, 1010 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Service/Flow/FlowEngineResumeScopeTest.php diff --git a/lib/Service/Flow/FlowOversightRegistry.php b/lib/Service/Flow/FlowOversightRegistry.php index 64f86bc899..e4056d4871 100644 --- a/lib/Service/Flow/FlowOversightRegistry.php +++ b/lib/Service/Flow/FlowOversightRegistry.php @@ -28,6 +28,7 @@ namespace OCA\OpenRegister\Service\Flow; +use OCP\EventDispatcher\IEventDispatcher; use Psr\Log\LoggerInterface; use Throwable; @@ -45,17 +46,68 @@ class FlowOversightRegistry { */ private array $checks = []; + /** + * Whether contributions have been collected. + * + * @var boolean + */ + private bool $discovered = false; + /** * Constructor. * * @param LoggerInterface $logger Records a check that misbehaves. + * @param IEventDispatcher|null $dispatcher Collects contributed checks by + * dispatching + * {@see RegisterFlowOversightEvent}, + * the same way FlowNodeRegistry + * collects node types. Nullable so + * the registry stays constructible + * without a container — a test that + * registers its checks by hand needs + * no discovery — and defaulted LAST + * so existing positional + * constructions keep meaning what + * they meant. */ public function __construct( private readonly LoggerInterface $logger, + private readonly ?IEventDispatcher $dispatcher = null, ) { }//end __construct() + /** + * Collect contributed checks once, lazily. + * + * THE GAP THIS CLOSES: the listeners for {@see RegisterFlowOversightEvent} + * were registered on every boot, but nothing ever DISPATCHED the event — + * `FlowNodeRegistry` dispatches its own registration event before first + * use, and this registry had no equivalent. The result was an oversight + * gate that was consulted on every hop and could never hold a check: + * `firstRefusal()` iterated an empty list and consented, so the instance + * kill switch (and every app-contributed check) was decorative. Observed + * live 2026-09-01: `flow_kill_switch=1` plus a resume left a suspended run + * suspended instead of stopping it, because no veto ever fired. + * + * Set BEFORE dispatching, exactly as FlowNodeRegistry::load() does: a + * listener that resolves a service which itself consults this registry + * would otherwise re-enter and dispatch again. + * + * @return void + * + * @spec openspec/changes/flow-engine-unification/specs/flow-oversight/spec.md + */ + private function discover(): void { + if ($this->discovered === true || $this->dispatcher === null) { + return; + } + + $this->discovered = true; + $this->dispatcher->dispatchTyped(new RegisterFlowOversightEvent(registry: $this)); + + }//end discover() + /** * Register an oversight check. * @@ -82,6 +134,8 @@ public function register(IFlowOversightCheck $check): void { * @spec openspec/changes/flow-engine-unification/specs/flow-oversight/spec.md */ public function all(): array { + $this->discover(); + return $this->checks; }//end all() @@ -100,6 +154,8 @@ public function all(): array { * @spec openspec/changes/flow-engine-unification/specs/flow-oversight/spec.md */ public function firstRefusal(array $context): ?array { + $this->discover(); + foreach ($this->checks as $id => $check) { try { $reason = $check->veto(context: $context); diff --git a/lib/Service/Flow/Nodes/WaitNode.php b/lib/Service/Flow/Nodes/WaitNode.php index 6eb37ad053..e393354160 100644 --- a/lib/Service/Flow/Nodes/WaitNode.php +++ b/lib/Service/Flow/Nodes/WaitNode.php @@ -9,7 +9,11 @@ * * Because the marking does not advance, this node runs a SECOND time when the * run resumes. That is what makes it correct rather than a one-shot: on the - * way back in it sees `context.resuming` and lets the items straight through. + * way back in it sees its OWN resume slot held and lets the items straight + * through. Its own slot, not `context.resuming` — the run-wide flag is true + * for every node of a resumed walk, so a SECOND wait node reached later in + * the same walk would read it as "my wait is over" and pass through without + * ever having waited. The slot marks exactly the node that suspended. * * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -31,6 +35,7 @@ namespace OCA\OpenRegister\Service\Flow\Nodes; use DateTime; +use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\IFlowNode; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; @@ -175,9 +180,19 @@ public function execute(array $items, array $config, array $context): array { return $items; } - if (($context['resuming'] ?? false) === true) { - // Woken by the worker: the wait is over by construction, because - // the run was only eligible once `resumeAt` had passed. + $resume = ($context[FlowNodeResumeState::CONTEXT_KEY] ?? null); + if ($resume instanceof FlowNodeResumeState === true) { + if ($resume->isResuming() === true) { + // THIS node's wait is over: it wrote its slot when it + // suspended, and the run was only eligible again once its + // `resumeAt` had passed. The dispatcher clears the slot on + // return, so a loop back into this node waits again. + return $items; + } + } elseif (($context['resuming'] ?? false) === true) { + // No slot machinery at all — a context built outside a real run + // (the flow tester, a node unit test). There is only one wait in + // such a walk, so the run-wide flag is unambiguous there. return $items; } @@ -189,6 +204,14 @@ public function execute(array $items, array $config, array $context): array { return $items; } + // The slot is the addressee mark the re-entry above reads. Without it, + // a resumed walk cannot tell the wait that is OVER from a wait it has + // only just reached — `context.resuming` is true for both, and reading + // that flag made a second wait node pass through in zero seconds. + if ($resume instanceof FlowNodeResumeState === true) { + $resume->set(key: 'waitingUntil', value: $resumeAt->format('c')); + } + throw new FlowSuspension( resumeAt: $resumeAt, reason: sprintf('waiting until %s', $resumeAt->format('c')) diff --git a/lib/Service/Flow/RegistryStepDispatcher.php b/lib/Service/Flow/RegistryStepDispatcher.php index 3dc05b299a..f1ec654184 100644 --- a/lib/Service/Flow/RegistryStepDispatcher.php +++ b/lib/Service/Flow/RegistryStepDispatcher.php @@ -110,6 +110,12 @@ public function dispatch(array $step, array $items, array $context): array { // and cannot reach another node's slot even by accident. $scoped = $this->scopeResumeState(step: $step, context: $context); + // Scope the resume SIGNAL the same way. Without this, the payload that + // answered ONE node's question is readable by every wait node the + // resumed walk goes on to enter, and each of them completes on somebody + // else's answer instead of suspending with a question of its own. + $this->scopeSignal(context: $context, scoped: $scoped); + $startedAt = microtime(true); $out = $node->execute(items: $items, config: $config, context: $context); $tookMs = (int)round((microtime(true) - $startedAt) * 1000); @@ -159,6 +165,57 @@ private function scopeResumeState(array $step, array &$context): ?FlowNodeResume return $scoped; }//end scopeResumeState() + /** + * Withhold the resume signal from every node except the one it answers. + * + * A signal wakes a RUN, but it answers one NODE: the one that suspended and + * whose resume slot is still held. The walk's context is shared, so without + * this gate every wait node the resumed walk re-enters AFTER the answered + * one reads the same payload as its own answer and completes instead of + * suspending — a flow with two approval steps auto-approves the second the + * moment the first is granted, and a decision step downstream of an answered + * task adopts the task's payload as its decision outcome. Observed live on + * dossiq case flows (runs f8996ccc and ca50c56c, 2026-09-01): a DECISION + * node's outcome held `{decision, node: "ask-indiener", taskId}` — an + * applicant task's completion — and a second decision node inherited the + * first decision's reference because it never suspended at all. + * + * The slot is the addressee test, not `$context['resuming']`: the run-wide + * flag is true for every node of a resumed walk, while a held slot marks + * exactly the node that suspended and has not yet been given its answer. + * The dispatcher clears the slot when a node returns, so a wait node + * re-entered later in the SAME walk (a loop) asks fresh rather than + * re-reading a consumed answer. With no slot machinery at all (a + * container-built dispatcher walking a tester context) the signal is + * withheld too: with no slots there is no addressee, and withholding makes + * the node suspend visibly where delivering would answer the wrong + * question silently. + * + * The strip is LOCAL to this node's context copy — `dispatch()` receives + * `$context` by value — so the walk keeps carrying the signal to the node + * whose slot it answers, wherever in the round-robin that node is visited. + * + * @param array $context The node context, modified in place. + * @param FlowNodeResumeState|null $scoped This node's resume slot, when it has one. + * + * @return void + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ + private function scopeSignal(array &$context, ?FlowNodeResumeState $scoped): void { + if (array_key_exists(FlowRunService::SIGNAL_CONTEXT_KEY, $context) === false) { + return; + } + + if ($scoped !== null && $scoped->isResuming() === true) { + // This node is the one that suspended: the answer is its to read. + return; + } + + unset($context[FlowRunService::SIGNAL_CONTEXT_KEY]); + + }//end scopeSignal() + /** * Stop a step that took longer than its own `maxRuntimeSeconds`. * diff --git a/tests/Unit/Service/Flow/FlowEngineResumeScopeTest.php b/tests/Unit/Service/Flow/FlowEngineResumeScopeTest.php new file mode 100644 index 0000000000..9789c24cb5 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowEngineResumeScopeTest.php @@ -0,0 +1,301 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + */ + +declare(strict_types=1); + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Service\Flow\FlowDefinitionBuilder; +use OCA\OpenRegister\Service\Flow\FlowEngine; +use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; +use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; +use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\FlowSuspension; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\RegistryStepDispatcher; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; + +/** + * An await-signal-shaped node: consumes the signal when one is visible, + * otherwise records its ask in its own slot and suspends. + * + * Mirrors what `AwaitSignalNode`, dossiq's askPerson and dossiq's + * requestDecision all do at this seam, including the per-ask reference a + * decision node mints (the correlation the second node must NOT inherit). + */ +class ScopedAskStub implements IFlowNode { + + /** + * The signal each node id saw on each entry, in order. + * + * @var array> + */ + public array $sawSignal = []; + + /** + * How many asks have been minted, so each ref is distinct. + * + * @var int + */ + private int $asks = 0; + + public function getId(): string { + return 'test.scoped-ask'; + } + + public function getDisplayName(): string { + return 'Ask'; + } + + public function getDescription(): string { + return 'Waits for an answer.'; + } + + public function getIcon(): string { + return 'i.svg'; + } + + public function isAvailableForScope(int $scope): bool { + return true; + } + + public function validateConfig(array $config): void { + } + + public function execute(array $items, array $config, array $context): array { + $slot = $context[FlowNodeResumeState::CONTEXT_KEY]; + $nodeId = $slot->nodeId(); + + $signal = ($context[FlowRunService::SIGNAL_CONTEXT_KEY] ?? null); + $this->sawSignal[$nodeId][] = $signal; + + if (is_array($signal) === true && trim((string)($signal['decision'] ?? '')) !== '') { + foreach ($items as $index => $item) { + $item['json'][(string)($config['signalKey'] ?? 'signal')] = $signal; + $items[$index] = $item; + } + + return $items; + } + + if ($slot->has(key: 'ref') === false) { + $this->asks++; + $slot->merge( + values: [ + 'askedAt' => '2026-09-01T00:00:00+00:00', + 'ref' => sprintf('ask-%d', $this->asks), + ] + ); + } + + throw new FlowSuspension(resumeAt: null, reason: 'waiting for an answer'); + } +}//end class + +/** + * A subject whose marking is a plain property, as the engine tests use. + */ +class ResumeScopeSubject { + + public $marking = []; +}//end class + +class FlowEngineResumeScopeTest extends TestCase { + + /** + * The stub both wait steps resolve to. + * + * @var ScopedAskStub + */ + private ScopedAskStub $ask; + + /** + * The engine under test. + * + * @var FlowEngine + */ + private FlowEngine $engine; + + /** + * The dispatcher — the REAL one, because the scoping under test lives in it. + * + * @var RegistryStepDispatcher + */ + private RegistryStepDispatcher $dispatcher; + + protected function setUp(): void { + $this->ask = new ScopedAskStub(); + + $registry = $this->createMock(FlowNodeRegistry::class); + $registry->method('get')->willReturn($this->ask); + + $this->engine = new FlowEngine( + new FlowDefinitionBuilder(), + $this->createMock(LoggerInterface::class) + ); + $this->dispatcher = new RegistryStepDispatcher(registry: $registry); + }//end setUp() + + /** + * Two sequential wait nodes, one edge between them. + */ + private function twoAsksFlow(): array { + return [ + 'id' => 'f-scope', + 'nodes' => [ + ['id' => 'first-ask', 'type' => 'test.scoped-ask', 'config' => ['signalKey' => 'firstAnswer']], + ['id' => 'second-ask', 'type' => 'test.scoped-ask', 'config' => ['signalKey' => 'secondAnswer']], + ], + 'edges' => [ + ['id' => 'e1', 'from' => 'first-ask', 'to' => 'second-ask'], + ], + ]; + }//end twoAsksFlow() + + /** + * Answering the first wait leaves the second SUSPENDED, asking fresh. + * + * The walk that consumed the first answer must not hand the same payload + * to the second wait: the second suspends with an ask of its own, and the + * run does not race to the end on one answer. + */ + public function testAnsweringTheFirstWaitLeavesTheSecondSuspendedWithItsOwnAsk(): void { + $flow = $this->twoAsksFlow(); + $subject = new ResumeScopeSubject(); + $store = new MethodMarkingStore(false, 'marking'); + $state = new FlowResumeState(); + + // Walk 1: the first ask suspends the run. + $first = $this->engine->run( + $flow, + $store, + $subject, + $this->dispatcher, + [FlowResumeState::CONTEXT_KEY => $state] + ); + $this->assertSame(FlowEngine::STATUS_SUSPENDED, $first['status']); + $this->assertTrue($state->forNode(nodeId: 'first-ask')->has(key: 'askedAt')); + + // Walk 2: the answer arrives, exactly as FlowRunService seeds it — the + // stored slots and the signal, on a context whose run-wide `resuming` + // flag is true for EVERY node. + $payload = ['decision' => 'approved', 'node' => 'first-ask', 'taskId' => 'task-1']; + $second = $this->engine->run( + $flow, + $store, + $subject, + $this->dispatcher, + [ + 'resuming' => true, + FlowResumeState::CONTEXT_KEY => FlowResumeState::fromArray($state->all()), + FlowRunService::SIGNAL_CONTEXT_KEY => $payload, + ] + ); + + $this->assertSame( + FlowEngine::STATUS_SUSPENDED, + $second['status'], + 'one answer must advance the run to the NEXT question, not to the end' + ); + + $statuses = []; + foreach ($second['log'] as $entry) { + $statuses[$entry['transition']] = $entry['status']; + } + + $this->assertSame('completed', $statuses['first-ask'], 'the answered node completes'); + $this->assertSame('suspended', $statuses['second-ask'], 'the next wait suspends fresh'); + + $this->assertSame( + [null, $payload], + $this->ask->sawSignal['first-ask'], + 'the answered node reads the payload on its resume, and only then' + ); + $this->assertSame( + [null], + $this->ask->sawSignal['second-ask'], + 'the second node never sees the first answer' + ); + + $fresh = ($second['context'][FlowResumeState::CONTEXT_KEY] ?? null); + $this->assertInstanceOf(FlowResumeState::class, $fresh); + $this->assertTrue( + $fresh->forNode(nodeId: 'second-ask')->has(key: 'askedAt'), + 'the second node recorded an ask of its own' + ); + $this->assertSame( + [], + $fresh->forNode(nodeId: 'first-ask')->all(), + 'the answered node keeps no slot' + ); + }//end testAnsweringTheFirstWaitLeavesTheSecondSuspendedWithItsOwnAsk() + + /** + * The decision-node variant: the second ask mints its OWN reference. + * + * Run ca50c56c's defect in miniature — the second decision node carried + * the FIRST decision's `decisionRef`, because it consumed the first's + * outcome instead of suspending and creating a decision of its own. + */ + public function testTheSecondAskNeverInheritsTheFirstsCorrelation(): void { + $flow = $this->twoAsksFlow(); + $subject = new ResumeScopeSubject(); + $store = new MethodMarkingStore(false, 'marking'); + $state = new FlowResumeState(); + + $this->engine->run($flow, $store, $subject, $this->dispatcher, [FlowResumeState::CONTEXT_KEY => $state]); + $firstRef = $state->forNode(nodeId: 'first-ask')->get(key: 'ref'); + $this->assertNotNull($firstRef); + + $second = $this->engine->run( + $flow, + $store, + $subject, + $this->dispatcher, + [ + 'resuming' => true, + FlowResumeState::CONTEXT_KEY => FlowResumeState::fromArray($state->all()), + FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'completed', 'decisionRef' => $firstRef], + ] + ); + + $fresh = $second['context'][FlowResumeState::CONTEXT_KEY]; + $secondRef = $fresh->forNode(nodeId: 'second-ask')->get(key: 'ref'); + + $this->assertNotNull($secondRef, 'the second ask creates its own reference'); + $this->assertNotSame($firstRef, $secondRef, 'the second ask must not ride the first decision'); + + // And the second node's slot holds nothing of the first's payload. + $this->assertNull($fresh->forNode(nodeId: 'second-ask')->get(key: 'decisionRef')); + }//end testTheSecondAskNeverInheritsTheFirstsCorrelation() +}//end class diff --git a/tests/Unit/Service/Flow/FlowOversightRegistryTest.php b/tests/Unit/Service/Flow/FlowOversightRegistryTest.php index 673e87b9f4..1dc54332ca 100644 --- a/tests/Unit/Service/Flow/FlowOversightRegistryTest.php +++ b/tests/Unit/Service/Flow/FlowOversightRegistryTest.php @@ -11,6 +11,9 @@ use OCA\OpenRegister\Service\Flow\FlowOversightRegistry; use OCA\OpenRegister\Service\Flow\IFlowOversightCheck; +use OCA\OpenRegister\Service\Flow\RegisterFlowOversightEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; use PHPUnit\Framework\TestCase; /** @@ -120,4 +123,55 @@ public function testRegisteringTheSameIdReplacesTheEarlierCheck(): void { $this->assertCount(1, $registry->all()); $this->assertSame('second', $registry->firstRefusal([])['reason']); }//end testRegisteringTheSameIdReplacesTheEarlierCheck() + + /** + * THE GAP THE 2026-09-01 ACCEPTANCE RUN FOUND. Listeners for + * RegisterFlowOversightEvent were registered on every boot, but nothing + * ever dispatched the event — so the registry consulted on every hop was + * permanently empty and the instance kill switch was decorative. The + * registry must collect its contributions itself, the way + * FlowNodeRegistry does, before it answers its first question. + */ + public function testTheRegistryCollectsContributedChecksBeforeAnswering(): void { + $contributed = $this->check('app.gate', 'closed'); + + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->expects($this->once())->method('dispatchTyped')->willReturnCallback( + static function (Event $event) use ($contributed): void { + if ($event instanceof RegisterFlowOversightEvent) { + $event->registerCheck(check: $contributed); + } + } + ); + + $registry = new FlowOversightRegistry(new \Psr\Log\NullLogger(), $dispatcher); + + $refusal = $registry->firstRefusal([]); + $this->assertNotNull($refusal, 'a contributed check must actually be consulted'); + $this->assertSame('app.gate', $refusal['checkId']); + + // Once. A second question must not re-dispatch and re-register. + $this->assertSame('app.gate', $registry->firstRefusal([])['checkId']); + }//end testTheRegistryCollectsContributedChecksBeforeAnswering() + + /** + * `all()` discovers too: a surface listing the active checks must not + * read empty while the checks are one dispatch away. + */ + public function testAllCollectsContributionsAsWell(): void { + $contributed = $this->check('app.gate', null); + + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (Event $event) use ($contributed): void { + if ($event instanceof RegisterFlowOversightEvent) { + $event->registerCheck(check: $contributed); + } + } + ); + + $registry = new FlowOversightRegistry(new \Psr\Log\NullLogger(), $dispatcher); + + $this->assertArrayHasKey('app.gate', $registry->all()); + }//end testAllCollectsContributionsAsWell() }//end class diff --git a/tests/Unit/Service/Flow/FlowRunServiceTest.php b/tests/Unit/Service/Flow/FlowRunServiceTest.php index 48bdc95557..ac278104df 100644 --- a/tests/Unit/Service/Flow/FlowRunServiceTest.php +++ b/tests/Unit/Service/Flow/FlowRunServiceTest.php @@ -16,13 +16,18 @@ use OCA\OpenRegister\Service\Flow\FlowEngine; use OCA\OpenRegister\Service\Flow\FlowItems; use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; +use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; +use OCA\OpenRegister\Service\Flow\FlowOversightRegistry; +use OCA\OpenRegister\Service\Flow\FlowResumeState; use OCA\OpenRegister\Service\Flow\FlowRunMarkingStore; use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\Oversight\KillSwitchCheck; use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -126,6 +131,62 @@ public function execute(array $items, array $config, array $context): array { } } +/** + * An await-signal-shaped node: consumes a visible signal as its answer, + * otherwise records the ask in its OWN resume slot and suspends — the shape + * AwaitSignalNode and dossiq's askPerson/requestDecision nodes share. + */ +class AskingNode implements IFlowNode { + /** + * The signal each node id saw on each entry, in order. + */ + public array $sawSignal = []; + + public function getId(): string { + return 'test.ask'; + } + + public function getDisplayName(): string { + return 'Ask'; + } + + public function getDescription(): string { + return 'Waits for an answer.'; + } + + public function getIcon(): string { + return 'i.svg'; + } + + public function isAvailableForScope(int $scope): bool { + return true; + } + + public function validateConfig(array $config): void { + } + + public function execute(array $items, array $config, array $context): array { + $slot = $context[FlowNodeResumeState::CONTEXT_KEY]; + $signal = ($context[FlowRunService::SIGNAL_CONTEXT_KEY] ?? null); + $this->sawSignal[$slot->nodeId()][] = $signal; + + if (is_array($signal) === true && trim((string)($signal['decision'] ?? '')) !== '') { + foreach ($items as $index => $item) { + $item['json'][$slot->nodeId()] = $signal; + $items[$index] = $item; + } + + return $items; + } + + if ($slot->has(key: 'askedAt') === false) { + $slot->set(key: 'askedAt', value: '2026-09-01T00:00:00+00:00'); + } + + throw new FlowSuspension(resumeAt: null, reason: 'waiting for an answer'); + } +} + /** Records the context it was handed, so attribution can be asserted. */ class ContextCapturingNode implements IFlowNode { public array $seenContext = []; @@ -173,6 +234,8 @@ class FlowRunServiceTest extends TestCase { private BranchRecordingNode $retryBranch; + private AskingNode $asker; + protected function setUp(): void { $this->mapper = $this->createMock(FlowRunMapper::class); // insert/update echo the entity back, so assertions read the real state. @@ -183,6 +246,7 @@ protected function setUp(): void { $this->capturer = new ContextCapturingNode(); $this->doneBranch = new BranchRecordingNode(id: 'test.done'); $this->retryBranch = new BranchRecordingNode(id: 'test.retry'); + $this->asker = new AskingNode(); $dispatcher = $this->createMock(IEventDispatcher::class); $dispatcher->method('dispatchTyped')->willReturnCallback( @@ -192,6 +256,7 @@ function (Event $event): void { $event->registerNode($this->capturer); $event->registerNode($this->doneBranch); $event->registerNode($this->retryBranch); + $event->registerNode($this->asker); } } ); @@ -410,6 +475,193 @@ public function testAReAskLoopThatReceivesTheAnswerTerminates(): void { $this->assertSame(1, $this->doneBranch->calls); }//end testAReAskLoopThatReceivesTheAnswerTerminates() + /** + * THE RESUME ANSWER IS SCOPED TO THE NODE THAT SUSPENDED — through the + * worker path. `signal()` then `execute()` is exactly what the resume + * endpoint plus the next FlowRunWorker pass do, so this is the defect the + * 2026-09-01 acceptance run caught (runs f8996ccc / ca50c56c) driven + * through the same seam: answering the FIRST wait must leave the SECOND + * suspended on a fresh ask of its own, not complete it in zero + * milliseconds on somebody else's answer. + * + * @return void + */ + public function testAnsweringOneWaitDoesNotAnswerTheNext(): void { + $flow = [ + 'id' => 'f1', + 'nodes' => [ + ['id' => 'first', 'type' => 'test.ask'], + ['id' => 'second', 'type' => 'test.ask'], + ], + 'edges' => [ + ['id' => 'e1', 'from' => 'first', 'to' => 'second'], + ], + ]; + + $run = $this->service->queue('f1', user: 'alice'); + $run = $this->service->execute($run, $flow, new RunSubject()); + $this->assertSame(FlowRun::STATUS_SUSPENDED, $run->getStatus()); + $this->assertArrayHasKey( + 'first', + ($run->getContext()[FlowResumeState::CONTEXT_KEY] ?? []), + 'the first ask holds the run' + ); + + // The answer to the FIRST question arrives; the worker picks the run up. + $answer = ['decision' => 'approved', 'node' => 'first', 'taskId' => 'task-1']; + $run = $this->service->signal($run, $answer); + $this->assertNotNull($run, 'a suspended run accepts its signal'); + $run = $this->service->execute($run, $flow, new RunSubject()); + + $this->assertSame( + FlowRun::STATUS_SUSPENDED, + $run->getStatus(), + 'one answer advances the run to the NEXT question, never to the end' + ); + + $slots = ($run->getContext()[FlowResumeState::CONTEXT_KEY] ?? []); + $this->assertArrayHasKey('second', $slots, 'the second ask recorded a question of its own'); + $this->assertArrayNotHasKey('first', $slots, 'the answered ask keeps no slot'); + $this->assertArrayNotHasKey( + FlowRunService::SIGNAL_CONTEXT_KEY, + ($run->getContext() ?? []), + 'the consumed signal does not linger in the stored context' + ); + + $this->assertSame( + [null, $answer], + $this->asker->sawSignal['first'] ?? [], + 'the answered node read the payload on its resume, and only then' + ); + $this->assertSame( + [null], + $this->asker->sawSignal['second'] ?? [], + 'the second node never saw the first answer' + ); + + // The second answer is the one that finishes the run, and each item + // carries each answer under its OWN node. + $secondAnswer = ['decision' => 'approved', 'node' => 'second']; + $run = $this->service->signal($run, $secondAnswer); + $run = $this->service->execute($run, $flow, new RunSubject()); + + $this->assertSame(FlowRun::STATUS_COMPLETED, $run->getStatus()); + $item = ($run->getItems()[0]['json'] ?? []); + $this->assertSame($answer, ($item['first'] ?? null)); + $this->assertSame($secondAnswer, ($item['second'] ?? null)); + }//end testAnsweringOneWaitDoesNotAnswerTheNext() + + /** + * On the first walk — no suspension yet, nothing resuming — an ask node + * asks rather than answering itself: the negative control for the + * scoping, proving the signal gate does not leak on fresh runs either. + * + * @return void + */ + public function testAFreshRunsFirstAskSeesNoSignal(): void { + $flow = [ + 'id' => 'f1', + 'nodes' => [['id' => 'only', 'type' => 'test.ask']], + 'edges' => [], + ]; + + $run = $this->service->queue('f1', user: 'alice'); + $run = $this->service->execute($run, $flow, new RunSubject()); + + $this->assertSame(FlowRun::STATUS_SUSPENDED, $run->getStatus()); + $this->assertSame([null], $this->asker->sawSignal['only'] ?? []); + }//end testAFreshRunsFirstAskSeesNoSignal() + + /** + * THE OPERATOR'S STOP LANDS ON THE NEXT OBSERVATION. The kill switch is + * thrown while a run is suspended; the operator nudges it (`resume` with + * an empty body is `signal([])`) and the next worker pass must end the run + * `stopped` — the oversight veto travels an author's Stop-step path — not + * leave it suspended forever. The run's terminal write is what triggers + * task termination (FlowRunTerminalEvent → terminateForRun), so a stop + * that never lands is also an inbox that never empties. + * + * @return void + */ + public function testAKillSwitchVetoLandsTheStopOnTheNextObservation(): void { + $thrown = false; + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool')->willReturnCallback( + static function (string $app, string $key, bool $default = false) use (&$thrown): bool { + return $thrown; + } + ); + + $oversight = new FlowOversightRegistry(logger: $this->createMock(LoggerInterface::class)); + $oversight->register(check: new KillSwitchCheck(appConfig: $appConfig)); + + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + function (Event $event): void { + if ($event instanceof RegisterFlowNodesEvent) { + $event->registerNode($this->waiter); + } + } + ); + $registry = new FlowNodeRegistry($dispatcher, $this->createMock(LoggerInterface::class)); + $engine = new FlowEngine( + new FlowDefinitionBuilder(), + $this->createMock(LoggerInterface::class), + $oversight + ); + + $container = $this->createMock(ContainerInterface::class); + $versions = $this->publishedVersionMapper(); + $pin = $this->pinReturning(); + $container->method('get')->willReturnCallback( + function (string $id) use ($versions, $pin): object { + if ($id === \OCA\OpenRegister\Db\FlowVersionMapper::class) { + return $versions; + } + + if ($id === \OCA\OpenRegister\Service\Flow\FlowDefinitionPin::class) { + return $pin; + } + + throw new \RuntimeException('not available'); + } + ); + + $service = new FlowRunService( + $this->mapper, + $this->createMock(\OCA\OpenRegister\Db\FlowStateMapper::class), + $engine, + $registry, + $this->createMock(LoggerInterface::class), + $container + ); + + $run = $service->queue('f1', user: 'alice'); + $run = $service->execute($run, $this->waitFlow(), new RunSubject()); + $this->assertSame(FlowRun::STATUS_SUSPENDED, $run->getStatus()); + + // The operator throws the switch and nudges the parked run. + $thrown = true; + $run = $service->signal($run, []); + $this->assertNotNull($run); + $run = $service->execute($run, $this->waitFlow(), new RunSubject()); + + $this->assertSame( + FlowRun::STATUS_STOPPED, + $run->getStatus(), + 'the veto must END the run; a stop that leaves it suspended never lands' + ); + + $last = $run->getLog()[count($run->getLog()) - 1]; + $this->assertSame('stopped', $last['status']); + $this->assertSame( + 'openregister.kill-switch', + ($last['checkId'] ?? null), + 'the history records WHICH gate closed' + ); + $this->assertNull($run->getResumeAt(), 'a stopped run is never due again'); + }//end testAKillSwitchVetoLandsTheStopOnTheNextObservation() + /** * The refresh reaches the PER-PLACE buffers too: with a stream layer the * resumed walk reads its input place's buffer, not the flat list, so a diff --git a/tests/Unit/Service/Flow/RegistryStepDispatcherResumeTest.php b/tests/Unit/Service/Flow/RegistryStepDispatcherResumeTest.php index 7e85237819..825c5c6c5f 100644 --- a/tests/Unit/Service/Flow/RegistryStepDispatcherResumeTest.php +++ b/tests/Unit/Service/Flow/RegistryStepDispatcherResumeTest.php @@ -12,6 +12,7 @@ use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\IFlowNode; use OCA\OpenRegister\Service\Flow\RegistryStepDispatcher; @@ -192,4 +193,120 @@ public function testAStepWithNoIdGetsNoSlot(): void { $this->assertNull($seen); } + + /** + * A resume signal answers ONE node: the one whose held slot marks it as + * the node that suspended. That node reads the payload. + */ + public function testTheSignalReachesTheNodeWhoseSlotItAnswers(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'first-ask')->set(key: 'askedAt', value: '2026-09-01T00:00:00+00:00'); + + $seen = 'unset'; + $dispatcher = $this->dispatcher( + $this->node(function (array $items, array $config, array $context) use (&$seen): array { + $seen = ($context[FlowRunService::SIGNAL_CONTEXT_KEY] ?? null); + + return $items; + }) + ); + + $dispatcher->dispatch( + ['id' => 'first-ask', 'type' => 'test.node'], + [], + [ + FlowResumeState::CONTEXT_KEY => $state, + FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'approved'], + ] + ); + + $this->assertSame(['decision' => 'approved'], $seen); + } + + /** + * THE LEAK THIS SCOPING REMOVES. A wait node the resumed walk reaches + * AFTER the answered one holds no slot — it never suspended — so the + * payload is not its answer and must not be readable as one. Unscoped, + * every later wait node in the walk completed on somebody else's answer: + * observed live as a decision step whose outcome was an applicant task's + * completion payload (run f8996ccc), and a second decision inheriting the + * first's reference (run ca50c56c). + */ + public function testAFreshWaitNodeInTheSameWalkDoesNotSeeTheSignal(): void { + $state = new FlowResumeState(); + // The answered node's slot is already cleared: it consumed the signal + // and returned. The second node enters with no slot of its own. + $seen = 'unset'; + $dispatcher = $this->dispatcher( + $this->node(function (array $items, array $config, array $context) use (&$seen): array { + $seen = ($context[FlowRunService::SIGNAL_CONTEXT_KEY] ?? null); + + return $items; + }) + ); + + $dispatcher->dispatch( + ['id' => 'second-ask', 'type' => 'test.node'], + [], + [ + FlowResumeState::CONTEXT_KEY => $state, + FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'approved', 'node' => 'first-ask'], + ] + ); + + $this->assertNull($seen, 'a node that never suspended has not been answered'); + } + + /** + * ...and holding a DIFFERENT node's slot does not help: the addressee test + * is this node's own slot, never the walk-wide fact that some slot exists. + */ + public function testAnotherNodesHeldSlotDoesNotDeliverTheSignalHere(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'first-ask')->set(key: 'askedAt', value: '2026-09-01T00:00:00+00:00'); + + $seen = 'unset'; + $dispatcher = $this->dispatcher( + $this->node(function (array $items, array $config, array $context) use (&$seen): array { + $seen = ($context[FlowRunService::SIGNAL_CONTEXT_KEY] ?? null); + + return $items; + }) + ); + + $dispatcher->dispatch( + ['id' => 'second-ask', 'type' => 'test.node'], + [], + [ + FlowResumeState::CONTEXT_KEY => $state, + FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'approved'], + ] + ); + + $this->assertNull($seen); + } + + /** + * With no slot machinery there is no addressee, so the signal is withheld + * rather than delivered to whichever node happens to run: suspending + * visibly beats answering the wrong question silently. + */ + public function testWithNoSlotMachineryTheSignalIsWithheld(): void { + $seen = 'unset'; + $dispatcher = $this->dispatcher( + $this->node(function (array $items, array $config, array $context) use (&$seen): array { + $seen = ($context[FlowRunService::SIGNAL_CONTEXT_KEY] ?? null); + + return $items; + }) + ); + + $dispatcher->dispatch( + ['id' => 'ask', 'type' => 'test.node'], + [], + [FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'approved']] + ); + + $this->assertNull($seen); + } } diff --git a/tests/Unit/Service/Flow/WaitNodeTest.php b/tests/Unit/Service/Flow/WaitNodeTest.php index 20cd94f0e3..1eda707b17 100644 --- a/tests/Unit/Service/Flow/WaitNodeTest.php +++ b/tests/Unit/Service/Flow/WaitNodeTest.php @@ -29,6 +29,8 @@ namespace OCA\OpenRegister\Tests\Unit\Service\Flow; +use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; +use OCA\OpenRegister\Service\Flow\FlowResumeState; use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\Nodes\WaitNode; use OCP\IL10N; @@ -37,6 +39,10 @@ /** * @covers \OCA\OpenRegister\Service\Flow\Nodes\WaitNode + * + * @uses \OCA\OpenRegister\Service\Flow\FlowNodeResumeState + * @uses \OCA\OpenRegister\Service\Flow\FlowResumeState + * @uses \OCA\OpenRegister\Service\Flow\FlowSuspension */ final class WaitNodeTest extends TestCase { @@ -129,4 +135,65 @@ public function testResumingPassesItemsThrough(): void { ); }//end testResumingPassesItemsThrough() + + /** + * A SECOND wait node in a resumed walk still waits. + * + * `context.resuming` is true for every node of a resumed walk, so a wait + * the walk has only just reached must not read it as "my wait is over". + * The node that suspended holds a resume slot; this one holds none, and an + * empty slot means the waiting has not started yet. + * + * @return void + */ + public function testASecondWaitInAResumedWalkStillWaits(): void { + $slot = (new FlowResumeState())->forNode(nodeId: 'second-wait'); + $context = [ + 'resuming' => true, + FlowNodeResumeState::CONTEXT_KEY => $slot, + ]; + + try { + $this->node->execute([['json' => ['a' => 1]]], ['for' => '60 seconds'], $context); + $this->fail('a wait that never waited must suspend, resumed walk or not'); + } catch (FlowSuspension $suspension) { + $this->assertNotNull( + actual: $suspension->getResumeAt(), + message: 'the second wait suspends on its OWN clock' + ); + } + + $this->assertTrue( + condition: $slot->has(key: 'waitingUntil'), + message: 'the suspension marks this node as the one now waiting' + ); + + }//end testASecondWaitInAResumedWalkStillWaits() + + /** + * The node whose own slot is held is the one whose wait is over. + * + * It wrote the slot when it suspended, and the run only became eligible + * again once its `resumeAt` had passed — so a held slot is the per-node + * fact the run-wide `resuming` flag only pretends to be. + * + * @return void + */ + public function testAWaitWhoseOwnSlotIsHeldPassesThrough(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'the-wait')->set(key: 'waitingUntil', value: '2026-01-01T00:00:00+00:00'); + + $items = [['json' => ['a' => 1]]]; + $context = [ + 'resuming' => true, + FlowNodeResumeState::CONTEXT_KEY => $state->forNode(nodeId: 'the-wait'), + ]; + + $this->assertSame( + expected: $items, + actual: $this->node->execute($items, ['for' => '60 seconds'], $context), + message: 'the wait that suspended is over once the run is woken' + ); + + }//end testAWaitWhoseOwnSlotIsHeldPassesThrough() }//end class diff --git a/tests/e2e/api-direct/flow-user-task.spec.ts b/tests/e2e/api-direct/flow-user-task.spec.ts index 31454d1e3e..0822267b18 100644 --- a/tests/e2e/api-direct/flow-user-task.spec.ts +++ b/tests/e2e/api-direct/flow-user-task.spec.ts @@ -580,6 +580,85 @@ test.describe('flow-user-task-node: a person in the graph', () => { expect(transitions).toContain('two') }) + // @e2e flow-engine::a-run-suspended-on-an-external-signal-must-be-reachable + test('a resume answers only the node that asked; the next await asks fresh', async ({ + request, + }) => { + const job = runWorkerJobId() + test.skip( + job === null, + 'FlowRunWorker not reachable via occ; a resume parks the run for the worker', + ) + + // Two sequential await-signal nodes. The 2026-09-01 acceptance run + // showed the second consuming the FIRST answer out of the shared + // signal key and completing in 0ms, racing the run to its end (runs + // f8996ccc / ca50c56c). One answer must advance the run to the NEXT + // question, never past it. + const flowId = await createFlow( + request, + 'two signals', + [ + { + id: 'first-gate', + type: 'openregister.await-signal', + config: { question: 'first gate?', signalKey: 'firstGate' }, + position: { x: 0, y: 0 }, + }, + { + id: 'second-gate', + type: 'openregister.await-signal', + config: { question: 'second gate?', signalKey: 'secondGate' }, + position: { x: 0, y: 0 }, + }, + setFields('done', { finished: true }), + ], + [ + { id: 'e1', from: 'first-gate', to: 'second-gate' }, + { id: 'e2', from: 'second-gate', to: 'done' }, + ], + ) + flows.push(flowId) + + const run = await testRun(request, flowId) + expect(run.status).toBe('suspended') + + const answer = async (payload: Record) => { + const resp = await request.post(`${API}/flow-runs/${run.uuid}/resume`, { + headers: JSON_HEADERS, + data: payload, + }) + expect(resp.status(), await resp.text()).toBe(200) + occ(`background-job:execute ${job} --force-execute`) + } + + await answer({ decision: 'approved', mark: 'first' }) + + const between = await readRun(request, run.uuid) + expect( + between.status, + 'one answer advances the run to the NEXT question, never to the end', + ).toBe('suspended') + const suspendedAt = (between.log ?? []) + .filter((entry: { status?: string }) => entry.status === 'suspended') + .map((entry: { transition?: string }) => entry.transition) + expect(suspendedAt, 'the second gate asked fresh').toContain('second-gate') + + await answer({ decision: 'approved', mark: 'second' }) + + const after = await readRun(request, run.uuid) + expect(after.status).toBe('completed') + const item = (after.items ?? [])[0]?.json ?? {} + expect(item.finished).toBe(true) + expect(item.firstGate?.mark, 'the first gate holds the first answer').toBe( + 'first', + ) + expect( + item.secondGate?.mark, + 'the second gate holds ITS answer, not an inherited one', + ).toBe('second') + }) + // @e2e flow-user-task-node::stopping-a-run-empties-its-inboxes test("stopping a run removes its tasks from the assignees' inboxes", async ({ request, From 81e3dd1810a1bab901b12fb1dd15b4a1ba84760c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 07:59:23 +0200 Subject: [PATCH 46/68] fix(flow): the await-signal answer lands in the item's record, not beside it (#3327) AwaitSignalNode wrote its resume payload at the item ENVELOPE level (item[key] = signal) while items are {json, binary} envelopes. Nothing downstream can see an envelope-level key: FlowExpression::dataFor exposes json.* only, so a Switch cannot branch on it, and SetFieldsNode rebuilds every item as [json, binary], dropping the stray key entirely. The two-gates e2e (flow-user-task.spec.ts) read item.firstGate as undefined for exactly this reason. The payload now lands at json., matching where UserTaskNode places its outcome bag. No production reader consumed the old shape (the only readers were this node's own unit tests), so no backward tolerance is kept. Two new unit tests pin the contract: one asserts json-level placement (and that the record itself is preserved), one chains the node into SetFieldsNode and proves the answer survives the rebuild. Also fixed while in the test suite: five non-static PHPUnit data providers (MagicMapperTest, LeafProvidersMetadataTest) and two single-argument ReflectionProperty::setValue() calls on a static property, all of which PHPUnit 10 flags as deprecations. --- lib/Service/Flow/Nodes/AwaitSignalNode.php | 18 +++--- .../Unit/Service/Flow/AwaitSignalNodeTest.php | 58 +++++++++++++++++-- .../Providers/LeafProvidersMetadataTest.php | 2 +- tests/Unit/Service/MagicMapperTest.php | 12 ++-- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/lib/Service/Flow/Nodes/AwaitSignalNode.php b/lib/Service/Flow/Nodes/AwaitSignalNode.php index a940fb90de..0e7d128452 100644 --- a/lib/Service/Flow/Nodes/AwaitSignalNode.php +++ b/lib/Service/Flow/Nodes/AwaitSignalNode.php @@ -27,9 +27,9 @@ * * WHAT COUNTS AS AN ANSWER. The payload posted to the resume endpoint lands at * `context.signal`. This node reads `decision` from it and writes the whole - * payload onto every item under `signalKey`, so the steps after it can branch on - * what was decided and see who decided it. A resume with no `decision` is a - * nudge, not an answer: the node suspends again. That is what makes an + * payload into every item's record under `json.`, so the steps after + * it can branch on what was decided and see who decided it. A resume with no + * `decision` is a nudge, not an answer: the node suspends again. That is what makes an * accidental or duplicate POST harmless. * * REJECTION IS NOT FAILURE. A rejected approval is the flow working correctly — @@ -301,15 +301,19 @@ public function execute(array $items, array $config, array $context): array { $key = 'signal'; } - // Onto every item rather than into the token, because the steps that - // follow route per item; a Switch cannot branch on something only the - // run holds. + // Into every item's record (`json`), not beside it, and not into the + // token: the steps that follow route per item and read `json.` + // (FlowExpression::dataFor exposes json.*, and a rebuilding node like + // set-fields keeps only [json, binary]). A key at the envelope level + // is invisible to a Switch and silently dropped by the next rebuild. foreach ($items as $index => $item) { if (is_array($item) === false) { continue; } - $item[$key] = $signal; + $json = (array)($item[FlowItems::JSON] ?? []); + $json[$key] = $signal; + $item[FlowItems::JSON] = $json; $items[$index] = $item; } diff --git a/tests/Unit/Service/Flow/AwaitSignalNodeTest.php b/tests/Unit/Service/Flow/AwaitSignalNodeTest.php index a938075202..649b30e800 100644 --- a/tests/Unit/Service/Flow/AwaitSignalNodeTest.php +++ b/tests/Unit/Service/Flow/AwaitSignalNodeTest.php @@ -16,6 +16,7 @@ use OCA\OpenRegister\Service\Flow\FlowStop; use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\Nodes\AwaitSignalNode; +use OCA\OpenRegister\Service\Flow\Nodes\SetFieldsNode; use OCP\IL10N; use OCP\IURLGenerator; use PHPUnit\Framework\Attributes\DataProvider; @@ -113,11 +114,30 @@ public function testAnAnswerPassesTheItemsThroughCarryingTheDecision(): void { $this->assertCount(2, $out); foreach ($out as $item) { - $this->assertSame('approve', $item['signal']['decision']); - $this->assertSame('ruben', $item['signal']['by']); + $this->assertSame('approve', $item[FlowItems::JSON]['signal']['decision']); + $this->assertSame('ruben', $item[FlowItems::JSON]['signal']['by']); } } + /** + * WHERE the answer lands is part of the contract: inside the item's record + * (`json.`), never beside it at the envelope level. The engine's + * expression data (FlowExpression::dataFor) exposes `json.*` only, and a + * rebuilding node keeps only `[json, binary]` — an envelope-level key is + * invisible to a Switch and silently dropped by the next rebuild. + */ + public function testTheAnswerLandsInsideTheItemsRecordNotBesideIt(): void { + $out = $this->node->execute( + $this->items([['id' => 1]]), + ['question' => 'Publish it?'], + [FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'approve']] + ); + + $this->assertArrayNotHasKey('signal', $out[0], 'the answer must not sit at the envelope level'); + $this->assertSame('approve', $out[0][FlowItems::JSON]['signal']['decision']); + $this->assertSame(1, $out[0][FlowItems::JSON]['id'], 'the record itself is kept, not replaced'); + } + /** * Where the answer lands is configurable, because a flow with two await * steps needs to keep both answers rather than have the second overwrite @@ -130,8 +150,8 @@ public function testTheAnswerFieldIsConfigurable(): void { [FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'approve']] ); - $this->assertSame('approve', $out[0]['legalReview']['decision']); - $this->assertArrayNotHasKey('signal', $out[0]); + $this->assertSame('approve', $out[0][FlowItems::JSON]['legalReview']['decision']); + $this->assertArrayNotHasKey('signal', $out[0][FlowItems::JSON]); } /** @@ -177,7 +197,7 @@ public function testARejectionIsNotAFailureByDefault(): void { [FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'reject', 'reason' => 'not ready']] ); - $this->assertSame('reject', $out[0]['signal']['decision']); + $this->assertSame('reject', $out[0][FlowItems::JSON]['signal']['decision']); } /** @@ -265,4 +285,32 @@ public function testEveryFormFieldWritesAKeyTheNodeReads(): void { $this->assertContains($field['key'], $keys); } } + + /** + * The regression that motivated json-level placement: set-fields rebuilds + * every item as `[json, binary]`, so an answer written at the envelope + * level did not survive the very next step. The flow in + * flow-user-task.spec.ts (two gates, then set-fields) read + * `item.firstGate` as undefined for exactly this reason. + */ + public function testTheAnswerSurvivesASetFieldsRebuild(): void { + $answered = $this->node->execute( + $this->items([['id' => 1]]), + ['question' => 'First gate?', 'signalKey' => 'firstGate'], + [FlowRunService::SIGNAL_CONTEXT_KEY => ['decision' => 'approved', 'mark' => 'first']] + ); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $setFields = new SetFieldsNode($l10n, $this->createMock(IURLGenerator::class)); + + $out = $setFields->execute($answered, ['set' => ['finished' => true]], []); + + $this->assertTrue($out[0][FlowItems::JSON]['finished']); + $this->assertSame( + 'first', + $out[0][FlowItems::JSON]['firstGate']['mark'] ?? null, + 'the gate answer must survive the set-fields rebuild' + ); + } } diff --git a/tests/Unit/Service/Integration/Providers/LeafProvidersMetadataTest.php b/tests/Unit/Service/Integration/Providers/LeafProvidersMetadataTest.php index e23f5b2126..8ad94d7fe2 100644 --- a/tests/Unit/Service/Integration/Providers/LeafProvidersMetadataTest.php +++ b/tests/Unit/Service/Integration/Providers/LeafProvidersMetadataTest.php @@ -513,7 +513,7 @@ public function testOpenProjectProviderListRoutesThroughExternalRouter(): void { * * @return array */ - public function greenfieldStubProvider(): array { + public static function greenfieldStubProvider(): array { // [class, id, label, icon, group, requiredApp, storage] return [ 'activity' => [ActivityProvider::class, 'activity', 'Activity', 'Timeline', 'workflow', 'activity', 'query-time'], diff --git a/tests/Unit/Service/MagicMapperTest.php b/tests/Unit/Service/MagicMapperTest.php index 2a62c898fc..747c131ec8 100644 --- a/tests/Unit/Service/MagicMapperTest.php +++ b/tests/Unit/Service/MagicMapperTest.php @@ -282,7 +282,7 @@ public function testGetTableNameForRegisterSchema(int $registerId, int $schemaId * * @return array> */ - public function registerSchemaTableNameProvider(): array { + public static function registerSchemaTableNameProvider(): array { return [ 'basic_combination' => [ 'registerId' => 1, @@ -334,7 +334,7 @@ public function testIsMagicMappingEnabled(?array $schemaConfig, string $globalCo * * @return array> */ - public function magicMappingConfigProvider(): array { + public static function magicMappingConfigProvider(): array { return [ 'enabled_in_schema' => [ 'schemaConfig' => ['magicMapping' => true], @@ -396,7 +396,7 @@ public function testColumnNameSanitization(string $input, string $expected): voi * * @return array> */ - public function columnSanitizationProvider(): array { + public static function columnSanitizationProvider(): array { return [ 'simple_name' => [ 'input' => 'name', @@ -498,7 +498,7 @@ public function testSchemaPropertyToColumnMapping(array $propertyConfig, array $ * * @return array> */ - public function schemaPropertyMappingProvider(): array { + public static function schemaPropertyMappingProvider(): array { return [ 'string_property' => [ 'propertyConfig' => ['type' => 'string'], @@ -748,7 +748,7 @@ public function testClearCache(): void { $tableExistsCache = $reflection->getProperty('tableExistsCache'); $tableExistsCache->setAccessible(true); - $tableExistsCache->setValue(['test_table' => time()]); + $tableExistsCache->setValue(null, ['test_table' => time()]); // Test full cache clear. $this->magicMapper->clearCache(); @@ -757,7 +757,7 @@ public function testClearCache(): void { $this->assertEquals([], $tableExistsCache->getValue()); // Test targeted cache clear. - $tableExistsCache->setValue(['1_1' => time()]); + $tableExistsCache->setValue(null, ['1_1' => time()]); $this->magicMapper->clearCache(1, 1); // Should clear specific cache entry. From d1594ccd6cd011f9fe47a21a827f7796bb071739 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 11:22:05 +0200 Subject: [PATCH 47/68] feat(flow): a decision-table node puts rule evaluation into the graph (#3329) * feat(flow): a decision-table node puts rule evaluation into the graph openregister.decision-table evaluates an inline DMN-style table against each item through the shared evaluator and writes the outputs onto configured item fields. The table travels in the node config, so flow versioning, pinning and x-openregister-flows import apply to the rules with no extra machinery. A table the evaluator cannot execute is refused when the flow is saved: DecisionTableValidator probes every rule cell through the evaluator's own grammar, so the accepted grammar is the executable one by construction. No-match is an explicit choice, a complete default row or a loud failure. The node never suspends: rule steps are the engine's, human decisions stay with the user task. The unit fixture is dossiq's real LHS enforcement matrix driven through dossiq's own inputMapping/outputMapping vocabulary, proving the migration that retires the app-side copy cannot strand. * test(flow): register the decision-table node in the listener suite, and settle the analyzers The listener test constructs every built-in by name, so the new node is a 25th argument there; the two StaticAccess findings are suppressed with their reasons, and the long form-help strings are wrapped. --------- Co-authored-by: Conduction Release Bot --- lib/Listener/FlowNodeRegistrationListener.php | 10 + lib/Service/Dmn/DecisionTableEvaluator.php | 44 +- lib/Service/Dmn/DecisionTableValidator.php | 292 ++++++++ lib/Service/Flow/Nodes/DecisionTableNode.php | 557 ++++++++++++++++ .../changes/flow-decision-tables/design.md | 140 ++++ .../changes/flow-decision-tables/proposal.md | 93 +++ .../specs/flow-decision-tables/spec.md | 222 +++++++ .../changes/flow-decision-tables/tasks.md | 64 ++ .../FlowNodeRegistrationListenerTest.php | 4 +- .../Dmn/DecisionTableValidatorTest.php | 266 ++++++++ .../Flow/Nodes/DecisionTableNodeTest.php | 623 ++++++++++++++++++ 11 files changed, 2301 insertions(+), 14 deletions(-) create mode 100644 lib/Service/Dmn/DecisionTableValidator.php create mode 100644 lib/Service/Flow/Nodes/DecisionTableNode.php create mode 100644 openspec/changes/flow-decision-tables/design.md create mode 100644 openspec/changes/flow-decision-tables/proposal.md create mode 100644 openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md create mode 100644 openspec/changes/flow-decision-tables/tasks.md create mode 100644 tests/Unit/Service/Dmn/DecisionTableValidatorTest.php create mode 100644 tests/Unit/Service/Flow/Nodes/DecisionTableNodeTest.php diff --git a/lib/Listener/FlowNodeRegistrationListener.php b/lib/Listener/FlowNodeRegistrationListener.php index 6baecfc076..d2d9b80217 100644 --- a/lib/Listener/FlowNodeRegistrationListener.php +++ b/lib/Listener/FlowNodeRegistrationListener.php @@ -29,6 +29,7 @@ namespace OCA\OpenRegister\Listener; use OCA\OpenRegister\Service\Flow\Nodes\AwaitSignalNode; +use OCA\OpenRegister\Service\Flow\Nodes\DecisionTableNode; use OCA\OpenRegister\Service\Flow\Nodes\EndNode; use OCA\OpenRegister\Service\Flow\Nodes\ExplodeNode; use OCA\OpenRegister\Service\Flow\Nodes\FilterNode; @@ -89,6 +90,7 @@ class FlowNodeRegistrationListener implements IEventListener { * @param TriggerManualNode $triggerManual The "When someone runs it" entry point. * @param UserTaskNode $userTask The built-in "Ask a person" node. * @param PortalTaskNode $portalTask The built-in "Ask a party outside the organisation" node. + * @param DecisionTableNode $decisionTable The built-in "Evaluate a decision table" node. */ public function __construct( private readonly SetFieldsNode $setFields, @@ -115,6 +117,7 @@ public function __construct( private readonly TriggerManualNode $triggerManual, private readonly UserTaskNode $userTask, private readonly PortalTaskNode $portalTask, + private readonly DecisionTableNode $decisionTable, ) { }//end __construct() @@ -150,6 +153,13 @@ public function handle(Event $event): void { $event->registerNode(node: $this->map); $event->registerNode(node: $this->iterate); + // The rule step (flow-decision-tables). Registered with the data + // reshapers rather than the waiters, deliberately: it decides and + // moves on in one firing. A decision that needs a person is the + // user task below, and the split is the fleet's directive - rule + // evaluation is the engine's, human decisions are decidiq's. + $event->registerNode(node: $this->decisionTable); + // Messaging. Three nodes, not one "send" with a channel picker: the // three differ in config shape and failure modes, so three flat forms // beat one union form. Deliberately NO send-webhook — outbound HTTP is diff --git a/lib/Service/Dmn/DecisionTableEvaluator.php b/lib/Service/Dmn/DecisionTableEvaluator.php index 7ca2f0a35c..f9927313bf 100644 --- a/lib/Service/Dmn/DecisionTableEvaluator.php +++ b/lib/Service/Dmn/DecisionTableEvaluator.php @@ -37,11 +37,6 @@ */ class DecisionTableEvaluator { - /** - * Hit policies fully implemented by this engine. - * - * @var string[] - */ /** * The hit policies this evaluator implements. * @@ -51,9 +46,13 @@ class DecisionTableEvaluator { * tables can already use it. This list is the union of the two, not the * intersection. * + * Public so that {@see DecisionTableValidator} refuses exactly the + * policies this class refuses. A second list would drift; the constant + * cannot. + * * @var array */ - private const IMPLEMENTED_HIT_POLICIES = ['UNIQUE', 'FIRST', 'COLLECT', 'PRIORITY', 'ANY']; + public const IMPLEMENTED_HIT_POLICIES = ['UNIQUE', 'FIRST', 'COLLECT', 'PRIORITY', 'ANY']; /** * Common spellings of the declared column types, mapped onto this @@ -400,6 +399,31 @@ private function applyHitPolicy(string $hitPolicy, array $matchedRules, array $d return ['outputs' => $outputs, 'matchedRuleIds' => [$winnerId], 'hitPolicy' => $hitPolicy]; }//end applyHitPolicy() + /** + * The type a declared column type actually evaluates under. + * + * Applies {@see TYPE_ALIASES} and the string fallback in one place. + * Public because {@see DecisionTableValidator} probes every rule cell + * with a value of the column's effective type; if it normalised types + * itself, the validator and the evaluator could disagree about what a + * column means, which is the drift this method exists to prevent. + * + * @param string $type The declared column type, any spelling. + * + * @return string One of {@see UnaryTestEvaluator::VALID_TYPES}. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ + public static function effectiveType(string $type): string { + $lower = strtolower($type); + $lower = (self::TYPE_ALIASES[$lower] ?? $lower); + if (in_array($lower, UnaryTestEvaluator::VALID_TYPES, true) === false) { + return 'string'; + } + + return $lower; + }//end effectiveType() + /** * Normalise a decision table's `inputs`/`outputs` array into a clean * positional list of `{name, type}`. @@ -420,13 +444,7 @@ private static function normaliseFields(array $fields): array { continue; } - $type = strtolower((string)($field['type'] ?? 'string')); - $type = (self::TYPE_ALIASES[$type] ?? $type); - if (in_array($type, UnaryTestEvaluator::VALID_TYPES, true) === false) { - $type = 'string'; - } - - $result[] = ['name' => $name, 'type' => $type]; + $result[] = ['name' => $name, 'type' => self::effectiveType(type: (string)($field['type'] ?? 'string'))]; } return $result; diff --git a/lib/Service/Dmn/DecisionTableValidator.php b/lib/Service/Dmn/DecisionTableValidator.php new file mode 100644 index 0000000000..b7fe6a92b2 --- /dev/null +++ b/lib/Service/Dmn/DecisionTableValidator.php @@ -0,0 +1,292 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Dmn; + +/** + * Says, at save time, whether the evaluator could execute a table. + * + * Returns problems rather than throwing, so a caller can show every defect + * in one refusal instead of one per save attempt. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ +class DecisionTableValidator { + + /** + * One already-coerced probe value per effective type, used to exercise + * every rule cell through the evaluator's own grammar. The probe's VALUE + * is irrelevant — a cell may match it or not — only the parse matters: + * `invalid_expression` and `type_mismatch` surface authoring errors, a + * boolean answer means the grammar executed. + * + * @var array + */ + private const PROBES = [ + 'string' => '', + 'number' => 0.0, + 'boolean' => false, + 'date' => 0, + ]; + + /** + * Constructor. + * + * @param UnaryTestEvaluator $evaluator The grammar being validated against, never re-implemented. + */ + public function __construct( + private readonly UnaryTestEvaluator $evaluator = new UnaryTestEvaluator(), + ) { + }//end __construct() + + /** + * Validate a decision-table definition. + * + * @param array $table The table definition (`hitPolicy`, `inputs`, `outputs`, `rules`). + * + * @return array Problems, each naming the offending part. Empty means the evaluator can execute the table. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ + public function validate(array $table): array { + $problems = []; + + $hitPolicy = strtoupper(trim((string)($table['hitPolicy'] ?? 'UNIQUE'))); + if (in_array($hitPolicy, DecisionTableEvaluator::IMPLEMENTED_HIT_POLICIES, true) === false) { + $problems[] = sprintf( + 'hit policy "%s" is not implemented; the implemented policies are %s', + $hitPolicy, + implode(', ', DecisionTableEvaluator::IMPLEMENTED_HIT_POLICIES) + ); + } + + $inputs = $this->validateColumns(raw: ($table['inputs'] ?? null), side: 'inputs', problems: $problems); + $outputs = $this->validateColumns(raw: ($table['outputs'] ?? null), side: 'outputs', problems: $problems); + + $this->validateRules( + raw: ($table['rules'] ?? null), + inputs: $inputs, + outputCount: count($outputs), + problems: $problems + ); + + return $problems; + }//end validate() + + /** + * Validate one side's column declarations. + * + * An empty side is refused: a table with no inputs matches nothing in + * particular and a table with no outputs decides nothing — both are the + * silent no-op shape. A duplicate name is refused because evaluation + * results are keyed by name, so two columns sharing one would silently + * collapse into a single answer. + * + * @param mixed $raw The declared `inputs` or `outputs`. + * @param string $side `inputs` or `outputs`, for the messages. + * @param array $problems Collected problems, appended to. + * + * @return array The usable columns, with effective types. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + * + * @SuppressWarnings(PHPMD.StaticAccess) `DecisionTableEvaluator::effectiveType()` + * is static precisely so validator and evaluator share one normalisation. + */ + private function validateColumns(mixed $raw, string $side, array &$problems): array { + if (is_array($raw) === false || $raw === []) { + $problems[] = sprintf('the table declares no %s; it needs at least one', $side); + + return []; + } + + $columns = []; + $seen = []; + foreach (array_values($raw) as $position => $column) { + if (is_array($column) === false) { + $problems[] = sprintf('%s entry %d is not an object', $side, $position); + continue; + } + + $name = trim((string)($column['name'] ?? '')); + if ($name === '') { + $problems[] = sprintf('%s entry %d has no name', $side, $position); + continue; + } + + if (in_array($name, $seen, true) === true) { + $problems[] = sprintf('%s name "%s" is declared twice; results are keyed by name, so the columns would collapse', $side, $name); + continue; + } + + $seen[] = $name; + $columns[] = [ + 'name' => $name, + 'type' => DecisionTableEvaluator::effectiveType(type: (string)($column['type'] ?? 'string')), + ]; + }//end foreach + + return $columns; + }//end validateColumns() + + /** + * Validate the rules: positional alignment, integer priority, and every + * input cell executed through the evaluator's grammar. + * + * @param mixed $raw The declared `rules`. + * @param array $inputs The declared inputs, positionally. + * @param int $outputCount The declared output count. + * @param array $problems Collected problems, appended to. + * + * @return void + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ + private function validateRules(mixed $raw, array $inputs, int $outputCount, array &$problems): void { + if (is_array($raw) === false || $raw === []) { + $problems[] = 'the table declares no rules; a table that can never match decides nothing'; + + return; + } + + foreach (array_values($raw) as $position => $rule) { + if (is_array($rule) === false) { + $problems[] = sprintf('rule %d is not an object', $position); + continue; + } + + $ruleId = trim((string)($rule['id'] ?? '')); + if ($ruleId === '') { + $ruleId = ('#' . $position); + } + + $this->validateRule(rule: $rule, ruleId: $ruleId, inputs: $inputs, outputCount: $outputCount, problems: $problems); + } + + }//end validateRules() + + /** + * Validate one rule row. + * + * @param array $rule The rule row. + * @param string $ruleId The rule's id or position, for the messages. + * @param array $inputs The declared inputs, positionally. + * @param int $outputCount The declared output count. + * @param array $problems Collected problems, appended to. + * + * @return void + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ + private function validateRule(array $rule, string $ruleId, array $inputs, int $outputCount, array &$problems): void { + $inputEntries = []; + if (is_array($rule['inputEntries'] ?? null) === true) { + $inputEntries = array_values($rule['inputEntries']); + } + + $outputEntries = []; + if (is_array($rule['outputEntries'] ?? null) === true) { + $outputEntries = array_values($rule['outputEntries']); + } + + if (count($inputEntries) !== count($inputs)) { + // A short row would silently wildcard its missing tail: the + // evaluator reads an absent positional entry as `-`. + $problems[] = sprintf( + 'rule %s has %d input entries for %d declared inputs; a short row would silently wildcard the missing columns', + $ruleId, + count($inputEntries), + count($inputs) + ); + } + + if (count($outputEntries) !== $outputCount) { + $problems[] = sprintf( + 'rule %s has %d output entries for %d declared outputs', + $ruleId, + count($outputEntries), + $outputCount + ); + } + + if (array_key_exists('priority', $rule) === true && is_int($rule['priority']) === false) { + $problems[] = sprintf('rule %s has a non-integer priority', $ruleId); + } + + foreach ($inputs as $columnPosition => $column) { + if (array_key_exists($columnPosition, $inputEntries) === false) { + continue; + } + + $this->probeCell( + expression: (string)$inputEntries[$columnPosition], + column: $column, + ruleId: $ruleId, + problems: $problems + ); + } + + }//end validateRule() + + /** + * Execute one cell through the evaluator's grammar and record what it + * refused. A boolean answer means the cell is executable; whether the + * probe matched is deliberately ignored. + * + * @param string $expression The raw cell text. + * @param array{name: string, type: string} $column The column it sits under. + * @param string $ruleId The rule's id, for the messages. + * @param array $problems Collected problems, appended to. + * + * @return void + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ + private function probeCell(string $expression, array $column, string $ruleId, array &$problems): void { + try { + $this->evaluator->matches( + expression: $expression, + value: self::PROBES[$column['type']], + type: $column['type'] + ); + } catch (DecisionEvaluationException $e) { + $problems[] = sprintf( + 'rule %s, column "%s": the cell "%s" cannot be executed (%s)', + $ruleId, + $column['name'], + $expression, + $e->getErrorCode() + ); + } + + }//end probeCell() +}//end class diff --git a/lib/Service/Flow/Nodes/DecisionTableNode.php b/lib/Service/Flow/Nodes/DecisionTableNode.php new file mode 100644 index 0000000000..7a894f515d --- /dev/null +++ b/lib/Service/Flow/Nodes/DecisionTableNode.php @@ -0,0 +1,557 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use OCA\OpenRegister\Service\Dmn\DecisionEvaluationException; +use OCA\OpenRegister\Service\Dmn\DecisionTableEvaluator; +use OCA\OpenRegister\Service\Dmn\DecisionTableValidator; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowValueTemplate; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\WorkflowEngine\IManager; +use UnexpectedValueException; + +/** + * Decides each item by its inline decision table. + */ +class DecisionTableNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm { + + /** + * The step type. + * + * @var string + */ + public const NODE_ID = 'openregister.decision-table'; + + /** + * Constructor. + * + * @param DecisionTableEvaluator $engine The shared evaluator; all deciding is delegated to it. + * @param DecisionTableValidator $validator Refuses a table the evaluator could not execute. + * @param IL10N $l10n Translations. + * @param IURLGenerator $urls For the palette icon. + */ + public function __construct( + private readonly DecisionTableEvaluator $engine, + private readonly DecisionTableValidator $validator, + private readonly IL10N $l10n, + private readonly IURLGenerator $urls, + ) { + + }//end __construct() + + /** + * The step type. + * + * @return string The id. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-decision-table-step-evaluates-its-table-against-every-item + */ + public function getId(): string { + return self::NODE_ID; + }//end getId() + + /** + * Palette name. + * + * @return string The display name. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-the-node-describes-its-own-form-and-writes-an-optional-evaluation-record + */ + public function getDisplayName(): string { + return $this->l10n->t('Evaluate a decision table'); + }//end getDisplayName() + + /** + * Palette description. + * + * @return string The description. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-the-node-describes-its-own-form-and-writes-an-optional-evaluation-record + */ + public function getDescription(): string { + return $this->l10n->t('Apply a table of rules to each item and write the outcome onto it.'); + }//end getDescription() + + /** + * Palette icon. + * + * @return string The icon URL. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-the-node-describes-its-own-form-and-writes-an-optional-evaluation-record + */ + public function getIcon(): string { + return $this->urls->imagePath('core', 'actions/checkmark.svg'); + }//end getIcon() + + /** + * Available in both scopes. Deciding over data the run already carries + * grants no privilege; what may be read and written is governed where it + * always is, at the object nodes. + * + * @param int $scope The scope constant. + * + * @return bool Whether it is available. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-decision-table-step-evaluates-its-table-against-every-item + */ + public function isAvailableForScope(int $scope): bool { + return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); + }//end isAvailableForScope() + + /** + * The config vocabulary of a decision-table step. + * + * @return array The accepted config keys. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-the-node-describes-its-own-form-and-writes-an-optional-evaluation-record + */ + public function configKeys(): array { + return ['table', 'inputMapping', 'outputMapping', 'defaultOutputs', 'resultKey']; + }//end configKeys() + + /** + * The fields this node is edited through. + * + * The table itself is edited as JSON for now; the raw pane is the honest + * fallback until the canvas grows a table editor, and the save path + * refuses anything the evaluator could not execute either way. + * + * @return array> The field descriptions. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-the-node-describes-its-own-form-and-writes-an-optional-evaluation-record + */ + public function configForm(): array { + return [ + [ + 'key' => 'table', + 'label' => $this->l10n->t('Decision table'), + 'type' => 'textarea', + 'help' => $this->l10n->t('The table as JSON: hit policy, inputs, outputs and rules. Saving refuses a table the engine cannot run.'), + 'required' => true, + ], + [ + 'key' => 'inputMapping', + 'label' => $this->l10n->t('Input fields'), + 'type' => 'textarea', + 'help' => $this->l10n->t( + 'Where each table input is read from, as input name to field path. An input without an entry reads the field with its own name.' + ), + ], + [ + 'key' => 'outputMapping', + 'label' => $this->l10n->t('Output fields'), + 'type' => 'textarea', + 'help' => $this->l10n->t( + 'Where each table output is written, as output name to field path. An output without an entry writes the field with its own name.' + ), + ], + [ + 'key' => 'defaultOutputs', + 'label' => $this->l10n->t('When no rule matches'), + 'type' => 'textarea', + 'help' => $this->l10n->t('Values to write when no rule matches, one per output. Leave empty to fail the step instead.'), + ], + [ + 'key' => 'resultKey', + 'label' => $this->l10n->t('Record the evaluation under'), + 'type' => 'text', + 'help' => $this->l10n->t('Optional field path for the evaluation record: which rules matched and under which hit policy.'), + ], + ]; + }//end configForm() + + /** + * Refuse a configuration the evaluator could not execute, when the flow + * is saved or imported rather than when a run reaches the step. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When the table or the mappings are refused. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ + public function validateConfig(array $config): void { + $table = ($config['table'] ?? null); + if (is_array($table) === false || $table === []) { + throw new UnexpectedValueException( + $this->l10n->t('A decision-table step needs a table.') + ); + } + + $problems = $this->validator->validate(table: $table); + if ($problems !== []) { + throw new UnexpectedValueException( + $this->l10n->t('The decision table cannot be run: %s.', [implode('; ', $problems)]) + ); + } + + $inputNames = $this->columnNames(columns: (array)($table['inputs'] ?? [])); + $outputNames = $this->columnNames(columns: (array)($table['outputs'] ?? [])); + + $this->assertMapping(config: $config, key: 'inputMapping', declared: $inputNames); + $this->assertMapping(config: $config, key: 'outputMapping', declared: $outputNames); + $this->assertDefaults(config: $config, table: $table, outputNames: $outputNames); + + if (array_key_exists('resultKey', $config) === true) { + $this->assertPath(path: $config['resultKey'], where: 'resultKey'); + } + + }//end validateConfig() + + /** + * Decide every item: map the inputs in, evaluate, map the outputs back. + * + * Deterministic by construction: no I/O, no clock, no state. The same + * item and the same configuration decide the same way every firing, and + * the node never suspends: a rule step completes in the firing that + * reached it. + * + * @param array $items The input items. + * @param array $config The step configuration. + * @param array $context Run-level metadata (unused: the decision reads only the item). + * + * @return array The output items, one per input item. + * + * @throws UnexpectedValueException When the configuration is refused. + * @throws DecisionEvaluationException When an item cannot be decided and no default row is configured. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-the-step-is-deterministic-and-never-suspends + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) `$context` is part of the + * node contract; a deterministic rule step deliberately reads none of it. + * @SuppressWarnings(PHPMD.StaticAccess) `FlowItems::item()` and + * `FlowValueTemplate::render()` are the engine's canonical constructors. + */ + public function execute(array $items, array $config, array $context): array { + if ($items === []) { + return []; + } + + // A flow written by hand or imported past the editor must meet the + // same bar as one saved through it, so the refusals run here too — + // the same belt-and-braces ObjectReadNode wears. + $this->validateConfig(config: $config); + + $table = (array)$config['table']; + $inputMapping = (array)($config['inputMapping'] ?? []); + $outputMapping = (array)($config['outputMapping'] ?? []); + $resultKey = trim((string)($config['resultKey'] ?? '')); + + $out = []; + foreach ($items as $index => $item) { + $json = (array)($item[FlowItems::JSON] ?? []); + + $result = $this->decide(table: $table, config: $config, json: $json, inputMapping: $inputMapping); + + foreach ($this->columnNames(columns: (array)($table['outputs'] ?? [])) as $name) { + self::assign( + json: $json, + path: (string)($outputMapping[$name] ?? $name), + value: ($result['outputs'][$name] ?? null) + ); + } + + if ($resultKey !== '') { + self::assign( + json: $json, + path: $resultKey, + value: [ + 'hitPolicy' => $result['hitPolicy'], + 'matchedRuleIds' => $result['matchedRuleIds'], + 'defaulted' => $result['defaulted'], + 'tableName' => (string)($table['name'] ?? ''), + 'tableKey' => (string)($table['key'] ?? ''), + ] + ); + } + + $out[] = FlowItems::item( + json: $json, + binary: (array)($item[FlowItems::BINARY] ?? []), + fromItemIndex: $index + ); + }//end foreach + + return $out; + }//end execute() + + /** + * Decide one item: build the inputs, run the evaluator, apply the + * author's no-match choice. + * + * @param array $table The table definition. + * @param array $config The step configuration. + * @param array $json The item's record. + * @param array $inputMapping Declared input name to field path. + * + * @return array{outputs: array, matchedRuleIds: array, hitPolicy: string, defaulted: bool} + * + * @throws DecisionEvaluationException When the item cannot be decided and no default row exists. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-no-match-takes-the-authors-explicit-default-or-fails-loudly + * + * @SuppressWarnings(PHPMD.StaticAccess) `FlowValueTemplate::render()` is the + * engine's canonical dotted-path read. + */ + private function decide(array $table, array $config, array $json, array $inputMapping): array { + $inputs = []; + foreach ($this->columnNames(columns: (array)($table['inputs'] ?? [])) as $name) { + $path = (string)($inputMapping[$name] ?? $name); + // A value that is exactly one placeholder keeps its type, so a + // number stays a number on its way into the coercion. + $inputs[$name] = FlowValueTemplate::render(value: ('{{' . $path . '}}'), json: $json); + } + + try { + $result = $this->engine->evaluate(decisionTable: $table, inputs: $inputs); + + return [ + 'outputs' => $result['outputs'], + 'matchedRuleIds' => $result['matchedRuleIds'], + 'hitPolicy' => $result['hitPolicy'], + 'defaulted' => false, + ]; + } catch (DecisionEvaluationException $e) { + $defaults = ($config['defaultOutputs'] ?? null); + if ($e->getErrorCode() !== 'no_rule_matched' || is_array($defaults) === false) { + // Everything else — a missing input, a type mismatch, a hit + // policy violation — is a fault, not a no-match, and the + // step's onError policy is the place that decides what a + // fault costs. Catching it here would report a completed + // decision that never happened. + throw $e; + } + + return [ + 'outputs' => $defaults, + 'matchedRuleIds' => [], + 'hitPolicy' => strtoupper((string)($table['hitPolicy'] ?? 'UNIQUE')), + 'defaulted' => true, + ]; + }//end try + }//end decide() + + /** + * The declared column names, in declaration order. + * + * @param array $columns The declared `inputs` or `outputs`. + * + * @return array The names. + */ + private function columnNames(array $columns): array { + $names = []; + foreach ($columns as $column) { + if (is_array($column) === false) { + continue; + } + + $name = trim((string)($column['name'] ?? '')); + if ($name !== '') { + $names[] = $name; + } + } + + return $names; + }//end columnNames() + + /** + * Refuse a mapping the step would otherwise silently ignore. + * + * A mapping entry for a name the table does not declare is configuration + * that looks like behaviour and is not; a templated path would let item + * data choose the write position on a RULE step, which no migrating + * table needs and which is refused rather than half-supported. + * + * @param array $config The step configuration. + * @param string $key `inputMapping` or `outputMapping`. + * @param array $declared The declared names for that side. + * + * @return void + * + * @throws UnexpectedValueException When the mapping is refused. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ + private function assertMapping(array $config, string $key, array $declared): void { + if (array_key_exists($key, $config) === false) { + return; + } + + if (is_array($config[$key]) === false) { + throw new UnexpectedValueException( + $this->l10n->t('The %s must be an object of name to field path.', [$key]) + ); + } + + foreach ($config[$key] as $name => $path) { + if (in_array((string)$name, $declared, true) === false) { + throw new UnexpectedValueException( + $this->l10n->t('The %1$s names "%2$s", which the table does not declare. The step would silently ignore it.', [$key, (string)$name]) + ); + } + + $this->assertPath(path: $path, where: ($key . ' "' . (string)$name . '"')); + } + + }//end assertMapping() + + /** + * Refuse the author's default row unless it is complete, and refuse it + * entirely on COLLECT. + * + * A partial default writes half a decision and nulls for the rest, which + * is the silent shape this node exists to refuse. On COLLECT the empty + * list is a real answer, so a default would be unreachable configuration + * that looks like behaviour. + * + * @param array $config The step configuration. + * @param array $table The table definition. + * @param array $outputNames The declared output names. + * + * @return void + * + * @throws UnexpectedValueException When the defaults are refused. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-no-match-takes-the-authors-explicit-default-or-fails-loudly + */ + private function assertDefaults(array $config, array $table, array $outputNames): void { + if (array_key_exists('defaultOutputs', $config) === false) { + return; + } + + if (is_array($config['defaultOutputs']) === false) { + throw new UnexpectedValueException( + $this->l10n->t('The defaultOutputs must be an object of output name to value.') + ); + } + + if (strtoupper(trim((string)($table['hitPolicy'] ?? 'UNIQUE'))) === 'COLLECT') { + throw new UnexpectedValueException( + $this->l10n->t('A COLLECT table never fails to match: its empty list is the answer. Remove defaultOutputs.') + ); + } + + foreach ($outputNames as $name) { + if (array_key_exists($name, $config['defaultOutputs']) === false) { + throw new UnexpectedValueException( + $this->l10n->t('The defaultOutputs are missing a value for output "%s". A partial default writes half a decision.', [$name]) + ); + } + } + + foreach (array_keys($config['defaultOutputs']) as $name) { + if (in_array((string)$name, $outputNames, true) === false) { + throw new UnexpectedValueException( + $this->l10n->t('The defaultOutputs name "%s", which the table does not declare. The step would silently ignore it.', [(string)$name]) + ); + } + } + + }//end assertDefaults() + + /** + * Refuse a field path that is empty, not a string, or templated. + * + * @param mixed $path The configured path. + * @param string $where Which configuration entry it came from, for the message. + * + * @return void + * + * @throws UnexpectedValueException When the path is refused. + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-table-the-evaluator-cannot-execute-is-refused-at-save + */ + private function assertPath(mixed $path, string $where): void { + if (is_string($path) === false || trim($path) === '') { + throw new UnexpectedValueException( + $this->l10n->t('The field path for %s is empty. A position is never empty.', [$where]) + ); + } + + if (str_contains($path, '{{') === true) { + throw new UnexpectedValueException( + $this->l10n->t('The field path for %s is templated. A rule step reads and writes the positions its author named, literally.', [$where]) + ); + } + + }//end assertPath() + + /** + * Write a value at a dotted path, creating the containers it needs. + * + * Same semantics as `openregister.set-fields` gives a literal path: the + * structure is a property of the configuration. A segment whose current + * value is not an array is replaced by a container, because merging into + * a scalar has no meaning and skipping silently would be the invisible + * no-op this node refuses everywhere else. + * + * @param array $json The item's record, modified in place. + * @param string $path The field path, optionally dotted. + * @param mixed $value The value to write. + * + * @return void + * + * @spec openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md#requirement-a-decision-table-step-evaluates-its-table-against-every-item + */ + private static function assign(array &$json, string $path, mixed $value): void { + if (str_contains($path, '.') === false) { + $json[$path] = $value; + + return; + } + + $segments = explode('.', $path); + $last = array_pop($segments); + $cursor = &$json; + + foreach ($segments as $segment) { + if (isset($cursor[$segment]) === false || is_array($cursor[$segment]) === false) { + $cursor[$segment] = []; + } + + $cursor = &$cursor[$segment]; + } + + $cursor[$last] = $value; + unset($cursor); + + }//end assign() +}//end class diff --git a/openspec/changes/flow-decision-tables/design.md b/openspec/changes/flow-decision-tables/design.md new file mode 100644 index 0000000000..9d51784233 --- /dev/null +++ b/openspec/changes/flow-decision-tables/design.md @@ -0,0 +1,140 @@ +# Design: flow-decision-tables + +## D-1: The table lives inline in the node config, not behind a reference + +Two storage shapes were on the table: + +1. **Inline**: the full table (`hitPolicy`, `inputs`, `outputs`, `rules`) + sits in the node's `table` config key. +2. **Referenced**: the node holds a register/schema/key triple and fetches + an engine-stored table object at run time — the shape dossiq has today + (`DecisionTableService::findByKey()` over a `decisionTable` schema). + +Inline wins, and the deciding facts are dossiq's own tables: + +- **Size.** dossiq's real tables are small. The LHS enforcement matrix — + the largest real producer, projected by + `LhsMatrixDecisionTableMigrator` — is three string inputs, one string + output and one rule per matrix cell; the register schema caps the shape + at flat positional arrays of short strings. A node config carries that + comfortably. Nothing in dossiq's data needs an out-of-band store. +- **Pinning is the point, and inline gets it for free.** A published flow + is pinned to its definition; a table inside that definition is pinned + with it. A referenced table is live data under a published flow: editing + a rule silently changes what every pinned run does, which is precisely + the mutable-rule-store hazard this migration retires. Making a reference + honour pinning would mean engine-side table versioning, a second + version graph, and a copy-on-publish story — all machinery the data does + not ask for. +- **Import already works.** `x-openregister-flows` imports flow + definitions whole; an inline table arrives inside its step and is + validated on the same save path as every hand-authored one. A reference + would need ordered two-phase import (tables before flows) and a + broken-link story. +- **Validation at save is only possible for a shape the save can see.** + The spec requires refusing a malformed table when the flow is saved. An + inline table is validated then and there. A referenced table can only be + validated at run time, which is the edge-condition defect's shape: an + accepted configuration whose executable content was never checked. + +What is given up, and why that is acceptable: + +- **Reuse across flows** becomes copy-per-node. dossiq's tables are keyed + per decision and invoked from specific transitions; fleet-wide, a + genuinely shared decision is expressed once in one flow and invoked via + `openregister.sub-flow`, which also gives the shared decision one + version history. If a real catalogue need emerges, a `tableFrom` key + reading a table fetched by `openregister.object-read` composes on top of + this node without changing its contract — recorded here so the future + discussion starts from the refusal, not from scratch. +- **Editing rules without republishing** is exactly the property refused + on purpose. Changing a decision rule IS changing the flow; the version + history should say so. + +## D-2: Validation executes the evaluator's grammar; nothing re-implements it + +The lesson written into this repo more than once: an accepted grammar that +is not the executable grammar accepts flows that fail at 03:00. +`DecisionTableValidator` therefore does not parse rule cells itself. For +every cell it calls `UnaryTestEvaluator::matches()` with a probe value of +the column's effective type and treats `invalid_expression` and +`type_mismatch` as authoring errors to report with the rule id and column +name. A cell the evaluator cannot execute cannot be saved; a cell it can +execute needs no second parser to agree. + +Two consequences worth naming: + +- The evaluator's type normalisation (`integer` → `number`, `bool` → + `boolean`, unknown → `string`) is exposed as + `DecisionTableEvaluator::effectiveType()` and used by the validator, so + the two cannot drift on what a column's type means. +- The evaluator's implemented-hit-policy list is exposed as a constant and + the validator refuses anything outside it BY NAME + (`hit policy "X" is not implemented`), loudly, at save. dossiq's five + (UNIQUE, FIRST, PRIORITY, ANY, COLLECT) are all implemented, so no + migrating table hits this; a future sixth spelling fails the save, not + the run. + +Structural checks beyond the grammar, each refusing a silent no-op or a +silent collapse: + +- at least one input, one output, one rule (a table missing any of them + can never decide anything); +- unique input names and unique output names (the evaluator keys results + by name, so a duplicate would silently collapse two columns into one); +- per rule, entry counts equal to the declared input/output counts + (positional alignment is the contract; a short row would silently + wildcard the missing tail); +- `priority`, when present, an integer. + +## D-3: No-match is an explicit choice: a complete default row, or a loud failure + +The evaluator throws `no_rule_matched` for the single-winner policies and +returns empty lists for COLLECT. The node adds exactly one thing on top: +an optional `defaultOutputs` map. When configured, a no-match yields those +outputs (flagged `defaulted: true` in the `resultKey` record); when not, +the exception propagates and the step's `onError` policy decides. Two +refusals keep this honest: + +- `defaultOutputs` must cover EVERY declared output — a partial default + would write half a decision and nulls for the rest, which is the silent + shape this whole node refuses. +- `defaultOutputs` is refused on a COLLECT table: COLLECT's empty list is + a real answer ("nothing applied"), not a failure to answer, so a default + would be unreachable code that looks like behaviour. + +## D-4: The node is a rule step: per item, deterministic, no suspension + +- **Per item, independently.** Inputs are read from each item's record via + `inputMapping` (declared input name → dotted path, same-name default — + dossiq's handler vocabulary, kept verbatim so the migration is a + mechanical rewrite). Outputs are written back per item via + `outputMapping`, same default. One item's failure semantics follow the + engine's `onError` policy like every other node. +- **Deterministic.** The evaluation path does no I/O, reads no clock, and + holds no state. Same item + same config = same outputs. This is asserted + by test, not just by prose. +- **Never suspends.** Rule steps and human steps are different species: + the human half is `openregister.user-task` and decidiq's consumption of + it. This node completes in the firing that reached it. +- **Mapping paths are literal.** A `{{templated}}` mapping path is refused + at save. SetFieldsNode earned its templated positions; here a + data-controlled write position on a RULE step would mean the table's + outputs land somewhere the author never named, and no dossiq table needs + it. Add it later if a table does; refusing now keeps the contract small. +- **A mapping for a name the table does not declare is refused.** It would + be configuration the node silently ignores — the looks-like-it-works + shape `IFlowNodeConfigForm`'s own docblock warns about. + +## D-5: What lands on the item + +- Each declared output's value at its mapped (dotted) path, created + containers included — the same `assign` semantics as + `openregister.set-fields`, minus templated positions. +- Optionally, under `resultKey`, the evaluation record: + `{hitPolicy, matchedRuleIds, defaulted, tableName, tableKey}` — enough + for a downstream Switch to branch on which rule fired and for a run log + reader to see why. Off by default: a node that stamps bookkeeping on + every record unasked pollutes schemas. +- COLLECT writes lists (one entry per matched rule, in declaration order), + matching the shared evaluator's contract unchanged. diff --git a/openspec/changes/flow-decision-tables/proposal.md b/openspec/changes/flow-decision-tables/proposal.md new file mode 100644 index 0000000000..968057a715 --- /dev/null +++ b/openspec/changes/flow-decision-tables/proposal.md @@ -0,0 +1,93 @@ +--- +kind: code +depends_on: [shared-decision-table-evaluator] +--- + +# Proposal: flow-decision-tables + +## Summary + +Put rule evaluation into the graph. A new node type +`openregister.decision-table` evaluates a DMN-style decision table against +each item and writes the winning outputs onto configured item fields. The +table travels inline in the node's configuration, so flow versioning and +pinning apply to the rules the same way they apply to every other authored +step. Evaluation is delegated in full to the shared +`DecisionTableEvaluator`; this node maps item fields in and out, and +refuses a table the evaluator could not execute at save time rather than at +run time. + +## Why + +**The directive is explicit: apps get no flow engines of their own, and +decision tables are flow-engine work.** dossiq ships its own decision-table +storage, lookup and wiring (`EvaluateDecisionHandler`, +`DecisionTableService`, a `decisionTable` register schema and a +`dossiq.evaluateDecision` flow-node wrapper) and is staging their +retirement against this change. The evaluation core already moved here — +`shared-decision-table-evaluator` consolidated dossiq's and openbuild's +engines into `lib/Service/Dmn/` — but nothing in the flow palette can +invoke it. The capability exists and is unreachable from a flow, which is +exactly the orphan shape gate-57 exists to catch. + +**A rule step is not a human step.** The fleet's decision split is: +automated rule evaluation belongs to the engine (this node), human +decisions belong to decidiq (`openregister.user-task` and its consumers). +This node therefore never suspends, never creates a task and never waits. +It is deterministic: the same item and the same configuration produce the +same outputs, every time, with no I/O in the evaluation path. + +**The migration must not strand.** dossiq's tables and handler define the +floor: five hit policies (UNIQUE, FIRST, PRIORITY, ANY, COLLECT — the +shared evaluator already implements all five), positional +`inputEntries`/`outputEntries`, typed inputs (string, number, boolean, +date), an `inputMapping`/`outputMapping` vocabulary with a same-name +default, and loud typed failures. Every one of those is expressible here, +and the test suite proves it with a table translated from dossiq's real +LHS enforcement matrix. + +## What Changes + +- **A new node, `openregister.decision-table`**, in + `lib/Service/Flow/Nodes/`, implementing `IFlowNode`, + `IFlowNodeConfigKeys` and `IFlowNodeConfigForm`, registered through + `lib/Listener/FlowNodeRegistrationListener.php` like every built-in. Its + `configForm()` is served by the existing node catalog; no editor change. +- **The table is inline in the node config** (`table`), so it is versioned + and pinned with the flow and imported through `x-openregister-flows` + with no extra machinery. See design.md D-1 for why a live object + reference was considered and refused. +- **A new engine-owned validator, `DecisionTableValidator`**, in + `lib/Service/Dmn/`, that validates a table by exercising the + evaluator's own grammar against every rule cell. The accepted grammar is + the executable one by construction, not by a parallel re-implementation + that can drift. +- **No-match behaviour is a choice the author makes explicitly**: + `defaultOutputs` supplies a complete fallback row, otherwise a no-match + fails the step loudly and the step's `onError` policy decides. There is + no silent empty result. +- **No new endpoints.** The node plus the existing flow save/import path + is the whole surface. + +## Out of Scope + +- **A referenced, engine-stored table catalogue.** Refused for now, with + the reasoning recorded in design.md D-1. If it returns it composes on + top of this node rather than replacing it. +- **Retiring dossiq's copies.** The parallel dossiq change does that, + pointing at this node; deleting a working evaluation path from here + would couple two repos' release trains. +- **DMN XML interchange and full FEEL.** Both already ruled out by + ADR-065 and `shared-decision-table-evaluator`; nothing here reopens + them. + +## Risks + +- ⚠️ **An inline table is copied per node.** Two nodes using one policy + table hold two copies. Deliberate: a shared mutable table under a + published flow is the bigger hazard (design.md D-1); a genuinely shared + decision lives in one flow invoked via `openregister.sub-flow`. +- ⚠️ **Every declared input must resolve on the item.** The evaluator + refuses a missing input (`type_mismatch`/`missing_input`) rather than + wildcarding it, inherited deliberately from dossiq's handler: a decision + taken over absent data is not a decision. diff --git a/openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md b/openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md new file mode 100644 index 0000000000..70e79e828b --- /dev/null +++ b/openspec/changes/flow-decision-tables/specs/flow-decision-tables/spec.md @@ -0,0 +1,222 @@ +# Spec: flow decision tables + +## Purpose + +One step type that evaluates a decision table inside a flow: rules in the +graph, evaluated by the engine's shared evaluator, with the outputs written +onto the items so downstream nodes can route on them. Rule steps are the +engine's; human decisions stay with the user-task machinery and its +consumers. + +## ADDED Requirements + +### Requirement: A decision-table step evaluates its table against every item + +The system SHALL provide a step node type `openregister.decision-table`. +For each item that reaches it, the node SHALL build the table's declared +inputs from the item's record, evaluate the table through the shared +decision-table evaluator, and write every declared output's value onto the +item's record. + +The node SHALL delegate evaluation in full: hit policies, unary-test +grammar, type coercion and typed errors are the shared evaluator's, and the +node SHALL NOT re-implement or wrap any of them in altered form. + +Input values SHALL be read via `inputMapping` (declared input name to a +dotted path in the item's record) with a same-name default, and outputs +SHALL be written via `outputMapping` with the same default — the vocabulary +dossiq's `evaluateDecision` handler already uses, kept so the migration is +a mechanical rewrite. + +An item that lacks a declared input SHALL fail that item's evaluation with +the evaluator's typed error rather than wildcarding the gap: a decision +over absent data is not a decision. + +A firing with no items SHALL return no items and do nothing else. + +#### Scenario: A dossiq table decides an item + +- **GIVEN** a decision-table step carrying dossiq's LHS enforcement table + (three string inputs, one output, UNIQUE) +- **WHEN** an item carrying `severity`, `behaviour` and `actorType` passes + through +- **THEN** the item's record MUST carry the matched rule's `intervention` +- @e2e exclude engine-internal node behaviour, proven by the + DecisionTableNode unit suite's dossiq fixture + +#### Scenario: Mapped paths read and write nested fields + +- **GIVEN** `inputMapping` pointing an input at `case.severity` and + `outputMapping` pointing an output at `advies.maatregel` +- **WHEN** an item with a nested `case` record passes through +- **THEN** the input MUST be read from the nested path and the output MUST + be written at the nested path, containers created as needed +- @e2e exclude engine-internal mapping semantics, covered by unit tests + +#### Scenario: A missing input fails loudly + +- **GIVEN** a table declaring input `severity` and an item without it +- **WHEN** the step fires +- **THEN** the step MUST fail with the evaluator's typed error +- **AND** the step MUST NOT evaluate the table as if the input were a + wildcard +- @e2e exclude failure-path semantics, covered by unit tests + +### Requirement: The step is deterministic and never suspends + +The node SHALL be a pure function of (item record, step configuration): no +I/O, no clock reads and no stored state in the evaluation path. Evaluating +the same item against the same configuration twice SHALL produce identical +outputs. + +The node SHALL NOT suspend the run, create a task, or wait for anything. +Automated rule evaluation is a rule step; a decision needing a person is a +user task and belongs to that node type and its consumers. + +#### Scenario: The same item decides the same way twice + +- **GIVEN** any valid decision-table step and any item its table accepts +- **WHEN** the step fires twice over an identical item +- **THEN** both firings MUST produce identical records +- @e2e exclude determinism is a unit-level property, asserted by unit tests + +### Requirement: The table travels inline and is pinned with the flow + +The step SHALL carry its decision table inline in the `table` configuration +key. The table is thereby part of the flow definition: versioning, pinning +and `x-openregister-flows` import apply to it exactly as they apply to any +other authored step configuration, with no additional machinery. + +The system SHALL NOT resolve the table from live data at run time. Changing +a decision rule is changing the flow, and the flow's version history SHALL +record it as such. + +#### Scenario: A pinned run keeps the rules it was published with + +- **GIVEN** a published flow whose decision-table step carries version A of + a table +- **WHEN** the draft's table is edited to version B without republishing +- **THEN** runs of the published flow MUST keep evaluating version A +- @e2e exclude follows from definition pinning, which flow-engine already + specifies and tests; the table adds no new pinning surface + +### Requirement: A table the evaluator cannot execute is refused at save + +The node's configuration validation SHALL refuse, when the flow is saved or +imported and with a message naming the offending part: + +- a missing or non-object `table`; +- a hit policy the shared evaluator does not implement, by name; +- a table without at least one input, one output and one rule; +- a duplicate input name or duplicate output name; +- a rule whose `inputEntries` or `outputEntries` count differs from the + declared inputs/outputs count; +- a rule cell the evaluator's own grammar cannot execute. + +Grammar validation SHALL be performed by exercising the shared evaluator +against every rule cell, not by a parallel parser: the accepted grammar +MUST be the executable grammar by construction. + +The validation SHALL also refuse configuration the node would otherwise +silently ignore: an `inputMapping` or `outputMapping` entry naming an +undeclared input/output, a templated (`{{...}}`) mapping path, and a +non-string mapping path. + +#### Scenario: A malformed rule cell cannot be saved + +- **GIVEN** a table whose rule cell reads `[5..` on a number column +- **WHEN** the flow is saved or imported +- **THEN** the save MUST be refused with a message naming the rule and the + column +- @e2e exclude save-path refusal, covered by validator and node unit tests + +#### Scenario: An unimplemented hit policy is refused by name + +- **GIVEN** a table declaring hit policy `OUTPUT ORDER` +- **WHEN** the flow is saved or imported +- **THEN** the save MUST be refused with a message naming `OUTPUT ORDER` + as not implemented +- @e2e exclude save-path refusal, covered by unit tests + +#### Scenario: A mapping over an undeclared name is refused + +- **GIVEN** an `outputMapping` entry for an output the table does not + declare +- **WHEN** the flow is saved +- **THEN** the save MUST be refused: configuration the step ignores looks + like behaviour and is not +- @e2e exclude save-path refusal, covered by unit tests + +### Requirement: Hit policies are the shared evaluator's five, unchanged + +The step SHALL support exactly the hit policies the shared evaluator +implements — UNIQUE, FIRST, PRIORITY, ANY and COLLECT — with the shared +evaluator's semantics, including UNIQUE refusing multiple matches, ANY +refusing disagreeing matches, PRIORITY taking the highest priority with +declaration order breaking ties, and COLLECT returning one list per output. + +#### Scenario: COLLECT writes lists + +- **GIVEN** a COLLECT table where two rules match an item +- **WHEN** the step fires +- **THEN** each mapped output field MUST hold a two-entry list in rule + declaration order +- @e2e exclude policy semantics live in the shared evaluator; the node's + pass-through is unit-tested per policy + +#### Scenario: UNIQUE with two matches fails the step + +- **GIVEN** a UNIQUE table where two rules match an item +- **WHEN** the step fires +- **THEN** the step MUST fail with the evaluator's `hit_policy_violation` +- @e2e exclude failure-path semantics, covered by unit tests + +### Requirement: No-match takes the author's explicit default or fails loudly + +When no rule matches under a single-winner policy, the step SHALL fail with +the evaluator's `no_rule_matched` error unless the configuration carries +`defaultOutputs`, in which case the step SHALL write those values instead +and mark the evaluation record as defaulted. + +`defaultOutputs`, when present, SHALL be refused at save unless it provides +a value for every declared output: a partial default writes half a decision. +It SHALL also be refused on a COLLECT table, whose empty list is an answer +rather than a failure to answer. + +#### Scenario: No match without defaults fails the step + +- **GIVEN** a FIRST table none of whose rules match an item, and no + `defaultOutputs` +- **WHEN** the step fires +- **THEN** the step MUST fail with `no_rule_matched` +- @e2e exclude failure-path semantics, covered by unit tests + +#### Scenario: No match with a complete default row decides the default + +- **GIVEN** the same table with `defaultOutputs` covering every output +- **WHEN** the step fires +- **THEN** the item MUST carry the default values +- **AND** the evaluation record under `resultKey`, when configured, MUST + say `defaulted: true` with no matched rule ids +- @e2e exclude default-path semantics, covered by unit tests + +### Requirement: The node describes its own form and writes an optional evaluation record + +The node SHALL implement the config-form contract so the canvas can edit +it, describing at least the table, both mappings, `defaultOutputs` and +`resultKey`. All palette and form strings SHALL be translated, in sentence +case, without em-dashes. + +When `resultKey` is configured, the node SHALL write the evaluation record +`{hitPolicy, matchedRuleIds, defaulted, tableName, tableKey}` at that +dotted path on each item, so a downstream switch can branch on which rule +fired. Without `resultKey` the node SHALL write only the mapped outputs. + +#### Scenario: The palette serves the form + +- **GIVEN** the node catalog endpoint +- **WHEN** the palette is fetched +- **THEN** `openregister.decision-table` MUST be present with a non-empty + `configForm` +- @e2e exclude catalog plumbing is flow-node-config-forms' tested surface; + the node's form content is unit-tested diff --git a/openspec/changes/flow-decision-tables/tasks.md b/openspec/changes/flow-decision-tables/tasks.md new file mode 100644 index 0000000000..d1ab51967e --- /dev/null +++ b/openspec/changes/flow-decision-tables/tasks.md @@ -0,0 +1,64 @@ +# Tasks: flow-decision-tables + +## 1. The validator + +- [x] 1.1 `lib/Service/Dmn/DecisionTableValidator.php`: structural checks + (hit policy by name against the evaluator's implemented list, at + least one input/output/rule, unique names, positional entry counts, + integer `priority`) plus grammar checks that EXECUTE + `UnaryTestEvaluator::matches()` over every rule cell with a probe of + the column's effective type. Returns a list of problems naming rule + and column; never a boolean. +- [x] 1.2 Expose `DecisionTableEvaluator::effectiveType()` and make the + implemented-hit-policy list public, so validator and evaluator cannot + drift. `normaliseFields()` switches to `effectiveType()`. + +## 2. The node + +- [x] 2.1 `lib/Service/Flow/Nodes/DecisionTableNode.php` implementing + `IFlowNode`, `IFlowNodeConfigKeys` and `IFlowNodeConfigForm`. + `getId()` returns `openregister.decision-table`; scope is admin and + user (reshaping data grants no privilege); EUPL-1.2 header; `@spec` + on every method. +- [x] 2.2 `configKeys()`: `table`, `inputMapping`, `outputMapping`, + `defaultOutputs`, `resultKey`. `configForm()` describes each in the + Conduction voice (sentence case, no em-dashes), l10n throughout. +- [x] 2.3 `validateConfig()`: table through the validator; mappings must be + string maps over declared names only, with literal (untemplated) + paths; `defaultOutputs` complete over the declared outputs and + refused on COLLECT; `resultKey` a non-empty literal path when + present. +- [x] 2.4 `execute()`: per item — inputs via mapping paths (same-name + default, type-preserving dotted reads), evaluate through + `DecisionTableEvaluator`, `no_rule_matched` takes `defaultOutputs` + when configured and rethrows otherwise, outputs written via dotted + assign, optional `resultKey` record, provenance via + `FlowItems::item(fromItemIndex:)`. No suspension anywhere. +- [x] 2.5 Register in `lib/Listener/FlowNodeRegistrationListener.php`. + +## 3. Tests + +- [x] 3.1 `tests/Unit/Service/Dmn/DecisionTableValidatorTest.php`: every + structural refusal, grammar refusals through the evaluator (bad + range, bad operand, bad literal for the column type), type aliases + accepted, a clean table over each of the five policies passing. +- [x] 3.2 `tests/Unit/Service/Flow/Nodes/DecisionTableNodeTest.php`: + evaluation per hit policy (UNIQUE match + violation, FIRST, + PRIORITY with tie, ANY agree + disagree, COLLECT lists + empty), + mapping in and out over nested paths, missing input loud, + no-match default vs loud failure, validation refusals at the node + boundary, determinism (two firings, identical records), empty items, + provenance and binary pass-through. +- [x] 3.3 The dossiq fixture: a table translated from the LHS enforcement + matrix (`LhsMatrixDecisionTableMigrator::tableFor()`'s shape) with + the `inputMapping`/`outputMapping` vocabulary of + `EvaluateDecisionHandler`, proving the migration's expressiveness + end to end. +- [x] 3.4 Complete `@covers`/`@uses` on both test classes. + +## 4. Quality + +- [x] 4.1 Analyzers individually and in the foreground: phpcs, phpstan, + psalm, phpmd (per subdirectory), full Unit slice. +- [x] 4.2 hydra gates `--scope-to-diff`, zero FAIL. +- [x] 4.3 One PR to `development`. diff --git a/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php b/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php index 48ef92d68a..818950707c 100644 --- a/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php +++ b/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php @@ -23,6 +23,7 @@ use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; use OCA\OpenRegister\Service\Flow\IFlowNode; use OCA\OpenRegister\Service\Flow\Nodes\AwaitSignalNode; +use OCA\OpenRegister\Service\Flow\Nodes\DecisionTableNode; use OCA\OpenRegister\Service\Flow\Nodes\EndNode; use OCA\OpenRegister\Service\Flow\Nodes\ExplodeNode; use OCA\OpenRegister\Service\Flow\Nodes\FilterNode; @@ -92,6 +93,7 @@ private function listener(array &$registered): FlowNodeRegistrationListener { triggerManual: $mock(TriggerManualNode::class), userTask: $mock(UserTaskNode::class), portalTask: $mock(PortalTaskNode::class), + decisionTable: $mock(DecisionTableNode::class), ); return $listener; @@ -116,7 +118,7 @@ static function (IFlowNode $node) use (&$registered): void { $listener->handle(new RegisterFlowNodesEvent(registry: $registry)); - $this->assertCount(24, $registered, 'all twenty-four built-ins are registered'); + $this->assertCount(25, $registered, 'all twenty-five built-ins are registered'); $classes = array_map(static fn (IFlowNode $node): string => get_parent_class($node) ?: get_class($node), $registered); foreach ([PortalTaskNode::class, UserTaskNode::class, AwaitSignalNode::class] as $waiter) { $this->assertContains($waiter, $classes, "$waiter is registered"); diff --git a/tests/Unit/Service/Dmn/DecisionTableValidatorTest.php b/tests/Unit/Service/Dmn/DecisionTableValidatorTest.php new file mode 100644 index 0000000000..ca9a6840dd --- /dev/null +++ b/tests/Unit/Service/Dmn/DecisionTableValidatorTest.php @@ -0,0 +1,266 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\Service\Dmn; + +use OCA\OpenRegister\Service\Dmn\DecisionTableEvaluator; +use OCA\OpenRegister\Service\Dmn\DecisionTableValidator; +use PHPUnit\Framework\TestCase; + +/** + * The save-time table validator. + * + * The property under test is single: the accepted grammar is the executable + * grammar. Every refusal here is a run-time failure moved to the editor, and + * every acceptance is backed by the evaluator executing the same cell — + * because the validator PROBES the evaluator rather than parsing anything + * itself, the two cannot disagree. + * + * @covers \OCA\OpenRegister\Service\Dmn\DecisionTableValidator + * @uses \OCA\OpenRegister\Service\Dmn\DecisionTableEvaluator + * @uses \OCA\OpenRegister\Service\Dmn\UnaryTestEvaluator + * @uses \OCA\OpenRegister\Service\Dmn\DecisionEvaluationException + */ +class DecisionTableValidatorTest extends TestCase { + + /** + * The validator under test. + * + * @return DecisionTableValidator The validator. + */ + private function validator(): DecisionTableValidator { + return new DecisionTableValidator(); + + }//end validator() + + /** + * A minimal well-formed table. + * + * @param array $overrides Keys to replace. + * + * @return array The table. + */ + private function table(array $overrides = []): array { + return array_merge( + [ + 'hitPolicy' => 'UNIQUE', + 'inputs' => [['name' => 'severity', 'type' => 'string']], + 'outputs' => [['name' => 'intervention', 'type' => 'string']], + 'rules' => [ + [ + 'id' => 'r1', + 'inputEntries' => ['ernstig'], + 'outputEntries' => ['boete'], + ], + ], + ], + $overrides + ); + + }//end table() + + /** + * A clean table passes under every implemented hit policy. + * + * @return void + */ + public function testCleanTablePassesUnderEveryImplementedPolicy(): void { + foreach (DecisionTableEvaluator::IMPLEMENTED_HIT_POLICIES as $policy) { + $problems = $this->validator()->validate(table: $this->table(['hitPolicy' => $policy])); + $this->assertSame([], $problems, 'policy ' . $policy); + } + + }//end testCleanTablePassesUnderEveryImplementedPolicy() + + /** + * An unimplemented hit policy is refused BY NAME, so the author learns + * which spelling the engine rejected rather than that "something" did. + * + * @return void + */ + public function testUnimplementedHitPolicyIsRefusedByName(): void { + $problems = $this->validator()->validate(table: $this->table(['hitPolicy' => 'OUTPUT ORDER'])); + + $this->assertCount(1, $problems); + $this->assertStringContainsString('OUTPUT ORDER', $problems[0]); + $this->assertStringContainsString('not implemented', $problems[0]); + + }//end testUnimplementedHitPolicyIsRefusedByName() + + /** + * A table without inputs, outputs or rules can never decide anything and + * is refused rather than saved as a silent no-op. + * + * @return void + */ + public function testEmptySidesAndEmptyRulesAreRefused(): void { + $this->assertNotSame([], $this->validator()->validate(table: $this->table(['inputs' => []]))); + $this->assertNotSame([], $this->validator()->validate(table: $this->table(['outputs' => []]))); + $this->assertNotSame([], $this->validator()->validate(table: $this->table(['rules' => []]))); + + }//end testEmptySidesAndEmptyRulesAreRefused() + + /** + * Results are keyed by name, so a duplicate column name would silently + * collapse two columns into one answer. + * + * @return void + */ + public function testDuplicateColumnNamesAreRefused(): void { + $problems = $this->validator()->validate( + table: $this->table( + [ + 'outputs' => [ + ['name' => 'intervention', 'type' => 'string'], + ['name' => 'intervention', 'type' => 'string'], + ], + 'rules' => [ + ['id' => 'r1', 'inputEntries' => ['ernstig'], 'outputEntries' => ['boete', 'boete']], + ], + ] + ) + ); + + $this->assertNotSame([], $problems); + $this->assertStringContainsString('declared twice', $problems[0]); + + }//end testDuplicateColumnNamesAreRefused() + + /** + * A short input row would be read as wildcards for the missing tail; a + * short output row as nulls. Both are refused with the counts named. + * + * @return void + */ + public function testEntryCountMismatchesAreRefused(): void { + $problems = $this->validator()->validate( + table: $this->table( + [ + 'rules' => [ + ['id' => 'kort', 'inputEntries' => [], 'outputEntries' => ['boete']], + ['id' => 'leeg', 'inputEntries' => ['ernstig'], 'outputEntries' => []], + ], + ] + ) + ); + + $this->assertCount(2, $problems); + $this->assertStringContainsString('kort', $problems[0]); + $this->assertStringContainsString('wildcard', $problems[0]); + $this->assertStringContainsString('leeg', $problems[1]); + + }//end testEntryCountMismatchesAreRefused() + + /** + * PRIORITY ranks by an integer; a string that looks like one is an + * authoring error, not a coercion opportunity. + * + * @return void + */ + public function testNonIntegerPriorityIsRefused(): void { + $problems = $this->validator()->validate( + table: $this->table( + [ + 'rules' => [ + ['id' => 'r1', 'inputEntries' => ['ernstig'], 'outputEntries' => ['boete'], 'priority' => 'hoog'], + ], + ] + ) + ); + + $this->assertCount(1, $problems); + $this->assertStringContainsString('non-integer priority', $problems[0]); + + }//end testNonIntegerPriorityIsRefused() + + /** + * Grammar refusals come from the evaluator executing the cell, and the + * problem names the rule and the column so the author can find it. + * + * @return void + */ + public function testMalformedCellsAreRefusedWithRuleAndColumn(): void { + $problems = $this->validator()->validate( + table: $this->table( + [ + 'inputs' => [['name' => 'bedrag', 'type' => 'number']], + 'rules' => [ + ['id' => 'r1', 'inputEntries' => ['[5..'], 'outputEntries' => ['boete']], + ['id' => 'r2', 'inputEntries' => ['>='], 'outputEntries' => ['boete']], + ['id' => 'r3', 'inputEntries' => ['abc'], 'outputEntries' => ['boete']], + ], + ] + ) + ); + + $this->assertCount(3, $problems); + $this->assertStringContainsString('r1', $problems[0]); + $this->assertStringContainsString('bedrag', $problems[0]); + $this->assertStringContainsString('r2', $problems[1]); + // `abc` on a number column is a literal that can never be coerced, so + // the rule could never match anything: an authoring error, not data. + $this->assertStringContainsString('r3', $problems[2]); + $this->assertStringContainsString('type_mismatch', $problems[2]); + + }//end testMalformedCellsAreRefusedWithRuleAndColumn() + + /** + * The fleet's tables spell types in more than one vocabulary; the + * validator honours the evaluator's aliases so a table the evaluator + * would run is not refused over a spelling. + * + * @return void + */ + public function testAliasedAndUnknownTypesFollowTheEvaluator(): void { + $problems = $this->validator()->validate( + table: $this->table( + [ + 'inputs' => [ + ['name' => 'leeftijd', 'type' => 'integer'], + ['name' => 'akkoord', 'type' => 'bool'], + ['name' => 'vrij', 'type' => 'onbekend'], + ], + 'rules' => [ + ['id' => 'r1', 'inputEntries' => ['>=18', 'true', 'x'], 'outputEntries' => ['boete']], + ], + ] + ) + ); + + $this->assertSame([], $problems); + + }//end testAliasedAndUnknownTypesFollowTheEvaluator() + + /** + * Wildcards, quoted literals, ranges, comparisons and sets all execute: + * the grammar the editor accepts is the one dossiq's tables already use. + * + * @return void + */ + public function testTheFullUnaryGrammarIsAccepted(): void { + $problems = $this->validator()->validate( + table: $this->table( + [ + 'inputs' => [ + ['name' => 'soort', 'type' => 'string'], + ['name' => 'bedrag', 'type' => 'number'], + ], + 'rules' => [ + ['id' => 'r1', 'inputEntries' => ['-', '[0..25000]'], 'outputEntries' => ['boete']], + ['id' => 'r2', 'inputEntries' => ['"-"', '> 25000'], 'outputEntries' => ['dwangsom']], + ['id' => 'r3', 'inputEntries' => ['in (bouw, milieu)', '!= 0'], 'outputEntries' => ['waarschuwing']], + ], + ] + ) + ); + + $this->assertSame([], $problems); + + }//end testTheFullUnaryGrammarIsAccepted() +}//end class diff --git a/tests/Unit/Service/Flow/Nodes/DecisionTableNodeTest.php b/tests/Unit/Service/Flow/Nodes/DecisionTableNodeTest.php new file mode 100644 index 0000000000..52b2630a09 --- /dev/null +++ b/tests/Unit/Service/Flow/Nodes/DecisionTableNodeTest.php @@ -0,0 +1,623 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @covers \OCA\OpenRegister\Service\Flow\Nodes\DecisionTableNode + * @uses \OCA\OpenRegister\Service\Dmn\DecisionTableEvaluator + * @uses \OCA\OpenRegister\Service\Dmn\DecisionTableValidator + * @uses \OCA\OpenRegister\Service\Dmn\UnaryTestEvaluator + * @uses \OCA\OpenRegister\Service\Dmn\DecisionEvaluationException + * @uses \OCA\OpenRegister\Service\Flow\FlowItems + * @uses \OCA\OpenRegister\Service\Flow\FlowValueTemplate + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Nodes; + +use OCA\OpenRegister\Service\Dmn\DecisionEvaluationException; +use OCA\OpenRegister\Service\Dmn\DecisionTableEvaluator; +use OCA\OpenRegister\Service\Dmn\DecisionTableValidator; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\Nodes\DecisionTableNode; +use OCP\IL10N; +use OCP\IURLGenerator; +use PHPUnit\Framework\TestCase; +use UnexpectedValueException; + +/** + * DecisionTableNode behaviour. + */ +class DecisionTableNodeTest extends TestCase { + + /** + * The node under test. + * + * @var DecisionTableNode + */ + private DecisionTableNode $node; + + /** + * Build the node. The evaluator and validator are REAL, deliberately: a + * mocked evaluator here would be the fake-agrees-with-the-caller shape, + * and the whole point of the fixture is that the shared engine itself + * decides dossiq's table. + * + * @return void + */ + protected function setUp(): void { + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static fn (string $text, array $params = []): string => vsprintf($text, $params) + ); + + $urls = $this->createMock(IURLGenerator::class); + $urls->method('imagePath')->willReturn('/icon.svg'); + + $this->node = new DecisionTableNode( + engine: new DecisionTableEvaluator(), + validator: new DecisionTableValidator(), + l10n: $l10n, + urls: $urls + ); + + }//end setUp() + + /** + * dossiq's LHS enforcement matrix, in the shape + * `LhsMatrixDecisionTableMigrator::tableFor()` produces: three string + * inputs, one output, UNIQUE, one rule per matrix cell. + * + * @return array The table. + */ + private function lhsTable(): array { + return [ + 'name' => 'LHS', + 'key' => 'lhs-matrix-1', + 'hitPolicy' => 'UNIQUE', + 'inputs' => [ + ['name' => 'severity', 'type' => 'string'], + ['name' => 'behaviour', 'type' => 'string'], + ['name' => 'actorType', 'type' => 'string'], + ], + 'outputs' => [['name' => 'intervention', 'type' => 'string']], + 'rules' => [ + [ + 'id' => 'ernstig:opzettelijk:bedrijf', + 'inputEntries' => ['ernstig', 'opzettelijk', 'bedrijf'], + 'outputEntries' => ['bestuurlijke boete'], + ], + [ + 'id' => 'ernstig:onverschillig:bedrijf', + 'inputEntries' => ['ernstig', 'onverschillig', 'bedrijf'], + 'outputEntries' => ['last onder dwangsom'], + ], + [ + 'id' => 'licht:goedwillend:burger', + 'inputEntries' => ['licht', 'goedwillend', 'burger'], + 'outputEntries' => ['waarschuwing'], + ], + ], + ]; + + }//end lhsTable() + + /** + * A one-input, one-output table for the policy tests. + * + * @param string $hitPolicy The hit policy. + * @param array> $rules The rules. + * + * @return array The table. + */ + private function table(string $hitPolicy, array $rules): array { + return [ + 'hitPolicy' => $hitPolicy, + 'inputs' => [['name' => 'severity', 'type' => 'string']], + 'outputs' => [['name' => 'intervention', 'type' => 'string']], + 'rules' => $rules, + ]; + + }//end table() + + /** + * One flow item. + * + * @param array $json The record. + * + * @return array The item. + */ + private function item(array $json): array { + return FlowItems::item(json: $json); + + }//end item() + + /** + * The dossiq proof: the real LHS table, driven through dossiq's own + * mapping vocabulary, decided by the shared engine inside the node. + * + * @return void + */ + public function testDossiqLhsTableDecidesAnItem(): void { + $config = [ + 'table' => $this->lhsTable(), + 'inputMapping' => [ + 'severity' => 'case.severity', + 'behaviour' => 'case.behaviour', + 'actorType' => 'case.actorType', + ], + 'outputMapping' => ['intervention' => 'advies.maatregel'], + 'resultKey' => 'advies.evaluatie', + ]; + + $out = $this->node->execute( + items: [$this->item(['case' => ['severity' => 'ernstig', 'behaviour' => 'opzettelijk', 'actorType' => 'bedrijf']])], + config: $config, + context: [] + ); + + $this->assertCount(1, $out); + $json = $out[0][FlowItems::JSON]; + $this->assertSame('bestuurlijke boete', $json['advies']['maatregel']); + $this->assertSame(['ernstig:opzettelijk:bedrijf'], $json['advies']['evaluatie']['matchedRuleIds']); + $this->assertSame('UNIQUE', $json['advies']['evaluatie']['hitPolicy']); + $this->assertFalse($json['advies']['evaluatie']['defaulted']); + $this->assertSame('lhs-matrix-1', $json['advies']['evaluatie']['tableKey']); + + }//end testDossiqLhsTableDecidesAnItem() + + /** + * Without mappings, every input reads and every output writes the field + * carrying its own name — dossiq's same-name default, kept verbatim. + * + * @return void + */ + public function testSameNameDefaultsMapInputsAndOutputs(): void { + $out = $this->node->execute( + items: [$this->item(['severity' => 'licht', 'behaviour' => 'goedwillend', 'actorType' => 'burger'])], + config: ['table' => $this->lhsTable()], + context: [] + ); + + $this->assertSame('waarschuwing', $out[0][FlowItems::JSON]['intervention']); + + }//end testSameNameDefaultsMapInputsAndOutputs() + + /** + * The typed unary grammar decides typed item fields: a number read from + * the item stays a number on its way into a range cell. + * + * @return void + */ + public function testTypedExpressionsDecideTypedInputs(): void { + $table = [ + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'leeftijd', 'type' => 'number']], + 'outputs' => [['name' => 'categorie', 'type' => 'string']], + 'rules' => [ + ['id' => 'jong', 'inputEntries' => ['< 18'], 'outputEntries' => ['minderjarig']], + ['id' => 'werk', 'inputEntries' => ['[18..67)'], 'outputEntries' => ['volwassen']], + ['id' => 'oud', 'inputEntries' => ['>= 67'], 'outputEntries' => ['gepensioneerd']], + ], + ]; + + $out = $this->node->execute( + items: [$this->item(['leeftijd' => 42])], + config: ['table' => $table], + context: [] + ); + + $this->assertSame('volwassen', $out[0][FlowItems::JSON]['categorie']); + + }//end testTypedExpressionsDecideTypedInputs() + + /** + * UNIQUE with two matching rules is the evaluator's refusal, passed + * through untouched so the step's onError policy sees the real fault. + * + * @return void + */ + public function testUniqueViolationFailsTheStep(): void { + $table = $this->table( + 'UNIQUE', + [ + ['id' => 'r1', 'inputEntries' => ['-'], 'outputEntries' => ['boete']], + ['id' => 'r2', 'inputEntries' => ['-'], 'outputEntries' => ['dwangsom']], + ] + ); + + $this->expectException(DecisionEvaluationException::class); + $this->expectExceptionMessage('hit_policy_violation'); + + $this->node->execute(items: [$this->item(['severity' => 'x'])], config: ['table' => $table], context: []); + + }//end testUniqueViolationFailsTheStep() + + /** + * FIRST takes declaration order. + * + * @return void + */ + public function testFirstTakesDeclarationOrder(): void { + $table = $this->table( + 'FIRST', + [ + ['id' => 'r1', 'inputEntries' => ['-'], 'outputEntries' => ['eerste']], + ['id' => 'r2', 'inputEntries' => ['-'], 'outputEntries' => ['tweede']], + ] + ); + + $out = $this->node->execute(items: [$this->item(['severity' => 'x'])], config: ['table' => $table], context: []); + + $this->assertSame('eerste', $out[0][FlowItems::JSON]['intervention']); + + }//end testFirstTakesDeclarationOrder() + + /** + * PRIORITY takes the highest priority; an equal priority leaves the + * earlier rule in place, so the outcome is deterministic. + * + * @return void + */ + public function testPriorityTakesHighestAndTiesBreakByOrder(): void { + $table = $this->table( + 'PRIORITY', + [ + ['id' => 'laag', 'inputEntries' => ['-'], 'outputEntries' => ['laag'], 'priority' => 1], + ['id' => 'hoog', 'inputEntries' => ['-'], 'outputEntries' => ['hoog'], 'priority' => 5], + ['id' => 'gelijk', 'inputEntries' => ['-'], 'outputEntries' => ['gelijk'], 'priority' => 5], + ] + ); + + $out = $this->node->execute(items: [$this->item(['severity' => 'x'])], config: ['table' => $table], context: []); + + $this->assertSame('hoog', $out[0][FlowItems::JSON]['intervention']); + + }//end testPriorityTakesHighestAndTiesBreakByOrder() + + /** + * ANY passes when every matching rule agrees and fails when they differ: + * a disagreement is a fault in the table, not a choice to make silently. + * + * @return void + */ + public function testAnyAgreesOrFails(): void { + $agreeing = $this->table( + 'ANY', + [ + ['id' => 'r1', 'inputEntries' => ['-'], 'outputEntries' => ['boete']], + ['id' => 'r2', 'inputEntries' => ['-'], 'outputEntries' => ['boete']], + ] + ); + + $out = $this->node->execute(items: [$this->item(['severity' => 'x'])], config: ['table' => $agreeing], context: []); + $this->assertSame('boete', $out[0][FlowItems::JSON]['intervention']); + + $disagreeing = $this->table( + 'ANY', + [ + ['id' => 'r1', 'inputEntries' => ['-'], 'outputEntries' => ['boete']], + ['id' => 'r2', 'inputEntries' => ['-'], 'outputEntries' => ['dwangsom']], + ] + ); + + $this->expectException(DecisionEvaluationException::class); + $this->node->execute(items: [$this->item(['severity' => 'x'])], config: ['table' => $disagreeing], context: []); + + }//end testAnyAgreesOrFails() + + /** + * COLLECT writes one list per output, in declaration order, and an empty + * match writes empty lists rather than failing: "nothing applied" is an + * answer. + * + * @return void + */ + public function testCollectWritesListsAndEmptyMatchIsAnAnswer(): void { + $table = $this->table( + 'COLLECT', + [ + ['id' => 'r1', 'inputEntries' => ['ernstig'], 'outputEntries' => ['boete']], + ['id' => 'r2', 'inputEntries' => ['in (ernstig, matig)'], 'outputEntries' => ['dwangsom']], + ] + ); + + $out = $this->node->execute(items: [$this->item(['severity' => 'ernstig'])], config: ['table' => $table], context: []); + $this->assertSame(['boete', 'dwangsom'], $out[0][FlowItems::JSON]['intervention']); + + $out = $this->node->execute(items: [$this->item(['severity' => 'anders'])], config: ['table' => $table], context: []); + $this->assertSame([], $out[0][FlowItems::JSON]['intervention']); + + }//end testCollectWritesListsAndEmptyMatchIsAnAnswer() + + /** + * No match without a default row is a loud failure for the onError + * policy, never a silent empty result. + * + * @return void + */ + public function testNoMatchWithoutDefaultsFailsLoudly(): void { + $table = $this->table( + 'FIRST', + [['id' => 'r1', 'inputEntries' => ['ernstig'], 'outputEntries' => ['boete']]] + ); + + $this->expectException(DecisionEvaluationException::class); + $this->expectExceptionMessage('no_rule_matched'); + + $this->node->execute(items: [$this->item(['severity' => 'anders'])], config: ['table' => $table], context: []); + + }//end testNoMatchWithoutDefaultsFailsLoudly() + + /** + * No match with a complete default row decides the default, and the + * evaluation record says so: defaulted, with no matched rule ids. + * + * @return void + */ + public function testNoMatchWithDefaultsDecidesTheDefault(): void { + $table = $this->table( + 'FIRST', + [['id' => 'r1', 'inputEntries' => ['ernstig'], 'outputEntries' => ['boete']]] + ); + + $out = $this->node->execute( + items: [$this->item(['severity' => 'anders'])], + config: [ + 'table' => $table, + 'defaultOutputs' => ['intervention' => 'geen actie'], + 'resultKey' => 'evaluatie', + ], + context: [] + ); + + $json = $out[0][FlowItems::JSON]; + $this->assertSame('geen actie', $json['intervention']); + $this->assertTrue($json['evaluatie']['defaulted']); + $this->assertSame([], $json['evaluatie']['matchedRuleIds']); + + }//end testNoMatchWithDefaultsDecidesTheDefault() + + /** + * A missing input fails with the evaluator's typed error rather than + * being wildcarded: a decision over absent data is not a decision. The + * default row does NOT catch this — it answers "no rule matched", not + * "the question could not be asked". + * + * @return void + */ + public function testMissingInputFailsLoudlyEvenWithDefaults(): void { + $this->expectException(DecisionEvaluationException::class); + + $this->node->execute( + items: [$this->item(['behaviour' => 'opzettelijk', 'actorType' => 'bedrijf'])], + config: [ + 'table' => $this->lhsTable(), + 'defaultOutputs' => ['intervention' => 'geen actie'], + ], + context: [] + ); + + }//end testMissingInputFailsLoudlyEvenWithDefaults() + + /** + * Determinism: the same item against the same configuration decides the + * same way, and the second firing's records are identical to the first's. + * + * @return void + */ + public function testTheSameItemDecidesTheSameWayTwice(): void { + $config = ['table' => $this->lhsTable(), 'resultKey' => 'evaluatie']; + $items = [$this->item(['severity' => 'ernstig', 'behaviour' => 'onverschillig', 'actorType' => 'bedrijf'])]; + + $first = $this->node->execute(items: $items, config: $config, context: []); + $second = $this->node->execute(items: $items, config: $config, context: []); + + $this->assertSame($first, $second); + $this->assertSame('last onder dwangsom', $first[0][FlowItems::JSON]['intervention']); + + }//end testTheSameItemDecidesTheSameWayTwice() + + /** + * Items are decided independently, provenance points each output item at + * its input, and binaries ride along untouched. + * + * @return void + */ + public function testItemsAreIndependentAndProvenanceIsKept(): void { + $out = $this->node->execute( + items: [ + FlowItems::item(json: ['severity' => 'licht', 'behaviour' => 'goedwillend', 'actorType' => 'burger'], binary: ['scan' => 'blob']), + FlowItems::item(json: ['severity' => 'ernstig', 'behaviour' => 'opzettelijk', 'actorType' => 'bedrijf']), + ], + config: ['table' => $this->lhsTable()], + context: [] + ); + + $this->assertCount(2, $out); + $this->assertSame('waarschuwing', $out[0][FlowItems::JSON]['intervention']); + $this->assertSame('bestuurlijke boete', $out[1][FlowItems::JSON]['intervention']); + $this->assertSame(['scan' => 'blob'], $out[0][FlowItems::BINARY]); + $this->assertSame(['item' => 0], $out[0][FlowItems::PAIRED_ITEM]); + $this->assertSame(['item' => 1], $out[1][FlowItems::PAIRED_ITEM]); + + }//end testItemsAreIndependentAndProvenanceIsKept() + + /** + * An empty firing returns nothing and touches nothing. + * + * @return void + */ + public function testEmptyItemsReturnEmpty(): void { + $this->assertSame([], $this->node->execute(items: [], config: [], context: [])); + + }//end testEmptyItemsReturnEmpty() + + /** + * The save-path refusals, one per silent failure they close off. + * + * @return void + */ + public function testMissingTableIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->node->validateConfig(config: []); + + }//end testMissingTableIsRefused() + + /** + * An unimplemented hit policy is refused at save, by name. + * + * @return void + */ + public function testUnimplementedHitPolicyIsRefusedAtSave(): void { + $table = $this->lhsTable(); + $table['hitPolicy'] = 'RULE ORDER'; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('RULE ORDER'); + + $this->node->validateConfig(config: ['table' => $table]); + + }//end testUnimplementedHitPolicyIsRefusedAtSave() + + /** + * A malformed rule cell cannot be saved; the refusal names the rule and + * the column. + * + * @return void + */ + public function testMalformedCellIsRefusedAtSave(): void { + // `>=` with no operand is malformed on any column type; the string + // column cases that ARE executable (a bare `[5..` is a literal on a + // string column) belong to the validator's own suite. + $table = $this->lhsTable(); + $table['rules'][0]['inputEntries'][0] = '>='; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('severity'); + + $this->node->validateConfig(config: ['table' => $table]); + + }//end testMalformedCellIsRefusedAtSave() + + /** + * A mapping naming something the table does not declare is refused: the + * step would silently ignore it, which looks like behaviour and is not. + * + * @return void + */ + public function testMappingOverUndeclaredNameIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('spookveld'); + + $this->node->validateConfig( + config: [ + 'table' => $this->lhsTable(), + 'outputMapping' => ['spookveld' => 'ergens'], + ] + ); + + }//end testMappingOverUndeclaredNameIsRefused() + + /** + * A templated mapping path is refused: a rule step's positions are the + * author's, literally, never the data's. + * + * @return void + */ + public function testTemplatedMappingPathIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('templated'); + + $this->node->validateConfig( + config: [ + 'table' => $this->lhsTable(), + 'outputMapping' => ['intervention' => 'advies.{{veld}}'], + ] + ); + + }//end testTemplatedMappingPathIsRefused() + + /** + * A default row missing a declared output is refused: a partial default + * writes half a decision. + * + * @return void + */ + public function testPartialDefaultRowIsRefused(): void { + $table = $this->lhsTable(); + $table['outputs'][] = ['name' => 'termijn', 'type' => 'string']; + foreach ($table['rules'] as $index => $rule) { + $table['rules'][$index]['outputEntries'][] = '6 weken'; + } + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('termijn'); + + $this->node->validateConfig( + config: [ + 'table' => $table, + 'defaultOutputs' => ['intervention' => 'geen actie'], + ] + ); + + }//end testPartialDefaultRowIsRefused() + + /** + * A default row on COLLECT is refused: the empty list is the answer. + * + * @return void + */ + public function testDefaultsOnCollectAreRefused(): void { + $table = $this->lhsTable(); + $table['hitPolicy'] = 'COLLECT'; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('COLLECT'); + + $this->node->validateConfig( + config: [ + 'table' => $table, + 'defaultOutputs' => ['intervention' => 'geen actie'], + ] + ); + + }//end testDefaultsOnCollectAreRefused() + + /** + * The palette contract: the id, and a form field for every configurable + * key so the canvas never edits a key the node ignores. + * + * @return void + */ + public function testPaletteAndFormCoverTheVocabulary(): void { + $this->assertSame('openregister.decision-table', $this->node->getId()); + $this->assertNotSame('', $this->node->getDisplayName()); + $this->assertNotSame('', $this->node->getDescription()); + + $formKeys = array_map(static fn (array $field): string => (string)$field['key'], $this->node->configForm()); + $this->assertSame($this->node->configKeys(), $formKeys); + + }//end testPaletteAndFormCoverTheVocabulary() +}//end class From 24a751687204601e3826d5428905bb1f580bedfc Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 13:10:48 +0200 Subject: [PATCH 48/68] feat(flow): the engine owns the two seams every consumer was rebuilding (#3332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audit gaps closed, both of the shape 'the app must remember a rule the engine already has': - FlowRunSignalService::signalAs()/signalRunAs(): the guarded server-side signal verb. Applies the recorded-assignee rule (group resolution included) through the existing FlowRunAssignee, audits a refusal, and delivers via the unguarded primitive, which is now documented as engine-internal. The HTTP resume endpoints delegate to the same seam, so exactly one guard exists; their responses are byte-identical. A caller may address the node its answer belongs to — addressing narrows the check to that node's own assignee and can never loosen it. - RegistryStepDispatcher executes every CONTRIBUTED node inside the run's runAs identity (FlowRunAsScope: exists + enabled validated, refused loudly otherwise, narrowing via ObjectService::runAs()). The engine's own nodes keep scoping themselves; IFlowSelfScopedNode is the documented escape hatch. The context key ships as FlowRunService::RUN_AS_CONTEXT_KEY and the in-tree literal reads move onto it. openspec change: flow-engine-consumer-seams (the request-scoped trigger and the shared retry-policy vocabulary are named there as follow-ups). --- lib/AppInfo/Application.php | 6 +- lib/Controller/FlowRunController.php | 129 ++++---- lib/Exception/FlowSignalRefused.php | 102 ++++++ lib/Service/Flow/FlowMessagingService.php | 2 +- lib/Service/Flow/FlowRunAsScope.php | 115 +++++++ lib/Service/Flow/FlowRunAssignee.php | 29 +- lib/Service/Flow/FlowRunService.php | 83 ++++- lib/Service/Flow/FlowRunSignalService.php | 221 +++++++++++++ lib/Service/Flow/IFlowSelfScopedNode.php | 53 +++ lib/Service/Flow/Nodes/ObjectReadNode.php | 3 +- lib/Service/Flow/Nodes/ObjectWriteNode.php | 3 +- lib/Service/Flow/RegistryStepDispatcher.php | 61 +++- .../flow-engine-consumer-seams/.openspec.yaml | 2 + .../flow-engine-consumer-seams/design.md | 134 ++++++++ .../flow-engine-consumer-seams/proposal.md | 92 ++++++ .../specs/flow-engine-consumer-seams/spec.md | 120 +++++++ .../flow-engine-consumer-seams/tasks.md | 57 ++++ .../Unit/Service/Flow/FlowRunAsScopeTest.php | 159 +++++++++ .../Unit/Service/Flow/FlowRunAssigneeTest.php | 65 ++++ .../Service/Flow/FlowRunSignalServiceTest.php | 260 +++++++++++++++ .../Flow/RegistryStepDispatcherRunAsTest.php | 307 ++++++++++++++++++ 21 files changed, 1924 insertions(+), 79 deletions(-) create mode 100644 lib/Exception/FlowSignalRefused.php create mode 100644 lib/Service/Flow/FlowRunAsScope.php create mode 100644 lib/Service/Flow/FlowRunSignalService.php create mode 100644 lib/Service/Flow/IFlowSelfScopedNode.php create mode 100644 openspec/changes/flow-engine-consumer-seams/.openspec.yaml create mode 100644 openspec/changes/flow-engine-consumer-seams/design.md create mode 100644 openspec/changes/flow-engine-consumer-seams/proposal.md create mode 100644 openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md create mode 100644 openspec/changes/flow-engine-consumer-seams/tasks.md create mode 100644 tests/Unit/Service/Flow/FlowRunAsScopeTest.php create mode 100644 tests/Unit/Service/Flow/FlowRunSignalServiceTest.php create mode 100644 tests/Unit/Service/Flow/RegistryStepDispatcherRunAsTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bb3691964c..bcb3f795d3 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -526,7 +526,11 @@ function () { \OCA\OpenRegister\Service\Flow\FlowStepDispatcher::class, static function ($c) { return new RegistryStepDispatcher( - registry: $c->get(\OCA\OpenRegister\Service\Flow\FlowNodeRegistry::class) + registry: $c->get(\OCA\OpenRegister\Service\Flow\FlowNodeRegistry::class), + // The acting-identity scope IS container state, unlike the + // guard: a loop's body steps must scope a contributed node + // to the run's runAs exactly as top-level steps do. + scope: $c->get(\OCA\OpenRegister\Service\Flow\FlowRunAsScope::class) ); } ); diff --git a/lib/Controller/FlowRunController.php b/lib/Controller/FlowRunController.php index e03d29aa56..acaea69b1d 100644 --- a/lib/Controller/FlowRunController.php +++ b/lib/Controller/FlowRunController.php @@ -35,8 +35,9 @@ use OCA\OpenRegister\Service\Flow\FlowDeadEnd; use OCA\OpenRegister\Service\Flow\FlowLifecycleRefused; use OCA\OpenRegister\Service\Flow\FlowLocator; -use OCA\OpenRegister\Service\Flow\FlowRunAssignee; +use OCA\OpenRegister\Exception\FlowSignalRefused; use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\FlowRunSignalService; use OCA\OpenRegister\Service\Flow\FlowService; use OCA\OpenRegister\Service\OrganisationService; use OCP\AppFramework\Controller; @@ -97,6 +98,14 @@ class FlowRunController extends Controller { * caller; absent, the endpoint * reports the surface unavailable * rather than an empty run. + * @param FlowRunSignalService|null $signalService The guarded signal seam the + * resume endpoints delegate to. + * Nullable and appended so no + * construction site shifts; + * absent, one is built on + * demand from this + * controller's own + * collaborators. */ public function __construct( string $appName, @@ -112,6 +121,7 @@ public function __construct( // inserted anywhere else shifts every positional caller, and the // resulting TypeError names the argument AFTER the one that moved. private readonly ?AuditFlowAttribution $auditTrails = null, + private readonly ?FlowRunSignalService $signalService = null, ) { parent::__construct(appName: $appName, request: $request); @@ -681,21 +691,16 @@ public function resume(string $uuid): JSONResponse { return $refusal; } - $refusal = $this->refuseUnlessAssignee(run: $run); - if ($refusal !== null) { - return $refusal; - } - $payload = $this->request->getParams(); // Routing artefacts, not part of what the signaller is telling the run. unset($payload['uuid'], $payload['_route']); - $signalled = $this->runner->signal(run: $run, payload: $payload); - if ($signalled === null) { - return new JSONResponse( - ['error' => 'Only a suspended run can be resumed; this one is ' . $run->getStatus() . '.'], - Http::STATUS_CONFLICT - ); + // The guard and the delivery are ONE seam — the same one a PHP consumer + // calls — so who may answer is decided in exactly one place. + try { + $signalled = $this->signals()->signalRunAs(run: $run, payload: $payload, actorUid: $this->callerUid()); + } catch (FlowSignalRefused $refused) { + return $this->refusalResponse(refused: $refused, run: $run, verb: 'resumed'); } return new JSONResponse($signalled->jsonSerialize()); @@ -762,21 +767,15 @@ public function signalByKey(string $key): JSONResponse { return $refusal; } - $refusal = $this->refuseUnlessAssignee(run: $run); - if ($refusal !== null) { - return $refusal; - } - $payload = $this->request->getParams(); // Routing artefacts, not part of what the signaller is telling the run. unset($payload['key'], $payload['_route']); - $signalled = $this->runner->signal(run: $run, payload: $payload); - if ($signalled === null) { - return new JSONResponse( - ['error' => 'Only a suspended run can be signalled; this one is ' . $run->getStatus() . '.'], - Http::STATUS_CONFLICT - ); + // Same seam as resume() and as every PHP consumer: one guard. + try { + $signalled = $this->signals()->signalRunAs(run: $run, payload: $payload, actorUid: $this->callerUid()); + } catch (FlowSignalRefused $refused) { + return $this->refusalResponse(refused: $refused, run: $run, verb: 'signalled'); } return new JSONResponse($signalled->jsonSerialize()); @@ -823,70 +822,66 @@ private function refuseUnlessRunnable(string $flowId): ?JSONResponse { }//end refuseUnlessRunnable() /** - * Refuse when the awaiting step names an assignee and the caller is not it. + * Translate the seam's typed refusal into this endpoint's HTTP contract. + * + * The GUARD lives in {@see FlowRunSignalService} — the same seam a PHP + * consumer calls, so who may answer is decided in one place (ADR-098 named + * the gap this closes: "no task authz — anyone reaching the resume endpoint + * can decide"). What stays here is only the WORDING, which differs per + * endpoint and is part of the existing HTTP contract. * - * WHY THIS EXISTS. `AwaitSignalNode` has always RECORDED an `assignee` on - * the suspension, and nothing ever read it back. The run-level check above - * asks "may you run this flow?", which is a different question from "is - * this decision yours to make": everyone who could run the flow could - * approve a step assigned to someone else, and the recorded assignee made - * it look otherwise. ADR-098 names this gap — "no task authz — anyone - * reaching the resume endpoint can decide". + * A step with no assignee is unchanged — silence still means anyone, + * because tightening that would break every existing webhook and child-run + * signal, which are not human decisions at all. * - * Scope, stated honestly: this closes the WHO of an already-recorded - * assignment. It is not the task entity, inbox or definition versioning - * ADR-098 describes, and a step with no assignee is unchanged — silence - * still means anyone, because tightening that would break every existing - * webhook and child-run signal, which are not human decisions at all. + * @param FlowSignalRefused $refused The seam's refusal. + * @param FlowRun $run The run the signal addressed. + * @param string $verb This endpoint's verb for the 409 wording ('resumed' or 'signalled'). * - * @param FlowRun $run The suspended run. + * @return JSONResponse The 403 or 409 the refusal maps to. * - * @return JSONResponse|null A 403 when the caller is not the assignee. + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-server-side-signal-passes-the-same-guard-as-the-http-resume */ - private function refuseUnlessAssignee(FlowRun $run): ?JSONResponse { - // The rule itself lives in FlowRunAssignee, because HTTP is no longer - // the only way to answer a step: a leaf app whose object completes a - // task resumes the run in-process through FlowRunService::signal(), - // which never passes this controller. One implementation, so the two - // paths cannot drift into disagreeing about who may answer. - $assignee = $this->assignees()->recordedFor(run: $run); - if ($assignee === '') { - return null; - } - - $uid = $this->callerUid(); - - if ($this->assignees()->mayAnswer(run: $run, uid: $uid) === true) { - return null; - } + private function refusalResponse(FlowSignalRefused $refused, FlowRun $run, string $verb): JSONResponse { + if ($refused->getReason() === FlowSignalRefused::NOT_ASSIGNEE) { + if ($refused->getActorUid() === null) { + return new JSONResponse( + ['error' => 'This step is assigned; sign in as the assignee to answer it.'], + Http::STATUS_FORBIDDEN + ); + } - if ($uid === null) { return new JSONResponse( - ['error' => 'This step is assigned; sign in as the assignee to answer it.'], + ['error' => 'This step is assigned to someone else.'], Http::STATUS_FORBIDDEN ); } return new JSONResponse( - ['error' => 'This step is assigned to someone else.'], - Http::STATUS_FORBIDDEN + ['error' => 'Only a suspended run can be ' . $verb . '; this one is ' . $run->getStatus() . '.'], + Http::STATUS_CONFLICT ); - }//end refuseUnlessAssignee() + }//end refusalResponse() /** - * The assignee rule, made on demand when none was injected. + * The guarded signal seam, made on demand when none was injected. * * Built locally rather than required as a constructor argument so adding it - * breaks no existing construction site; it holds no state, so a locally - * made one is indistinguishable from an injected one. + * breaks no existing construction site. The refusal a locally-built seam + * cannot log still reaches the caller as the 403 above, so nothing is + * silent; the container-built instance audits too. * - * @return FlowRunAssignee The rule. + * @return FlowRunSignalService The seam. * - * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-server-side-signal-passes-the-same-guard-as-the-http-resume */ - private function assignees(): FlowRunAssignee { - return new FlowRunAssignee(groupManager: $this->groupManager); - }//end assignees() + private function signals(): FlowRunSignalService { + return ($this->signalService ?? new FlowRunSignalService( + mapper: $this->mapper, + runner: $this->runner, + groupManager: $this->groupManager + )); + }//end signals() /** * The current caller's uid, or null when anonymous. diff --git a/lib/Exception/FlowSignalRefused.php b/lib/Exception/FlowSignalRefused.php new file mode 100644 index 0000000000..c4cde9081a --- /dev/null +++ b/lib/Exception/FlowSignalRefused.php @@ -0,0 +1,102 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-server-side-signal-passes-the-same-guard-as-the-http-resume + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * Thrown by the guarded signal seam when a signal may not be delivered. + */ +class FlowSignalRefused extends RuntimeException { + /** + * No run carries the given uuid. + * + * @var string + */ + public const RUN_NOT_FOUND = 'run-not-found'; + + /** + * The awaiting step is assigned, and the actor is not its assignee. + * + * @var string + */ + public const NOT_ASSIGNEE = 'not-assignee'; + + /** + * The run is not suspended, so there is nothing to answer. + * + * @var string + */ + public const NOT_SUSPENDED = 'not-suspended'; + + /** + * Constructor. + * + * @param string $reason One of the reason constants. + * @param string $message What went wrong, for a human. + * @param string $runUuid The run the signal addressed. + * @param string|null $actorUid The refused actor, or null when anonymous. + */ + public function __construct( + private readonly string $reason, + string $message, + private readonly string $runUuid = '', + private readonly ?string $actorUid = null, + ) { + parent::__construct(message: $message); + }//end __construct() + + /** + * Why the signal was refused — one of the reason constants. + * + * @return string The reason. + */ + public function getReason(): string { + return $this->reason; + }//end getReason() + + /** + * The run the refused signal addressed. + * + * @return string The run uuid, or '' when unknown. + */ + public function getRunUuid(): string { + return $this->runUuid; + }//end getRunUuid() + + /** + * The refused actor. + * + * @return string|null The actor uid, or null when the caller was anonymous. + */ + public function getActorUid(): ?string { + return $this->actorUid; + }//end getActorUid() +}//end class diff --git a/lib/Service/Flow/FlowMessagingService.php b/lib/Service/Flow/FlowMessagingService.php index 87fee2e1b4..0c2467c3a1 100644 --- a/lib/Service/Flow/FlowMessagingService.php +++ b/lib/Service/Flow/FlowMessagingService.php @@ -518,7 +518,7 @@ private function deliver(string $channel, string $uid, string $title, string $bo * @spec openspec/changes/flow-messaging-nodes/specs/flow-messaging-nodes/spec.md#requirement-flow-sends-are-attributed-logged-and-bounded */ private function resolveActingUser(array $context): string { - $uid = ($context['runAs'] ?? null); + $uid = ($context[FlowRunService::RUN_AS_CONTEXT_KEY] ?? null); if (is_string($uid) === false || trim($uid) === '') { throw new RuntimeException( 'This flow run has no acting identity (runAs); a message must have a sender, so nothing was sent.' diff --git a/lib/Service/Flow/FlowRunAsScope.php b/lib/Service/Flow/FlowRunAsScope.php new file mode 100644 index 0000000000..1654c17846 --- /dev/null +++ b/lib/Service/Flow/FlowRunAsScope.php @@ -0,0 +1,115 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-contributed-node-executes-under-the-runs-acting-identity + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Service\ObjectService; +use OCP\IUserManager; +use RuntimeException; + +/** + * Validates the run's acting identity and scopes a callable to it. + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-contributed-node-executes-under-the-runs-acting-identity + */ +class FlowRunAsScope { + /** + * Constructor. + * + * @param IUserManager $userManager Resolves the named identity to a real account. + * @param ObjectService $objectService Owns the session-scoping seam every + * RBAC and organisation predicate reads. + */ + public function __construct( + private readonly IUserManager $userManager, + private readonly ObjectService $objectService, + ) { + + }//end __construct() + + /** + * Run the operation as the context's acting identity, when it names one. + * + * @param array $context The node context, possibly carrying the identity + * under {@see FlowRunService::RUN_AS_CONTEXT_KEY}. + * @param callable $operation The node's work. + * + * @return mixed Whatever the operation returns. + * + * @throws RuntimeException When the named identity cannot be acted as. + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-contributed-node-executes-under-the-runs-acting-identity + */ + public function call(array $context, callable $operation): mixed { + $uid = trim((string)($context[FlowRunService::RUN_AS_CONTEXT_KEY] ?? '')); + if ($uid === '') { + // No acting identity declared: the interactive path, where the + // ambient session user already answers the permission checks. + return $operation(); + } + + $user = $this->userManager->get($uid); + if ($user === null) { + throw new RuntimeException( + sprintf('This flow run\'s acting identity "%s" (runAs) is not a user account; the step is refused.', $uid) + ); + } + + if ($user->isEnabled() === false) { + throw new RuntimeException( + sprintf('This flow run\'s acting identity "%s" (runAs) is a disabled account; the step is refused.', $uid) + ); + } + + return $this->objectService->runAs($user, $operation); + }//end call() +}//end class diff --git a/lib/Service/Flow/FlowRunAssignee.php b/lib/Service/Flow/FlowRunAssignee.php index 88d77143d8..5226f1d507 100644 --- a/lib/Service/Flow/FlowRunAssignee.php +++ b/lib/Service/Flow/FlowRunAssignee.php @@ -74,19 +74,37 @@ public function __construct( * several nodes across its life, so the one that matters is a slot that * ASKED (`askedAt`) and has not been answered. * + * WITH A NODE ID, the addressed node's own slot decides. A run can await + * several nodes at once, each with its own assignee, and the run-level scan + * answers with the FIRST asked slot's — which refuses the second node's own + * audience. A `nodeId` whose slot is NOT held falls through to the scan: + * naming a node that asked nothing must not become a way around the guard + * on the node that did. + * * @param FlowRun $run The suspended run. + * @param string|null $nodeId The node the answer addresses, when the caller knows it. * * @return string The assignee uid or group id; '' when the step is unassigned. * - * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-server-side-signal-passes-the-same-guard-as-the-http-resume */ - public function recordedFor(FlowRun $run): string { + public function recordedFor(FlowRun $run, ?string $nodeId = null): string { $context = ($run->getContext() ?? []); $slots = ($context[FlowResumeState::CONTEXT_KEY] ?? []); if (is_array($slots) === false) { return ''; } + $nodeId = trim((string)$nodeId); + if ($nodeId !== '') { + $slot = ($slots[$nodeId] ?? null); + if (is_array($slot) === true && isset($slot['askedAt']) === true) { + // The addressed node is asking; ITS record decides, including + // an empty one — an unassigned step is deliberately open. + return trim((string)($slot['assignee'] ?? '')); + } + } + foreach ($slots as $slot) { if (is_array($slot) === false) { continue; @@ -110,13 +128,16 @@ public function recordedFor(FlowRun $run): string { * * @param FlowRun $run The suspended run. * @param string|null $uid The acting user, or null when there is no session. + * @param string|null $nodeId The node the answer addresses, when the caller + * knows it — see {@see recordedFor()} for how + * addressing narrows and never loosens. * * @return boolean True when the answer may be accepted. * * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable */ - public function mayAnswer(FlowRun $run, ?string $uid): bool { - $assignee = $this->recordedFor(run: $run); + public function mayAnswer(FlowRun $run, ?string $uid, ?string $nodeId = null): bool { + $assignee = $this->recordedFor(run: $run, nodeId: $nodeId); // Unassigned is deliberately open — see the class docblock. This is the // one branch that must NOT be tightened without changing the spec. diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index 5d00f0c5ba..5f4da8706e 100644 --- a/lib/Service/Flow/FlowRunService.php +++ b/lib/Service/Flow/FlowRunService.php @@ -86,6 +86,39 @@ class FlowRunService { */ public const SIGNAL_CONTEXT_KEY = 'signal'; + /** + * The context key the run's ACTING IDENTITY travels under. + * + * Stamped by {@see self::baseContextFor()} from the run — never from the + * context, per ADR-099 — and read back by every node whose work needs the + * run's rights. Exported so consumers reference one name instead of each + * hard-coding the literal; the VALUE is frozen, because it is stored inside + * every parked run's context and a rename would strand them. + * + * @var string + */ + public const RUN_AS_CONTEXT_KEY = 'runAs'; + + /** + * The acting-identity scope handed to every dispatcher this service builds. + * + * Resolved lazily from the container and cached — see {@see identityScope()}. + * + * @var FlowRunAsScope|null + */ + private ?FlowRunAsScope $runAsScope = null; + + /** + * Whether the lazy resolution above has been attempted. + * + * A separate flag because null is ALSO a valid outcome: a harness container + * that cannot build the scope answers null once rather than being asked on + * every step. + * + * @var boolean + */ + private bool $runAsScopeResolved = false; + /** * Loads and writes back the state that belongs to the FLOW, not the run. * @@ -295,7 +328,7 @@ public function advanceStream(FlowRun $run, array $flow, object $subject, string flow: $flow, store: new FlowRunMarkingStore(run: $run), subject: $subject, - dispatcher: new RegistryStepDispatcher(registry: $this->registry, guard: $guard), + dispatcher: new RegistryStepDispatcher(registry: $this->registry, guard: $guard, scope: $this->identityScope()), context: $context, items: ($run->getItems() ?? []), startAt: null, @@ -401,7 +434,7 @@ private function baseContextFor(FlowRun $run, bool $resuming): array { // Assignment, not coalesce: the run wins over anything the stored context // carries. See the docblock — a context-supplied acting identity would be // an authoring-time privilege escalation. - $context['runAs'] = $run->getRunAs(); + $context[self::RUN_AS_CONTEXT_KEY] = $run->getRunAs(); return $context; }//end baseContextFor() @@ -458,6 +491,39 @@ private function nodeContextFor(FlowRun $run, bool $resuming, FlowRunGuard $guar return $context; }//end nodeContextFor() + /** + * The acting-identity scope for the dispatchers this service builds. + * + * Resolved from the container rather than the constructor so adding it + * breaks no existing construction site, and LAZILY because most of what + * this service does (queueing, listing, signalling) never dispatches a + * node. A container that cannot build one — the unit harness — answers + * null, and the dispatcher then runs nodes bare, which is the harness's + * existing contract; every production container can build it. + * + * @return FlowRunAsScope|null The scope, or null when the container cannot build one. + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-contributed-node-executes-under-the-runs-acting-identity + */ + private function identityScope(): ?FlowRunAsScope { + if ($this->runAsScopeResolved === true) { + return $this->runAsScope; + } + + $this->runAsScopeResolved = true; + + try { + $scope = $this->container->get(FlowRunAsScope::class); + if ($scope instanceof FlowRunAsScope === true) { + $this->runAsScope = $scope; + } + } catch (Throwable $e) { + $this->runAsScope = null; + } + + return $this->runAsScope; + }//end identityScope() + /** @@ -761,6 +827,17 @@ public function retry(FlowRun $run): ?FlowRun { * walk ({@see self::persistResult()}), so a node reads the answer to ITS * question rather than one left behind by an earlier suspension. * + * 🔴 THIS IS THE UNGUARDED PRIMITIVE, for trusted engine-internal delivery + * only: it checks the run's STATUS and nothing about the CALLER, so calling + * it directly is asserting "whoever I am acting for has already been + * allowed to answer this". Everything outside the engine goes through + * {@see FlowRunSignalService::signalAs()}, which applies the + * recorded-assignee guard (group resolution included) and audits a refusal + * — the HTTP resume endpoints use that same seam, so there is exactly one + * guard. A consumer that calls this method instead has re-opened the gap + * the seam exists to close, silently: the wrong person's answer arrives + * correctly formatted. + * * @param FlowRun $run The suspended run to wake. * @param array $payload What the signaller wants the run to know. * @@ -858,7 +935,7 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed flow: $flow, store: new FlowRunMarkingStore(run: $run), subject: $subject, - dispatcher: new RegistryStepDispatcher(registry: $this->registry, guard: $guard), + dispatcher: new RegistryStepDispatcher(registry: $this->registry, guard: $guard, scope: $this->identityScope()), context: $context, items: $items, startAt: $start, diff --git a/lib/Service/Flow/FlowRunSignalService.php b/lib/Service/Flow/FlowRunSignalService.php new file mode 100644 index 0000000000..e816c4b6c5 --- /dev/null +++ b/lib/Service/Flow/FlowRunSignalService.php @@ -0,0 +1,221 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-server-side-signal-passes-the-same-guard-as-the-http-resume + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Exception\FlowSignalRefused; +use OCP\IGroupManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Delivers a signal to a suspended run, on behalf of a named actor, guarded. + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-server-side-signal-passes-the-same-guard-as-the-http-resume + */ +class FlowRunSignalService { + /** + * Constructor. + * + * @param FlowRunMapper $mapper Resolves a run uuid to the run. + * @param FlowRunService $runner Owns the unguarded delivery primitive. + * @param LoggerInterface|null $logger Audits a refusal. Nullable so the + * controller can build this seam on + * demand without one — there the + * refusal is surfaced to the caller as + * a 403, so nothing goes unrecorded; + * the container-built instance always + * has a logger. + * @param IGroupManager|null $groupManager Resolves group assignment. Absent, + * a group-assigned step refuses + * rather than admits — the + * fail-closed direction. + * @param FlowRunAssignee|null $assignees The access rule. Injectable so a + * consumer's test can drive the real + * contract; defaults to the real one. + */ + public function __construct( + private readonly FlowRunMapper $mapper, + private readonly FlowRunService $runner, + private readonly ?LoggerInterface $logger = null, + private readonly ?IGroupManager $groupManager = null, + private readonly ?FlowRunAssignee $assignees = null, + ) { + + }//end __construct() + + /** + * Answer a suspended run by uuid, as a named actor, guarded. + * + * The entry point for PHP consumers — a listener whose object completes a + * task holds exactly a run uuid. Resolution, the guard and delivery are + * one call, so there is nothing left for the consumer to remember. + * + * @param string $runUuid The run to answer. + * @param array $payload What the signaller wants the run to know. + * @param string|null $actorUid Who is answering; null or '' means anonymous, + * which an ASSIGNED step refuses. + * @param string|null $nodeId The node the answer addresses, when known. The + * guard then checks THAT node's recorded + * assignee; addressing can narrow the check but + * never loosen it — see {@see FlowRunAssignee::recordedFor()}. + * + * @return FlowRun The parked run, due for the worker's next pass. + * + * @throws FlowSignalRefused With reason RUN_NOT_FOUND, NOT_ASSIGNEE or NOT_SUSPENDED. + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-server-side-signal-passes-the-same-guard-as-the-http-resume + */ + public function signalAs(string $runUuid, array $payload, ?string $actorUid, ?string $nodeId = null): FlowRun { + try { + $run = $this->mapper->findByUuid($runUuid); + } catch (Throwable $e) { + throw new FlowSignalRefused( + reason: FlowSignalRefused::RUN_NOT_FOUND, + message: 'No run carries uuid "' . $runUuid . '"; the signal was not delivered.', + runUuid: $runUuid, + actorUid: $this->normalize(actorUid: $actorUid) + ); + } + + return $this->signalRunAs(run: $run, payload: $payload, actorUid: $actorUid, nodeId: $nodeId); + }//end signalAs() + + /** + * Answer an already-resolved run, as a named actor, guarded. + * + * For callers that resolved the run themselves — the HTTP endpoints, whose + * 404 wording is their own. The guard and the delivery are identical to + * {@see signalAs()}; a refusal touches nothing. + * + * @param FlowRun $run The run to answer. + * @param array $payload What the signaller wants the run to know. + * @param string|null $actorUid Who is answering; null or '' means anonymous. + * @param string|null $nodeId The node the answer addresses, when known. + * + * @return FlowRun The parked run, due for the worker's next pass. + * + * @throws FlowSignalRefused With reason NOT_ASSIGNEE or NOT_SUSPENDED. + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-server-side-signal-passes-the-same-guard-as-the-http-resume + */ + public function signalRunAs(FlowRun $run, array $payload, ?string $actorUid, ?string $nodeId = null): FlowRun { + $actor = $this->normalize(actorUid: $actorUid); + $rule = ($this->assignees ?? new FlowRunAssignee(groupManager: $this->groupManager)); + + if ($rule->mayAnswer(run: $run, uid: $actor, nodeId: $nodeId) === false) { + $this->auditRefusal(run: $run, actor: $actor, assignee: $rule->recordedFor(run: $run, nodeId: $nodeId), nodeId: $nodeId); + + throw new FlowSignalRefused( + reason: FlowSignalRefused::NOT_ASSIGNEE, + message: 'The awaiting step of run "' . (string)$run->getUuid() + . '" is assigned, and the acting user is not its assignee; the signal was not delivered.', + runUuid: (string)$run->getUuid(), + actorUid: $actor + ); + } + + $signalled = $this->runner->signal(run: $run, payload: $payload); + if ($signalled === null) { + throw new FlowSignalRefused( + reason: FlowSignalRefused::NOT_SUSPENDED, + message: 'Only a suspended run can be signalled; run "' . (string)$run->getUuid() + . '" is ' . (string)$run->getStatus() . '.', + runUuid: (string)$run->getUuid(), + actorUid: $actor + ); + } + + return $signalled; + }//end signalRunAs() + + /** + * An empty actor is an anonymous one. + * + * @param string|null $actorUid The claimed actor. + * + * @return string|null The uid, or null when there is effectively none. + */ + private function normalize(?string $actorUid): ?string { + if ($actorUid === null || trim($actorUid) === '') { + return null; + } + + return trim($actorUid); + }//end normalize() + + /** + * Record who was refused from answering what. + * + * The refusal ALSO reaches the caller as an exception, but the caller may + * be a listener that can only log it locally — dossiq's task listener sees + * the refusal after the task is already saved, so the only trace of "the + * wrong person tried to advance this decision" is this record. Written by + * the engine so it exists whether or not the consumer remembers to. + * + * @param FlowRun $run The run the signal addressed. + * @param string|null $actor Who tried, or null when anonymous. + * @param string $assignee Who the step is recorded for. + * @param string|null $nodeId The addressed node, when the caller named one. + * + * @return void + */ + private function auditRefusal(FlowRun $run, ?string $actor, string $assignee, ?string $nodeId): void { + $this->logger?->warning( + message: '[FlowRunSignalService] Refused a signal: the actor is not the awaiting step\'s assignee', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'run' => (string)$run->getUuid(), + 'flow' => (string)$run->getFlowId(), + 'actor' => ($actor ?? '(anonymous)'), + 'assignee' => $assignee, + 'node' => ($nodeId ?? ''), + ] + ); + + }//end auditRefusal() +}//end class diff --git a/lib/Service/Flow/IFlowSelfScopedNode.php b/lib/Service/Flow/IFlowSelfScopedNode.php new file mode 100644 index 0000000000..0969c0518f --- /dev/null +++ b/lib/Service/Flow/IFlowSelfScopedNode.php @@ -0,0 +1,53 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-contributed-node-executes-under-the-runs-acting-identity + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +/** + * Marks a contributed node that manages its own acting identity. + */ +interface IFlowSelfScopedNode { + +}//end interface diff --git a/lib/Service/Flow/Nodes/ObjectReadNode.php b/lib/Service/Flow/Nodes/ObjectReadNode.php index 71de6ad2bf..f75553755b 100644 --- a/lib/Service/Flow/Nodes/ObjectReadNode.php +++ b/lib/Service/Flow/Nodes/ObjectReadNode.php @@ -70,6 +70,7 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowValueTemplate; use OCA\OpenRegister\Service\Flow\IFlowNode; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; @@ -475,7 +476,7 @@ private function resolveOwner(array $context): IUser { // For a scheduled run they differ — the cause is a schedule, the acting // identity is the user the trigger declares — and reading provenance here // is what made a scheduled read execute as whoever authored the flow. - $uid = ($context['runAs'] ?? null); + $uid = ($context[FlowRunService::RUN_AS_CONTEXT_KEY] ?? null); if (is_string($uid) === false || trim($uid) === '') { throw new RuntimeException( $this->l10n->t('This flow run has no acting identity (runAs); an object read must be attributable.') diff --git a/lib/Service/Flow/Nodes/ObjectWriteNode.php b/lib/Service/Flow/Nodes/ObjectWriteNode.php index 7a13d2f7a5..60881a3129 100644 --- a/lib/Service/Flow/Nodes/ObjectWriteNode.php +++ b/lib/Service/Flow/Nodes/ObjectWriteNode.php @@ -96,6 +96,7 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\IFlowNode; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; @@ -1528,7 +1529,7 @@ private function resolveOwner(array $context): IUser { // For a scheduled run they differ — the cause is a schedule, the acting // identity is the user the trigger declares — and reading provenance here // is what made a scheduled write execute as whoever authored the flow. - $uid = ($context['runAs'] ?? null); + $uid = ($context[FlowRunService::RUN_AS_CONTEXT_KEY] ?? null); if (is_string($uid) === false || trim($uid) === '') { throw new RuntimeException( $this->l10n->t('This flow run has no acting identity (runAs); an object write must be attributable.') diff --git a/lib/Service/Flow/RegistryStepDispatcher.php b/lib/Service/Flow/RegistryStepDispatcher.php index f1ec654184..613479da2d 100644 --- a/lib/Service/Flow/RegistryStepDispatcher.php +++ b/lib/Service/Flow/RegistryStepDispatcher.php @@ -48,10 +48,19 @@ class RegistryStepDispatcher implements FlowStepDispatcher { * callers with no run to guard — the flow * tester and the node unit tests dispatch * without one. + * @param FlowRunAsScope|null $scope Executes a CONTRIBUTED node as the + * run's acting identity. Null only for a + * dispatcher built by hand with no + * container behind it — the flow tester + * and the node unit tests — which then + * runs every node bare, exactly like the + * nullable guard; all three production + * construction sites supply one. */ public function __construct( private readonly FlowNodeRegistry $registry, private readonly ?FlowRunGuard $guard = null, + private readonly ?FlowRunAsScope $scope = null, ) { }//end __construct() @@ -117,7 +126,7 @@ public function dispatch(array $step, array $items, array $context): array { $this->scopeSignal(context: $context, scoped: $scoped); $startedAt = microtime(true); - $out = $node->execute(items: $items, config: $config, context: $context); + $out = $this->executeScoped(node: $node, items: $items, config: $config, context: $context); $tookMs = (int)round((microtime(true) - $startedAt) * 1000); // Reached only when the node RETURNED. A node that suspends throws, so @@ -133,6 +142,56 @@ public function dispatch(array $step, array $items, array $context): array { return $out; }//end dispatch() + /** + * Execute the node — a CONTRIBUTED one inside the run's acting identity. + * + * The engine's `runAs` used to reach a contributed node as a context key + * and nothing more: unless the app built its own wrapper, every write the + * node performed ran under the ambient session — which under the cron + * worker is nobody, so it was refused as anonymous no matter whose rights + * the run declared. dossiq shipped three broken nodes (and a fourth + * handler) before building that wrapper. The identity is run-level state, + * so the dispatcher applies it: every consumer inherits the scoping + * instead of re-implementing it. + * + * WHO IS WRAPPED. A node whose class lives under `OCA\OpenRegister\` is + * the engine's own and already scopes itself — `ObjectWriteNode` and + * friends validate and wrap internally, with node-specific wording and + * skip-when semantics — so wrapping it again would change the ambient + * identity of nodes that deliberately run bare. Everything else is + * contributed and runs inside {@see FlowRunAsScope}, which validates the + * identity (exists, enabled — refused loudly otherwise) and NARROWS to it; + * a contributed node that must manage its own identity declares + * {@see IFlowSelfScopedNode}, the documented escape hatch. + * + * A context that names NO identity runs the node bare either way — the + * interactive path — so a dispatcher walking a tester context changes + * nothing. + * + * @param IFlowNode $node The resolved node. + * @param array $items The input items. + * @param array $config The step configuration. + * @param array $context The node context. + * + * @return array The output items. + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-contributed-node-executes-under-the-runs-acting-identity + */ + private function executeScoped(IFlowNode $node, array $items, array $config, array $context): array { + $engineOwned = str_starts_with(get_class($node), 'OCA\\OpenRegister\\'); + + if ($this->scope === null || $engineOwned === true || $node instanceof IFlowSelfScopedNode === true) { + return $node->execute(items: $items, config: $config, context: $context); + } + + return (array)$this->scope->call( + context: $context, + operation: static function () use ($node, $items, $config, $context): array { + return $node->execute(items: $items, config: $config, context: $context); + } + ); + }//end executeScoped() + /** * Put this node's resume slot into the context it is about to be called with. * diff --git a/openspec/changes/flow-engine-consumer-seams/.openspec.yaml b/openspec/changes/flow-engine-consumer-seams/.openspec.yaml new file mode 100644 index 0000000000..032461ff9f --- /dev/null +++ b/openspec/changes/flow-engine-consumer-seams/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-02 diff --git a/openspec/changes/flow-engine-consumer-seams/design.md b/openspec/changes/flow-engine-consumer-seams/design.md new file mode 100644 index 0000000000..dbba64d166 --- /dev/null +++ b/openspec/changes/flow-engine-consumer-seams/design.md @@ -0,0 +1,134 @@ +# Design: flow-engine-consumer-seams + +## Context + +The state of the code today: + +- `FlowRunAssignee` (`lib/Service/Flow/FlowRunAssignee.php`) already holds the + ONE copy of the "who may answer" rule — uid match, group resolution, + unassigned-is-open, anonymous-fails-closed. What the engine does not hold is + a guarded VERB: `FlowRunController::refuseUnlessAssignee()` consults the + rule for HTTP, and every PHP caller of `FlowRunService::signal()` must + remember to consult it themselves. dossiq's two listeners do; the seam + exists so the third consumer does not have to. +- `FlowRunService::baseContextFor()` stamps `$context['runAs'] = + $run->getRunAs()` on every walk — assignment, not coalesce, per ADR-099 + (identity narrows, never widens; a context-supplied identity would be a + queue-time privilege escalation). The literal `'runAs'` is read back in + `ObjectWriteNode`, `ObjectReadNode` and `FlowMessagingService`, and by + dossiq's `FlowRunAsScope` — with no exported constant to reference. +- `RegistryStepDispatcher::dispatch()` checkpoints the guard, scopes the + resume slot, scopes the signal (#3325), executes the node, clears the slot. + It is constructed at three sites: twice inside `FlowRunService` (execute and + the #3310 stream walk — both with a per-run guard) and once as a container + factory in `Application.php` (guardless; the loop node's dispatcher). +- OpenRegister's own storage-touching nodes each wrap their work in + `ObjectService::runAs()` after validating the identity exists and is + enabled (`ObjectWriteNode::resolveOwner()`). A contributed node starts with + none of that. + +## Decisions + +### D-1: The signal seam wraps the existing rule; it does not replace it + +`FlowRunSignalService` composes `FlowRunAssignee` + `FlowRunMapper` + +`FlowRunService::signal()`. The rule object stays exactly where it is and +keeps its contract (unassigned means anyone; anonymous fails closed; group +membership admits). What the seam adds is the VERB: resolve the run, apply +the rule for a NAMED actor, audit a refusal, deliver. Two methods: + +- `signalAs(runUuid, payload, actorUid, nodeId?)` — for callers holding a + uuid (dossiq's listeners hold exactly that). +- `signalRunAs(run, payload, actorUid, nodeId?)` — for callers that already + resolved the run (the controller, which must 404 before it 403s). + +Refusals are one typed exception, `FlowSignalRefused`, carrying a reason +constant. An exception rather than a nullable return because the unguarded +primitive already uses `null` to mean "not suspended", and a seam whose +refusal can be ignored by ignoring a return value is not a guard. + +### D-2: The HTTP path migrates onto the seam, byte-identical + +`resume()` and `signalByKey()` keep their own run resolution (their 404/409 +wording differs per endpoint and is asserted by existing tests) and delegate +the guard + delivery to `signalRunAs()`. `refuseUnlessAssignee()` and the +controller's private rule factory are deleted — the controller no longer +touches `FlowRunAssignee` at all. Status codes, bodies and the +routing-artefact stripping are unchanged, so no contract test moves. + +### D-3: `nodeId` narrows the guard to the addressed slot, and can only narrow + +A run can await several nodes at once, each with its own recorded assignee. +The run-level rule answers with the FIRST asked slot's assignee, which +refuses the second node's own audience. When the caller names the node its +answer addresses, the guard checks THAT node's recorded assignee. A `nodeId` +whose slot is not held (the node is not asking) falls back to the run-level +rule — naming a node that asked nothing must not become a way around the +guard on the node that did. + +### D-4: The dispatcher scopes contributed nodes; engine nodes are untouched + +The identity wrap happens in `RegistryStepDispatcher::dispatch()`, around +`$node->execute()` only — after the checkpoint, the resume-slot scoping and +the signal scoping (#3325), and before the per-node budget check, so neither +#3325's addressing nor #3310's stream walk changes shape. + +Which nodes: a node whose class lives under `OCA\OpenRegister\` manages its +own scoping today (`ObjectWriteNode` et al. validate and wrap internally, +with node-specific error wording and skip-when semantics) and is left alone — +wrapping it again would be harmless for writes but would change the ambient +identity of nodes that deliberately run bare. Everything else is contributed +and gets the wrap, unless it implements `IFlowSelfScopedNode` — the +documented escape hatch for a node that must manage its own identity (for +example one whose work is legitimately system-level installation plumbing; +such a node takes on the obligations the interface docblock names). + +Semantics match dossiq's `FlowRunAsScope`, which mirrored +`ObjectWriteNode::resolveOwner()`: + +- context names NO identity → the node runs bare (the interactive path, and + every existing test fixture); +- identity resolves to no account → refuse loudly; +- identity resolves to a DISABLED account → refuse loudly, so a run parked + for weeks cannot resume with an offboarded user's rights; +- identity valid → `ObjectService::runAs($user, execute)`. Narrowing only: + a run whose owner cannot write is still refused, now for the right reason. + +### D-5: The scope collaborator is nullable, and null means the harness + +The dispatcher's scope is resolved from the container (`FlowRunService` +resolves it lazily; the `Application.php` factory injects it). A dispatcher +built by hand with none — the flow tester, node unit tests — runs nodes bare, +exactly as the nullable `FlowRunGuard` already works. This is not fail-open +in production: all three production construction sites supply the scope, and +the refusal tests pin the behaviour when it is present. + +### D-6: The context key becomes a constant, and the value is frozen + +`FlowRunService::RUN_AS_CONTEXT_KEY = 'runAs'`. The literal is stored inside +every parked run's context, so the VALUE can never change; the constant +exists so readers (engine nodes, the messaging service, consuming apps) +reference one name. All in-tree literal reads migrate to it. + +## Follow-ups (audit gaps NOT built here) + +The fleet audit named four engine gaps; this change closes two. The other two +are recorded so the change record is honest about scope: + +- **Request-scoped trigger**: apps that trigger flows from a live request + still queue through the worker; a request-scoped fire-and-return seam is a + separate change. +- **Shared retry-policy vocabulary**: each app still declares its own retry + semantics for failed runs; a shared vocabulary (and its config schema) is a + separate change. + +## Risks / Trade-offs + +- **A contributed node that relied on running bare while its run named a + `runAs` changes behaviour** — it now executes under the run's identity, or + is refused if that identity is stale. That is the point; the escape hatch + is the opt-out and its docblock names the cost. +- **Class-namespace as the engine/contributed test** is a heuristic, but a + stable one: the namespace is claimed by this app's autoloader, and a + contributed node cannot live under it. The marker interface exists for the + cases the heuristic cannot express. diff --git a/openspec/changes/flow-engine-consumer-seams/proposal.md b/openspec/changes/flow-engine-consumer-seams/proposal.md new file mode 100644 index 0000000000..16575c1346 --- /dev/null +++ b/openspec/changes/flow-engine-consumer-seams/proposal.md @@ -0,0 +1,92 @@ +--- +kind: code +depends_on: [flow-approval-consolidation] +--- + +# Proposal: flow-engine-consumer-seams + +## Summary + +Close the two engine gaps the fleet audit handed back, both of the shape +"every consuming app re-implements a rule the engine should own": + +1. **A guarded server-side signal API.** `FlowRunSignalService::signalAs()` + resumes a suspended run on behalf of a named actor and applies the + recorded-assignee guard (group resolution included) before anything is + delivered, auditing a refusal. The HTTP resume path migrates onto the same + seam, so there is ONE guard. `FlowRunService::signal()` stays as the + unguarded engine-internal primitive and is documented as such. + +2. **Native `runAs` scoping for contributed nodes.** `RegistryStepDispatcher` + resolves the run's acting identity (validated: the account exists and is + enabled, refused loudly otherwise) and executes every CONTRIBUTED node + inside `ObjectService::runAs()`, so an app node's writes inherit the run's + identity without the app building a wrapper. The context key is exported as + a constant, and a documented escape hatch exists for nodes that manage + their own scoping. OpenRegister's own nodes are unaffected — they already + scope themselves. + +## Why + +**Gap 1 — the guard only lives on the HTTP path.** The assignee rule was +extracted into `FlowRunAssignee` precisely because in-process resumes exist, +but the engine still leaves CALLING it to the consumer: +`FlowRunController::refuseUnlessAssignee()` applies it for HTTP, while an app +that resumes from PHP calls `FlowRunService::signal()` directly — which +delivers unconditionally. dossiq's `TaskCompletionResumeListener` and +`ParaafResumeListener` each remembered to consult the rule (and +mutation-tested it), but "each caller must remember" is the defect class: the +next app forgets, and a forgotten check does not throw — it lets the wrong +person answer somebody else's step, HTTP 200. The engine must offer the +guarded verb so the unguarded primitive stops being the obvious API. + +**Gap 2 — app nodes execute as nobody.** The engine stamps the run's `runAs` +into the node context, and its OWN write-capable nodes (`ObjectWriteNode`, +`ObjectReadNode`) wrap their storage calls in `ObjectService::runAs()` — +because the RBAC handlers read the ambient session, which under a cron worker +carries no one. A contributed node gets the context key and nothing else: +unless the app builds its own wrapper, every write it performs is refused as +anonymous (or worse, silently executed under whatever ambient identity a +request happens to carry). dossiq shipped THREE broken nodes before building +`FlowRunAsScope`, and `MergeTemplateHandler` was a fourth. The identity is +run-level state; scoping to it belongs in the dispatcher that hands the node +its context, not in every app. + +**Measured this week:** two shipped dossiq defects trace to these gaps; the +audit found the same re-implementation pressure in every consuming app. + +## What Changes + +- New `lib/Service/Flow/FlowRunSignalService.php` — `signalAs(runUuid, + payload, actorUid, nodeId?)` and `signalRunAs(run, …)`: resolve, guard + (via the existing `FlowRunAssignee` rule), audit a refusal, deliver via + `FlowRunService::signal()`. +- New `lib/Exception/FlowSignalRefused.php` — one typed refusal with a reason + (`run-not-found`, `not-assignee`, `not-suspended`) so PHP callers and the + controller map it without string-matching. +- `FlowRunAssignee` learns an optional `nodeId`: a caller that knows WHICH + node its answer addresses is checked against that node's recorded assignee; + naming a node that is not asking falls back to the run-level rule, so + addressing can never loosen the guard. +- `FlowRunController::resume()` and `signalByKey()` migrate onto the seam; + their routes, status codes and response bodies are byte-identical. +- `FlowRunService::signal()` is documented as the trusted engine-internal + primitive; `FlowRunService` exports `RUN_AS_CONTEXT_KEY`. +- New `lib/Service/Flow/FlowRunAsScope.php` — validates and applies the acting + identity (mirrors dossiq's service of the same name, which becomes + deletable). +- New `lib/Service/Flow/IFlowSelfScopedNode.php` — the escape hatch marker for + contributed nodes that manage their own identity. +- `RegistryStepDispatcher` executes contributed nodes inside the scope. + +## Impact + +- Affected specs: `flow-engine-consumer-seams` (new capability, delta spec in + this change). +- Affected code: `lib/Service/Flow/` (dispatcher, run service, assignee rule, + two new services, one marker interface), `lib/Controller/FlowRunController.php`, + `lib/Exception/`, `lib/AppInfo/Application.php`. +- No route, migration or frontend changes. The HTTP contract is unchanged. +- Consuming apps: dossiq's `FlowRunAsScope` and the guard block in its two + resume listeners become deletable once they adopt the seam; the next app + never builds either. diff --git a/openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md b/openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md new file mode 100644 index 0000000000..1920414b27 --- /dev/null +++ b/openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md @@ -0,0 +1,120 @@ +## Purpose + +The two seams every consuming app was re-implementing: a guarded server-side +signal verb, and native acting-identity scoping for contributed nodes. + +## ADDED Requirements + +### Requirement: A server-side signal passes the same guard as the HTTP resume + +The engine SHALL provide a guarded server-side signal verb — +`FlowRunSignalService::signalAs()` — that resumes a suspended run on behalf +of a NAMED actor. It SHALL apply the same recorded-assignee rule as the HTTP +resume endpoint, group resolution included, through the same implementation +(`FlowRunAssignee`), so the two paths cannot drift. + +A refusal SHALL be a typed exception naming its reason (run not found, actor +not the assignee, run not suspended) and SHALL be audited with the run, the +actor and the recorded assignee. A refused signal SHALL NOT touch the run. + +The unguarded primitive `FlowRunService::signal()` SHALL remain for +engine-internal delivery and SHALL be documented as such; consumers outside +the engine use the guarded verb. + +A caller MAY name the node its answer addresses. The guard then SHALL check +that node's recorded assignee; naming a node that is not awaiting an answer +SHALL fall back to the run-level rule, so addressing can never loosen the +guard. A step that records no assignee remains answerable by anyone, per the +existing contract. + +The HTTP resume endpoints SHALL delegate to this seam, so exactly one guard +exists. + +#### Scenario: The recorded assignee may answer +- **GIVEN** a run suspended on a step assigned to alice +- **WHEN** `signalAs` is called with actor alice +- **THEN** the run MUST become due and carry the payload +- @e2e exclude server-side PHP seam — covered by FlowRunSignalServiceTest + +#### Scenario: A member of the assigned group may answer +- **GIVEN** a run suspended on a step assigned to group reviewers +- **WHEN** `signalAs` is called with an actor who is in reviewers +- **THEN** the run MUST become due and carry the payload +- @e2e exclude server-side PHP seam — covered by FlowRunSignalServiceTest + +#### Scenario: A stranger is refused and the run is untouched +- **GIVEN** a run suspended on a step assigned to alice +- **WHEN** `signalAs` is called with actor mallory +- **THEN** the call MUST be refused with reason not-assignee +- **AND** the run MUST remain suspended with no payload delivered +- **AND** the refusal MUST be audited +- @e2e exclude server-side PHP seam — covered by FlowRunSignalServiceTest + +#### Scenario: An unassigned step is answerable by anyone +- **GIVEN** a run suspended on a step that records no assignee +- **WHEN** `signalAs` is called with any actor +- **THEN** the run MUST become due +- @e2e exclude server-side PHP seam — covered by FlowRunSignalServiceTest + +#### Scenario: Addressing a node checks that node's assignee +- **GIVEN** a run awaiting node A (assigned to alice) and node B (assigned to bob) +- **WHEN** `signalAs` is called with actor bob naming node B +- **THEN** the run MUST become due +- @e2e exclude server-side PHP seam — covered by FlowRunSignalServiceTest + +#### Scenario: Addressing a silent node cannot loosen the guard +- **GIVEN** a run awaiting node A, assigned to alice +- **WHEN** `signalAs` is called with actor mallory naming a node that is not asking +- **THEN** the call MUST be refused with reason not-assignee +- @e2e exclude server-side PHP seam — covered by FlowRunSignalServiceTest + +### Requirement: A contributed node executes under the run's acting identity + +The step dispatcher SHALL execute every CONTRIBUTED node (one whose class is +not the engine's own) inside `ObjectService::runAs()` scoped to the run's +`runAs` identity, so every read and write the node performs inherits the +run's rights without the contributing app building a wrapper. + +The identity SHALL be validated at execution time: an identity that resolves +to no account, or to a disabled account, SHALL refuse the step loudly rather +than execute it as anyone else. A run whose context names NO identity SHALL +run the node bare, under the ambient session — the interactive path. + +The scoping SHALL narrow, never grant: a run whose identity lacks a right is +still refused it. The engine's own nodes, which already scope themselves, +SHALL be unaffected. A contributed node that must manage its own identity +MAY declare so via `IFlowSelfScopedNode`, the documented escape hatch. + +The engine SHALL export the context key under which the acting identity +travels (`FlowRunService::RUN_AS_CONTEXT_KEY`), and its value SHALL remain +`runAs` — it is stored inside parked runs' contexts and cannot move. + +#### Scenario: A contributed node's write executes as the run owner +- **GIVEN** a run whose runAs is alice, an enabled account +- **WHEN** the dispatcher executes a contributed node +- **THEN** the node MUST execute inside `ObjectService::runAs(alice)` +- @e2e exclude dispatcher-internal — covered by RegistryStepDispatcherRunAsTest + +#### Scenario: An unresolvable identity refuses loudly +- **GIVEN** a run whose runAs names no existing account +- **WHEN** the dispatcher executes a contributed node +- **THEN** the step MUST be refused with a message naming the identity +- @e2e exclude dispatcher-internal — covered by RegistryStepDispatcherRunAsTest + +#### Scenario: A disabled identity refuses loudly +- **GIVEN** a run whose runAs names a disabled account +- **WHEN** the dispatcher executes a contributed node +- **THEN** the step MUST be refused with a message naming the identity +- @e2e exclude dispatcher-internal — covered by RegistryStepDispatcherRunAsTest + +#### Scenario: The engine's own nodes are unaffected +- **GIVEN** a run whose runAs is set +- **WHEN** the dispatcher executes one of the engine's own nodes +- **THEN** the dispatcher MUST NOT wrap it — the node scopes itself +- @e2e exclude dispatcher-internal — covered by RegistryStepDispatcherRunAsTest + +#### Scenario: A self-scoped contributed node runs bare +- **GIVEN** a contributed node declaring `IFlowSelfScopedNode` +- **WHEN** the dispatcher executes it on a run whose runAs is set +- **THEN** the dispatcher MUST NOT wrap it +- @e2e exclude dispatcher-internal — covered by RegistryStepDispatcherRunAsTest diff --git a/openspec/changes/flow-engine-consumer-seams/tasks.md b/openspec/changes/flow-engine-consumer-seams/tasks.md new file mode 100644 index 0000000000..502c804693 --- /dev/null +++ b/openspec/changes/flow-engine-consumer-seams/tasks.md @@ -0,0 +1,57 @@ +# Tasks: flow-engine-consumer-seams + +## 1. The guarded signal seam + +- [x] 1.1 `lib/Exception/FlowSignalRefused.php`: one typed refusal with reason + constants (`run-not-found`, `not-assignee`, `not-suspended`), the + refused actor and the run uuid readable by the caller. +- [x] 1.2 `lib/Service/Flow/FlowRunSignalService.php`: `signalAs(runUuid, + payload, actorUid, nodeId?)` and `signalRunAs(run, …)` — resolve, apply + `FlowRunAssignee` (group resolution included), audit a refusal with + run/actor/assignee, deliver via `FlowRunService::signal()`. A refused + signal touches nothing. +- [x] 1.3 `FlowRunAssignee`: optional `nodeId` on `recordedFor()` / + `mayAnswer()` — an addressed held slot's assignee decides; a `nodeId` + whose slot is not held falls back to the run-level rule. +- [x] 1.4 Migrate `FlowRunController::resume()` and `signalByKey()` onto + `signalRunAs()`; delete `refuseUnlessAssignee()` and the local rule + factory. Responses stay byte-identical. +- [x] 1.5 Document `FlowRunService::signal()` as the unguarded + engine-internal primitive, pointing consumers at the seam. + +## 2. Native runAs scoping + +- [x] 2.1 `FlowRunService::RUN_AS_CONTEXT_KEY = 'runAs'`; migrate the + in-tree literal reads (`baseContextFor`, `ObjectWriteNode`, + `ObjectReadNode`, `FlowMessagingService`) onto it. +- [x] 2.2 `lib/Service/Flow/FlowRunAsScope.php`: validate (exists, enabled — + refuse loudly otherwise) and apply `ObjectService::runAs()`; bare when + the context names nobody. +- [x] 2.3 `lib/Service/Flow/IFlowSelfScopedNode.php`: the escape-hatch marker, + with the obligations documented. +- [x] 2.4 `RegistryStepDispatcher`: execute contributed nodes inside the + scope; engine-owned nodes and marker-declaring nodes untouched; wrap + sits inside the #3325 signal scoping and the per-node budget check so + neither moves. +- [x] 2.5 Wire the scope into all three dispatcher construction sites + (`FlowRunService::execute()`, the #3310 stream walk, the + `Application.php` factory). + +## 3. Tests + +- [x] 3.1 `FlowRunSignalServiceTest`: assignee passes, group member passes, + stranger refused with nothing touched (the mutation check: skipping the + guard reds it), anonymous refused, unassigned open, not-suspended and + not-found reasons, nodeId addressing and its no-loosening fallback, + refusal audited. +- [x] 3.2 `FlowRunAssigneeTest`: the nodeId addressing branches. +- [x] 3.3 `FlowRunAsScopeTest`: valid identity narrows via + `ObjectService::runAs`, unknown refused, disabled refused, empty runs + bare. +- [x] 3.4 `RegistryStepDispatcherRunAsTest`: contributed node wrapped as the + run owner, unresolvable/disabled identity refused loudly, engine-owned + node untouched, marker node untouched, scope-less dispatcher (the + harness) runs bare, no-runAs context runs bare. +- [x] 3.5 Existing suites stay green: #3325 signal scoping + (`RegistryStepDispatcherResumeTest`), #3310 stream walk + (`FlowRunServiceAdvanceStreamTest`), the controller's resume tests. diff --git a/tests/Unit/Service/Flow/FlowRunAsScopeTest.php b/tests/Unit/Service/Flow/FlowRunAsScopeTest.php new file mode 100644 index 0000000000..7851a09e71 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowRunAsScopeTest.php @@ -0,0 +1,159 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-contributed-node-executes-under-the-runs-acting-identity + */ + +declare(strict_types=1); + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Service\Flow\FlowRunAsScope; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\ObjectService; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +class FlowRunAsScopeTest extends TestCase { + + /** + * A user manager resolving exactly one uid. + * + * @param string $uid The uid that resolves. + * @param boolean $enabled Whether the account is enabled. + * + * @return IUserManager The manager. + */ + private function userManagerWith(string $uid, bool $enabled): IUserManager { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $user->method('isEnabled')->willReturn($enabled); + + $manager = $this->createMock(IUserManager::class); + $manager->method('get')->willReturnCallback( + static function (string $asked) use ($uid, $user): ?IUser { + if ($asked === $uid) { + return $user; + } + + return null; + } + ); + + return $manager; + }//end userManagerWith() + + public function testAValidIdentityRunsTheOperationInsideRunAs(): void { + $scopedAs = null; + $objects = $this->createMock(ObjectService::class); + $objects->method('runAs')->willReturnCallback( + static function (IUser $user, callable $operation) use (&$scopedAs): mixed { + $scopedAs = $user->getUID(); + + return $operation(); + } + ); + + $scope = new FlowRunAsScope( + userManager: $this->userManagerWith(uid: 'alice', enabled: true), + objectService: $objects + ); + + $out = $scope->call( + context: [FlowRunService::RUN_AS_CONTEXT_KEY => 'alice'], + operation: static fn (): string => 'wrote' + ); + + $this->assertSame('wrote', $out); + $this->assertSame('alice', $scopedAs, 'the work must run inside runAs(alice)'); + }//end testAValidIdentityRunsTheOperationInsideRunAs() + + /** + * No identity declared is the interactive path: the operation runs bare, + * under the ambient session, and runAs is never involved. + */ + public function testNoIdentityRunsBare(): void { + $objects = $this->createMock(ObjectService::class); + $objects->expects($this->never())->method('runAs'); + + $scope = new FlowRunAsScope( + userManager: $this->createMock(IUserManager::class), + objectService: $objects + ); + + $this->assertSame('bare', $scope->call(context: [], operation: static fn (): string => 'bare')); + $this->assertSame( + 'bare', + $scope->call( + context: [FlowRunService::RUN_AS_CONTEXT_KEY => ' '], + operation: static fn (): string => 'bare' + ) + ); + }//end testNoIdentityRunsBare() + + /** + * 🔴 An identity that resolves to NO account refuses loudly, and the + * operation never runs. + */ + public function testAnUnknownIdentityRefusesLoudly(): void { + $objects = $this->createMock(ObjectService::class); + $objects->expects($this->never())->method('runAs'); + + $scope = new FlowRunAsScope( + userManager: $this->userManagerWith(uid: 'alice', enabled: true), + objectService: $objects + ); + + $ran = false; + + try { + $scope->call( + context: [FlowRunService::RUN_AS_CONTEXT_KEY => 'ghost'], + operation: static function () use (&$ran): void { + $ran = true; + } + ); + $this->fail('Expected the step to be refused.'); + } catch (RuntimeException $refused) { + $this->assertStringContainsString('ghost', $refused->getMessage()); + } + + $this->assertFalse($ran, 'a refused step must not have run its work'); + }//end testAnUnknownIdentityRefusesLoudly() + + /** + * 🔴 A DISABLED account still resolves, and must refuse anyway: rights are + * re-checked at the moment work runs so that offboarding takes effect on a + * run parked for weeks. + */ + public function testADisabledIdentityRefusesLoudly(): void { + $objects = $this->createMock(ObjectService::class); + $objects->expects($this->never())->method('runAs'); + + $scope = new FlowRunAsScope( + userManager: $this->userManagerWith(uid: 'former', enabled: false), + objectService: $objects + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/disabled/'); + + $scope->call( + context: [FlowRunService::RUN_AS_CONTEXT_KEY => 'former'], + operation: static fn (): string => 'never' + ); + }//end testADisabledIdentityRefusesLoudly() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunAssigneeTest.php b/tests/Unit/Service/Flow/FlowRunAssigneeTest.php index 74559aaca9..bd272537bb 100644 --- a/tests/Unit/Service/Flow/FlowRunAssigneeTest.php +++ b/tests/Unit/Service/Flow/FlowRunAssigneeTest.php @@ -151,4 +151,69 @@ public function testARunWithNoSlotsNamesNobodyAndIsOpen(): void { $this->assertSame('', $assignee->recordedFor(run: $run)); $this->assertTrue($assignee->mayAnswer(run: $run, uid: 'anyone')); }//end testARunWithNoSlotsNamesNobodyAndIsOpen() + + /** + * ADDRESSING. A run can await several nodes at once, each with its own + * assignee. The run-level scan answers with the FIRST asked slot's, which + * refuses the second node's own audience — so a caller that knows which + * node its answer addresses is checked against THAT node's record. + */ + public function testAddressingANodeChecksThatNodesAssignee(): void { + $run = $this->runWithSlots([ + 'ask-a' => ['askedAt' => 'now', 'assignee' => 'alice'], + 'ask-b' => ['askedAt' => 'now', 'assignee' => 'bob'], + ]); + + $assignee = new FlowRunAssignee(); + + $this->assertSame('bob', $assignee->recordedFor(run: $run, nodeId: 'ask-b')); + $this->assertTrue($assignee->mayAnswer(run: $run, uid: 'bob', nodeId: 'ask-b')); + // Without addressing, the run-level scan would have refused bob. + $this->assertFalse($assignee->mayAnswer(run: $run, uid: 'bob')); + }//end testAddressingANodeChecksThatNodesAssignee() + + /** + * 🔴 Addressing can NARROW the check but never loosen it: a nodeId whose + * slot is not held (the node is not asking) falls back to the run-level + * rule, so naming a silent node is not a way around the guard on the node + * that IS asking. + */ + public function testAddressingASilentNodeFallsBackToTheRunLevelRule(): void { + $run = $this->runWithSlots(['ask-a' => ['askedAt' => 'now', 'assignee' => 'alice']]); + + $assignee = new FlowRunAssignee(); + + $this->assertSame('alice', $assignee->recordedFor(run: $run, nodeId: 'never-asked')); + $this->assertFalse($assignee->mayAnswer(run: $run, uid: 'mallory', nodeId: 'never-asked')); + }//end testAddressingASilentNodeFallsBackToTheRunLevelRule() + + /** + * ...and a slot that exists but never ASKED is equally silent: recording + * an assignee is not asking a question. + */ + public function testAddressingAnUnaskedSlotFallsBackToo(): void { + $run = $this->runWithSlots([ + 'ask-a' => ['askedAt' => 'now', 'assignee' => 'alice'], + 'idle' => ['assignee' => 'mallory'], + ]); + + $this->assertFalse( + (new FlowRunAssignee())->mayAnswer(run: $run, uid: 'mallory', nodeId: 'idle') + ); + }//end testAddressingAnUnaskedSlotFallsBackToo() + + /** + * An addressed node whose held slot records NO assignee is open — the + * unassigned contract holds per node, exactly as it does run-level. + */ + public function testAnAddressedUnassignedNodeIsOpen(): void { + $run = $this->runWithSlots([ + 'ask-a' => ['askedAt' => 'now', 'assignee' => 'alice'], + 'hook' => ['askedAt' => 'now'], + ]); + + $this->assertTrue( + (new FlowRunAssignee())->mayAnswer(run: $run, uid: 'anyone', nodeId: 'hook') + ); + }//end testAnAddressedUnassignedNodeIsOpen() }//end class diff --git a/tests/Unit/Service/Flow/FlowRunSignalServiceTest.php b/tests/Unit/Service/Flow/FlowRunSignalServiceTest.php new file mode 100644 index 0000000000..e39fa74a1b --- /dev/null +++ b/tests/Unit/Service/Flow/FlowRunSignalServiceTest.php @@ -0,0 +1,260 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-server-side-signal-passes-the-same-guard-as-the-http-resume + */ + +declare(strict_types=1); + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Exception\FlowSignalRefused; +use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\FlowRunSignalService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IGroupManager; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class FlowRunSignalServiceTest extends TestCase { + + /** + * The delivery primitive underneath the seam. + * + * @var FlowRunService&MockObject + */ + private FlowRunService $runner; + + /** + * Resolves uuids to runs. + * + * @var FlowRunMapper&MockObject + */ + private FlowRunMapper $mapper; + + /** + * Receives the refusal audit. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface $logger; + + protected function setUp(): void { + $this->runner = $this->createMock(FlowRunService::class); + $this->mapper = $this->createMock(FlowRunMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + }//end setUp() + + /** + * A suspended run whose resume slots are exactly as given. + * + * @param array $slots The per-node resume slots. + * + * @return FlowRun The run. + */ + private function suspendedRun(array $slots): FlowRun { + $run = new FlowRun(); + $run->setUuid('run-1'); + $run->setStatus(FlowRun::STATUS_SUSPENDED); + $run->setContext([FlowResumeState::CONTEXT_KEY => $slots]); + + return $run; + }//end suspendedRun() + + /** + * The seam under test. + * + * @param IGroupManager|null $groups The group resolver, when the test needs one. + * + * @return FlowRunSignalService The seam. + */ + private function seam(?IGroupManager $groups = null): FlowRunSignalService { + return new FlowRunSignalService( + mapper: $this->mapper, + runner: $this->runner, + logger: $this->logger, + groupManager: $groups + ); + }//end seam() + + public function testTheRecordedAssigneeMayAnswer(): void { + $run = $this->suspendedRun(['ask' => ['askedAt' => 'now', 'assignee' => 'alice']]); + + $this->runner->expects($this->once()) + ->method('signal') + ->with($run, ['decision' => 'approved']) + ->willReturn($run); + + $signalled = $this->seam()->signalRunAs(run: $run, payload: ['decision' => 'approved'], actorUid: 'alice'); + + $this->assertSame($run, $signalled); + }//end testTheRecordedAssigneeMayAnswer() + + /** + * 🔴 THE GROUP BRANCH — the half a hand-written copy of the guard tends to + * forget, refusing the step's own intended audience while reading as "the + * guard works". + */ + public function testAGroupMemberMayAnswerAGroupAssignedStep(): void { + $run = $this->suspendedRun(['ask' => ['askedAt' => 'now', 'assignee' => 'behandelaars']]); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('isInGroup')->with('carol', 'behandelaars')->willReturn(true); + + $this->runner->expects($this->once())->method('signal')->willReturn($run); + + $this->seam(groups: $groups)->signalRunAs(run: $run, payload: [], actorUid: 'carol'); + }//end testAGroupMemberMayAnswerAGroupAssignedStep() + + /** + * 🔴 THE MUTATION CHECK. Skip the guard and this test reds: the signal + * would be delivered instead of the refusal being thrown, and the + * never-called delivery assertion fails with it. + */ + public function testAStrangerIsRefusedAndTheRunIsUntouched(): void { + $run = $this->suspendedRun(['ask' => ['askedAt' => 'now', 'assignee' => 'alice']]); + + $this->runner->expects($this->never())->method('signal'); + + try { + $this->seam()->signalRunAs(run: $run, payload: ['decision' => 'approved'], actorUid: 'mallory'); + $this->fail('Expected the signal to be refused.'); + } catch (FlowSignalRefused $refused) { + $this->assertSame(FlowSignalRefused::NOT_ASSIGNEE, $refused->getReason()); + $this->assertSame('mallory', $refused->getActorUid()); + $this->assertSame('run-1', $refused->getRunUuid()); + } + }//end testAStrangerIsRefusedAndTheRunIsUntouched() + + /** + * The refusal is AUDITED by the engine, so the trace exists whether or not + * the consumer remembers to log it — a listener sees the refusal after its + * own object is already saved, and this record is what is left. + */ + public function testARefusalIsAudited(): void { + $run = $this->suspendedRun(['ask' => ['askedAt' => 'now', 'assignee' => 'alice']]); + + $this->logger->expects($this->once())->method('warning'); + + $this->expectException(FlowSignalRefused::class); + $this->seam()->signalRunAs(run: $run, payload: [], actorUid: 'mallory'); + }//end testARefusalIsAudited() + + public function testAnAnonymousActorIsRefusedOnAnAssignedStep(): void { + $run = $this->suspendedRun(['ask' => ['askedAt' => 'now', 'assignee' => 'alice']]); + + $this->runner->expects($this->never())->method('signal'); + + try { + $this->seam()->signalRunAs(run: $run, payload: [], actorUid: null); + $this->fail('Expected the signal to be refused.'); + } catch (FlowSignalRefused $refused) { + $this->assertSame(FlowSignalRefused::NOT_ASSIGNEE, $refused->getReason()); + // A blank actor is anonymous, and the refusal says so. + $this->assertNull($refused->getActorUid()); + } + }//end testAnAnonymousActorIsRefusedOnAnAssignedStep() + + /** + * The unassigned contract is UNCHANGED by the seam: webhook and child-run + * signals are not human decisions, and silence still means anyone. + */ + public function testAnUnassignedStepIsAnswerableByAnyone(): void { + $run = $this->suspendedRun(['hook' => ['askedAt' => 'now']]); + + $this->runner->expects($this->once())->method('signal')->willReturn($run); + + $this->seam()->signalRunAs(run: $run, payload: [], actorUid: 'anyone'); + }//end testAnUnassignedStepIsAnswerableByAnyone() + + /** + * ADDRESSING. A caller that knows which node its answer addresses is + * checked against that node's own assignee — the run-level scan would have + * refused the second node's audience. + */ + public function testAddressingANodeChecksThatNodesAssignee(): void { + $run = $this->suspendedRun([ + 'ask-a' => ['askedAt' => 'now', 'assignee' => 'alice'], + 'ask-b' => ['askedAt' => 'now', 'assignee' => 'bob'], + ]); + + $this->runner->expects($this->once())->method('signal')->willReturn($run); + + $this->seam()->signalRunAs(run: $run, payload: [], actorUid: 'bob', nodeId: 'ask-b'); + }//end testAddressingANodeChecksThatNodesAssignee() + + /** + * 🔴 ...and addressing can never LOOSEN the guard: a node that is not + * asking has no slot, so the run-level rule still refuses the stranger. + */ + public function testAddressingASilentNodeCannotLoosenTheGuard(): void { + $run = $this->suspendedRun(['ask-a' => ['askedAt' => 'now', 'assignee' => 'alice']]); + + $this->runner->expects($this->never())->method('signal'); + + $this->expectException(FlowSignalRefused::class); + $this->seam()->signalRunAs(run: $run, payload: [], actorUid: 'mallory', nodeId: 'never-asked'); + }//end testAddressingASilentNodeCannotLoosenTheGuard() + + public function testANonSuspendedRunRefusesWithItsOwnReason(): void { + $run = $this->suspendedRun([]); + $run->setStatus(FlowRun::STATUS_RUNNING); + + // The primitive's contract: null means "not suspended". + $this->runner->method('signal')->willReturn(null); + + try { + $this->seam()->signalRunAs(run: $run, payload: [], actorUid: 'alice'); + $this->fail('Expected the signal to be refused.'); + } catch (FlowSignalRefused $refused) { + $this->assertSame(FlowSignalRefused::NOT_SUSPENDED, $refused->getReason()); + } + }//end testANonSuspendedRunRefusesWithItsOwnReason() + + public function testSignalAsResolvesTheRunByUuid(): void { + $run = $this->suspendedRun(['ask' => ['askedAt' => 'now', 'assignee' => 'alice']]); + + $this->mapper->method('findByUuid')->with('run-1')->willReturn($run); + $this->runner->expects($this->once())->method('signal')->willReturn($run); + + $signalled = $this->seam()->signalAs(runUuid: 'run-1', payload: ['ok' => true], actorUid: 'alice'); + + $this->assertSame($run, $signalled); + }//end testSignalAsResolvesTheRunByUuid() + + public function testSignalAsRefusesAnUnknownUuid(): void { + $this->mapper->method('findByUuid')->willThrowException(new DoesNotExistException('nope')); + $this->runner->expects($this->never())->method('signal'); + + try { + $this->seam()->signalAs(runUuid: 'ghost', payload: [], actorUid: 'alice'); + $this->fail('Expected the signal to be refused.'); + } catch (FlowSignalRefused $refused) { + $this->assertSame(FlowSignalRefused::RUN_NOT_FOUND, $refused->getReason()); + $this->assertSame('ghost', $refused->getRunUuid()); + } + }//end testSignalAsRefusesAnUnknownUuid() +}//end class diff --git a/tests/Unit/Service/Flow/RegistryStepDispatcherRunAsTest.php b/tests/Unit/Service/Flow/RegistryStepDispatcherRunAsTest.php new file mode 100644 index 0000000000..8353525539 --- /dev/null +++ b/tests/Unit/Service/Flow/RegistryStepDispatcherRunAsTest.php @@ -0,0 +1,307 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-engine-consumer-seams/specs/flow-engine-consumer-seams/spec.md#requirement-a-contributed-node-executes-under-the-runs-acting-identity + */ + +declare(strict_types=1); + +namespace Unit\Service\Flow { + + use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; + use OCA\OpenRegister\Service\Flow\FlowRunAsScope; + use OCA\OpenRegister\Service\Flow\FlowRunService; + use OCA\OpenRegister\Service\Flow\IFlowNode; + use OCA\OpenRegister\Service\Flow\RegistryStepDispatcher; + use OCA\OpenRegister\Service\ObjectService; + use OCP\IUser; + use OCP\IUserManager; + use PHPUnit\Framework\TestCase; + use RuntimeException; + + class RegistryStepDispatcherRunAsTest extends TestCase { + + /** + * A dispatcher resolving every type to the given node. + * + * @param IFlowNode $node The node the registry answers with. + * @param FlowRunAsScope|null $scope The identity scope, when the test has one. + * + * @return RegistryStepDispatcher The dispatcher under test. + */ + private function dispatcher(IFlowNode $node, ?FlowRunAsScope $scope): RegistryStepDispatcher { + $registry = $this->createMock(FlowNodeRegistry::class); + $registry->method('get')->willReturn($node); + + return new RegistryStepDispatcher(registry: $registry, scope: $scope); + }//end dispatcher() + + /** + * A REAL scope over a stub object service that records who it acted as. + * + * @param string $uid The uid that resolves. + * @param boolean $enabled Whether that account is enabled. + * @param string|null $scopedAs Receives the uid runAs() was entered with. + * + * @return FlowRunAsScope The scope. + */ + private function scope(string $uid, bool $enabled, ?string &$scopedAs): FlowRunAsScope { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $user->method('isEnabled')->willReturn($enabled); + + $manager = $this->createMock(IUserManager::class); + $manager->method('get')->willReturnCallback( + static function (string $asked) use ($uid, $user): ?IUser { + if ($asked === $uid) { + return $user; + } + + return null; + } + ); + + $objects = $this->createMock(ObjectService::class); + $objects->method('runAs')->willReturnCallback( + static function (IUser $user, callable $operation) use (&$scopedAs): mixed { + $scopedAs = $user->getUID(); + + return $operation(); + } + ); + + return new FlowRunAsScope(userManager: $manager, objectService: $objects); + }//end scope() + + /** + * THE SEAM ITSELF: a contributed node's work runs inside + * `ObjectService::runAs()` scoped to the run's identity — the node wrote + * no wrapper and does not know one exists. + */ + public function testAContributedNodesWorkExecutesAsTheRunOwner(): void { + $node = new ContributedRecordingNode(); + $scopedAs = null; + + $dispatcher = $this->dispatcher(node: $node, scope: $this->scope(uid: 'alice', enabled: true, scopedAs: $scopedAs)); + + $out = $dispatcher->dispatch( + ['id' => 'n1', 'type' => 'dossiq.write'], + [['case' => 1]], + [FlowRunService::RUN_AS_CONTEXT_KEY => 'alice'] + ); + + $this->assertSame('alice', $scopedAs, 'the node must have run inside runAs(alice)'); + $this->assertTrue($node->executedInsideScope, 'the WORK, not just the dispatch, must sit inside the scope'); + $this->assertSame([['case' => 1, 'wrote' => true]], $out); + }//end testAContributedNodesWorkExecutesAsTheRunOwner() + + /** + * 🔴 An identity that resolves to nobody refuses the step LOUDLY — the + * silent alternative is the node writing as whoever the ambient session + * carries, which under the worker is nobody and on a request is somebody + * else. + */ + public function testAnUnresolvableIdentityRefusesLoudly(): void { + $node = new ContributedRecordingNode(); + $scopedAs = null; + + $dispatcher = $this->dispatcher(node: $node, scope: $this->scope(uid: 'alice', enabled: true, scopedAs: $scopedAs)); + + try { + $dispatcher->dispatch( + ['id' => 'n1', 'type' => 'dossiq.write'], + [], + [FlowRunService::RUN_AS_CONTEXT_KEY => 'ghost'] + ); + $this->fail('Expected the step to be refused.'); + } catch (RuntimeException $refused) { + $this->assertStringContainsString('ghost', $refused->getMessage()); + } + + $this->assertFalse($node->executedInsideScope, 'a refused step must not have executed'); + }//end testAnUnresolvableIdentityRefusesLoudly() + + /** + * 🔴 A DISABLED account refuses too: a run parked for weeks must not + * resume with the rights of somebody who has since been offboarded. + */ + public function testADisabledIdentityRefusesLoudly(): void { + $node = new ContributedRecordingNode(); + $scopedAs = null; + + $dispatcher = $this->dispatcher(node: $node, scope: $this->scope(uid: 'former', enabled: false, scopedAs: $scopedAs)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/disabled/'); + + $dispatcher->dispatch( + ['id' => 'n1', 'type' => 'dossiq.write'], + [], + [FlowRunService::RUN_AS_CONTEXT_KEY => 'former'] + ); + }//end testADisabledIdentityRefusesLoudly() + + /** + * A run that names NO identity runs the node bare — the interactive + * path, where the ambient session already answers the permission checks. + */ + public function testNoIdentityRunsTheNodeBare(): void { + $node = new ContributedRecordingNode(); + $scopedAs = null; + + $dispatcher = $this->dispatcher(node: $node, scope: $this->scope(uid: 'alice', enabled: true, scopedAs: $scopedAs)); + + $dispatcher->dispatch(['id' => 'n1', 'type' => 'dossiq.write'], [], []); + + $this->assertNull($scopedAs, 'with no runAs there is nothing to scope to'); + $this->assertTrue($node->executedInsideScope); + }//end testNoIdentityRunsTheNodeBare() + + /** + * THE BOUNDARY: the engine's own nodes scope themselves — with their own + * validation wording and skip-when semantics — and are NOT wrapped, even + * when the run names an identity the scope would refuse. Were the + * dispatcher wrapping them, this dispatch would throw on the + * unresolvable uid; instead the node runs and handles its own identity. + */ + public function testAnEngineOwnedNodeIsNotWrapped(): void { + $node = new \OCA\OpenRegister\Tests\Unit\Service\Flow\EngineNamespacedRecordingNode(); + $scopedAs = null; + + $dispatcher = $this->dispatcher(node: $node, scope: $this->scope(uid: 'alice', enabled: true, scopedAs: $scopedAs)); + + $dispatcher->dispatch( + ['id' => 'n1', 'type' => 'openregister.write'], + [], + [FlowRunService::RUN_AS_CONTEXT_KEY => 'ghost'] + ); + + $this->assertNull($scopedAs, 'an engine-owned node manages its own identity'); + $this->assertTrue($node->executedInsideScope); + }//end testAnEngineOwnedNodeIsNotWrapped() + + /** + * The ESCAPE HATCH: a contributed node declaring IFlowSelfScopedNode + * runs bare and takes on the obligations the interface documents. + */ + public function testASelfScopedContributedNodeIsNotWrapped(): void { + $node = new SelfScopedContributedNode(); + $scopedAs = null; + + $dispatcher = $this->dispatcher(node: $node, scope: $this->scope(uid: 'alice', enabled: true, scopedAs: $scopedAs)); + + $dispatcher->dispatch( + ['id' => 'n1', 'type' => 'dossiq.system'], + [], + [FlowRunService::RUN_AS_CONTEXT_KEY => 'ghost'] + ); + + $this->assertNull($scopedAs); + $this->assertTrue($node->executedInsideScope); + }//end testASelfScopedContributedNodeIsNotWrapped() + + /** + * A dispatcher built by hand with NO scope — the flow tester, the node + * unit tests — keeps dispatching bare. The harness's existing contract, + * unchanged; every production construction site supplies a scope. + */ + public function testAScopelessDispatcherRunsBare(): void { + $node = new ContributedRecordingNode(); + + $dispatcher = $this->dispatcher(node: $node, scope: null); + + $dispatcher->dispatch( + ['id' => 'n1', 'type' => 'dossiq.write'], + [], + [FlowRunService::RUN_AS_CONTEXT_KEY => 'alice'] + ); + + $this->assertTrue($node->executedInsideScope); + }//end testAScopelessDispatcherRunsBare() + }//end class + + /** + * A contributed node: any class OUTSIDE `OCA\OpenRegister\`. Records that + * (and with what) it executed, standing in for a leaf app's write node. + */ + class ContributedRecordingNode implements IFlowNode { + + /** + * Whether execute() ran. + * + * @var boolean + */ + public bool $executedInsideScope = false; + + public function getId(): string { + return 'dossiq.write'; + }//end getId() + + public function getDisplayName(): string { + return 'Contributed write'; + }//end getDisplayName() + + public function getDescription(): string { + return 'A leaf app node that writes.'; + }//end getDescription() + + public function getIcon(): string { + return 'icon-edit'; + }//end getIcon() + + public function isAvailableForScope(int $scope): bool { + return true; + }//end isAvailableForScope() + + public function validateConfig(array $config): void { + }//end validateConfig() + + public function execute(array $items, array $config, array $context): array { + $this->executedInsideScope = true; + + return array_map( + static function (array $item): array { + $item['wrote'] = true; + + return $item; + }, + $items + ); + }//end execute() + }//end class + + /** + * The escape hatch declared: still contributed, but self-scoped. + */ + class SelfScopedContributedNode extends ContributedRecordingNode implements \OCA\OpenRegister\Service\Flow\IFlowSelfScopedNode { + + }//end class +}//end namespace + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow { + + /** + * A node under `OCA\OpenRegister\` — the dispatcher must treat it as + * engine-owned and leave its identity handling to the node itself. + */ + class EngineNamespacedRecordingNode extends \Unit\Service\Flow\ContributedRecordingNode { + + }//end class +}//end namespace From 2839ab901a6d3fe8e53e75d29f419fe840b15b69 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 13:31:25 +0200 Subject: [PATCH 49/68] fix(search): count only the chunk owners the caller can actually see (#3333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(search): count only the chunk owners the caller can actually see `_content_search` reported a total that included chunk owners it then refused to return. Measured on the dev instance 2026-09-02, UNAUTHENTICATED, against OpenCatalogi's #[PublicPage] search: GET /apps/opencatalogi/api/search?_search=&_content=true -> {"results":[],"total":1} The owning document was soft-deleted, so the row was correctly withheld and the count was not. An anonymous caller could probe a phrase and learn from the count alone that a document containing it exists. A count is an answer, so it has to obey the same visibility rules as the rows. The over-count was deliberate and documented: counting the distinct chunk-owner set BEFORE the resolve loop kept `total` stable across pages, because the loop is clamped to the page's remaining room. Stability was the right goal; the upper bound was the wrong way to reach it. Scope mismatch, RBAC, tenancy and a soft-deleted owner each drop a row without touching the count. RESOLVE FIRST, THEN COUNT AND PAGE. Every candidate is resolved, `total` counts what resolved, and the page is a slice of that same set. Stability is preserved and is now a property of the query rather than a guess: page 1 and page 3 resolve the same candidates and report the same number. The existing cross-page test still passes unchanged. Cost: up to CHUNK_CANDIDATE_LIMIT resolves per call instead of up to the page's room. That is the worst case this class already budgets for and documents on that constant, and `_content_search` is opt-in. Six tests asserted the over-count as intentional. They are rewritten, not deleted: each now pins the visibility rule it was documenting the absence of. The clamping test additionally proves the page is still clamped while the total still counts the resolvable owner. * test: cover the two scope shapes resolveScope() had no test for Coverage Baseline Protection was the only red on this PR. The change itself removed six covered statements — the distinct-owner upper bound, replaced by a count of the owners the caller can actually see — so the ratio slipped 96.33% -> 96.12% without any new uncovered code. The honest way back up is to cover something real rather than to reinstate dead statements. resolveScope() reads four keys per dimension, and this file exercised '_register' (singular) and '_schemas' (plural) — one of each, never the other diagonal. So a caller passing '_registers' or '_schema' got a scope assembled by branches nothing had ever run. Both are added, and both directions: the plural register list narrowing to a match, and an object outside it being skipped. The second is what makes the first mean anything, since an empty scope matches everything and a '_registers' branch that silently contributed nothing would pass the match test on its own. --------- Co-authored-by: Conduction Release Bot --- lib/Service/Object/ContentSearchHandler.php | 71 ++++++------ .../Object/ContentSearchHandlerTest.php | 107 ++++++++++++++---- 2 files changed, 120 insertions(+), 58 deletions(-) diff --git a/lib/Service/Object/ContentSearchHandler.php b/lib/Service/Object/ContentSearchHandler.php index 5d909afc7b..60ce6ef1fc 100644 --- a/lib/Service/Object/ContentSearchHandler.php +++ b/lib/Service/Object/ContentSearchHandler.php @@ -168,44 +168,34 @@ public function augmentWithChunkMatches( } } - // Stable total across pages: use the distinct-chunk-owner set (grouped - // by entity_type+entity_id) as the chunk-arm upper bound BEFORE the - // room-clamped resolve loop. Without this, `$total` drifts across - // pages (page 1 appends 0 → reports metaTotal; page 3 appends N → - // reports metaTotal+N). + // RESOLVE FIRST, THEN COUNT AND PAGE. `$total` is the number of chunk + // owners this caller can actually SEE, and the page is a slice of that + // same resolved set. // - // ACCEPTED OVER-COUNT — the upper bound is a THEORETICAL maximum: - // scope/register/schema mismatch, tenant/RBAC filtering, and unresolved - // file→object joins can each reduce the actually-appended count to zero - // without changing `$total`. In a multi-register corpus where the - // scope filters out most chunks, the client's `pages = ceil(total/limit)` - // will point at pages that render empty. This is the accepted - // trade-off for pagination stability across pages of the same query; - // the alternative (recompute `$total` per page from the actually- - // appended count) reintroduces the drift the fix is meant to close. - // Pre-filtering `$chunkHits` by scope + a batch `MagicMapper::findMany` - // would tighten the bound but requires the bulk mapper methods - // deferred with the batch-resolve refactor. - $distinctChunkOwners = []; - foreach ($chunkHits as $hit) { - $key = ($hit['entity_type'] ?? 'file') . ':' . ($hit['entity_id'] ?? ''); - $distinctChunkOwners[$key] = true; - } - - $chunkOwnerUpperBound = count($distinctChunkOwners); - + // This used to count the distinct chunk-owner set BEFORE resolving, as + // a deliberate upper bound, to keep `$total` stable across pages. + // Stability was the right goal; the upper bound was the wrong way to + // reach it, because scope mismatch, RBAC, tenancy and a soft-deleted + // owner each drop a row from `results` without touching `$total`. + // + // Measured on the dev instance 2026-09-02, unauthenticated, against + // OpenCatalogi's #[PublicPage] search: a document that had been + // soft-deleted still answered `{"results":[],"total":1}`. An anonymous + // caller could therefore probe a phrase and learn from the count alone + // that a document containing it exists, while being correctly refused + // the document itself. A count is an answer, so it has to obey the same + // visibility rules as the rows. + // + // Resolving every candidate rather than only `$room` of them costs at + // most CHUNK_CANDIDATE_LIMIT resolves, which is the worst case this + // class already budgets for and documents on that constant. `$total` + // stays stable across pages because the resolved set is a property of + // the query, not of the page: page 1 and page 3 resolve the same + // candidates and report the same number. $scope = $this->resolveScope(query: $query); - $appended = []; - $room = PHP_INT_MAX; - if ($limit > 0) { - $room = max(0, $limit - count($results)); - } + $resolved = []; foreach ($chunkHits as $hit) { - if (count($appended) >= $room) { - break; - } - $object = $this->resolveAndDedupeHit( hit: $hit, seenUuids: $seenUuids, @@ -217,13 +207,22 @@ public function augmentWithChunkMatches( continue; } + // Seed the dedupe set as we go: two chunks of the same document + // are one owner, and must be counted once. $seenUuids[$object->getUuid()] = true; - $appended[] = $object; + $resolved[] = $object; }//end foreach + $room = PHP_INT_MAX; + if ($limit > 0) { + $room = max(0, $limit - count($results)); + } + + $appended = array_slice($resolved, 0, $room); + return [ 'results' => array_merge($results, $appended), - 'total' => $total + $chunkOwnerUpperBound, + 'total' => $total + count($resolved), ]; }//end augmentWithChunkMatches() diff --git a/tests/Unit/Service/Object/ContentSearchHandlerTest.php b/tests/Unit/Service/Object/ContentSearchHandlerTest.php index 17d345132d..8ef98b4bb6 100644 --- a/tests/Unit/Service/Object/ContentSearchHandlerTest.php +++ b/tests/Unit/Service/Object/ContentSearchHandlerTest.php @@ -201,10 +201,10 @@ public function testFileChunkWithUnresolvableOwningObjectIsSkippedSilently(): vo ); $this->assertSame([], $result['results']); - // Total is metaTotal + distinct-chunk-owners upper bound (1 chunk hit - // even though it turned out to be unresolvable). See "stable total - // across pages" fix — over-count is intentional and accepted. - $this->assertSame(4, $result['total']); + // The unresolvable owner is NOT counted. `total` is the number of + // chunk owners this caller can actually see, so a chunk whose owning + // object cannot be resolved contributes nothing to it. + $this->assertSame(3, $result['total']); }//end testFileChunkWithUnresolvableOwningObjectIsSkippedSilently() public function testResolveExceptionIsCaughtLoggedAndSkipped(): void { @@ -225,9 +225,10 @@ public function testResolveExceptionIsCaughtLoggedAndSkipped(): void { ); $this->assertSame([], $result['results']); - // Total is upper-bound: chunk-owner set included the doomed id - // before the resolve threw. See "stable total across pages" fix. - $this->assertSame(1, $result['total']); + // A hit whose resolve THREW is not a hit this caller can see, so it + // is not counted either. Swallowing the exception must not leave the + // row behind in the count. + $this->assertSame(0, $result['total']); }//end testResolveExceptionIsCaughtLoggedAndSkipped() // ========================================================================= @@ -256,10 +257,10 @@ public function testObjectAlreadyMatchedByMetadataArmIsNotDuplicated(): void { ); $this->assertCount(1, $result['results']); - // Total is upper-bound: chunk-owner set had 1 hit even though it - // deduped against the metadata arm. Over-count is intentional per - // "stable total across pages" fix — pagination stays consistent. - $this->assertSame(2, $result['total']); + // One object, counted once. It is already in the metadata arm's + // total, so the chunk arm must not add it a second time — an object + // that matches BOTH ways is still one result. + $this->assertSame(1, $result['total']); }//end testObjectAlreadyMatchedByMetadataArmIsNotDuplicated() // ========================================================================= @@ -282,9 +283,8 @@ public function testObjectOutsideRequestedRegisterIsSkipped(): void { ); $this->assertSame([], $result['results']); - // Total is upper-bound: the out-of-scope chunk is counted before the - // scope filter runs. Over-count accepted per "stable total" fix. - $this->assertSame(1, $result['total']); + // Out of the caller's register scope is out of the caller's count. + $this->assertSame(0, $result['total']); }//end testObjectOutsideRequestedRegisterIsSkipped() public function testObjectOutsideRequestedSchemasIsSkipped(): void { @@ -303,9 +303,8 @@ public function testObjectOutsideRequestedSchemasIsSkipped(): void { ); $this->assertSame([], $result['results']); - // Total is upper-bound: the out-of-scope chunk is counted before the - // scope filter runs. Over-count accepted per "stable total" fix. - $this->assertSame(1, $result['total']); + // Out of the caller's schema scope is out of the caller's count. + $this->assertSame(0, $result['total']); }//end testObjectOutsideRequestedSchemasIsSkipped() public function testUnscopedQueryMatchesAnyRegisterOrSchema(): void { @@ -328,6 +327,64 @@ public function testUnscopedQueryMatchesAnyRegisterOrSchema(): void { $this->assertSame($matched, $result['results'][0]); }//end testUnscopedQueryMatchesAnyRegisterOrSchema() + /** + * `_registers` (plural, array) and `_schema` (singular) are supported query + * shapes with no test until now. `resolveScope()` reads four keys per + * dimension — `@self.register`, `_register`, `register` and the plural + * `_registers` — and the two shapes exercised elsewhere in this file are + * `_register` and `_schemas`, i.e. one singular and one plural, never the + * other diagonal. A caller using the untested pair got a scope assembled by + * branches nothing had ever run. + * + * Asserted as a MATCH rather than a skip, so it fails if either branch stops + * contributing its id: a scope that silently resolved to empty would let this + * object through for the wrong reason and read identically. + */ + public function testPluralRegistersAndSingularSchemaBothNarrowTheScope(): void { + $this->chunkMapper->method('searchByKeyword')->willReturn( + [ + ['entity_type' => 'object', 'entity_id' => '42', 'score' => 0.8, 'chunk_text' => 'x', 'chunk_index' => 0, 'metadata' => []], + ] + ); + $matched = $this->makeObject(42, register: '7', schema: '3'); + $this->objectMapper->method('find')->willReturn($matched); + + $result = $this->handler->augmentWithChunkMatches( + query: ['_search' => 'quarterly report', '_registers' => [7, 8], '_schema' => 3], + results: [], + total: 0, + limit: 20 + ); + + $this->assertCount(1, $result['results']); + $this->assertSame($matched, $result['results'][0]); + $this->assertSame(1, $result['total']); + }//end testPluralRegistersAndSingularSchemaBothNarrowTheScope() + + /** + * The same pair, narrowing the other way: an object OUTSIDE the plural + * register list is skipped. Without this, the test above would pass even if + * `_registers` contributed nothing, because an empty scope matches everything. + */ + public function testObjectOutsideThePluralRegisterListIsSkipped(): void { + $this->chunkMapper->method('searchByKeyword')->willReturn( + [ + ['entity_type' => 'object', 'entity_id' => '42', 'score' => 0.8, 'chunk_text' => 'x', 'chunk_index' => 0, 'metadata' => []], + ] + ); + $this->objectMapper->method('find')->willReturn($this->makeObject(42, register: '99', schema: '3')); + + $result = $this->handler->augmentWithChunkMatches( + query: ['_search' => 'quarterly report', '_registers' => [7, 8], '_schema' => 3], + results: [], + total: 0, + limit: 20 + ); + + $this->assertSame([], $result['results']); + $this->assertSame(0, $result['total']); + }//end testObjectOutsideThePluralRegisterListIsSkipped() + // ========================================================================= // Page-limit clamping // ========================================================================= @@ -338,7 +395,12 @@ public function testAppendedRowsAreClampedToRemainingRoomOnThePage(): void { ['entity_type' => 'object', 'entity_id' => '42', 'score' => 0.8, 'chunk_text' => 'x', 'chunk_index' => 0, 'metadata' => []], ] ); - $this->objectMapper->expects($this->never())->method('find'); + // The hit RESOLVES even though this page has no room for it. That is + // the change: `total` can only be truthful if visibility is actually + // checked, and visibility cannot be known without resolving. The cost + // is bounded by CHUNK_CANDIDATE_LIMIT, which this class already + // documents as its worst case. + $this->objectMapper->method('find')->willReturn($this->makeObject(42)); // limit=1, already 1 metadata-match result -> zero room for chunk-only appends. $result = $this->handler->augmentWithChunkMatches( @@ -348,11 +410,12 @@ public function testAppendedRowsAreClampedToRemainingRoomOnThePage(): void { limit: 1 ); + // The page is still clamped: nothing is appended beyond the room. $this->assertCount(1, $result['results']); - // Total is upper-bound = metaTotal + distinct chunk-owner count - // (1 chunk hit, room=0 so nothing appended). This is the whole point - // of the "stable total" fix: page 2 of the same query would then - // append this chunk, and the total stays 2 across both pages. + // And the total still counts the resolvable owner, so page 2 of the + // same query reports the same number. Stability now comes from + // resolving the same candidate set every page, not from counting + // candidates nobody can see. $this->assertSame(2, $result['total']); }//end testAppendedRowsAreClampedToRemainingRoomOnThePage() From 17a44780099c009f14d91389e565602802815df7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 14:11:42 +0200 Subject: [PATCH 50/68] test(e2e): the VTODO deep link serves the shell, it no longer redirects (#3337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This block asserted 302/303 with Location containing '#/flow-tasks/'. Both halves are gone: #3315 rewrote TaskController::open() to return a TemplateResponse serving the SPA shell directly, and #3270 had already moved the router off hash routing so that fragment addressed nothing. It survived both changes because the block SKIPS whenever the assignee has no VTODO-capable calendar, and CI's admin has none — 'No VTODO-supporting calendar found for user admin' shows up in the server logs. So it has been asserting a contract that stopped existing, without ever running to say so. Now asserts what open() actually does: 200, and the shell rather than an API payload or an error page. Filed as #3313 on the premise that the redirect pointed at a route that did not exist; the route (/flow-tasks/:uuid, flow-task-detail in src/main.js) exists now and the redirect is gone, so nothing is left to decide. Co-authored-by: Conduction Release Bot --- tests/e2e/task-projections.spec.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/e2e/task-projections.spec.ts b/tests/e2e/task-projections.spec.ts index bff64b70a7..e86ca5ec38 100644 --- a/tests/e2e/task-projections.spec.ts +++ b/tests/e2e/task-projections.spec.ts @@ -157,12 +157,26 @@ test.describe('flow-task-projections: the calendar projection', () => { expect(url).toContain(`${OPEN}/${task.uuid}`) expect(url).not.toContain('/api/') - // Following it lands in the app (a redirect into the task route), not a 404. + // Following it lands in the app, not a 404 and not the API. + // + // THIS ASSERTED A REDIRECT AND A HASH URL, AND BOTH ARE GONE. + // `TaskController::open()` used to answer 302 with + // `Location: …/#/flow-tasks/`; #3315 rewrote it to serve the SPA + // shell directly, and #3270 had already moved the router off hash + // routing so that fragment addressed nothing. The old assertion + // survived both changes because this block SKIPS on any instance whose + // assignee has no VTODO-capable calendar, which CI's `admin` does not + // have — so it has been asserting a contract that no longer exists, + // without ever running to say so. const followed = await request.get(url!, { maxRedirects: 0 }) - expect([302, 303]).toContain(followed.status()) - expect(followed.headers().location).toContain( - `#/flow-tasks/${task.uuid}`, - ) + expect( + followed.status(), + 'the deep link serves the app shell directly, no redirect hop', + ).toBe(200) + expect( + await followed.text(), + 'the shell, not an API payload or an error page', + ).toContain(' Date: Wed, 2 Sep 2026 14:22:36 +0200 Subject: [PATCH 51/68] fix(tenant): follow the merge before the UUID becomes a scope (#3336) resolveMergeTarget() and findByUuidFollowingMerge() were built and then called by nothing, so a merge was recorded and never followed. A user whose active organisation had been merged away kept resolving to the row that was merged: it still loads, its queries still return rows, and every one of them runs under a tenant boundary that no longer applies. Both entries into fetchActiveOrganisationFromDatabase() walk it now. The stored active UUID is the obvious one. The auto-pick is the one that matters more: a user's membership list can still name a merged-away organisation, so without the walk there the first login after a merge writes the dead UUID straight back into config and re-creates the defect. The walk is guarded on isMerged(), because the flag lives on the row that was merged away and almost no row carries it, so the common case costs no query at all. Membership is then checked against the SURVIVOR, which fails closed: a user the merge did not carry over falls through to their own organisations rather than being handed a tenant on the strength of a membership that ended. An unresolvable survivor keeps the row already in hand and logs, because a read path is the wrong place to throw over a data defect. Co-authored-by: Conduction Release Bot --- lib/Service/OrganisationService.php | 106 ++++++- .../consolidate-organisation-on-or/tasks.md | 10 +- .../ActiveOrganisationFollowsMergeTest.php | 267 ++++++++++++++++++ 3 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Service/ActiveOrganisationFollowsMergeTest.php diff --git a/lib/Service/OrganisationService.php b/lib/Service/OrganisationService.php index 4edb7ddbb8..54ec8dce6a 100644 --- a/lib/Service/OrganisationService.php +++ b/lib/Service/OrganisationService.php @@ -1166,6 +1166,92 @@ private function hasAdminGroupInAuthorization(array $authorization): bool { return false; }//end hasAdminGroupInAuthorization() + /** + * Resolve an organisation through its merge chain + * + * A merge is recorded on the row that was merged AWAY, so a UUID held + * anywhere outside the mapper — a user's stored active organisation, a + * membership list, a federation peer's reference — can name an organisation + * that no longer owns anything. The resolver is bounded and cycle-guarded + * and never fails open; if the survivor cannot be loaded this returns the + * organisation it was handed, which is a real row. + * + * @param Organisation $organisation The (possibly merged-away) organisation. + * + * @return Organisation The surviving organisation, or the input if unresolvable. + * + * @spec openspec/changes/consolidate-organisation-on-or/tasks.md#3-merge-resolution + */ + private function followMerge(Organisation $organisation): Organisation { + // The overwhelming majority of rows were never merged, and those need no + // walk at all: the flag lives on the row that was merged AWAY. + if ($organisation->isMerged() === false) { + return $organisation; + } + + $uuid = (string) $organisation->getUuid(); + if ($uuid === '') { + return $organisation; + } + + try { + return $this->organisationMapper->findByUuidFollowingMerge(uuid: $uuid); + } catch (Exception $e) { + // An unresolvable survivor is a data defect, not a reason to hand + // the caller nothing: the row we already have is real. + $this->logger->warning( + message: '[OrganisationService] Could not resolve merge target; keeping the organisation as stored', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'organisationUuid' => $uuid, + 'error' => $e->getMessage(), + ] + ); + return $organisation; + } + }//end followMerge() + + /** + * Write a followed merge back into the user's stored active organisation + * + * Following the merge on every read is correct but leaves the dead UUID in + * config forever, so every later read pays the walk. Persisting the survivor + * once makes the walk a one-off per user per merge. + * + * @param string $userId The user whose setting is being corrected. + * @param string $storedUuid The UUID currently in user config. + * @param Organisation $organisation The organisation the merge resolved to. + * + * @return void + * + * @spec openspec/changes/consolidate-organisation-on-or/tasks.md#3-merge-resolution + */ + private function persistFollowedMerge(string $userId, string $storedUuid, Organisation $organisation): void { + $survivorUuid = (string) $organisation->getUuid(); + if ($survivorUuid === '' || $survivorUuid === $storedUuid) { + return; + } + + $this->config->setUserValue( + $userId, + self::APP_NAME, + self::CONFIG_ACTIVE_ORGANISATION, + $survivorUuid + ); + + $this->logger->info( + message: '[OrganisationService] Active organisation followed a merge', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'userId' => $userId, + 'from' => $storedUuid, + 'to' => $survivorUuid, + ] + ); + }//end persistFollowedMerge() + /** * Fetch active organisation from database (cache miss fallback) * @@ -1190,8 +1276,21 @@ private function fetchActiveOrganisationFromDatabase(string $userId, ?array $pre try { $organisation = $this->organisationMapper->findByUuid($activeUuid); - // Verify user still has access to this organisation. + // Follow the merge chain BEFORE the UUID is used as a scope. A + // merged-away organisation no longer owns its data, so using the + // stored UUID literally would run every scoped query for this + // user under a tenant boundary that no longer applies. + $organisation = $this->followMerge(organisation: $organisation); + + // Verify user still has access to this organisation. Note this is + // membership of the SURVIVOR: a user the merge did not carry over + // falls through to their own organisation list, which fails closed. if ($organisation->hasUser($userId) === true) { + $this->persistFollowedMerge( + userId: $userId, + storedUuid: $activeUuid, + organisation: $organisation + ); return $organisation; } @@ -1234,7 +1333,10 @@ function ($a, $b) { } ); - $oldestOrg = $organisations[0]; + // The user's membership list can still name a merged-away + // organisation, so the auto-pick resolves too — otherwise the very + // first login after a merge writes the dead UUID back into config. + $oldestOrg = $this->followMerge(organisation: $organisations[0]); // Set in user configuration. $this->config->setUserValue( diff --git a/openspec/changes/consolidate-organisation-on-or/tasks.md b/openspec/changes/consolidate-organisation-on-or/tasks.md index 01fd6bf504..e5029e1174 100644 --- a/openspec/changes/consolidate-organisation-on-or/tasks.md +++ b/openspec/changes/consolidate-organisation-on-or/tasks.md @@ -22,9 +22,13 @@ - [x] 3.1 Implement `OrganisationMapper::resolveMergeTarget()` — bounded, cycle-guarded, never failing open. - [x] 3.2 Implement `findByUuidFollowingMerge()` as the read counterpart. -- [ ] 3.3 Call the resolver from the live tenant-resolution path. HELD: this - changes which rows every scoped query returns for a merged organisation, - so it needs its own change and its own regression suite. +- [x] 3.3 Call the resolver from the live tenant-resolution path. Both entries + into `fetchActiveOrganisationFromDatabase()` now walk it: the stored + active UUID, and the oldest-membership auto-pick that runs when nothing + is stored. The walk is guarded on `isMerged()`, so an unmerged row costs + no query, and the survivor is written back to user config so the walk is + a one-off per user per merge. Six regression tests in + `tests/Unit/Service/ActiveOrganisationFollowsMergeTest.php`. ## 4. Tests diff --git a/tests/Unit/Service/ActiveOrganisationFollowsMergeTest.php b/tests/Unit/Service/ActiveOrganisationFollowsMergeTest.php new file mode 100644 index 0000000000..e0c8fb83ad --- /dev/null +++ b/tests/Unit/Service/ActiveOrganisationFollowsMergeTest.php @@ -0,0 +1,267 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service; + +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\OpenRegister\Service\OrganisationService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\ISession; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Locks the merge walk onto the live tenant-resolution path. + */ +class ActiveOrganisationFollowsMergeTest extends TestCase { + /** + * @var OrganisationMapper|MockObject + */ + private $organisationMapper; + + /** + * @var IConfig|MockObject + */ + private $config; + + /** + * @var ISession|MockObject + */ + private $session; + + /** + * @var IUserSession|MockObject + */ + private $userSession; + + /** + * @var LoggerInterface|MockObject + */ + private $logger; + + /** + * @var OrganisationService + */ + private OrganisationService $service; + + /** + * Build the service over mocked collaborators and clear the static caches. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $reflection = new \ReflectionClass(OrganisationService::class); + foreach (['defaultOrgCache' => null, 'defaultOrgCacheTs' => null, 'userOrgsCache' => []] as $name => $value) { + $property = $reflection->getProperty($name); + $property->setAccessible(true); + $property->setValue(null, $value); + } + + $this->organisationMapper = $this->createMock(OrganisationMapper::class); + $this->config = $this->createMock(IConfig::class); + $this->session = $this->createMock(ISession::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + + // No session cache, so every call reaches the database path. + $this->session->method('get')->willReturn(null); + + $this->service = new OrganisationService( + organisationMapper: $this->organisationMapper, + userSession: $this->userSession, + session: $this->session, + config: $this->config, + appConfig: $this->createMock(IAppConfig::class), + groupManager: $this->createMock(IGroupManager::class), + userManager: $this->createMock(IUserManager::class), + logger: $this->logger + ); + + }//end setUp() + + /** + * Build an organisation row. + * + * @param string $uuid The organisation uuid. + * @param string|null $mergedInto The uuid it was merged into, if any. + * @param array $users The member user ids. + * + * @return Organisation The organisation. + */ + private function organisation(string $uuid, ?string $mergedInto = null, array $users = ['alice']): Organisation { + $organisation = new Organisation(); + $organisation->setUuid($uuid); + $organisation->setName('Org ' . $uuid); + $organisation->setUsers($users); + $organisation->setCreated(new \DateTime('2024-01-01')); + $organisation->setMergedInto($mergedInto); + + return $organisation; + + }//end organisation() + + /** + * The stored active organisation was merged away, so the survivor is + * returned instead of the row the UUID names. + * + * @return void + */ + public function testAMergedAwayActiveOrganisationResolvesToTheSurvivor(): void { + $merged = $this->organisation(uuid: 'dead-uuid', mergedInto: 'survivor-uuid'); + $survivor = $this->organisation(uuid: 'survivor-uuid'); + + $this->config->method('getUserValue')->willReturn('dead-uuid'); + $this->organisationMapper->method('findByUuid')->willReturn($merged); + $this->organisationMapper->method('findByUuidFollowingMerge') + ->with(uuid: 'dead-uuid') + ->willReturn($survivor); + + $this->assertSame('survivor-uuid', $this->service->getActiveOrganisation()?->getUuid()); + + }//end testAMergedAwayActiveOrganisationResolvesToTheSurvivor() + + /** + * The followed merge is written back, so the walk is a one-off per user + * rather than a cost every read pays forever. + * + * @return void + */ + public function testTheFollowedMergeIsWrittenBackToUserConfig(): void { + $merged = $this->organisation(uuid: 'dead-uuid', mergedInto: 'survivor-uuid'); + $survivor = $this->organisation(uuid: 'survivor-uuid'); + + $this->config->method('getUserValue')->willReturn('dead-uuid'); + $this->organisationMapper->method('findByUuid')->willReturn($merged); + $this->organisationMapper->method('findByUuidFollowingMerge')->willReturn($survivor); + + $this->config->expects($this->once()) + ->method('setUserValue') + ->with('alice', 'openregister', $this->anything(), 'survivor-uuid'); + + $this->service->getActiveOrganisation(); + + }//end testTheFollowedMergeIsWrittenBackToUserConfig() + + /** + * An organisation that was never merged is not walked at all: the flag + * lives on the row that was merged away, so a lookup here would be a query + * per request for the case that is almost always true. + * + * @return void + */ + public function testAnUnmergedOrganisationIsNeverWalked(): void { + $this->config->method('getUserValue')->willReturn('live-uuid'); + $this->organisationMapper->method('findByUuid')->willReturn($this->organisation(uuid: 'live-uuid')); + + $this->organisationMapper->expects($this->never())->method('findByUuidFollowingMerge'); + $this->config->expects($this->never())->method('setUserValue'); + + $this->assertSame('live-uuid', $this->service->getActiveOrganisation()?->getUuid()); + + }//end testAnUnmergedOrganisationIsNeverWalked() + + /** + * A user the merge did not carry over is not a member of the survivor, so + * the stale setting is cleared and resolution falls through to the user's + * own organisations. It fails closed: no tenant is handed out on the + * strength of a membership that ended. + * + * @return void + */ + public function testAUserTheMergeDidNotCarryOverFallsThrough(): void { + $merged = $this->organisation(uuid: 'dead-uuid', mergedInto: 'survivor-uuid'); + $survivor = $this->organisation(uuid: 'survivor-uuid', mergedInto: null, users: ['bob']); + $own = $this->organisation(uuid: 'own-uuid'); + + $this->config->method('getUserValue')->willReturn('dead-uuid'); + $this->organisationMapper->method('findByUuid')->willReturn($merged); + $this->organisationMapper->method('findByUuidFollowingMerge')->willReturn($survivor); + $this->organisationMapper->method('findByUserId')->willReturn([$own]); + + $this->config->expects($this->once())->method('deleteUserValue'); + + $this->assertSame('own-uuid', $this->service->getActiveOrganisation()?->getUuid()); + + }//end testAUserTheMergeDidNotCarryOverFallsThrough() + + /** + * With no stored setting the oldest membership is auto-picked, and that + * list can name a merged-away organisation too. Without the walk here the + * very first login after a merge writes the dead UUID straight back into + * config, which is the defect re-creating itself. + * + * @return void + */ + public function testTheAutoPickedOldestOrganisationFollowsTheMergeToo(): void { + $merged = $this->organisation(uuid: 'dead-uuid', mergedInto: 'survivor-uuid'); + $survivor = $this->organisation(uuid: 'survivor-uuid'); + + $this->config->method('getUserValue')->willReturn(''); + $this->organisationMapper->method('findByUserId')->willReturn([$merged]); + $this->organisationMapper->method('findByUuidFollowingMerge')->willReturn($survivor); + + $this->config->expects($this->once()) + ->method('setUserValue') + ->with('alice', 'openregister', $this->anything(), 'survivor-uuid'); + + $this->assertSame('survivor-uuid', $this->service->getActiveOrganisation()?->getUuid()); + + }//end testTheAutoPickedOldestOrganisationFollowsTheMergeToo() + + /** + * A merge pointing at a row that cannot be loaded is a data defect, not a + * reason to hand the caller nothing. The organisation already in hand is + * real, so resolution keeps it rather than throwing out of a read path. + * + * @return void + */ + public function testAnUnresolvableSurvivorKeepsTheOrganisationAsStored(): void { + $merged = $this->organisation(uuid: 'dead-uuid', mergedInto: 'ghost-uuid'); + + $this->config->method('getUserValue')->willReturn('dead-uuid'); + $this->organisationMapper->method('findByUuid')->willReturn($merged); + $this->organisationMapper->method('findByUuidFollowingMerge') + ->willThrowException(new DoesNotExistException('gone')); + + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame('dead-uuid', $this->service->getActiveOrganisation()?->getUuid()); + + }//end testAnUnresolvableSurvivorKeepsTheOrganisationAsStored() +}//end class From 246222dd376af3993e75755bc4303794c045bb0c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 14:45:00 +0200 Subject: [PATCH 52/68] feat(tasks): task expiry enforcement and configurable timeout/reject outcomes (#3338) * feat(tasks): task expiry enforcement and configurable timeout/reject outcomes Harvested from integriq's HITL semantics (fleet audit, wave 2): a task can now declare onTimeout and onReject in the reserved outcome vocabulary (skip|error|dead_letter). The timer sweep gains a third bounded range scan that closes non-terminal tasks past their enforcing deadline through the existing timer-outcome path (no second scheduler); a non-enforcing expiry timer falls back to the subject task's declared onTimeout; a rejecting completion of a task declaring onReject dead_letter records the dead-letter outcome through the same mapping. Both behaviours are serialized and carried by the user-task and portal-task nodes next to expiresAt. * test(migration): cover the declared-behaviour migration The coverage guard correctly flagged the one untested addition: the migration adding on_timeout/on_reject and the expiry-scan index. Three cases: fresh table gets both columns and the index, a re-run changes nothing and returns null, an absent table is left alone. * test(mapper): cover the sweep's task-timeout scan lib/Migration is excluded from clover, so the previous commit recovered nothing; the actually-uncovered addition was TaskMapper::findDueTimeouts(). The new case pins the scan's predicates (openness, declared on_timeout, deadline order) and the floored batch limit. --------- Co-authored-by: Conduction Release Bot --- lib/BackgroundJob/FlowTimerWorker.php | 6 +- lib/Db/Task.php | 35 ++++++ lib/Db/TaskMapper.php | 29 +++++ lib/Migration/Version1Date20260902090000.php | 96 ++++++++++++++ lib/Service/Flow/Nodes/PortalTaskConfig.php | 19 +++ lib/Service/Flow/Nodes/PortalTaskNode.php | 14 +++ lib/Service/Flow/Nodes/UserTaskConfig.php | 2 + lib/Service/Flow/Nodes/UserTaskNode.php | 14 +++ lib/Service/Flow/Timer/FlowTimerService.php | 27 +++- lib/Service/Flow/Timer/FlowTimerSweep.php | 75 ++++++++++- lib/Service/Task/TaskBuilder.php | 45 +++++++ lib/Service/Task/TaskFormResolver.php | 24 +++- lib/Service/Task/TaskService.php | 46 +++++-- .../task-expiry-and-outcomes/design.md | 66 ++++++++++ .../task-expiry-and-outcomes/proposal.md | 56 +++++++++ .../specs/task-expiry-and-outcomes/spec.md | 85 +++++++++++++ .../changes/task-expiry-and-outcomes/tasks.md | 39 ++++++ .../BackgroundJob/FlowTimerWorkerTest.php | 6 +- tests/Unit/Db/TaskMapperQueriesTest.php | 22 ++++ .../Version1Date20260902090000Test.php | 111 +++++++++++++++++ .../Flow/Timer/FlowTimerServiceTest.php | 41 ++++++ .../Service/Flow/Timer/FlowTimerSweepTest.php | 74 ++++++++++- tests/Unit/Service/Task/TaskServiceTest.php | 117 ++++++++++++++++++ 23 files changed, 1022 insertions(+), 27 deletions(-) create mode 100644 lib/Migration/Version1Date20260902090000.php create mode 100644 openspec/changes/task-expiry-and-outcomes/design.md create mode 100644 openspec/changes/task-expiry-and-outcomes/proposal.md create mode 100644 openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md create mode 100644 openspec/changes/task-expiry-and-outcomes/tasks.md create mode 100644 tests/Unit/Migration/Version1Date20260902090000Test.php diff --git a/lib/BackgroundJob/FlowTimerWorker.php b/lib/BackgroundJob/FlowTimerWorker.php index ff584d42d7..f9d1519958 100644 --- a/lib/BackgroundJob/FlowTimerWorker.php +++ b/lib/BackgroundJob/FlowTimerWorker.php @@ -100,12 +100,14 @@ protected function run($argument): void { $now = DateTimeImmutable::createFromInterface($this->time->getDateTime()); $result = $this->sweep->run(now: $now, batch: $this->batchLimit()); - if ($result['expiriesFired'] > 0 || $result['rungsFired'] > 0 || $result['truncated'] === true || $result['errors'] > 0) { + $quiet = ($result['expiriesFired'] === 0 && $result['rungsFired'] === 0 && $result['taskTimeouts'] === 0); + if ($quiet === false || $result['truncated'] === true || $result['errors'] > 0) { $this->logger->info( message: sprintf( - '[FlowTimerWorker] Fired %d expiry timer(s) and %d escalation rung(s); truncated: %s; errors: %d', + '[FlowTimerWorker] Fired %d expiry timer(s), %d escalation rung(s) and %d task timeout(s); truncated: %s; errors: %d', $result['expiriesFired'], $result['rungsFired'], + $result['taskTimeouts'], var_export($result['truncated'], true), $result['errors'] ), diff --git a/lib/Db/Task.php b/lib/Db/Task.php index 5748a36a1c..a7f6a4065f 100644 --- a/lib/Db/Task.php +++ b/lib/Db/Task.php @@ -99,6 +99,10 @@ * @method void setDueAt(?DateTime $dueAt) * @method DateTime|null getExpiresAt() * @method void setExpiresAt(?DateTime $expiresAt) + * @method string|null getOnTimeout() + * @method void setOnTimeout(?string $onTimeout) + * @method string|null getOnReject() + * @method void setOnReject(?string $onReject) * @method integer|null getSlaValue() * @method void setSlaValue(?int $slaValue) * @method string|null getSlaUnit() @@ -214,6 +218,15 @@ class Task extends Entity implements JsonSerializable { self::STATE_DISABLED, ]; + /** + * The reserved behaviour vocabulary `on_timeout` and `on_reject` accept — + * the same words `TaskService::applyTimerOutcome()` resolves, so one + * mapping serves the timer path, the sweep and the reject routing. + * + * @var array + */ + public const OUTCOME_BEHAVIOURS = ['skip', 'error', 'dead_letter']; + /** * Performer types (ADR-098 D3). * @@ -508,6 +521,24 @@ class Task extends Entity implements JsonSerializable { */ protected ?DateTime $expiresAt = null; + /** + * Declared behaviour when the enforcing deadline passes: one value of + * the reserved timer-outcome vocabulary (skip|error|dead_letter). Null + * means no declared behaviour — the bare deadline enforces nothing. + * + * @var string|null + */ + protected ?string $onTimeout = null; + + /** + * Declared behaviour on a rejecting completion: one value of the + * reserved timer-outcome vocabulary. Only `dead_letter` reroutes the + * record; `skip` and `error` are the resuming consumer's contract. + * + * @var string|null + */ + protected ?string $onReject = null; + /** * SLA magnitude. Stored, not interpreted here. * @@ -752,6 +783,8 @@ public function __construct() { $this->addType(fieldName: 'startAt', type: 'datetime'); $this->addType(fieldName: 'dueAt', type: 'datetime'); $this->addType(fieldName: 'expiresAt', type: 'datetime'); + $this->addType(fieldName: 'onTimeout', type: 'string'); + $this->addType(fieldName: 'onReject', type: 'string'); $this->addType(fieldName: 'slaValue', type: 'integer'); $this->addType(fieldName: 'slaUnit', type: 'string'); $this->addType(fieldName: 'compliancePeriodDays', type: 'integer'); @@ -867,6 +900,8 @@ public function jsonSerialize(): array { 'startAt' => $this->startAt?->format('c'), 'dueAt' => $this->dueAt?->format('c'), 'expiresAt' => $this->expiresAt?->format('c'), + 'onTimeout' => $this->onTimeout, + 'onReject' => $this->onReject, 'slaValue' => $this->slaValue, 'slaUnit' => $this->slaUnit, 'compliancePeriodDays' => $this->compliancePeriodDays, diff --git a/lib/Db/TaskMapper.php b/lib/Db/TaskMapper.php index c1daa7a40c..33fca510f8 100644 --- a/lib/Db/TaskMapper.php +++ b/lib/Db/TaskMapper.php @@ -155,6 +155,35 @@ private function announceTerminality(Task $task): void { ); }//end announceTerminality() + /** + * The due timeouts: non-terminal tasks past their enforcing deadline + * that DECLARE a timeout behaviour, earliest deadline first. + * + * A bounded, index-backed range scan (`or_tasks_open_expiry`), the same + * discipline as the timer scans (flow-business-timers design D-8): never + * a page of open rows filtered in PHP. A processed row leaves the scan + * because applying its behaviour makes it terminal. + * + * @param \DateTimeInterface $now The sweep instant. + * @param int $limit The batch limit. + * + * @return array The due tasks, earliest expiry first. + * + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-the-timer-sweep-enforces-a-declared-task-expiry + */ + public function findDueTimeouts(\DateTimeInterface $now, int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNotNull('on_timeout')) + ->andWhere($qb->expr()->lte('expires_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE))) + ->orderBy('expires_at', 'ASC') + ->setMaxResults(max(1, $limit)); + + return $this->findEntities(query: $qb); + }//end findDueTimeouts() + /** * A sequence's positions, in ordinal order. * diff --git a/lib/Migration/Version1Date20260902090000.php b/lib/Migration/Version1Date20260902090000.php new file mode 100644 index 0000000000..ea41c8aa4d --- /dev/null +++ b/lib/Migration/Version1Date20260902090000.php @@ -0,0 +1,96 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Storage for task expiry enforcement and configurable outcomes + * (task-expiry-and-outcomes, harvested from integriq's HITL semantics): + * + * - `openregister_tasks` gains `on_timeout` and `on_reject`: each holds one + * value of the reserved timer-outcome vocabulary (skip|error|dead_letter), + * null meaning "no declared behaviour" — which is exactly the pre-change + * behaviour, so existing rows change nothing. + * - `or_tasks_open_expiry (is_terminal, expires_at)` backs the sweep's third + * range scan (`is_terminal = false AND on_timeout IS NOT NULL AND + * expires_at <= now ORDER BY expires_at LIMIT batch`), keeping the + * bounded-scan discipline flow-business-timers design D-8 requires. + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-a-task-declares-its-timeout-and-reject-behaviour-in-one-vocabulary + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the declared-behaviour columns and the expiry-scan index. + * + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-the-timer-sweep-enforces-a-declared-task-expiry + */ +class Version1Date20260902090000 extends SimpleMigrationStep { + + /** + * The task table. + */ + private const TABLE_TASKS = 'openregister_tasks'; + + /** + * Add the columns and the index, idempotently. + * + * @param IOutput $output Migration output. + * @param Closure(): ISchemaWrapper $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The interface fixes the signature. + * + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-a-task-declares-its-timeout-and-reject-behaviour-in-one-vocabulary + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable(self::TABLE_TASKS) === false) { + return null; + } + + $table = $schema->getTable(self::TABLE_TASKS); + $changed = false; + + if ($table->hasColumn('on_timeout') === false) { + $table->addColumn('on_timeout', Types::STRING, ['notnull' => false, 'length' => 32]); + $changed = true; + } + + if ($table->hasColumn('on_reject') === false) { + $table->addColumn('on_reject', Types::STRING, ['notnull' => false, 'length' => 32]); + $changed = true; + } + + if ($table->hasIndex('or_tasks_open_expiry') === false) { + $table->addIndex(['is_terminal', 'expires_at'], 'or_tasks_open_expiry'); + $changed = true; + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Service/Flow/Nodes/PortalTaskConfig.php b/lib/Service/Flow/Nodes/PortalTaskConfig.php index a81aae4351..13e36f523b 100644 --- a/lib/Service/Flow/Nodes/PortalTaskConfig.php +++ b/lib/Service/Flow/Nodes/PortalTaskConfig.php @@ -269,6 +269,8 @@ public function taskData( 'assignee' => $partyReference, 'dueAt' => $this->renderedOrNull(value: ($config['dueAt'] ?? null), json: $json), 'expiresAt' => $this->renderedOrNull(value: ($config['expiresAt'] ?? null), json: $json), + 'onTimeout' => $this->behaviourOrNull(value: ($config['onTimeout'] ?? '')), + 'onReject' => $this->behaviourOrNull(value: ($config['onReject'] ?? '')), 'metadata' => [ 'flowNodeType' => $nodeType, 'flowNode' => $nodeId, @@ -512,6 +514,23 @@ private function representativeJson(array $items): array { return []; }//end representativeJson() + /** + * A declared behaviour value, trimmed, or null for absent. Vocabulary + * validation is TaskBuilder's boundary, not this node's. + * + * @param mixed $value The configured value. + * + * @return string|null The behaviour, or null. + */ + private function behaviourOrNull(mixed $value): ?string { + $behaviour = trim((string)$value); + if ($behaviour === '') { + return null; + } + + return $behaviour; + }//end behaviourOrNull() + /** * A templated string, or null when it renders to nothing. * diff --git a/lib/Service/Flow/Nodes/PortalTaskNode.php b/lib/Service/Flow/Nodes/PortalTaskNode.php index 5633041eb2..9c628c9acc 100644 --- a/lib/Service/Flow/Nodes/PortalTaskNode.php +++ b/lib/Service/Flow/Nodes/PortalTaskNode.php @@ -211,6 +211,8 @@ public function configKeys(): array { 'reasonField', 'dueAt', 'expiresAt', + 'onTimeout', + 'onReject', 'heartbeatMinutes', 'advance', ]; @@ -600,6 +602,18 @@ private function whenFields(): array { 'When the ask stops being answerable. Same shapes as "Due". Expiry is enforced by the business timers, not by this step.' ), ], + [ + 'key' => 'onTimeout', + 'label' => $this->l10n->t('On timeout'), + 'type' => 'text', + 'help' => $this->l10n->t('What happens when the ask expires: skip, error or dead_letter. Empty means the deadline enforces nothing.'), + ], + [ + 'key' => 'onReject', + 'label' => $this->l10n->t('On rejection'), + 'type' => 'text', + 'help' => $this->l10n->t('Only dead_letter changes the record; skip and error are read by whatever resumes the flow.'), + ], [ 'key' => 'heartbeatMinutes', 'label' => $this->l10n->t('Re-check every (minutes)'), diff --git a/lib/Service/Flow/Nodes/UserTaskConfig.php b/lib/Service/Flow/Nodes/UserTaskConfig.php index f474824958..22f196e54b 100644 --- a/lib/Service/Flow/Nodes/UserTaskConfig.php +++ b/lib/Service/Flow/Nodes/UserTaskConfig.php @@ -195,6 +195,8 @@ public function taskData(array $config, array $items, string $nodeId, string $no 'routingFallback' => $this->nullIfEmpty(value: trim((string)($config['routingFallback'] ?? ''))), 'dueAt' => $this->renderedOrNull(value: ($config['dueAt'] ?? null), json: $json), 'expiresAt' => $this->renderedOrNull(value: ($config['expiresAt'] ?? null), json: $json), + 'onTimeout' => $this->nullIfEmpty(value: trim((string)($config['onTimeout'] ?? ''))), + 'onReject' => $this->nullIfEmpty(value: trim((string)($config['onReject'] ?? ''))), 'metadata' => [ 'flowNodeType' => $nodeType, 'flowNode' => $nodeId, diff --git a/lib/Service/Flow/Nodes/UserTaskNode.php b/lib/Service/Flow/Nodes/UserTaskNode.php index aa254479e3..46969627cd 100644 --- a/lib/Service/Flow/Nodes/UserTaskNode.php +++ b/lib/Service/Flow/Nodes/UserTaskNode.php @@ -197,6 +197,8 @@ public function configKeys(): array { 'priority', 'dueAt', 'expiresAt', + 'onTimeout', + 'onReject', 'outcomes', 'outcomeKey', 'failOnReject', @@ -536,6 +538,18 @@ private function whenFields(): array { 'type' => 'text', 'help' => $this->l10n->t('When the task stops being doable. Same shapes as "Due". Must not lie before it.'), ], + [ + 'key' => 'onTimeout', + 'label' => $this->l10n->t('On timeout'), + 'type' => 'text', + 'help' => $this->l10n->t('What happens when the task expires: skip, error or dead_letter. Empty means the deadline enforces nothing.'), + ], + [ + 'key' => 'onReject', + 'label' => $this->l10n->t('On rejection'), + 'type' => 'text', + 'help' => $this->l10n->t('Only dead_letter changes the record; skip and error are read by whatever resumes the flow.'), + ], [ 'key' => 'heartbeatMinutes', 'label' => $this->l10n->t('Re-check every (minutes)'), diff --git a/lib/Service/Flow/Timer/FlowTimerService.php b/lib/Service/Flow/Timer/FlowTimerService.php index 673e94cb7a..568a038b5d 100644 --- a/lib/Service/Flow/Timer/FlowTimerService.php +++ b/lib/Service/Flow/Timer/FlowTimerService.php @@ -1196,23 +1196,42 @@ private function cancelAll(array $timers, string $reason, ?string $actor, DateTi }//end cancelAll() /** - * Apply an expiry timer's enforcing outcome to a task subject. + * Apply a fired expiry timer's outcome to a task subject. + * + * An enforcing timer applies its own validated `onExpiry`. A + * non-enforcing expiry timer falls back to the SUBJECT TASK's declared + * `onTimeout` (task-expiry-and-outcomes D-4): {@see project()} nulls the + * task's `expires_at` once the last expiry timer fires, so without this + * fallback the sweep's task scan would never reach a timer-managed task. * * @param FlowTimer $timer The fired timer. * * @return void + * + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-a-non-enforcing-expiry-timer-falls-back-to-the-tasks-declared-behaviour */ private function applyOutcome(FlowTimer $timer): void { - if ($timer->isEnforcing() === false || $timer->getSubjectType() !== 'task') { + if ($timer->getSubjectType() !== 'task') { + return; + } + + $outcome = $this->subjectTask(timer: $timer)?->getOnTimeout(); + $basis = 'task-declared onTimeout'; + if ($timer->isEnforcing() === true) { + $outcome = (string)$timer->getOnExpiry(); + $basis = (string)$timer->getLegalEffect(); + } + + if ($outcome === null || $outcome === '') { return; } try { $this->taskService->applyTimerOutcome( uuid: (string)$timer->getSubjectUuid(), - outcome: (string)$timer->getOnExpiry(), + outcome: $outcome, source: 'flow-timer:' . (string)$timer->getUuid(), - reason: sprintf("Expiry timer '%s' (%s) reached its deadline.", (string)$timer->getUuid(), (string)$timer->getLegalEffect()) + reason: sprintf("Expiry timer '%s' (%s) reached its deadline.", (string)$timer->getUuid(), $basis) ); } catch (TaskConflictException $race) { // The task closed concurrently: nothing to do, the timer is cancelled by that close. diff --git a/lib/Service/Flow/Timer/FlowTimerSweep.php b/lib/Service/Flow/Timer/FlowTimerSweep.php index 2626a66007..6a89948e52 100644 --- a/lib/Service/Flow/Timer/FlowTimerSweep.php +++ b/lib/Service/Flow/Timer/FlowTimerSweep.php @@ -1,12 +1,14 @@ calendars->reset(); - $result = ['expiriesFired' => 0, 'rungsFired' => 0, 'truncated' => false, 'errors' => 0]; + $result = ['expiriesFired' => 0, 'rungsFired' => 0, 'taskTimeouts' => 0, 'truncated' => false, 'errors' => 0]; $expiries = $this->timers->findDueExpiries(now: $now, limit: $batch); $result['truncated'] = (count($expiries) >= $batch); @@ -103,6 +121,53 @@ public function run(DateTimeInterface $now, int $batch): array { } } + $this->sweepTaskTimeouts(now: $now, batch: $batch, result: $result); + return $result; }//end run() + + /** + * The third scan (task-expiry-and-outcomes D-3): non-terminal tasks past + * their enforcing deadline that DECLARE a timeout behaviour, each closed + * through the existing timer-outcome path. A failure on one task is + * logged and counted and does not stop the pass; the row is retried next + * pass because only a successful apply makes it terminal. + * + * @param DateTimeInterface $now The sweep instant. + * @param int $batch The per-scan batch limit. + * @param array{expiriesFired: int, rungsFired: int, taskTimeouts: int, truncated: bool, errors: int} $result Mutated in place. + * + * @return void + * + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-the-timer-sweep-enforces-a-declared-task-expiry + */ + private function sweepTaskTimeouts(DateTimeInterface $now, int $batch, array &$result): void { + if ($this->tasks === null || $this->taskService === null) { + return; + } + + $due = $this->tasks->findDueTimeouts(now: $now, limit: $batch); + $result['truncated'] = ($result['truncated'] === true || count($due) >= $batch); + foreach ($due as $task) { + try { + $this->taskService->applyTimerOutcome( + uuid: (string)$task->getUuid(), + outcome: (string)$task->getOnTimeout(), + source: self::TIMEOUT_SOURCE, + reason: sprintf( + "Task expired at %s; declared onTimeout '%s' applied.", + (string)$task->getExpiresAt()?->format('c'), + (string)$task->getOnTimeout() + ) + ); + $result['taskTimeouts']++; + } catch (Throwable $failure) { + $result['errors']++; + $this->logger->error( + '[FlowTimerSweep] Task timeout failed: ' . $failure->getMessage(), + ['task' => $task->getUuid(), 'exception' => $failure] + ); + } + } + }//end sweepTaskTimeouts() }//end class diff --git a/lib/Service/Task/TaskBuilder.php b/lib/Service/Task/TaskBuilder.php index 5e8d554f9e..2ff124e771 100644 --- a/lib/Service/Task/TaskBuilder.php +++ b/lib/Service/Task/TaskBuilder.php @@ -114,6 +114,19 @@ public function fromData(array $data, ?string $actor): Task { $task->setDueAt($dueAt); $task->setExpiresAt($expiresAt); + + // Declared behaviours: one reserved vocabulary, refused by name + // outside it, and a timeout behaviour with no deadline is a + // configuration error, not a schedule (task-expiry-and-outcomes D-6). + $onTimeout = $this->validBehaviour(value: ($data['onTimeout'] ?? null), field: 'onTimeout'); + if ($onTimeout !== null && $expiresAt === null) { + throw new TaskValidationException( + message: sprintf("onTimeout '%s' without an expiresAt is a configuration error: there is no deadline to time out on.", $onTimeout) + ); + } + + $task->setOnTimeout($onTimeout); + $task->setOnReject($this->validBehaviour(value: ($data['onReject'] ?? null), field: 'onReject')); $task->setStartAt($this->parseDate(value: ($data['startAt'] ?? null), field: 'startAt')); $task->setSuspendedUntil($this->parseDate(value: ($data['suspendedUntil'] ?? null), field: 'suspendedUntil')); @@ -213,6 +226,38 @@ public function relationsFor(Task $task, array $data): array { return $rows; }//end relationsFor() + /** + * A declared behaviour: one value of the reserved outcome vocabulary. + * + * @param mixed $value The incoming value. + * @param string $field The field name, for the refusal message. + * + * @return string|null The validated behaviour, or null for absent. + * + * @throws TaskValidationException When present but outside the vocabulary. + * + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-a-task-declares-its-timeout-and-reject-behaviour-in-one-vocabulary + */ + private function validBehaviour(mixed $value, string $field): ?string { + $behaviour = $this->stringOrNull(value: $value); + if ($behaviour === null) { + return null; + } + + if (in_array($behaviour, Task::OUTCOME_BEHAVIOURS, true) === false) { + throw new TaskValidationException( + message: sprintf( + "Field '%s' value '%s' is not in the behaviour vocabulary (%s).", + $field, + $behaviour, + implode('|', Task::OUTCOME_BEHAVIOURS) + ) + ); + } + + return $behaviour; + }//end validBehaviour() + /** * Parse a date field: DateTime passes, ISO strings parse, junk refuses. * diff --git a/lib/Service/Task/TaskFormResolver.php b/lib/Service/Task/TaskFormResolver.php index 51f39b55ad..88aca91584 100644 --- a/lib/Service/Task/TaskFormResolver.php +++ b/lib/Service/Task/TaskFormResolver.php @@ -130,22 +130,44 @@ public function describe(Task $task): array { 'error' => $unresolvable->getMessage(), ], 'requireChecklist' => false, + 'behaviours' => $this->behavioursOf(task: $task), ]; } $form = null; if ($declaration->isNative() === true) { $form = $this->describeNative(declaration: $declaration); - } else if ($declaration->isExternal() === true) { + } elseif ($declaration->isExternal() === true) { $form = $this->describeExternal(declaration: $declaration, task: $task); } return [ 'form' => $form, 'requireChecklist' => $declaration->requireChecklist, + 'behaviours' => $this->behavioursOf(task: $task), ]; }//end describe() + /** + * The task's declared deadline behaviours, so a surface rendering the + * form can say what happens on timeout or rejection + * (task-expiry-and-outcomes: the behaviours are the task's contract, + * not the form's). + * + * @param Task $task The task. + * + * @return array expiresAt, onTimeout and onReject. + * + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-a-task-declares-its-timeout-and-reject-behaviour-in-one-vocabulary + */ + private function behavioursOf(Task $task): array { + return [ + 'expiresAt' => $task->getExpiresAt()?->format('c'), + 'onTimeout' => $task->getOnTimeout(), + 'onReject' => $task->getOnReject(), + ]; + }//end behavioursOf() + /** * The declaration a task resolves to: pinned version, or its own record. * diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php index 1f5069fdc6..b2d86f71b7 100644 --- a/lib/Service/Task/TaskService.php +++ b/lib/Service/Task/TaskService.php @@ -49,10 +49,10 @@ use OCA\OpenRegister\Db\TaskMapper; use OCA\OpenRegister\Db\TaskRelationMapper; use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Event\TaskTransitionedEvent; use OCA\OpenRegister\Exception\TaskAccessDeniedException; use OCA\OpenRegister\Exception\TaskConflictException; use OCA\OpenRegister\Exception\TaskValidationException; -use OCA\OpenRegister\Event\TaskTransitionedEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; use Psr\Log\LoggerInterface; @@ -504,9 +504,9 @@ public function resolve(string $uuid, ?string $resultText, ?string $comment, ?st * when the completion carries * any (a portal task's form). * @param array>|null $evidence References to the - * files ALREADY stored - * for this completion; - * never bytes. + * files ALREADY stored + * for this completion; + * never bytes. * * @return Task The completed task. * @@ -1056,6 +1056,7 @@ private function assignInternal(string $uuid, string $assignee, ?string $actor, * @throws TaskValidationException When a rejecting outcome has no comment. * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-a-rejecting-completion-honours-the-tasks-declared-reject-behaviour */ private function completeInternal( string $verb, @@ -1084,9 +1085,36 @@ private function completeInternal( ); } + // Declared reject behaviour (task-expiry-and-outcomes D-5): only + // `dead_letter` reroutes the record, through the SAME mapping the + // timer path resolves; `skip` and `error` are the resuming + // consumer's contract and change nothing about the completion. + $recordedOutcome = $outcome; + $targetState = Task::STATE_COMPLETED; + $auditReason = $comment; + if (TaskState::isRejectingOutcome(outcome: $outcome) === true && $task->getOnReject() === 'dead_letter') { + [$targetState, $recordedOutcome] = $this->timerOutcomeTarget(outcome: 'dead_letter'); + $auditReason = sprintf( + "Rejected as '%s'; declared onReject 'dead_letter' routed it to the dead letter state. %s", + $outcome, + (string)$comment + ); + } + return $this->transactional( - mutation: function () use ($task, $outcome, $resultText, $comment, $actor, $verb, $responses, $evidence): Task { - $task->setOutcome($outcome); + mutation: function () use ( + $task, + $recordedOutcome, + $targetState, + $auditReason, + $resultText, + $comment, + $actor, + $verb, + $responses, + $evidence, + ): Task { + $task->setOutcome($recordedOutcome); $task->setResultText($resultText); $task->setComment($comment); if ($responses !== null) { @@ -1099,9 +1127,9 @@ private function completeInternal( $task->setCompletedAt(new DateTime()); $task->setCompletedBy($actor); - $this->applyState(task: $task, state: Task::STATE_COMPLETED, action: $verb); + $this->applyState(task: $task, state: $targetState, action: $verb); $persisted = $this->persistOpen(task: $task); - $this->appendAudit(task: $persisted, action: $verb, actor: $actor, reason: $comment); + $this->appendAudit(task: $persisted, action: $verb, actor: $actor, reason: $auditReason); return $persisted; } @@ -1214,7 +1242,7 @@ private function refuseUnrenderableForm(Task $task): void { * @return Task The open, authorized task. * * @throws TaskConflictException When the task is already terminal — with - * the current state in the message. + * the current state in the message. * @throws TaskAccessDeniedException When authorization denies (audited). * * @spec openspec/changes/flow-task-entity/specs/flow-tasks/spec.md#requirement-every-lifecycle-verb-is-authorized-fail-closed diff --git a/openspec/changes/task-expiry-and-outcomes/design.md b/openspec/changes/task-expiry-and-outcomes/design.md new file mode 100644 index 0000000000..1fefd2a24a --- /dev/null +++ b/openspec/changes/task-expiry-and-outcomes/design.md @@ -0,0 +1,66 @@ +# Design: task expiry and configurable outcomes + +## D-1: the behaviours live on the task row, not in metadata + +`on_timeout` and `on_reject` are two nullable string columns. The sweep +selects on `on_timeout`, so it must be a column, not a JSON key; `on_reject` +matches for symmetry and because consumers (a resuming bridge, a portal +form) read it as a first-class contract of the task. Null means "no declared +behaviour", which is exactly today's behaviour. + +## D-2: one vocabulary, one mapping + +Both columns use the reserved timer-outcome vocabulary already published by +`TaskService::timerOutcomeTarget()`: `skip` → completed/skipped, `error` → +terminated/failed, `dead_letter` → disabled/dead_letter. integriq's values +map onto it directly (`error` default, `dead_letter` opt-in; its `skip` +means "continue without the gate", which the flow reads from the recorded +outcome). A second mapping for the same words would drift; a rejecting +completion routed by `on_reject` therefore resolves through the SAME method +as a fired timer. + +## D-3: the sweep rides FlowTimerSweep — a third scan, not a second scheduler + +integriq enforced expiry with its own 300s `ApprovalTimeoutSweepJob`. The +shared service already has a 300s sweep worker (`FlowTimerWorker` → +`FlowTimerSweep`) with the bounded-range-scan discipline (design D-8 of +flow-business-timers: never read a page of open rows and filter in PHP). +Task expiry becomes the pass's third scan: + + is_terminal = false AND on_timeout IS NOT NULL AND expires_at <= now + ORDER BY expires_at LIMIT batch + +backed by a new `or_tasks_open_expiry (is_terminal, expires_at)` index. A +processed row leaves the scan because `applyTimerOutcome()` makes it +terminal; a row that fails to process is counted, logged, and retried next +pass — the same contract the timer scans keep. + +## D-4: a timer-managed task is enforced at the timer, not by the scan + +`FlowTimerService::project()` rewrites a task's `expires_at` from its OPEN +timers, and nulls it once the last expiry timer fires. A non-enforcing +expiry timer (legal effect below `wettelijk`, so `onExpiry` is refused) +would therefore fire, null the projection, and starve the scan — the task's +declared `on_timeout` would never apply. So `applyOutcome()` gains a +fallback: when the fired expiry timer is NOT enforcing and the subject task +declares `on_timeout`, the task's own behaviour is applied. An enforcing +timer still wins: `wettelijk` is the stronger claim and its `onExpiry` was +validated at arm time. + +## D-5: `on_reject` only reroutes `dead_letter` + +integriq's `onReject` values `error` and `skip` are instructions to the +RESUMING orchestration (fail the pipeline vs continue past the gate); the +record itself stays `rejected`. Only `dead_letter` changes the terminal +record. The shared service mirrors that: a rejecting completion (per +`TaskState::isRejectingOutcome()`, comment still mandatory) of a task with +`on_reject: dead_letter` records outcome `dead_letter` in state `disabled`, +with the original rejecting outcome preserved in the audit reason. `error` +and `skip` are stored and serialized for the consumer to read, and change +nothing about the completion itself. + +## D-6: intake refuses a timeout with no deadline + +`onTimeout` without `expiresAt` is a configuration error, refused at +`TaskBuilder` intake naming both fields — the same posture as the existing +"expiry before due" refusal. `onReject` needs no deadline. diff --git a/openspec/changes/task-expiry-and-outcomes/proposal.md b/openspec/changes/task-expiry-and-outcomes/proposal.md new file mode 100644 index 0000000000..eb08dcd3f9 --- /dev/null +++ b/openspec/changes/task-expiry-and-outcomes/proposal.md @@ -0,0 +1,56 @@ +# Task expiry and configurable outcomes + +## Why + +The shared task entity stores `expires_at` and calls it the ENFORCING +deadline, but nothing enforces it unless a `wettelijk` expiry timer happens +to exist for the task. A task created with a bare `expiresAt` — over HTTP, +through `import()`, or by a consuming app — keeps a decorative deadline +forever. + +integriq's app-local HITL implementation (harvested by the fleet audit) has +the semantics the shared service lacks: every `approval_request` carries an +`expiresAt`, an `onTimeout` behaviour and an `onReject` behaviour. Its sweep +turns a pending request past its `expiresAt` into `expired` (or +`dead_letter` when `onTimeout` says so), and a rejection lands in +`dead_letter` when `onReject` says so. Those behaviours are what this change +moves into the shared service, so integriq (wave 2) and later adopters can +delegate instead of duplicating. + +## What changes + +- `openregister_tasks` gains two nullable columns, `on_timeout` and + `on_reject`, each holding one value of the existing reserved timer-outcome + vocabulary (`skip` | `error` | `dead_letter`). +- `TaskBuilder` accepts `onTimeout` and `onReject` at intake, refuses values + outside the vocabulary by name, and refuses `onTimeout` on a task without + an `expiresAt`. +- `FlowTimerSweep` gains a third bounded range scan: non-terminal tasks whose + `expires_at` has passed and whose `on_timeout` is declared. Each hit goes + through the existing `TaskService::applyTimerOutcome()`. Same worker + (`FlowTimerWorker`), same pass, same bounded-scan discipline — no second + scheduler. +- `FlowTimerService` applies a task's own declared `on_timeout` when a + non-enforcing expiry timer of that task fires, so a timer-managed task is + enforced deterministically even though `project()` rewrites `expires_at`. +- A rejecting completion of a task that declares `on_reject: dead_letter` + records the dead-letter outcome instead of a plain rejection, through the + same outcome mapping the timer path uses. +- The task JSON (entity serialization and the task-form description) carries + `onTimeout` and `onReject`, and the user-task and portal-task nodes accept + both keys next to `expiresAt`. + +## Impact + +- Affected specs: task-expiry-and-outcomes (new delta). +- Affected code: `lib/Db/Task.php`, `lib/Db/TaskMapper.php`, a new + migration, `lib/Service/Task/TaskBuilder.php`, + `lib/Service/Task/TaskService.php`, + `lib/Service/Task/TaskFormResolver.php`, + `lib/Service/Flow/Timer/FlowTimerSweep.php`, + `lib/Service/Flow/Timer/FlowTimerService.php`, + `lib/Service/Flow/Nodes/UserTaskNode.php` (+ config), + `lib/Service/Flow/Nodes/PortalTaskNode.php` (+ config). +- Backwards compatible: a task without a declared `on_timeout` keeps today's + behaviour (nothing enforces the bare deadline); a task without `on_reject` + keeps the plain rejecting completion. diff --git a/openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md b/openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md new file mode 100644 index 0000000000..40aabf8c35 --- /dev/null +++ b/openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md @@ -0,0 +1,85 @@ +# task-expiry-and-outcomes + +## ADDED Requirements + +### Requirement: A task declares its timeout and reject behaviour in one vocabulary + +A task SHALL carry two optional behaviours, `onTimeout` and `onReject`, each +one value of the reserved outcome vocabulary (`skip`, `error`, +`dead_letter`). Intake SHALL refuse a value outside the vocabulary by name, +and SHALL refuse `onTimeout` on a task without an `expiresAt`. Both +behaviours SHALL appear in the task's JSON serialization and in the +task-form description. + +#### Scenario: an outcome outside the vocabulary is refused + +- **GIVEN** a task creation payload with `onTimeout: 'explode'` +- **WHEN** the task is built +- **THEN** the build is refused with a message naming the value and the vocabulary +- @e2e exclude {intake validation is a service boundary; covered by unit tests} + +#### Scenario: a timeout behaviour with no deadline is refused + +- **GIVEN** a task creation payload with `onTimeout: 'error'` and no `expiresAt` +- **WHEN** the task is built +- **THEN** the build is refused naming both fields +- @e2e exclude {intake validation is a service boundary; covered by unit tests} + +### Requirement: The timer sweep enforces a declared task expiry + +The existing sweep pass SHALL include a bounded, index-backed range scan of +non-terminal tasks whose `expires_at` has passed and whose `on_timeout` is +declared, and SHALL apply each hit's declared behaviour through the existing +timer-outcome path. The scan SHALL be bounded by the pass's batch limit, +SHALL report work performed and truncation, and a failure on one task SHALL +NOT stop the pass. No second scheduler SHALL be introduced. + +#### Scenario: a task past its expiry is closed with its declared behaviour + +- **GIVEN** a non-terminal task with `expiresAt` in the past and `onTimeout: 'dead_letter'` +- **WHEN** the sweep pass runs +- **THEN** the task ends in state `disabled` with outcome `dead_letter`, audited with the sweep as actor +- @e2e exclude {background sweep with a past deadline is not driveable from the UI; covered by unit tests} + +#### Scenario: a task without a declared behaviour is left alone + +- **GIVEN** a non-terminal task with `expiresAt` in the past and no `onTimeout` +- **WHEN** the sweep pass runs +- **THEN** the task is not selected and keeps its state +- @e2e exclude {absence of background mutation is only observable in a unit test} + +### Requirement: A non-enforcing expiry timer falls back to the task's declared behaviour + +When an expiry timer fires for a task subject and the timer itself carries +no enforcing outcome, the task's own declared `onTimeout` SHALL be applied. +An enforcing timer's own `onExpiry` SHALL take precedence over the task's +declared behaviour. + +#### Scenario: the task's behaviour applies when the timer cannot enforce + +- **GIVEN** a task declaring `onTimeout: 'error'` with a fired, non-enforcing expiry timer +- **WHEN** the timer's outcome is applied +- **THEN** the task ends in state `terminated` with outcome `failed` +- @e2e exclude {timer firing is a background path; covered by unit tests} + +### Requirement: A rejecting completion honours the task's declared reject behaviour + +A rejecting completion of a task declaring `onReject: 'dead_letter'` SHALL +record the dead-letter outcome through the same outcome mapping the timer +path uses, preserving the mandatory comment and auditing the original +rejecting outcome. Declared `onReject` values `error` and `skip` SHALL be +stored and serialized without changing the completion itself. + +#### Scenario: a rejection routes to the dead letter state + +- **GIVEN** a task declaring `onReject: 'dead_letter'` +- **WHEN** its assignee completes it with outcome `rejected` and a comment +- **THEN** the task ends in state `disabled` with outcome `dead_letter`, and the audit names the original rejection +- @e2e exclude {reject routing is a service-boundary rule; covered by unit tests} + +#### Scenario: a rejection without a declared behaviour stays a rejection + +- **GIVEN** a task with no declared `onReject` +- **WHEN** its assignee completes it with outcome `rejected` and a comment +- **THEN** the task ends in state `completed` with outcome `rejected` +- @e2e exclude {existing behaviour retained; covered by unit tests} diff --git a/openspec/changes/task-expiry-and-outcomes/tasks.md b/openspec/changes/task-expiry-and-outcomes/tasks.md new file mode 100644 index 0000000000..57fa1cafb3 --- /dev/null +++ b/openspec/changes/task-expiry-and-outcomes/tasks.md @@ -0,0 +1,39 @@ +# Tasks: task-expiry-and-outcomes + +## 1. Storage + +- [x] 1.1 Add `on_timeout` and `on_reject` columns plus the + `or_tasks_open_expiry (is_terminal, expires_at)` index to + `openregister_tasks` (new migration). +- [x] 1.2 Add the fields to the `Task` entity (types, serialization). + +## 2. Intake + +- [x] 2.1 `TaskBuilder` accepts and validates `onTimeout`/`onReject` + against the reserved vocabulary; `onTimeout` without `expiresAt` + refused. + +## 3. Enforcement + +- [x] 3.1 `TaskMapper::findDueTimeouts()` — bounded, index-backed scan. +- [x] 3.2 `FlowTimerSweep` third scan calling + `TaskService::applyTimerOutcome()` per hit, counted and truncation + reported. +- [x] 3.3 `FlowTimerService::applyOutcome()` fallback to the task's + declared `onTimeout` for a non-enforcing expiry timer. + +## 4. Reject routing + +- [x] 4.1 `completeInternal()` routes a rejecting completion through the + timer-outcome mapping when the task declares `onReject: + dead_letter`. + +## 5. Surfaces + +- [x] 5.1 Task JSON and task-form description carry both behaviours. +- [x] 5.2 User-task and portal-task nodes accept both config keys. + +## 6. Tests + +- [x] 6.1 TaskBuilder validation, sweep scan, timer fallback, reject + routing. diff --git a/tests/Unit/BackgroundJob/FlowTimerWorkerTest.php b/tests/Unit/BackgroundJob/FlowTimerWorkerTest.php index be7332209a..b046ac6d3d 100644 --- a/tests/Unit/BackgroundJob/FlowTimerWorkerTest.php +++ b/tests/Unit/BackgroundJob/FlowTimerWorkerTest.php @@ -70,16 +70,16 @@ public function testRunsOnePassWithTheConfiguredBatchAndLogsWorkPerformed(): voi $this->appConfig->method('getValueString')->with('openregister', FlowTimerWorker::CONFIG_BATCH, '200')->willReturn('50'); $this->sweep->expects(self::once())->method('run') ->with(self::callback(static fn (DateTimeImmutable $now): bool => $now->format('Y-m-d H:i') === '2026-09-01 10:00'), 50) - ->willReturn(['expiriesFired' => 3, 'rungsFired' => 2, 'truncated' => true, 'errors' => 0]); + ->willReturn(['expiriesFired' => 3, 'rungsFired' => 2, 'taskTimeouts' => 1, 'truncated' => true, 'errors' => 0]); $this->logger->expects(self::once())->method('info') - ->with(self::stringContains('Fired 3 expiry timer(s) and 2 escalation rung(s); truncated: true'), self::anything()); + ->with(self::stringContains('Fired 3 expiry timer(s), 2 escalation rung(s) and 1 task timeout(s); truncated: true'), self::anything()); $this->tick(); }//end testRunsOnePassWithTheConfiguredBatchAndLogsWorkPerformed() public function testAQuietPassLogsNothingAndABadBatchIsFlooredAtOne(): void { $this->appConfig->method('getValueString')->willReturn('-5'); $this->sweep->expects(self::once())->method('run')->with(self::anything(), 1) - ->willReturn(['expiriesFired' => 0, 'rungsFired' => 0, 'truncated' => false, 'errors' => 0]); + ->willReturn(['expiriesFired' => 0, 'rungsFired' => 0, 'taskTimeouts' => 0, 'truncated' => false, 'errors' => 0]); $this->logger->expects(self::never())->method('info'); $this->tick(); }//end testAQuietPassLogsNothingAndABadBatchIsFlooredAtOne() diff --git a/tests/Unit/Db/TaskMapperQueriesTest.php b/tests/Unit/Db/TaskMapperQueriesTest.php index 34abbc975e..0fb93eb9bb 100644 --- a/tests/Unit/Db/TaskMapperQueriesTest.php +++ b/tests/Unit/Db/TaskMapperQueriesTest.php @@ -165,6 +165,28 @@ public function testInsertAndUpdateStampAndGuardTheEntityType(): void { $mapper->update(entity: new FlowRun()); }//end testInsertAndUpdateStampAndGuardTheEntityType() + /** + * The sweep's task scan (task-expiry-and-outcomes D-3) selects only open + * rows that DECLARE a timeout behaviour, orders by the deadline, and is + * bounded by a floored batch limit. + * + * @return void + */ + public function testFindDueTimeoutsScansOpenDeclaredRowsBoundedAndOrdered(): void { + $mapper = new TaskMapper(db: $this->connectionWith(rows: [$this->row()])); + $due = $mapper->findDueTimeouts(now: new \DateTime('2026-09-02 10:00:00'), limit: 200); + $this->assertCount(1, $due); + $this->assertSame('t-7', $due[0]->getUuid()); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + $this->assertTrue($this->saw('expr.isNotNull', 'on_timeout')); + $this->assertTrue($this->saw('orderBy', 'expires_at')); + $this->assertTrue($this->saw('setMaxResults', 200)); + + // The batch limit is floored at one, never zero or negative. + (new TaskMapper(db: $this->connectionWith(rows: [])))->findDueTimeouts(now: new \DateTime('2026-09-02 10:00:00'), limit: -5); + $this->assertTrue($this->saw('setMaxResults', 1)); + }//end testFindDueTimeoutsScansOpenDeclaredRowsBoundedAndOrdered() + /** * The propagation read selects by run uuid AND openness — the structural * half of "a standalone task survives everything". diff --git a/tests/Unit/Migration/Version1Date20260902090000Test.php b/tests/Unit/Migration/Version1Date20260902090000Test.php new file mode 100644 index 0000000000..cff7b80119 --- /dev/null +++ b/tests/Unit/Migration/Version1Date20260902090000Test.php @@ -0,0 +1,111 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/task-expiry-and-outcomes/specs/task-expiry-and-outcomes/spec.md#requirement-a-task-declares-its-timeout-and-reject-behaviour-in-one-vocabulary + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Migration; + +use Doctrine\DBAL\Schema\Table; +use OCA\OpenRegister\Migration\Version1Date20260902090000; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * The migration. + * + * @covers \OCA\OpenRegister\Migration\Version1Date20260902090000 + */ +class Version1Date20260902090000Test extends TestCase { + + /** + * A schema wrapper answering for the tasks table. + * + * @param Table&MockObject $table The table the wrapper serves. + * + * @return ISchemaWrapper&MockObject The wrapper. + */ + private function schemaWith(Table&MockObject $table): ISchemaWrapper&MockObject { + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->willReturnCallback( + static fn (string $name): bool => $name === 'openregister_tasks' + ); + $schema->method('getTable')->willReturn($table); + + return $schema; + }//end schemaWith() + + /** + * Run the step against a schema. + * + * @param ISchemaWrapper $schema The schema the closure yields. + * + * @return ISchemaWrapper|null What changeSchema() returned. + */ + private function apply(ISchemaWrapper $schema): ?ISchemaWrapper { + $step = new Version1Date20260902090000(); + + return $step->changeSchema( + output: $this->createMock(IOutput::class), + schemaClosure: static fn (): ISchemaWrapper => $schema, + options: [] + ); + }//end apply() + + public function testAddsBothColumnsAndTheExpiryIndexWhenMissing(): void { + $table = $this->createMock(Table::class); + $table->method('hasColumn')->willReturn(false); + $table->method('hasIndex')->willReturn(false); + + $added = []; + $table->expects($this->exactly(2))->method('addColumn')->willReturnCallback( + function (string $name, string $type, array $options) use (&$added): Table { + $added[] = [$name, $type, $options['notnull']]; + + return $this->createMock(Table::class); + } + ); + $table->expects($this->once())->method('addIndex')->with(['is_terminal', 'expires_at'], 'or_tasks_open_expiry'); + + $schema = $this->schemaWith(table: $table); + self::assertSame($schema, $this->apply(schema: $schema)); + self::assertSame([['on_timeout', 'string', false], ['on_reject', 'string', false]], $added); + }//end testAddsBothColumnsAndTheExpiryIndexWhenMissing() + + public function testARerunAgainstAMigratedTableChangesNothingAndReturnsNull(): void { + $table = $this->createMock(Table::class); + $table->method('hasColumn')->willReturn(true); + $table->method('hasIndex')->willReturn(true); + $table->expects($this->never())->method('addColumn'); + $table->expects($this->never())->method('addIndex'); + + self::assertNull($this->apply(schema: $this->schemaWith(table: $table))); + }//end testARerunAgainstAMigratedTableChangesNothingAndReturnsNull() + + public function testAnAbsentTasksTableIsLeftAlone(): void { + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->willReturn(false); + $schema->expects($this->never())->method('getTable'); + + self::assertNull($this->apply(schema: $schema)); + }//end testAnAbsentTasksTableIsLeftAlone() +}//end class diff --git a/tests/Unit/Service/Flow/Timer/FlowTimerServiceTest.php b/tests/Unit/Service/Flow/Timer/FlowTimerServiceTest.php index c1d4e36a95..e5f73b313d 100644 --- a/tests/Unit/Service/Flow/Timer/FlowTimerServiceTest.php +++ b/tests/Unit/Service/Flow/Timer/FlowTimerServiceTest.php @@ -825,6 +825,47 @@ public function testAnAnchorlessTimerRunsFromNow(): void { self::assertSame('2026-09-01 09:00', $timer->getAnchorAt()->format('Y-m-d H:i')); }//end testAnAnchorlessTimerRunsFromNow() + public function testANonEnforcingExpiryFallsBackToTheTasksDeclaredTimeout(): void { + // A servicenorm expiry timer may not carry onExpiry (only wettelijk + // enforces), but the SUBJECT TASK declares its own onTimeout — the + // task's contract applies when the timer fires + // (task-expiry-and-outcomes D-4). + $task = $this->task(); + $task->setOnTimeout('error'); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm( + config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'servicenorm', 'ladder' => null]), + actor: null, + now: $start + ); + + $applied = []; + $this->taskService->method('applyTimerOutcome')->willReturnCallback( + function (string $uuid, string $outcome, string $source, string $reason) use (&$applied): Task { + $applied[] = [$uuid, $outcome, $reason]; + + return $this->store->tasks[$uuid]; + } + ); + + self::assertTrue($this->service->fireExpiry(timer: $timer, now: $start->modify('+57 days'))); + self::assertSame([['task-1', 'error']], array_map(static fn (array $row): array => [$row[0], $row[1]], $applied)); + self::assertStringContainsString('task-declared onTimeout', $applied[0][2]); + }//end testANonEnforcingExpiryFallsBackToTheTasksDeclaredTimeout() + + public function testANonEnforcingExpiryWithNoDeclaredTimeoutAppliesNothing(): void { + $this->task(); + $start = $this->at('2026-09-01 09:00'); + $timer = $this->service->arm( + config: $this->config(['purpose' => 'expiry', 'legalEffect' => 'servicenorm', 'ladder' => null]), + actor: null, + now: $start + ); + + $this->taskService->expects(self::never())->method('applyTimerOutcome'); + self::assertTrue($this->service->fireExpiry(timer: $timer, now: $start->modify('+57 days'))); + }//end testANonEnforcingExpiryWithNoDeclaredTimeoutAppliesNothing() + public function testANonTaskSubjectEnforcesNothingAndEscalatesToRoles(): void { // An enforcing timer on an OBJECT subject fires and records, but no // task action is applied and the rung recipients stay role descriptors. diff --git a/tests/Unit/Service/Flow/Timer/FlowTimerSweepTest.php b/tests/Unit/Service/Flow/Timer/FlowTimerSweepTest.php index 0308427a59..6d1eb2f7aa 100644 --- a/tests/Unit/Service/Flow/Timer/FlowTimerSweepTest.php +++ b/tests/Unit/Service/Flow/Timer/FlowTimerSweepTest.php @@ -26,9 +26,12 @@ use DateTimeImmutable; use OCA\OpenRegister\Db\FlowTimer; use OCA\OpenRegister\Db\FlowTimerMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; use OCA\OpenRegister\Service\Flow\Timer\FlowTimerSweep; use OCA\OpenRegister\Service\Flow\Timer\WorkingCalendarService; +use OCA\OpenRegister\Service\Task\TaskService; use OCP\IDBConnection; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -39,6 +42,8 @@ * @covers \OCA\OpenRegister\Service\Flow\Timer\FlowTimerSweep * @covers \OCA\OpenRegister\Db\FlowTimer * @covers \OCA\OpenRegister\Db\FlowTimerMapper + * + * @uses \OCA\OpenRegister\Db\Task */ class FlowTimerSweepTest extends TestCase { @@ -88,7 +93,7 @@ public function testADueTimerBeyondTheBatchSizeIsStillReached(): void { $this->calendars->expects(self::once())->method('reset'); $result = $this->sweep->run(now: new DateTimeImmutable('2026-09-01 10:00:00'), batch: 200); - self::assertSame(['expiriesFired' => 1, 'rungsFired' => 0, 'truncated' => false, 'errors' => 0], $result); + self::assertSame(['expiriesFired' => 1, 'rungsFired' => 0, 'taskTimeouts' => 0, 'truncated' => false, 'errors' => 0], $result); }//end testADueTimerBeyondTheBatchSizeIsStillReached() public function testCountsReportWorkNotReadsAndTruncationIsVisible(): void { @@ -114,7 +119,7 @@ public function testASuspendedTimerIsNeverSelected(): void { $this->seed('s-1', 'expiry', '2026-08-30 00:00:00', '2026-08-30 00:00:00', FlowTimer::STATE_SUSPENDED); $this->service->expects(self::never())->method('fireExpiry'); $this->service->expects(self::never())->method('fireRungs'); - self::assertSame(['expiriesFired' => 0, 'rungsFired' => 0, 'truncated' => false, 'errors' => 0], $this->sweep->run(now: new DateTimeImmutable('2026-09-01'), batch: 10)); + self::assertSame(['expiriesFired' => 0, 'rungsFired' => 0, 'taskTimeouts' => 0, 'truncated' => false, 'errors' => 0], $this->sweep->run(now: new DateTimeImmutable('2026-09-01'), batch: 10)); }//end testASuspendedTimerIsNeverSelected() public function testOneFailureIsCountedAndDoesNotStopThePass(): void { @@ -132,6 +137,69 @@ public function testOneFailureIsCountedAndDoesNotStopThePass(): void { $this->logger->expects(self::exactly(2))->method('error'); $result = $this->sweep->run(now: new DateTimeImmutable('2026-09-01'), batch: 10); - self::assertSame(['expiriesFired' => 1, 'rungsFired' => 0, 'truncated' => false, 'errors' => 2], $result); + self::assertSame(['expiriesFired' => 1, 'rungsFired' => 0, 'taskTimeouts' => 0, 'truncated' => false, 'errors' => 2], $result); }//end testOneFailureIsCountedAndDoesNotStopThePass() + + /** + * A sweep wired with the task scan closes each due task through the + * timer-outcome path with the TASK's declared behaviour, counts the work, + * reports truncation when the scan hits the batch limit, and one failure + * does not stop the pass (task-expiry-and-outcomes D-3). + */ + public function testDueTaskTimeoutsAreAppliedCountedAndErrorTolerant(): void { + $due = []; + foreach ([['t-1', 'error'], ['t-2', 'dead_letter'], ['t-3', 'skip']] as [$uuid, $behaviour]) { + $task = new Task(); + $task->setUuid($uuid); + $task->setOnTimeout($behaviour); + $task->setExpiresAt(new DateTime('2026-08-30 00:00:00')); + $due[] = $task; + } + + $tasks = $this->createMock(TaskMapper::class); + $tasks->expects(self::once())->method('findDueTimeouts')->willReturn($due); + $taskService = $this->createMock(TaskService::class); + $applied = []; + $taskService->method('applyTimerOutcome')->willReturnCallback( + static function (string $uuid, string $outcome, string $source, string $reason) use (&$applied): Task { + if ($uuid === 't-2') { + throw new RuntimeException('row is poisoned'); + } + + $applied[$uuid] = [$outcome, $source, $reason]; + + return new Task(); + } + ); + $this->logger->expects(self::once())->method('error'); + + $sweep = new FlowTimerSweep( + timers: $this->mapper, + service: $this->service, + calendars: $this->calendars, + logger: $this->logger, + tasks: $tasks, + taskService: $taskService + ); + $result = $sweep->run(now: new DateTimeImmutable('2026-09-01 10:00:00'), batch: 3); + + self::assertSame(2, $result['taskTimeouts']); + self::assertSame(1, $result['errors']); + self::assertTrue($result['truncated'], 'the task scan hit the batch limit'); + self::assertSame(['error', FlowTimerSweep::TIMEOUT_SOURCE], [$applied['t-1'][0], $applied['t-1'][1]]); + self::assertSame('skip', $applied['t-3'][0]); + self::assertStringContainsString('2026-08-30', $applied['t-1'][2], 'the reason names the deadline that passed'); + }//end testDueTaskTimeoutsAreAppliedCountedAndErrorTolerant() + + /** + * A sweep constructed WITHOUT the task scan (the pre-change positional + * shape) keeps running the two timer scans and reports zero task work. + */ + public function testASweepWithoutTheTaskScanStillSweepsTimers(): void { + $this->seed('exp-1', 'expiry', '2026-08-30 00:00:00'); + $this->service->method('fireExpiry')->willReturn(true); + + $result = $this->sweep->run(now: new DateTimeImmutable('2026-09-01'), batch: 10); + self::assertSame(['expiriesFired' => 1, 'rungsFired' => 0, 'taskTimeouts' => 0, 'truncated' => false, 'errors' => 0], $result); + }//end testASweepWithoutTheTaskScanStillSweepsTimers() }//end class diff --git a/tests/Unit/Service/Task/TaskServiceTest.php b/tests/Unit/Service/Task/TaskServiceTest.php index f8112953a7..306c580589 100644 --- a/tests/Unit/Service/Task/TaskServiceTest.php +++ b/tests/Unit/Service/Task/TaskServiceTest.php @@ -1126,4 +1126,121 @@ public function testTheTemplateIsFrozenAtCreation(): void { $this->assertSame(3, $created->getTemplateVersion()); $this->assertSame(['checklist' => [['id' => 'c1', 'label' => 'Vast']]], $created->getTemplateSnapshot()); }//end testTheTemplateIsFrozenAtCreation() + + /** + * A REJECTION OF A TASK DECLARING onReject dead_letter LANDS IN THE + * DEAD LETTER STATE, through the same mapping the timer path resolves, + * with the comment kept and the audit naming the original rejection + * (task-expiry-and-outcomes D-5). + * + * @return void + */ + public function testARejectionRoutesToTheDeadLetterStateWhenDeclared(): void { + $task = $this->openTask(); + $task->setOnReject('dead_letter'); + $this->tasks->method('findByUuid')->willReturn($task); + $audited = []; + $this->setUpAuditRecorder(audited: $audited); + + $completed = $this->service()->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: 'niet akkoord', actor: 'alice'); + + $this->assertSame(Task::STATE_DISABLED, $completed->getState()); + $this->assertSame('dead_letter', $completed->getOutcome()); + $this->assertTrue($completed->getIsTerminal()); + $this->assertSame('niet akkoord', $completed->getComment()); + $this->assertStringContainsString("'rejected'", (string)$audited[0]->getReason()); + }//end testARejectionRoutesToTheDeadLetterStateWhenDeclared() + + /** + * WITHOUT A DECLARED BEHAVIOUR a rejection stays a plain rejection, and + * a declared `error`/`skip` changes nothing about the completion either: + * those two are the resuming consumer's contract, not the record's. + * + * @return void + */ + public function testARejectionWithoutDeadLetterStaysARejection(): void { + foreach ([null, 'error', 'skip'] as $declared) { + $this->setUp(); + $task = $this->openTask(); + $task->setOnReject($declared); + $this->tasks->method('findByUuid')->willReturn($task); + + $completed = $this->service()->complete(uuid: 't-7', outcome: 'rejected', resultText: null, comment: 'nee', actor: 'alice'); + + $this->assertSame(Task::STATE_COMPLETED, $completed->getState(), var_export($declared, true)); + $this->assertSame('rejected', $completed->getOutcome(), var_export($declared, true)); + } + }//end testARejectionWithoutDeadLetterStaysARejection() + + /** + * AN APPROVING COMPLETION IGNORES onReject ENTIRELY: the behaviour is a + * reject behaviour, not a completion behaviour. + * + * @return void + */ + public function testAnApprovalIgnoresTheDeclaredRejectBehaviour(): void { + $task = $this->openTask(); + $task->setOnReject('dead_letter'); + $this->tasks->method('findByUuid')->willReturn($task); + + $completed = $this->service()->complete(uuid: 't-7', outcome: 'approved', resultText: null, comment: null, actor: 'alice'); + + $this->assertSame(Task::STATE_COMPLETED, $completed->getState()); + $this->assertSame('approved', $completed->getOutcome()); + }//end testAnApprovalIgnoresTheDeclaredRejectBehaviour() + + /** + * INTAKE REFUSES a behaviour outside the vocabulary by name, and a + * timeout behaviour with no deadline (task-expiry-and-outcomes D-6); + * valid declarations are carried onto the row. + * + * @return void + */ + public function testDeclaredBehavioursAreValidatedAtIntake(): void { + $builder = new TaskBuilder(); + + try { + $builder->fromData(data: ['onTimeout' => 'explode', 'expiresAt' => '2027-01-01T00:00:00+00:00'], actor: 'rita'); + $this->fail('An unknown behaviour must be refused.'); + } catch (TaskValidationException $refusal) { + $this->assertStringContainsString("'explode'", $refusal->getMessage()); + $this->assertStringContainsString('dead_letter', $refusal->getMessage()); + } + + try { + $builder->fromData(data: ['onTimeout' => 'error'], actor: 'rita'); + $this->fail('A timeout behaviour without a deadline must be refused.'); + } catch (TaskValidationException $refusal) { + $this->assertStringContainsString('expiresAt', $refusal->getMessage()); + } + + $task = $builder->fromData( + data: ['expiresAt' => '2027-01-01T00:00:00+00:00', 'onTimeout' => 'dead_letter', 'onReject' => 'error'], + actor: 'rita' + ); + $this->assertSame('dead_letter', $task->getOnTimeout()); + $this->assertSame('error', $task->getOnReject()); + + $rejectOnly = $builder->fromData(data: ['onReject' => 'dead_letter'], actor: 'rita'); + $this->assertSame('dead_letter', $rejectOnly->getOnReject()); + $this->assertNull($rejectOnly->getOnTimeout()); + }//end testDeclaredBehavioursAreValidatedAtIntake() + + /** + * Re-route audit inserts into a local recorder for one test. + * + * @param array $audited Filled with the audit rows, by reference. + * + * @return void + */ + private function setUpAuditRecorder(array &$audited): void { + $this->audits = $this->createMock(TaskAuditMapper::class); + $this->audits->method('insert')->willReturnCallback( + static function (TaskAudit $entry) use (&$audited): TaskAudit { + $audited[] = $entry; + + return $entry; + } + ); + }//end setUpAuditRecorder() }//end class From 501136a817ad56c12b018c56552dd8bb6145316b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 20:37:11 +0200 Subject: [PATCH 53/68] Takes nextcloud-vue 2.31.1 so a hovered KPI tile stops drawing a card inside (#3343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit its own card. ⚠️ THE CAUSE IS AN INSET, NOT A COMPONENT. A card widget renders `flush` and then has its padding put BACK by the card-fit rule (`padding: 8px 14px`), so the KPI sits inside the wrapper while the WRAPPER draws the border, radius and background the user reads as "the card". The library's `--clickable:hover` rule then drew a 2px border and a drop shadow on the KPI itself, 8-14px in from the edge being hovered. Measured live on dossiq, while genuinely hovered: the tile carried a 2px rgb(0,103,158) border and its own shadow, inset 9px from the wrapper's top and 15px from its left. A non-card-fit tile sits at 1px, so its border lands on the wrapper's own edge and reads as one card. That inset is the whole difference, which is why it looked app-specific and was not. nextcloud-vue#932 moves the affordance to the wrapper. No layout change: nothing moves, one card outlines. Verified in the browser with a real pointer hover: before inner 2px rgb(0,103,158) + shadow wrapper grey, no shadow after inner transparent, no shadow wrapper rgb(0,103,158) + shadow Every app with clickable stat, gauge or delta tiles had this. This bump is one of the fleet sweep that clears it. Dependency change only: package.json and the nextcloud-vue entry in package-lock.json. Co-authored-by: Conduction Release Bot --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa595c240e..90f6a2679a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "EUPL-1.2", "dependencies": { "@codemirror/lang-json": "^6.0.1", - "@conduction/nextcloud-vue": "^2.30.0", + "@conduction/nextcloud-vue": "^2.31.1", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", @@ -2289,9 +2289,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.30.0.tgz", - "integrity": "sha512-Nztc3DlrEeXUwlAkT3MY1wWmyXSuh3T4kTgVdK2ejIrnBO0oFM7AimrFnRY/0YvXJWJE3uV/uM7cmte58TqnLw==", + "version": "2.31.1", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.31.1.tgz", + "integrity": "sha512-tF1/7yNaBgxj5iHhpNuxVdUrPugfy2ePQZILl7y+fFJTq68tR7WPs8EqPin8aaCBc6ZxgWp1ciyY+NW9rXBMYA==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", diff --git a/package.json b/package.json index b2771358c5..d0bfe0b57c 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ }, "dependencies": { "@codemirror/lang-json": "^6.0.1", - "@conduction/nextcloud-vue": "^2.30.0", + "@conduction/nextcloud-vue": "^2.31.1", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", From 8783ce60d9e15f61bfa0188ba439bb59183ac9bb Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Thu, 3 Sep 2026 07:07:49 +0200 Subject: [PATCH 54/68] fix(federation): a federated create may not address an existing object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO holes in one line, and the second is the one the line was written to prevent. `FederationController::createObject()` is `#[PublicPage]`, `#[NoCSRFRequired]`, and calls `saveObject()` with `_rbac: false` and `_multitenancy: false`, so nothing downstream refuses what it lets through. It stripped `shareToken` and `_route`, then pinned the organisation with: $data['@self'] = (($data['@self'] ?? []) + ['organisation' => ...]); PHP's `+` keeps the LEFT operand's keys. So for a caller holding a writable share token: 1. `@self.id` SURVIVED. `saveObject()` resolves its target from the payload — `extractUuidAndNormalizeObject()` reads `@self.id` first, then `id` — and the write is PUT-semantic, so omitted fields are NULLED. A share that grants the right to ADD objects also granted the right to overwrite every existing object in the shared register/schema. 2. `@self.organisation` SURVIVED TOO, which defeats the pin. The comment on that very line reads "a federated writer can never plant an object into another organisation". It could. The second was found by the first test written for the first: a pin has to overwrite, or it is only a default. Assigned rather than merged now. `DsarCaseController::create()` gets the same treatment. It is guarded by `requireAuthenticated()` rather than an admin setting, and stripped only `_route`, so a create could address an existing DSAR case. All three new tests fail against the original code and pass against the fix. Full suite: 19037 tests, phpcs clean. NOT CLAIMED: end-to-end exploitability. Both paths need a valid `read-write` share token, and this was not driven against a live federated pair. --- lib/Controller/DsarCaseController.php | 15 +- lib/Controller/FederationController.php | 30 ++- ...FederationControllerCreateIdentityTest.php | 191 ++++++++++++++++++ 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Controller/FederationControllerCreateIdentityTest.php diff --git a/lib/Controller/DsarCaseController.php b/lib/Controller/DsarCaseController.php index 8594d9b658..57915149a6 100644 --- a/lib/Controller/DsarCaseController.php +++ b/lib/Controller/DsarCaseController.php @@ -117,8 +117,19 @@ public function create(): JSONResponse { } $body = (array)$this->request->getParams(); - // Strip framework-injected keys so only the case payload is saved. - unset($body['_route']); + // Strip framework-injected keys so only the case payload is saved, and + // the caller's IDENTITY with them. `saveObject()` resolves its target + // from the payload (`@self.id` first, then `id`) and the write is + // PUT-semantic, so a create carrying either would overwrite an existing + // case and null every field it omitted. This endpoint creates. + unset($body['_route'], $body['id'], $body['uuid']); + $self = (array)($body['@self'] ?? []); + unset($self['id'], $self['uuid']); + if ($self !== []) { + $body['@self'] = $self; + } else { + unset($body['@self']); + } try { $saved = $this->objectService->saveObject( diff --git a/lib/Controller/FederationController.php b/lib/Controller/FederationController.php index 82ac171210..087914850c 100644 --- a/lib/Controller/FederationController.php +++ b/lib/Controller/FederationController.php @@ -332,9 +332,37 @@ public function createObject(string $shareToken): JSONResponse { $data = (array)$this->request->getParams(); unset($data['shareToken'], $data['_route']); + + // 🔴 STRIP THE CALLER'S IDENTITY. This endpoint CREATES. + // + // `saveObject()` resolves its target from the payload: + // `extractUuidAndNormalizeObject()` reads `@self.id` first, then `id`, + // and treats a match as the uuid to UPDATE — PUT-semantically, so every + // field the payload omits is NULLED. + // + // This path is `#[PublicPage]` and runs `_rbac: false`, so nothing + // downstream would have refused it. A share grants the holder the right + // to ADD objects; without this it also granted the right to overwrite + // every existing object in the shared register/schema. + // + // The organisation pin below was already guarding the sibling half of + // this — "a federated writer can never plant an object into another + // organisation" — but `+` preserves the LEFT operand's keys, so a + // caller-supplied `@self: {id: …}` survived the merge untouched. + unset($data['id'], $data['uuid']); + $self = (array)($data['@self'] ?? []); + unset($self['id'], $self['uuid']); + // Pin the object to the sharing organisation — a federated writer can // never plant an object into another organisation. - $data['@self'] = (($data['@self'] ?? []) + ['organisation' => $share->getOrganisation()]); + // + // ASSIGNED, not merged. This was `$self + ['organisation' => …]`, and + // `+` keeps the LEFT operand's keys: a caller who supplied + // `@self: {organisation: 'somebody-else'}` kept it, so the pin this + // comment describes did not hold. The same `+` is what let a supplied + // `@self.id` through. A pin has to overwrite, or it is a default. + $self['organisation'] = $share->getOrganisation(); + $data['@self'] = $self; try { $saved = $this->objectService->saveObject( diff --git a/tests/Unit/Controller/FederationControllerCreateIdentityTest.php b/tests/Unit/Controller/FederationControllerCreateIdentityTest.php new file mode 100644 index 0000000000..f79cc2d709 --- /dev/null +++ b/tests/Unit/Controller/FederationControllerCreateIdentityTest.php @@ -0,0 +1,191 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use OCA\OpenRegister\Controller\FederationController; +use OCA\OpenRegister\Db\FederatedShare; +use OCA\OpenRegister\Db\FederatedShareMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\FederationShareService; +use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\Security\Bruteforce\IThrottler; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * A federated create may not address an existing object. + */ +class FederationControllerCreateIdentityTest extends TestCase { + /** + * The share mapper. + * + * @var FederatedShareMapper&MockObject + */ + private $shareMapper; + + /** + * The object service. + * + * @var ObjectService&MockObject + */ + private $objectService; + + /** + * The payload the controller handed to saveObject. + * + * @var array|null + */ + private ?array $written = null; + + /** + * Build the controller over a request carrying the given body. + * + * @param array $params The request parameters. + * + * @return FederationController The controller. + */ + private function controller(array $params): FederationController { + $share = new FederatedShare(); + $share->setDirection('outgoing'); + $share->setStatus('accepted'); + $share->setScope('collection'); + $share->setRegister('zaken'); + $share->setSchema('zaak'); + $share->setOrganisation('org-a'); + $share->setPermissions('read-write'); + + $this->shareMapper = $this->createMock(FederatedShareMapper::class); + $this->shareMapper->method('findByToken')->willReturn($share); + + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + + $this->objectService = $this->createMock(ObjectService::class); + $this->objectService->method('saveObject')->willReturnCallback( + function (...$args) { + // saveObject is called with named arguments; the object is the + // first, however PHPUnit hands it back. + $this->written = (array)($args[0] ?? []); + return $this->createMock(ObjectEntity::class); + } + ); + + return new FederationController( + 'openregister', + $request, + $this->shareMapper, + $this->objectService, + $this->createMock(FederationShareService::class), + $this->createMock(IThrottler::class), + $this->createMock(LoggerInterface::class) + ); + }//end controller() + + /** + * 🔴 A caller-supplied `@self.id` must not reach the write. + * + * It is the key `saveObject()` reads FIRST, and this path disables RBAC, so + * nothing downstream would have refused the resulting update. + * + * @return void + */ + public function testASuppliedSelfIdDoesNotReachTheWrite(): void { + $controller = $this->controller( + [ + 'title' => 'Federated addition', + '@self' => ['id' => 'someone-elses-object'], + ] + ); + + $response = $controller->createObject(shareToken: 'tok'); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertIsArray($this->written, 'the write must have happened'); + $this->assertArrayNotHasKey('id', $this->written['@self'] ?? []); + $this->assertArrayNotHasKey('uuid', $this->written['@self'] ?? []); + }//end testASuppliedSelfIdDoesNotReachTheWrite() + + /** + * A top-level `id` or `uuid` must not reach the write either. + * + * `@self.id` is read first, but `id` is the fallback, so stripping only the + * nested one would leave the same hole one key over. + * + * @return void + */ + public function testASuppliedTopLevelIdDoesNotReachTheWrite(): void { + $controller = $this->controller( + [ + 'title' => 'Federated addition', + 'id' => 'someone-elses-object', + 'uuid' => 'someone-elses-object', + ] + ); + + $controller->createObject(shareToken: 'tok'); + + $this->assertIsArray($this->written); + $this->assertArrayNotHasKey('id', $this->written); + $this->assertArrayNotHasKey('uuid', $this->written); + }//end testASuppliedTopLevelIdDoesNotReachTheWrite() + + /** + * The organisation pin still holds, and the payload still arrives. + * + * The guard must not have traded one defect for another: stripping too much + * would break federated writes outright, and dropping the pin would let a + * writer plant an object into another organisation. + * + * @return void + */ + public function testTheOrganisationPinAndThePayloadSurvive(): void { + $controller = $this->controller( + [ + 'title' => 'Federated addition', + '@self' => ['id' => 'someone-elses-object', 'organisation' => 'org-attacker'], + ] + ); + + $controller->createObject(shareToken: 'tok'); + + $this->assertIsArray($this->written); + $this->assertSame('org-a', $this->written['@self']['organisation'] ?? null); + $this->assertSame('Federated addition', $this->written['title'] ?? null); + }//end testTheOrganisationPinAndThePayloadSurvive() +}//end class From 2740ada0483a8875acb4bdc60f171312c97c17bf Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Thu, 3 Sep 2026 07:16:01 +0200 Subject: [PATCH 55/68] style(dsar): drop the else phpmd flagged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phpmd's ElseExpression rule, on the identity strip added in the previous commit. The unset is unconditional now and the conditional only re-adds a non-empty @self, which reads better anyway. Scoped phpmd on the file: clean. The full-tree run is deliberately left to CI — it OOM-killed this host once already. --- lib/Controller/DsarCaseController.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/Controller/DsarCaseController.php b/lib/Controller/DsarCaseController.php index 57915149a6..dcc1b39a5e 100644 --- a/lib/Controller/DsarCaseController.php +++ b/lib/Controller/DsarCaseController.php @@ -122,13 +122,11 @@ public function create(): JSONResponse { // from the payload (`@self.id` first, then `id`) and the write is // PUT-semantic, so a create carrying either would overwrite an existing // case and null every field it omitted. This endpoint creates. - unset($body['_route'], $body['id'], $body['uuid']); $self = (array)($body['@self'] ?? []); unset($self['id'], $self['uuid']); + unset($body['_route'], $body['id'], $body['uuid'], $body['@self']); if ($self !== []) { $body['@self'] = $self; - } else { - unset($body['@self']); } try { From 709a8558d7b8133c20f717db84d5f85901352bc7 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Thu, 3 Sep 2026 07:26:29 +0200 Subject: [PATCH 56/68] docs(federation): the @spec tag gate-16 asked for createObject() was the one changed method without one. Tagged to the same federation-scope-enforcement spec its three sibling methods in this controller already point at. Co-Authored-By: Claude Opus 5 (1M context) --- lib/Controller/FederationController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Controller/FederationController.php b/lib/Controller/FederationController.php index 087914850c..5d84176f4d 100644 --- a/lib/Controller/FederationController.php +++ b/lib/Controller/FederationController.php @@ -319,6 +319,8 @@ public function object(string $shareToken, string $id): JSONResponse { * @param string $shareToken The scoped bearer share token. * * @return JSONResponse The created object, or an error. + * + * @spec openspec/changes/federation-scope-enforcement/specs/federation-scope-enforcement/spec.md */ #[PublicPage] #[NoCSRFRequired] From 63ad6ff8c3638538de8a3401ca31cc0386d41dfb Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Thu, 3 Sep 2026 07:56:18 +0200 Subject: [PATCH 57/68] feat(quality): the quality assessment is @self metadata, not object data A per-object quality score is an assessment OF the object's data, not a fact about the thing the object describes. It had nowhere to live except the object body, which forced every schema wanting a score to declare `qualityScore` and `qualityStatus` as ordinary properties. Three things followed, and all three are wrong: - The properties appear on every form the schema drives. A case handler filing a case in dossiq was shown a "Quality score" number field to fill in, for a value the platform overwrites on save. - Removing the declaration silently DELETES the values, because the store strips what the schema does not declare. So the mistake could not be undone without data loss. - Two schemas scoring the same way had to agree on property names by convention, with nothing to enforce it. `_quality` is now a metadata column of the same kind as `_validation` and `_retention`, surfaced in the `@self` envelope as `quality` with `score`, `status` and `scoredAt`. The change is additive on purpose. The listener still writes the body properties, but ONLY where the schema actually declares them: dropping that outright would freeze the stored value of every schema that has one, and a score that silently stops updating reads exactly like a score that is simply good. A schema that has migrated declares neither, so nothing is written to its objects and the body stays clean. The migration sweeps every per-schema object table, adding the column where it is missing and skipping it where present, so re-running is a no-op. It identifies an object table by the `_uuid` column rather than by the name prefix alone; verified against a live database, where every `oc_openregister_table_%` carries `_uuid` and `_validation` and none yet carries `_quality`. Verified: 1886 unit tests in tests/Unit/Db and tests/Unit/Listener pass (1881 before, plus the 5 added here), with the same 3 pre-existing vendor warnings and 5 skips as the pristine tree. PHPCS, PHPStan clean on every changed file. Co-Authored-By: Claude Opus 5 (1M context) --- lib/Db/MagicMapper.php | 11 ++ lib/Db/ObjectEntity.php | 23 +++ lib/Listener/QualityScoreOnSaveListener.php | 49 ++++-- lib/Migration/Version1Date20260903090000.php | 132 +++++++++++++++ .../QualityScoreOnSaveListenerTest.php | 156 ++++++++++++++++++ 5 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 lib/Migration/Version1Date20260903090000.php create mode 100644 tests/Unit/Listener/QualityScoreOnSaveListenerTest.php diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php index 67d75a8127..b2ea5380b0 100644 --- a/lib/Db/MagicMapper.php +++ b/lib/Db/MagicMapper.php @@ -2460,6 +2460,15 @@ private function getMetadataColumns(): array { 'type' => 'json', 'nullable' => true, ], + // Platform-owned quality assessment. Lives here rather than in the + // object body so a schema does not have to declare `qualityScore` + // as an ordinary property just to be scored — which put a number + // field the platform overwrites on every form the schema drives. + self::METADATA_PREFIX . 'quality' => [ + 'name' => self::METADATA_PREFIX . 'quality', + 'type' => 'json', + 'nullable' => true, + ], self::METADATA_PREFIX . 'deleted' => [ 'name' => self::METADATA_PREFIX . 'deleted', 'type' => 'json', @@ -3689,6 +3698,7 @@ private function prepareObjectDataForTable(array $objectData, Register $register // which is what "not writable by this path" has to mean. Writes go // through the dedicated authorization-management path. 'validation', + 'quality', 'deleted', 'geo', 'retention', @@ -3736,6 +3746,7 @@ private function prepareObjectDataForTable(array $objectData, Register $register 'locked', 'authorization', 'validation', + 'quality', 'deleted', 'geo', 'retention', diff --git a/lib/Db/ObjectEntity.php b/lib/Db/ObjectEntity.php index 136472ab4d..86d9df2d97 100644 --- a/lib/Db/ObjectEntity.php +++ b/lib/Db/ObjectEntity.php @@ -89,6 +89,8 @@ * @method void setOrganisation(?string $organisation) * @method array|null getValidation() * @method void setValidation(?array $validation) + * @method array|null getQuality() + * @method void setQuality(?array $quality) * @method array|null getDeleted() * @method void setDeleted(?array $deleted) * @method array|null getGeo() @@ -263,6 +265,24 @@ class ObjectEntity extends Entity implements JsonSerializable, ObjectEntityInter */ protected ?array $validation = []; + /** + * Platform-computed quality assessment for the object. + * + * Written by QualityScoreOnSaveListener when the schema carries an + * `x-openregister-quality` annotation. Shape: + * `['score' => 0.0-1.0, 'status' => 'good'|..., 'scoredAt' => '...']` + * + * This is an assessment OF the object's data, not a fact about the thing + * the object describes, which is why it lives in the `@self` envelope + * rather than among the object's own properties. Storing it here means a + * schema no longer has to declare `qualityScore` as an ordinary property + * to be scored, and so no longer puts a number field the platform + * overwrites in front of the person filling the form. + * + * @var array|null Array describing the quality assessment + */ + protected ?array $quality = []; + /** * Deletion details if the object is deleted. * @@ -694,6 +714,7 @@ public function __construct() { $this->addType(fieldName: 'application', type: 'string'); $this->addType(fieldName: 'organisation', type: 'string'); $this->addType(fieldName: 'validation', type: 'json'); + $this->addType(fieldName: 'quality', type: 'json'); $this->addType(fieldName: 'deleted', type: 'json'); $this->addType(fieldName: 'geo', type: 'json'); $this->addType(fieldName: 'retention', type: 'json'); @@ -737,6 +758,7 @@ protected function getter(string $name): mixed { 'relations', 'authorization', 'validation', + 'quality', 'deleted', 'groups', 'geo', @@ -1004,6 +1026,7 @@ public function getObjectArray(array $object = []): array { 'folder' => $this->folder, 'application' => $this->application, 'validation' => $this->getValidation(), + 'quality' => $this->getQuality(), 'geo' => $this->getGeo(), 'retention' => $this->getRetention(), 'tmlo' => $this->getTmlo(), diff --git a/lib/Listener/QualityScoreOnSaveListener.php b/lib/Listener/QualityScoreOnSaveListener.php index 3ccec15fc4..fdd8936bff 100644 --- a/lib/Listener/QualityScoreOnSaveListener.php +++ b/lib/Listener/QualityScoreOnSaveListener.php @@ -143,28 +143,53 @@ private function process(ObjectEntity $object): void { return; } + $thresholds = ($quality['thresholds'] ?? []); + if (is_array($thresholds) === false) { + $thresholds = []; + } + + $status = $this->scorer->status(score: $score, thresholds: $thresholds); + + // The assessment goes in the `@self` envelope, always. It describes + // the object's data rather than the thing the object describes, and + // putting it there is what lets a schema be scored WITHOUT declaring + // `qualityScore` as an ordinary property — which used to put a number + // field the platform overwrites on save in front of whoever was + // filling the form. + $object->setQuality( + [ + 'score' => $score, + 'status' => $status, + 'scoredAt' => (new DateTimeImmutable())->format(DATE_ATOM), + ] + ); + + // The body properties are still written, but ONLY where the schema + // actually declares them. Dropping the write outright would freeze the + // stored value of every schema that has one, and a score that silently + // stops updating reads exactly like a score that is simply good. + // + // A schema that has migrated to `@self.quality` declares neither, so + // this writes nothing and the object body stays clean. + $properties = ($schema->getProperties() ?? []); + $field = (string)($quality['field'] ?? self::DEFAULT_FIELD); if ($field === '') { $field = self::DEFAULT_FIELD; } - if (($data[$field] ?? null) !== $score) { + if (array_key_exists($field, $properties) === true && ($data[$field] ?? null) !== $score) { $data[$field] = $score; $changed = true; } $statusField = (string)($quality['statusField'] ?? ''); - if ($statusField !== '') { - $thresholds = ($quality['thresholds'] ?? []); - if (is_array($thresholds) === false) { - $thresholds = []; - } - - $status = $this->scorer->status(score: $score, thresholds: $thresholds); - if (($data[$statusField] ?? null) !== $status) { - $data[$statusField] = $status; - $changed = true; - } + if ($statusField !== '' + && array_key_exists($statusField, $properties) === true + && ($data[$statusField] ?? null) !== $status + ) { + $data[$statusField] = $status; + $changed = true; } if ($changed === true) { diff --git a/lib/Migration/Version1Date20260903090000.php b/lib/Migration/Version1Date20260903090000.php new file mode 100644 index 0000000000..44dedab44c --- /dev/null +++ b/lib/Migration/Version1Date20260903090000.php @@ -0,0 +1,132 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Gives every per-schema object table a `_quality` metadata column. + */ +class Version1Date20260903090000 extends SimpleMigrationStep { + + /** + * The metadata column this step adds. + * + * @var string + */ + private const QUALITY_COLUMN = '_quality'; + + /** + * A column that every per-schema object table carries. + * + * Used to tell a per-schema object table from any other table that + * happens to share the prefix. `_uuid` is written by every object write + * path, so a table without it is not one this step should touch. + * + * @var string + */ + private const SIGNATURE_COLUMN = '_uuid'; + + /** + * Constructor. + * + * @param IConfig $config Reads the configured database table prefix. + */ + public function __construct(private readonly IConfig $config) { + + }//end __construct() + + /** + * Add `_quality` to every per-schema object table that lacks it. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the live ISchemaWrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + // @var ISchemaWrapper $schema + $schema = $schemaClosure(); + $prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_'); + $changed = 0; + + foreach ($schema->getTables() as $table) { + if (str_starts_with($table->getName(), $prefix.'openregister_table_') === false) { + continue; + } + + // A table sharing the prefix but missing the signature column is + // not an object table. Adding a column to it would be a guess. + if ($table->hasColumn(self::SIGNATURE_COLUMN) === false) { + continue; + } + + if ($table->hasColumn(self::QUALITY_COLUMN) === true) { + continue; + } + + $table->addColumn(self::QUALITY_COLUMN, Types::JSON, ['notnull' => false]); + $changed++; + }//end foreach + + if ($changed === 0) { + $output->info('quality metadata: every per-schema object table already has _quality, nothing to do'); + return null; + } + + $output->info(sprintf('quality metadata: added _quality to %d per-schema object table(s)', $changed)); + + return $schema; + + }//end changeSchema() + +}//end class diff --git a/tests/Unit/Listener/QualityScoreOnSaveListenerTest.php b/tests/Unit/Listener/QualityScoreOnSaveListenerTest.php new file mode 100644 index 0000000000..1049daac0f --- /dev/null +++ b/tests/Unit/Listener/QualityScoreOnSaveListenerTest.php @@ -0,0 +1,156 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\Listener; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Event\ObjectCreatingEvent; +use OCA\OpenRegister\Listener\QualityScoreOnSaveListener; +use OCA\OpenRegister\Service\Quality\QualityScorer; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * The quality assessment belongs to `@self`, not to the object's data. + * + * It describes the object's data rather than the thing the object describes. + * Keeping it out of the body is what lets a schema be scored without + * declaring `qualityScore` as an ordinary property, which used to put a + * number field the platform overwrites in front of whoever filled the form. + */ +class QualityScoreOnSaveListenerTest extends TestCase { + + private SchemaMapper $schemaMapper; + + private QualityScorer $scorer; + + private QualityScoreOnSaveListener $listener; + + protected function setUp(): void { + $this->schemaMapper = $this->createMock(SchemaMapper::class); + $this->scorer = $this->createMock(QualityScorer::class); + + $this->listener = new QualityScoreOnSaveListener( + $this->schemaMapper, + $this->scorer, + $this->createMock(LoggerInterface::class), + ); + } + + /** + * A schema carrying the annotation, declaring the given properties. + * + * Only methods Schema really declares are stubbed. PHPUnit refuses to + * configure one that does not exist, which is the property that makes this + * mock able to fail when the real signature moves. + * + * @param array $properties The schema's declared properties. + * @param array $quality The `x-openregister-quality` annotation. + */ + private function schema(array $properties, array $quality = ['rules' => [['field' => 'title']]]): Schema { + $schema = $this->createMock(Schema::class); + $schema->method('getConfiguration')->willReturn(['x-openregister-quality' => $quality]); + $schema->method('getProperties')->willReturn($properties); + + return $schema; + } + + private function fire(ObjectEntity $object, Schema $schema): void { + $this->schemaMapper->method('find')->willReturn($schema); + $this->scorer->method('score')->willReturn(0.5); + $this->scorer->method('status')->willReturn('fair'); + + $this->listener->handle(new ObjectCreatingEvent($object)); + } + + public function testWritesTheAssessmentIntoSelf(): void { + $object = new ObjectEntity(); + $object->setSchema(1); + $object->setObject(['title' => 'A case']); + + $this->fire($object, $this->schema(['title' => ['type' => 'string']])); + + $quality = $object->getQuality(); + $this->assertSame(0.5, $quality['score']); + $this->assertSame('fair', $quality['status']); + $this->assertArrayHasKey('scoredAt', $quality); + } + + public function testLeavesTheObjectBodyAloneWhenTheSchemaDeclaresNoQualityProperty(): void { + // The whole point of the change. A schema that has dropped its + // `qualityScore` declaration is still scored, and its objects stay + // clean. + $object = new ObjectEntity(); + $object->setSchema(1); + $object->setObject(['title' => 'A case']); + + $this->fire($object, $this->schema(['title' => ['type' => 'string']])); + + $this->assertSame('A case', $object->getObject()['title']); + $this->assertArrayNotHasKey('qualityScore', $object->getObject()); + $this->assertArrayNotHasKey('qualityStatus', $object->getObject()); + } + + public function testStillWritesTheBodyPropertyWhileTheSchemaDeclaresIt(): void { + // Dropping the body write outright would freeze the stored value of + // every schema that has one, and a score that silently stops updating + // reads exactly like a score that is simply good. + $object = new ObjectEntity(); + $object->setSchema(1); + $object->setObject(['title' => 'A case', 'qualityScore' => 0.1]); + + $this->fire($object, $this->schema( + [ + 'title' => ['type' => 'string'], + 'qualityScore' => ['type' => 'number'], + 'qualityStatus' => ['type' => 'string'], + ], + ['rules' => [['field' => 'title']], 'statusField' => 'qualityStatus'], + )); + + $this->assertSame(0.5, $object->getObject()['qualityScore']); + $this->assertSame('fair', $object->getObject()['qualityStatus']); + // And it is in @self as well, so a consumer can move over before the + // schema drops the declaration. + $this->assertSame(0.5, $object->getQuality()['score']); + } + + public function testDoesNotInventAStatusPropertyTheSchemaNeverDeclared(): void { + $object = new ObjectEntity(); + $object->setSchema(1); + $object->setObject(['title' => 'A case']); + + $this->fire($object, $this->schema( + ['title' => ['type' => 'string']], + ['rules' => [['field' => 'title']], 'statusField' => 'qualityStatus'], + )); + + $this->assertArrayNotHasKey('qualityStatus', $object->getObject()); + // The status is still assessed; it just lives in the envelope. + $this->assertSame('fair', $object->getQuality()['status']); + } + + public function testIgnoresASchemaWithoutTheAnnotation(): void { + $object = new ObjectEntity(); + $object->setSchema(1); + $object->setObject(['title' => 'A case']); + + $schema = $this->createMock(Schema::class); + $schema->method('getConfiguration')->willReturn([]); + $schema->method('getProperties')->willReturn([]); + $this->schemaMapper->method('find')->willReturn($schema); + + $this->listener->handle(new ObjectCreatingEvent($object)); + + $this->assertEmpty($object->getQuality()); + $this->assertArrayNotHasKey('qualityScore', $object->getObject()); + } +} From 4309cec0ce79dfcaef44e1e32a7fe19e3158d461 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 09:40:40 +0200 Subject: [PATCH 58/68] feat(flow): a user task arms its own deadline (#3350) * feat(flow): a user task arms its own deadline FlowTimerService::arm() had no production caller anywhere in OpenRegister. Every reference to it lived in its own unit test, and it was never resolved from the container by anything that runs. The escalation ladders, the working calendar and the SLA calculator were all built, all tested and all unreachable: a flow author could describe a deadline and nothing would ever measure it. UserTaskNode now arms a timer when it creates its task, bound to subjectType 'task' and the task's uuid. That is the same pair FlowTimerSubjectTerminalListener already cancels on, so completing or cancelling the task disarms its deadline instead of leaving a timer firing against a subject that is finished. Three properties worth stating, because they are what makes this safe to add to an engine every app shares: - A node with no `sla` arms nothing and behaves exactly as before. All 36 pre-existing tests pass untouched. - An SLA that cannot be armed fails the node rather than logging and continuing. A task carrying a declared deadline that nothing is measuring is worse than a task with no deadline, because the flow reports a term it is not keeping; for a wettelijk deadline that is a legal defect, not a logging concern. No existing flow can reach this branch. - Only keys the author actually set are forwarded, so arm()'s own defaults for purpose, legalEffect and onExpiry still apply instead of being overridden with nulls. Verified: full suite 19045 tests green; negative control (remove the arm call) fails testADeclaredSlaArmsATimerBoundToTheTask; no circular DI, since FlowTimerService -> TaskService never reaches a flow node or the registry. Co-Authored-By: Claude Opus 5 (1M context) * test(flow): cover the titleless-node branch the ratchet caught The merge-base coverage guard failed the branch by 0.13%: 33 added statements with exactly one never executed. It was `$title = null`, the branch that turns an empty title into null before arming. Nothing reached it because every test in the class builds its config through a helper that always supplies a title, so the branch was dead on the coverage report and nothing would have noticed if it stopped working. It is a real case, not filler. `title` is optional on a user task, and the timer renders it in the escalation notice: '' produces a blank subject line where null lets the timer describe its subject instead. The new test passes whitespace rather than omitting the key, because a title of spaces is still no title and trim() is what makes the two the same. Negative control: removing the branch so an empty title reaches the timer as '' fails testATitlelessNodeArmsItsTimerWithNoTitle. 40 tests, 155 assertions. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Conduction Release Bot Co-authored-by: Claude Opus 5 (1M context) --- lib/Service/Flow/Nodes/UserTaskNode.php | 91 ++++++++++ .../specs/flow-business-timers/spec.md | 46 +++++ tests/Unit/Service/Flow/UserTaskNodeTest.php | 167 +++++++++++++++++- 3 files changed, 303 insertions(+), 1 deletion(-) diff --git a/lib/Service/Flow/Nodes/UserTaskNode.php b/lib/Service/Flow/Nodes/UserTaskNode.php index 46969627cd..d7a309d962 100644 --- a/lib/Service/Flow/Nodes/UserTaskNode.php +++ b/lib/Service/Flow/Nodes/UserTaskNode.php @@ -71,6 +71,7 @@ use OCA\OpenRegister\Service\Flow\IFlowNode; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; use OCA\OpenRegister\Service\Task\TaskFormReader; use OCP\IL10N; use OCP\IURLGenerator; @@ -104,6 +105,7 @@ class UserTaskNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigFor * @param IL10N $l10n Translations. * @param IURLGenerator $urls For the palette icon. * @param TaskFormReader $forms Reads and refuses the step's form declaration. + * @param FlowTimerService $timers Arms the task's business timer. * * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md */ @@ -112,6 +114,7 @@ public function __construct( private readonly IL10N $l10n, private readonly IURLGenerator $urls, TaskFormReader $forms, + private readonly FlowTimerService $timers, ) { $this->config = new UserTaskConfig(l10n: $l10n, forms: $forms); @@ -204,6 +207,15 @@ public function configKeys(): array { 'failOnReject', 'heartbeatMinutes', 'advance', + // The business-timer half. Absent from a node, nothing is armed and + // the node behaves exactly as it did before these existed. + 'sla', + 'calendar', + 'ladder', + 'escalationRules', + 'purpose', + 'legalEffect', + 'onExpiry', ...TaskFormReader::CONFIG_KEYS, ]; }//end configKeys() @@ -337,6 +349,23 @@ private function createTask(array $items, array $config, array $context, FlowNod actor: $this->actingIdentity(context: $context) ); + // ARM THE DEADLINE, if this node declares one. Nothing else in the + // engine did: `FlowTimerService::arm()` had no production caller at + // all, so a node could describe an SLA and no clock ever started. + // + // Here rather than anywhere else because this is where the task's uuid + // first exists, and the timer is bound to the TASK — the same + // (subjectType: 'task', subjectUuid) pair that + // FlowTimerSubjectTerminalListener already cancels on completion. Arm + // somewhere the uuid is not yet known and the two halves cannot meet. + $this->armDeadline( + config: $config, + taskUuid: (string)$task->getUuid(), + runUuid: $runUuid, + nodeId: $resume->nodeId(), + context: $context + ); + // Written ONCE. A heartbeat re-enters execute() and finds the uuid // held, so it never reaches this line again; askedAt therefore records // when somebody was first asked, not when the run last checked. @@ -346,6 +375,68 @@ private function createTask(array $items, array $config, array $context, FlowNod }//end createTask() + /** + * Arm a business timer for the task this node just created. + * + * NO-OP UNLESS THE NODE DECLARES AN SLA. A flow that never mentioned one + * behaves exactly as before, which is what makes this safe to add to an + * engine every app shares. + * + * 🔴 A FAILURE HERE IS NOT SWALLOWED. If an SLA is declared and cannot be + * armed, the task has no deadline, and for a `wettelijk` term that is a + * legal defect rather than something to log and carry on from. Letting it + * propagate is also safe for existing flows precisely because they do not + * reach this branch. + * + * Bound to the TASK, matching what FlowTimerSubjectTerminalListener already + * cancels: `subjectType: 'task'` with the task's uuid. The run and node are + * recorded as provenance, which is what the timer model asks for — a timer + * is subject-bound, and its run is optional context. + * + * @param array $config The node configuration. + * @param string $taskUuid The uuid of the task just created. + * @param string $runUuid The run this node is executing in. + * @param string $nodeId This node's id. + * @param array $context The execution context. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + private function armDeadline(array $config, string $taskUuid, string $runUuid, string $nodeId, array $context): void { + $sla = ($config['sla'] ?? null); + if ($sla === null || $sla === '' || $sla === []) { + return; + } + + $title = trim((string)($config['title'] ?? '')); + if ($title === '') { + $title = null; + } + + $timerConfig = [ + 'subjectType' => 'task', + 'subjectUuid' => $taskUuid, + 'runUuid' => $runUuid, + 'nodeId' => $nodeId, + 'appId' => 'openregister', + 'title' => $title, + 'sla' => $sla, + ]; + + // Only forward what the author actually set: `build()` applies its own + // defaults, and passing an explicit null would override them. + foreach (['calendar', 'ladder', 'escalationRules', 'purpose', 'legalEffect', 'onExpiry', 'organisation'] as $key) { + if (array_key_exists($key, $config) === true && $config[$key] !== null && $config[$key] !== '') { + $timerConfig[$key] = $config[$key]; + } + } + + $this->timers->arm(config: $timerConfig, actor: $this->actingIdentity(context: $context)); + + }//end armDeadline() + + /** * Write the outcome bag onto every item, under the configured key. * diff --git a/openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md b/openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md index 06d3d4b1b7..1d42d0a60c 100644 --- a/openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md +++ b/openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md @@ -456,3 +456,49 @@ SHALL reflect work performed rather than rows examined. - **WHEN** the pass logs its result - **THEN** the reported fired count MUST be three - @e2e exclude covered by sweep logging unit tests + +### Requirement: A user task arms its own deadline + +`openregister.user-task` SHALL arm a business timer when it creates its task, +and only when the node declares an `sla`. A node that declares none SHALL arm +nothing and behave exactly as it did before this requirement existed. + +The timer SHALL be bound to the TASK — `subjectType: 'task'` with the task's +uuid — which is the same pair the terminal listener cancels on. Arming against +anything else produces a timer nothing ever cancels, which this spec already +calls a defect rather than a tolerable condition. + +The run and node SHALL be recorded as provenance rather than identity, per the +durability requirement above. + +An SLA that is declared and CANNOT be armed SHALL fail the node. A task +carrying a declared deadline with no timer is a term nobody is measuring, and +for a `wettelijk` deadline that is a legal defect rather than something to log +and continue from. + +**Why this requirement exists.** Everything above it was implemented, tested +and unreachable: `FlowTimerService::arm()` had no production caller anywhere in +the app. Every reference lived in its own unit test. A node could describe an +SLA in full and no clock would ever start. + +@e2e exclude Arming happens inside a flow run against a persisted task and a resolvable working calendar; observing it needs a seeded run and a clock, which the timer suite already provides. Covered by UserTaskNodeTest with a negative control: removing the arm call fails the binding assertion. + +#### Scenario: A declared SLA arms a timer bound to the task + +- **GIVEN** a user-task node declaring an `sla` and a `ladder` +- **WHEN** it creates its task +- **THEN** a timer MUST be armed with `subjectType: 'task'` and that task's uuid +- **AND** the sla and ladder MUST reach the timer +- **AND** the run and node MUST be recorded on it + +#### Scenario: A node without an SLA arms nothing + +- **GIVEN** a user-task node declaring no `sla` +- **WHEN** it creates its task +- **THEN** no timer MUST be armed + +#### Scenario: An unarmable SLA fails the node + +- **GIVEN** a node whose declared SLA cannot be armed +- **WHEN** it creates its task +- **THEN** the node MUST fail rather than continue without a deadline diff --git a/tests/Unit/Service/Flow/UserTaskNodeTest.php b/tests/Unit/Service/Flow/UserTaskNodeTest.php index 673e6d5ad1..6260e97d44 100644 --- a/tests/Unit/Service/Flow/UserTaskNodeTest.php +++ b/tests/Unit/Service/Flow/UserTaskNodeTest.php @@ -21,6 +21,8 @@ use OCA\OpenRegister\Service\Flow\FlowTaskBridge; use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; use OCA\OpenRegister\Service\Task\TaskForm; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; use OCA\OpenRegister\Service\Task\TaskFormReader; use OCP\IL10N; use OCP\IURLGenerator; @@ -41,6 +43,13 @@ class UserTaskNodeTest extends TestCase { private FlowTaskBridge&MockObject $bridge; + /** + * The timer service the node arms through. + * + * @var FlowTimerService&MockObject + */ + private $timers; + private UserTaskNode $node; protected function setUp(): void { @@ -62,7 +71,15 @@ static function (string $text, array $parameters = []): string { $forms = $this->createMock(TaskFormReader::class); $forms->method('fromConfig')->willReturn(new TaskForm(kind: null)); - $this->node = new UserTaskNode($this->bridge, $l10n, $this->createMock(IURLGenerator::class), $forms); + $this->timers = $this->createMock(FlowTimerService::class); + + $this->node = new UserTaskNode( + $this->bridge, + $l10n, + $this->createMock(IURLGenerator::class), + $forms, + $this->timers + ); }//end setUp() /** @@ -117,6 +134,154 @@ private function task(string $state, string $uuid = 't-1'): Task { // ---- Creation and suspension ------------------------------------------ + /** + * 🔴 A NODE THAT DECLARES AN SLA ARMS A TIMER FOR ITS TASK. + * + * Nothing in the engine did this before: `FlowTimerService::arm()` had no + * production caller at all, so a node could describe an SLA and no clock + * ever started. The whole business-timer capability was built, tested and + * unreachable. + * + * The binding is what matters. `FlowTimerSubjectTerminalListener` cancels + * on `(subjectType: 'task', subjectUuid)`, so arming on anything else would + * leave a timer nothing ever cancels — an orphan the spec calls a defect + * rather than a tolerable condition. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md#requirement-a-business-timer-is-durable-subject-bound-and-cancelled-by-completion + */ + public function testADeclaredSlaArmsATimerBoundToTheTask(): void { + $state = new FlowResumeState(); + $this->bridge->method('createTask')->willReturn($this->task(state: Task::STATE_ACTIVE)); + + $armed = null; + $this->timers->expects($this->once()) + ->method('arm') + ->willReturnCallback( + function (array $config, ?string $actor = null, $now = null) use (&$armed) { + $armed = $config; + + return $this->createMock(FlowTimer::class); + } + ); + + try { + $this->node->execute( + $this->items(), + $this->config(['sla' => ['value' => 5, 'unit' => 'businessDays'], 'ladder' => 'awb-standard']), + $this->context($state) + ); + } catch (FlowSuspension $suspension) { + // Suspension is the normal outcome; the arming is the subject here. + } + + $this->assertIsArray($armed, 'a declared SLA must arm a timer'); + $this->assertSame('task', $armed['subjectType']); + $this->assertSame('t-1', $armed['subjectUuid'], 'the timer must bind to the task the listener cancels'); + $this->assertSame(['value' => 5, 'unit' => 'businessDays'], $armed['sla']); + $this->assertSame('awb-standard', $armed['ladder'], 'the escalation ladder must reach the timer'); + $this->assertSame('run-1', $armed['runUuid'], 'the run is recorded as provenance'); + $this->assertSame('ask', $armed['nodeId']); + }//end testADeclaredSlaArmsATimerBoundToTheTask() + + + /** + * A node may carry a deadline without carrying a title. + * + * `title` is optional on a user task, and an empty one must reach the timer + * as null rather than as an empty string: the timer renders it in the + * escalation notice, where '' produces a blank subject line while null lets + * the timer fall back to describing its subject. + * + * This case is also the only one that executes the `$title = null` branch. + * Every other test in this class builds its config through the helper, + * which always supplies a title, so without this test that branch is dead + * on the coverage report and nothing would notice if it stopped working. + * + * @return void + */ + public function testATitlelessNodeArmsItsTimerWithNoTitle(): void { + $state = new FlowResumeState(); + $this->bridge->method('createTask')->willReturn($this->task(state: Task::STATE_ACTIVE)); + + $armed = null; + $this->timers->expects($this->once()) + ->method('arm') + ->willReturnCallback( + function (array $config, ?string $actor = null, $now = null) use (&$armed) { + $armed = $config; + + return $this->createMock(FlowTimer::class); + } + ); + + try { + $this->node->execute( + $this->items(), + // Whitespace, not a missing key: a title of spaces is still no + // title, and trim() is what makes the two the same. + ['title' => ' ', 'assignee' => 'alice', 'sla' => ['value' => 2, 'unit' => 'businessDays']], + $this->context($state) + ); + } catch (FlowSuspension $suspension) { + // Suspension is the normal outcome; the arming is the subject here. + } + + $this->assertIsArray($armed, 'a declared SLA must arm a timer even with no title'); + $this->assertNull($armed['title'], 'an empty title must reach the timer as null, not as an empty string'); + $this->assertSame('task', $armed['subjectType']); + $this->assertSame(['value' => 2, 'unit' => 'businessDays'], $armed['sla']); + }//end testATitlelessNodeArmsItsTimerWithNoTitle() + + /** + * A node WITHOUT an SLA arms nothing. + * + * This is what makes the change safe to add to an engine every app shares: + * a flow that never mentioned a deadline behaves exactly as it did. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md + */ + public function testANodeWithoutAnSlaArmsNothing(): void { + $state = new FlowResumeState(); + $this->bridge->method('createTask')->willReturn($this->task(state: Task::STATE_ACTIVE)); + + $this->timers->expects($this->never())->method('arm'); + + try { + $this->node->execute($this->items(), $this->config(), $this->context($state)); + } catch (FlowSuspension $suspension) { + // Expected. + } + }//end testANodeWithoutAnSlaArmsNothing() + + /** + * An SLA that cannot be armed FAILS the node rather than losing the clock. + * + * A task with a declared deadline and no timer is a task whose term nobody + * is measuring. For a `wettelijk` term that is a legal defect, not + * something to log and carry on from — and it cannot regress an existing + * flow, because a flow with no SLA never reaches this branch. + * + * @return void + * + * @spec openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md + */ + public function testAnUnarmableSlaFailsRatherThanSilentlyLosingTheDeadline(): void { + $this->bridge->method('createTask')->willReturn($this->task(state: Task::STATE_ACTIVE)); + $this->timers->method('arm')->willThrowException(new RuntimeException('calendar unresolvable')); + + $this->expectException(RuntimeException::class); + + $this->node->execute( + $this->items(), + $this->config(['sla' => ['value' => 5, 'unit' => 'businessDays']]), + $this->context(new FlowResumeState()) + ); + }//end testAnUnarmableSlaFailsRatherThanSilentlyLosingTheDeadline() + /** * The first firing creates exactly one task, stamped with run and node, * and suspends with a heartbeat that is NOT null. From 7b576fb02c2a541a965d333c2b045d71d93f289c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 09:40:44 +0200 Subject: [PATCH 59/68] fix(tests): three extraction tests asserted nothing and could not fail (#3351) PHPUnit reported these three as risky for performing no assertions. The reason is the same in all three, and it is worse than a missing assertion: each put its only assertion inside a `catch` block that did not run. try { $this->service->extractObject(1, false); } catch (\Throwable $e) { $this->assertNotSame('Object already processed and up-to-date', $e->getMessage()); } extractObject() returns void and throws nothing on the path being tested, so the catch never fired. And the string being compared is a LOG message that never reaches an exception, so even when the catch DID run the assertion was vacuous. Both tests claim to prove that extraction was not skipped, and both pass most reliably when it is skipped. Each now asserts on the only observable there is: the log. A helper captures every message the service emits, and the tests assert the up-to-date line was NOT logged, plus a control that '[ObjectHandler] Extracting text from object' WAS. Without that control the first assertion passes just as happily when extraction never began for an unrelated reason. testExtractObjectWithNullUpdatedTimestamp also needed a real fixture. It set getLatestUpdatedTimestamp() to null, which short-circuits isSourceUpToDate() before the `?? time()` fallback is ever consulted, so it would have passed with any fallback at all, including one that made the object look permanently up-to-date and skipped extraction forever. With a stored chunk at 100 the test now distinguishes `?? time()` from `?? 0`. testExtractObjectEntityExtractionFailureLogsError now tests what its name says. It never reached the entity handler before; the mocked mappers can drive the whole extraction, so the entity pass is made to throw and the test asserts the error is logged AND that the call returns. Chunks are already persisted by then, and a failing entity pass must not throw them away. Negative controls, each applied to lib/ and reverted: - `?? time()` -> `?? 0`: test 1 fails. - entity catch rethrows: test 3 errors. - force bypass removed: test 2 fails, but only when BOTH guards go. The bypass is implemented twice (extractObject and isSourceUpToDate) and either alone suffices, so neither site is individually covered. Recorded in a comment rather than silently left for the next reader. No production code changed. The two `Undefined array key` warnings the rewritten tests exposed come from a bare mock answering [] where the one real implementation of processSourceChunks() always fills all three keys, so the mock was made to keep the contract rather than the service made defensive about a fake. Co-authored-by: Conduction Release Bot Co-authored-by: Claude Opus 5 (1M context) --- .../Service/TextExtractionServiceTest.php | 152 ++++++++++++++---- 1 file changed, 125 insertions(+), 27 deletions(-) diff --git a/tests/Unit/Service/TextExtractionServiceTest.php b/tests/Unit/Service/TextExtractionServiceTest.php index 577b8edccd..a343af8a14 100644 --- a/tests/Unit/Service/TextExtractionServiceTest.php +++ b/tests/Unit/Service/TextExtractionServiceTest.php @@ -1960,23 +1960,92 @@ function (Chunk $chunk) use (&$insertedChunks) { // extractObject — full extraction path // ──────────────────────────────────────────────────────── + /** + * Capture every message the service logs, at any level. + * + * extractObject() returns void, so its early return has no return value to + * assert on and throws nothing. The only observable is a log line. The + * three tests below used to assert on an exception message instead, which + * could not fail twice over: the string they compared against is a LOG + * message that never reaches an exception, and the assertion sat inside a + * catch block that did not run, so PHPUnit reported them risky for + * performing no assertions at all. + * + * @param array $captured Filled with the messages logged. + * + * @return void + */ + private function captureLogMessages(array &$captured): void { + $record = static function (string|\Stringable $message) use (&$captured): void { + $captured[] = (string) $message; + }; + + foreach (['debug', 'info', 'warning', 'error'] as $level) { + $this->logger->method($level)->willReturnCallback($record); + } + } + + /** + * The shape processSourceChunks() actually returns. + * + * A bare createMock() answers [], which extractObject() then reads + * 'entities_found' out of. That state cannot occur in production — the one + * implementation always fills all three keys — so the mock is made to keep + * the contract rather than the service made defensive about a fake. + * + * @return void + */ + private function entityPassSucceeds(): void { + $this->entityHandler->method('processSourceChunks')->willReturn( + [ + 'chunks_processed' => 0, + 'entities_found' => 0, + 'relations_created' => 0, + ] + ); + } + public function testExtractObjectWithNullUpdatedTimestamp(): void { - // Object with null getUpdated() — should use time() as fallback. + // An object whose getUpdated() is null falls back to time(), which is + // newer than any stored chunk, so extraction must proceed. $object = new \OCA\OpenRegister\Db\ObjectEntity(); // Updated is null by default. + $object->setUuid('object-without-a-timestamp'); $this->objectMapper->method('find')->willReturn($object); - $this->chunkMapper->method('getLatestUpdatedTimestamp')->willReturn(null); + // A REAL chunk timestamp, deliberately. A null one short-circuits + // isSourceUpToDate() before the fallback is ever consulted, so this + // test would pass with any fallback at all — including one that made + // the object look permanently up-to-date and skipped extraction + // forever. With a stored chunk at 100, `?? time()` proceeds and `?? 0` + // would not. + $this->chunkMapper->method('getLatestUpdatedTimestamp')->willReturn(100); + $this->entityPassSucceeds(); + + $logged = []; + $this->captureLogMessages($logged); - // Since ObjectHandler is instantiated internally and we can't mock it, - // we verify the flow proceeds past the up-to-date check. - // It will fail at ObjectHandler, but that proves the timestamp logic works. + // ObjectHandler is built inline from the mocked mappers, so the call + // runs on into it. What happens there is not this test's claim; whether + // the up-to-date check let it through is. try { $this->service->extractObject(1, false); - } catch (\Throwable $e) { - // Expected — ObjectHandler needs real mappers. - $this->assertNotSame('Object already processed and up-to-date', $e->getMessage()); + } catch (\Throwable) { + // Deliberately swallowed — the assertions below are the claim. } + + $this->assertNotContains( + '[TextExtractionService] Object already processed and up-to-date', + $logged, + 'a null timestamp must not read as up-to-date', + ); + // The control. Without it the assertion above passes just as happily + // when extraction never started for some unrelated reason. + $this->assertContains( + '[ObjectHandler] Extracting text from object', + $logged, + 'extraction must actually have been attempted', + ); } public function testExtractObjectForceReExtractIgnoresUpToDate(): void { @@ -1985,19 +2054,38 @@ public function testExtractObjectForceReExtractIgnoresUpToDate(): void { $object = new \OCA\OpenRegister\Db\ObjectEntity(); $object->setUpdated($updated); + $object->setUuid('object-with-newer-chunks'); $this->objectMapper->method('find')->willReturn($object); - // Chunks are newer, but force=true should bypass. + // Chunks are newer, so without force this object IS up-to-date. $this->chunkMapper->method('getLatestUpdatedTimestamp')->willReturn(200); + $this->entityPassSucceeds(); + + $logged = []; + $this->captureLogMessages($logged); - // Should proceed past the up-to-date check (will fail at ObjectHandler). try { $this->service->extractObject(1, true); - } catch (\Throwable $e) { - // Expected — ObjectHandler needs real infrastructure. - // The key assertion is that it did NOT return early. - $this->assertNotSame('Object already processed and up-to-date', $e->getMessage()); + } catch (\Throwable) { + // Deliberately swallowed — the assertions below are the claim. } + + // Note for whoever breaks this next: the force bypass is implemented + // TWICE — the guard here in extractObject() and an early return at the + // top of isSourceUpToDate() — and either one alone is sufficient. + // Removing just one does NOT redden this test, because the behaviour it + // asserts is unchanged. That is correct for a test of the outcome, but + // it does mean neither site is individually covered. + $this->assertNotContains( + '[TextExtractionService] Object already processed and up-to-date', + $logged, + 'force must bypass the up-to-date check', + ); + $this->assertContains( + '[ObjectHandler] Extracting text from object', + $logged, + 'extraction must actually have been attempted', + ); } // ──────────────────────────────────────────────────────── @@ -2913,28 +3001,38 @@ function (Chunk $chunk) use (&$insertedChunk) { // ──────────────────────────────────────────────────────── public function testExtractObjectEntityExtractionFailureLogsError(): void { - // This verifies the entity extraction catch block in extractObject. - // We need ObjectHandler to succeed but entityHandler to fail. - // Since ObjectHandler is created inline, we have to let it fail - // and verify the flow still proceeds. + // The catch around the entity handler is there so a failure in entity + // recognition does not throw away the chunks that were already + // persisted: the text is extracted and stored either way, and only the + // entity pass is lost. Reaching that block means driving the whole + // extraction, which the mocked mappers can do — ObjectHandler reads the + // object through them, so an object with real content is enough. $updated = new DateTime(); $updated->setTimestamp(100); $object = new \OCA\OpenRegister\Db\ObjectEntity(); $object->setUpdated($updated); + $object->setUuid('object-with-a-failing-entity-pass'); + $object->setObject(['subject' => 'Een besluit over een handhavingszaak.']); $this->objectMapper->method('find')->willReturn($object); $this->chunkMapper->method('getLatestUpdatedTimestamp')->willReturn(null); - // ObjectHandler will be created with real mappers (which are mocks). - // It will likely fail trying to get source metadata. - // We verify that the code gets past the up-to-date check. - try { - $this->service->extractObject(1, false); - } catch (\Throwable $e) { - // Expected — ObjectHandler needs real infrastructure. - $this->assertInstanceOf(\Throwable::class, $e); - } + $this->entityHandler->method('processSourceChunks') + ->willThrowException(new Exception('entity recognition unavailable')); + + $logged = []; + $this->captureLogMessages($logged); + + // No try/catch. That the call RETURNS is half the assertion: a failing + // entity pass must not propagate out of extractObject. + $this->service->extractObject(1, false); + + $this->assertContains( + '[TextExtractionService] Entity extraction failed', + $logged, + 'a failing entity pass must be logged, not swallowed silently', + ); } // ──────────────────────────────────────────────────────── From 2accc475906b8b510c5c64da18b1aaca9fe28503 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 10:16:27 +0200 Subject: [PATCH 60/68] fix: two silent setters, and the warnings that were pointing at them (#3354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite reported 10 PHP warnings and 23 deprecations. Two of the warnings were not hygiene: they were production bugs, and both had been written into the tests as known behaviour. ORGANISATION::SETACTIVE(FALSE) NEVER DEACTIVATED ANYTHING It called parent::setActive(active: $value) with a NAMED argument. Entity has no such method — it is reached through __call(array $args), where a named argument lands under its name, so Entity::setter() read an undefined $args[0] and the value never arrived. OrganisationController has an endpoint whose whole job is deactivating an organisation, and it could not work. OrganisationTest asserted assertTrue() on a value that should have been false and called it "the current actual behavior". ENDPOINTLOG::SETRESPONSE() STORED NULL ON EVERY CALL LOG Same root cause, same shape. setStatusCode and setStatusMessage were positional and worked; the response payload was dropped on every endpoint call ever logged. EndpointServiceTest said "setResponse uses named arg in source code (known issue), so response may be null. We verify it was attempted" — and then verified nothing. Both are fixed and both now assert the correct behaviour. Reintroducing either bug reddens its test; that was checked, not assumed. I swept for the rest of the class rather than stopping at two: 94 Entity subclasses, 686 magic setter names, 256 named-argument call sites in lib/. Every other match is legitimate — $objectService->setRegister() is a real service method, ->setObject(type:, id:) is OCP's INotification, and the last two candidates were the regex matching self:: in a class constant. The class is bounded at these two. THE REST - SettingsController: `while ($row !== false)` never terminates if fetch() answers null, which it can. Two other loops in the same file already used is_array(); this one did not. - BsnFormat: str_pad() was handed a null, deprecated in 8.1 and a TypeError in 9. Cast once up front; an array/object is now refused explicitly, which is the contract str_pad() used to enforce further down by accident. - SaveObject: a schema with a null slug cannot be found BY slug, and feeding the null to strtolower()/strcasecmp() is deprecated. Skipped instead. - Four test files: mocks that answered [] or null where the single real implementation always fills the contract (rechainAll, processSourceChunks, handlePreValidationCascading). The mocks were made to keep the contract rather than the services made defensive about a fake. RechainAuditTrail also gained the tombstone-count assertion its message never had. - Dynamic properties declared; ReflectionProperty::setValue() given its explicit null object argument at 14 sites. Suite: 19046 tests. Warnings 10 -> 0, deprecations 23 -> 2, risky 3 -> 0 (the risky three landed separately in #3351). The last two deprecations are ZipArchive::open() on an empty fixture file. Co-authored-by: Conduction Release Bot Co-authored-by: Claude Opus 5 (1M context) --- lib/Controller/SettingsController.php | 11 ++++++-- lib/Db/Organisation.php | 6 ++++- lib/Formats/BsnFormat.php | 25 ++++++++++++++++++- lib/Service/EndpointService.php | 7 +++++- lib/Service/Object/SaveObject.php | 8 ++++++ .../Command/RechainAuditTrailCommandTest.php | 15 +++++++++-- tests/Unit/Db/OrganisationTest.php | 18 +++++++------ .../Mcp/BuiltIn/FlowMcpToolProviderTest.php | 6 +++++ .../ActiveOrganisationManagementTest.php | 7 ++++-- .../DefaultOrganisationCachingTest.php | 24 +++++++++--------- .../DefaultOrganisationManagementTest.php | 4 +-- tests/Unit/Service/EndpointServiceTest.php | 12 +++++++-- tests/Unit/Service/ObjectServiceTest.php | 8 +++++- 13 files changed, 118 insertions(+), 33 deletions(-) diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 0443c1a2fc..96de9fdcf1 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -665,7 +665,11 @@ private function getHybridSearchDiagnostics(bool $isPostgres): array { $row = $result->fetch(); $result->closeCursor(); - if ($row !== false) { + // An is_array check, not `!== false`. fetch() can answer NULL + // as well as false, which passes a `!== false` guard and then + // warns on every offset read. Lines 476 and 644 of this file + // already use this idiom; this was the one site that did not. + if (is_array($row) === true) { $diagnostics['vectors']['total'] = (int)$row['total']; $diagnostics['vectors']['pgvectorPopulated'] = (int)$row['populated']; } @@ -876,8 +880,11 @@ function (\OCA\OpenRegister\Db\ObjectEntity $org): array { // (see RegisterMapper::getAllRegisterIdsWithSchema / MarkerLookupTrait). $stmt = $qb->executeQuery(); $rows = []; + // Same reason as above, and here it matters more: a fetch() that + // answers NULL never satisfies `!== false`, so the loop would spin + // forever appending nulls rather than ending. $row = $stmt->fetch(); - while ($row !== false) { + while (is_array($row) === true) { $rows[] = $row; $row = $stmt->fetch(); } diff --git a/lib/Db/Organisation.php b/lib/Db/Organisation.php index 8cd6fc56fe..0ec9cf1fdc 100644 --- a/lib/Db/Organisation.php +++ b/lib/Db/Organisation.php @@ -751,7 +751,11 @@ public function setActive(mixed $active): static { $activeValue = (bool)$active; } - parent::setActive(active: $activeValue); + // Assign DIRECTLY, as Application::setActive() does. This called + // parent::setActive(active: ...) — a NAMED arg into Entity::__call(), + // which reads $args[0], so the value never arrived and setActive(false) + // left the organisation ACTIVE. Nothing could be deactivated. + $this->active = $activeValue; $this->markFieldUpdated(attribute: 'active'); return $this; diff --git a/lib/Formats/BsnFormat.php b/lib/Formats/BsnFormat.php index 99aa712d28..2e70e341e5 100644 --- a/lib/Formats/BsnFormat.php +++ b/lib/Formats/BsnFormat.php @@ -23,7 +23,13 @@ namespace OCA\OpenRegister\Formats; use Opis\JsonSchema\Format; +use TypeError; +/** + * Validates the Dutch BSN (Burgerservicenummer) as a JSON Schema format. + * + * @spec openspec/specs/data-import-export/spec.md + */ class BsnFormat implements Format { /** * Validates if a given value conforms to the Dutch BSN (Burgerservicenummer) format. @@ -37,10 +43,27 @@ class BsnFormat implements Format { * @spec openspec/specs/data-import-export/spec.md */ public function validate(mixed $data): bool { + // An array or object is a caller bug, not an invalid BSN, so it is + // refused loudly. str_pad() used to raise the TypeError itself, further + // down and by accident; raising it here keeps that contract explicit + // once the cast below stops the value ever reaching str_pad() untyped. + if (is_array($data) === true || is_object($data) === true) { + throw new TypeError( + 'BsnFormat::validate() expects a scalar or null, '.get_debug_type($data).' given' + ); + } + + // Cast ONCE, here. null and false coerce to '' and pad to the all-zero + // sentinel, which is rejected below — that is the documented behaviour + // (ADR-008 Rule 4). Passing null on to str_pad() instead is deprecated + // in PHP 8.1 and a TypeError in PHP 9, so the same input would stop + // being 'not a BSN' and start being a fatal. + $data = (string)$data; + // Reject over-length input before padding: str_pad only left-pads and // never truncates, so a >9-digit value would otherwise be checksummed // on a miscalculated weighting (ADR-008 Rule 4). - if (strlen((string)$data) > 9) { + if (strlen($data) > 9) { return false; } diff --git a/lib/Service/EndpointService.php b/lib/Service/EndpointService.php index a3cef79ec7..cdbd084b27 100644 --- a/lib/Service/EndpointService.php +++ b/lib/Service/EndpointService.php @@ -492,8 +492,13 @@ private function logEndpointCall(Endpoint $endpoint, array $request, array $resu // Set request/response data. $log->setRequest($request); + // POSITIONAL, deliberately. setResponse() is magic — EndpointLog + // declares the property, not the method — so it goes through + // Entity::__call(array $args), where a NAMED argument lands under + // its name and $args[0] is never set. Every endpoint log was + // storing a null response. $log->setResponse( - response: [ + [ 'statusCode' => $result['statusCode'], 'body' => $result['response'], ] diff --git a/lib/Service/Object/SaveObject.php b/lib/Service/Object/SaveObject.php index 0332a5670b..6222e5faa5 100644 --- a/lib/Service/Object/SaveObject.php +++ b/lib/Service/Object/SaveObject.php @@ -516,6 +516,14 @@ private function resolveSchemaReference(string $reference): ?string { $schemas = $this->schemaMapper->findAll(); // Cache all schemas by slug for future lookups. foreach ($schemas as $schema) { + // A schema with no slug cannot be found BY slug, and feeding + // the null on to strtolower()/strcasecmp() is deprecated in + // PHP 8.1 and a TypeError in PHP 9. Skipping it is what the + // loop was already doing in effect, just noisily. + if ($schema->getSlug() === null) { + continue; + } + $schemaSlug = strtolower($schema->getSlug()); $schemaId = (string)$schema->getId(); // Cache the schema entity. diff --git a/tests/Unit/Command/RechainAuditTrailCommandTest.php b/tests/Unit/Command/RechainAuditTrailCommandTest.php index 80a48e5673..387660dec1 100644 --- a/tests/Unit/Command/RechainAuditTrailCommandTest.php +++ b/tests/Unit/Command/RechainAuditTrailCommandTest.php @@ -147,10 +147,21 @@ public function testForceRepairsAndReportsSuccess(): void { $this->hashes->method('verifyChain') ->willReturnOnConsecutiveCalls($this->verification(false), $this->verification(true)); $this->hashes->method('countUnsealed')->willReturn(0); - $this->hashes->expects($this->once())->method('rechainAll')->willReturn(['rechained' => 313136]); + // The FULL shape rechainAll() returns. Both of its return paths fill + // 'tombstonesPreserved', and the command prints it, so a mock that + // omits it makes the command read an undefined key and the test + // exercises a state production cannot reach. + $this->hashes->expects($this->once())->method('rechainAll') + ->willReturn(['rechained' => 313136, 'tombstonesPreserved' => 12]); $this->assertSame(Command::SUCCESS, $this->tester->execute(['--force' => true])); $this->assertStringContainsString('313136', $this->tester->getDisplay()); + // The second half of the same sentence. An operator reads this line to + // decide whether the repair touched what they expected, and a repair + // that silently skipped rows would report the tombstone count wrong. + // Nothing asserted it before, so the placeholder could have printed + // anything. + $this->assertStringContainsString('12 retention tombstone', $this->tester->getDisplay()); }//end testForceRepairsAndReportsSuccess() @@ -168,7 +179,7 @@ public function testForceRepairsAndReportsSuccess(): void { public function testStillBrokenAfterwardsIsAFailure(): void { $this->hashes->method('verifyChain')->willReturn($this->verification(false)); $this->hashes->method('countUnsealed')->willReturn(3); - $this->hashes->method('rechainAll')->willReturn(['rechained' => 5]); + $this->hashes->method('rechainAll')->willReturn(['rechained' => 5, 'tombstonesPreserved' => 0]); $this->assertSame(Command::FAILURE, $this->tester->execute(['--force' => true])); $this->assertStringContainsString('do not treat this repair as complete', $this->tester->getDisplay()); diff --git a/tests/Unit/Db/OrganisationTest.php b/tests/Unit/Db/OrganisationTest.php index b3fc4c4e19..ff660de2b2 100644 --- a/tests/Unit/Db/OrganisationTest.php +++ b/tests/Unit/Db/OrganisationTest.php @@ -336,11 +336,14 @@ public function testIsActiveDefaultTrue(): void { } public function testSetActiveFalse(): void { - // Organisation::setActive() calls parent::setActive(active: $val) with named args, - // which triggers the Entity __call named-arg bug. The value is always truthy. - // This test documents the current actual behavior. + // An organisation CAN be deactivated. This test used to assert the + // opposite and called it "the current actual behavior": setActive() + // passed a named argument to Entity::__call(), which reads $args[0], + // so the value never arrived and every organisation stayed active. + // The OrganisationController endpoint for deactivating one could not + // work, and this test is what made that look intended. $result = $this->organisation->setActive(false); - $this->assertTrue($this->organisation->isActive()); + $this->assertFalse($this->organisation->isActive(), 'setActive(false) must deactivate'); $this->assertSame($this->organisation, $result); } @@ -365,10 +368,11 @@ public function testSetActiveTruthyString(): void { } public function testSetActiveFalsyStringZero(): void { - // Due to the named-arg bug in parent::setActive(), '0' is cast to false - // but the named arg causes it to be set as truthy string 'active'. + // '0' is a falsy string, and the setter casts it. The API sends + // strings, so this is the path a deactivation actually arrives on — + // which is why it mattered that the value was being dropped. $this->organisation->setActive('0'); - $this->assertTrue($this->organisation->isActive()); + $this->assertFalse($this->organisation->isActive(), "'0' must deactivate"); } public function testIsActiveWhenInternallyNull(): void { diff --git a/tests/Unit/Mcp/BuiltIn/FlowMcpToolProviderTest.php b/tests/Unit/Mcp/BuiltIn/FlowMcpToolProviderTest.php index ec992bae67..976352a21b 100644 --- a/tests/Unit/Mcp/BuiltIn/FlowMcpToolProviderTest.php +++ b/tests/Unit/Mcp/BuiltIn/FlowMcpToolProviderTest.php @@ -25,6 +25,12 @@ class FlowMcpToolProviderTest extends TestCase { private FlowRunMapper $mapper; private IUserSession&MockObject $userSession; private FlowMcpToolProvider $provider; + // Declared, not created on the fly. Assigning an undeclared property in + // setUp() is deprecated in PHP 8.2 and an error in PHP 9, and it also + // costs the type: an undeclared $flows is mixed, so nothing checks that + // the provider is handed a FlowService at all. + private \OCA\OpenRegister\Service\Flow\FlowService&MockObject $flows; + private \OCA\OpenRegister\Service\Flow\FlowNodePreflight&MockObject $preflight; protected function setUp(): void { $this->runner = $this->createMock(FlowRunService::class); diff --git a/tests/Unit/Service/ActiveOrganisationManagementTest.php b/tests/Unit/Service/ActiveOrganisationManagementTest.php index 67bae5d5c2..7311f5fc34 100644 --- a/tests/Unit/Service/ActiveOrganisationManagementTest.php +++ b/tests/Unit/Service/ActiveOrganisationManagementTest.php @@ -874,10 +874,13 @@ public function testGetOrganisationForNewEntityFallbackToDefault(): void { $reflection = new \ReflectionClass(OrganisationService::class); $cacheProperty = $reflection->getProperty('defaultOrgCache'); $cacheProperty->setAccessible(true); - $cacheProperty->setValue($defaultOrg); + // Both are STATIC, so the object argument is null. Omitting it is + // deprecated in PHP 8.3 and removed in PHP 9; the same file already + // writes these two caches the explicit way further up. + $cacheProperty->setValue(null, $defaultOrg); $tsProperty = $reflection->getProperty('defaultOrgCacheTs'); $tsProperty->setAccessible(true); - $tsProperty->setValue(time()); + $tsProperty->setValue(null, time()); // Act. $result = $this->organisationService->getOrganisationForNewEntity(); diff --git a/tests/Unit/Service/DefaultOrganisationCachingTest.php b/tests/Unit/Service/DefaultOrganisationCachingTest.php index e71f17cd97..d98f8dc64b 100644 --- a/tests/Unit/Service/DefaultOrganisationCachingTest.php +++ b/tests/Unit/Service/DefaultOrganisationCachingTest.php @@ -158,11 +158,11 @@ private function clearStaticCache(): void { $cacheProperty = $reflection->getProperty('defaultOrgCache'); $cacheProperty->setAccessible(true); - $cacheProperty->setValue(null); + $cacheProperty->setValue(null, null); $timestampProperty = $reflection->getProperty('defaultOrgCacheTs'); $timestampProperty->setAccessible(true); - $timestampProperty->setValue(null); + $timestampProperty->setValue(null, null); } /** @@ -183,11 +183,11 @@ public function testDefaultOrganisationStaticCacheHit(): void { $cacheProperty = $reflection->getProperty('defaultOrgCache'); $cacheProperty->setAccessible(true); - $cacheProperty->setValue($defaultOrg); + $cacheProperty->setValue(null, $defaultOrg); $timestampProperty = $reflection->getProperty('defaultOrgCacheTs'); $timestampProperty->setAccessible(true); - $timestampProperty->setValue(time()); + $timestampProperty->setValue(null, time()); // The mapper should NOT be called since cache is populated. $this->organisationMapper @@ -219,12 +219,12 @@ public function testDefaultOrganisationCacheExpiration(): void { $cacheProperty = $reflection->getProperty('defaultOrgCache'); $cacheProperty->setAccessible(true); - $cacheProperty->setValue($defaultOrg); + $cacheProperty->setValue(null, $defaultOrg); // Set expired timestamp (older than cache timeout). $timestampProperty = $reflection->getProperty('defaultOrgCacheTs'); $timestampProperty->setAccessible(true); - $timestampProperty->setValue(time() - 1000); + $timestampProperty->setValue(null, time() - 1000); // Assert: Cache was set with expired timestamp. $this->assertNotNull($cacheProperty->getValue()); @@ -247,11 +247,11 @@ public function testDefaultOrganisationCacheSharedAcrossInstances(): void { $cacheProperty = $reflection->getProperty('defaultOrgCache'); $cacheProperty->setAccessible(true); - $cacheProperty->setValue($defaultOrg); + $cacheProperty->setValue(null, $defaultOrg); $timestampProperty = $reflection->getProperty('defaultOrgCacheTs'); $timestampProperty->setAccessible(true); - $timestampProperty->setValue(time()); + $timestampProperty->setValue(null, time()); // Create a second service instance. $organisationService2 = new OrganisationService( @@ -290,11 +290,11 @@ public function testDefaultOrganisationCacheInvalidationOnModification(): void { $cacheProperty = $reflection->getProperty('defaultOrgCache'); $cacheProperty->setAccessible(true); - $cacheProperty->setValue($defaultOrg); + $cacheProperty->setValue(null, $defaultOrg); $timestampProperty = $reflection->getProperty('defaultOrgCacheTs'); $timestampProperty->setAccessible(true); - $timestampProperty->setValue(time()); + $timestampProperty->setValue(null, time()); // Verify cache is populated. $this->assertNotNull($cacheProperty->getValue()); @@ -332,11 +332,11 @@ public function testDefaultOrganisationPerformanceOptimization(): void { $cacheProperty = $reflection->getProperty('defaultOrgCache'); $cacheProperty->setAccessible(true); - $cacheProperty->setValue($defaultOrg); + $cacheProperty->setValue(null, $defaultOrg); $timestampProperty = $reflection->getProperty('defaultOrgCacheTs'); $timestampProperty->setAccessible(true); - $timestampProperty->setValue(time()); + $timestampProperty->setValue(null, time()); // The mapper should NOT be called since cache is populated. $this->organisationMapper diff --git a/tests/Unit/Service/DefaultOrganisationManagementTest.php b/tests/Unit/Service/DefaultOrganisationManagementTest.php index 4fa86187ae..0e3a2c7c31 100644 --- a/tests/Unit/Service/DefaultOrganisationManagementTest.php +++ b/tests/Unit/Service/DefaultOrganisationManagementTest.php @@ -478,10 +478,10 @@ public function testGetUserOrganisationsAutoAssignsToDefault(): void { $cacheProperty = $reflection->getProperty('defaultOrgCache'); $cacheProperty->setAccessible(true); - $cacheProperty->setValue($defaultOrg); + $cacheProperty->setValue(null, $defaultOrg); $tsProperty = $reflection->getProperty('defaultOrgCacheTs'); $tsProperty->setAccessible(true); - $tsProperty->setValue(time()); + $tsProperty->setValue(null, time()); // Mock: update called to save user addition. $this->organisationMapper diff --git a/tests/Unit/Service/EndpointServiceTest.php b/tests/Unit/Service/EndpointServiceTest.php index 59e2b54d32..4e6a8f346b 100644 --- a/tests/Unit/Service/EndpointServiceTest.php +++ b/tests/Unit/Service/EndpointServiceTest.php @@ -1102,8 +1102,16 @@ public function testLogEndpointCallVerifiesLogProperties(): void { $this->assertSame('some warning', $log->getStatusMessage()); // Verify request data. $this->assertSame(['method' => 'GET', 'path' => '/api/test', 'data' => ['key' => 'val'], 'headers' => ['X-Foo' => 'bar']], $log->getRequest()); - // Note: setResponse uses named arg in source code (known issue), - // so response may be null. We verify it was attempted. + // The response, asserted rather than excused. This used to read + // "setResponse uses named arg in source code (known issue), so + // response may be null. We verify it was attempted" — and it + // verified nothing, so every endpoint log stored a null + // response and the suite stayed green. + $this->assertSame( + ['statusCode' => 200, 'body' => ['items' => [1, 2, 3]]], + $log->getResponse(), + 'the response must be stored on the log, not dropped' + ); // Verify timestamps. $this->assertInstanceOf(\DateTime::class, $log->getCreated()); $this->assertInstanceOf(\DateTime::class, $log->getExpires()); diff --git a/tests/Unit/Service/ObjectServiceTest.php b/tests/Unit/Service/ObjectServiceTest.php index acf53e53b2..537c1cf2dc 100644 --- a/tests/Unit/Service/ObjectServiceTest.php +++ b/tests/Unit/Service/ObjectServiceTest.php @@ -1870,8 +1870,14 @@ public function testCreateObjectCallsSaveObjectInternally(): void { $this->setProperty('currentSchema', $this->schema); // The cascading handler is called before save — verify delegation starts. + // It is made to return its documented shape, [object, uuid]. An + // unconfigured mock answers null, which ObjectService then reads + // offset 0 of: the real handler is `: array` on every return path, so + // that was the fake disagreeing with the contract, not a gap in the + // production guard. $this->cascadingHandler->expects($this->once()) - ->method('handlePreValidationCascading'); + ->method('handlePreValidationCascading') + ->willReturn([['title' => 'New'], null]); // The actual save will fail due to deep dependencies, but we verify // the method delegates to saveObject() correctly. From afb1f00436e3617c4ff705f99874f37908a28f75 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 10:19:44 +0200 Subject: [PATCH 61/68] feat(organisations): adopt a leaf app's organisations without losing them (#3340) * feat(organisations): adopt a leaf app's organisations without losing them Several apps grew their own organisation schema before OR's Organisation carried the fields they needed. The slug is global per organisation, so those copies collide: SchemaMapper::find() matches LOWER(slug) across every app and hands back whichever row it reaches first. Task 1 added the columns, which made reuse possible and moved nobody's rows. This moves them, under two rules that were learned rather than chosen. The uuid is the idempotency key and is preserved. dossiq's migrate-partners arrived at the same rule and wrote down why: a leaf row is free to carry no slug at all, and two rows sharing a name are routine, so a name-derived key skips the second as already migrated and silently merges two distinct legal entities. Where the same legal entity already exists under a different uuid the rows are not collapsed into one. The adopted row is created and pointed at the existing one through mergedInto, so both uuids keep resolving and the merge is a fact recorded on a row rather than data thrown away. Matching runs on OIN, then RSIN, then KVK, normalised for punctuation because the same OIN is typed with and without spaces and dots, and never on a name. The lowest id is canonical so a repeated run picks the same survivor, and a candidate that was itself merged away loses to a live one. Properties Organisation has no column for are named before the write. OpenRegister discards an undeclared property and answers 200 with the object, so an adoption that loses fields is otherwise indistinguishable from one that did not. Dry-run by default. Verified on the dev instance end to end: adoption, a second run adopting nothing, a merge matched across punctuation, and the negative control where clearing the shared OIN stops the merge being reported. * docs(organisations): record what the leaf-side measurement found The plan for task 5.2 was to map stackiq's fields onto Organisation where they fit and rehome the rest. Reading the entity rather than assuming, none of the three candidates fit. contacts is linked Nextcloud Contacts app data, not a list of contact people. children is not stored at all: the setter says it is loaded on demand for API responses, so a written value is dropped and recomputed. type has a closed vocabulary and a collaboration SUBtype is a different axis from it. So all nine stackiq properties belong on a stackiq-owned schema and none on Organisation, which is a different change from the one that was planned. Also records the site counts that decide the sequencing: 22 for opencatalogi, most of them stored references that keep resolving, against 235 for stackiq, which treats the slug as a first-class object type. * test(organisations): drive the adopt command end to end The coverage guard was right again: the pure rules were pinned and the command that runs them was not, so execute(), adoptRow(), reportUndeclared() and existingOrganisations() shipped uncovered. Ten cases through CommandTester, and the ones that matter are the refusals. Without --register the command says so rather than reading whatever it can find. The default is a dry run, because the alternative default is a command that writes to every organisation on the instance the first time somebody types its name to see what it does. A row with no uuid is skipped rather than adopted, since without an idempotency key it would be duplicated on every run. One case is worth its own line: two rows in the SAME run sharing a legal identifier now merge, because the candidate set grows as rows are adopted. That path had no coverage and is exactly where a by-value parameter would have silently done nothing. --------- Co-authored-by: Conduction Release Bot --- appinfo/info.xml | 1 + lib/Command/AdoptLeafOrganisationsCommand.php | 591 ++++++++++++++++++ .../specs/consolidated-organisation/spec.md | 57 ++ .../consolidate-organisation-on-or/tasks.md | 54 +- .../AdoptLeafOrganisationsCommandTest.php | 554 ++++++++++++++++ 5 files changed, 1249 insertions(+), 8 deletions(-) create mode 100644 lib/Command/AdoptLeafOrganisationsCommand.php create mode 100644 tests/Unit/Command/AdoptLeafOrganisationsCommandTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 1f48c5e704..3fd175ad83 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -352,6 +352,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Command\EncryptFieldCommand OCA\OpenRegister\Command\DedupeRegistersCommand + OCA\OpenRegister\Command\AdoptLeafOrganisationsCommand OCA\OpenRegister\Command\PruneRetiredSchemasCommand OCA\OpenRegister\Command\RelinkRegisterSchemasCommand OCA\OpenRegister\Command\ReconcileMagicTablesCommand diff --git a/lib/Command/AdoptLeafOrganisationsCommand.php b/lib/Command/AdoptLeafOrganisationsCommand.php new file mode 100644 index 0000000000..53d2aa4d1e --- /dev/null +++ b/lib/Command/AdoptLeafOrganisationsCommand.php @@ -0,0 +1,591 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Command; + +use DateTime; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\OpenRegister\Service\ObjectService; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; + +/** + * Adopt a leaf app's organisation objects into OpenRegister's Organisation. + * + * @spec openspec/changes/consolidate-organisation-on-or/tasks.md#5-leaf-app-consolidation + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The spread is the point: each + * branch is one rule about what an adoption keeps, merges or drops, and the + * class is at 53 against a threshold of 50. Collapsing branches to satisfy the + * number would hide the rules rather than simplify them, and every one of them + * is pinned by its own test. + */ +class AdoptLeafOrganisationsCommand extends Command { + /** + * The legal identifiers a match may be made on, in precedence order. + * + * A name is deliberately absent. Two organisations sharing a name are + * routine; two sharing an OIN are the same body. + * + * @var array + */ + private const LEGAL_IDENTIFIERS = ['oin', 'rsin', 'kvk']; + + /** + * Leaf property name to Organisation setter suffix. + * + * Only properties Organisation actually declares are listed. Anything the + * leaf schema carries beyond these is reported rather than dropped quietly, + * because a property OR does not declare is a property OR discards. + * + * @var array + */ + private const FIELD_MAP = [ + 'name' => 'Name', + 'summary' => 'Summary', + 'description' => 'Description', + 'oin' => 'Oin', + 'tooi' => 'Tooi', + 'rsin' => 'Rsin', + 'kvk' => 'Kvk', + 'pki' => 'Pki', + 'image' => 'Image', + 'type' => 'Type', + 'status' => 'Status', + 'registrationStatus' => 'RegistrationStatus', + ]; + + /** + * Wire the mappers and the object reader. + * + * @param OrganisationMapper $organisationMapper The Organisation mapper. + * @param ObjectService $objectService Reader for the leaf objects. + * @param LoggerInterface $logger Logger. + * + * @return void + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + public function __construct( + private readonly OrganisationMapper $organisationMapper, + private readonly ObjectService $objectService, + private readonly LoggerInterface $logger, + ) { + parent::__construct(); + }//end __construct() + + /** + * Define command name, description, and options. + * + * @return void + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + protected function configure(): void { + $this->setName(name: 'openregister:organisations:adopt') + ->setDescription( + 'Adopt a leaf app\'s own organisation objects into OpenRegister\'s Organisation, ' + . 'preserving each uuid and recording a merge where the same legal entity already exists.' + ) + ->addOption( + 'register', + null, + InputOption::VALUE_REQUIRED, + 'The register slug holding the leaf organisation objects (for example `publication`).' + ) + ->addOption( + 'schema', + null, + InputOption::VALUE_REQUIRED, + 'The leaf schema slug to adopt from. Defaults to `organization`.', + 'organization' + ) + ->addOption( + 'apply', + null, + InputOption::VALUE_NONE, + 'Actually write. Without this flag the command reports what it WOULD do.' + ); + }//end configure() + + /** + * Read the leaf rows and adopt each one. + * + * @param InputInterface $input Console input. + * @param OutputInterface $output Console output stream. + * + * @return int Symfony command exit code. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $registerSlug = (string)$input->getOption('register'); + $schemaSlug = (string)$input->getOption('schema'); + $dryRun = ((bool)$input->getOption('apply') === false); + + if ($registerSlug === '') { + $output->writeln('--register is required.'); + return Command::FAILURE; + } + + if ($dryRun === true) { + $output->writeln( + 'Running in DRY-RUN mode — nothing will be written. ' + . 'Re-run with --apply to adopt.' + ); + } + + try { + $rows = $this->objectService->searchObjectsBySlug( + $registerSlug, + $schemaSlug, + ['_limit' => 5000], + false, + false + ); + } catch (Throwable $e) { + $output->writeln(sprintf('Could not read %s/%s: %s', $registerSlug, $schemaSlug, $e->getMessage())); + return Command::FAILURE; + } + + if (is_array($rows) === false) { + $output->writeln('The object reader returned a count rather than rows.'); + return Command::FAILURE; + } + + $existing = $this->existingOrganisations(); + $adopted = 0; + $merged = 0; + $skipped = 0; + $failed = 0; + + foreach ($rows as $row) { + $outcome = $this->adoptRow( + row: $row, + existing: $existing, + dryRun: $dryRun, + output: $output + ); + + $adopted += $outcome['adopted']; + $merged += $outcome['merged']; + $skipped += $outcome['skipped']; + $failed += $outcome['failed']; + }//end foreach + + $suffix = ''; + if ($dryRun === true) { + $suffix = ' (dry run — nothing written)'; + } + + $output->writeln( + sprintf( + 'Done. Adopted=%d (of which merged=%d), skipped=%d, failed=%d%s', + $adopted, + $merged, + $skipped, + $failed, + $suffix + ) + ); + + $this->logger->info( + 'OpenRegister: adopted leaf organisations', + [ + 'register' => $registerSlug, + 'schema' => $schemaSlug, + 'adopted' => $adopted, + 'merged' => $merged, + 'skipped' => $skipped, + 'failed' => $failed, + 'dryRun' => $dryRun, + ] + ); + + if ($failed > 0) { + return Command::FAILURE; + } + + return Command::SUCCESS; + }//end execute() + + /** + * Adopt one leaf row, reporting what happened to it. + * + * @param mixed $row The row as the reader returned it. + * @param array> $existing Organisations already on the instance, + * keyed by uuid. It grows as rows are + * adopted, so a legal identifier appearing + * twice within one run merges the second + * occurrence too. + * @param bool $dryRun Whether to report rather than write. + * @param OutputInterface $output Console output stream. + * + * @return array{adopted:int, merged:int, skipped:int, failed:int} The tally for this row. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + private function adoptRow(mixed $row, array &$existing, bool $dryRun, OutputInterface $output): array { + $none = ['adopted' => 0, 'merged' => 0, 'skipped' => 0, 'failed' => 0]; + + $fields = self::toFields(row: $row); + $uuid = (string)($fields['uuid'] ?? ''); + + if ($uuid === '') { + $output->writeln(' SKIP a row with no uuid: it has no idempotency key.'); + return array_merge($none, ['skipped' => 1]); + } + + if (isset($existing[$uuid]) === true) { + $output->writeln(sprintf(' SKIP %s: already adopted.', $uuid)); + return array_merge($none, ['skipped' => 1]); + } + + $target = self::findMergeTarget(row: $fields, existing: array_values($existing)); + + $mergeNote = ''; + $mergedCount = 0; + if ($target !== null) { + $mergeNote = sprintf(' -> merges into %s', $target['uuid']); + $mergedCount = 1; + } + + $output->writeln( + sprintf('%s (%s)%s', $uuid, (string)($fields['name'] ?? 'unnamed'), $mergeNote) + ); + + $this->reportUndeclared(fields: $fields, output: $output); + + if ($dryRun === true) { + $output->writeln(' WOULD ADOPT'); + return $none; + } + + try { + $saved = $this->organisationMapper->insert( + self::buildOrganisation(fields: $fields, mergeTarget: $target) + ); + } catch (Throwable $e) { + $output->writeln(sprintf(' FAILED: %s', $e->getMessage())); + return array_merge($none, ['failed' => 1]); + } + + $existing[$uuid] = self::toCandidate(organisation: $saved); + $output->writeln(' ADOPTED'); + + return array_merge($none, ['adopted' => 1, 'merged' => $mergedCount]); + }//end adoptRow() + + /** + * Name the properties this adoption will not carry over. + * + * @param array $fields The leaf row's fields. + * @param OutputInterface $output Console output stream. + * + * @return void + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + private function reportUndeclared(array $fields, OutputInterface $output): void { + $dropped = self::undeclaredProperties(row: $fields); + if ($dropped === []) { + return; + } + + $phrase = sprintf('%d properties have', count($dropped)); + if (count($dropped) === 1) { + $phrase = '1 property has'; + } + + $output->writeln( + sprintf( + ' %s no column on Organisation and will NOT be carried over: %s', + $phrase, + implode(', ', $dropped) + ) + ); + }//end reportUndeclared() + + /** + * Normalise a legal identifier for comparison. + * + * The same OIN is written with and without spaces and dots depending on who + * typed it, so a literal comparison misses matches that are plainly the same + * body. Everything that is not a letter or a digit is dropped. + * + * @param mixed $value The stored identifier. + * + * @return string The comparable form, or '' when there is nothing to compare. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + public static function normaliseIdentifier(mixed $value): string { + if (is_string($value) === false && is_int($value) === false) { + return ''; + } + + $stripped = preg_replace('/[^a-z0-9]/i', '', (string)$value); + if (is_string($stripped) === false) { + return ''; + } + + return strtolower($stripped); + }//end normaliseIdentifier() + + /** + * Find the organisation this row is the same legal entity as, if any. + * + * Matching runs on OIN, then RSIN, then KVK, and stops at the first + * identifier the row actually carries. A name is never matched on: two + * organisations sharing a name are routine, and collapsing them would + * destroy data that no later step can recover. + * + * Among several matches the LOWEST id is canonical, so a repeated run + * chooses the same survivor. A candidate that was itself merged away is + * deprioritised rather than excluded: pointing at it still resolves, + * because the resolver walks the chain. + * + * @param array $row The leaf row's fields. + * @param array> $existing Candidate organisations. + * + * @return array|null The organisation to merge into, or null. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + public static function findMergeTarget(array $row, array $existing): ?array { + foreach (self::LEGAL_IDENTIFIERS as $field) { + $needle = self::normaliseIdentifier(value: ($row[$field] ?? null)); + if ($needle === '') { + continue; + } + + $matches = []; + foreach ($existing as $candidate) { + if (self::normaliseIdentifier(value: ($candidate[$field] ?? null)) === $needle) { + $matches[] = $candidate; + } + } + + if ($matches === []) { + continue; + } + + usort( + $matches, + static function (array $a, array $b) { + $aMerged = 0; + if (($a['mergedInto'] ?? null) !== null) { + $aMerged = 1; + } + + $bMerged = 0; + if (($b['mergedInto'] ?? null) !== null) { + $bMerged = 1; + } + + if ($aMerged !== $bMerged) { + return ($aMerged <=> $bMerged); + } + + return ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)); + } + ); + + return $matches[0]; + }//end foreach + + return null; + }//end findMergeTarget() + + /** + * The leaf properties Organisation has nowhere to put. + * + * OpenRegister discards a property its schema does not declare, and it does + * so with a 200 and the object back, so an adoption that loses fields looks + * exactly like one that did not. Naming them is the whole point. + * + * @param array $row The leaf row's fields. + * + * @return array The property names that will not be carried over. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + public static function undeclaredProperties(array $row): array { + $dropped = []; + foreach (array_keys($row) as $property) { + if (str_starts_with($property, '@') === true || $property === 'id' || $property === 'uuid') { + continue; + } + + if (isset(self::FIELD_MAP[$property]) === true) { + continue; + } + + $dropped[] = $property; + } + + sort($dropped); + + return $dropped; + }//end undeclaredProperties() + + /** + * Flatten an object row into a plain field map. + * + * @param mixed $row The row as the object reader returned it. + * + * @return array The fields, with the uuid resolved. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + public static function toFields(mixed $row): array { + if (is_object($row) === true && method_exists($row, 'jsonSerialize') === true) { + $row = $row->jsonSerialize(); + } + + if (is_array($row) === false) { + return []; + } + + $self = ($row['@self'] ?? []); + $uuid = ''; + if (is_array($self) === true) { + $uuid = (string)($self['uuid'] ?? ($self['id'] ?? '')); + } + + if ($uuid === '') { + $uuid = (string)($row['uuid'] ?? ($row['id'] ?? '')); + } + + $row['uuid'] = $uuid; + + return $row; + }//end toFields() + + /** + * Build the Organisation to insert for one leaf row. + * + * @param array $fields The leaf row's fields. + * @param array|null $mergeTarget The organisation to merge into, if any. + * + * @return Organisation The organisation, not yet persisted. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + public static function buildOrganisation(array $fields, ?array $mergeTarget = null): Organisation { + $organisation = new Organisation(); + $organisation->setUuid((string)$fields['uuid']); + + foreach (self::FIELD_MAP as $property => $suffix) { + $value = ($fields[$property] ?? null); + if ($value === null || $value === '') { + continue; + } + + if (is_scalar($value) === false) { + continue; + } + + $organisation->{'set' . $suffix}((string)$value); + } + + // A slug is derived from the uuid rather than the name. Two adopted rows + // can legitimately share a name, and a name-derived slug would collide. + $organisation->setSlug('adopted-' . substr((string)$fields['uuid'], 0, 36)); + + if ($mergeTarget !== null) { + $organisation->setMergedInto((string)$mergeTarget['uuid']); + $organisation->setMergedAt(new DateTime()); + } + + return $organisation; + }//end buildOrganisation() + + /** + * Reduce an Organisation to the fields matching needs. + * + * @param Organisation $organisation The organisation. + * + * @return array The candidate record. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md#requirement-a-leaf-apps-organisations-are-adopted-not-re-created-req-org-106 + */ + public static function toCandidate(Organisation $organisation): array { + return [ + 'id' => (int)$organisation->getId(), + 'uuid' => (string)$organisation->getUuid(), + 'oin' => $organisation->getOin(), + 'rsin' => $organisation->getRsin(), + 'kvk' => $organisation->getKvk(), + 'mergedInto' => $organisation->getMergedInto(), + ]; + }//end toCandidate() + + /** + * Every organisation already on the instance, keyed by uuid. + * + * @return array> The candidates. + */ + private function existingOrganisations(): array { + $candidates = []; + foreach ($this->organisationMapper->findAll(limit: 10000, offset: 0, filters: []) as $organisation) { + $candidates[(string)$organisation->getUuid()] = self::toCandidate(organisation: $organisation); + } + + return $candidates; + }//end existingOrganisations() +}//end class diff --git a/openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md b/openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md index 802cac906c..56b6334ada 100644 --- a/openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md +++ b/openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md @@ -133,3 +133,60 @@ uuid/slug rather than minting new ones. - **GIVEN** an instance where the migration already ran - **WHEN** it runs again - **THEN** no column or index is added a second time and no row is modified. + +### Requirement: A leaf app's organisations are adopted, not re-created (REQ-ORG-106) + +Adopting a leaf app's organisation objects into the OpenRegister Organisation +MUST preserve each row's existing uuid, because references to it are stored in +places no migration can reach. + +The idempotency key MUST be the uuid, never the slug and never the name. A leaf +row is free to carry no slug at all, and two rows sharing a name are routine, so +a name-derived key would skip the second row as already migrated and silently +merge two distinct legal entities. + +Where the same legal entity already exists in OpenRegister under a different +uuid, the rows MUST NOT be collapsed into one. The adopted row is created and +pointed at the existing one through `mergedInto`, so both uuids keep resolving +and the merge is a fact recorded on a row rather than data thrown away. Matching +MUST be on a legal identifier, in the order OIN, RSIN, KVK, and MUST NOT be on a +name. Among several matches the lowest id is canonical, so a repeated run +chooses the same survivor. + +A leaf property the Organisation entity does not declare MUST be reported before +the write. OpenRegister discards an undeclared property and answers 200 with the +object, so an adoption that loses fields is indistinguishable from one that did +not. + +#### Scenario: An adopted organisation keeps its uuid + +- **GIVEN** a leaf organisation object with uuid `abc` +- **WHEN** it is adopted +- **THEN** the resulting Organisation carries uuid `abc`. + +#### Scenario: A second run adopts nothing + +- **GIVEN** an instance where the adoption already ran +- **WHEN** it runs again +- **THEN** every row is skipped as already adopted and nothing is written. + +#### Scenario: The same OIN records a merge rather than collapsing + +- **GIVEN** an existing organisation carrying OIN `00000001002220647000` +- **AND** a leaf row carrying the same OIN under a different uuid +- **WHEN** the leaf row is adopted +- **THEN** it is created with its own uuid and `mergedInto` set to the existing + organisation's uuid. + +#### Scenario: Two organisations sharing only a name are not merged + +- **GIVEN** two organisations with the same name and no shared legal identifier +- **WHEN** one is adopted +- **THEN** no merge is recorded. + +#### Scenario: Properties with no column are named before the write + +- **GIVEN** a leaf schema carrying a property the Organisation entity does not + declare +- **WHEN** the adoption runs +- **THEN** that property is reported as one that will not be carried over. diff --git a/openspec/changes/consolidate-organisation-on-or/tasks.md b/openspec/changes/consolidate-organisation-on-or/tasks.md index e5029e1174..83d6006196 100644 --- a/openspec/changes/consolidate-organisation-on-or/tasks.md +++ b/openspec/changes/consolidate-organisation-on-or/tasks.md @@ -38,11 +38,49 @@ ## 5. Leaf-app consolidation -- [ ] 5.1 DECISION REQUIRED: the migration path for existing leaf-app - organisation data. Adding the columns makes reuse possible; it does not - move OpenCatalogi's publisher rows or Stackiq's vendor rows into them. - The backfill must preserve the existing uuid/slug, and it needs a ruling - on what happens when the same legal entity exists in BOTH leaf apps under - different UUIDs. Until then the leaf apps keep their own records and OR's - new columns stay empty. -- [ ] 5.2 Point the leaf apps at the OR organisation once 5.1 is decided. +- [x] 5.1 DECIDED and built: `openregister:organisations:adopt`. The uuid is + the idempotency key and is preserved, following the rule dossiq's + `migrate-partners` arrived at — a leaf row may carry no slug, and two + rows sharing a name are routine, so a name-derived key would skip the + second as "already migrated" and silently merge two legal entities. + Where the same entity already exists under a different uuid the rows are + NOT collapsed: the adopted row is created and pointed at the existing one + through `mergedInto`, so both uuids keep resolving. Matching is on OIN, + then RSIN, then KVK, normalised for punctuation, and never on a name. + Lowest id is canonical; a merged-away candidate loses to a live one. + Properties Organisation has no column for are NAMED before the write, + because OpenRegister discards an undeclared property and answers 200. + Dry-run by default. Proven live on the dev instance including the + negative control (no shared identifier, no merge reported). +- [ ] 5.2 Point the leaf apps at the OR organisation, then retire their + schemas. Measured 2026-09-02, and the measurement changed the shape: + + **opencatalogi** maps 9-for-9 onto Organisation apart from + `tooiIdentifier`. 22 code sites name the slug, and most are + `catalog['organization']`, a stored REFERENCE that keeps resolving once + the uuid is preserved. Roughly 8 real UI sites do + `getCollection('organization')` and need repointing at the Organisation + API. Tractable as one change. + + **stackiq** carries 21 properties, 9 of which have no column on + Organisation. The plan was to map what maps and rehome the rest. Reading + the entity rather than assuming, NOTHING maps: + + - `contactpersonen` -> `contacts` is a different thing. `contacts` is + linked Nextcloud Contacts app data, serialised as `_contacts`. + - `participants` / `deelnames` -> `children` would be lost. The setter + says it plainly: "Children are not stored in the database, only loaded + on demand." A written value is dropped and then recomputed. + - `samenwerkingtype` -> `type` collides. `type` has a closed vocabulary + (organisation, government, vendor, collaboration, department), and a + collaboration SUBtype is a different axis from it. + + So all 9 belong on a stackiq-owned schema under a non-colliding slug, + and none on Organisation. 235 code sites name the slug, because the app + treats `organization` as a first-class object type throughout + (`objectStore.getCollection('organization')`). That is its own change + with its own spec, not a step in this one. + + A later option worth recording: `participants` could be modelled through + OR's `parent`, which IS stored, by setting it on each participant rather + than listing them on the collaboration. diff --git a/tests/Unit/Command/AdoptLeafOrganisationsCommandTest.php b/tests/Unit/Command/AdoptLeafOrganisationsCommandTest.php new file mode 100644 index 0000000000..2205a497aa --- /dev/null +++ b/tests/Unit/Command/AdoptLeafOrganisationsCommandTest.php @@ -0,0 +1,554 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Command; + +use OCA\OpenRegister\Command\AdoptLeafOrganisationsCommand; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\OpenRegister\Service\ObjectService; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; + +/** + * Locks the rules that decide what an adoption keeps, merges and drops. + */ +class AdoptLeafOrganisationsCommandTest extends TestCase { + + /** + * Build a candidate organisation record. + * + * @param int $id The row id. + * @param string $uuid The uuid. + * @param string|null $oin The OIN, if any. + * @param string|null $mergedInto The uuid this row was merged into, if any. + * + * @return array The candidate. + */ + private function candidate(int $id, string $uuid, ?string $oin = null, ?string $mergedInto = null): array { + return [ + 'id' => $id, + 'uuid' => $uuid, + 'oin' => $oin, + 'rsin' => null, + 'kvk' => null, + 'mergedInto' => $mergedInto, + ]; + + }//end candidate() + + /** + * The same OIN written with and without punctuation is the same body. + * + * @return void + */ + public function testPunctuationDoesNotHideAMatch(): void { + $this->assertSame( + AdoptLeafOrganisationsCommand::normaliseIdentifier(value: '0000 0001-0022.2064 7000'), + AdoptLeafOrganisationsCommand::normaliseIdentifier(value: '00000001002220647000') + ); + + }//end testPunctuationDoesNotHideAMatch() + + /** + * A value that is not a scalar identifier compares as nothing, rather than + * as an empty string that would match every other empty one. + * + * @return void + */ + public function testANonScalarIdentifierIsNotComparable(): void { + $this->assertSame('', AdoptLeafOrganisationsCommand::normaliseIdentifier(value: ['00000001'])); + $this->assertSame('', AdoptLeafOrganisationsCommand::normaliseIdentifier(value: null)); + + }//end testANonScalarIdentifierIsNotComparable() + + /** + * A shared OIN identifies the same legal entity. + * + * @return void + */ + public function testASharedOinFindsTheMergeTarget(): void { + $target = AdoptLeafOrganisationsCommand::findMergeTarget( + row: ['oin' => '00000001002220647000'], + existing: [$this->candidate(id: 7, uuid: 'existing', oin: '00000001002220647000')] + ); + + $this->assertSame('existing', $target['uuid']); + + }//end testASharedOinFindsTheMergeTarget() + + /** + * Two organisations sharing only a name are two organisations. Merging them + * would destroy data no later step could recover. + * + * @return void + */ + public function testASharedNameNeverMerges(): void { + $this->assertNull( + AdoptLeafOrganisationsCommand::findMergeTarget( + row: ['name' => 'Gemeente Utrecht'], + existing: [ + [ + 'id' => 7, + 'uuid' => 'existing', + 'name' => 'Gemeente Utrecht', + 'oin' => null, + 'rsin' => null, + 'kvk' => null, + 'mergedInto' => null, + ], + ] + ) + ); + + }//end testASharedNameNeverMerges() + + /** + * An empty identifier on either side is not a match: otherwise every row + * carrying no OIN would merge into the first other row carrying none. + * + * @return void + */ + public function testAnEmptyIdentifierIsNotAMatch(): void { + $this->assertNull( + AdoptLeafOrganisationsCommand::findMergeTarget( + row: ['oin' => ''], + existing: [$this->candidate(id: 7, uuid: 'existing', oin: '')] + ) + ); + + }//end testAnEmptyIdentifierIsNotAMatch() + + /** + * Among several matches the lowest id is canonical, so a repeated run + * chooses the same survivor rather than whichever row came back first. + * + * @return void + */ + public function testTheLowestIdIsCanonical(): void { + $target = AdoptLeafOrganisationsCommand::findMergeTarget( + row: ['oin' => '123'], + existing: [ + $this->candidate(id: 9, uuid: 'later', oin: '123'), + $this->candidate(id: 4, uuid: 'earlier', oin: '123'), + ] + ); + + $this->assertSame('earlier', $target['uuid']); + + }//end testTheLowestIdIsCanonical() + + /** + * A candidate that was itself merged away loses to a live one, so the + * adoption points at a row that is still a usable tenant. + * + * @return void + */ + public function testALiveCandidateBeatsAMergedAwayOne(): void { + $target = AdoptLeafOrganisationsCommand::findMergeTarget( + row: ['oin' => '123'], + existing: [ + $this->candidate(id: 2, uuid: 'merged-away', oin: '123', mergedInto: 'somewhere'), + $this->candidate(id: 8, uuid: 'live', oin: '123'), + ] + ); + + $this->assertSame('live', $target['uuid']); + + }//end testALiveCandidateBeatsAMergedAwayOne() + + /** + * OIN is tried before RSIN, so a row carrying both matches on the stronger + * identifier. + * + * @return void + */ + public function testOinIsTriedBeforeRsin(): void { + $target = AdoptLeafOrganisationsCommand::findMergeTarget( + row: ['oin' => '111', 'rsin' => '222'], + existing: [ + ['id' => 3, 'uuid' => 'by-rsin', 'oin' => null, 'rsin' => '222', 'kvk' => null, 'mergedInto' => null], + ['id' => 9, 'uuid' => 'by-oin', 'oin' => '111', 'rsin' => null, 'kvk' => null, 'mergedInto' => null], + ] + ); + + $this->assertSame('by-oin', $target['uuid']); + + }//end testOinIsTriedBeforeRsin() + + /** + * The uuid is preserved: references to it are stored where no migration can + * reach them. + * + * @return void + */ + public function testTheUuidIsPreserved(): void { + $organisation = AdoptLeafOrganisationsCommand::buildOrganisation( + fields: ['uuid' => 'leaf-uuid-123', 'name' => 'Gemeente Utrecht'] + ); + + $this->assertSame('leaf-uuid-123', $organisation->getUuid()); + $this->assertSame('Gemeente Utrecht', $organisation->getName()); + + }//end testTheUuidIsPreserved() + + /** + * The derived slug comes from the uuid, not the name: two adopted rows can + * legitimately share a name and a name-derived slug would collide. + * + * @return void + */ + public function testTheSlugIsDerivedFromTheUuidNotTheName(): void { + $first = AdoptLeafOrganisationsCommand::buildOrganisation( + fields: ['uuid' => 'uuid-one', 'name' => 'Gemeente Utrecht'] + ); + $second = AdoptLeafOrganisationsCommand::buildOrganisation( + fields: ['uuid' => 'uuid-two', 'name' => 'Gemeente Utrecht'] + ); + + $this->assertNotSame($first->getSlug(), $second->getSlug()); + + }//end testTheSlugIsDerivedFromTheUuidNotTheName() + + /** + * A merge target is recorded on the adopted row, so both uuids keep + * resolving. + * + * @return void + */ + public function testTheMergeIsRecordedOnTheAdoptedRow(): void { + $organisation = AdoptLeafOrganisationsCommand::buildOrganisation( + fields: ['uuid' => 'leaf-uuid', 'name' => 'Gemeente Utrecht'], + mergeTarget: ['id' => 4, 'uuid' => 'survivor-uuid'] + ); + + $this->assertSame('leaf-uuid', $organisation->getUuid()); + $this->assertSame('survivor-uuid', $organisation->getMergedInto()); + $this->assertNotNull($organisation->getMergedAt()); + + }//end testTheMergeIsRecordedOnTheAdoptedRow() + + /** + * Properties the entity has no column for are named. OpenRegister discards + * an undeclared property and answers 200 with the object, so an adoption + * that loses fields is otherwise indistinguishable from one that did not. + * + * @return void + */ + public function testUndeclaredPropertiesAreNamed(): void { + $this->assertSame( + ['contactpersonen', 'deelnames', 'xml'], + AdoptLeafOrganisationsCommand::undeclaredProperties( + row: [ + 'uuid' => 'leaf', + '@self' => ['id' => 1], + 'name' => 'Gemeente Utrecht', + 'oin' => '123', + 'xml' => '', + 'deelnames' => [], + 'contactpersonen' => [], + ] + ) + ); + + }//end testUndeclaredPropertiesAreNamed() + + /** + * The uuid is read from the `@self` metadata block, which is where the + * object reader puts it. + * + * @return void + */ + public function testTheUuidIsReadFromTheSelfBlock(): void { + $fields = AdoptLeafOrganisationsCommand::toFields( + row: ['@self' => ['uuid' => 'from-self'], 'name' => 'Gemeente Utrecht'] + ); + + $this->assertSame('from-self', $fields['uuid']); + + }//end testTheUuidIsReadFromTheSelfBlock() + + /** + * A row with no identifier anywhere yields an empty uuid, which the command + * treats as a row it cannot key on rather than minting one. + * + * @return void + */ + public function testARowWithNoIdentifierYieldsNoUuid(): void { + $this->assertSame('', AdoptLeafOrganisationsCommand::toFields(row: ['name' => 'Nameless'])['uuid']); + + }//end testARowWithNoIdentifierYieldsNoUuid() + + /** + * A non-scalar value is not forced into a string column: an array cast to + * string is the word "Array", which is worse than not carrying it. + * + * @return void + */ + public function testANonScalarValueIsNotWrittenToAStringColumn(): void { + $organisation = AdoptLeafOrganisationsCommand::buildOrganisation( + fields: ['uuid' => 'leaf', 'name' => ['nested' => 'value']] + ); + + $this->assertNull($organisation->getName()); + + }//end testANonScalarValueIsNotWrittenToAStringColumn() + + /** + * Build a command over mocked collaborators. + * + * @param array> $rows The leaf rows the reader returns. + * @param array $existing Organisations already on the instance. + * + * @return array{tester: CommandTester, mapper: OrganisationMapper&MockObject} The tester and the mapper. + */ + private function commandOver(array $rows, array $existing = []): array { + $objectService = $this->createMock(ObjectService::class); + $objectService->method('searchObjectsBySlug')->willReturn($rows); + + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findAll')->willReturn($existing); + $mapper->method('insert')->willReturnArgument(0); + + $command = new AdoptLeafOrganisationsCommand( + organisationMapper: $mapper, + objectService: $objectService, + logger: $this->createMock(LoggerInterface::class) + ); + + return ['tester' => new CommandTester($command), 'mapper' => $mapper]; + + }//end commandOver() + + /** + * Build an existing organisation. + * + * @param string $uuid The uuid. + * @param string|null $oin The OIN, if any. + * + * @return Organisation The organisation. + */ + private function existing(string $uuid, ?string $oin = null): Organisation { + $organisation = new Organisation(); + $organisation->setId(4); + $organisation->setUuid($uuid); + $organisation->setOin($oin); + + return $organisation; + + }//end existing() + + /** + * Without --register there is nothing to read from, and the command says so + * rather than reading whatever it can find. + * + * @return void + */ + public function testTheRegisterOptionIsRequired(): void { + $run = $this->commandOver(rows: []); + $this->assertSame(Command::FAILURE, $run['tester']->execute([])); + $this->assertStringContainsString('--register is required', $run['tester']->getDisplay()); + + }//end testTheRegisterOptionIsRequired() + + /** + * The default is a dry run, because the alternative default is a command + * that writes to every organisation on the instance the first time someone + * types its name to see what it does. + * + * @return void + */ + public function testItIsADryRunByDefault(): void { + $run = $this->commandOver(rows: [['@self' => ['uuid' => 'leaf-1'], 'name' => 'Gemeente Utrecht']]); + $run['mapper']->expects($this->never())->method('insert'); + + $this->assertSame(Command::SUCCESS, $run['tester']->execute(['--register' => 'publication'])); + $display = $run['tester']->getDisplay(); + $this->assertStringContainsString('DRY-RUN', $display); + $this->assertStringContainsString('WOULD ADOPT', $display); + $this->assertStringContainsString('nothing written', $display); + + }//end testItIsADryRunByDefault() + + /** + * With --apply the row is inserted, keeping its uuid. + * + * @return void + */ + public function testApplyInsertsTheRowKeepingItsUuid(): void { + $run = $this->commandOver(rows: [['@self' => ['uuid' => 'leaf-1'], 'name' => 'Gemeente Utrecht']]); + $run['mapper']->expects($this->once()) + ->method('insert') + ->with( + $this->callback( + static function ($organisation) { + return ((string) $organisation->getUuid() === 'leaf-1'); + } + ) + ) + ->willReturnArgument(0); + + $this->assertSame( + Command::SUCCESS, + $run['tester']->execute(['--register' => 'publication', '--apply' => true]) + ); + $this->assertStringContainsString('Adopted=1', $run['tester']->getDisplay()); + + }//end testApplyInsertsTheRowKeepingItsUuid() + + /** + * A row whose uuid is already an organisation is skipped, which is what + * makes a second run of the command a no-op. + * + * @return void + */ + public function testAnAlreadyAdoptedRowIsSkipped(): void { + $run = $this->commandOver( + rows: [['@self' => ['uuid' => 'leaf-1'], 'name' => 'Gemeente Utrecht']], + existing: [$this->existing(uuid: 'leaf-1')] + ); + $run['mapper']->expects($this->never())->method('insert'); + + $run['tester']->execute(['--register' => 'publication', '--apply' => true]); + $display = $run['tester']->getDisplay(); + $this->assertStringContainsString('already adopted', $display); + $this->assertStringContainsString('skipped=1', $display); + + }//end testAnAlreadyAdoptedRowIsSkipped() + + /** + * A row with no uuid has no idempotency key, so adopting it would create a + * duplicate on every run. It is skipped and reported. + * + * @return void + */ + public function testARowWithNoUuidIsSkipped(): void { + $run = $this->commandOver(rows: [['name' => 'Nameless']]); + $run['mapper']->expects($this->never())->method('insert'); + + $run['tester']->execute(['--register' => 'publication', '--apply' => true]); + $this->assertStringContainsString('no uuid', $run['tester']->getDisplay()); + + }//end testARowWithNoUuidIsSkipped() + + /** + * A matching legal identifier is reported as a merge and recorded on the + * adopted row, so both uuids keep resolving. + * + * @return void + */ + public function testAMatchingIdentifierRecordsAMerge(): void { + $run = $this->commandOver( + rows: [['@self' => ['uuid' => 'leaf-1'], 'name' => 'Gemeente Utrecht', 'oin' => '0000-0001']], + existing: [$this->existing(uuid: 'survivor', oin: '00000001')] + ); + + $run['tester']->execute(['--register' => 'publication', '--apply' => true]); + $display = $run['tester']->getDisplay(); + $this->assertStringContainsString('merges into survivor', $display); + $this->assertStringContainsString('merged=1', $display); + + }//end testAMatchingIdentifierRecordsAMerge() + + /** + * Properties Organisation has no column for are named before the write. + * This is the whole reason the report exists: OpenRegister discards an + * undeclared property and answers 200, so a lossy adoption is otherwise + * indistinguishable from a clean one. + * + * @return void + */ + public function testUndeclaredPropertiesAreReportedBeforeTheWrite(): void { + $run = $this->commandOver( + rows: [ + [ + '@self' => ['uuid' => 'leaf-1'], + 'name' => 'Gemeente Utrecht', + 'xml' => '', + 'deelnames' => [], + ], + ] + ); + + $run['tester']->execute(['--register' => 'publication']); + $display = $run['tester']->getDisplay(); + $this->assertStringContainsString('2 properties have no column', $display); + $this->assertStringContainsString('deelnames, xml', $display); + + }//end testUndeclaredPropertiesAreReportedBeforeTheWrite() + + /** + * One undeclared property reads as one, not as "1 property have". + * + * @return void + */ + public function testTheSingularReportReadsAsASingular(): void { + $run = $this->commandOver( + rows: [['@self' => ['uuid' => 'leaf-1'], 'name' => 'Gemeente Utrecht', 'xml' => '']] + ); + + $run['tester']->execute(['--register' => 'publication']); + $this->assertStringContainsString('1 property has no column', $run['tester']->getDisplay()); + + }//end testTheSingularReportReadsAsASingular() + + /** + * A row that fails to insert is counted as failed and the command exits + * non-zero, so a partial adoption does not report success. + * + * @return void + */ + public function testAFailedInsertMakesTheCommandFail(): void { + $run = $this->commandOver(rows: [['@self' => ['uuid' => 'leaf-1'], 'name' => 'Gemeente Utrecht']]); + $run['mapper']->method('insert')->willThrowException(new \RuntimeException('constraint')); + + $this->assertSame( + Command::FAILURE, + $run['tester']->execute(['--register' => 'publication', '--apply' => true]) + ); + $display = $run['tester']->getDisplay(); + $this->assertStringContainsString('FAILED: constraint', $display); + $this->assertStringContainsString('failed=1', $display); + + }//end testAFailedInsertMakesTheCommandFail() + + /** + * A second row sharing the first's legal identifier merges into it within + * the same run, because the candidate set grows as rows are adopted. + * + * @return void + */ + public function testARunMergesADuplicateItAdoptedItself(): void { + $run = $this->commandOver( + rows: [ + ['@self' => ['uuid' => 'leaf-1'], 'name' => 'Gemeente Utrecht', 'oin' => '111'], + ['@self' => ['uuid' => 'leaf-2'], 'name' => 'Gemeente Utrecht', 'oin' => '111'], + ] + ); + + $run['tester']->execute(['--register' => 'publication', '--apply' => true]); + $display = $run['tester']->getDisplay(); + $this->assertStringContainsString('merges into leaf-1', $display); + $this->assertStringContainsString('Adopted=2 (of which merged=1)', $display); + + }//end testARunMergesADuplicateItAdoptedItself() +}//end class From 6e8bc8f7db71a1dcd04973a91fcc5df4106ea403 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 10:21:50 +0200 Subject: [PATCH 62/68] feat(files): a publication window on the file, so an attachment needs no object (#3346) Apps grew their own document object to hold a publication window over an attached file. Measuring opencatalogi's, every other property it carries already has a home: filename and mime type are the file, description and labels are the OR-side metadata row, the owning publication is the folder the file lives in, and the file's text is already extracted into openregister_chunks and searchable. The window was the only real gap. publishFile() is a boolean, so an attachment could not be depublished on a date independently of the record it belongs to, which is what a WOO bijlage needs, and that alone forced a whole object type into existence. The second reason is stronger. ContentSearchHandler already resolves a file chunk to its owning object through findOwningObjectUuid(), so a keyword hit inside a file attached to a publication resolves straight to the publication. The schema widening in opencatalogi #1391 exists only because the attachment is a separate object outside the catalog's schema scope. Files on publications would have made that class of bug impossible. The window is a rule rather than a comparison because each null in it means something different: no publication date is never published and must not fall back to the creation time, a future one is not yet, and no depublication date is no end date rather than an end date in the past. Start inclusive, end exclusive, so a zero-length window publishes nothing. A depublication date is written onto the public share's expiration, which Nextcloud already honours. An OR-side flag alone would leave a public URL that still serves the file, and a URL that still works is not a depublication. Two repairs on the way. formatFile() reported the creation time under 'published', so every file that had ever existed looked published and 'not published' was unrepresentable; the creation time is kept under 'created', where it is true. And FileMapper declared a @phpstan-type alias named File describing a filecache ROW, which shadowed the entity of the same name in every docblock in that file: a method annotated @return File read as an array shape. Renaming it to FilecacheRow and naming the entity in the generic removed 12 phpstan baseline entries. Verified on the dev instance: the migration applies and is a no-op on re-run, and all four window states behave correctly round-tripped through the database, not only in memory. Co-authored-by: Conduction Release Bot --- lib/Db/File.php | 102 ++++++++- lib/Db/FileMapper.php | 76 +++++-- lib/Migration/Version1Date20260903100000.php | 119 ++++++++++ lib/Service/File/FileFormattingHandler.php | 17 +- .../file-publication-window/proposal.md | 60 +++++ .../specs/file-publication-window/spec.md | 78 +++++++ .../changes/file-publication-window/tasks.md | 42 ++++ phpstan-baseline.neon | 48 ---- tests/Unit/Db/FilePublicationWindowTest.php | 213 ++++++++++++++++++ 9 files changed, 678 insertions(+), 77 deletions(-) create mode 100644 lib/Migration/Version1Date20260903100000.php create mode 100644 openspec/changes/file-publication-window/proposal.md create mode 100644 openspec/changes/file-publication-window/specs/file-publication-window/spec.md create mode 100644 openspec/changes/file-publication-window/tasks.md create mode 100644 tests/Unit/Db/FilePublicationWindowTest.php diff --git a/lib/Db/File.php b/lib/Db/File.php index b1b2a2920d..68f6d61bdd 100644 --- a/lib/Db/File.php +++ b/lib/Db/File.php @@ -23,6 +23,24 @@ * @link https://OpenRegister.app * * @spec openspec/changes/file-actions/tasks.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use OCP\AppFramework\Db\Entity; + +/** + * File entity for the `openregister_files` table. + * + * The `@method` tags below belong on the CLASS, not on the file docblock they + * used to sit in. A static analyser resolves magic accessors from the class it + * is analysing; a docblock above `declare(strict_types=1)` documents the file + * and is invisible to it. That is why phpstan.neon carried counted ignore + * entries for `File::setUpdated()` and friends, and why adding one accessor + * made an unrelated ignore count wrong. * * @method int|null getFileId() * @method void setFileId(int $fileId) @@ -44,16 +62,10 @@ * @method void setCreated(\DateTime $created) * @method \DateTime|null getUpdated() * @method void setUpdated(?\DateTime $updated) - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Db; - -use OCP\AppFramework\Db\Entity; - -/** - * File entity for the `openregister_files` table. + * @method \DateTime|null getPublished() + * @method void setPublished(?\DateTime $published) + * @method \DateTime|null getDepublished() + * @method void setDepublished(?\DateTime $depublished) */ class File extends Entity { @@ -127,6 +139,29 @@ class File extends Entity { */ protected ?\DateTime $updated = null; + /** + * When this file becomes public. + * + * Null means it has never been published, which is why the key is nullable + * rather than defaulting to the creation time. A file that exists is not a + * file that was published, and reporting the creation time as a publication + * date is what this column exists to stop. + * + * @var \DateTime|null + */ + protected ?\DateTime $published = null; + + /** + * When this file stops being public. + * + * Null means no end date, not "already ended". The distinction matters: a + * published attachment with no depublication date stays public, which is the + * ordinary case. + * + * @var \DateTime|null + */ + protected ?\DateTime $depublished = null; + /** * Configure typed columns for the file metadata row. * @@ -143,6 +178,8 @@ public function __construct() { $this->addType(fieldName: 'downloadCount', type: 'integer'); $this->addType(fieldName: 'created', type: 'datetime'); $this->addType(fieldName: 'updated', type: 'datetime'); + $this->addType(fieldName: 'published', type: 'datetime'); + $this->addType(fieldName: 'depublished', type: 'datetime'); }//end __construct() @@ -165,6 +202,51 @@ public function jsonSerialize(): array { 'downloadCount' => $this->downloadCount, 'created' => $this->created?->format('c'), 'updated' => $this->updated?->format('c'), + 'published' => $this->published?->format('c'), + 'depublished' => $this->depublished?->format('c'), + 'isPublished' => $this->isPublishedAt(), ]; }//end jsonSerialize() + + /** + * Whether this file is public at a given moment. + * + * The rule reads as three separate questions rather than one expression, + * because each null means something different: + * + * - No `published` at all means never published. This is NOT the same as + * published long ago, and defaulting it to the creation time would make + * every file that has ever existed look published. + * - A `published` in the future means not yet, which is the whole point of + * being able to set one. + * - No `depublished` means no end date, not an end date in the past. + * + * The boundaries are inclusive at the start and exclusive at the end, so a + * file published and depublished at the same instant is not public. + * + * @param DateTime|null $now The moment to evaluate, defaulting to this one. + * + * @return bool True when the file is public at that moment. + * + * @spec openspec/changes/file-publication-window/specs/file-publication-window/spec.md#requirement-a-file-carries-its-own-publication-window-req-fpw-101 + */ + public function isPublishedAt(?DateTime $now = null): bool { + if ($this->published === null) { + return false; + } + + if ($now === null) { + $now = new DateTime(); + } + + if ($this->published > $now) { + return false; + } + + if ($this->depublished === null) { + return true; + } + + return ($this->depublished > $now); + }//end isPublishedAt() }//end class diff --git a/lib/Db/FileMapper.php b/lib/Db/FileMapper.php index 847ebe4d18..01a4cd835b 100644 --- a/lib/Db/FileMapper.php +++ b/lib/Db/FileMapper.php @@ -42,7 +42,13 @@ * @version GIT: * @link https://OpenRegister.app * - * @phpstan-type File array{ + * The alias below describes a filecache ROW, not the {@see File} entity. It was + * named `File`, which shadowed that entity in every docblock in this file: a + * mapper method annotated `@return File` read as an array shape, which is why + * the phpstan baseline carried an entry for each one. Renamed rather than + * baselined. + * + * @phpstan-type FilecacheRow array{ * fileid: int, * storage: int, * path: string, @@ -68,17 +74,7 @@ * published: string|null * } * - * @method \OCP\AppFramework\Db\Entity insert(\OCP\AppFramework\Db\Entity $entity) - * @method \OCP\AppFramework\Db\Entity update(\OCP\AppFramework\Db\Entity $entity) - * @method \OCP\AppFramework\Db\Entity insertOrUpdate(\OCP\AppFramework\Db\Entity $entity) - * @method \OCP\AppFramework\Db\Entity delete(\OCP\AppFramework\Db\Entity $entity) - * @method \OCP\AppFramework\Db\Entity find(int|string $id) - * @method \OCP\AppFramework\Db\Entity findEntity(IQueryBuilder $query) - * @method File[] findAll(int|null $limit=null, int|null $offset=null) - * @method File[] findEntities(IQueryBuilder $query) - * @psalm-suppress LessSpecificImplementedReturnType - File[] is more specific than list - * - * @template-extends QBMapper + * @template-extends QBMapper * * @SuppressWarnings(PHPMD.ExcessiveClassLength) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) @@ -203,6 +199,34 @@ public function setLabelsForFile(int $fileId, ?array $labels): File { return $this->update(entity: $file); }//end setLabelsForFile() + /** + * Set the publication window for a Nextcloud file. + * + * Both bounds are set together because they are one fact. Setting them + * separately invites the state where a depublication date precedes the + * publication date it belongs to, which reads as "published" to a careless + * comparison and as nothing at all to a careful one. + * + * @param int $fileId The Nextcloud filecache fileid. + * @param \DateTime|null $published When the file becomes public, or null for never. + * @param \DateTime|null $depublished When it stops, or null for no end date. + * + * @return File The updated entity. + * + * @spec openspec/changes/file-publication-window/specs/file-publication-window/spec.md#requirement-a-file-carries-its-own-publication-window-req-fpw-101 + */ + public function setPublicationWindowForFile( + int $fileId, + ?\DateTime $published, + ?\DateTime $depublished + ): File { + $file = $this->findOrCreateByFileId(fileId: $fileId); + $file->setPublished($published); + $file->setDepublished($depublished); + $file->setUpdated(new DateTime()); + return $this->update(entity: $file); + }//end setPublicationWindowForFile() + /** * Increment the cached download count for a Nextcloud file. * Idempotent: creates the row on first download. @@ -289,7 +313,7 @@ public function findByFileIds(array $fileIds): array { * * @phpstan-param int|null $node * @phpstan-param array|null $ids - * @phpstan-return list + * @phpstan-return list */ public function getFiles(?int $node = null, ?array $ids = null): array { // Create a new query builder instance. @@ -392,7 +416,7 @@ public function getFiles(?int $node = null, ?array $ids = null): array { * @return array|null The file as an associative array with share information and owner data, or null if not found * * @phpstan-param int $fileId - * @phpstan-return File|null + * @phpstan-return FilecacheRow|null */ public function getFile(int $fileId): ?array { // Create a new query builder instance. @@ -488,7 +512,7 @@ public function getFile(int $fileId): ?array { * numeric string, which PHP coerces to an int array key. * * @phpstan-param array $fileIds - * @phpstan-return array + * @phpstan-return array */ public function getFilesByIds(array $fileIds): array { // Normalise to unique positive integers; ignore non-numeric entries. @@ -526,7 +550,7 @@ public function getFilesByIds(array $fileIds): array { * @throws \RuntimeException If more than one node is found for the object's uuid * * @phpstan-param ObjectEntity $object - * @phpstan-return list + * @phpstan-return list */ public function getFilesForObject(ObjectEntity $object): array { // Retrieve the folder property from the object entity. @@ -802,9 +826,12 @@ private function generateAuthenticatedDownloadUrl(int $fileId): string { * @param string $sharedBy The user who is sharing the file * @param string $shareOwner The owner of the file * @param int $permissions The permissions for the share (default: 1 = read) + * @param \DateTime|null $depublished When the share stops working, or null for no end date * * @return (int|string)[] * + * @spec openspec/changes/file-publication-window/specs/file-publication-window/spec.md#requirement-a-depublication-date-expires-the-public-share-req-fpw-102 + * * @throws \Exception If the share creation fails * * @phpstan-param int $fileId @@ -816,7 +843,13 @@ private function generateAuthenticatedDownloadUrl(int $fileId): string { * * @psalm-return array{id: int, token: string, accessUrl: string, downloadUrl: string, published: string} */ - public function publishFile(int $fileId, string $sharedBy, string $shareOwner, int $permissions = 1): array { + public function publishFile( + int $fileId, + string $sharedBy, + string $shareOwner, + int $permissions = 1, + ?\DateTime $depublished = null + ): array { // Check if a public share already exists for this file. $existingShare = $this->getPublicShare(fileId: $fileId); if ($existingShare !== null) { @@ -854,7 +887,14 @@ public function publishFile(int $fileId, string $sharedBy, string $shareOwner, i 'permissions' => $qb->createNamedParameter($permissions, IQueryBuilder::PARAM_INT), 'stime' => $qb->createNamedParameter($currentTime, IQueryBuilder::PARAM_INT), 'accepted' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), - 'expiration' => $qb->createNamedParameter(null), + // The share carries the end of the window, so Nextcloud stops + // serving the link on the date with nothing having to run. + // An OR-side flag alone would leave a public URL that still + // works, which is not a depublication. + 'expiration' => $qb->createNamedParameter( + $depublished?->format('Y-m-d H:i:s'), + IQueryBuilder::PARAM_STR + ), 'token' => $qb->createNamedParameter($token), 'mail_send' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), 'hide_download' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), diff --git a/lib/Migration/Version1Date20260903100000.php b/lib/Migration/Version1Date20260903100000.php new file mode 100644 index 0000000000..d87706605e --- /dev/null +++ b/lib/Migration/Version1Date20260903100000.php @@ -0,0 +1,119 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * A publication window on the file metadata row. + * + * Several apps grew their own `document` object purely to hold a publication + * window over an attached file. Everything else such an object carried is + * already on the file or on the object it hangs from: the filename and mime + * type are the file, `description` / `category` / `labels` are the OR-side + * metadata row, the owning publication is the folder the file lives in, and the + * file's text is already extracted into `openregister_chunks` and searchable. + * + * The window was the one thing with nowhere to live. `publishFile()` is a + * boolean: it creates a public share or it does not. So an attachment could not + * be depublished on a date independently of the record it belongs to, which is + * exactly what a WOO bijlage needs. + * + * Two nullable datetimes, no row rewritten, both guarded so the step is + * re-runnable. `depublished` is indexed because the sweep that stops serving an + * expired file scans on it. + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/file-publication-window/specs/file-publication-window/spec.md#requirement-a-file-carries-its-own-publication-window-req-fpw-101 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds `published` and `depublished` to the file metadata row. + * + * @spec openspec/changes/file-publication-window/specs/file-publication-window/spec.md#requirement-a-file-carries-its-own-publication-window-req-fpw-101 + */ +class Version1Date20260903100000 extends SimpleMigrationStep { + + /** + * The OpenRegister file metadata table. + */ + private const TABLE_FILES = 'openregister_files'; + + /** + * Add the window columns and the expiry index, idempotently. + * + * @param IOutput $output Migration output. + * @param Closure(): ISchemaWrapper $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The interface fixes the signature. + * + * @spec openspec/changes/file-publication-window/specs/file-publication-window/spec.md#requirement-a-file-carries-its-own-publication-window-req-fpw-101 + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable(self::TABLE_FILES) === false) { + $output->warning(message: 'openregister_files is absent; skipping the publication window'); + return null; + } + + $table = $schema->getTable(self::TABLE_FILES); + $added = []; + + if ($table->hasColumn('published') === false) { + $table->addColumn( + 'published', + Types::DATETIME_MUTABLE, + [ + 'notnull' => false, + 'comment' => 'When this file becomes public. Null means it has never been published.', + ] + ); + $added[] = 'published'; + } + + if ($table->hasColumn('depublished') === false) { + $table->addColumn( + 'depublished', + Types::DATETIME_MUTABLE, + [ + 'notnull' => false, + 'comment' => 'When this file stops being public. Null means no end date.', + ] + ); + $added[] = 'depublished'; + } + + if ($table->hasIndex('openregister_file_depub_idx') === false) { + $table->addIndex(['depublished'], 'openregister_file_depub_idx'); + $added[] = 'index:depublished'; + } + + if ($added === []) { + return null; + } + + $output->info(message: 'File publication window added: ' . implode(', ', $added)); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Service/File/FileFormattingHandler.php b/lib/Service/File/FileFormattingHandler.php index 86001d94c0..adda315139 100644 --- a/lib/Service/File/FileFormattingHandler.php +++ b/lib/Service/File/FileFormattingHandler.php @@ -142,7 +142,15 @@ public function formatFile(Node $file): array { 'extension' => $file->getExtension(), 'size' => $file->getSize(), 'hash' => $file->getEtag(), - 'published' => (new DateTime())->setTimestamp($file->getCreationTime())->format('c'), + // `published` reported the CREATION time, so every file that had ever + // existed looked published and nothing could be read as unpublished. + // It is the publication window now, filled from the OR-side row + // below, and null when the file was never published. The creation + // time it used to carry is kept under its own name. + 'published' => null, + 'depublished' => null, + 'isPublished' => false, + 'created' => (new DateTime())->setTimestamp($file->getCreationTime())->format('c'), 'modified' => (new DateTime())->setTimestamp($file->getUploadTime())->format('c'), 'labels' => $this->fileService->getFileTags((string)$file->getId()), ]; @@ -169,6 +177,13 @@ public function formatFile(Node $file): array { if ($orFile !== null) { $metadata['description'] = $orFile->getDescription(); $metadata['category'] = $orFile->getCategory(); + + // The publication window is public-safe: it is exactly what + // an anonymous caller is entitled to know about whether this + // file is published and until when. + $metadata['published'] = $orFile->getPublished()?->format('c'); + $metadata['depublished'] = $orFile->getDepublished()?->format('c'); + $metadata['isPublished'] = $orFile->isPublishedAt(); $orLabels = ($orFile->getLabels() ?? []); if (empty($orLabels) === false) { // Merge OR-managed labels into the existing tag- diff --git a/openspec/changes/file-publication-window/proposal.md b/openspec/changes/file-publication-window/proposal.md new file mode 100644 index 0000000000..b31d9eda2e --- /dev/null +++ b/openspec/changes/file-publication-window/proposal.md @@ -0,0 +1,60 @@ +# A publication window on the file, so an attachment needs no object + +## Why + +Several apps grew their own `document` object, and measuring one of them showed +what it is actually for. opencatalogi's `document` carries `title`, `filename`, +`mimeType`, `summary`, `description`, `publication`, `organization`, +`publicationDate` and `depublicationDate`. Every one of those already has a home +somewhere else: + +| document property | where it already lives | +| --- | --- | +| `filename`, `mimeType` | the file | +| `description` | `File.description` | +| `title` | the filename, or a label | +| `publication`, `organization` | the object the file hangs from | +| `summary` | fold into `description` | +| `publicationDate` / `depublicationDate` | **nowhere** | + +The window is the only real gap. `publishFile()` is a boolean: it creates a +public share or it does not. So an attachment could not be depublished on a date +independently of the record it belongs to, which is exactly what a WOO bijlage +needs, and that alone forced a whole object type into existence. + +There is a second reason, and it is the stronger one. OpenRegister already +extracts file text into `openregister_chunks` with `source_type='file'`, and +`ContentSearchHandler::resolveOwningObject()` already resolves a file chunk to +its owning object through `FileMapper::findOwningObjectUuid()`. A keyword hit +inside a file attached to a publication therefore resolves straight to the +publication. The schema-widening in opencatalogi PR #1391 exists ONLY because +the attachment is a separate `document` object living outside the catalog's +schema scope. Files attached to publications would have made that whole class of +bug impossible. + +## What changes + +`openregister_files` gains `published` and `depublished`, both nullable, and the +file API reports the window and a computed `isPublished`. + +A depublication date is written onto the public share's `expiration` column, +which Nextcloud already honours. An OR-side flag alone would leave a public URL +that still works, and a URL that still works is not a depublication. + +## Two things this repairs on the way + +`formatFile()` reported `'published' => creationTime`. Every file that had ever +existed therefore looked published, and no file could be read as unpublished. +The creation time is kept, under `created`, where it is true. + +`FileMapper` declared `@phpstan-type File` as a filecache ROW shape, named after +the entity the mapper maps. That alias shadowed the entity in every docblock in +the file, so a method annotated `@return File` read as an array, and the phpstan +baseline carried an entry for each one. Renaming the alias to `FilecacheRow` and +naming the entity in the generic removed 12 baseline entries. + +## What this does not do + +It does not retire any app's `document` schema. That is per-app work with its +own migration and its own repointing, and it should follow this rather than ride +along with it. diff --git a/openspec/changes/file-publication-window/specs/file-publication-window/spec.md b/openspec/changes/file-publication-window/specs/file-publication-window/spec.md new file mode 100644 index 0000000000..59ab1be906 --- /dev/null +++ b/openspec/changes/file-publication-window/specs/file-publication-window/spec.md @@ -0,0 +1,78 @@ +# File publication window + +## ADDED Requirements + +### Requirement: A file carries its own publication window (REQ-FPW-101) + +A file MUST be able to declare when it becomes public and when it stops being +public, independently of the object it is attached to. Without this an +attachment can only be published or not published, which is why apps grew a +separate `document` object to hold the dates. + +Both bounds are nullable, and each null means something specific: + +- No publication date means the file was NEVER published. It MUST NOT default to + the file's creation time. A file that exists is not a file that was published. +- A publication date in the future means not yet. +- No depublication date means no end date. It MUST NOT be read as an end date in + the past. + +The window is inclusive at the start and exclusive at the end, so a file +published and depublished at the same instant is not public. + +#### Scenario: A file with no publication date is not published + +- **GIVEN** a file whose `published` is null +- **WHEN** its publication state is evaluated +- **THEN** it is not published, whatever its creation time. + +#### Scenario: A future publication date is not yet published + +- **GIVEN** a file published tomorrow +- **WHEN** its publication state is evaluated today +- **THEN** it is not published. + +#### Scenario: No depublication date means it stays published + +- **GIVEN** a published file whose `depublished` is null +- **WHEN** its publication state is evaluated +- **THEN** it is published. + +#### Scenario: A passed depublication date ends publication + +- **GIVEN** a file published yesterday and depublished this morning +- **WHEN** its publication state is evaluated now +- **THEN** it is not published. + +### Requirement: A depublication date expires the public share (REQ-FPW-102) + +Publishing a file with a depublication date MUST set that date as the public +share's expiration. Recording the date only on the OpenRegister side would leave +a public URL that still serves the file, and a URL that still works is not a +depublication. + +#### Scenario: The share carries the end of the window + +- **WHEN** a file is published with a depublication date +- **THEN** the created public share carries that date as its expiration. + +#### Scenario: No depublication date leaves the share open + +- **WHEN** a file is published with no depublication date +- **THEN** the share has no expiration. + +### Requirement: The file API reports the window, not the creation time (REQ-FPW-103) + +The formatted file MUST report `published`, `depublished` and a computed +`isPublished`. It MUST NOT report the creation time under `published`: doing so +made every file that had ever existed look published, and made "not published" +unrepresentable. + +The creation time remains available under `created`. + +#### Scenario: An unpublished file reports no publication date + +- **GIVEN** a file that was never published +- **WHEN** it is formatted +- **THEN** `published` is null and `isPublished` is false +- **AND** `created` carries its creation time. diff --git a/openspec/changes/file-publication-window/tasks.md b/openspec/changes/file-publication-window/tasks.md new file mode 100644 index 0000000000..a423627515 --- /dev/null +++ b/openspec/changes/file-publication-window/tasks.md @@ -0,0 +1,42 @@ +# Tasks + +## 1. Storage + +- [x] 1.1 Add `published` and `depublished` to `openregister_files`, both + nullable, guarded so the step is re-runnable. +- [x] 1.2 Index `depublished`, which is what an expiry scan ranges on. + +## 2. The window rule + +- [x] 2.1 `File::isPublishedAt()`. Each null means something different: no + `published` is never published, a future `published` is not yet, and no + `depublished` is no end date rather than an end date in the past. +- [x] 2.2 `FileMapper::setPublicationWindowForFile()` sets both bounds together, + because they are one fact. + +## 3. Honouring it + +- [x] 3.1 `publishFile()` writes the depublication date onto the share's + `expiration`, so Nextcloud stops serving the link itself. +- [x] 3.2 `formatFile()` reports `published`, `depublished` and `isPublished`, + and stops reporting the creation time as a publication date. + +## 4. Repairs found on the way + +- [x] 4.1 Rename `FileMapper`'s `@phpstan-type File` to `FilecacheRow`: it + described a filecache row and shadowed the entity of the same name. +- [x] 4.2 Name the entity in the generic and drop the `@method` shadows that + worked around the shadowing. +- [x] 4.3 Remove the 12 phpstan baseline entries those two defects required. + +## 5. Tests + +- [x] 5.1 Pin the window rule, including every null case and both boundaries. + +## 6. Follow-on, not in this change + +- [ ] 6.1 Retire opencatalogi's `document` schema: attachments become files on + the publication, classified with labels. Revert the schema widening in + opencatalogi PR #1391 once nothing needs it. +- [ ] 6.2 Decide whether an expired share needs a sweep, or whether Nextcloud's + own expiration handling is sufficient on every deployment. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 891c30703c..d06758fccd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2450,60 +2450,16 @@ parameters: count: 1 path: lib/Db/FeedbackMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:getDownloadCount\\(\\)\\.$#" - count: 1 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setCategory\\(\\)\\.$#" - count: 1 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setCreated\\(\\)\\.$#" - count: 1 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setDescription\\(\\)\\.$#" - count: 1 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setDownloadCount\\(\\)\\.$#" - count: 2 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setFileId\\(\\)\\.$#" - count: 1 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setLabels\\(\\)\\.$#" - count: 1 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setLockExpires\\(\\)\\.$#" - count: 1 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setLockedAt\\(\\)\\.$#" - count: 1 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setLockedBy\\(\\)\\.$#" - count: 1 - path: lib/Db/FileMapper.php - - - message: "#^Call to an undefined method OCA\\\\OpenRegister\\\\Db\\\\File\\:\\:setUpdated\\(\\)\\.$#" - count: 5 - path: lib/Db/FileMapper.php - message: "#^Cannot call method getFileId\\(\\) on array\\\\.$#" @@ -2545,10 +2501,6 @@ parameters: count: 1 path: lib/Db/FileMapper.php - - - message: "#^PHPDoc tag @return with type array\\ is incompatible with native type OCA\\\\OpenRegister\\\\Db\\\\File\\.$#" - count: 6 - path: lib/Db/FileMapper.php - message: "#^PHPDoc tag @return with type array\\\\|null is not subtype of native type OCA\\\\OpenRegister\\\\Db\\\\File\\|null\\.$#" diff --git a/tests/Unit/Db/FilePublicationWindowTest.php b/tests/Unit/Db/FilePublicationWindowTest.php new file mode 100644 index 0000000000..4c27508912 --- /dev/null +++ b/tests/Unit/Db/FilePublicationWindowTest.php @@ -0,0 +1,213 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use DateTime; +use OCA\OpenRegister\Db\File; +use PHPUnit\Framework\TestCase; + +/** + * Locks the window rule. Each null in it means something different, and the + * whole reason the rule is a method rather than an inline comparison is that + * treating them alike is the mistake it exists to prevent. + */ +class FilePublicationWindowTest extends TestCase { + + /** + * The moment every case is evaluated against. + */ + private const NOW = '2026-06-15 12:00:00'; + + /** + * Build a file with a window. + * + * @param string|null $published When it becomes public. + * @param string|null $depublished When it stops. + * + * @return File The file. + */ + private function file(?string $published, ?string $depublished = null): File { + $file = new File(); + $file->setFileId(1); + + if ($published !== null) { + $file->setPublished(new DateTime($published)); + } + + if ($depublished !== null) { + $file->setDepublished(new DateTime($depublished)); + } + + return $file; + + }//end file() + + /** + * The moment under test. + * + * @return DateTime The evaluation moment. + */ + private function now(): DateTime { + return new DateTime(self::NOW); + + }//end now() + + /** + * No publication date means never published. It must NOT fall back to the + * creation time: that is what made every file that had ever existed look + * published, and made "not published" unrepresentable. + * + * @return void + */ + public function testAFileWithNoPublicationDateIsNotPublished(): void { + $this->assertFalse($this->file(published: null)->isPublishedAt(now: $this->now())); + + }//end testAFileWithNoPublicationDateIsNotPublished() + + /** + * A depublication date without a publication date does not publish + * anything. An end date is not a start date. + * + * @return void + */ + public function testADepublicationDateAloneDoesNotPublish(): void { + $this->assertFalse( + $this->file(published: null, depublished: '2030-01-01 00:00:00')->isPublishedAt(now: $this->now()) + ); + + }//end testADepublicationDateAloneDoesNotPublish() + + /** + * A future publication date is not yet published, which is the whole point + * of being able to set one. + * + * @return void + */ + public function testAFuturePublicationDateIsNotYetPublished(): void { + $this->assertFalse($this->file(published: '2026-06-16 12:00:00')->isPublishedAt(now: $this->now())); + + }//end testAFuturePublicationDateIsNotYetPublished() + + /** + * No depublication date means no end date, not an end date in the past. + * This is the ordinary case and the one a careless comparison gets wrong. + * + * @return void + */ + public function testNoDepublicationDateMeansItStaysPublished(): void { + $this->assertTrue($this->file(published: '2020-01-01 00:00:00')->isPublishedAt(now: $this->now())); + + }//end testNoDepublicationDateMeansItStaysPublished() + + /** + * A depublication date that has passed ends publication. + * + * @return void + */ + public function testAPassedDepublicationDateEndsPublication(): void { + $this->assertFalse( + $this->file(published: '2020-01-01 00:00:00', depublished: '2026-06-15 09:00:00') + ->isPublishedAt(now: $this->now()) + ); + + }//end testAPassedDepublicationDateEndsPublication() + + /** + * A depublication date still ahead keeps the file published. + * + * @return void + */ + public function testAFutureDepublicationDateKeepsItPublished(): void { + $this->assertTrue( + $this->file(published: '2020-01-01 00:00:00', depublished: '2026-06-16 00:00:00') + ->isPublishedAt(now: $this->now()) + ); + + }//end testAFutureDepublicationDateKeepsItPublished() + + /** + * The start boundary is inclusive: a file published at exactly this instant + * is published. + * + * @return void + */ + public function testTheStartBoundaryIsInclusive(): void { + $this->assertTrue($this->file(published: self::NOW)->isPublishedAt(now: $this->now())); + + }//end testTheStartBoundaryIsInclusive() + + /** + * The end boundary is exclusive, so a file published and depublished at the + * same instant is not public. A zero-length window publishes nothing. + * + * @return void + */ + public function testTheEndBoundaryIsExclusive(): void { + $this->assertFalse( + $this->file(published: self::NOW, depublished: self::NOW)->isPublishedAt(now: $this->now()) + ); + + }//end testTheEndBoundaryIsExclusive() + + /** + * A window whose end precedes its start publishes nothing, rather than + * reading as published because the start has passed. + * + * @return void + */ + public function testAnInvertedWindowPublishesNothing(): void { + $this->assertFalse( + $this->file(published: '2020-01-01 00:00:00', depublished: '2019-01-01 00:00:00') + ->isPublishedAt(now: $this->now()) + ); + + }//end testAnInvertedWindowPublishesNothing() + + /** + * The serialised form carries the window and the computed state, so a + * caller never has to re-derive the rule. + * + * @return void + */ + public function testTheSerialisedFormCarriesTheWindow(): void { + $serialised = $this->file(published: '2020-01-01 00:00:00', depublished: '2030-01-01 00:00:00') + ->jsonSerialize(); + + $this->assertSame('2020-01-01T00:00:00+00:00', $serialised['published']); + $this->assertSame('2030-01-01T00:00:00+00:00', $serialised['depublished']); + $this->assertTrue($serialised['isPublished']); + + }//end testTheSerialisedFormCarriesTheWindow() + + /** + * An unpublished file serialises nulls rather than dates, so "never + * published" is representable on the wire. + * + * @return void + */ + public function testAnUnpublishedFileSerialisesNulls(): void { + $serialised = $this->file(published: null)->jsonSerialize(); + + $this->assertNull($serialised['published']); + $this->assertNull($serialised['depublished']); + $this->assertFalse($serialised['isPublished']); + + }//end testAnUnpublishedFileSerialisesNulls() +}//end class From 925c20489c2192b6b3c1d1b50c6bb5096f5f241c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 12:27:19 +0200 Subject: [PATCH 63/68] fix(flow): the heartbeat recovers a missed signal instead of rolling forever (#3358) A UserTask's completion signal was REFUSED on the acceptance rig (the assignee group did not exist at signal time), and the suspended run's 30-minute heartbeat then fired, re-suspended, and never advanced: resume_at rolled 08:07 -> 08:37 -> ... while the task sat `completed` and the group had long been created. The heartbeat exists precisely to recover a missed wake; it recovered nothing. The node was never the problem. UserTaskNode has always re-read its task on re-entry and applied the outcome when terminal. What wedged is upstream: persistResult() dropped EVERY parked node's resume slot whenever a pass ended anything but `suspended`. FlowResumeState::storableWhen(suspended:) read NOT-SUSPENDED as "nothing left to continue from", which conflates it with TERMINAL -- and a pass legitimately ends `queued` while a node parked in an EARLIER pass still waits: the in-request advance of a sibling branch finalises `queued` whenever other enabled work remains, and a refused claim does the same. The parked node then lost the uuid of the task it was waiting on. Its next wake found an empty slot and -- correctly, by its own idempotency guard -- created a NEW task. From that moment the ORIGINAL task's completion could never address the node's slot, its signal was refused against the new slot's recorded assignee (the refusal observed on the rig), every heartbeat re-read the new open task and re-suspended, and a duplicate task sat in somebody's inbox. Slots now survive every pass end the run can still advance from, and drop only on a terminal one. A recovered delivery is recorded on the task's audit as `heartbeat-recovered`, attributed to the task's completedBy, so the trail no longer ends at the refusal. The symmetric cases need no new mechanism, and the change says so. A task completed while the run was not yet suspended (the race) loses its signal -- signal() refuses a non-suspended run -- and the node then parks on a non-null heartbeat, so the next wake re-reads it; the race costs at most one heartbeat period. A task concluded by TaskSequenceService reaches its terminal state on the same row the re-read observes, so it is covered with no sequence-specific handling. No second delivery mechanism is added: the wake, the re-read and the outcome application are the paths that already existed. Proven red before the fix: FlowHeartbeatRecoveryTest drives the real engine, dispatcher, node, stream walk, claims and commit path over in-memory mappers, so the pass-to-pass persistence that loses the slot is exercised exactly as the worker exercises it. Co-authored-by: Claude Opus 5 (1M context) --- lib/Service/Flow/FlowResumeState.php | 29 +- lib/Service/Flow/FlowRunService.php | 54 +- lib/Service/Flow/FlowTaskBridge.php | 54 ++ lib/Service/Flow/Nodes/PortalTaskNode.php | 10 + lib/Service/Flow/Nodes/UserTaskNode.php | 11 + .../changes/flow-heartbeat-recovery/design.md | 97 ++++ .../flow-heartbeat-recovery/proposal.md | 76 +++ .../specs/flow-heartbeat-recovery/spec.md | 111 ++++ .../changes/flow-heartbeat-recovery/tasks.md | 37 ++ .../Flow/FlowHeartbeatRecoveryTest.php | 514 ++++++++++++++++++ .../Unit/Service/Flow/FlowResumeStateTest.php | 39 ++ .../Unit/Service/Flow/PortalTaskNodeTest.php | 46 ++ tests/Unit/Service/Flow/UserTaskNodeTest.php | 44 ++ 13 files changed, 1105 insertions(+), 17 deletions(-) create mode 100644 openspec/changes/flow-heartbeat-recovery/design.md create mode 100644 openspec/changes/flow-heartbeat-recovery/proposal.md create mode 100644 openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md create mode 100644 openspec/changes/flow-heartbeat-recovery/tasks.md create mode 100644 tests/Unit/Service/Flow/FlowHeartbeatRecoveryTest.php diff --git a/lib/Service/Flow/FlowResumeState.php b/lib/Service/Flow/FlowResumeState.php index 50583d44b1..e94457c5bf 100644 --- a/lib/Service/Flow/FlowResumeState.php +++ b/lib/Service/Flow/FlowResumeState.php @@ -207,24 +207,37 @@ public static function fromArray(mixed $stored): self { /** * The storable form, or null when there is nothing worth storing. * - * Only a SUSPENDED run has anywhere to continue from. A terminal one does - * not, so keeping its slots would put a stale cursor in front of anyone - * reading the run to find out what happened — and the dispatcher has already - * cleared every node that returned, so anything still held belongs to a node - * the run never came back to. + * Kept for every run that can still advance, dropped only on a terminal + * one. The first version of this rule said "only a SUSPENDED run has + * anywhere to continue from", and that conflated NOT-SUSPENDED with + * TERMINAL: a pass can end `queued` — an in-request advance whose sibling + * still has enabled work, a claim refused on contention — while a node + * parked in an EARLIER pass still holds live progress. Dropping the slots + * there is how the heartbeat wedge happened: a user-task node lost the + * uuid of the task it was waiting on, asked again on the next wake, and + * the original task's completion could never address the node's slot + * again — its signal was refused against the new slot's assignee, and the + * run rolled its heartbeat forever. + * + * A terminal run still drops them: keeping its slots would put a stale + * cursor in front of anyone reading the run to find out what happened — + * and the dispatcher has already cleared every node that returned, so + * anything still held belongs to a node the run never came back to. * * Lives here rather than in the run service because it is a question about * this value, not about persistence: the state knows when it is worth * keeping. * - * @param boolean $suspended Whether the walk ended suspended. + * @param boolean $live Whether the run can still advance (any non-terminal + * status — suspended, queued, running). * * @return array>|null The slots, or null to drop them. * * @spec openspec/specs/flow-engine/spec.md#requirement-a-node-must-be-able-to-resume-from-where-it-stopped + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-live-run-keeps-every-parked-nodes-resume-slot */ - public function storableWhen(bool $suspended): ?array { - if ($suspended === false || $this->byNode === []) { + public function storableWhen(bool $live): ?array { + if ($live === false || $this->byNode === []) { return null; } diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index 5f4da8706e..43346f0f82 100644 --- a/lib/Service/Flow/FlowRunService.php +++ b/lib/Service/Flow/FlowRunService.php @@ -907,7 +907,7 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed $resuming = ($run->getStatus() === FlowRun::STATUS_SUSPENDED); if ($resuming === true) { // Stored items win on resume (below), but the subject's own fields - // on them are a trigger-time snapshot. {@see self::refreshSubjectItems()} + // on them are a trigger-time snapshot: see refreshSubjectItems(). $this->refreshSubjectItems(run: $run, subject: $subject); } @@ -1091,6 +1091,7 @@ private function failUnresolvableVersion(FlowRun $run): FlowRun { * @return FlowRun The updated run. * * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-live-run-keeps-every-parked-nodes-resume-slot */ private function persistResult(FlowRun $run, array $result): FlowRun { $status = (string)($result['status'] ?? FlowRun::STATUS_FAILED); @@ -1141,14 +1142,7 @@ private function persistResult(FlowRun $run, array $result): FlowRun { // other node also reads from. unset($context[FlowNodeResumeState::CONTEXT_KEY]); - $resumeState = ($context[FlowResumeState::CONTEXT_KEY] ?? null); - unset($context[FlowResumeState::CONTEXT_KEY]); - if ($resumeState instanceof FlowResumeState === true) { - $storable = $resumeState->storableWhen(suspended: ($status === FlowRun::STATUS_SUSPENDED)); - if ($storable !== null) { - $context[FlowResumeState::CONTEXT_KEY] = $storable; - } - } + $this->keepResumeSlots(context: $context, status: $status); // A signal is consumed by the walk it woke. Kept, it would still be // sitting there the NEXT time this run suspends on a signal, and that @@ -1186,6 +1180,48 @@ private function persistResult(FlowRun $run, array $result): FlowRun { return $persisted; }//end persistResult() + /** + * Fold the walk's per-node resume slots back into the storable context. + * + * 🔴 LIVE, NOT SUSPENDED. The rule used to be "only a suspended run has + * anywhere to continue from", which quietly conflated NOT-SUSPENDED with + * TERMINAL. A pass legitimately ends `queued` while a node parked in an + * EARLIER pass is still waiting: the in-request advance of one branch + * finalises `queued` whenever a sibling has enabled work, and a claim + * refused on contention does the same. Dropping the slots there costs a + * task-waiting node the uuid of the task it is waiting on — and that loss + * IS the heartbeat wedge. The node's next wake finds an empty slot, so + * (correctly, by its own idempotency guard) it asks again; from then on + * the ORIGINAL task's completion can never address the node's slot, its + * signal is refused against the new slot's recorded assignee, and the run + * re-suspends on its heartbeat forever while a duplicate task sits in + * somebody's inbox. + * + * A terminal run still drops them, for the reason it always did: anything + * still held belongs to a node the run never came back to. + * + * @param array $context The context being persisted, modified in place. + * @param string $status The status the walk ended in. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-live-run-keeps-every-parked-nodes-resume-slot + */ + private function keepResumeSlots(array &$context, string $status): void { + $resumeState = ($context[FlowResumeState::CONTEXT_KEY] ?? null); + unset($context[FlowResumeState::CONTEXT_KEY]); + + if ($resumeState instanceof FlowResumeState === false) { + return; + } + + $storable = $resumeState->storableWhen(live: (in_array($status, FlowRun::TERMINAL, true) === false)); + if ($storable !== null) { + $context[FlowResumeState::CONTEXT_KEY] = $storable; + } + + }//end keepResumeSlots() + /** * The correlation key a suspended run can be addressed by, or null. * diff --git a/lib/Service/Flow/FlowTaskBridge.php b/lib/Service/Flow/FlowTaskBridge.php index 1c1e751ffb..885f68cb51 100644 --- a/lib/Service/Flow/FlowTaskBridge.php +++ b/lib/Service/Flow/FlowTaskBridge.php @@ -186,6 +186,60 @@ public function record(string $uuid, string $action, ?string $actor, string $rea $this->tasks->record(uuid: $uuid, action: $action, actor: $actor, reason: $reason); }//end record() + /** + * Record that a heartbeat, not the completion's signal, delivered a + * terminal task's answer to its run. + * + * The heartbeat exists precisely to recover a missed wake — a completion + * whose signal was refused (the assignee guard, a group that did not exist + * yet) or lost. When it does recover one, the audit must say so: the + * guarded signal seam records a refusal, and without this entry the trail + * ends there, reading as though the answer never reached the run at all. + * Attributed to whoever completed the task, because the fact being + * recorded is THEIR answer arriving — late, by poll — not the cron job's. + * + * Best-effort by design: the recovery itself is the node applying the + * outcome, and a failure to write the audit row must never turn a + * recovered run back into a wedged one. + * + * @param Task $task The terminal task whose outcome the heartbeat applied. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-recovered-delivery-is-recorded-on-the-tasks-audit + */ + public function recordHeartbeatRecovery(Task $task): void { + try { + $this->tasks->record( + uuid: (string)$task->getUuid(), + action: 'heartbeat-recovered', + actor: $task->getCompletedBy(), + reason: sprintf( + 'The completion signal never reached run %s; the heartbeat re-read this task and applied its outcome.', + (string)$task->getRunUuid() + ) + ); + + $this->logger->info( + message: '[FlowTaskBridge] Heartbeat recovered a missed completion signal', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'task' => (string)$task->getUuid(), + 'run' => (string)$task->getRunUuid(), + 'node' => (string)$task->getNodeId(), + 'completedBy' => (string)($task->getCompletedBy() ?? ''), + ] + ); + } catch (Throwable $failure) { + $this->logger->warning( + message: '[FlowTaskBridge] Could not record a heartbeat recovery on task ' . $task->getUuid() + . '; the outcome itself was applied: ' . $failure->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'run' => (string)$task->getRunUuid()] + ); + }//end try + }//end recordHeartbeatRecovery() + /** * The task a node's resume slot points at, or null when it is gone. * diff --git a/lib/Service/Flow/Nodes/PortalTaskNode.php b/lib/Service/Flow/Nodes/PortalTaskNode.php index 9c628c9acc..abe7729271 100644 --- a/lib/Service/Flow/Nodes/PortalTaskNode.php +++ b/lib/Service/Flow/Nodes/PortalTaskNode.php @@ -76,6 +76,7 @@ use OCA\OpenRegister\Service\Flow\FlowItems; use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\FlowTaskBridge; use OCA\OpenRegister\Service\Flow\IFlowNode; @@ -267,6 +268,7 @@ public function validateConfig(array $config): void { * the case names nobody, or a re-ask has no reason. * * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-a-portal-task-step-creates-one-external-task-and-suspends-the-run + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-wake-re-reads-the-awaited-task-and-applies-a-terminal-outcome */ public function execute(array $items, array $config, array $context): array { if ($items === []) { @@ -300,6 +302,14 @@ public function execute(array $items, array $config, array $context): array { } if ($resume->get(key: PortalTaskConfig::SLOT_PASSED_AT, default: null) === null) { + // A terminal read with no signal in hand means the completion's + // wake never arrived — refused or lost — and the heartbeat is what + // recovered it. Recorded before the outcome is applied, which is + // identical on both paths; the user-task node makes the same call. + if (array_key_exists(FlowRunService::SIGNAL_CONTEXT_KEY, $context) === false) { + $this->bridge->recordHeartbeatRecovery(task: $task); + } + // The first pass over a terminal task: the answer travels on. Marked // ONCE, so the next firing of this node in this run is a re-entry. $resume->set(key: PortalTaskConfig::SLOT_PASSED_AT, value: (new DateTime())->format('c')); diff --git a/lib/Service/Flow/Nodes/UserTaskNode.php b/lib/Service/Flow/Nodes/UserTaskNode.php index d7a309d962..7b79f74aed 100644 --- a/lib/Service/Flow/Nodes/UserTaskNode.php +++ b/lib/Service/Flow/Nodes/UserTaskNode.php @@ -65,6 +65,7 @@ use OCA\OpenRegister\Service\Flow\FlowItems; use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowStop; use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\FlowTaskBridge; @@ -270,6 +271,7 @@ public function validateConfig(array $config): void { * @throws RuntimeException When the node has no resume slot, or its task is gone. * * @spec openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md#requirement-a-user-task-step-creates-exactly-one-task-and-suspends-the-run + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-wake-re-reads-the-awaited-task-and-applies-a-terminal-outcome */ public function execute(array $items, array $config, array $context): array { if ($items === []) { @@ -309,6 +311,15 @@ public function execute(array $items, array $config, array $context): array { throw $this->suspension(config: $config, items: $items); } + // A terminal read with no signal in hand means the completion's wake + // never arrived — refused by the assignee guard, or lost — and the + // heartbeat is what recovered it. Recorded on the task's audit, + // attributed to its completer, BEFORE the outcome is applied below: + // the applying is identical on both paths, which is the contract. + if (array_key_exists(FlowRunService::SIGNAL_CONTEXT_KEY, $context) === false) { + $this->bridge->recordHeartbeatRecovery(task: $task); + } + $bag = FlowTaskBridge::outcomeBagFor(task: $task); if ($bag['rejected'] === true && ($config['failOnReject'] ?? false) === true) { diff --git a/openspec/changes/flow-heartbeat-recovery/design.md b/openspec/changes/flow-heartbeat-recovery/design.md new file mode 100644 index 0000000000..2861336d40 --- /dev/null +++ b/openspec/changes/flow-heartbeat-recovery/design.md @@ -0,0 +1,97 @@ +# Design: flow-heartbeat-recovery + +## Context + +Measured before deciding anything: + +- **The node re-read has always existed.** `UserTaskNode::execute()` + (`lib/Service/Flow/Nodes/UserTaskNode.php`) re-enters on every wake, reads + `taskOrNull()` and applies the outcome bag when the task is terminal. + `PortalTaskNode` mirrors it. The single-stream walk and the stream walk + both re-dispatch a parked node on resume (`FlowStreamWalk::begin()`: + "Suspended streams become eligible again"). +- **The slot is the node's only memory.** The task uuid lives in the node's + per-node resume slot (#3325's scoping) and nowhere else the node can + reach. Lose the slot and the node MUST create a new task — its own + idempotency guard reads the slot to decide. +- **`persistResult()` was the only writer that lost it.** + `FlowResumeState::storableWhen(suspended:)` returned null for every + non-suspended pass end. `advanceStream()` finalising `queued` (sibling + work enabled) is a routine pass end for a run with parallel human + branches, and a claim refusal produces `queued` too. + +## Decisions + +### D-1: Fix the state, not the walk + +The heartbeat is made honest by making the state it reads durable, not by +adding a recovery sweep to `FlowRunWorker`. A worker-side "re-read every +suspended run's tasks" would be a second delivery mechanism with its own +addressing rules, racing the node's own re-read. With the slot intact, the +existing wake (`findDue()` → `advance()` → `execute()` → node re-entry) does +everything the defect report asks: re-read, apply, advance. + +`storableWhen()` keeps slots for every status outside `FlowRun::TERMINAL`. +Terminal runs still drop them, for the original reason: anything still held +belongs to a node the run never came back to, and the dispatcher has already +cleared every node that returned. + +### D-2: The recovery is audited on the task, attributed to the completer + +The guarded signal seam records a refusal +(`FlowRunSignalService::auditRefusal()`); without a matching entry the trail +ends there and reads as though the answer never reached the run. When a node +reads its task terminal with NO signal in the walk's context, the wake was a +heartbeat, not the completion's signal — `context['signal']` is set by +`signal()` and survives into the woken walk, so its absence is the +discriminator. The node then calls +`FlowTaskBridge::recordHeartbeatRecovery()`, which appends a +`heartbeat-recovered` audit entry on the task via `TaskService::record()`, +actor = `completedBy` — the fact recorded is THAT PERSON's answer arriving +late, not the cron job acting. Best-effort: a failure to write the entry is +logged and swallowed, because it must never turn a recovered run back into a +wedged one. + +### D-3: The symmetric cases need no new mechanism + +Stated explicitly, as the defect report asks: + +- **A task completed while the run was not yet suspended (the race).** + `signal()` returns null for a run that is `running` or `queued`, so the + completion's wake is lost. The run then parks with a non-null heartbeat + (`🔴 THE HEARTBEAT IS NEVER NULL`, UserTaskNode), and the next wake + re-reads the task — with the slot now durable, the race costs at most one + heartbeat period of latency. No pre-suspension re-check is added: the node + cannot read an answer before it has parked on the question, and the + heartbeat already bounds the wait. +- **A task whose sequence concluded (`TaskSequenceService`).** A sequence + drives every per-task transition through `TaskService`'s verbs, so the + task named by the node's slot reaches its terminal state on the same row + the heartbeat re-reads. Terminality is a property of that row + (`isInTerminalState()`); the re-read covers sequence-concluded tasks with + no sequence-specific handling. + +### D-4: Wrong-slot isolation is preserved by construction + +Each node reads only the slot the dispatcher scoped to it +(`FlowNodeResumeState`), so a heartbeat wake recovers exactly the nodes +whose OWN tasks are terminal; a sibling parked on an open task re-suspends +with its slot untouched. `FlowHeartbeatRecoveryTest::testOnlyTheNodeWhoseTaskEndedRecovers` +pins it. + +## Risks + +- Keeping slots on `queued`/`running` stores per-node state a little longer + than before. That state is exactly what a parked node needs on its next + wake; nodes that returned were already cleared by the dispatcher, so no + stale cursor can leak into a later pass. +- `recordHeartbeatRecovery()` re-dispatches `TaskTerminalEvent`, because + every `TaskService::record()` on a terminal task does. The re-entrancy is + closed by an existing guard rather than by a new one, and the chain is + short enough to state in full: the listener calls + `FlowTaskBridge::continueRun()`, which calls `FlowRunService::signal()`, + which returns null for any run that is not `suspended`. At the moment the + recovery is recorded the run row says `running` — `execute()` sets and + persists that before the walk begins — so the signal is refused and no + second walk starts. The recovery is written from inside that walk, and the + walk finishes normally. diff --git a/openspec/changes/flow-heartbeat-recovery/proposal.md b/openspec/changes/flow-heartbeat-recovery/proposal.md new file mode 100644 index 0000000000..801540cceb --- /dev/null +++ b/openspec/changes/flow-heartbeat-recovery/proposal.md @@ -0,0 +1,76 @@ +--- +kind: code +depends_on: [flow-user-task-node, flow-parallel-streams] +--- + +# Proposal: flow-heartbeat-recovery + +## Summary + +Make the user-task heartbeat honest: a suspended run whose completion signal +was refused or lost must recover on its next heartbeat wake instead of +re-suspending forever. The recovery mechanism already exists — the node +re-reads its task on every re-entry — but the state it depends on did not +survive: a pass that ends `queued` dropped every parked node's resume slot, +losing the uuid of the task the node was waiting on. This change keeps the +slots for every run that can still advance, and records a heartbeat-recovered +delivery on the task's audit so the trail no longer ends at the refusal. + +## Why + +**Observed on the acceptance rig, wedged forever.** A UserTask's completion +signal was refused (`[FlowRunSignalService] Refused a signal: the actor is +not the awaiting step's assignee` — the assignee group did not exist at +signal time). The suspended run's 30-minute heartbeat then fired, re-suspended +for another 30 minutes, and never advanced: `resume_at` rolled 08:07 → 08:37 +→ … while the task sat `completed` and the group had long been created. The +heartbeat exists precisely to recover a missed wake; it recovered nothing. + +**The node was never the problem.** `UserTaskNode::execute()` has always +re-read its task through `FlowTaskBridge::taskOrNull()` on every re-entry and +applied the outcome when the task is terminal — the unit suite proves it, and +a run driven through the real engine, dispatcher and stream commit path +recovers correctly (`FlowHeartbeatRecoveryTest`). What wedges is upstream: + +**`persistResult()` dropped every parked node's resume slot whenever a pass +ended anything but `suspended`.** `FlowResumeState::storableWhen(suspended:)` +read NOT-SUSPENDED as "nothing left to continue from", which conflates it +with TERMINAL. A pass legitimately ends `queued` while a node parked in an +EARLIER pass still waits: the in-request advance of a sibling branch +(`FlowTaskBridge::continueRun()` → `advanceStream()`) finalises `queued` +whenever other enabled work remains, and a claim refused on contention does +the same. The parked user-task node then lost its `taskUuid` slot; its next +wake found an empty slot and — exactly as its own guard demands — created a +NEW task rather than re-reading the original. From that moment: + +- the completion of the ORIGINAL task could never address the node's slot, + and its signal was refused against the new slot's recorded assignee — the + refusal observed on the rig; +- every heartbeat re-read the NEW, open task and re-suspended, rolling + `resume_at` forever; +- a duplicate task sat in somebody's inbox. + +`FlowHeartbeatRecoveryTest::testAnInRequestAdvanceKeepsTheSiblingNodesParkedSlot` +reproduces the drop red on the unfixed code. + +## What changes + +1. **Slots survive every live pass end.** `FlowResumeState::storableWhen()` + now keeps the per-node slots for any non-terminal status (`suspended`, + `queued`, `running`) and drops them only when the run is terminal. + `persistResult()` derives that from `FlowRun::TERMINAL`. +2. **A recovered delivery is recorded.** When `UserTaskNode` or + `PortalTaskNode` reads its task terminal WITHOUT a signal in hand (no + `context['signal']` — the completion's wake never arrived), it records + `heartbeat-recovered` on the task's audit through the new + `FlowTaskBridge::recordHeartbeatRecovery()`, attributed to the task's + `completedBy`. Best-effort: an audit failure never un-recovers the run. +3. **Nothing else.** No second delivery mechanism, no new sweep, no new + column: the heartbeat wake, the node re-read and the outcome application + are exactly the paths that already existed. + +## Out of scope + +Runs already wedged before this fix (slot lost, duplicate task created) +cannot be recovered retroactively: the original task's uuid is gone from the +run. They end at the abandoned-signal reaper or by manual retry, as today. diff --git a/openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md b/openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md new file mode 100644 index 0000000000..44cea2bff4 --- /dev/null +++ b/openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md @@ -0,0 +1,111 @@ +## Purpose + +A run suspended on a task must recover a missed completion signal on its +next heartbeat wake: the awaited task is re-read, a terminal outcome is +applied exactly as the signal path would have applied it, and the recovery +is recorded. A missed wake costs latency, never the run. + +## ADDED Requirements + +### Requirement: A live run keeps every parked node's resume slot + +The engine SHALL persist every node's resume slot across any pass end from +which the run can still advance — `suspended`, `queued` and `running` +alike. A pass that ends `queued` (an in-request advance of one branch while +a sibling has enabled work, a claim refused on contention) MUST NOT cost a +node parked in an earlier pass its stored progress; for a task-waiting node +that progress includes the uuid of the task it is waiting on, and losing it +forces a duplicate task and strands the original's completion. + +The engine SHALL drop the slots only when the run reaches a terminal +status: a finished run has nowhere to continue from, and the dispatcher has +already cleared every node that returned. + +#### Scenario: An in-request advance of one branch keeps the sibling's slot + +- **GIVEN** a run suspended on two parallel user-task nodes, each holding + its task's uuid in its own resume slot +- **WHEN** one task's completion advances its branch in-request and the pass + ends `queued` because the sibling branch still has enabled work +- **THEN** the sibling node's resume slot MUST still hold its task's uuid +- **AND** the sibling's next wake MUST re-park on that same task, never + create a second one + +#### Scenario: A terminal run stores no slots + +- **GIVEN** a run whose walk ends in a terminal status +- **THEN** no resume slots are persisted on the run + +### Requirement: A heartbeat wake re-reads the awaited task and applies a terminal outcome + +On every wake of a suspended run — heartbeat or signal — a task-waiting +node SHALL re-read the task named by its own resume slot. When that task is +terminal (completed, terminated, disabled), the node SHALL apply its +outcome exactly as the signal path would have: the same outcome bag under +`json.` on every item, the same advance of the run. When the +task is still open, the node SHALL suspend again on its heartbeat without +touching its slot. + +Recovery SHALL respect per-node slot addressing: only a node whose OWN task +is terminal advances; a sibling parked on an open task re-suspends with its +slot intact. + +The heartbeat is the recovery bound for the missed-signal cases, and no +second delivery mechanism SHALL be added for them: a completion that raced +the suspension (the run was not yet suspended when the signal was +attempted) and a task concluded by a task sequence both leave the task row +terminal, which the re-read observes within one heartbeat period. + +#### Scenario: A refused signal is recovered on the next heartbeat + +- **GIVEN** a run suspended on a user task whose completion signal was + refused, so the run never heard about the completion +- **WHEN** the run's heartbeat (`resume_at`) fires +- **THEN** the run MUST advance with the task's outcome on its items, + attributed to the task's completer +- **AND** no new task is created + +#### Scenario: A still-open task re-suspends unchanged + +- **GIVEN** a run suspended on a user task that is still open +- **WHEN** the heartbeat fires +- **THEN** the run suspends again on the same task, with the node's slot + (task uuid, askedAt) unchanged + +#### Scenario: Only the addressed node's slot recovers + +- **GIVEN** a run suspended on two user-task nodes, of which only one task + is terminal +- **WHEN** the heartbeat fires +- **THEN** the node whose task ended applies its outcome and advances its + branch +- **AND** the sibling re-suspends with its own slot intact + +### Requirement: A heartbeat-recovered delivery is recorded on the task's audit + +When a node applies a terminal task's outcome on a wake that carried no +signal — the completion's wake was refused or lost, and the heartbeat is +what recovered it — the engine SHALL record a `heartbeat-recovered` entry +on the task's audit trail, attributed to the task's `completedBy`. The +guarded signal seam already records the refusal; this entry is the other +half of that trail, so a recovered answer never reads as one that vanished. + +Recording SHALL be best-effort: a failure to write the audit entry MUST NOT +fail the recovery itself. + +A completion that arrived on its signal is the ordinary path and SHALL NOT +be recorded as a recovery. + +#### Scenario: The recovery is audited to the completer + +- **GIVEN** a suspended run whose awaited task was completed by a performer + while the completion signal never reached the run +- **WHEN** the heartbeat applies the outcome +- **THEN** the task's audit trail holds a `heartbeat-recovered` entry naming + that performer as actor + +#### Scenario: A signal-delivered completion records no recovery + +- **GIVEN** a suspended run woken by its task's completion signal +- **WHEN** the node applies the outcome +- **THEN** no `heartbeat-recovered` entry is written diff --git a/openspec/changes/flow-heartbeat-recovery/tasks.md b/openspec/changes/flow-heartbeat-recovery/tasks.md new file mode 100644 index 0000000000..bfe33b02ff --- /dev/null +++ b/openspec/changes/flow-heartbeat-recovery/tasks.md @@ -0,0 +1,37 @@ +# Tasks: flow-heartbeat-recovery + +## 1. Keep the slots alive + +- [x] 1.1 `FlowResumeState::storableWhen()` keeps the per-node slots for any + non-terminal status and drops them only on a terminal one; parameter + renamed `suspended` → `live` so the call site states the rule. +- [x] 1.2 `FlowRunService::persistResult()` derives liveness from + `FlowRun::TERMINAL` and passes it through, with a comment naming the + wedge the old `suspended`-only rule caused. + +## 2. Record the recovery + +- [x] 2.1 `FlowTaskBridge::recordHeartbeatRecovery()`: append a + `heartbeat-recovered` entry on the task's audit via + `TaskService::record()`, actor = the task's `completedBy`, reason + naming the run; log the recovery; swallow and log an audit failure. +- [x] 2.2 `UserTaskNode::execute()`: on a terminal read with no + `context['signal']`, call `recordHeartbeatRecovery()` before applying + the outcome, which stays identical on both paths. +- [x] 2.3 `PortalTaskNode::execute()`: the same call on its first pass over + a terminal task — the two nodes share the wedge. + +## 3. Prove it + +- [x] 3.1 `tests/Unit/Service/Flow/FlowHeartbeatRecoveryTest.php` — the + wedge reproduction driven through the real engine, dispatcher, node, + stream walk, claims and commit over in-memory mappers: + the in-request advance keeps the sibling's slot (RED on the unfixed + `storableWhen`), the heartbeat recovers a refused signal with + attribution, a still-open task re-parks unchanged, and only the + addressed node's slot recovers. +- [x] 3.2 `UserTaskNodeTest` — a heartbeat-recovered completion is audited + to the completer; a signal-delivered completion records no recovery. +- [x] 3.3 `PortalTaskNodeTest` — the same pair for the portal node. +- [x] 3.4 `FlowResumeStateTest` — `storableWhen()` keeps slots while live, + drops them on terminal, stores nothing when empty. diff --git a/tests/Unit/Service/Flow/FlowHeartbeatRecoveryTest.php b/tests/Unit/Service/Flow/FlowHeartbeatRecoveryTest.php new file mode 100644 index 0000000000..17ed44d8a7 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowHeartbeatRecoveryTest.php @@ -0,0 +1,514 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowClaim; +use OCA\OpenRegister\Db\FlowClaimMapper; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowRunStep; +use OCA\OpenRegister\Db\FlowRunStepMapper; +use OCA\OpenRegister\Db\FlowStream; +use OCA\OpenRegister\Db\FlowStreamMapper; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Flow\FlowDefinitionBuilder; +use OCA\OpenRegister\Service\Flow\FlowEngine; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; +use OCA\OpenRegister\Service\Flow\FlowPlaceClaims; +use OCA\OpenRegister\Service\Flow\FlowRunCommit; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\FlowTaskBridge; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; +use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use OCA\OpenRegister\Service\Task\TaskForm; +use OCA\OpenRegister\Service\Task\TaskFormReader; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\IURLGenerator; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + +/** A subject carrying nothing — the marking lives on the run. */ +class HeartbeatSubject { +} + +/** A step that passes items through, to split the graph. */ +class HeartbeatPassNode implements IFlowNode { + + public function getId(): string { + return 'test.pass'; + } + + public function getDisplayName(): string { + return 'Pass'; + } + + public function getDescription(): string { + return 'Passes items through.'; + } + + public function getIcon(): string { + return 'i.svg'; + } + + public function isAvailableForScope(int $scope): bool { + return true; + } + + public function validateConfig(array $config): void { + } + + public function execute(array $items, array $config, array $context): array { + return $items; + } +}//end class + +/** + * The wedge, reproduced and recovered. + */ +class FlowHeartbeatRecoveryTest extends TestCase { + use PublishedVersionDouble; + + private FlowRunService $service; + + /** + * The tasks the bridge "persisted", by uuid. + * + * @var array + */ + private array $tasks = []; + + /** + * Node ids handed to createTask, in call order. Growing past one entry + * per user-task node is the duplicate-task defect. + * + * @var array + */ + private array $created = []; + + /** + * Task uuids handed to recordHeartbeatRecovery, in call order. + * + * @var array + */ + private array $recovered = []; + + /** + * The "database": one run row, its streams, its claims. + * + * @var FlowRun|null + */ + private ?FlowRun $row = null; + + /** @var array */ + private array $streams = []; + + /** @var array */ + private array $claims = []; + + protected function setUp(): void { + parent::setUp(); + $mapper = $this->createMock(FlowRunMapper::class); + $mapper->method('insert')->willReturnCallback(function (FlowRun $run): FlowRun { + $this->row = $run; + return $run; + }); + $mapper->method('update')->willReturnCallback(function (FlowRun $run): FlowRun { + $this->row = $run; + return $run; + }); + $mapper->method('lockByUuid')->willReturnCallback(fn (): FlowRun => $this->row); + + $bridge = $this->createMock(FlowTaskBridge::class); + $bridge->method('createTask')->willReturnCallback(function (array $data, string $runUuid, string $nodeId, ?string $actor): Task { + $this->created[] = $nodeId; + $uuid = sprintf('t-%s-%d', $nodeId, count($this->created)); + $task = new Task(); + $task->setUuid($uuid); + $task->setState(Task::STATE_ACTIVE); + $task->setAssignee('alice'); + $task->setRunUuid($runUuid); + $task->setNodeId($nodeId); + $this->tasks[$uuid] = $task; + + return $task; + }); + $bridge->method('taskOrNull')->willReturnCallback(fn (string $uuid): ?Task => ($this->tasks[$uuid] ?? null)); + $bridge->method('recordHeartbeatRecovery')->willReturnCallback(function (Task $task): void { + $this->recovered[] = (string)$task->getUuid(); + }); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $forms = $this->createMock(TaskFormReader::class); + $forms->method('fromConfig')->willReturn(new TaskForm(kind: null)); + + $node = new UserTaskNode( + $bridge, + $l10n, + $this->createMock(IURLGenerator::class), + $forms, + $this->createMock(FlowTimerService::class) + ); + + $pass = new HeartbeatPassNode(); + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (Event $event) use ($node, $pass): void { + if ($event instanceof RegisterFlowNodesEvent) { + $event->registerNode($node); + $event->registerNode($pass); + } + } + ); + + $registry = new FlowNodeRegistry($dispatcher, $this->createMock(LoggerInterface::class)); + $engine = new FlowEngine(new FlowDefinitionBuilder(), $this->createMock(LoggerInterface::class)); + + $container = $this->createMock(ContainerInterface::class); + $versions = $this->publishedVersionMapper(); + $pin = $this->pinReturning(); + $container->method('get')->willReturnCallback( + function (string $id) use ($versions, $pin): object { + if ($id === \OCA\OpenRegister\Db\FlowVersionMapper::class) { + return $versions; + } + + if ($id === \OCA\OpenRegister\Service\Flow\FlowDefinitionPin::class) { + return $pin; + } + + throw new \RuntimeException('not available'); + } + ); + + $db = $this->createMock(IDBConnection::class); + $db->method('inTransaction')->willReturn(false); + + $streamMapper = $this->createMock(FlowStreamMapper::class); + $streamMapper->method('findByRun')->willReturnCallback(function (): array { + $list = array_values($this->streams); + usort($list, static fn (FlowStream $a, FlowStream $b): int => strcmp((string)$a->getOrdinalPath(), (string)$b->getOrdinalPath())); + return $list; + }); + $streamMapper->method('findByRunAndStream')->willReturnCallback(fn (string $runUuid, string $streamId): ?FlowStream => ($this->streams[$streamId] ?? null)); + $streamMapper->method('insert')->willReturnCallback(function (FlowStream $stream): FlowStream { + $this->streams[(string)$stream->getStreamId()] = $stream; + return $stream; + }); + $streamMapper->method('update')->willReturnCallback(function (FlowStream $stream): FlowStream { + $this->streams[(string)$stream->getStreamId()] = $stream; + return $stream; + }); + $streamMapper->method('allocateNextSequence')->willReturnCallback(function (string $runUuid, string $streamId): int { + $stream = ($this->streams[$streamId] ?? null); + if ($stream === null) { + return 0; + } + + $next = (int)$stream->getNextSequence(); + $stream->setNextSequence($next + 1); + return $next; + }); + + $claimMapper = $this->createMock(FlowClaimMapper::class); + $claimMapper->method('countHeldForRun')->willReturn(0); + $claimMapper->method('countHeldByOwner')->willReturn(0); + $claimMapper->method('insertOrRefuse')->willReturnCallback(function (FlowClaim $claim): bool { + $this->claims[] = $claim; + return true; + }); + $claimMapper->method('findByRun')->willReturnCallback(fn (): array => array_values($this->claims)); + $claimMapper->method('release')->willReturnCallback(function (string $runUuid, array $places): int { + $before = count($this->claims); + $this->claims = array_values(array_filter($this->claims, static fn (FlowClaim $c): bool => in_array($c->getPlace(), $places, true) === false)); + return ($before - count($this->claims)); + }); + $claimMapper->method('releaseByOwner')->willReturnCallback(function (string $runUuid, string $owner): int { + $before = count($this->claims); + $this->claims = array_values(array_filter($this->claims, static fn (FlowClaim $c): bool => $c->getOwner() !== $owner)); + return ($before - count($this->claims)); + }); + + $stepMapper = $this->createMock(FlowRunStepMapper::class); + $stepMapper->method('highestSequence')->willReturn(0); + $stepMapper->method('insert')->willReturnCallback(static fn (FlowRunStep $step): FlowRunStep => $step); + + $commit = new FlowRunCommit( + db: $db, + runs: $mapper, + streams: $streamMapper, + claims: $claimMapper, + steps: $stepMapper, + logger: new NullLogger() + ); + + $this->service = new FlowRunService( + $mapper, + $this->createMock(\OCA\OpenRegister\Db\FlowStateMapper::class), + $engine, + $registry, + $this->createMock(LoggerInterface::class), + $container, + null, + null, + $streamMapper, + new FlowPlaceClaims(claims: $claimMapper, db: $db, logger: new NullLogger()), + $commit + ); + }//end setUp() + + /** + * A split into two parallel user-task branches — the shape whose sibling + * completion ends a pass `queued` and used to drop the other slot. + * + * @return array The flow document. + */ + private function flow(): array { + return [ + 'id' => 'f1', + 'nodes' => [ + ['id' => 'start', 'type' => 'test.pass'], + [ + 'id' => 'askA', + 'type' => 'openregister.user-task', + 'config' => ['title' => 'Approve A', 'assignee' => 'alice', 'heartbeatMinutes' => 30], + ], + [ + 'id' => 'askB', + 'type' => 'openregister.user-task', + 'config' => ['title' => 'Approve B', 'assignee' => 'alice', 'heartbeatMinutes' => 30, 'outcomeKey' => 'taskB'], + ], + ], + 'edges' => [ + ['id' => 'e1', 'from' => 'start', 'to' => 'askA'], + ['id' => 'e2', 'from' => 'start', 'to' => 'askB'], + ], + ]; + }//end flow() + + /** + * Park both branches on their freshly created tasks. + * + * @return FlowRun The suspended run. + */ + private function suspendedOnBothTasks(): FlowRun { + $run = $this->service->queue('f1', user: 'alice'); + $run = $this->service->execute( + $run, + $this->flow(), + new HeartbeatSubject(), + seedItems: [FlowItems::item(json: ['name' => 'Case 7'])] + ); + + $this->assertSame(FlowRun::STATUS_SUSPENDED, $run->getStatus()); + $this->assertSame(['askA', 'askB'], $this->created); + + return $run; + }//end suspendedOnBothTasks() + + /** + * Mark a task terminal, as its completion verb would have left it. + * + * @param string $uuid The task. + * @param string $completedBy Who answered. + * + * @return void + */ + private function complete(string $uuid, string $completedBy): void { + $this->tasks[$uuid]->setState(Task::STATE_COMPLETED); + $this->tasks[$uuid]->setIsTerminal(true); + $this->tasks[$uuid]->setOutcome('approved'); + $this->tasks[$uuid]->setCompletedBy($completedBy); + }//end complete() + + /** + * The live stream whose token stands on a place. + * + * @param string $place The place. + * + * @return string The stream id. + */ + private function streamOn(string $place): string { + foreach ($this->streams as $stream) { + if ((string)$stream->getPlace() === $place && $stream->isTerminal() === false) { + return (string)$stream->getStreamId(); + } + } + + $this->fail(sprintf('No live stream stands on place "%s".', $place)); + }//end streamOn() + + /** + * 🔴 THE WEDGE'S ROOT CAUSE, proven red before the fix: an in-request + * advance of one branch ends the pass `queued` while the sibling branch + * still has enabled work, and that pass end used to DROP the sibling's + * resume slot — the uuid of the task it was waiting on. From there the + * sibling asked again on its next wake, the original task's completion + * signal was refused against the new slot's assignee, and the run rolled + * its heartbeat forever. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-live-run-keeps-every-parked-nodes-resume-slot + */ + public function testAnInRequestAdvanceKeepsTheSiblingNodesParkedSlot(): void { + $run = $this->suspendedOnBothTasks(); + $slots = ($run->getContext()['resumeState'] ?? []); + $taskB = (string)($slots['askB']['taskUuid'] ?? ''); + $this->assertNotSame('', $taskB); + + // Task A completes; its completion signals the run and spends the + // node's advance budget in-request, as FlowTaskBridge::continueRun() + // does. Branch B still has enabled work, so this pass ends `queued`. + $this->complete(uuid: (string)$slots['askA']['taskUuid'], completedBy: 'bob'); + $woken = $this->service->signal($run, []); + $this->assertNotNull($woken); + $run = $this->service->advanceStream($woken, $this->flow(), new HeartbeatSubject(), $this->streamOn('askA'), 'all'); + + $this->assertSame(FlowRun::STATUS_QUEUED, $run->getStatus()); + $kept = ($run->getContext()['resumeState'] ?? []); + $this->assertSame( + $taskB, + (string)($kept['askB']['taskUuid'] ?? ''), + 'a queued pass end must keep the sibling\'s parked slot, or the heartbeat loses the task it is waiting on' + ); + + // A signal-delivered completion is not a heartbeat recovery. + $this->assertSame([], $this->recovered); + + // The worker's next pass re-parks branch B on the SAME task — never a + // duplicate in somebody's inbox. + $run = $this->service->execute($run, $this->flow(), new HeartbeatSubject()); + $this->assertSame(FlowRun::STATUS_SUSPENDED, $run->getStatus()); + $this->assertSame(['askA', 'askB'], $this->created, 'a wake must never create a second task for a parked node'); + $this->assertSame($taskB, (string)($run->getContext()['resumeState']['askB']['taskUuid'] ?? '')); + }//end testAnInRequestAdvanceKeepsTheSiblingNodesParkedSlot() + + /** + * The heartbeat's whole reason to exist: a completion whose signal was + * refused or lost is recovered on the next wake — the node re-reads its + * task, applies the outcome exactly as the signal path would have (same + * bag under `json.`, same advance), and the recovery is + * recorded on the task's audit attributed to its completer. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-wake-re-reads-the-awaited-task-and-applies-a-terminal-outcome + */ + public function testTheHeartbeatRecoversACompletionWhoseSignalWasRefused(): void { + $run = $this->suspendedOnBothTasks(); + $slots = ($run->getContext()['resumeState'] ?? []); + $taskA = (string)$slots['askA']['taskUuid']; + $taskB = (string)$slots['askB']['taskUuid']; + + // Both tasks complete, but NO signal ever reaches the run — the + // observed case: the assignee guard refused the delivery. + $this->complete(uuid: $taskA, completedBy: 'bob'); + $this->complete(uuid: $taskB, completedBy: 'carol'); + + // The heartbeat fires: findDue() → advance() → execute(). + $run = $this->service->execute($run, $this->flow(), new HeartbeatSubject()); + + $this->assertSame(FlowRun::STATUS_COMPLETED, $run->getStatus()); + $bagA = ($run->getItems()[0]['json']['task'] ?? null); + $bagB = ($run->getItems()[0]['json']['taskB'] ?? null); + $this->assertSame('approved', ($bagA['outcome'] ?? ($bagB['outcome'] ?? null))); + $this->assertSame(['askA', 'askB'], $this->created, 'recovery must never create a task'); + $this->assertEqualsCanonicalizing([$taskA, $taskB], $this->recovered, 'each recovered delivery is audited, attributed to its completer'); + }//end testTheHeartbeatRecoversACompletionWhoseSignalWasRefused() + + /** + * A heartbeat that finds the task still open is a re-suspend, not an + * answer: same task, same slot, no audit entry, still suspended. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-wake-re-reads-the-awaited-task-and-applies-a-terminal-outcome + */ + public function testAHeartbeatWakeWithTheTasksStillOpenParksAgainOnTheSameTasks(): void { + $run = $this->suspendedOnBothTasks(); + $before = ($run->getContext()['resumeState'] ?? []); + + $run = $this->service->execute($run, $this->flow(), new HeartbeatSubject()); + + $this->assertSame(FlowRun::STATUS_SUSPENDED, $run->getStatus()); + $this->assertSame(['askA', 'askB'], $this->created); + $this->assertSame([], $this->recovered); + $after = ($run->getContext()['resumeState'] ?? []); + $this->assertSame($before['askA']['taskUuid'], $after['askA']['taskUuid']); + $this->assertSame($before['askB']['taskUuid'], $after['askB']['taskUuid']); + $this->assertSame($before['askA']['askedAt'], $after['askA']['askedAt'], 'a heartbeat must not restamp askedAt'); + }//end testAHeartbeatWakeWithTheTasksStillOpenParksAgainOnTheSameTasks() + + /** + * Per-node slot addressing holds through a recovery: only the node whose + * task ended advances; its sibling re-parks on its own task, slot intact. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-wake-re-reads-the-awaited-task-and-applies-a-terminal-outcome + */ + public function testOnlyTheNodeWhoseTaskEndedRecovers(): void { + $run = $this->suspendedOnBothTasks(); + $slots = ($run->getContext()['resumeState'] ?? []); + $taskA = (string)$slots['askA']['taskUuid']; + $taskB = (string)$slots['askB']['taskUuid']; + + // Only task A completed, and its signal never arrived. + $this->complete(uuid: $taskA, completedBy: 'bob'); + + $run = $this->service->execute($run, $this->flow(), new HeartbeatSubject()); + + $this->assertSame(FlowRun::STATUS_SUSPENDED, $run->getStatus(), 'branch B still waits'); + $this->assertSame([$taskA], $this->recovered, 'only the addressed node\'s slot recovers'); + $this->assertSame(['askA', 'askB'], $this->created); + $kept = ($run->getContext()['resumeState'] ?? []); + $this->assertArrayNotHasKey('askA', $kept, 'a node that answered has nothing left to remember'); + $this->assertSame($taskB, (string)($kept['askB']['taskUuid'] ?? ''), 'the waiting sibling keeps its own task'); + }//end testOnlyTheNodeWhoseTaskEndedRecovers() +}//end class diff --git a/tests/Unit/Service/Flow/FlowResumeStateTest.php b/tests/Unit/Service/Flow/FlowResumeStateTest.php index 70d05a202a..3c0f893da4 100644 --- a/tests/Unit/Service/Flow/FlowResumeStateTest.php +++ b/tests/Unit/Service/Flow/FlowResumeStateTest.php @@ -156,6 +156,45 @@ public function testFromArrayPassesAnExistingStateThrough(): void { $this->assertSame($state, FlowResumeState::fromArray($state)); } + /** + * Slots survive every pass end the run can still advance from — a pass + * that ends `queued` (an in-request advance whose sibling has enabled + * work, a claim refused on contention) must not cost a parked node the + * uuid of the task it is waiting on. Dropping it there was the heartbeat + * wedge: the node asked again, and the original task's completion could + * never address the slot again. + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-live-run-keeps-every-parked-nodes-resume-slot + */ + public function testSlotsAreStorableWhileTheRunIsLive(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: 'taskUuid', value: 't-1'); + + $this->assertSame(['ask' => ['taskUuid' => 't-1']], $state->storableWhen(live: true)); + } + + /** + * A terminal run drops its slots: anything still held belongs to a node + * the run never came back to, and keeping it would put a stale cursor in + * front of anyone reading the finished run. + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-live-run-keeps-every-parked-nodes-resume-slot + */ + public function testATerminalRunDropsItsSlots(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: 'taskUuid', value: 't-1'); + + $this->assertNull($state->storableWhen(live: false)); + } + + /** + * Nothing held is nothing stored, live or not — an empty bag must not + * write an empty key into every run's context. + */ + public function testAnEmptyStateStoresNothing(): void { + $this->assertNull((new FlowResumeState())->storableWhen(live: true)); + } + /** * The scoped view is what a node is handed, and it must not be able to * name another node's slot: there is no API on it that takes a node id. diff --git a/tests/Unit/Service/Flow/PortalTaskNodeTest.php b/tests/Unit/Service/Flow/PortalTaskNodeTest.php index 7d4a9ac8ae..36df0d9936 100644 --- a/tests/Unit/Service/Flow/PortalTaskNodeTest.php +++ b/tests/Unit/Service/Flow/PortalTaskNodeTest.php @@ -34,6 +34,7 @@ use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; use OCA\OpenRegister\Service\Flow\FlowResumeState; use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\FlowTaskBridge; use OCA\OpenRegister\Service\Flow\Nodes\PortalTaskConfig; @@ -60,6 +61,7 @@ * @uses \OCA\OpenRegister\Service\Flow\FlowAdvanceBudget * @uses \OCA\OpenRegister\Service\Flow\FlowItems * @uses \OCA\OpenRegister\Service\Flow\FlowTaskBridge + * @uses \OCA\OpenRegister\Service\Flow\FlowRunService * @uses \OCA\OpenRegister\Service\Flow\FlowValueTemplate * @uses \OCA\OpenRegister\Service\Task\TaskState * @uses \OCA\OpenRegister\Db\PortalTaskDelivery @@ -467,6 +469,50 @@ public function testACompletedTaskPlacesTheAnswerOnEveryItemAndMarksThePass(): v $this->assertNotNull($state->read(nodeId: 'ask')[PortalTaskConfig::SLOT_PASSED_AT], 'the pass is marked so the next firing is a re-entry'); }//end testACompletedTaskPlacesTheAnswerOnEveryItemAndMarksThePass() + /** + * A terminal read with no signal in hand is the heartbeat recovering a + * missed wake, and the recovery lands on the task's audit — the same call + * the user-task node makes, because the two share the wedge. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-recovered-delivery-is-recorded-on-the-tasks-audit + */ + public function testAHeartbeatRecoveredAnswerIsAuditedOnTheTask(): void { + $state = new FlowResumeState(); + $state->write(nodeId: 'ask', values: [FlowTaskBridge::SLOT_TASK_UUID => 't-1', PortalTaskConfig::SLOT_CYCLE => 1]); + $task = $this->task(state: Task::STATE_COMPLETED); + $task->setOutcome('submitted'); + $task->setCompletedBy('party:bsn-1'); + $this->bridge->method('taskOrNull')->willReturn($task); + $this->bridge->expects($this->once())->method('recordHeartbeatRecovery')->with($task); + + $this->node->execute($this->items(), $this->config(), $this->context($state)); + }//end testAHeartbeatRecoveredAnswerIsAuditedOnTheTask() + + /** + * An answer that arrived on its signal is the ordinary path: no recovery + * entry lands on the task's audit. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-recovered-delivery-is-recorded-on-the-tasks-audit + */ + public function testASignalDeliveredAnswerRecordsNoHeartbeatRecovery(): void { + $state = new FlowResumeState(); + $state->write(nodeId: 'ask', values: [FlowTaskBridge::SLOT_TASK_UUID => 't-1', PortalTaskConfig::SLOT_CYCLE => 1]); + $task = $this->task(state: Task::STATE_COMPLETED); + $task->setOutcome('submitted'); + $this->bridge->method('taskOrNull')->willReturn($task); + $this->bridge->expects($this->never())->method('recordHeartbeatRecovery'); + + $this->node->execute( + $this->items(), + $this->config(), + $this->context($state, extra: [FlowRunService::SIGNAL_CONTEXT_KEY => []]) + ); + }//end testASignalDeliveredAnswerRecordsNoHeartbeatRecovery() + /** * An expiry-terminated task continues the run distinguishably from an answer. * diff --git a/tests/Unit/Service/Flow/UserTaskNodeTest.php b/tests/Unit/Service/Flow/UserTaskNodeTest.php index 6260e97d44..3ef0647533 100644 --- a/tests/Unit/Service/Flow/UserTaskNodeTest.php +++ b/tests/Unit/Service/Flow/UserTaskNodeTest.php @@ -461,6 +461,50 @@ public function testACompletedTaskContinuesWithTheOutcomeOnEveryItem(): void { } }//end testACompletedTaskContinuesWithTheOutcomeOnEveryItem() + /** + * A terminal read with no signal in hand is the heartbeat recovering a + * missed wake — the completion's signal was refused or lost — and that + * recovery is recorded on the task's audit, attributed to its completer. + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-recovered-delivery-is-recorded-on-the-tasks-audit + */ + public function testAHeartbeatRecoveredCompletionIsAuditedOnTheTask(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $done = $this->task(state: Task::STATE_COMPLETED); + $done->setOutcome('approved'); + $done->setCompletedBy('bob'); + $this->bridge->method('taskOrNull')->willReturn($done); + $this->bridge->expects($this->once())->method('recordHeartbeatRecovery')->with($done); + + $out = $this->node->execute($this->items(), $this->config(), $this->context($state)); + + $this->assertSame('approved', $out[0][FlowItems::JSON]['task']['outcome'], 'the recovery applies the outcome exactly as the signal path would'); + }//end testAHeartbeatRecoveredCompletionIsAuditedOnTheTask() + + /** + * A completion whose signal DID arrive is the ordinary path, not a + * recovery: nothing extra lands on the task's audit. + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-recovered-delivery-is-recorded-on-the-tasks-audit + */ + public function testASignalDeliveredCompletionRecordsNoHeartbeatRecovery(): void { + $state = new FlowResumeState(); + $state->forNode(nodeId: 'ask')->set(key: FlowTaskBridge::SLOT_TASK_UUID, value: 't-1'); + $done = $this->task(state: Task::STATE_COMPLETED); + $done->setOutcome('approved'); + $this->bridge->method('taskOrNull')->willReturn($done); + $this->bridge->expects($this->never())->method('recordHeartbeatRecovery'); + + $out = $this->node->execute( + $this->items(), + $this->config(), + $this->context($state, extra: [FlowRunService::SIGNAL_CONTEXT_KEY => []]) + ); + + $this->assertSame('approved', $out[0][FlowItems::JSON]['task']['outcome']); + }//end testASignalDeliveredCompletionRecordsNoHeartbeatRecovery() + /** * A delegated completion names both identities: the deputy who acted and * the person they acted for. A four-eyes rule cannot be enforced without From f3f4abcd11907cd03b865fa9ca856e1a33a98fb5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 12:32:31 +0200 Subject: [PATCH 64/68] feat(tasks): find the newest sequence for a template, across every anchor (#3360) The mapper could answer "what happened to THIS object" three ways and "has this approval ever run" not at all. Every existing finder constrains anchor_object_uuid, so an aggregate over a template had no anchor to pass. buildiq is the caller that needed it. Its automation dry-run panel used to read the newest ApprovalStep on a chain; when #3302 retired that surface there was no replacement for the aggregate, so the panel degraded to reporting nothing at all (buildiq#651). This restores it. One row off the existing template_id index, ordered like its siblings and limited in the QUERY rather than trimmed in PHP. Tested with a negative control, not just a green run: adding an anchor predicate and dropping the limit makes the new test fail with "Failed asserting that an array does not contain 'anchor_object_uuid'". A finder that quietly constrained the anchor would answer a different question than the caller asked, which is the one thing this method must not do. Co-authored-by: Claude Opus 5 (1M context) --- lib/Db/TaskSequenceMapper.php | 37 +++++++++++++++++++ .../Unit/Db/TaskSequenceMapperQueriesTest.php | 26 +++++++++++++ 2 files changed, 63 insertions(+) diff --git a/lib/Db/TaskSequenceMapper.php b/lib/Db/TaskSequenceMapper.php index 0b9cc5f142..ad0b51a176 100644 --- a/lib/Db/TaskSequenceMapper.php +++ b/lib/Db/TaskSequenceMapper.php @@ -98,6 +98,43 @@ public function findRunning(string $anchorObjectUuid, string $templateId): ?Task return ($rows[0] ?? null); }//end findRunning() + /** + * The newest sequence opened from a template, across EVERY anchor. + * + * The other finders here answer "what happened to THIS object". This one + * answers "has this approval ever run", which is what a designer surface + * needs to report a template's last outcome without knowing an object to + * ask about. + * + * buildiq's automation dry-run panel is the caller: it used to read the + * newest ApprovalStep on the chain, and when #3302 retired that surface the + * per-anchor finders could not replace it — an aggregate over the template + * has no anchor to pass. Without this the panel degraded to reporting + * nothing at all (buildiq#651). + * + * Indexed by `template_id` and ordered like its siblings, so it is one row + * off the same index rather than a scan. + * + * @param string $templateId The compiled template id. + * + * @return TaskSequence|null The newest sequence for the template, or null when none has run. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-an-approval-is-an-ordered-task-sequence-with-o + */ + public function findNewestForTemplate(string $templateId): ?TaskSequence { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('template_id', $qb->createNamedParameter($templateId))) + ->orderBy('opened_at', 'DESC') + ->addOrderBy('id', 'DESC') + ->setMaxResults(1); + + $rows = $this->findEntities(query: $qb); + + return ($rows[0] ?? null); + }//end findNewestForTemplate() + /** * Every sequence for an anchor and template, newest first. * diff --git a/tests/Unit/Db/TaskSequenceMapperQueriesTest.php b/tests/Unit/Db/TaskSequenceMapperQueriesTest.php index b8ab357430..c35910e2d7 100644 --- a/tests/Unit/Db/TaskSequenceMapperQueriesTest.php +++ b/tests/Unit/Db/TaskSequenceMapperQueriesTest.php @@ -76,6 +76,32 @@ public function testFindNewestForAnchorOrdersByOpenTimeDescending(): void { self::assertNotSame([], $orderings, 'history must be explicitly ordered, never id-lucky'); }//end testFindNewestForAnchorOrdersByOpenTimeDescending() + public function testFindNewestForTemplateDoesNotFilterOnAnAnchor(): void { + $mapper = new TaskSequenceMapper(db: $this->connectionWith(rows: [$this->row('seq-9', 'rejected')])); + + $newest = $mapper->findNewestForTemplate(templateId: 'tpl-1'); + + self::assertSame('seq-9', $newest->getUuid()); + + // The whole point of this finder: it aggregates ACROSS anchors, so an + // anchor predicate would silently answer a different question than the + // caller asked. Its siblings all constrain the anchor; this one must not. + $predicates = array_filter($this->calls, static fn (array $call): bool => $call[0] === 'expr.eq'); + $columns = array_map(static fn (array $call): mixed => $call[1], $predicates); + self::assertContains('template_id', $columns); + self::assertNotContains('anchor_object_uuid', $columns, 'a template-wide finder must not constrain the anchor'); + + // One row off the index, not a scan the caller trims. + $limits = array_filter($this->calls, static fn (array $call): bool => $call[0] === 'setMaxResults'); + self::assertNotSame([], $limits, 'the newest row must be taken by the query, not in PHP'); + }//end testFindNewestForTemplateDoesNotFilterOnAnAnchor() + + public function testFindNewestForTemplateWithNoRunIsNull(): void { + $mapper = new TaskSequenceMapper(db: $this->connectionWith(rows: [])); + + self::assertNull($mapper->findNewestForTemplate(templateId: 'tpl-1')); + }//end testFindNewestForTemplateWithNoRunIsNull() + public function testATerminalStatusReadsAsTerminal(): void { $sequence = new TaskSequence(); $sequence->setStatus(TaskSequence::STATUS_REJECTED); From b8eb72e2769290b33146c0b14d58c4405123c462 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 12:58:00 +0200 Subject: [PATCH 65/68] test(flow): cover the heartbeat recovery paths that no test executed (#3362) The changed-files coverage ratchet on #3358 was right, and it was pointing at something real rather than at a percentage. Of the 32 statements that change added, 30 were the body of `FlowTaskBridge::recordHeartbeatRecovery()` -- and every test that exercised the recovery MOCKED FlowTaskBridge, because in those tests the nodes are the unit. So the method that writes the recovery's audit trail had no execution coverage at all: the audit entry, its attribution, and the catch that makes it best-effort were asserted nowhere. That matters more than the percentage does. The guarded signal seam records a refusal; this entry is the other half of that trail, and a silent regression in it would make a recovered answer read as one that vanished. Three tests through the REAL bridge, in the suite that already builds one: - the entry is recorded as `heartbeat-recovered`, attributed to the task's completedBy, with a reason naming the run whose signal never arrived; - an audit write that THROWS is swallowed, because the recovery is the node applying the outcome and letting the failure out would abort the very walk that was un-wedging the run; - an ending nobody answered (terminated, expired -- `completedBy` is null on exactly those) records no actor rather than a guessed one. And one test for the symmetric case the change documented but left unpinned: a completion that RACED the suspension. `signal()` refuses a run that is not suspended, so that wake is lost with nothing to retry it; the test asserts the refusal, asserts the run parks on a non-null heartbeat, and asserts the next wake recovers it. That is the whole basis for deciding the race needs no new mechanism, and it is now falsifiable. Every one of the four was checked by mutation -- breaking the action name, the attribution, the catch, or the recovery call itself turns each red. Co-authored-by: Claude Opus 5 (1M context) --- .../Flow/FlowHeartbeatRecoveryTest.php | 45 +++++++++ .../Unit/Service/Flow/FlowTaskBridgeTest.php | 99 +++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/tests/Unit/Service/Flow/FlowHeartbeatRecoveryTest.php b/tests/Unit/Service/Flow/FlowHeartbeatRecoveryTest.php index 17ed44d8a7..fc0f80b6b8 100644 --- a/tests/Unit/Service/Flow/FlowHeartbeatRecoveryTest.php +++ b/tests/Unit/Service/Flow/FlowHeartbeatRecoveryTest.php @@ -511,4 +511,49 @@ public function testOnlyTheNodeWhoseTaskEndedRecovers(): void { $this->assertArrayNotHasKey('askA', $kept, 'a node that answered has nothing left to remember'); $this->assertSame($taskB, (string)($kept['askB']['taskUuid'] ?? ''), 'the waiting sibling keeps its own task'); }//end testOnlyTheNodeWhoseTaskEndedRecovers() + + /** + * 🔴 THE SYMMETRIC CASE, PINNED: a task completed while the run was NOT yet + * suspended. `signal()` refuses any run that is not `suspended`, so that + * completion's wake is simply LOST — there is no queue for it, and nothing + * retries it. The design decided this needs no new mechanism, and this test + * is what makes that decision falsifiable: the lost wake must cost latency + * only, because the node parks on a NON-NULL heartbeat and the next wake + * re-reads the task. + * + * Were the heartbeat ever allowed to be null here, this run would be + * unreachable forever — `findDue()` never returns a run with a null + * `resume_at` — which is exactly the trap `UserTaskNode` documents. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-wake-re-reads-the-awaited-task-and-applies-a-terminal-outcome + */ + public function testACompletionThatRacedTheSuspensionIsRecoveredByTheHeartbeat(): void { + $run = $this->suspendedOnBothTasks(); + $slots = ($run->getContext()['resumeState'] ?? []); + $taskA = (string)$slots['askA']['taskUuid']; + + // THE RACE: the task completes while the run is still mid-walk. The + // completion listener calls signal(), which refuses a non-suspended + // run — so the wake is lost and nothing queues a retry of it. + $this->complete(uuid: $taskA, completedBy: 'bob'); + $run->setStatus(FlowRun::STATUS_RUNNING); + $this->assertNull( + $this->service->signal($run, []), + 'a run that is not suspended refuses the signal, so the completion wake is lost' + ); + + // The walk finishes and the run parks — on a heartbeat that is NEVER + // null, which is the only reason the lost wake is recoverable at all. + $run->setStatus(FlowRun::STATUS_SUSPENDED); + $this->assertNotNull($run->getResumeAt(), 'a task-waiting run must park on a clock, never on a signal alone'); + + // The next heartbeat re-reads the task and applies the outcome. + $run = $this->service->execute($run, $this->flow(), new HeartbeatSubject()); + + $this->assertSame([$taskA], $this->recovered, 'the raced completion is recovered, and audited as a recovery'); + $this->assertSame(['askA', 'askB'], $this->created, 'recovery never creates a task'); + }//end testACompletionThatRacedTheSuspensionIsRecoveredByTheHeartbeat() + }//end class diff --git a/tests/Unit/Service/Flow/FlowTaskBridgeTest.php b/tests/Unit/Service/Flow/FlowTaskBridgeTest.php index fda18e879b..a94003b97d 100644 --- a/tests/Unit/Service/Flow/FlowTaskBridgeTest.php +++ b/tests/Unit/Service/Flow/FlowTaskBridgeTest.php @@ -332,4 +332,103 @@ public function testTheBagSeparatesADecisionFromAnEnding(): void { $this->assertFalse($bag['rejected']); $this->assertSame(Task::STATE_TERMINATED, $bag['outcome'], 'an ending with no outcome reports its state'); }//end testTheBagSeparatesADecisionFromAnEnding() + + // ---- Heartbeat recovery ------------------------------------------------------- + + /** + * 🔴 THE OTHER HALF OF THE REFUSAL TRAIL. The guarded signal seam records + * that a completion was refused; without this entry the trail ends there + * and a recovered answer reads as one that vanished. Attributed to the + * task's COMPLETER, because the fact being recorded is that person's + * answer arriving late by poll — not the cron job acting. + * + * Driven through the REAL bridge. Every other test of this behaviour mocks + * FlowTaskBridge (the nodes are the unit there), so this method's body had + * no execution coverage at all until this test. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-recovered-delivery-is-recorded-on-the-tasks-audit + */ + public function testAHeartbeatRecoveryIsAuditedToTheTasksCompleter(): void { + $task = $this->terminalTask(); + $task->setCompletedBy('bob'); + + $seen = []; + $this->tasks->expects($this->once()) + ->method('record') + ->willReturnCallback( + function (string $uuid, string $action, ?string $actor, string $reason) use (&$seen, $task): Task { + $seen = ['uuid' => $uuid, 'action' => $action, 'actor' => $actor, 'reason' => $reason]; + + return $task; + } + ); + + $this->bridge->recordHeartbeatRecovery(task: $task); + + $this->assertSame('t-1', $seen['uuid']); + $this->assertSame('heartbeat-recovered', $seen['action']); + $this->assertSame('bob', $seen['actor'], 'the recovery is the completer\'s answer arriving, not the worker\'s'); + $this->assertStringContainsString('run-1', $seen['reason'], 'the reason names the run whose signal never arrived'); + }//end testAHeartbeatRecoveryIsAuditedToTheTasksCompleter() + + /** + * 🔴 BEST-EFFORT, AND THAT IS THE POINT. The recovery itself is the node + * applying the outcome; this entry only describes it. An audit write that + * fails must therefore NOT propagate — letting it out would abort the walk + * that was recovering the run and put the run straight back into the wedge + * this whole change exists to remove. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-recovered-delivery-is-recorded-on-the-tasks-audit + */ + public function testAFailedRecoveryAuditIsSwallowedSoTheRecoveredRunStands(): void { + $task = $this->terminalTask(); + $task->setCompletedBy('bob'); + + $this->tasks->expects($this->once()) + ->method('record') + ->willThrowException(new RuntimeException('the audit table is unavailable')); + + $this->bridge->recordHeartbeatRecovery(task: $task); + + // Reached only because nothing propagated: the recovery outlives its + // own audit failure. + $this->addToAssertionCount(1); + }//end testAFailedRecoveryAuditIsSwallowedSoTheRecoveredRunStands() + + /** + * A task that ended WITHOUT a completer — terminated or expired rather than + * answered — still records its recovery, with no actor rather than an + * invented one. `completedBy` is null on exactly those endings, and an + * audit that guessed a name there would be worse than one that admits it + * has none. + * + * @return void + * + * @spec openspec/changes/flow-heartbeat-recovery/specs/flow-heartbeat-recovery/spec.md#requirement-a-heartbeat-recovered-delivery-is-recorded-on-the-tasks-audit + */ + public function testARecoveredEndingWithNoCompleterRecordsNoActor(): void { + $task = $this->terminalTask(); + $task->setState(Task::STATE_TERMINATED); + $task->setCompletedBy(null); + + $actor = 'unset'; + $this->tasks->expects($this->once()) + ->method('record') + ->willReturnCallback( + function (string $uuid, string $action, ?string $seenActor, string $reason) use (&$actor, $task): Task { + $actor = $seenActor; + + return $task; + } + ); + + $this->bridge->recordHeartbeatRecovery(task: $task); + + $this->assertNull($actor, 'an ending nobody answered is recorded with no actor, never a guessed one'); + }//end testARecoveredEndingWithNoCompleterRecordsNoActor() + }//end class From 87816603faaabe2104035573f9dc074773f91059 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 13:30:48 +0200 Subject: [PATCH 66/68] feat(organisations): project the organisation as an object so a $ref can reach it (#3363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(organisations): project the organisation as an object so a $ref can reach it Several apps grew their own `organization` SCHEMA, and measuring one showed why. `publication.organization` and `catalog.organization` are declared as `{"format": "uuid", "$ref": "organization"}`. A $ref resolves against a SCHEMA, and OpenRegister's Organisation is an ENTITY with no object projection, so there was nothing for that reference to point at and each app declared its own copy. A schema slug is global per organisation, so those copies collide: `organization` is claimed by both opencatalogi and stackiq today. Adding the identity columns made reuse possible at the entity level and `organisations:adopt` moves the rows. Neither gives a leaf schema something to reference. This does. Not a new mechanism: OpenRegister already projects nc-user and nc-group exactly this way, and nc-group is even mapped to schema:Organization. The provider follows GroupObjectSourceProvider line for line, including the rule that absent and denied are indistinguishable. The schema is nc-prefixed for the reason the semantic map already gives for the app-gated rows: it must not collide with the leaf `organization` schemas it exists to replace, which keep working until each app migrates off. Three deliberate limits. It is READ-ONLY, because the authoritative record is the Organisation row and a write path here would be a second way to mutate a tenant that bypasses the organisation lifecycle. It carries the IDENTITY FACET ONLY, because quota, users, groups and authorization are tenancy administration and this schema exists so a record can reference an organisation rather than configure one. And it is SCOPED: an admin sees every organisation, anyone else only the ones they belong to, anonymous callers nothing, so the projection cannot be used to discover which tenants an instance has. A merged-away organisation is not listed, because it owns nothing and offering it invites a reference to an unusable target. find() still resolves THROUGH a merge, so a reference stored before one keeps working. One defect the tests caught and reading did not: project() first used method_exists($organisation, 'getName') before calling the getter. Organisation's accessors are magic, so method_exists is false for every one of them and the projection would have shipped carrying nothing but an id — a schema that resolves, returns objects, and is empty. Verified live: the object API returns the organisation with its identity facet, the same call unauthenticated returns total 0 rather than an error, find by uuid works, and seeding on a clean row creates the schema WITH its properties and links it to the directory register. Co-Authored-By: Claude Opus 5 (1M context) * docs(organisations): prove the premise, not just the projection Everything already verified showed the projection can be READ. None of it showed a $ref could reach it, which is the only reason it exists. A projection that reads fine and cannot be referenced would have passed every other check and been useless. Measured: publication.organization repointed to {"$ref": "nc-organisation"} returns the raw uuid plain, and inlines the organisation's identity facet under _extend. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Conduction Release Bot Co-authored-by: Claude Opus 5 (1M context) --- lib/AppInfo/Application.php | 14 + lib/Repair/SeedDirectoryVirtualSchemas.php | 24 ++ .../ObjectSource/NcEntitySemanticMap.php | 16 + .../OrganisationObjectSourceProvider.php | 375 ++++++++++++++++++ .../organisations-are-objects/proposal.md | 59 +++ .../specs/organisation-projection/spec.md | 81 ++++ .../organisations-are-objects/tasks.md | 64 +++ .../OrganisationObjectSourceProviderTest.php | 247 ++++++++++++ 8 files changed, 880 insertions(+) create mode 100644 lib/Service/ObjectSource/OrganisationObjectSourceProvider.php create mode 100644 openspec/changes/organisations-are-objects/proposal.md create mode 100644 openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md create mode 100644 openspec/changes/organisations-are-objects/tasks.md create mode 100644 tests/Unit/Service/ObjectSource/OrganisationObjectSourceProviderTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bcb3f795d3..73ff7a4171 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -222,6 +222,7 @@ use OCA\OpenRegister\Service\ObjectSource\FederatedObjectSourceProvider; use OCA\OpenRegister\Service\ObjectSource\FilesObjectSourceProvider; use OCA\OpenRegister\Service\ObjectSource\GroupObjectSourceProvider; +use OCA\OpenRegister\Service\ObjectSource\OrganisationObjectSourceProvider; use OCA\OpenRegister\Service\ObjectSource\ObjectSourceRegistry; use OCA\OpenRegister\Service\ObjectSource\TablesColumnMapper; use OCA\OpenRegister\Service\ObjectSource\TablesObjectSourceProvider; @@ -1474,6 +1475,18 @@ function (ContainerInterface $container) { } ); + $context->registerService( + OrganisationObjectSourceProvider::class, + function (ContainerInterface $container) { + return new OrganisationObjectSourceProvider( + organisationMapper: $container->get('OCA\OpenRegister\Db\OrganisationMapper'), + userSession: $container->get('OCP\IUserSession'), + groupManager: $container->get('OCP\IGroupManager'), + logger: $container->get('Psr\Log\LoggerInterface') + ); + } + ); + $context->registerService( ContactsObjectSourceProvider::class, function (ContainerInterface $container) { @@ -4436,6 +4449,7 @@ private function bootObjectSourceProviders($server): void { CalDavVtodoObjectSourceProvider::class, UserDirectoryObjectSourceProvider::class, GroupObjectSourceProvider::class, + OrganisationObjectSourceProvider::class, ContactsObjectSourceProvider::class, CalendarEventObjectSourceProvider::class, FilesObjectSourceProvider::class, diff --git a/lib/Repair/SeedDirectoryVirtualSchemas.php b/lib/Repair/SeedDirectoryVirtualSchemas.php index fcabacced1..a97fdf97bb 100644 --- a/lib/Repair/SeedDirectoryVirtualSchemas.php +++ b/lib/Repair/SeedDirectoryVirtualSchemas.php @@ -67,6 +67,30 @@ class SeedDirectoryVirtualSchemas implements IRepairStep { 'id' => ['type' => 'string', 'title' => 'Group ID', 'description' => 'The Nextcloud group id (gid).'], 'displayName' => ['type' => 'string', 'title' => 'Display name', 'description' => 'The group display name.'], ], + // The identity facet of an OpenRegister organisation, and nothing else. + // This set MUST stay in step with + // {@see \OCA\OpenRegister\Service\ObjectSource\OrganisationObjectSourceProvider}'s + // projection: a property declared here and not projected reads as + // permanently empty, and one projected but not declared is discarded by + // the store without a word. + // + // Tenancy administration (quota, users, groups, authorization) is + // deliberately absent. This schema exists so another record can REFERENCE + // an organisation, not so anyone can configure one through the object API. + 'nc-organisation' => [ + 'id' => ['type' => 'string', 'title' => 'Organisation ID', 'description' => 'The organisation uuid.'], + 'name' => ['type' => 'string', 'title' => 'Name', 'description' => 'The organisation name.'], + 'description' => ['type' => 'string', 'title' => 'Description', 'description' => 'A description of the organisation.'], + 'summary' => ['type' => 'string', 'title' => 'Summary', 'description' => 'A short summary.'], + 'oin' => ['type' => 'string', 'title' => 'OIN', 'description' => 'Organisatie-identificatienummer.'], + 'tooi' => ['type' => 'string', 'title' => 'TOOI', 'description' => 'TOOI register identifier.'], + 'rsin' => ['type' => 'string', 'title' => 'RSIN', 'description' => 'Rechtspersonen en Samenwerkingsverbanden Informatienummer.'], + 'kvk' => ['type' => 'string', 'title' => 'KVK', 'description' => 'Chamber of Commerce number.'], + 'pki' => ['type' => 'string', 'title' => 'PKI', 'description' => 'PKIoverheid certificate identifier.'], + 'image' => ['type' => 'string', 'title' => 'Image', 'description' => 'A logo or image URL.'], + 'type' => ['type' => 'string', 'title' => 'Type', 'description' => 'What kind of organisation this row describes.'], + 'registrationStatus' => ['type' => 'string', 'title' => 'Registration status', 'description' => 'Registration lifecycle state.'], + ], ]; /** diff --git a/lib/Service/ObjectSource/NcEntitySemanticMap.php b/lib/Service/ObjectSource/NcEntitySemanticMap.php index ce707d31f5..61f114d775 100644 --- a/lib/Service/ObjectSource/NcEntitySemanticMap.php +++ b/lib/Service/ObjectSource/NcEntitySemanticMap.php @@ -84,6 +84,22 @@ final class NcEntitySemanticMap { 'requiredApp' => null, 'application' => 'openregister', ], + // OpenRegister's own organisation, projected so a leaf schema can point a + // `{"$ref": ...}` at it. Several apps declared their own `organization` + // SCHEMA precisely because there was nothing here to reference, and a + // schema slug is global per organisation, so those copies collide. + // + // `nc-`-prefixed for the reason the app-gated rows below are: it must not + // collide with the leaf-app `organization` schemas it exists to replace, + // which have to keep working until each app has migrated off them. + 'organisation' => [ + 'register' => self::DIRECTORY_REGISTER, + 'schema' => 'nc-organisation', + 'schemaOrg' => 'schema:Organization', + 'provider' => 'organisation-source', + 'requiredApp' => null, + 'application' => 'openregister', + ], // App-gated rows — each lives on its OWN app-named register (application = // register slug) so the ADR-048 app-enabled gate degrades the projection // when the backing app is uninstalled. Schemas are `nc-`-prefixed to avoid diff --git a/lib/Service/ObjectSource/OrganisationObjectSourceProvider.php b/lib/Service/ObjectSource/OrganisationObjectSourceProvider.php new file mode 100644 index 0000000000..500ea96592 --- /dev/null +++ b/lib/Service/ObjectSource/OrganisationObjectSourceProvider.php @@ -0,0 +1,375 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\ObjectSource; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\Schema; +use OCP\IGroupManager; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Projects each organisation as a read-only virtual object. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ +class OrganisationObjectSourceProvider implements ObjectSourceProvider { + + /** + * The properties the projection exposes. + * + * Deliberately the identity facet and nothing else. The quota, authorization + * and lifecycle columns are tenancy administration: an object projection is + * for referencing an organisation from another record, not for managing one, + * and exposing them here would put tenant configuration behind the object API. + * + * @var array + */ + private const PROJECTED = [ + 'name', + 'description', + 'summary', + 'oin', + 'tooi', + 'rsin', + 'kvk', + 'pki', + 'image', + 'type', + 'registrationStatus', + ]; + + /** + * Wire the mapper and the acting-user services. + * + * @param OrganisationMapper $organisationMapper The organisation mapper. + * @param IUserSession $userSession The acting user's session. + * @param IGroupManager $groupManager Group manager, for the admin check. + * @param LoggerInterface $logger Logger. + * + * @return void + */ + public function __construct( + private readonly OrganisationMapper $organisationMapper, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * {@inheritDoc} + * + * @return string The provider id. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function getId(): string { + return 'organisation-source'; + }//end getId() + + /** + * {@inheritDoc} + * + * Organisations are OpenRegister's own, so this provider is always available. + * + * @return bool Always true. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function isEnabled(): bool { + return true; + }//end isEnabled() + + /** + * {@inheritDoc} + * + * Returns null when the organisation is absent OR the acting user may not + * read it, so the two are indistinguishable and the projection cannot be used + * to enumerate the instance's tenants. + * + * A merged-away organisation resolves to its survivor, so a reference stored + * before a merge keeps pointing at a real record. + * + * @param Register $register The register the schema belongs to. + * @param Schema $schema The sourced schema. + * @param string $id The organisation uuid. + * @param array $config The object-source config block (unused). + * + * @return ObjectEntity|null The virtual object, or null when absent or denied. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $config reserved for future scoping options. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function find(Register $register, Schema $schema, string $id, array $config = []): ?ObjectEntity { + try { + $organisation = $this->organisationMapper->findByUuidFollowingMerge(uuid: $id); + } catch (Throwable $e) { + $this->logger->debug('[ObjectSource:organisation-source] could not read organisation: ' . $e->getMessage()); + return null; + } + + if ($this->mayRead(organisation: $organisation) === false) { + return null; + } + + return $this->toObjectEntity(register: $register, schema: $schema, organisation: $organisation); + }//end find() + + /** + * {@inheritDoc} + * + * Honours `filters.search` / `_search`, `limit` and `offset`. An admin sees + * every organisation; anyone else sees only the ones they belong to. + * + * @param Register $register The register the schema belongs to. + * @param Schema $schema The sourced schema. + * @param array $query Query (filters/search/limit/offset). + * @param array $config The object-source config block (unused). + * + * @return ObjectEntity[] The matching virtual objects. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $config reserved for future scoping options. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function findAll(Register $register, Schema $schema, array $query = [], array $config = []): array { + $objects = []; + foreach ($this->readOrganisations(query: $query) as $organisation) { + $objects[] = $this->toObjectEntity(register: $register, schema: $schema, organisation: $organisation); + } + + return $objects; + }//end findAll() + + /** + * {@inheritDoc} + * + * @param Register $register The register the schema belongs to. + * @param Schema $schema The sourced schema. + * @param array $query Query (filters/search). + * @param array $config The object-source config block (unused). + * + * @return int The number of matching virtual objects. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function count(Register $register, Schema $schema, array $query = [], array $config = []): int { + return count($this->findAll(register: $register, schema: $schema, query: $query, config: $config)); + }//end count() + + /** + * The projected fields of one organisation. + * + * Empty values are omitted rather than written as null, so a consumer can + * tell "this organisation has no OIN" from "this projection does not carry + * OINs". + * + * @param Organisation $organisation The organisation. + * + * @return array The object body. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-the-projection-carries-the-identity-facet-only-req-orp-102 + */ + public static function project(Organisation $organisation): array { + // Read the SERIALISED entity, not `method_exists()` + a derived getter. + // Organisation's accessors are magic (`Entity::__call`), so + // `method_exists($organisation, 'getName')` is FALSE and every field + // would have been skipped, leaving a projection carrying nothing but an + // id. That shipped-looking-fine failure is what the tests caught. + $serialised = $organisation->jsonSerialize(); + + $data = ['id' => (string)$organisation->getUuid()]; + + foreach (self::PROJECTED as $property) { + $value = ($serialised[$property] ?? null); + if ($value === null || $value === '') { + continue; + } + + $data[$property] = $value; + } + + return $data; + }//end project() + + /** + * Whether the acting user may read this organisation's projection. + * + * @param Organisation $organisation The organisation being read. + * + * @return bool True when the acting user may read it. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-the-projection-is-not-an-enumeration-oracle-req-orp-103 + */ + private function mayRead(Organisation $organisation): bool { + $acting = $this->userSession->getUser(); + if ($acting === null) { + return false; + } + + try { + if ($this->groupManager->isAdmin($acting->getUID()) === true) { + return true; + } + + return $organisation->hasUser($acting->getUID()); + } catch (Throwable $e) { + return false; + } + }//end mayRead() + + /** + * The organisations visible to the acting user, failing closed to an empty list. + * + * @param array $query Query (filters/search/limit/offset). + * + * @return array The visible organisations. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-the-projection-is-not-an-enumeration-oracle-req-orp-103 + */ + private function readOrganisations(array $query): array { + $acting = $this->userSession->getUser(); + if ($acting === null) { + return []; + } + + $search = (string)($query['filters']['search'] ?? $query['_search'] ?? $query['search'] ?? ''); + $limit = (int)($query['limit'] ?? 200); + $offset = (int)($query['offset'] ?? 0); + + try { + if ($this->groupManager->isAdmin($acting->getUID()) === true) { + $organisations = $this->organisationMapper->findAll(limit: $limit, offset: $offset, filters: []); + return array_values(self::matching(organisations: $organisations, search: $search)); + } + + $organisations = $this->organisationMapper->findByUserId($acting->getUID()); + } catch (Throwable $e) { + $this->logger->warning('[ObjectSource:organisation-source] could not list organisations: ' . $e->getMessage()); + return []; + } + + return array_values(self::matching(organisations: $organisations, search: $search)); + }//end readOrganisations() + + /** + * Filter organisations by a search term over the fields a person would type. + * + * A merged-away organisation is excluded: it is not a usable reference target, + * and offering it in a picker invites a reference to a record that no longer + * owns anything. + * + * @param array $organisations The candidates. + * @param string $search The search term, or ''. + * + * @return array The matches. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-the-projection-carries-the-identity-facet-only-req-orp-102 + */ + public static function matching(array $organisations, string $search): array { + $live = array_filter( + $organisations, + static function ($organisation) { + return ($organisation instanceof Organisation && $organisation->isMerged() === false); + } + ); + + if ($search === '') { + return $live; + } + + $needle = strtolower($search); + + return array_filter( + $live, + static function (Organisation $organisation) use ($needle) { + foreach ([$organisation->getName(), $organisation->getOin(), $organisation->getRsin(), $organisation->getKvk()] as $field) { + if ($field !== null && str_contains(strtolower((string)$field), $needle) === true) { + return true; + } + } + + return false; + } + ); + }//end matching() + + /** + * Map an organisation onto a non-persisted virtual ObjectEntity. + * + * @param Register $register The register. + * @param Schema $schema The sourced schema. + * @param Organisation $organisation The organisation. + * + * @return ObjectEntity The virtual object (never saved). + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + private function toObjectEntity(Register $register, Schema $schema, Organisation $organisation): ObjectEntity { + $entity = new ObjectEntity(); + $entity->setUuid((string)$organisation->getUuid()); + $entity->setRegister((string)$register->getId()); + $entity->setSchema((string)$schema->getId()); + $entity->setObject(self::project(organisation: $organisation)); + + return $entity; + }//end toObjectEntity() +}//end class diff --git a/openspec/changes/organisations-are-objects/proposal.md b/openspec/changes/organisations-are-objects/proposal.md new file mode 100644 index 0000000000..d78117d794 --- /dev/null +++ b/openspec/changes/organisations-are-objects/proposal.md @@ -0,0 +1,59 @@ +# An organisation is addressable as an object + +## Why + +Several apps grew their own `organization` SCHEMA, and measuring one showed why. +`publication.organization` and `catalog.organization` are declared as +`{"type": "string", "format": "uuid", "$ref": "organization"}`. A `$ref` +resolves against a SCHEMA, and OpenRegister's Organisation is an ENTITY with no +object projection, so there was nothing for that reference to point at. Each app +declared its own copy instead. + +A schema slug is global per organisation — `SchemaMapper::find()` matches +`LOWER(slug)` across every app and returns the first row it reaches — so those +copies collide. `organization` is currently claimed by both opencatalogi and +stackiq. + +Adding the identity columns to Organisation (change +`consolidate-organisation-on-or`) made reuse possible at the entity level, and +`openregister:organisations:adopt` moves the rows. Neither gives a leaf schema +something to reference. This does. + +## What changes + +An `nc-organisation` virtual schema on the always-available `directory` +register, served read-only by an `OrganisationObjectSourceProvider`. + +This is not a new mechanism. OpenRegister already projects `nc-user` and +`nc-group` exactly this way, and `nc-group` is even mapped to +`schema:Organization`. The provider follows `GroupObjectSourceProvider` +line for line: the same read-only contract, the same acting-user scoping, the +same "absent and denied are indistinguishable" rule. + +The schema is `nc-`-prefixed for the reason the map already states for the +app-gated rows: it must not collide with the leaf-app `organization` schemas it +exists to replace, which have to keep working until each app has migrated off +them. + +## Three deliberate limits + +**Read-only.** The authoritative record is the Organisation row. A write path +here would be a second way to mutate a tenant, reachable through the object API +and bypassing `OrganisationService`'s lifecycle. + +**The identity facet only.** Quota, users, groups and authorization are tenancy +administration. This schema exists so another record can REFERENCE an +organisation, not so anyone can configure one through the object API. + +**Scoped, and not an enumeration oracle.** An organisation IS the tenant +boundary. An admin sees all of them; anyone else sees only the ones they belong +to; absent and denied both return null. Anonymous callers see nothing. + +## A defect this found + +`project()` was first written with `method_exists($organisation, 'getName')` +before calling the getter. Organisation's accessors are magic +(`Entity::__call`), so `method_exists` is FALSE for every one of them and the +projection would have shipped carrying nothing but an id — a schema that +resolves, returns objects, and is empty. The tests caught it; reading the code +did not. diff --git a/openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md b/openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md new file mode 100644 index 0000000000..03d48aada0 --- /dev/null +++ b/openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md @@ -0,0 +1,81 @@ +# Organisation projection + +## ADDED Requirements + +### Requirement: An organisation is addressable as an object (REQ-ORP-101) + +An organisation MUST be readable through the object API as a virtual object on +the `directory` register, so a schema property can reference it with a `$ref`. + +The object's id MUST be the organisation uuid, so a reference stored before this +change keeps naming the same record. + +`find()` MUST resolve through a merge chain, so a reference stored before a +merge resolves to the surviving organisation rather than to a row that owns +nothing. + +The projection MUST be read-only. The authoritative record is the Organisation +row, and a write path here would be a second way to mutate a tenant that +bypasses the organisation lifecycle. + +#### Scenario: An organisation is readable as an object + +- **WHEN** the `nc-organisation` schema is listed by an authorised caller +- **THEN** each organisation they may see is returned as an object whose id is + its uuid. + +#### Scenario: A reference to a merged organisation still resolves + +- **GIVEN** an organisation that was merged into another +- **WHEN** it is fetched by its own uuid +- **THEN** the surviving organisation is returned. + +### Requirement: The projection carries the identity facet only (REQ-ORP-102) + +The projection MUST carry name, description, summary, the legal identifiers +(OIN, TOOI, RSIN, KVK, PKI), image, type and registration status. + +It MUST NOT carry quota, users, groups or authorization. Those are tenancy +administration, and this schema exists so another record can reference an +organisation rather than configure one. + +An empty field MUST be omitted rather than emitted as null, so a consumer can +distinguish "this organisation has no OIN" from "this projection does not carry +OINs". + +A merged-away organisation MUST NOT be listed: it owns nothing, and offering it +invites a reference to a record that is not a usable target. + +#### Scenario: Tenancy administration is absent + +- **WHEN** an organisation is projected +- **THEN** the object carries no quota, users, groups or authorization. + +#### Scenario: An organisation with no OIN omits the key + +- **GIVEN** an organisation carrying no OIN +- **WHEN** it is projected +- **THEN** the object has no `oin` key at all. + +### Requirement: The projection is not an enumeration oracle (REQ-ORP-103) + +Reads MUST be scoped to the acting user: an admin sees every organisation, +anyone else sees only the organisations they belong to. + +An organisation that is absent and one the caller may not read MUST be +indistinguishable, so the projection cannot be used to discover which tenants +exist on an instance. + +An anonymous caller MUST see nothing. + +#### Scenario: An anonymous caller sees no organisations + +- **WHEN** an unauthenticated caller lists the schema +- **THEN** the response is empty rather than an error, and reveals no + organisation. + +#### Scenario: A denied organisation is reported as absent + +- **GIVEN** an organisation the acting user does not belong to +- **WHEN** they fetch it by uuid +- **THEN** the result is null, the same as for a uuid that does not exist. diff --git a/openspec/changes/organisations-are-objects/tasks.md b/openspec/changes/organisations-are-objects/tasks.md new file mode 100644 index 0000000000..742710d345 --- /dev/null +++ b/openspec/changes/organisations-are-objects/tasks.md @@ -0,0 +1,64 @@ +# Tasks + +## 1. The projection + +- [x] 1.1 `OrganisationObjectSourceProvider`, read-only, following + `GroupObjectSourceProvider`. +- [x] 1.2 An `nc-organisation` row in `NcEntitySemanticMap` on the always + available `directory` register. +- [x] 1.3 Property definitions in `SeedDirectoryVirtualSchemas`, kept in step + with the provider's projected set — a property declared and not projected + reads as permanently empty, and one projected and not declared is + discarded by the store without a word. +- [x] 1.4 Register the provider in DI and in the provider list. + +## 2. The limits + +- [x] 2.1 Read-only: no write path through the object API. +- [x] 2.2 Identity facet only; no quota, users, groups or authorization. +- [x] 2.3 Scoped to the acting user, with absent and denied indistinguishable. +- [x] 2.4 A merged-away organisation is not offered: it owns nothing, and + listing it invites a reference to a record that is not a usable target. + `find()` still resolves one THROUGH the merge, so a reference stored + before a merge keeps working. + +## 3. Tests + +- [x] 3.1 Ten unit tests covering the projected set, the omissions, the merge + exclusion, search, and the anonymous case. + +## 4. Verified live + +- [x] 4.1 `GET /api/objects/{directory}/{nc-organisation}` returns the + organisation with its identity facet. +- [x] 4.2 The same call unauthenticated returns `total: 0` — not an error, and + not a row. +- [x] 4.3 `find` by uuid returns the organisation. +- [x] 4.4 Seeding on a clean row creates the schema WITH its properties and + links it to the directory register. +- [x] 4.5 THE PREMISE ITSELF, which the rest of this list does not prove. A + leaf property repointed to `{"$ref": "nc-organisation"}` resolves: + `publication.organization` read plain returns the raw uuid, and read with + `_extend[]=organization` inlines the organisation's identity facet from + OpenRegister's own record. That is exactly what the leaf `organization` + schemas were there to provide. + + Worth stating separately because everything above only proves the + projection can be READ. A projection that reads fine and cannot be + `$ref`'d would have satisfied every other check on this list and been + useless for the one thing it exists to do. + +⚠️ `ensureSchema()` reuses an existing schema and never updates it, by design. +A property added to `SCHEMA_PROPERTIES` therefore does NOT reach an instance +that already seeded that schema. Verified by deleting the row and re-running the +repair step. Any future property change needs its own migration. + +## 5. What this unblocks, and does not do + +- [ ] 5.1 Repoint opencatalogi's `publication.organization` and + `catalog.organization` at `nc-organisation`, then retire its own + `organization` schema. +- [ ] 5.2 The same for stackiq, which additionally needs a home for the nine + properties Organisation has no column for (`xml`, `contactsUid`, + `contactpersonen`, `deelnames`, `participants`, `samenwerkingtype`, + `registeredBy`, `publicationDate`/`depublicationDate`). diff --git a/tests/Unit/Service/ObjectSource/OrganisationObjectSourceProviderTest.php b/tests/Unit/Service/ObjectSource/OrganisationObjectSourceProviderTest.php new file mode 100644 index 0000000000..ffe185a2d0 --- /dev/null +++ b/tests/Unit/Service/ObjectSource/OrganisationObjectSourceProviderTest.php @@ -0,0 +1,247 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\ObjectSource; + +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Service\ObjectSource\OrganisationObjectSourceProvider; +use PHPUnit\Framework\TestCase; + +/** + * Locks what the projection exposes and what it refuses to offer. + */ +class OrganisationObjectSourceProviderTest extends TestCase { + + /** + * Build an organisation. + * + * @param string $uuid The uuid. + * @param string $name The name. + * @param string|null $oin The OIN, if any. + * @param string|null $mergedInto The uuid it was merged into, if any. + * + * @return Organisation The organisation. + */ + private function organisation( + string $uuid, + string $name, + ?string $oin = null, + ?string $mergedInto = null + ): Organisation { + $organisation = new Organisation(); + $organisation->setUuid($uuid); + $organisation->setName($name); + $organisation->setOin($oin); + $organisation->setMergedInto($mergedInto); + + return $organisation; + + }//end organisation() + + /** + * The projection carries the uuid as the object id, so a stored reference + * resolves to the same record it always named. + * + * @return void + */ + public function testTheUuidIsTheObjectId(): void { + $data = OrganisationObjectSourceProvider::project( + organisation: $this->organisation(uuid: 'org-uuid', name: 'Gemeente Utrecht') + ); + + $this->assertSame('org-uuid', $data['id']); + $this->assertSame('Gemeente Utrecht', $data['name']); + + }//end testTheUuidIsTheObjectId() + + /** + * The identity facet is projected, so a leaf app referencing an organisation + * gets the fields it used to keep its own copy of. + * + * @return void + */ + public function testTheIdentityFacetIsProjected(): void { + $organisation = $this->organisation(uuid: 'org-uuid', name: 'Gemeente Utrecht', oin: '00000001002220647000'); + $organisation->setRsin('001234567'); + $organisation->setSummary('A municipality.'); + + $data = OrganisationObjectSourceProvider::project(organisation: $organisation); + + $this->assertSame('00000001002220647000', $data['oin']); + $this->assertSame('001234567', $data['rsin']); + $this->assertSame('A municipality.', $data['summary']); + + }//end testTheIdentityFacetIsProjected() + + /** + * Tenancy administration is NOT projected. An object projection is for + * referencing an organisation, not for managing one, and putting quota or + * authorization behind the object API would make tenant configuration + * readable wherever an object is. + * + * @return void + */ + public function testTenancyAdministrationIsNotProjected(): void { + $organisation = $this->organisation(uuid: 'org-uuid', name: 'Gemeente Utrecht'); + $organisation->setStorageQuota(1024); + $organisation->setUsers(['alice', 'bob']); + $organisation->setAuthorization(['read' => ['admin']]); + + $data = OrganisationObjectSourceProvider::project(organisation: $organisation); + + foreach (['storageQuota', 'bandwidthQuota', 'requestQuota', 'users', 'groups', 'authorization'] as $forbidden) { + $this->assertArrayNotHasKey($forbidden, $data, $forbidden . ' must not be projected'); + } + + }//end testTenancyAdministrationIsNotProjected() + + /** + * An empty field is omitted rather than written as null, so a consumer can + * tell "this organisation has no OIN" from "this projection does not carry + * OINs at all". + * + * @return void + */ + public function testEmptyFieldsAreOmittedRatherThanNulled(): void { + $data = OrganisationObjectSourceProvider::project( + organisation: $this->organisation(uuid: 'org-uuid', name: 'Gemeente Utrecht') + ); + + $this->assertArrayNotHasKey('oin', $data); + $this->assertArrayNotHasKey('rsin', $data); + + }//end testEmptyFieldsAreOmittedRatherThanNulled() + + /** + * A merged-away organisation is not offered. It no longer owns anything, and + * listing it invites a reference to a record that is not a usable target. + * + * @return void + */ + public function testAMergedAwayOrganisationIsNotListed(): void { + $matches = OrganisationObjectSourceProvider::matching( + organisations: [ + $this->organisation(uuid: 'live', name: 'Gemeente Utrecht'), + $this->organisation(uuid: 'dead', name: 'Gemeente Utrecht', oin: null, mergedInto: 'live'), + ], + search: '' + ); + + $this->assertSame(['live'], array_values(array_map(static fn ($o) => $o->getUuid(), $matches))); + + }//end testAMergedAwayOrganisationIsNotListed() + + /** + * Search matches the fields a person would actually type. + * + * @return void + */ + public function testSearchMatchesNameAndLegalIdentifiers(): void { + $organisations = [ + $this->organisation(uuid: 'a', name: 'Gemeente Utrecht', oin: '00000001002220647000'), + $this->organisation(uuid: 'b', name: 'Provincie Zuid-Holland', oin: '99999999999999999999'), + ]; + + $byName = OrganisationObjectSourceProvider::matching(organisations: $organisations, search: 'utrecht'); + $this->assertSame(['a'], array_values(array_map(static fn ($o) => $o->getUuid(), $byName))); + + $byOin = OrganisationObjectSourceProvider::matching(organisations: $organisations, search: '9999999999'); + $this->assertSame(['b'], array_values(array_map(static fn ($o) => $o->getUuid(), $byOin))); + + }//end testSearchMatchesNameAndLegalIdentifiers() + + /** + * Search is case-insensitive, because a picker's user types what they read. + * + * @return void + */ + public function testSearchIsCaseInsensitive(): void { + $matches = OrganisationObjectSourceProvider::matching( + organisations: [$this->organisation(uuid: 'a', name: 'Gemeente Utrecht')], + search: 'GEMEENTE' + ); + + $this->assertCount(1, $matches); + + }//end testSearchIsCaseInsensitive() + + /** + * A search matching nothing returns nothing, rather than falling back to the + * whole list — which would offer every tenant to anyone who typed a typo. + * + * @return void + */ + public function testANonMatchingSearchReturnsNothing(): void { + $this->assertSame( + [], + OrganisationObjectSourceProvider::matching( + organisations: [$this->organisation(uuid: 'a', name: 'Gemeente Utrecht')], + search: 'nothing-matches-this' + ) + ); + + }//end testANonMatchingSearchReturnsNothing() + + /** + * The provider is always available: organisations are OpenRegister's own, so + * there is no backing app that can be uninstalled. + * + * @return void + */ + public function testTheProviderIsAlwaysEnabled(): void { + $provider = new OrganisationObjectSourceProvider( + organisationMapper: $this->createMock(\OCA\OpenRegister\Db\OrganisationMapper::class), + userSession: $this->createMock(\OCP\IUserSession::class), + groupManager: $this->createMock(\OCP\IGroupManager::class), + logger: $this->createMock(\Psr\Log\LoggerInterface::class) + ); + + $this->assertTrue($provider->isEnabled()); + $this->assertSame('organisation-source', $provider->getId()); + + }//end testTheProviderIsAlwaysEnabled() + + /** + * An anonymous caller sees nothing. Absent and denied must be + * indistinguishable, so the projection cannot enumerate the instance's + * tenants. + * + * @return void + */ + public function testAnAnonymousCallerSeesNothing(): void { + $userSession = $this->createMock(\OCP\IUserSession::class); + $userSession->method('getUser')->willReturn(null); + + $provider = new OrganisationObjectSourceProvider( + organisationMapper: $this->createMock(\OCA\OpenRegister\Db\OrganisationMapper::class), + userSession: $userSession, + groupManager: $this->createMock(\OCP\IGroupManager::class), + logger: $this->createMock(\Psr\Log\LoggerInterface::class) + ); + + $this->assertSame( + [], + $provider->findAll( + register: new \OCA\OpenRegister\Db\Register(), + schema: new \OCA\OpenRegister\Db\Schema() + ) + ); + + }//end testAnAnonymousCallerSeesNothing() +}//end class From 0961e894b6aff5cbe6ec746ca25d8a5d105b00e1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 13:35:25 +0200 Subject: [PATCH 67/68] fix(deferral): register the shutdown flush through a closure, and test that it registers (#3369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate-57 (orphaned-write-capability) named `ListenerDeferralService::flushAll()` and `SearchQueryHandler::flushSearchTrails()` as write-capable methods with no production caller. Both are live: they run from a shutdown function. The gate could not see it, because both were registered as `[$this, 'method']` — a string method name that no static tool can follow. That form is genuinely worse than the closure it replaces, and not only for the gate. Rename either flush and the array callable silently points at nothing: the failure surfaces at request shutdown, after the response, where nobody is looking. Deferred listener jobs would simply stop being enqueued and search trails would stop being written, with no error anywhere. The three other shutdown registrations in this repo (WritePhaseProbe, SchemaMapper, ObjectEventProxyListener) already use closures; these two were the outliers. The registration itself had no test either. Every existing test called the flush by hand, so deleting the one line that arms it left the suites green. PHP cannot introspect its shutdown queue, so the tests declare `register_shutdown_function()` inside the services' own namespaces — an unqualified call resolves to the current namespace before the global one, which intercepts the real production statement without adding a seam to the class or a subclass that could stub out the very line under test. Verified by deleting each registration and watching four tests go red. - neither method is dead, and neither needed a gate exclusion - the search trail is best-effort by spec, so the fail-soft flush is correct and nothing retained is discarded - 19105 unit tests green; phpcs, phpmd, psalm, phpstan clean on the diff Co-authored-by: Claude Opus 5 (1M context) --- .../Deferral/ListenerDeferralService.php | 14 +- lib/Service/Object/SearchQueryHandler.php | 10 +- tests/Support/ShutdownFunctionSpy.php | 130 ++++++++++++++++++ .../Deferral/ListenerDeferralServiceTest.php | 74 +++++++++- .../Object/SearchTrailDeferralTest.php | 60 ++++++++ 5 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 tests/Support/ShutdownFunctionSpy.php diff --git a/lib/Service/Deferral/ListenerDeferralService.php b/lib/Service/Deferral/ListenerDeferralService.php index b5d032eaa6..6f8ab678f9 100644 --- a/lib/Service/Deferral/ListenerDeferralService.php +++ b/lib/Service/Deferral/ListenerDeferralService.php @@ -311,6 +311,14 @@ private function captureActor(): void { /** * Register the shutdown flush exactly once per request. * + * The callback is a closure calling `flushAll()` rather than the + * `[$this, 'flushAll']` array form it replaced. A string method name is + * invisible to every static tool: a rename of `flushAll()` leaves the + * array callable pointing at nothing and fails only at shutdown, where + * nothing observes it — the deferred jobs would simply stop being + * enqueued, silently. The closure makes the call site greppable and + * rename-safe, and matches ObjectEventProxyListener::traceEnabled(). + * * @return void */ private function hookShutdown(): void { @@ -319,6 +327,10 @@ private function hookShutdown(): void { } $this->shutdownHooked = true; - register_shutdown_function([$this, 'flushAll']); + register_shutdown_function( + function (): void { + $this->flushAll(); + } + ); }//end hookShutdown() }//end class diff --git a/lib/Service/Object/SearchQueryHandler.php b/lib/Service/Object/SearchQueryHandler.php index 5aa4bc7fd0..b61cbc2864 100644 --- a/lib/Service/Object/SearchQueryHandler.php +++ b/lib/Service/Object/SearchQueryHandler.php @@ -664,9 +664,17 @@ public function logSearchTrail( // Register the deferred flush once per request; it runs after the // response has been generated so the write cost is off the hot path. + // The callback is a closure rather than the `[$this, 'flushSearchTrails']` + // array form it replaced: a string method name is invisible to every + // static tool, so renaming the flush would break the registration with + // no error anywhere — trails would just stop being written. if ($this->trailFlushRegistered === false) { $this->trailFlushRegistered = true; - register_shutdown_function([$this, 'flushSearchTrails']); + register_shutdown_function( + function (): void { + $this->flushSearchTrails(); + } + ); } }//end logSearchTrail() diff --git a/tests/Support/ShutdownFunctionSpy.php b/tests/Support/ShutdownFunctionSpy.php new file mode 100644 index 0000000000..168491d33c --- /dev/null +++ b/tests/Support/ShutdownFunctionSpy.php @@ -0,0 +1,130 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Support { + + /** + * Records the callbacks the namespaced stubs below intercept. + */ + final class ShutdownFunctionSpy { + + /** + * Callbacks handed to register_shutdown_function(), in order. + * + * @var array + */ + private static array $callbacks = []; + + /** + * Forget every recorded callback. Call from setUp(). + * + * @return void + */ + public static function reset(): void { + self::$callbacks = []; + }//end reset() + + /** + * Record one intercepted callback. + * + * @param callable $callback The callback that was registered. + * + * @return void + */ + public static function record(callable $callback): void { + self::$callbacks[] = $callback; + }//end record() + + /** + * Every callback recorded since the last reset(). + * + * @return array + */ + public static function callbacks(): array { + return self::$callbacks; + }//end callbacks() + + /** + * Run every recorded callback, as PHP would at request shutdown. + * + * @return void + */ + public static function runAll(): void { + foreach (self::$callbacks as $callback) { + $callback(); + } + }//end runAll() + }//end class +} + +namespace OCA\OpenRegister\Service\Deferral { + + use OCA\OpenRegister\Tests\Support\ShutdownFunctionSpy; + + /** + * Namespaced override of the global register_shutdown_function(). + * + * @param callable $callback Callback the service registers. + * @param mixed ...$args Ignored; the services pass no extra args. + * + * @return void + */ + function register_shutdown_function(callable $callback, mixed ...$args): void { + ShutdownFunctionSpy::record($callback); + }//end register_shutdown_function() +} + +namespace OCA\OpenRegister\Service\Object { + + use OCA\OpenRegister\Tests\Support\ShutdownFunctionSpy; + + /** + * Namespaced override of the global register_shutdown_function(). + * + * @param callable $callback Callback the handler registers. + * @param mixed ...$args Ignored; the handler passes no extra args. + * + * @return void + */ + function register_shutdown_function(callable $callback, mixed ...$args): void { + ShutdownFunctionSpy::record($callback); + }//end register_shutdown_function() +} diff --git a/tests/Unit/Service/Deferral/ListenerDeferralServiceTest.php b/tests/Unit/Service/Deferral/ListenerDeferralServiceTest.php index c69df13181..76c6f0946a 100644 --- a/tests/Unit/Service/Deferral/ListenerDeferralServiceTest.php +++ b/tests/Unit/Service/Deferral/ListenerDeferralServiceTest.php @@ -7,6 +7,7 @@ use OCA\OpenRegister\Db\Organisation; use OCA\OpenRegister\Service\Deferral\ListenerDeferralService; use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Tests\Support\ShutdownFunctionSpy; use OCP\BackgroundJob\IJobList; use OCP\IAppConfig; use OCP\IUser; @@ -15,12 +16,14 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +require_once __DIR__ . '/../../../Support/ShutdownFunctionSpy.php'; + /** * Chunked buffering, dedupe coalescing, kill switch and fail-soft capture. * - * Every test drains its buffers (chunk flush or explicit flushAll) so the - * service's register_shutdown_function flush is a guaranteed no-op after the - * test process ends. + * The service's shutdown registration is intercepted by ShutdownFunctionSpy, + * so no test leaves a live shutdown callback behind and the registration + * itself is asserted on rather than assumed. */ class ListenerDeferralServiceTest extends TestCase { private IUserSession&MockObject $userSession; @@ -31,6 +34,7 @@ class ListenerDeferralServiceTest extends TestCase { protected function setUp(): void { parent::setUp(); + ShutdownFunctionSpy::reset(); $this->userSession = $this->createMock(IUserSession::class); $this->organisation = $this->createMock(OrganisationService::class); $this->jobList = $this->createMock(IJobList::class); @@ -185,6 +189,70 @@ public function testOrganisationCaptureFailureIsSoft(): void { $this->assertNull($argument['organisationUuid']); } + // ─── Shutdown registration ─────────────────────────────────────── + + /** + * Buffering an entry registers the shutdown flush, exactly once per + * request no matter how many entries or job classes follow. + * + * Without this the deferral is a black hole: `defer()` buffers, nothing + * ever flushes, and every other test in this file still passes because + * they all call `flushAll()` by hand. + */ + public function testDeferRegistersTheShutdownFlushExactlyOnce(): void { + $this->actAs('alice', 'org-1'); + $service = $this->makeService(); + + // Well under the default chunk size, so only the shutdown flush can + // ever enqueue these. + $this->jobList->expects($this->never())->method('add'); + + $service->defer(jobClass: 'Some\\Job', entry: ['uuid' => 'a']); + $service->defer(jobClass: 'Some\\Job', entry: ['uuid' => 'b']); + $service->defer(jobClass: 'Other\\Job', entry: ['uuid' => 'c']); + + $this->assertCount(1, ShutdownFunctionSpy::callbacks()); + } + + /** + * The callback handed to the shutdown queue really is the flush: running + * it enqueues the buffered work. Proves the registration points at + * flushAll() and not at some other or stale callable. + */ + public function testRegisteredShutdownCallbackEnqueuesTheBufferedWork(): void { + $this->actAs('alice', 'org-1'); + $service = $this->makeService(); + + $enqueued = []; + $this->jobList->method('add') + ->willReturnCallback(function (string $jobClass, $argument) use (&$enqueued): void { + $enqueued[] = ['jobClass' => $jobClass, 'argument' => $argument]; + }); + + $service->defer(jobClass: 'Some\\Job', entry: ['uuid' => 'a']); + + // Still only buffered: nothing reached the job list yet. + $this->assertSame([], $enqueued); + $this->assertCount(1, ShutdownFunctionSpy::callbacks()); + + ShutdownFunctionSpy::runAll(); + + $this->assertCount(1, $enqueued); + $this->assertSame('Some\\Job', $enqueued[0]['jobClass']); + $this->assertSame('alice', $enqueued[0]['argument']['userId']); + $this->assertSame([['uuid' => 'a']], $enqueued[0]['argument']['entries']); + } + + /** + * No entry, no registration: an idle request must not pay for a shutdown + * callback it has nothing to flush. + */ + public function testNoShutdownCallbackIsRegisteredWithoutADeferredEntry(): void { + $this->makeService(); + + $this->assertSame([], ShutdownFunctionSpy::callbacks()); + } + public function testEnqueueFailureIsLoggedNotThrown(): void { $this->actAs('alice', null); $service = $this->makeService(); diff --git a/tests/Unit/Service/Object/SearchTrailDeferralTest.php b/tests/Unit/Service/Object/SearchTrailDeferralTest.php index 2e16918a79..e2134d8a4e 100644 --- a/tests/Unit/Service/Object/SearchTrailDeferralTest.php +++ b/tests/Unit/Service/Object/SearchTrailDeferralTest.php @@ -32,11 +32,14 @@ use OCA\OpenRegister\Service\Object\SearchQueryHandler; use OCA\OpenRegister\Service\SearchTrailService; use OCA\OpenRegister\Service\SettingsService; +use OCA\OpenRegister\Tests\Support\ShutdownFunctionSpy; use OCP\IRequest; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +require_once __DIR__ . '/../../../Support/ShutdownFunctionSpy.php'; + /** * Unit tests for deferred search-trail recording in SearchQueryHandler. */ @@ -56,6 +59,7 @@ class SearchTrailDeferralTest extends TestCase { */ protected function setUp(): void { parent::setUp(); + ShutdownFunctionSpy::reset(); $this->viewMapper = $this->createMock(ViewMapper::class); $this->schemaMapper = $this->createMock(SchemaMapper::class); @@ -189,6 +193,62 @@ public function testFlushPersistsAllBufferedEntries(): void { $this->assertSame(3, $this->handler->flushSearchTrails()); }//end testFlushPersistsAllBufferedEntries() + // ─── Shutdown registration ─────────────────────────────────────── + + /** + * Buffering a trail entry registers the post-response flush, exactly once + * per request however many searches it serves. + * + * Without this the deferral is a black hole: entries buffer and nothing + * ever persists them, while every other test in this file still passes + * because they all call flushSearchTrails() by hand. + * + * @return void + */ + public function testLogSearchTrailRegistersTheShutdownFlushExactlyOnce(): void { + $this->configureSettings(); + + $this->handler->logSearchTrail(['_search' => 'one'], 1, 1, 1.0); + $this->handler->logSearchTrail(['_search' => 'two'], 2, 2, 2.0); + + $this->assertCount(1, ShutdownFunctionSpy::callbacks()); + }//end testLogSearchTrailRegistersTheShutdownFlushExactlyOnce() + + /** + * The callback handed to the shutdown queue really is the flush: running + * it persists the buffered entry. Proves the registration points at + * flushSearchTrails() and not at some other or stale callable. + * + * @return void + */ + public function testRegisteredShutdownCallbackPersistsTheBufferedTrail(): void { + $this->configureSettings(); + + $this->searchTrailService->expects($this->once()) + ->method('createSearchTrail') + ->with(['_search' => 'deferred'], 5, 42, 12.5, 'database') + ->willReturn(new SearchTrail()); + + $this->handler->logSearchTrail(['_search' => 'deferred'], 5, 42, 12.5, 'database'); + $this->assertCount(1, ShutdownFunctionSpy::callbacks()); + + ShutdownFunctionSpy::runAll(); + }//end testRegisteredShutdownCallbackPersistsTheBufferedTrail() + + /** + * Trails disabled: nothing is buffered, so no shutdown callback is + * registered either. + * + * @return void + */ + public function testNoShutdownCallbackIsRegisteredWhenTrailsAreDisabled(): void { + $this->configureSettings(enabled: false); + + $this->handler->logSearchTrail(['_search' => 'ignored'], 1, 1, 1.0); + + $this->assertSame([], ShutdownFunctionSpy::callbacks()); + }//end testNoShutdownCallbackIsRegisteredWhenTrailsAreDisabled() + // ─── Recording disabled ────────────────────────────────────────── /** From c130aa232b0019892fc2e032efe12bcb131d8ea4 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 14:27:57 +0200 Subject: [PATCH 68/68] fix(nav): let users reach Personal settings again (#3367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(nav): let users reach Personal settings again The manifest carried `nav.includePersonalSettings: false`, which turns off the entry CnAppNav auto-prepends at the top of the settings foldout. There was no note saying why, and nothing replaced it. That entry is not decoration. It opens CnAppRoot's NcAppSettingsDialog, whose `#user-settings` slot falls back to the user's NOTIFICATION PREFERENCES and renders the ADR-110 Integrations section below them. Suppressing the entry put both out of reach from inside the app entirely. The flag is legitimate for an app that declares its own entry with `action: "user-settings"`, which opens the same dialog: keepiq does exactly that and injects its own Session / Security / Encryption sections into the slot, so re-enabling the shell copy there would give it two entries onto one dialog. This app declares no such entry, so the flag was simply hiding a surface. Found by pointing gate-107 (ADR-114, the seven-item app chrome) at the fleet: three apps set this flag, and only keepiq had a replacement. * fix(spec-tags): repair the anchor truncated mid-word in TaskSequenceMapper gate-46 (spec-anchor-existence) fails openregister on `development`, and every branch cut from it inherits the red. One tag, one file: lib/Db/TaskSequenceMapper.php:122 …spec.md#requirement-an-approval-is-an-ordered-task-sequence-with-o The anchor stops mid-word at `with-o`. Three sibling tags in the same file (lines 26, 62 and 149) carry the full `…-with-one-position-enabled-at-a-time`, and the heading it names exists in the spec, so this is a truncation rather than a stale reference to something that moved. Verified by running the gate's own checker over every tracked lib/**/*.php: 1 finding before, 0 after. Fixing the TARGET rather than each tag, as the gate asks — here the target was right all along and the tag was wrong. --------- Co-authored-by: Conduction Release Bot --- lib/Db/TaskSequenceMapper.php | 2 +- src/manifest.json | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/Db/TaskSequenceMapper.php b/lib/Db/TaskSequenceMapper.php index ad0b51a176..bae3a38643 100644 --- a/lib/Db/TaskSequenceMapper.php +++ b/lib/Db/TaskSequenceMapper.php @@ -119,7 +119,7 @@ public function findRunning(string $anchorObjectUuid, string $templateId): ?Task * * @return TaskSequence|null The newest sequence for the template, or null when none has run. * - * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-an-approval-is-an-ordered-task-sequence-with-o + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-an-approval-is-an-ordered-task-sequence-with-one-position-enabled-at-a-time */ public function findNewestForTemplate(string $templateId): ?TaskSequence { $qb = $this->db->getQueryBuilder(); diff --git a/src/manifest.json b/src/manifest.json index 09e90a6bd6..82a4e3b068 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -26,9 +26,6 @@ ] }, "version": "1.1.0", - "nav": { - "includePersonalSettings": false - }, "pages": [ { "id": "dashboard",