diff --git a/--help b/--help new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 635d7632e6..c85a276b98 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -43,4 +43,20 @@ if git diff --cached --name-only | grep -qE "^openspec/(specs/|features\.overlay fi fi +# A NEW MIGRATION NEEDS A VERSION BUMP, and this is the cheapest place to be +# told. Nextcloud reads an app's migration files only when appinfo/info.xml's +# is greater than the installed_version it recorded, so a migration +# committed without a bump runs on no existing instance and says nothing about +# it. CI (merge-hygiene) is the enforcement; this only tells you earlier. +# +# WARN ONLY, like everything else in this hook: a commit is a fine moment to +# learn about it and a bad moment to be blocked, and the bump is often the last +# thing you write. The gate on the PR is what actually holds the line. +if git diff --cached --name-only --diff-filter=A | grep -q "^lib/Migration/.*\.php$"; then + if ! php scripts/check-migration-version-bump.php >/dev/null 2>&1; then + echo "pre-commit: WARNING — this change adds a migration without moving in appinfo/info.xml." >&2 + echo " Run: php scripts/check-migration-version-bump.php" >&2 + fi +fi + exit 0 diff --git a/.github/workflows/api-test-coverage.yml b/.github/workflows/api-test-coverage.yml index 0b9f8bc71d..1f95466fe5 100644 --- a/.github/workflows/api-test-coverage.yml +++ b/.github/workflows/api-test-coverage.yml @@ -131,6 +131,36 @@ jobs: # than 40 lines later where it reads as a dependency-install failure. docker exec nextcloud php /usr/local/bin/composer --version + - name: Provide git inside the container + # Same shape as the composer step above, and for the same reason: the + # unit suite runs INSIDE the nextcloud container, and that image ships + # no git. + # + # MigrationVersionBumpCheckTest builds a small repository per case and + # runs scripts/check-migration-version-bump.php over it, because a test + # that inspects THIS repository can only report whatever today's branch + # happens to look like. With no git the fixtures are never created, so + # all six repository cases failed with + # + # /tmp/or-migration-gate-256a137d7fd3 is not a git repository + # + # which reads as a defect in the gate rather than an absent binary. + # Measured 2026-09-06: this job had failed on all eight development + # pushes since the gate landed in #3451, and `git --version` in the + # container answers `command not found`. With git present the class + # passes 8/8. + # + # apt rather than `docker cp` of the runner's binary: git is not a + # single file like the composer phar, and a copied binary would leave + # its shared libraries and helper programs behind. + run: | + set -e + docker exec -u root nextcloud bash -lc \ + 'apt-get update -qq && apt-get install -y -qq --no-install-recommends git' + # Prove it is usable HERE, as the user the suite runs as, rather than + # 40 lines later where it reads as a broken gate. + docker exec -u www-data nextcloud git --version + - name: PHPUnit unit suite (in-container, HARD GATE) # The unit suite requires the full Nextcloud runtime — the OCP stubs # reference Doctrine\DBAL\* and OC\* internals that only resolve once diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 185352d84c..e111ed7f69 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -172,7 +172,31 @@ permissions: jobs: quality: - if: github.event_name != 'push' || github.event.created != true + # SECOND CLAUSE: skip the run a STANDING release PR triggers. + # + # `Release: merge development into beta` is permanently open with + # `head_ref: development`, so every push to development fires this + # workflow TWICE on the SAME sha — once for `push`, once for that PR's + # `pull_request` event. The concurrency block above deliberately gives + # them separate lanes so the push run is not cancelled, which is right, + # and the consequence is that both run to completion. + # + # The PR run is the redundant one, not the push run: its head sha IS + # development's, which the push run already decided, and the push run + # carries jobs the PR run does not (Coverage Baseline Check, SBOM, + # Features Extract). Measured 2026-09-03: runs 33747123932 (push) and + # 33747129312 (pull_request), same sha b8eb72e2, four seconds apart. + # + # 20 of the fleet's 21 apps carry `beta` in `pull_request.branches` and + # so pay this on every development push. Removing `beta` from that list + # would also work and is worse: the `release/v*` and `sync/main-to-beta` + # PRs genuinely need their run, and they target `beta` too. + # + # A `development -> main` promotion PR is skipped by the same clause, for + # the same reason and just as correctly. + if: >- + (github.event_name != 'push' || github.event.created != true) + && (github.event_name != 'pull_request' || github.head_ref != 'development') uses: ConductionNL/.github/.github/workflows/quality.yml@main with: app-name: openregister diff --git a/.github/workflows/merge-hygiene.yml b/.github/workflows/merge-hygiene.yml index 4852cee5ee..fcf09c8944 100644 --- a/.github/workflows/merge-hygiene.yml +++ b/.github/workflows/merge-hygiene.yml @@ -33,7 +33,13 @@ jobs: name: Conflict markers and PHP syntax runs-on: ubuntu-latest steps: + # FULL HISTORY, deliberately. The migration/version check below compares + # this branch against the merge base with `development`, and a depth-1 + # checkout has no merge base to find — it would report "no verdict" on + # every run, which is the silent skip the check exists to remove. - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Conflict markers, anywhere in the tree we author. A marker means a merge # was committed half-finished; every downstream signal from that commit is @@ -69,6 +75,30 @@ jobs: php-version: '8.3' coverage: none + # A MIGRATION THAT SHIPS WITHOUT A VERSION BUMP REACHES NOBODY. + # + # Nextcloud runs an app's migrations only when appinfo/info.xml's + # is greater than the installed_version it recorded. Equal + # versions mean `occ upgrade` answers "No upgrade required.", exits 0, and + # reads none of the migration files — no log line, no failure, and the + # feature that needed the table is simply absent. + # + # Measured on a throwaway NC 34 rig 2026-09-05: a migration added with the + # version left alone did not run, was not recorded, and + # `occ migrations:status openregister` reported "Pending Migrations: None" + # while showing 204 executed of 205 available. Bumping only , with + # the code byte-identical, ran it. The version string is the whole gate. + # + # It lives here rather than in Code Quality because it needs the base ref + # and takes milliseconds — the same reason everything else in this file is + # here. + - name: A new migration moves the app version + run: | + set -euo pipefail + BASE="${{ github.event.pull_request.base.ref || 'development' }}" + git fetch --no-tags --prune origin "$BASE" + MIGRATION_VERSION_BASE_REF="origin/$BASE" php scripts/check-migration-version-bump.php + # Every PHP file parses. A conflict marker is caught above, but so is any # other way a file can be committed unparseable — and this is the check # that would have failed within seconds of the merge landing. diff --git a/.license-overrides.json b/.license-overrides.json index d7940bc6fd..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 MIT — license-checker misreads logo URL as custom license, see https://github.com/apexcharts/apexcharts.js/blob/main/LICENSE", + "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" } diff --git a/appinfo/info.xml b/appinfo/info.xml index 954c758c8f..e1165fbb12 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.12 + 2.0.15-unstable.20260905143606 EUPL-1.2 Conduction OpenRegister @@ -129,27 +129,26 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\BackgroundJob\TransferCheckJob OCA\OpenRegister\BackgroundJob\SyncConfigurationsJob OCA\OpenRegister\BackgroundJob\SyncDataJob - OCA\OpenRegister\BackgroundJob\ScheduledWorkflowJob OCA\OpenRegister\BackgroundJob\ScheduledNotificationJob - OCA\OpenRegister\BackgroundJob\ActionScheduleJob OCA\OpenRegister\BackgroundJob\TenantDeprovisionJob OCA\OpenRegister\BackgroundJob\TenantPurgeJob OCA\OpenRegister\BackgroundJob\TenantUsageSyncJob - OCA\OpenRegister\BackgroundJob\ExecutionHistoryCleanupJob OCA\OpenRegister\BackgroundJob\FlowRunRetentionJob OCA\OpenRegister\BackgroundJob\AuditSealJob OCA\OpenRegister\BackgroundJob\AvgRetentionJob 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 + OCA\OpenRegister\BackgroundJob\FlowTimerWorker OCA\OpenRegister\BackgroundJob\HandoffQueueDrainJob OCA\OpenRegister\BackgroundJob\TemporalCalculationSweepJob OCA\OpenRegister\BackgroundJob\DsarDpiaDetectionJob - OCA\OpenRegister\BackgroundJob\ScheduleReconcilerJob OCA\OpenRegister\BackgroundJob\DbalIntrospectionJob + OCA\OpenRegister\BackgroundJob\OAuth2TokenRefreshJob OCA\OpenRegister\BackgroundJob\ScheduledReportJob OCA\OpenRegister\BackgroundJob\GroupReconcilerJob @@ -219,6 +218,27 @@ Vrij en open source onder de EUPL-licentie. Copies the register-authored ones across, disabled, leaving the register rows in place so the step stays reversible. --> OCA\OpenRegister\Repair\MigrateRegisterFlowsToTable + + OCA\OpenRegister\Repair\PurgePhantomMigratedFlows OCA\OpenRegister\Repair\ImportTrustConfigurationRegister OCA\OpenRegister\Repair\SeedVocabularyRegister OCA\OpenRegister\Repair\RegisterOpenRegisterWithDoriath @@ -240,6 +260,42 @@ 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\SeedFlowTimerRegister + OCA\OpenRegister\Repair\CheckFlowTimerInvariants + + OCA\OpenRegister\Repair\MigrateApprovalChainsToTasks + OCA\OpenRegister\Repair\SeedCaseFixtures + + OCA\OpenRegister\Repair\SeedFlowTimerRegister + OCA\OpenRegister\Repair\CheckFlowTimerInvariants OCA\OpenRegister\Repair\ReconcileDeclaredBackgroundJobs @@ -281,6 +337,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 @@ -295,6 +352,9 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Command\MigrateSchemaApplicationCommand + + OCA\OpenRegister\Command\RollbackApprovalMigrationCommand OCA\OpenRegister\Command\RechainAuditTrailCommand OCA\OpenRegister\Command\EncryptFieldCommand OCA\OpenRegister\Command\DedupeRegistersCommand + OCA\OpenRegister\Command\AdoptLeafOrganisationsCommand + OCA\OpenRegister\Command\PruneRetiredSchemasCommand OCA\OpenRegister\Command\RelinkRegisterSchemasCommand OCA\OpenRegister\Command\ReconcileMagicTablesCommand + + OCA\OpenRegister\Command\PurgeObjectCommand OCA\OpenRegister\Command\DedupeConfigurationsCommand OCA\OpenRegister\Command\ResolverListCommand OCA\OpenRegister\Command\TimeReconcileCommand @@ -363,4 +429,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 6f4b5ab5ba..7822695240 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -19,6 +19,7 @@ // First-time setup wizard (ADR-042) - the standard CnSetupWizard contract. ['name' => 'setup#status', 'url' => '/api/setup/status', 'verb' => 'GET'], ['name' => 'setup#runAction', 'url' => '/api/setup/action/{actionId}', 'verb' => 'POST', 'requirements' => ['actionId' => '[a-z0-9\\-]+']], + ['name' => 'setup#saveConfig', 'url' => '/api/setup/config', 'verb' => 'POST'], ['name' => 'federation#objects', 'url' => '/api/federation/{shareToken}/objects', 'verb' => 'GET', 'requirements' => ['shareToken' => '[^/]+']], ['name' => 'federation#object', 'url' => '/api/federation/{shareToken}/objects/{id}', 'verb' => 'GET', 'requirements' => ['shareToken' => '[^/]+', 'id' => '[^/]+']], ['name' => 'federation#meta', 'url' => '/api/federation/{shareToken}/meta', 'verb' => 'GET', 'requirements' => ['shareToken' => '[^/]+']], @@ -52,6 +53,18 @@ ['name' => 'credential#registerApp', 'url' => '/api/credentials/apps/{appId}/register', 'verb' => 'POST', 'requirements' => ['appId' => '[a-z0-9_-]+']], ['name' => 'credential#brokerRequest', 'url' => '/api/credentials/{id}/request', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], ['name' => 'credential#sessionBrokerRequest', 'url' => '/api/credentials/{id}/session-request', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], + // OAuth2 connect (credential-oauth2-connect-flow). `start` sits under the + // LITERAL `/api/credentials/oauth2/...` prefix, declared BEFORE any + // `/api/credentials/{id}` route above would need to be consulted, so a + // credential whose UUID happened to be the string `oauth2` could never + // swallow it. The callback is deliberately NOT under /api: it is the URL a + // provider redirects a BROWSER to, and it is the value registered with the + // provider, so it must stay short, stable and free of an API prefix that + // might later gain middleware a redirect cannot satisfy. + ['name' => 'credentialOauth2#start', 'url' => '/api/credentials/oauth2/start', 'verb' => 'POST'], + ['name' => 'credentialOauth2#disconnect', 'url' => '/api/credentials/oauth2/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+']], + ['name' => 'credentialOauth2#callback', 'url' => '/oauth2/callback', 'verb' => 'GET'], + ['name' => 'credentialOauth2#clientMetadata', 'url' => '/oauth2/client-metadata.json', 'verb' => 'GET'], // Web Push channel (openregister-web-push-engine). // VAPID public key (browser subscribe key) + current-user subscription CRUD @@ -235,14 +248,6 @@ ['name' => 'Settings\ApiTokenSettings#testGitHubToken', 'url' => '/api/settings/api-tokens/test/github', 'verb' => 'POST'], ['name' => 'Settings\ApiTokenSettings#testGitLabToken', 'url' => '/api/settings/api-tokens/test/gitlab', 'verb' => 'POST'], - // n8n workflow integration. - ['name' => 'Settings\N8nSettings#getN8nSettings', 'url' => '/api/settings/n8n', 'verb' => 'GET'], - ['name' => 'Settings\N8nSettings#updateN8nSettings', 'url' => '/api/settings/n8n', 'verb' => 'POST'], - ['name' => 'Settings\N8nSettings#updateN8nSettings', 'url' => '/api/settings/n8n', 'verb' => 'PATCH'], - ['name' => 'Settings\N8nSettings#updateN8nSettings', 'url' => '/api/settings/n8n', 'verb' => 'PUT'], - ['name' => 'Settings\N8nSettings#testN8nConnection', 'url' => '/api/settings/n8n/test', 'verb' => 'POST'], - ['name' => 'Settings\N8nSettings#initializeN8n', 'url' => '/api/settings/n8n/initialize', 'verb' => 'POST'], - ['name' => 'Settings\N8nSettings#getWorkflows', 'url' => '/api/settings/n8n/workflows', 'verb' => 'GET'], // Statistics endpoint. ['name' => 'settings#getStatistics', 'url' => '/api/settings/statistics', 'verb' => 'GET'], @@ -550,6 +555,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' => '[^/]+']], @@ -795,15 +804,6 @@ ['name' => 'fileSidebar#getExtractionStatus', 'url' => '/api/files/{fileId}/extraction-status', 'verb' => 'GET', 'requirements' => ['fileId' => '[0-9]+']], // Action registry CRUD + utilities. - ['name' => 'actions#index', 'url' => '/api/actions', 'verb' => 'GET'], - ['name' => 'actions#create', 'url' => '/api/actions', 'verb' => 'POST'], - ['name' => 'actions#show', 'url' => '/api/actions/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[0-9]+']], - ['name' => 'actions#update', 'url' => '/api/actions/{id}', 'verb' => 'PUT', 'requirements' => ['id' => '[0-9]+']], - ['name' => 'actions#patch', 'url' => '/api/actions/{id}', 'verb' => 'PATCH', 'requirements' => ['id' => '[0-9]+']], - ['name' => 'actions#destroy', 'url' => '/api/actions/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '[0-9]+']], - ['name' => 'actions#test', 'url' => '/api/actions/{id}/test', 'verb' => 'POST', 'requirements' => ['id' => '[0-9]+']], - ['name' => 'actions#logs', 'url' => '/api/actions/{id}/logs', 'verb' => 'GET', 'requirements' => ['id' => '[0-9]+']], - ['name' => 'actions#migrateFromHooks', 'url' => '/api/actions/migrate-hooks/{schemaId}', 'verb' => 'POST', 'requirements' => ['schemaId' => '[0-9]+']], ['name' => 'objects#index', 'url' => '/api/objects/{register}/{schema}', 'verb' => 'GET'], @@ -1232,37 +1232,11 @@ ['name' => 'migrationPacks#export', 'url' => '/api/migration-packs/{id}/export', 'verb' => 'GET', 'requirements' => ['id' => '\d+']], // Workflow Engines - CRUD and health check. - ['name' => 'workflowEngine#available', 'url' => '/api/engines/available', 'verb' => 'GET'], - ['name' => 'workflowEngine#index', 'url' => '/api/engines', 'verb' => 'GET'], - ['name' => 'workflowEngine#create', 'url' => '/api/engines', 'verb' => 'POST'], - ['name' => 'workflowEngine#show', 'url' => '/api/engines/{id}', 'verb' => 'GET', 'requirements' => ['id' => '\d+']], - ['name' => 'workflowEngine#update', 'url' => '/api/engines/{id}', 'verb' => 'PUT', 'requirements' => ['id' => '\d+']], - ['name' => 'workflowEngine#destroy', 'url' => '/api/engines/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '\d+']], - ['name' => 'workflowEngine#health', 'url' => '/api/engines/{id}/health', 'verb' => 'POST', 'requirements' => ['id' => '\d+']], - ['name' => 'workflowEngine#testHook', 'url' => '/api/engines/{id}/test-hook', 'verb' => 'POST', 'requirements' => ['id' => '\d+']], // Workflow Execution History - read/admin-delete persisted hook executions. - ['name' => 'workflowExecution#index', 'url' => '/api/workflow-executions', 'verb' => 'GET'], - ['name' => 'workflowExecution#show', 'url' => '/api/workflow-executions/{id}', 'verb' => 'GET', 'requirements' => ['id' => '\d+']], - ['name' => 'workflowExecution#destroy', 'url' => '/api/workflow-executions/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '\d+']], // Scheduled Workflows - CRUD for TimedJob-driven workflow triggers. - ['name' => 'scheduledWorkflow#index', 'url' => '/api/scheduled-workflows', 'verb' => 'GET'], - ['name' => 'scheduledWorkflow#show', 'url' => '/api/scheduled-workflows/{id}', 'verb' => 'GET', 'requirements' => ['id' => '\d+']], - ['name' => 'scheduledWorkflow#create', 'url' => '/api/scheduled-workflows', 'verb' => 'POST'], - ['name' => 'scheduledWorkflow#update', 'url' => '/api/scheduled-workflows/{id}', 'verb' => 'PUT', 'requirements' => ['id' => '\d+']], - ['name' => 'scheduledWorkflow#destroy', 'url' => '/api/scheduled-workflows/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '\d+']], - - // Approval Chains - multi-step approval definitions and per-object progress. - ['name' => 'approval#index', 'url' => '/api/approval-chains', 'verb' => 'GET'], - ['name' => 'approval#show', 'url' => '/api/approval-chains/{id}', 'verb' => 'GET', 'requirements' => ['id' => '\d+']], - ['name' => 'approval#create', 'url' => '/api/approval-chains', 'verb' => 'POST'], - ['name' => 'approval#update', 'url' => '/api/approval-chains/{id}', 'verb' => 'PUT', 'requirements' => ['id' => '\d+']], - ['name' => 'approval#destroy', 'url' => '/api/approval-chains/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '\d+']], - ['name' => 'approval#objects', 'url' => '/api/approval-chains/{id}/objects', 'verb' => 'GET', 'requirements' => ['id' => '\d+']], - ['name' => 'approval#steps', 'url' => '/api/approval-steps', 'verb' => 'GET'], - ['name' => 'approval#approve', 'url' => '/api/approval-steps/{id}/approve', 'verb' => 'POST', 'requirements' => ['id' => '\d+']], - ['name' => 'approval#reject', 'url' => '/api/approval-steps/{id}/reject', 'verb' => 'POST', 'requirements' => ['id' => '\d+']], + // MCP Discovery - Tiered API discovery for AI agents. // CORS preflight (OPTIONS) is handled automatically by the @CORS annotation. @@ -1338,12 +1312,85 @@ // 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' => '[^/]+']], ['name' => 'flowRun#resume', 'url' => '/api/flow-runs/{uuid}/resume', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], + // Correlation-addressed signal delivery (flow-approval-consolidation): + // same authority as resume, addressed by business key instead of run + // uuid, fail-closed on zero and on more than one match. Registered on + // a literal segment so it can never shadow, or be shadowed by, the + // uuid-addressed routes. + ['name' => 'flowRun#signalByKey', 'url' => '/api/flow-run-signals/{key}', 'verb' => 'POST', 'requirements' => ['key' => '[^/]+']], // 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. + // The one stable "open this task" address (flow-task-inbox-projections): + // the VTODO URL, the notification buttons and the rule actions all + // resolve here. It serves the SPA SHELL (history-mode router), and the + // SPA's `flow-task-detail` route (src/main.js) renders the task; the bare + // `/flow-tasks` inbox list needs no entry of its own because + // `dashboard#catchAll` below already shells every non-api sub-path. + ['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' => '[^/]+']], + ['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' => '[^/]+']], + + // 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 + // 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. @@ -1366,5 +1413,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/composer.json b/composer.json index d1c459351f..75e563e94f 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "post-update-cmd": [ "@composer bin all update --ansi" ], + "check:migration-version": "php scripts/check-migration-version-bump.php", "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", @@ -84,7 +85,7 @@ "openapi": "generate-spec", "check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", "check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", - "check:strict": "E=0; for CMD in lint phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", + "check:strict": "E=0; for CMD in lint check:migration-version phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", "fix": [ "@cs:fix" ], @@ -159,7 +160,8 @@ "optimize-autoloader": true, "sort-packages": true, "platform": { - "php": "8.3" + "php": "8.3", + "ext-xsl": "1" } }, "extra": { diff --git a/composer.lock b/composer.lock index a9192b0f00..689b3f5e6d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e8d1e40030308144ae31c190012f7581", + "content-hash": "c0b042643db9431c7683bb92de4211ae", "packages": [ { "name": "adbario/php-dot-notation", @@ -6872,16 +6872,16 @@ }, { "name": "conduction/hydra-gates", - "version": "v1.10.0", + "version": "v1.15.1", "source": { "type": "git", "url": "https://github.com/ConductionNL/.github.git", - "reference": "d143bc27aecbb44a66c843dba89d374628bf9eef" + "reference": "4db52393987b85957787e144bc82bfaf67f4860c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ConductionNL/.github/zipball/d143bc27aecbb44a66c843dba89d374628bf9eef", - "reference": "d143bc27aecbb44a66c843dba89d374628bf9eef", + "url": "https://api.github.com/repos/ConductionNL/.github/zipball/4db52393987b85957787e144bc82bfaf67f4860c", + "reference": "4db52393987b85957787e144bc82bfaf67f4860c", "shasum": "" }, "require": { @@ -6920,9 +6920,9 @@ "support": { "docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md", "issues": "https://github.com/ConductionNL/.github/issues", - "source": "https://github.com/ConductionNL/.github/tree/v1.10.0" + "source": "https://github.com/ConductionNL/.github/tree/v1.15.1" }, - "time": "2026-08-27T16:18:04+00:00" + "time": "2026-09-05T12:26:12+00:00" }, { "name": "consolidation/annotated-command", @@ -13242,7 +13242,8 @@ }, "platform-dev": {}, "platform-overrides": { - "php": "8.3" + "php": "8.3", + "ext-xsl": "1" }, "plugin-api-version": "2.9.0" } diff --git a/docs/Features/search.md b/docs/Features/search.md index 82905fec24..594118be60 100644 --- a/docs/Features/search.md +++ b/docs/Features/search.md @@ -1132,14 +1132,12 @@ Clean up old search trail entries. #### Retention Policy -Configure search trail retention: - -```php -// In app configuration -'search_trail_retention_days' => 90, -'search_trail_cleanup_enabled' => true, -'search_trail_cleanup_schedule' => 'daily', -``` +Search trail retention is the `searchTrailRetention` value on the Retention +admin page (milliseconds, default `2592000000`, 30 days), read and written +through `GET`/`PATCH /api/settings/retention`. The hourly `LogCleanUpTask` +background job enforces it: rows without an expiry are stamped `created` plus +the retention, and rows whose expiry has passed are deleted. There is no +separate enable switch or schedule for the cleanup. #### Privacy Settings diff --git a/docs/development/approval-events-migration.md b/docs/development/approval-events-migration.md new file mode 100644 index 0000000000..49b878ef0f --- /dev/null +++ b/docs/development/approval-events-migration.md @@ -0,0 +1,81 @@ +# Approval events: the replacement mapping + +The four `ApprovalStep*Event` classes are removed by +`flow-approval-consolidation`. No event is re-emitted, aliased or wrapped. A +consumer that has not migrated stops receiving events at deploy, visibly, +instead of receiving prompts it can no longer answer. + +This page is the normative mapping for consumers. filinq's +`migrate-signing-to-or-tasks` change migrates against it. + +## Per event + +### ApprovalStepInitiatedEvent + +A step became `pending`. Replacement: a task of the sequence becoming +`enabled`. Subscribe to the task lifecycle and filter on `sequenceUuid`, or +read the enabled position through the task inbox. + +| Retired field | Replacement | +|---|---| +| `chain` | The sequence: `templateId`, `chainKey`, `schemaId` and the frozen `templateSnapshot`. | +| `step` | The enabled task. Ordinal in `sequencePosition`, role in `candidateGroups[0]`. | +| `objectUuid` | `task.objectUuid`, also `sequence.anchorObjectUuid`. | + +Ordering: the next position is enabled in the same request as the approving +decision, before that request returns. + +### ApprovalStepApprovedEvent + +Replacement: a sequence task completing with an approving outcome +(`TaskTerminalEvent` with `isCommitted() === true`, state `completed`, +outcome not in the rejecting vocabulary). + +| Retired field | Replacement | +|---|---| +| `chain` | The sequence, as above. | +| `step` | The completed task: `completedBy`, `completedAt`, `comment`. | +| `userId` | `task.completedBy`. The audit entry also carries `onBehalfOf` and `mandate`, which the retired event never had. | +| `statusOnApprove` | Resolved from the frozen snapshot; carried on `TaskSequenceCompletedEvent` for the final position. Mid-sequence it is readable from the position's `templateSnapshot`. | +| `nextStep` | The newly enabled task, readable from the sequence after the same request. No replacement carries it inline; named as such. | + +### ApprovalStepRejectedEvent + +Replacement: a sequence task completing with a rejecting outcome. The +comment is mandatory and is on `task.comment`. + +| Retired field | Replacement | +|---|---| +| `chain`, `step`, `userId` | As above. | +| `statusOnReject` | `sequence.outcome` after the rejection closed the sequence. | + +Ordering: the sequence closes and every remaining task is terminated in the +same request as the rejecting decision. + +### ApprovalStepCompletedEvent + +Replacement: `TaskSequenceCompletedEvent`, dispatched at exactly the same +moment: the final position completing with an approving outcome. + +| Retired field | Replacement | +|---|---| +| `chain` | `event.getSequence()`. | +| `finalStep` | `event.getFinalTask()`. | +| `userId` | `event.getDecider()`. | +| `statusOnApprove` | `event.getStatusOnApprove()`. | + +## The reply path + +The retired listener contract was a loop: filinq consumed the events and +called `ApprovalService::approveStep` or `rejectStep` back. The reply path +is now `TaskService::complete()` with an approving or rejecting outcome, or +the task routes over HTTP. Deciding an already terminal task is refused with +a reason naming its terminal state. + +## What to do + +Register a listener for `TaskTerminalEvent` and `TaskSequenceCompletedEvent`, +filter on your own sequences, and reply through the task verbs. The +retirement inventory in +`tests/fixtures/approval-consolidation/retired-approval-surface.json` lists +every removed route and class your app must no longer touch. diff --git a/docs/features/search-and-faceting.md b/docs/features/search-and-faceting.md index d425872cd4..743bacbd08 100644 --- a/docs/features/search-and-faceting.md +++ b/docs/features/search-and-faceting.md @@ -238,6 +238,16 @@ Recording is best-effort: a write failure logs a warning and the search returns normally, so analytics recording never degrades search availability. The settings round-trip through `GET`/`PATCH /api/settings/retention`. +**Retention.** A third setting on the same page, `searchTrailRetention` +(milliseconds, default `2592000000`, 30 days), says how long a trail entry is +kept. The hourly `LogCleanUpTask` background job enforces it: entries are +recorded without an expiry, so the job first stamps `expires` = `created` plus +the retention on entries that have none, then deletes every entry whose expiry +has passed. The same job tombstones expired audit trail rows; the two sweeps are +independent, so a failure in one never skips the other. A value of `0` or lower +keeps entries indefinitely. `POST /api/search-trails/cleanup` runs the same +deletion on demand. + **Standards**: BIO (audit logging), AVG/GDPR Article 30 (processing register context). ## Related Features diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7fe4dcdf3a..9c13cc1d67 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -153,41 +153,6 @@ "count": 2 } }, - "src/components/workflow/ApprovalChainPanel.vue": { - "no-console": { - "count": 2 - } - }, - "src/components/workflow/ApprovalStepList.vue": { - "@nextcloud/l10n-enforce-ellipsis": { - "count": 2 - }, - "jsdoc/require-param-type": { - "count": 2 - }, - "no-console": { - "count": 3 - }, - "vue/prefer-define-options": { - "count": 1 - } - }, - "src/components/workflow/ScheduledWorkflowPanel.vue": { - "jsdoc/require-param-type": { - "count": 1 - }, - "no-console": { - "count": 2 - } - }, - "src/components/workflow/WorkflowExecutionPanel.vue": { - "jsdoc/require-param-type": { - "count": 1 - }, - "no-console": { - "count": 1 - } - }, "src/dialogs/Dialogs.vue": { "vue/multi-word-component-names": { "count": 1 @@ -196,11 +161,6 @@ "count": 1 } }, - "src/dialogs/TestHookDialog.vue": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/dialogs/avg/DenialComposerDialog.vue": { "@nextcloud/no-deprecated-library-props": { "count": 1 @@ -242,11 +202,6 @@ "count": 1 } }, - "src/dialogs/workflow/TestHookDialog.vue": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/entities/application/application.mock.ts": { "import-extensions/extensions": { "count": 1 @@ -1745,11 +1700,6 @@ "count": 1 } }, - "src/views/schemas/SchemaWorkflowTab.vue": { - "jsdoc/require-param-type": { - "count": 4 - } - }, "src/views/search/SearchIndex.vue": { "eqeqeq": { "count": 2 @@ -1831,17 +1781,6 @@ "count": 1 } }, - "src/views/settings/sections/N8nConfiguration.vue": { - "@nextcloud/l10n-enforce-ellipsis": { - "count": 2 - }, - "jsdoc/check-tag-names": { - "count": 1 - }, - "no-console": { - "count": 3 - } - }, "src/views/settings/sections/OrganisationConfiguration.vue": { "no-console": { "count": 4 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/img/lock.svg b/img/lock.svg new file mode 100644 index 0000000000..5c0eb3f668 --- /dev/null +++ b/img/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/img/unlock.svg b/img/unlock.svg new file mode 100644 index 0000000000..4e0291dad5 --- /dev/null +++ b/img/unlock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/l10n/.schema-l10n-baseline.json b/l10n/.schema-l10n-baseline.json index 3ed52820fd..63f26dc5a0 100644 --- a/l10n/.schema-l10n-baseline.json +++ b/l10n/.schema-l10n-baseline.json @@ -1,3 +1,3 @@ { - "uncovered": 634 + "uncovered": 623 } diff --git a/l10n/be.js b/l10n/be.js index c6a5ca9768..5ca3889b20 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Падлучыце ўліковы запіс сацсеткі або аналітыкі адзін раз. OpenRegister захоўвае токен і абнаўляе яго, таму праграмы ніколі яго не трымаюць.", + "Connect account": "Падлучыць уліковы запіс", + "Connected accounts": "Падлучаныя ўліковыя запісы", + "Could not disconnect this account.": "Не ўдалося адлучыць гэты ўліковы запіс.", + "Could not load your connected accounts.": "Не ўдалося загрузіць вашы падлучаныя ўліковыя запісы.", + "Could not start the connection. Check the provider and try again.": "Не ўдалося пачаць падлучэнне. Праверце пастаўшчыка і паспрабуйце зноў.", + "Disconnect": "Адлучыць", + "Expired": "Пратэрмінавана", + "Expires {date}": "Сканчаецца {date}", + "No accounts are connected yet.": "Пакуль не падлучаны ніводны ўліковы запіс.", + "Not connected yet": "Яшчэ не падлучана", + "Reconnect": "Падлучыць зноў", + "Relink needed": "Патрэбна паўторная прывязка", + "Server address": "Адрас сервера", + "Load example data?": "Загрузіць прыклады даных?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Прыклады даных запаўняюць спісы, старонкі падрабязнасцей і панэлі, каб вы адразу ўбачылі праграму ў працы. На працоўнай усталёўцы абярыце \"Няма\".", + "Load the example data": "Загрузіць прыклады даных", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Загружае тое, што вы абралі. Гэта відавочна прыклады даных, дзеянне можна бяспечна паўтарыць, а потым іх можна выдаліць.", + "None, I will set this up myself": "Няма, я наладжу гэта сам", + "Nothing is imported. You start with an empty app and add your own data.": "Нічога не імпартуецца. Вы пачынаеце з пустой праграмы і дадаяце свае даныя.", + "Example data": "Прыклады даных", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Прыклады значэнняў для кожнай схемы, якую пастаўляе гэтая праграма, згенераваныя з саміх схем. Яны паказваюць спісы, старонкі падрабязнасцей і панэлі ў працы, а не расказваюць гісторыю. Бяспечна паўтараць і потым выдаліць.", + "from amount {amount}": "ад сумы {amount}", + "Gated transition: {transition}": "Кантраляваны пераход: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Кожны абвешчаны ланцужок адкрывае ўпарадкаваную паслядоўнасць задач пры спробе пераходу. Узгадняльнікі вырашаюць у сваёй скрыні задач, а не тут.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Абвясціце x-openregister-approval-chains у гэтай схеме, каб пераход жыццёвага цыкла патрабаваў узгаднення.", + "Approval sequences": "Паслядоўнасці ўзгаднення", "%n entries have no hash yet": "%n запісаў яшчэ не маюць хэша", "%n entry has no hash yet": "%n запіс яшчэ не мае хэша", "(Dolphin required)": "(требуется Dolphin)", + "No approval chains declared": "Ланцужкі ўзгаднення не абвешчаны", "(no subject)": "(без темы)", "(no title)": "(без названия)", + "Role {role}": "Роля {role}", + "The transition is applied automatically once every step approves.": "Пераход ужываецца аўтаматычна, як толькі кожны крок узгоднены.", + "The requester may not decide their own approval.": "Заяўнік не можа вырашаць наконт уласнага ўзгаднення.", + "Tier routing on field: {field}": "Ступеньчатая маршрутызацыя па полі: {field}", "(unnamed)": "(без имени)", "(untitled card)": "(карточка без названия)", "({count} selected)": "({count} выбрано)", @@ -2738,7 +2770,88 @@ 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 у тэрмін або пасля.", + "No such task": "Няма такой задачы", + "The task does not exist, or it is not yours to see.": "Задача не існуе, або вы не маеце права яе бачыць.", + "Details": "Падрабязнасці", + "Due": "Тэрмін", + "Assignee": "Выканаўца", + "Requested by": "Запытаў", + "Outcome": "Вынік", + "Ready to claim": "Гатова да ўзяцця", + "In progress": "Выконваецца", + "Terminated": "Спынена", + "Normal": "Звычайны", + "Urgent": "Тэрміновы", + "Overdue by 1 day": "Пратэрмінавана на 1 дзень", + "Overdue by {days} days": "Пратэрмінавана на {days} дз.", + "Due today": "Тэрмін сёння", + "Due tomorrow": "Тэрмін заўтра", + "Due in {days} days": "Тэрмін праз {days} дз.", + "Tasks": "Задачы", + "Task": "Задача", + "Unassigned, open for claiming": "Не прызначана, адкрыта для ўзяцця", + "Claim": "Узяць", + "Complete": "Завяршыць", + "Complete: {outcome}": "Завяршыць: {outcome}", + "Could not load the task": "Не ўдалося загрузіць задачу", + "The task refused that action": "Задача адхіліла гэта дзеянне", + "Store": "Крама", + "Install registers, schemas and flows that other organisations have published.": "Усталюйце рэестры, схемы і патокі, апублікаваныя іншымі арганізацыямі.", + "No such run": "Няма такога запуску", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Запуск не існуе, або вы не маеце права яго бачыць. Выдаленне патоку выдаляе і яго запускі." }, "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..76cede97d9 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Падлучыце ўліковы запіс сацсеткі або аналітыкі адзін раз. OpenRegister захоўвае токен і абнаўляе яго, таму праграмы ніколі яго не трымаюць.", + "Connect account": "Падлучыць уліковы запіс", + "Connected accounts": "Падлучаныя ўліковыя запісы", + "Could not disconnect this account.": "Не ўдалося адлучыць гэты ўліковы запіс.", + "Could not load your connected accounts.": "Не ўдалося загрузіць вашы падлучаныя ўліковыя запісы.", + "Could not start the connection. Check the provider and try again.": "Не ўдалося пачаць падлучэнне. Праверце пастаўшчыка і паспрабуйце зноў.", + "Disconnect": "Адлучыць", + "Expired": "Пратэрмінавана", + "Expires {date}": "Сканчаецца {date}", + "No accounts are connected yet.": "Пакуль не падлучаны ніводны ўліковы запіс.", + "Not connected yet": "Яшчэ не падлучана", + "Reconnect": "Падлучыць зноў", + "Relink needed": "Патрэбна паўторная прывязка", + "Server address": "Адрас сервера", + "Load example data?": "Загрузіць прыклады даных?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Прыклады даных запаўняюць спісы, старонкі падрабязнасцей і панэлі, каб вы адразу ўбачылі праграму ў працы. На працоўнай усталёўцы абярыце \"Няма\".", + "Load the example data": "Загрузіць прыклады даных", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Загружае тое, што вы абралі. Гэта відавочна прыклады даных, дзеянне можна бяспечна паўтарыць, а потым іх можна выдаліць.", + "None, I will set this up myself": "Няма, я наладжу гэта сам", + "Nothing is imported. You start with an empty app and add your own data.": "Нічога не імпартуецца. Вы пачынаеце з пустой праграмы і дадаяце свае даныя.", + "Example data": "Прыклады даных", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Прыклады значэнняў для кожнай схемы, якую пастаўляе гэтая праграма, згенераваныя з саміх схем. Яны паказваюць спісы, старонкі падрабязнасцей і панэлі ў працы, а не расказваюць гісторыю. Бяспечна паўтараць і потым выдаліць.", + "from amount {amount}": "ад сумы {amount}", + "Gated transition: {transition}": "Кантраляваны пераход: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Кожны абвешчаны ланцужок адкрывае ўпарадкаваную паслядоўнасць задач пры спробе пераходу. Узгадняльнікі вырашаюць у сваёй скрыні задач, а не тут.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Абвясціце x-openregister-approval-chains у гэтай схеме, каб пераход жыццёвага цыкла патрабаваў узгаднення.", + "Approval sequences": "Паслядоўнасці ўзгаднення", "%n entries have no hash yet": "%n запісаў яшчэ не маюць хэша", "%n entry has no hash yet": "%n запіс яшчэ не мае хэша", "(Dolphin required)": "(требуется Dolphin)", + "No approval chains declared": "Ланцужкі ўзгаднення не абвешчаны", "(no subject)": "(без темы)", "(no title)": "(без названия)", + "Role {role}": "Роля {role}", + "The transition is applied automatically once every step approves.": "Пераход ужываецца аўтаматычна, як толькі кожны крок узгоднены.", + "The requester may not decide their own approval.": "Заяўнік не можа вырашаць наконт уласнага ўзгаднення.", + "Tier routing on field: {field}": "Ступеньчатая маршрутызацыя па полі: {field}", "(unnamed)": "(без имени)", "(untitled card)": "(карточка без названия)", "({count} selected)": "({count} выбрано)", @@ -2817,7 +2849,88 @@ "Паспяхова адноўленыя {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 у тэрмін або пасля.", + "No such task": "Няма такой задачы", + "The task does not exist, or it is not yours to see.": "Задача не існуе, або вы не маеце права яе бачыць.", + "Details": "Падрабязнасці", + "Due": "Тэрмін", + "Assignee": "Выканаўца", + "Requested by": "Запытаў", + "Outcome": "Вынік", + "Ready to claim": "Гатова да ўзяцця", + "In progress": "Выконваецца", + "Terminated": "Спынена", + "Normal": "Звычайны", + "Urgent": "Тэрміновы", + "Overdue by 1 day": "Пратэрмінавана на 1 дзень", + "Overdue by {days} days": "Пратэрмінавана на {days} дз.", + "Due today": "Тэрмін сёння", + "Due tomorrow": "Тэрмін заўтра", + "Due in {days} days": "Тэрмін праз {days} дз.", + "Tasks": "Задачы", + "Task": "Задача", + "Unassigned, open for claiming": "Не прызначана, адкрыта для ўзяцця", + "Claim": "Узяць", + "Complete": "Завяршыць", + "Complete: {outcome}": "Завяршыць: {outcome}", + "Could not load the task": "Не ўдалося загрузіць задачу", + "The task refused that action": "Задача адхіліла гэта дзеянне", + "Store": "Крама", + "Install registers, schemas and flows that other organisations have published.": "Усталюйце рэестры, схемы і патокі, апублікаваныя іншымі арганізацыямі.", + "No such run": "Няма такога запуску", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Запуск не існуе, або вы не маеце права яго бачыць. Выдаленне патоку выдаляе і яго запускі." }, "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..bb48783703 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Свържете социален или аналитичен акаунт еднократно. OpenRegister пази токена и го подновява, така че приложенията никога не го държат.", + "Connect account": "Свързване на акаунт", + "Connected accounts": "Свързани акаунти", + "Could not disconnect this account.": "Този акаунт не можа да бъде разкачен.", + "Could not load your connected accounts.": "Свързаните ви акаунти не можаха да бъдат заредени.", + "Could not start the connection. Check the provider and try again.": "Свързването не можа да започне. Проверете доставчика и опитайте отново.", + "Disconnect": "Разкачване", + "Expired": "Изтекъл", + "Expires {date}": "Изтича на {date}", + "No accounts are connected yet.": "Още няма свързани акаунти.", + "Not connected yet": "Още не е свързан", + "Reconnect": "Свързване отново", + "Relink needed": "Нужно е ново свързване", + "Server address": "Адрес на сървъра", + "Load example data?": "Да се заредят ли примерни данни?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Примерните данни попълват списъците, страниците с подробности и таблата, за да видите приложението веднага в действие. Изберете \"Няма\" при производствена инсталация.", + "Load the example data": "Заредете примерните данни", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Зарежда това, което сте избрали. Данните са явно примерни, действието може да се повтори безопасно и след това можете да ги изтриете.", + "None, I will set this up myself": "Няма, ще настроя това сам", + "Nothing is imported. You start with an empty app and add your own data.": "Нищо не се внася. Започвате с празно приложение и добавяте собствени данни.", + "Example data": "Примерни данни", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Примерни стойности за всяка схема, която приложението предоставя, генерирани от самите схеми. Показват списъците, страниците с подробности и таблата в действие, вместо да разказват история. Безопасно за повторно изпълнение и може да се изтрие после.", + "from amount {amount}": "от сума {amount}", + "Gated transition: {transition}": "Контролиран преход: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Всяка декларирана верига отваря подредена последователност от задачи при опит за прехода. Одобряващите решават в своята кутия със задачи, не тук.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Декларирайте x-openregister-approval-chains в тази схема, за да поставите преход от жизнения цикъл зад одобрение.", + "Approval sequences": "Последователности за одобрение", "%n entries have no hash yet": "%n записа все още нямат хеш", "%n entry has no hash yet": "%n запис все още няма хеш", "(Dolphin required)": "(изисква се Dolphin)", + "No approval chains declared": "Няма декларирани вериги за одобрение", "(no subject)": "(без тема)", "(no title)": "(без заглавие)", + "Role {role}": "Роля {role}", + "The transition is applied automatically once every step approves.": "Преходът се прилага автоматично, щом всяка стъпка бъде одобрена.", + "The requester may not decide their own approval.": "Заявителят не може да решава за собственото си одобрение.", + "Tier routing on field: {field}": "Маршрутизиране по нива по полето: {field}", "(unnamed)": "(без име)", "(untitled card)": "(карта без заглавие)", "({count} selected)": "({count} избрани)", @@ -2738,7 +2770,88 @@ 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 на срока или след него.", + "No such task": "Няма такава задача", + "The task does not exist, or it is not yours to see.": "Задачата не съществува или нямате право да я видите.", + "Details": "Детайли", + "Due": "Срок", + "Assignee": "Изпълнител", + "Requested by": "Заявено от", + "Outcome": "Резултат", + "Ready to claim": "Готово за поемане", + "In progress": "В процес", + "Terminated": "Прекратена", + "Normal": "Нормален", + "Urgent": "Спешен", + "Overdue by 1 day": "Просрочена с 1 ден", + "Overdue by {days} days": "Просрочена с {days} дни", + "Due today": "Срок днес", + "Due tomorrow": "Срок утре", + "Due in {days} days": "Срок след {days} дни", + "Tasks": "Задачи", + "Task": "Задача", + "Unassigned, open for claiming": "Неразпределена, отворена за поемане", + "Claim": "Поемане", + "Complete": "Завършване", + "Complete: {outcome}": "Завършване: {outcome}", + "Could not load the task": "Задачата не можа да се зареди", + "The task refused that action": "Задачата отказа това действие", + "Store": "Магазин", + "Install registers, schemas and flows that other organisations have published.": "Инсталирайте регистри, схеми и потоци, публикувани от други организации.", + "No such run": "Няма такова изпълнение", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Изпълнението не съществува или нямате право да го видите. Изтриването на поток изтрива и изпълненията му." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index b248b7bffd..7e4c5556fc 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Свържете социален или аналитичен акаунт еднократно. OpenRegister пази токена и го подновява, така че приложенията никога не го държат.", + "Connect account": "Свързване на акаунт", + "Connected accounts": "Свързани акаунти", + "Could not disconnect this account.": "Този акаунт не можа да бъде разкачен.", + "Could not load your connected accounts.": "Свързаните ви акаунти не можаха да бъдат заредени.", + "Could not start the connection. Check the provider and try again.": "Свързването не можа да започне. Проверете доставчика и опитайте отново.", + "Disconnect": "Разкачване", + "Expired": "Изтекъл", + "Expires {date}": "Изтича на {date}", + "No accounts are connected yet.": "Още няма свързани акаунти.", + "Not connected yet": "Още не е свързан", + "Reconnect": "Свързване отново", + "Relink needed": "Нужно е ново свързване", + "Server address": "Адрес на сървъра", + "Load example data?": "Да се заредят ли примерни данни?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Примерните данни попълват списъците, страниците с подробности и таблата, за да видите приложението веднага в действие. Изберете \"Няма\" при производствена инсталация.", + "Load the example data": "Заредете примерните данни", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Зарежда това, което сте избрали. Данните са явно примерни, действието може да се повтори безопасно и след това можете да ги изтриете.", + "None, I will set this up myself": "Няма, ще настроя това сам", + "Nothing is imported. You start with an empty app and add your own data.": "Нищо не се внася. Започвате с празно приложение и добавяте собствени данни.", + "Example data": "Примерни данни", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Примерни стойности за всяка схема, която приложението предоставя, генерирани от самите схеми. Показват списъците, страниците с подробности и таблата в действие, вместо да разказват история. Безопасно за повторно изпълнение и може да се изтрие после.", + "from amount {amount}": "от сума {amount}", + "Gated transition: {transition}": "Контролиран преход: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Всяка декларирана верига отваря подредена последователност от задачи при опит за прехода. Одобряващите решават в своята кутия със задачи, не тук.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Декларирайте x-openregister-approval-chains в тази схема, за да поставите преход от жизнения цикъл зад одобрение.", + "Approval sequences": "Последователности за одобрение", "%n entries have no hash yet": "%n записа все още нямат хеш", "%n entry has no hash yet": "%n запис все още няма хеш", "(Dolphin required)": "(изисква се Dolphin)", + "No approval chains declared": "Няма декларирани вериги за одобрение", "(no subject)": "(без тема)", "(no title)": "(без заглавие)", + "Role {role}": "Роля {role}", + "The transition is applied automatically once every step approves.": "Преходът се прилага автоматично, щом всяка стъпка бъде одобрена.", + "The requester may not decide their own approval.": "Заявителят не може да решава за собственото си одобрение.", + "Tier routing on field: {field}": "Маршрутизиране по нива по полето: {field}", "(unnamed)": "(без име)", "(untitled card)": "(карта без заглавие)", "({count} selected)": "({count} избрани)", @@ -2785,7 +2817,88 @@ "_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 на срока или след него.", + "No such task": "Няма такава задача", + "The task does not exist, or it is not yours to see.": "Задачата не съществува или нямате право да я видите.", + "Details": "Детайли", + "Due": "Срок", + "Assignee": "Изпълнител", + "Requested by": "Заявено от", + "Outcome": "Резултат", + "Ready to claim": "Готово за поемане", + "In progress": "В процес", + "Terminated": "Прекратена", + "Normal": "Нормален", + "Urgent": "Спешен", + "Overdue by 1 day": "Просрочена с 1 ден", + "Overdue by {days} days": "Просрочена с {days} дни", + "Due today": "Срок днес", + "Due tomorrow": "Срок утре", + "Due in {days} days": "Срок след {days} дни", + "Tasks": "Задачи", + "Task": "Задача", + "Unassigned, open for claiming": "Неразпределена, отворена за поемане", + "Claim": "Поемане", + "Complete": "Завършване", + "Complete: {outcome}": "Завършване: {outcome}", + "Could not load the task": "Задачата не можа да се зареди", + "The task refused that action": "Задачата отказа това действие", + "Store": "Магазин", + "Install registers, schemas and flows that other organisations have published.": "Инсталирайте регистри, схеми и потоци, публикувани от други организации.", + "No such run": "Няма такова изпълнение", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Изпълнението не съществува или нямате право да го видите. Изтриването на поток изтрива и изпълненията му." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/bs.js b/l10n/bs.js index 9fe988bdb6..106f25c762 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Poveži društveni ili analitički nalog jednom. OpenRegister čuva token i obnavlja ga, pa ga aplikacije nikada ne drže.", + "Connect account": "Poveži nalog", + "Connected accounts": "Povezani nalozi", + "Could not disconnect this account.": "Ovaj nalog nije bilo moguće odspojiti.", + "Could not load your connected accounts.": "Nije bilo moguće učitati tvoje povezane naloge.", + "Could not start the connection. Check the provider and try again.": "Nije bilo moguće pokrenuti povezivanje. Provjeri pružaoca i pokušaj ponovo.", + "Disconnect": "Odspoji", + "Expired": "Isteklo", + "Expires {date}": "Ističe {date}", + "No accounts are connected yet.": "Još nije povezan nijedan nalog.", + "Not connected yet": "Još nije povezano", + "Reconnect": "Poveži ponovo", + "Relink needed": "Potrebno je ponovno povezivanje", + "Server address": "Adresa servera", + "Load example data?": "Učitati primjere podataka?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Primjeri podataka popunjavaju liste, stranice s detaljima i kontrolne table, pa aplikaciju odmah vidite kako radi. Odaberite \"Ništa\" na produkcijskoj instalaciji.", + "Load the example data": "Učitaj primjere podataka", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Učitava ono što ste odabrali. To su očito primjeri podataka, radnja se može sigurno ponoviti, a poslije ih možete obrisati.", + "None, I will set this up myself": "Ništa, sam ću ovo postaviti", + "Nothing is imported. You start with an empty app and add your own data.": "Ništa se ne uvozi. Počinjete s praznom aplikacijom i dodajete vlastite podatke.", + "Example data": "Primjeri podataka", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Primjeri vrijednosti za svaku shemu koju ova aplikacija donosi, generisani iz samih shema. Pokazuju liste, stranice s detaljima i kontrolne table u radu umjesto da pričaju priču. Sigurno za ponavljanje i brisanje poslije.", + "from amount {amount}": "od iznosa {amount}", + "Gated transition: {transition}": "Nadzirani prelaz: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Svaki deklarisani lanac otvara uređeni niz zadataka kad se prelaz pokuša. Odobravaoci odlučuju u svom sandučetu zadataka, ne ovdje.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarišite x-openregister-approval-chains na ovoj šemi kako bi prelaz životnog ciklusa zahtijevao odobrenje.", + "Approval sequences": "Nizovi odobravanja", "%n entries have no hash yet": "%n unosa još nema heš", "%n entry has no hash yet": "%n unos još nema heš", "(Dolphin required)": "(Potreban Dolphin)", + "No approval chains declared": "Nema deklarisanih lanaca odobravanja", "(no subject)": "(bez predmeta)", "(no title)": "(bez naslova)", + "Role {role}": "Uloga {role}", + "The transition is applied automatically once every step approves.": "Prelaz se primjenjuje automatski čim je svaki korak odobren.", + "The requester may not decide their own approval.": "Podnosilac zahtjeva ne može odlučivati o vlastitom odobrenju.", + "Tier routing on field: {field}": "Usmjeravanje po razredima prema polju: {field}", "(unnamed)": "(bez naziva)", "(untitled card)": "(kartica bez naslova)", "({count} selected)": "({count} odabrano)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Nema takvog zadatka", + "The task does not exist, or it is not yours to see.": "Zadatak ne postoji ili ga ne smijete vidjeti.", + "Details": "Detalji", + "Due": "Rok", + "Assignee": "Dodijeljeno", + "Requested by": "Zatražio", + "Outcome": "Ishod", + "Ready to claim": "Spremno za preuzimanje", + "In progress": "U toku", + "Terminated": "Prekinuto", + "Normal": "Normalno", + "Urgent": "Hitno", + "Overdue by 1 day": "Kasni 1 dan", + "Overdue by {days} days": "Kasni {days} dana", + "Due today": "Rok je danas", + "Due tomorrow": "Rok je sutra", + "Due in {days} days": "Rok za {days} dana", + "Tasks": "Zadaci", + "Task": "Zadatak", + "Unassigned, open for claiming": "Nedodijeljeno, otvoreno za preuzimanje", + "Claim": "Preuzmi", + "Complete": "Završi", + "Complete: {outcome}": "Završi: {outcome}", + "Could not load the task": "Zadatak se ne može učitati", + "The task refused that action": "Zadatak je odbio tu radnju", + "Store": "Trgovina", + "Install registers, schemas and flows that other organisations have published.": "Instalirajte registre, šeme i tokove koje su objavile druge organizacije.", + "No such run": "Nema takvog izvršavanja", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Izvršavanje ne postoji ili ga ne smijete vidjeti. Brisanjem toka brišu se i njegova izvršavanja." }, "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..ff2bb5d69d 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Poveži društveni ili analitički nalog jednom. OpenRegister čuva token i obnavlja ga, pa ga aplikacije nikada ne drže.", + "Connect account": "Poveži nalog", + "Connected accounts": "Povezani nalozi", + "Could not disconnect this account.": "Ovaj nalog nije bilo moguće odspojiti.", + "Could not load your connected accounts.": "Nije bilo moguće učitati tvoje povezane naloge.", + "Could not start the connection. Check the provider and try again.": "Nije bilo moguće pokrenuti povezivanje. Provjeri pružaoca i pokušaj ponovo.", + "Disconnect": "Odspoji", + "Expired": "Isteklo", + "Expires {date}": "Ističe {date}", + "No accounts are connected yet.": "Još nije povezan nijedan nalog.", + "Not connected yet": "Još nije povezano", + "Reconnect": "Poveži ponovo", + "Relink needed": "Potrebno je ponovno povezivanje", + "Server address": "Adresa servera", + "Load example data?": "Učitati primjere podataka?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Primjeri podataka popunjavaju liste, stranice s detaljima i kontrolne table, pa aplikaciju odmah vidite kako radi. Odaberite \"Ništa\" na produkcijskoj instalaciji.", + "Load the example data": "Učitaj primjere podataka", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Učitava ono što ste odabrali. To su očito primjeri podataka, radnja se može sigurno ponoviti, a poslije ih možete obrisati.", + "None, I will set this up myself": "Ništa, sam ću ovo postaviti", + "Nothing is imported. You start with an empty app and add your own data.": "Ništa se ne uvozi. Počinjete s praznom aplikacijom i dodajete vlastite podatke.", + "Example data": "Primjeri podataka", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Primjeri vrijednosti za svaku shemu koju ova aplikacija donosi, generisani iz samih shema. Pokazuju liste, stranice s detaljima i kontrolne table u radu umjesto da pričaju priču. Sigurno za ponavljanje i brisanje poslije.", + "from amount {amount}": "od iznosa {amount}", + "Gated transition: {transition}": "Nadzirani prelaz: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Svaki deklarisani lanac otvara uređeni niz zadataka kad se prelaz pokuša. Odobravaoci odlučuju u svom sandučetu zadataka, ne ovdje.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarišite x-openregister-approval-chains na ovoj šemi kako bi prelaz životnog ciklusa zahtijevao odobrenje.", + "Approval sequences": "Nizovi odobravanja", "%n entries have no hash yet": "%n unosa još nema heš", "%n entry has no hash yet": "%n unos još nema heš", "(Dolphin required)": "(Potreban Dolphin)", + "No approval chains declared": "Nema deklarisanih lanaca odobravanja", "(no subject)": "(bez predmeta)", "(no title)": "(bez naslova)", + "Role {role}": "Uloga {role}", + "The transition is applied automatically once every step approves.": "Prelaz se primjenjuje automatski čim je svaki korak odobren.", + "The requester may not decide their own approval.": "Podnosilac zahtjeva ne može odlučivati o vlastitom odobrenju.", + "Tier routing on field: {field}": "Usmjeravanje po razredima prema polju: {field}", "(unnamed)": "(bez naziva)", "(untitled card)": "(kartica bez naslova)", "({count} selected)": "({count} odabrano)", @@ -2801,7 +2833,88 @@ "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.", + "No such task": "Nema takvog zadatka", + "The task does not exist, or it is not yours to see.": "Zadatak ne postoji ili ga ne smijete vidjeti.", + "Details": "Detalji", + "Due": "Rok", + "Assignee": "Dodijeljeno", + "Requested by": "Zatražio", + "Outcome": "Ishod", + "Ready to claim": "Spremno za preuzimanje", + "In progress": "U toku", + "Terminated": "Prekinuto", + "Normal": "Normalno", + "Urgent": "Hitno", + "Overdue by 1 day": "Kasni 1 dan", + "Overdue by {days} days": "Kasni {days} dana", + "Due today": "Rok je danas", + "Due tomorrow": "Rok je sutra", + "Due in {days} days": "Rok za {days} dana", + "Tasks": "Zadaci", + "Task": "Zadatak", + "Unassigned, open for claiming": "Nedodijeljeno, otvoreno za preuzimanje", + "Claim": "Preuzmi", + "Complete": "Završi", + "Complete: {outcome}": "Završi: {outcome}", + "Could not load the task": "Zadatak se ne može učitati", + "The task refused that action": "Zadatak je odbio tu radnju", + "Store": "Trgovina", + "Install registers, schemas and flows that other organisations have published.": "Instalirajte registre, šeme i tokove koje su objavile druge organizacije.", + "No such run": "Nema takvog izvršavanja", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Izvršavanje ne postoji ili ga ne smijete vidjeti. Brisanjem toka brišu se i njegova izvršavanja." }, "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..514569d0c6 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Connecta un compte social o d’analítica una sola vegada. L’OpenRegister desa el testimoni i el renova, de manera que les aplicacions no el retenen mai.", + "Connect account": "Connecta el compte", + "Connected accounts": "Comptes connectats", + "Could not disconnect this account.": "No s’ha pogut desconnectar aquest compte.", + "Could not load your connected accounts.": "No s’han pogut carregar els vostres comptes connectats.", + "Could not start the connection. Check the provider and try again.": "No s’ha pogut iniciar la connexió. Comproveu el proveïdor i torneu-ho a provar.", + "Disconnect": "Desconnecta", + "Expired": "Caducat", + "Expires {date}": "Caduca el {date}", + "No accounts are connected yet.": "Encara no hi ha cap compte connectat.", + "Not connected yet": "Encara no connectat", + "Reconnect": "Torna a connectar", + "Relink needed": "Cal tornar a enllaçar", + "Server address": "Adreça del servidor", + "Load example data?": "Voleu carregar dades d’exemple?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Les dades d’exemple omplen les llistes, les pàgines de detall i els taulers, perquè vegeu l’aplicació funcionant de seguida. Trieu \"Cap\" en una instal·lació de producció.", + "Load the example data": "Carrega les dades d’exemple", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Carrega el que heu triat. Són clarament dades d’exemple, l’operació es pot repetir sense risc i les podeu esborrar després.", + "None, I will set this up myself": "Cap, ja ho configuraré jo mateix", + "Nothing is imported. You start with an empty app and add your own data.": "No s’importa res. Comenceu amb una aplicació buida i hi afegiu les vostres dades.", + "Example data": "Dades d’exemple", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valors d’exemple per a cada esquema que aporta aquesta aplicació, generats a partir dels esquemes mateixos. Mostren les llistes, les pàgines de detall i els taulers funcionant en lloc d’explicar una història. Es pot repetir sense risc i esborrar després.", + "from amount {amount}": "a partir de l'import {amount}", + "Gated transition: {transition}": "Transició controlada: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Cada cadena declarada obre una seqüència ordenada de tasques quan s'intenta la transició. Els aprovadors decideixen a la seva safata de tasques, no aquí.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declareu x-openregister-approval-chains en aquest esquema per condicionar una transició del cicle de vida a una aprovació.", + "Approval sequences": "Seqüències d'aprovació", "%n entries have no hash yet": "%n entrades encara no tenen resum", "%n entry has no hash yet": "%n entrada encara no té resum", "(Dolphin required)": "(Es requereix Dolphin)", + "No approval chains declared": "No hi ha cadenes d'aprovació declarades", "(no subject)": "(sense assumpte)", "(no title)": "(sense títol)", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "La transició s'aplica automàticament quan tots els passos estan aprovats.", + "The requester may not decide their own approval.": "El sol·licitant no pot decidir sobre la seva pròpia aprovació.", + "Tier routing on field: {field}": "Encaminament per trams segons el camp: {field}", "(unnamed)": "(sense nom)", "(untitled card)": "(targeta sense títol)", "({count} selected)": "({count} seleccionats)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "No existeix aquesta tasca", + "The task does not exist, or it is not yours to see.": "La tasca no existeix o no la podeu veure.", + "Details": "Detalls", + "Due": "Venciment", + "Assignee": "Assignat a", + "Requested by": "Sol·licitat per", + "Outcome": "Resultat", + "Ready to claim": "A punt per reclamar", + "In progress": "En curs", + "Terminated": "Finalitzada", + "Normal": "Normal", + "Urgent": "Urgent", + "Overdue by 1 day": "Endarrerida 1 dia", + "Overdue by {days} days": "Endarrerida {days} dies", + "Due today": "Venç avui", + "Due tomorrow": "Venç demà", + "Due in {days} days": "Venç d'aquí a {days} dies", + "Tasks": "Tasques", + "Task": "Tasca", + "Unassigned, open for claiming": "Sense assignar, oberta per reclamar", + "Claim": "Reclama", + "Complete": "Completa", + "Complete: {outcome}": "Completa: {outcome}", + "Could not load the task": "No s'ha pogut carregar la tasca", + "The task refused that action": "La tasca ha rebutjat aquesta acció", + "Store": "Botiga", + "Install registers, schemas and flows that other organisations have published.": "Instal·leu registres, esquemes i fluxos publicats per altres organitzacions.", + "No such run": "No existeix aquesta execució", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L'execució no existeix o no la podeu veure. En esborrar un flux, se n'esborren les execucions." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index 4bd25d354a..e903954a27 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Connecta un compte social o d’analítica una sola vegada. L’OpenRegister desa el testimoni i el renova, de manera que les aplicacions no el retenen mai.", + "Connect account": "Connecta el compte", + "Connected accounts": "Comptes connectats", + "Could not disconnect this account.": "No s’ha pogut desconnectar aquest compte.", + "Could not load your connected accounts.": "No s’han pogut carregar els vostres comptes connectats.", + "Could not start the connection. Check the provider and try again.": "No s’ha pogut iniciar la connexió. Comproveu el proveïdor i torneu-ho a provar.", + "Disconnect": "Desconnecta", + "Expired": "Caducat", + "Expires {date}": "Caduca el {date}", + "No accounts are connected yet.": "Encara no hi ha cap compte connectat.", + "Not connected yet": "Encara no connectat", + "Reconnect": "Torna a connectar", + "Relink needed": "Cal tornar a enllaçar", + "Server address": "Adreça del servidor", + "Load example data?": "Voleu carregar dades d’exemple?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Les dades d’exemple omplen les llistes, les pàgines de detall i els taulers, perquè vegeu l’aplicació funcionant de seguida. Trieu \"Cap\" en una instal·lació de producció.", + "Load the example data": "Carrega les dades d’exemple", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Carrega el que heu triat. Són clarament dades d’exemple, l’operació es pot repetir sense risc i les podeu esborrar després.", + "None, I will set this up myself": "Cap, ja ho configuraré jo mateix", + "Nothing is imported. You start with an empty app and add your own data.": "No s’importa res. Comenceu amb una aplicació buida i hi afegiu les vostres dades.", + "Example data": "Dades d’exemple", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valors d’exemple per a cada esquema que aporta aquesta aplicació, generats a partir dels esquemes mateixos. Mostren les llistes, les pàgines de detall i els taulers funcionant en lloc d’explicar una història. Es pot repetir sense risc i esborrar després.", + "from amount {amount}": "a partir de l'import {amount}", + "Gated transition: {transition}": "Transició controlada: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Cada cadena declarada obre una seqüència ordenada de tasques quan s'intenta la transició. Els aprovadors decideixen a la seva safata de tasques, no aquí.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declareu x-openregister-approval-chains en aquest esquema per condicionar una transició del cicle de vida a una aprovació.", + "Approval sequences": "Seqüències d'aprovació", "%n entries have no hash yet": "%n entrades encara no tenen resum", "%n entry has no hash yet": "%n entrada encara no té resum", "(Dolphin required)": "(Es requereix Dolphin)", + "No approval chains declared": "No hi ha cadenes d'aprovació declarades", "(no subject)": "(sense assumpte)", "(no title)": "(sense títol)", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "La transició s'aplica automàticament quan tots els passos estan aprovats.", + "The requester may not decide their own approval.": "El sol·licitant no pot decidir sobre la seva pròpia aprovació.", + "Tier routing on field: {field}": "Encaminament per trams segons el camp: {field}", "(unnamed)": "(sense nom)", "(untitled card)": "(targeta sense títol)", "({count} selected)": "({count} seleccionats)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "No existeix aquesta tasca", + "The task does not exist, or it is not yours to see.": "La tasca no existeix o no la podeu veure.", + "Details": "Detalls", + "Due": "Venciment", + "Assignee": "Assignat a", + "Requested by": "Sol·licitat per", + "Outcome": "Resultat", + "Ready to claim": "A punt per reclamar", + "In progress": "En curs", + "Terminated": "Finalitzada", + "Normal": "Normal", + "Urgent": "Urgent", + "Overdue by 1 day": "Endarrerida 1 dia", + "Overdue by {days} days": "Endarrerida {days} dies", + "Due today": "Venç avui", + "Due tomorrow": "Venç demà", + "Due in {days} days": "Venç d'aquí a {days} dies", + "Tasks": "Tasques", + "Task": "Tasca", + "Unassigned, open for claiming": "Sense assignar, oberta per reclamar", + "Claim": "Reclama", + "Complete": "Completa", + "Complete: {outcome}": "Completa: {outcome}", + "Could not load the task": "No s'ha pogut carregar la tasca", + "The task refused that action": "La tasca ha rebutjat aquesta acció", + "Store": "Botiga", + "Install registers, schemas and flows that other organisations have published.": "Instal·leu registres, esquemes i fluxos publicats per altres organitzacions.", + "No such run": "No existeix aquesta execució", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L'execució no existeix o no la podeu veure. En esborrar un flux, se n'esborren les execucions." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/cs.js b/l10n/cs.js index 94b6dc71b8..5d19320a82 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Připojte účet sociální sítě nebo analytiky jednou. OpenRegister uchová token a obnovuje jej, takže jej aplikace nikdy nedrží.", + "Connect account": "Připojit účet", + "Connected accounts": "Připojené účty", + "Could not disconnect this account.": "Tento účet se nepodařilo odpojit.", + "Could not load your connected accounts.": "Vaše připojené účty se nepodařilo načíst.", + "Could not start the connection. Check the provider and try again.": "Připojení se nepodařilo spustit. Zkontrolujte poskytovatele a zkuste to znovu.", + "Disconnect": "Odpojit", + "Expired": "Vypršelo", + "Expires {date}": "Vyprší {date}", + "No accounts are connected yet.": "Zatím nejsou připojeny žádné účty.", + "Not connected yet": "Zatím nepřipojeno", + "Reconnect": "Připojit znovu", + "Relink needed": "Je potřeba znovu propojit", + "Server address": "Adresa serveru", + "Load example data?": "Načíst ukázková data?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Ukázková data naplní seznamy, stránky s podrobnostmi a nástěnky, takže aplikaci uvidíte hned v provozu. Na produkční instalaci zvolte \"Žádná\".", + "Load the example data": "Načíst ukázková data", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Načte, co jste vybrali. Jsou to zjevně ukázková data, akci lze bezpečně opakovat a poté je můžete smazat.", + "None, I will set this up myself": "Žádná, nastavím si to sám", + "Nothing is imported. You start with an empty app and add your own data.": "Nic se neimportuje. Začínáte s prázdnou aplikací a přidáte vlastní data.", + "Example data": "Ukázková data", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Ukázkové hodnoty pro každé schéma, které tato aplikace přináší, vygenerované ze samotných schémat. Ukazují seznamy, stránky s podrobnostmi a nástěnky v provozu, místo aby vyprávěly příběh. Lze bezpečně opakovat a poté smazat.", + "from amount {amount}": "od částky {amount}", + "Gated transition: {transition}": "Hlídaný přechod: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Každý deklarovaný řetězec otevře uspořádanou sekvenci úkolů při pokusu o přechod. Schvalovatelé rozhodují ve své schránce úkolů, ne zde.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarujte x-openregister-approval-chains na tomto schématu, aby přechod životního cyklu podléhal schválení.", + "Approval sequences": "Schvalovací sekvence", "%n entries have no hash yet": "%n záznamů zatím nemá otisk", "%n entry has no hash yet": "%n záznam zatím nemá otisk", "(Dolphin required)": "(vyžaduje Dolphin)", + "No approval chains declared": "Nejsou deklarovány žádné schvalovací řetězce", "(no subject)": "(bez předmětu)", "(no title)": "(bez názvu)", + "Role {role}": "Role {role}", + "The transition is applied automatically once every step approves.": "Přechod se použije automaticky, jakmile je každý krok schválen.", + "The requester may not decide their own approval.": "Žadatel nesmí rozhodovat o vlastním schválení.", + "Tier routing on field: {field}": "Směrování podle pásem přes pole: {field}", "(unnamed)": "(bez názvu)", "(untitled card)": "(karta bez názvu)", "({count} selected)": "({count} vybráno)", @@ -2738,7 +2770,88 @@ 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í.", + "No such task": "Žádný takový úkol", + "The task does not exist, or it is not yours to see.": "Úkol neexistuje, nebo ho nesmíte vidět.", + "Details": "Podrobnosti", + "Due": "Termín", + "Assignee": "Přiřazeno", + "Requested by": "Vyžádal", + "Outcome": "Výsledek", + "Ready to claim": "Připraveno k převzetí", + "In progress": "Probíhá", + "Terminated": "Ukončeno", + "Normal": "Normální", + "Urgent": "Naléhavé", + "Overdue by 1 day": "1 den po termínu", + "Overdue by {days} days": "{days} dní po termínu", + "Due today": "Termín dnes", + "Due tomorrow": "Termín zítra", + "Due in {days} days": "Termín za {days} dní", + "Tasks": "Úkoly", + "Task": "Úkol", + "Unassigned, open for claiming": "Nepřiřazeno, lze převzít", + "Claim": "Převzít", + "Complete": "Dokončit", + "Complete: {outcome}": "Dokončit: {outcome}", + "Could not load the task": "Úkol se nepodařilo načíst", + "The task refused that action": "Úkol tuto akci odmítl", + "Store": "Obchod", + "Install registers, schemas and flows that other organisations have published.": "Nainstalujte registry, schémata a toky zveřejněné jinými organizacemi.", + "No such run": "Žádný takový běh", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Běh neexistuje, nebo ho nesmíte vidět. Smazáním toku se smažou i jeho běhy." }, "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..e4ed419a74 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Připojte účet sociální sítě nebo analytiky jednou. OpenRegister uchová token a obnovuje jej, takže jej aplikace nikdy nedrží.", + "Connect account": "Připojit účet", + "Connected accounts": "Připojené účty", + "Could not disconnect this account.": "Tento účet se nepodařilo odpojit.", + "Could not load your connected accounts.": "Vaše připojené účty se nepodařilo načíst.", + "Could not start the connection. Check the provider and try again.": "Připojení se nepodařilo spustit. Zkontrolujte poskytovatele a zkuste to znovu.", + "Disconnect": "Odpojit", + "Expired": "Vypršelo", + "Expires {date}": "Vyprší {date}", + "No accounts are connected yet.": "Zatím nejsou připojeny žádné účty.", + "Not connected yet": "Zatím nepřipojeno", + "Reconnect": "Připojit znovu", + "Relink needed": "Je potřeba znovu propojit", + "Server address": "Adresa serveru", + "Load example data?": "Načíst ukázková data?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Ukázková data naplní seznamy, stránky s podrobnostmi a nástěnky, takže aplikaci uvidíte hned v provozu. Na produkční instalaci zvolte \"Žádná\".", + "Load the example data": "Načíst ukázková data", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Načte, co jste vybrali. Jsou to zjevně ukázková data, akci lze bezpečně opakovat a poté je můžete smazat.", + "None, I will set this up myself": "Žádná, nastavím si to sám", + "Nothing is imported. You start with an empty app and add your own data.": "Nic se neimportuje. Začínáte s prázdnou aplikací a přidáte vlastní data.", + "Example data": "Ukázková data", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Ukázkové hodnoty pro každé schéma, které tato aplikace přináší, vygenerované ze samotných schémat. Ukazují seznamy, stránky s podrobnostmi a nástěnky v provozu, místo aby vyprávěly příběh. Lze bezpečně opakovat a poté smazat.", + "from amount {amount}": "od částky {amount}", + "Gated transition: {transition}": "Hlídaný přechod: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Každý deklarovaný řetězec otevře uspořádanou sekvenci úkolů při pokusu o přechod. Schvalovatelé rozhodují ve své schránce úkolů, ne zde.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarujte x-openregister-approval-chains na tomto schématu, aby přechod životního cyklu podléhal schválení.", + "Approval sequences": "Schvalovací sekvence", "%n entries have no hash yet": "%n záznamů zatím nemá otisk", "%n entry has no hash yet": "%n záznam zatím nemá otisk", "(Dolphin required)": "(vyžaduje Dolphin)", + "No approval chains declared": "Nejsou deklarovány žádné schvalovací řetězce", "(no subject)": "(bez předmětu)", "(no title)": "(bez názvu)", + "Role {role}": "Role {role}", + "The transition is applied automatically once every step approves.": "Přechod se použije automaticky, jakmile je každý krok schválen.", + "The requester may not decide their own approval.": "Žadatel nesmí rozhodovat o vlastním schválení.", + "Tier routing on field: {field}": "Směrování podle pásem přes pole: {field}", "(unnamed)": "(bez názvu)", "(untitled card)": "(karta bez názvu)", "({count} selected)": "({count} vybráno)", @@ -2817,7 +2849,88 @@ "Ú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í.", + "No such task": "Žádný takový úkol", + "The task does not exist, or it is not yours to see.": "Úkol neexistuje, nebo ho nesmíte vidět.", + "Details": "Podrobnosti", + "Due": "Termín", + "Assignee": "Přiřazeno", + "Requested by": "Vyžádal", + "Outcome": "Výsledek", + "Ready to claim": "Připraveno k převzetí", + "In progress": "Probíhá", + "Terminated": "Ukončeno", + "Normal": "Normální", + "Urgent": "Naléhavé", + "Overdue by 1 day": "1 den po termínu", + "Overdue by {days} days": "{days} dní po termínu", + "Due today": "Termín dnes", + "Due tomorrow": "Termín zítra", + "Due in {days} days": "Termín za {days} dní", + "Tasks": "Úkoly", + "Task": "Úkol", + "Unassigned, open for claiming": "Nepřiřazeno, lze převzít", + "Claim": "Převzít", + "Complete": "Dokončit", + "Complete: {outcome}": "Dokončit: {outcome}", + "Could not load the task": "Úkol se nepodařilo načíst", + "The task refused that action": "Úkol tuto akci odmítl", + "Store": "Obchod", + "Install registers, schemas and flows that other organisations have published.": "Nainstalujte registry, schémata a toky zveřejněné jinými organizacemi.", + "No such run": "Žádný takový běh", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Běh neexistuje, nebo ho nesmíte vidět. Smazáním toku se smažou i jeho běhy." }, "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..ca2dd70971 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Forbind en social konto eller analysekonto én gang. OpenRegister opbevarer token og fornyer det, så apps aldrig selv har det.", + "Connect account": "Forbind konto", + "Connected accounts": "Forbundne konti", + "Could not disconnect this account.": "Denne konto kunne ikke afbrydes.", + "Could not load your connected accounts.": "Dine forbundne konti kunne ikke indlæses.", + "Could not start the connection. Check the provider and try again.": "Forbindelsen kunne ikke startes. Tjek udbyderen, og prøv igen.", + "Disconnect": "Afbryd", + "Expired": "Udløbet", + "Expires {date}": "Udløber {date}", + "No accounts are connected yet.": "Der er endnu ikke forbundet nogen konti.", + "Not connected yet": "Endnu ikke forbundet", + "Reconnect": "Forbind igen", + "Relink needed": "Ny tilknytning er nødvendig", + "Server address": "Serveradresse", + "Load example data?": "Indlæs eksempeldata?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Eksempeldata fylder lister, detaljesider og dashboards, så du med det samme ser appen virke. Vælg \"Ingen\" på en produktionsinstallation.", + "Load the example data": "Indlæs eksempeldataene", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Indlæser det, du valgte. Det er tydeligvis eksempeldata, handlingen kan gentages uden risiko, og du kan slette dem bagefter.", + "None, I will set this up myself": "Ingen, jeg sætter det op selv", + "Nothing is imported. You start with an empty app and add your own data.": "Der importeres intet. Du starter med en tom app og tilføjer dine egne data.", + "Example data": "Eksempeldata", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Eksempelværdier for hvert skema, denne app leverer, genereret ud fra skemaerne selv. De viser lister, detaljesider og dashboards i drift frem for at fortælle en historie. Kan gentages uden risiko og slettes bagefter.", + "from amount {amount}": "fra beløb {amount}", + "Gated transition: {transition}": "Overvåget overgang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Hver deklareret kæde åbner en ordnet opgavesekvens, når overgangen forsøges. Godkendere beslutter i deres opgaveindbakke, ikke her.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarér x-openregister-approval-chains på dette skema for at kræve godkendelse før en livscyklusovergang.", + "Approval sequences": "Godkendelsessekvenser", "%n entries have no hash yet": "%n poster har endnu ingen hash", "%n entry has no hash yet": "%n post har endnu ingen hash", "(Dolphin required)": "(Dolphin påkrævet)", + "No approval chains declared": "Ingen godkendelseskæder deklareret", "(no subject)": "(intet emne)", "(no title)": "(ingen titel)", + "Role {role}": "Rolle {role}", + "The transition is applied automatically once every step approves.": "Overgangen anvendes automatisk, når hvert trin er godkendt.", + "The requester may not decide their own approval.": "Anmoderen må ikke afgøre sin egen godkendelse.", + "Tier routing on field: {field}": "Trinvis routing på feltet: {field}", "(unnamed)": "(uden navn)", "(untitled card)": "(kort uden titel)", "({count} selected)": "({count} valgt)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Ingen sådan opgave", + "The task does not exist, or it is not yours to see.": "Opgaven findes ikke, eller du må ikke se den.", + "Details": "Detaljer", + "Due": "Frist", + "Assignee": "Tildelt", + "Requested by": "Anmodet af", + "Outcome": "Resultat", + "Ready to claim": "Klar til at overtage", + "In progress": "I gang", + "Terminated": "Afbrudt", + "Normal": "Normal", + "Urgent": "Haster", + "Overdue by 1 day": "1 dag over fristen", + "Overdue by {days} days": "{days} dage over fristen", + "Due today": "Frist i dag", + "Due tomorrow": "Frist i morgen", + "Due in {days} days": "Frist om {days} dage", + "Tasks": "Opgaver", + "Task": "Opgave", + "Unassigned, open for claiming": "Ikke tildelt, åben for overtagelse", + "Claim": "Overtag", + "Complete": "Fuldfør", + "Complete: {outcome}": "Fuldfør: {outcome}", + "Could not load the task": "Kunne ikke indlæse opgaven", + "The task refused that action": "Opgaven afviste den handling", + "Store": "Butik", + "Install registers, schemas and flows that other organisations have published.": "Installer registre, skemaer og flows, som andre organisationer har udgivet.", + "No such run": "Ingen sådan kørsel", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Kørslen findes ikke, eller du må ikke se den. Sletter du et flow, slettes dets kørsler også." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index e0292c88a9..33d60250a1 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Forbind en social konto eller analysekonto én gang. OpenRegister opbevarer token og fornyer det, så apps aldrig selv har det.", + "Connect account": "Forbind konto", + "Connected accounts": "Forbundne konti", + "Could not disconnect this account.": "Denne konto kunne ikke afbrydes.", + "Could not load your connected accounts.": "Dine forbundne konti kunne ikke indlæses.", + "Could not start the connection. Check the provider and try again.": "Forbindelsen kunne ikke startes. Tjek udbyderen, og prøv igen.", + "Disconnect": "Afbryd", + "Expired": "Udløbet", + "Expires {date}": "Udløber {date}", + "No accounts are connected yet.": "Der er endnu ikke forbundet nogen konti.", + "Not connected yet": "Endnu ikke forbundet", + "Reconnect": "Forbind igen", + "Relink needed": "Ny tilknytning er nødvendig", + "Server address": "Serveradresse", + "Load example data?": "Indlæs eksempeldata?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Eksempeldata fylder lister, detaljesider og dashboards, så du med det samme ser appen virke. Vælg \"Ingen\" på en produktionsinstallation.", + "Load the example data": "Indlæs eksempeldataene", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Indlæser det, du valgte. Det er tydeligvis eksempeldata, handlingen kan gentages uden risiko, og du kan slette dem bagefter.", + "None, I will set this up myself": "Ingen, jeg sætter det op selv", + "Nothing is imported. You start with an empty app and add your own data.": "Der importeres intet. Du starter med en tom app og tilføjer dine egne data.", + "Example data": "Eksempeldata", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Eksempelværdier for hvert skema, denne app leverer, genereret ud fra skemaerne selv. De viser lister, detaljesider og dashboards i drift frem for at fortælle en historie. Kan gentages uden risiko og slettes bagefter.", + "from amount {amount}": "fra beløb {amount}", + "Gated transition: {transition}": "Overvåget overgang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Hver deklareret kæde åbner en ordnet opgavesekvens, når overgangen forsøges. Godkendere beslutter i deres opgaveindbakke, ikke her.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarér x-openregister-approval-chains på dette skema for at kræve godkendelse før en livscyklusovergang.", + "Approval sequences": "Godkendelsessekvenser", "%n entries have no hash yet": "%n poster har endnu ingen hash", "%n entry has no hash yet": "%n post har endnu ingen hash", "(Dolphin required)": "(Dolphin påkrævet)", + "No approval chains declared": "Ingen godkendelseskæder deklareret", "(no subject)": "(intet emne)", "(no title)": "(ingen titel)", + "Role {role}": "Rolle {role}", + "The transition is applied automatically once every step approves.": "Overgangen anvendes automatisk, når hvert trin er godkendt.", + "The requester may not decide their own approval.": "Anmoderen må ikke afgøre sin egen godkendelse.", + "Tier routing on field: {field}": "Trinvis routing på feltet: {field}", "(unnamed)": "(uden navn)", "(untitled card)": "(kort uden titel)", "({count} selected)": "({count} valgt)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Ingen sådan opgave", + "The task does not exist, or it is not yours to see.": "Opgaven findes ikke, eller du må ikke se den.", + "Details": "Detaljer", + "Due": "Frist", + "Assignee": "Tildelt", + "Requested by": "Anmodet af", + "Outcome": "Resultat", + "Ready to claim": "Klar til at overtage", + "In progress": "I gang", + "Terminated": "Afbrudt", + "Normal": "Normal", + "Urgent": "Haster", + "Overdue by 1 day": "1 dag over fristen", + "Overdue by {days} days": "{days} dage over fristen", + "Due today": "Frist i dag", + "Due tomorrow": "Frist i morgen", + "Due in {days} days": "Frist om {days} dage", + "Tasks": "Opgaver", + "Task": "Opgave", + "Unassigned, open for claiming": "Ikke tildelt, åben for overtagelse", + "Claim": "Overtag", + "Complete": "Fuldfør", + "Complete: {outcome}": "Fuldfør: {outcome}", + "Could not load the task": "Kunne ikke indlæse opgaven", + "The task refused that action": "Opgaven afviste den handling", + "Store": "Butik", + "Install registers, schemas and flows that other organisations have published.": "Installer registre, skemaer og flows, som andre organisationer har udgivet.", + "No such run": "Ingen sådan kørsel", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Kørslen findes ikke, eller du må ikke se den. Sletter du et flow, slettes dets kørsler også." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/de.js b/l10n/de.js index d862152ac3..6d89972f39 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Verbinde ein Social- oder Analytics-Konto einmalig. OpenRegister bewahrt das Token auf und erneuert es, damit Apps es nie selbst halten.", + "Connect account": "Konto verbinden", + "Connected accounts": "Verbundene Konten", + "Could not disconnect this account.": "Dieses Konto konnte nicht getrennt werden.", + "Could not load your connected accounts.": "Deine verbundenen Konten konnten nicht geladen werden.", + "Could not start the connection. Check the provider and try again.": "Die Verbindung konnte nicht gestartet werden. Prüfe den Anbieter und versuche es erneut.", + "Disconnect": "Trennen", + "Expired": "Abgelaufen", + "Expires {date}": "Läuft am {date} ab", + "No accounts are connected yet.": "Es sind noch keine Konten verbunden.", + "Not connected yet": "Noch nicht verbunden", + "Reconnect": "Erneut verbinden", + "Relink needed": "Neuverknüpfung nötig", + "Server address": "Serveradresse", + "Load example data?": "Beispieldaten laden?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Beispieldaten füllen die Listen, Detailseiten und Dashboards, damit Sie die App sofort in Betrieb sehen. Wählen Sie \"Keine\" bei einer Produktivinstallation.", + "Load the example data": "Die Beispieldaten laden", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Lädt, was Sie gewählt haben. Die Daten sind erkennbar Beispieldaten, der Vorgang kann mehrfach ausgeführt werden, und Sie können sie danach löschen.", + "None, I will set this up myself": "Keine, ich richte das selbst ein", + "Nothing is imported. You start with an empty app and add your own data.": "Es wird nichts importiert. Sie beginnen mit einer leeren App und fügen Ihre eigenen Daten hinzu.", + "Example data": "Beispieldaten", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Beispielwerte für jedes Schema, das diese App mitbringt, aus den Schemata selbst erzeugt. Sie zeigen die Listen, Detailseiten und Dashboards in Betrieb, statt eine Geschichte zu erzählen. Mehrfach ausführbar, und Sie können sie danach löschen.", + "from amount {amount}": "ab Betrag {amount}", + "Gated transition: {transition}": "Bewachter Übergang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Jede deklarierte Kette eröffnet beim Versuch des Übergangs eine geordnete Aufgabensequenz. Freigebende entscheiden in ihrem Aufgabeneingang, nicht hier.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarieren Sie x-openregister-approval-chains auf diesem Schema, um einen Lebenszyklusübergang hinter eine Freigabe zu stellen.", + "Approval sequences": "Freigabesequenzen", "%n entries have no hash yet": "%n Einträge haben noch keinen Hash", "%n entry has no hash yet": "%n Eintrag hat noch keinen Hash", "(Dolphin required)": "(Dolphin erforderlich)", + "No approval chains declared": "Keine Freigabeketten deklariert", "(no subject)": "(kein Betreff)", "(no title)": "(kein Titel)", + "Role {role}": "Rolle {role}", + "The transition is applied automatically once every step approves.": "Der Übergang wird automatisch angewendet, sobald jeder Schritt freigegeben ist.", + "The requester may not decide their own approval.": "Wer den Antrag stellt, darf nicht über die eigene Freigabe entscheiden.", + "Tier routing on field: {field}": "Stufen-Routing über Feld: {field}", "(unnamed)": "(unbenannt)", "(untitled card)": "(unbenannte Karte)", "({count} selected)": "({count} ausgewählt)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Keine solche Aufgabe", + "The task does not exist, or it is not yours to see.": "Die Aufgabe existiert nicht oder Sie dürfen sie nicht sehen.", + "Details": "Details", + "Due": "Fällig", + "Assignee": "Zugewiesen an", + "Requested by": "Angefordert von", + "Outcome": "Ergebnis", + "Ready to claim": "Bereit zur Übernahme", + "In progress": "In Bearbeitung", + "Terminated": "Beendet", + "Normal": "Normal", + "Urgent": "Dringend", + "Overdue by 1 day": "1 Tag überfällig", + "Overdue by {days} days": "{days} Tage überfällig", + "Due today": "Heute fällig", + "Due tomorrow": "Morgen fällig", + "Due in {days} days": "Fällig in {days} Tagen", + "Tasks": "Aufgaben", + "Task": "Aufgabe", + "Unassigned, open for claiming": "Nicht zugewiesen, zur Übernahme offen", + "Claim": "Übernehmen", + "Complete": "Abschließen", + "Complete: {outcome}": "Abschließen: {outcome}", + "Could not load the task": "Aufgabe konnte nicht geladen werden", + "The task refused that action": "Die Aufgabe hat diese Aktion abgelehnt", + "Store": "Store", + "Install registers, schemas and flows that other organisations have published.": "Installieren Sie Register, Schemata und Flows, die andere Organisationen veröffentlicht haben.", + "No such run": "Keine solche Ausführung", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Die Ausführung existiert nicht oder Sie dürfen sie nicht sehen. Wird ein Flow gelöscht, werden auch seine Ausführungen gelöscht." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index cf8036950c..b9c7623a11 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Verbinde ein Social- oder Analytics-Konto einmalig. OpenRegister bewahrt das Token auf und erneuert es, damit Apps es nie selbst halten.", + "Connect account": "Konto verbinden", + "Connected accounts": "Verbundene Konten", + "Could not disconnect this account.": "Dieses Konto konnte nicht getrennt werden.", + "Could not load your connected accounts.": "Deine verbundenen Konten konnten nicht geladen werden.", + "Could not start the connection. Check the provider and try again.": "Die Verbindung konnte nicht gestartet werden. Prüfe den Anbieter und versuche es erneut.", + "Disconnect": "Trennen", + "Expired": "Abgelaufen", + "Expires {date}": "Läuft am {date} ab", + "No accounts are connected yet.": "Es sind noch keine Konten verbunden.", + "Not connected yet": "Noch nicht verbunden", + "Reconnect": "Erneut verbinden", + "Relink needed": "Neuverknüpfung nötig", + "Server address": "Serveradresse", + "Load example data?": "Beispieldaten laden?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Beispieldaten füllen die Listen, Detailseiten und Dashboards, damit Sie die App sofort in Betrieb sehen. Wählen Sie \"Keine\" bei einer Produktivinstallation.", + "Load the example data": "Die Beispieldaten laden", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Lädt, was Sie gewählt haben. Die Daten sind erkennbar Beispieldaten, der Vorgang kann mehrfach ausgeführt werden, und Sie können sie danach löschen.", + "None, I will set this up myself": "Keine, ich richte das selbst ein", + "Nothing is imported. You start with an empty app and add your own data.": "Es wird nichts importiert. Sie beginnen mit einer leeren App und fügen Ihre eigenen Daten hinzu.", + "Example data": "Beispieldaten", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Beispielwerte für jedes Schema, das diese App mitbringt, aus den Schemata selbst erzeugt. Sie zeigen die Listen, Detailseiten und Dashboards in Betrieb, statt eine Geschichte zu erzählen. Mehrfach ausführbar, und Sie können sie danach löschen.", + "from amount {amount}": "ab Betrag {amount}", + "Gated transition: {transition}": "Bewachter Übergang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Jede deklarierte Kette eröffnet beim Versuch des Übergangs eine geordnete Aufgabensequenz. Freigebende entscheiden in ihrem Aufgabeneingang, nicht hier.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarieren Sie x-openregister-approval-chains auf diesem Schema, um einen Lebenszyklusübergang hinter eine Freigabe zu stellen.", + "Approval sequences": "Freigabesequenzen", "%n entries have no hash yet": "%n Einträge haben noch keinen Hash", "%n entry has no hash yet": "%n Eintrag hat noch keinen Hash", "(Dolphin required)": "(Dolphin erforderlich)", + "No approval chains declared": "Keine Freigabeketten deklariert", "(no subject)": "(kein Betreff)", "(no title)": "(kein Titel)", + "Role {role}": "Rolle {role}", + "The transition is applied automatically once every step approves.": "Der Übergang wird automatisch angewendet, sobald jeder Schritt freigegeben ist.", + "The requester may not decide their own approval.": "Wer den Antrag stellt, darf nicht über die eigene Freigabe entscheiden.", + "Tier routing on field: {field}": "Stufen-Routing über Feld: {field}", "(unnamed)": "(unbenannt)", "(untitled card)": "(unbenannte Karte)", "({count} selected)": "({count} ausgewählt)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Keine solche Aufgabe", + "The task does not exist, or it is not yours to see.": "Die Aufgabe existiert nicht oder Sie dürfen sie nicht sehen.", + "Details": "Details", + "Due": "Fällig", + "Assignee": "Zugewiesen an", + "Requested by": "Angefordert von", + "Outcome": "Ergebnis", + "Ready to claim": "Bereit zur Übernahme", + "In progress": "In Bearbeitung", + "Terminated": "Beendet", + "Normal": "Normal", + "Urgent": "Dringend", + "Overdue by 1 day": "1 Tag überfällig", + "Overdue by {days} days": "{days} Tage überfällig", + "Due today": "Heute fällig", + "Due tomorrow": "Morgen fällig", + "Due in {days} days": "Fällig in {days} Tagen", + "Tasks": "Aufgaben", + "Task": "Aufgabe", + "Unassigned, open for claiming": "Nicht zugewiesen, zur Übernahme offen", + "Claim": "Übernehmen", + "Complete": "Abschließen", + "Complete: {outcome}": "Abschließen: {outcome}", + "Could not load the task": "Aufgabe konnte nicht geladen werden", + "The task refused that action": "Die Aufgabe hat diese Aktion abgelehnt", + "Store": "Store", + "Install registers, schemas and flows that other organisations have published.": "Installieren Sie Register, Schemata und Flows, die andere Organisationen veröffentlicht haben.", + "No such run": "Keine solche Ausführung", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Die Ausführung existiert nicht oder Sie dürfen sie nicht sehen. Wird ein Flow gelöscht, werden auch seine Ausführungen gelöscht." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/el.js b/l10n/el.js index 4acdfbbd63..719b9810e8 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Συνδέστε έναν λογαριασμό κοινωνικού δικτύου ή αναλυτικών μία φορά. Το OpenRegister κρατά το διακριτικό και το ανανεώνει, ώστε οι εφαρμογές να μην το κρατούν ποτέ.", + "Connect account": "Σύνδεση λογαριασμού", + "Connected accounts": "Συνδεδεμένοι λογαριασμοί", + "Could not disconnect this account.": "Δεν ήταν δυνατή η αποσύνδεση αυτού του λογαριασμού.", + "Could not load your connected accounts.": "Δεν ήταν δυνατή η φόρτωση των συνδεδεμένων λογαριασμών σας.", + "Could not start the connection. Check the provider and try again.": "Δεν ήταν δυνατή η έναρξη της σύνδεσης. Ελέγξτε τον πάροχο και δοκιμάστε ξανά.", + "Disconnect": "Αποσύνδεση", + "Expired": "Έληξε", + "Expires {date}": "Λήγει στις {date}", + "No accounts are connected yet.": "Δεν έχει συνδεθεί ακόμη κανένας λογαριασμός.", + "Not connected yet": "Δεν έχει συνδεθεί ακόμη", + "Reconnect": "Επανασύνδεση", + "Relink needed": "Απαιτείται νέα σύνδεση", + "Server address": "Διεύθυνση διακομιστή", + "Load example data?": "Να φορτωθούν δεδομένα παραδείγματος;", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Τα δεδομένα παραδείγματος γεμίζουν τις λίστες, τις σελίδες λεπτομερειών και τους πίνακες, ώστε να δείτε αμέσως την εφαρμογή να λειτουργεί. Επιλέξτε \"Κανένα\" σε εγκατάσταση παραγωγής.", + "Load the example data": "Φόρτωση των δεδομένων παραδείγματος", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Φορτώνει ό,τι επιλέξατε. Είναι εμφανώς δεδομένα παραδείγματος, η ενέργεια επαναλαμβάνεται με ασφάλεια και μπορείτε να τα διαγράψετε μετά.", + "None, I will set this up myself": "Κανένα, θα το ρυθμίσω μόνος μου", + "Nothing is imported. You start with an empty app and add your own data.": "Δεν εισάγεται τίποτα. Ξεκινάτε με μια κενή εφαρμογή και προσθέτετε τα δικά σας δεδομένα.", + "Example data": "Δεδομένα παραδείγματος", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Τιμές παραδείγματος για κάθε σχήμα που παρέχει αυτή η εφαρμογή, παραγμένες από τα ίδια τα σχήματα. Δείχνουν τις λίστες, τις σελίδες λεπτομερειών και τους πίνακες σε λειτουργία αντί να αφηγούνται μια ιστορία. Ασφαλής επανάληψη και διαγραφή μετά.", + "from amount {amount}": "από ποσό {amount}", + "Gated transition: {transition}": "Ελεγχόμενη μετάβαση: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Κάθε δηλωμένη αλυσίδα ανοίγει μια διατεταγμένη ακολουθία εργασιών όταν επιχειρείται η μετάβαση. Οι εγκρίνοντες αποφασίζουν στα εισερχόμενα εργασιών τους, όχι εδώ.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Δηλώστε x-openregister-approval-chains σε αυτό το σχήμα ώστε μια μετάβαση κύκλου ζωής να απαιτεί έγκριση.", + "Approval sequences": "Ακολουθίες έγκρισης", "%n entries have no hash yet": "%n καταχωρίσεις δεν έχουν ακόμη κατακερματισμό", "%n entry has no hash yet": "%n καταχώριση δεν έχει ακόμη κατακερματισμό", "(Dolphin required)": "(απαιτείται Dolphin)", + "No approval chains declared": "Δεν έχουν δηλωθεί αλυσίδες έγκρισης", "(no subject)": "(χωρίς θέμα)", "(no title)": "(χωρίς τίτλο)", + "Role {role}": "Ρόλος {role}", + "The transition is applied automatically once every step approves.": "Η μετάβαση εφαρμόζεται αυτόματα μόλις εγκριθεί κάθε βήμα.", + "The requester may not decide their own approval.": "Ο αιτών δεν μπορεί να αποφασίσει για τη δική του έγκριση.", + "Tier routing on field: {field}": "Κλιμακωτή δρομολόγηση στο πεδίο: {field}", "(unnamed)": "(χωρίς όνομα)", "(untitled card)": "(κάρτα χωρίς τίτλο)", "({count} selected)": "({count} επιλεγμένα)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Δεν υπάρχει τέτοια εργασία", + "The task does not exist, or it is not yours to see.": "Η εργασία δεν υπάρχει ή δεν επιτρέπεται να τη δείτε.", + "Details": "Λεπτομέρειες", + "Due": "Προθεσμία", + "Assignee": "Ανάθεση σε", + "Requested by": "Αιτήθηκε από", + "Outcome": "Αποτέλεσμα", + "Ready to claim": "Έτοιμο για ανάληψη", + "In progress": "Σε εξέλιξη", + "Terminated": "Τερματίστηκε", + "Normal": "Κανονική", + "Urgent": "Επείγουσα", + "Overdue by 1 day": "Εκπρόθεσμη κατά 1 ημέρα", + "Overdue by {days} days": "Εκπρόθεσμη κατά {days} ημέρες", + "Due today": "Λήγει σήμερα", + "Due tomorrow": "Λήγει αύριο", + "Due in {days} days": "Λήγει σε {days} ημέρες", + "Tasks": "Εργασίες", + "Task": "Εργασία", + "Unassigned, open for claiming": "Χωρίς ανάθεση, ανοιχτή για ανάληψη", + "Claim": "Ανάληψη", + "Complete": "Ολοκλήρωση", + "Complete: {outcome}": "Ολοκλήρωση: {outcome}", + "Could not load the task": "Δεν ήταν δυνατή η φόρτωση της εργασίας", + "The task refused that action": "Η εργασία απέρριψε αυτήν την ενέργεια", + "Store": "Κατάστημα", + "Install registers, schemas and flows that other organisations have published.": "Εγκαταστήστε μητρώα, σχήματα και ροές που έχουν δημοσιεύσει άλλοι οργανισμοί.", + "No such run": "Δεν υπάρχει τέτοια εκτέλεση", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Η εκτέλεση δεν υπάρχει ή δεν επιτρέπεται να τη δείτε. Η διαγραφή μιας ροής διαγράφει και τις εκτελέσεις της." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index 5a21bf9a7f..d65383c771 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Συνδέστε έναν λογαριασμό κοινωνικού δικτύου ή αναλυτικών μία φορά. Το OpenRegister κρατά το διακριτικό και το ανανεώνει, ώστε οι εφαρμογές να μην το κρατούν ποτέ.", + "Connect account": "Σύνδεση λογαριασμού", + "Connected accounts": "Συνδεδεμένοι λογαριασμοί", + "Could not disconnect this account.": "Δεν ήταν δυνατή η αποσύνδεση αυτού του λογαριασμού.", + "Could not load your connected accounts.": "Δεν ήταν δυνατή η φόρτωση των συνδεδεμένων λογαριασμών σας.", + "Could not start the connection. Check the provider and try again.": "Δεν ήταν δυνατή η έναρξη της σύνδεσης. Ελέγξτε τον πάροχο και δοκιμάστε ξανά.", + "Disconnect": "Αποσύνδεση", + "Expired": "Έληξε", + "Expires {date}": "Λήγει στις {date}", + "No accounts are connected yet.": "Δεν έχει συνδεθεί ακόμη κανένας λογαριασμός.", + "Not connected yet": "Δεν έχει συνδεθεί ακόμη", + "Reconnect": "Επανασύνδεση", + "Relink needed": "Απαιτείται νέα σύνδεση", + "Server address": "Διεύθυνση διακομιστή", + "Load example data?": "Να φορτωθούν δεδομένα παραδείγματος;", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Τα δεδομένα παραδείγματος γεμίζουν τις λίστες, τις σελίδες λεπτομερειών και τους πίνακες, ώστε να δείτε αμέσως την εφαρμογή να λειτουργεί. Επιλέξτε \"Κανένα\" σε εγκατάσταση παραγωγής.", + "Load the example data": "Φόρτωση των δεδομένων παραδείγματος", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Φορτώνει ό,τι επιλέξατε. Είναι εμφανώς δεδομένα παραδείγματος, η ενέργεια επαναλαμβάνεται με ασφάλεια και μπορείτε να τα διαγράψετε μετά.", + "None, I will set this up myself": "Κανένα, θα το ρυθμίσω μόνος μου", + "Nothing is imported. You start with an empty app and add your own data.": "Δεν εισάγεται τίποτα. Ξεκινάτε με μια κενή εφαρμογή και προσθέτετε τα δικά σας δεδομένα.", + "Example data": "Δεδομένα παραδείγματος", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Τιμές παραδείγματος για κάθε σχήμα που παρέχει αυτή η εφαρμογή, παραγμένες από τα ίδια τα σχήματα. Δείχνουν τις λίστες, τις σελίδες λεπτομερειών και τους πίνακες σε λειτουργία αντί να αφηγούνται μια ιστορία. Ασφαλής επανάληψη και διαγραφή μετά.", + "from amount {amount}": "από ποσό {amount}", + "Gated transition: {transition}": "Ελεγχόμενη μετάβαση: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Κάθε δηλωμένη αλυσίδα ανοίγει μια διατεταγμένη ακολουθία εργασιών όταν επιχειρείται η μετάβαση. Οι εγκρίνοντες αποφασίζουν στα εισερχόμενα εργασιών τους, όχι εδώ.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Δηλώστε x-openregister-approval-chains σε αυτό το σχήμα ώστε μια μετάβαση κύκλου ζωής να απαιτεί έγκριση.", + "Approval sequences": "Ακολουθίες έγκρισης", "%n entries have no hash yet": "%n καταχωρίσεις δεν έχουν ακόμη κατακερματισμό", "%n entry has no hash yet": "%n καταχώριση δεν έχει ακόμη κατακερματισμό", "(Dolphin required)": "(απαιτείται Dolphin)", + "No approval chains declared": "Δεν έχουν δηλωθεί αλυσίδες έγκρισης", "(no subject)": "(χωρίς θέμα)", "(no title)": "(χωρίς τίτλο)", + "Role {role}": "Ρόλος {role}", + "The transition is applied automatically once every step approves.": "Η μετάβαση εφαρμόζεται αυτόματα μόλις εγκριθεί κάθε βήμα.", + "The requester may not decide their own approval.": "Ο αιτών δεν μπορεί να αποφασίσει για τη δική του έγκριση.", + "Tier routing on field: {field}": "Κλιμακωτή δρομολόγηση στο πεδίο: {field}", "(unnamed)": "(χωρίς όνομα)", "(untitled card)": "(κάρτα χωρίς τίτλο)", "({count} selected)": "({count} επιλεγμένα)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Δεν υπάρχει τέτοια εργασία", + "The task does not exist, or it is not yours to see.": "Η εργασία δεν υπάρχει ή δεν επιτρέπεται να τη δείτε.", + "Details": "Λεπτομέρειες", + "Due": "Προθεσμία", + "Assignee": "Ανάθεση σε", + "Requested by": "Αιτήθηκε από", + "Outcome": "Αποτέλεσμα", + "Ready to claim": "Έτοιμο για ανάληψη", + "In progress": "Σε εξέλιξη", + "Terminated": "Τερματίστηκε", + "Normal": "Κανονική", + "Urgent": "Επείγουσα", + "Overdue by 1 day": "Εκπρόθεσμη κατά 1 ημέρα", + "Overdue by {days} days": "Εκπρόθεσμη κατά {days} ημέρες", + "Due today": "Λήγει σήμερα", + "Due tomorrow": "Λήγει αύριο", + "Due in {days} days": "Λήγει σε {days} ημέρες", + "Tasks": "Εργασίες", + "Task": "Εργασία", + "Unassigned, open for claiming": "Χωρίς ανάθεση, ανοιχτή για ανάληψη", + "Claim": "Ανάληψη", + "Complete": "Ολοκλήρωση", + "Complete: {outcome}": "Ολοκλήρωση: {outcome}", + "Could not load the task": "Δεν ήταν δυνατή η φόρτωση της εργασίας", + "The task refused that action": "Η εργασία απέρριψε αυτήν την ενέργεια", + "Store": "Κατάστημα", + "Install registers, schemas and flows that other organisations have published.": "Εγκαταστήστε μητρώα, σχήματα και ροές που έχουν δημοσιεύσει άλλοι οργανισμοί.", + "No such run": "Δεν υπάρχει τέτοια εκτέλεση", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Η εκτέλεση δεν υπάρχει ή δεν επιτρέπεται να τη δείτε. Η διαγραφή μιας ροής διαγράφει και τις εκτελέσεις της." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/en.js b/l10n/en.js index 042d84ba92..9878f5c2a2 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -3,9 +3,41 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.", + "Connect account": "Connect account", + "Connected accounts": "Connected accounts", + "Could not disconnect this account.": "Could not disconnect this account.", + "Could not load your connected accounts.": "Could not load your connected accounts.", + "Could not start the connection. Check the provider and try again.": "Could not start the connection. Check the provider and try again.", + "Disconnect": "Disconnect", + "Expired": "Expired", + "Expires {date}": "Expires {date}", + "No accounts are connected yet.": "No accounts are connected yet.", + "Not connected yet": "Not connected yet", + "Reconnect": "Reconnect", + "Relink needed": "Relink needed", + "Server address": "Server address", + "Load example data?": "Load example data?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.", + "Load the example data": "Load the example data", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.", + "None, I will set this up myself": "None, I will set this up myself", + "Nothing is imported. You start with an empty app and add your own data.": "Nothing is imported. You start with an empty app and add your own data.", + "Example data": "Example data", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.", + "Approval sequences": "Approval sequences", "(Dolphin required)": "(Dolphin required)", + "from amount {amount}": "from amount {amount}", + "Gated transition: {transition}": "Gated transition: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.", + "No approval chains declared": "No approval chains declared", "(no subject)": "(no subject)", "(no title)": "(no title)", + "Role {role}": "Role {role}", + "The transition is applied automatically once every step approves.": "The transition is applied automatically once every step approves.", + "The requester may not decide their own approval.": "The requester may not decide their own approval.", + "Tier routing on field: {field}": "Tier routing on field: {field}", "(unnamed)": "(unnamed)", "(untitled card)": "(untitled card)", "({count} selected)": "({count} selected)", @@ -2738,7 +2770,86 @@ 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:.", + "No such task": "No such task", + "The task does not exist, or it is not yours to see.": "The task does not exist, or it is not yours to see.", + "Details": "Details", + "Due": "Due", + "Assignee": "Assignee", + "Requested by": "Requested by", + "Outcome": "Outcome", + "Ready to claim": "Ready to claim", + "In progress": "In progress", + "Terminated": "Terminated", + "Normal": "Normal", + "Urgent": "Urgent", + "Overdue by 1 day": "Overdue by 1 day", + "Overdue by {days} days": "Overdue by {days} days", + "Due today": "Due today", + "Due tomorrow": "Due tomorrow", + "Due in {days} days": "Due in {days} days", + "Tasks": "Tasks", + "Task": "Task", + "Unassigned, open for claiming": "Unassigned, open for claiming", + "Claim": "Claim", + "Complete": "Complete", + "Complete: {outcome}": "Complete: {outcome}", + "Could not load the task": "Could not load the task", + "The task refused that action": "The task refused that action", + "No such run": "No such run", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index ea7366b7ef..82d6bbbf76 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.", + "Connect account": "Connect account", + "Connected accounts": "Connected accounts", + "Could not disconnect this account.": "Could not disconnect this account.", + "Could not load your connected accounts.": "Could not load your connected accounts.", + "Could not start the connection. Check the provider and try again.": "Could not start the connection. Check the provider and try again.", + "Disconnect": "Disconnect", + "Expired": "Expired", + "Expires {date}": "Expires {date}", + "No accounts are connected yet.": "No accounts are connected yet.", + "Not connected yet": "Not connected yet", + "Reconnect": "Reconnect", + "Relink needed": "Relink needed", + "Server address": "Server address", + "Load example data?": "Load example data?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.", + "Load the example data": "Load the example data", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.", + "None, I will set this up myself": "None, I will set this up myself", + "Nothing is imported. You start with an empty app and add your own data.": "Nothing is imported. You start with an empty app and add your own data.", + "Example data": "Example data", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.", "3": "3", "30": "30", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.", + "Approval sequences": "Approval sequences", "(Dolphin required)": "(Dolphin required)", + "from amount {amount}": "from amount {amount}", + "Gated transition: {transition}": "Gated transition: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.", + "No approval chains declared": "No approval chains declared", "(no subject)": "(no subject)", "(no title)": "(no title)", + "Role {role}": "Role {role}", + "The transition is applied automatically once every step approves.": "The transition is applied automatically once every step approves.", + "The requester may not decide their own approval.": "The requester may not decide their own approval.", + "Tier routing on field: {field}": "Tier routing on field: {field}", "(unnamed)": "(unnamed)", "(untitled card)": "(untitled card)", "({count} selected)": "({count} selected)", @@ -2785,7 +2817,86 @@ "_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:.", + "No such task": "No such task", + "The task does not exist, or it is not yours to see.": "The task does not exist, or it is not yours to see.", + "Details": "Details", + "Due": "Due", + "Assignee": "Assignee", + "Requested by": "Requested by", + "Outcome": "Outcome", + "Ready to claim": "Ready to claim", + "In progress": "In progress", + "Terminated": "Terminated", + "Normal": "Normal", + "Urgent": "Urgent", + "Overdue by 1 day": "Overdue by 1 day", + "Overdue by {days} days": "Overdue by {days} days", + "Due today": "Due today", + "Due tomorrow": "Due tomorrow", + "Due in {days} days": "Due in {days} days", + "Tasks": "Tasks", + "Task": "Task", + "Unassigned, open for claiming": "Unassigned, open for claiming", + "Claim": "Claim", + "Complete": "Complete", + "Complete: {outcome}": "Complete: {outcome}", + "Could not load the task": "Could not load the task", + "The task refused that action": "The task refused that action", + "No such run": "No such run", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/es.js b/l10n/es.js index 7c3dbfdd38..1c82643f99 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Conecta una cuenta social o de analítica una sola vez. OpenRegister guarda el token y lo renueva, de modo que las aplicaciones nunca lo retienen.", + "Connect account": "Conectar cuenta", + "Connected accounts": "Cuentas conectadas", + "Could not disconnect this account.": "No se pudo desconectar esta cuenta.", + "Could not load your connected accounts.": "No se pudieron cargar tus cuentas conectadas.", + "Could not start the connection. Check the provider and try again.": "No se pudo iniciar la conexión. Comprueba el proveedor e inténtalo de nuevo.", + "Disconnect": "Desconectar", + "Expired": "Caducado", + "Expires {date}": "Caduca el {date}", + "No accounts are connected yet.": "Aún no hay cuentas conectadas.", + "Not connected yet": "Aún no conectado", + "Reconnect": "Reconectar", + "Relink needed": "Hace falta volver a enlazar", + "Server address": "Dirección del servidor", + "Load example data?": "¿Cargar datos de ejemplo?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Los datos de ejemplo llenan las listas, las páginas de detalle y los paneles, para que veas la aplicación funcionando de inmediato. Elige \"Ninguno\" en una instalación de producción.", + "Load the example data": "Cargar los datos de ejemplo", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Carga lo que elegiste. Son datos de ejemplo evidentes, se puede ejecutar más de una vez sin riesgo y puedes borrarlos después.", + "None, I will set this up myself": "Ninguno, lo configuro yo mismo", + "Nothing is imported. You start with an empty app and add your own data.": "No se importa nada. Empiezas con una aplicación vacía y añades tus propios datos.", + "Example data": "Datos de ejemplo", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valores de ejemplo para cada esquema que aporta esta aplicación, generados a partir de los propios esquemas. Muestran las listas, las páginas de detalle y los paneles en funcionamiento en lugar de contar una historia. Se puede repetir sin riesgo y borrar después.", + "from amount {amount}": "a partir del importe {amount}", + "Gated transition: {transition}": "Transición controlada: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Cada cadena declarada abre una secuencia ordenada de tareas cuando se intenta la transición. Quienes aprueban deciden en su bandeja de tareas, no aquí.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declare x-openregister-approval-chains en este esquema para condicionar una transición del ciclo de vida a una aprobación.", + "Approval sequences": "Secuencias de aprobación", "%n entries have no hash yet": "%n entradas aún no tienen hash", "%n entry has no hash yet": "%n entrada aún no tiene hash", "(Dolphin required)": "(Se requiere Dolphin)", + "No approval chains declared": "No hay cadenas de aprobación declaradas", "(no subject)": "(sin asunto)", "(no title)": "(sin título)", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "La transición se aplica automáticamente cuando todos los pasos están aprobados.", + "The requester may not decide their own approval.": "El solicitante no puede decidir sobre su propia aprobación.", + "Tier routing on field: {field}": "Enrutamiento por tramos según el campo: {field}", "(unnamed)": "(sin nombre)", "(untitled card)": "(tarjeta sin título)", "({count} selected)": "({count} seleccionados)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "No existe esa tarea", + "The task does not exist, or it is not yours to see.": "La tarea no existe o no puede verla.", + "Details": "Detalles", + "Due": "Vencimiento", + "Assignee": "Asignado a", + "Requested by": "Solicitado por", + "Outcome": "Resultado", + "Ready to claim": "Listo para reclamar", + "In progress": "En curso", + "Terminated": "Terminada", + "Normal": "Normal", + "Urgent": "Urgente", + "Overdue by 1 day": "Atrasada 1 día", + "Overdue by {days} days": "Atrasada {days} días", + "Due today": "Vence hoy", + "Due tomorrow": "Vence mañana", + "Due in {days} days": "Vence en {days} días", + "Tasks": "Tareas", + "Task": "Tarea", + "Unassigned, open for claiming": "Sin asignar, abierta para reclamar", + "Claim": "Reclamar", + "Complete": "Completar", + "Complete: {outcome}": "Completar: {outcome}", + "Could not load the task": "No se pudo cargar la tarea", + "The task refused that action": "La tarea rechazó esa acción", + "Store": "Tienda", + "Install registers, schemas and flows that other organisations have published.": "Instale registros, esquemas y flujos publicados por otras organizaciones.", + "No such run": "No existe esa ejecución", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "La ejecución no existe o no puede verla. Al eliminar un flujo se eliminan sus ejecuciones." }, "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..7b11bf85c9 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Conecta una cuenta social o de analítica una sola vez. OpenRegister guarda el token y lo renueva, de modo que las aplicaciones nunca lo retienen.", + "Connect account": "Conectar cuenta", + "Connected accounts": "Cuentas conectadas", + "Could not disconnect this account.": "No se pudo desconectar esta cuenta.", + "Could not load your connected accounts.": "No se pudieron cargar tus cuentas conectadas.", + "Could not start the connection. Check the provider and try again.": "No se pudo iniciar la conexión. Comprueba el proveedor e inténtalo de nuevo.", + "Disconnect": "Desconectar", + "Expired": "Caducado", + "Expires {date}": "Caduca el {date}", + "No accounts are connected yet.": "Aún no hay cuentas conectadas.", + "Not connected yet": "Aún no conectado", + "Reconnect": "Reconectar", + "Relink needed": "Hace falta volver a enlazar", + "Server address": "Dirección del servidor", + "Load example data?": "¿Cargar datos de ejemplo?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Los datos de ejemplo llenan las listas, las páginas de detalle y los paneles, para que veas la aplicación funcionando de inmediato. Elige \"Ninguno\" en una instalación de producción.", + "Load the example data": "Cargar los datos de ejemplo", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Carga lo que elegiste. Son datos de ejemplo evidentes, se puede ejecutar más de una vez sin riesgo y puedes borrarlos después.", + "None, I will set this up myself": "Ninguno, lo configuro yo mismo", + "Nothing is imported. You start with an empty app and add your own data.": "No se importa nada. Empiezas con una aplicación vacía y añades tus propios datos.", + "Example data": "Datos de ejemplo", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valores de ejemplo para cada esquema que aporta esta aplicación, generados a partir de los propios esquemas. Muestran las listas, las páginas de detalle y los paneles en funcionamiento en lugar de contar una historia. Se puede repetir sin riesgo y borrar después.", + "from amount {amount}": "a partir del importe {amount}", + "Gated transition: {transition}": "Transición controlada: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Cada cadena declarada abre una secuencia ordenada de tareas cuando se intenta la transición. Quienes aprueban deciden en su bandeja de tareas, no aquí.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declare x-openregister-approval-chains en este esquema para condicionar una transición del ciclo de vida a una aprobación.", + "Approval sequences": "Secuencias de aprobación", "%n entries have no hash yet": "%n entradas aún no tienen hash", "%n entry has no hash yet": "%n entrada aún no tiene hash", "(Dolphin required)": "(Se requiere Dolphin)", + "No approval chains declared": "No hay cadenas de aprobación declaradas", "(no subject)": "(sin asunto)", "(no title)": "(sin título)", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "La transición se aplica automáticamente cuando todos los pasos están aprobados.", + "The requester may not decide their own approval.": "El solicitante no puede decidir sobre su propia aprobación.", + "Tier routing on field: {field}": "Enrutamiento por tramos según el campo: {field}", "(unnamed)": "(sin nombre)", "(untitled card)": "(tarjeta sin título)", "({count} selected)": "({count} seleccionados)", @@ -2801,7 +2833,88 @@ "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.", + "No such task": "No existe esa tarea", + "The task does not exist, or it is not yours to see.": "La tarea no existe o no puede verla.", + "Details": "Detalles", + "Due": "Vencimiento", + "Assignee": "Asignado a", + "Requested by": "Solicitado por", + "Outcome": "Resultado", + "Ready to claim": "Listo para reclamar", + "In progress": "En curso", + "Terminated": "Terminada", + "Normal": "Normal", + "Urgent": "Urgente", + "Overdue by 1 day": "Atrasada 1 día", + "Overdue by {days} days": "Atrasada {days} días", + "Due today": "Vence hoy", + "Due tomorrow": "Vence mañana", + "Due in {days} days": "Vence en {days} días", + "Tasks": "Tareas", + "Task": "Tarea", + "Unassigned, open for claiming": "Sin asignar, abierta para reclamar", + "Claim": "Reclamar", + "Complete": "Completar", + "Complete: {outcome}": "Completar: {outcome}", + "Could not load the task": "No se pudo cargar la tarea", + "The task refused that action": "La tarea rechazó esa acción", + "Store": "Tienda", + "Install registers, schemas and flows that other organisations have published.": "Instale registros, esquemas y flujos publicados por otras organizaciones.", + "No such run": "No existe esa ejecución", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "La ejecución no existe o no puede verla. Al eliminar un flujo se eliminan sus ejecuciones." }, "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..76ec5796cb 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Ühenda sotsiaal- või analüütikakonto ühe korra. OpenRegister hoiab loa alles ja uuendab seda, nii et rakendused ei hoia seda kunagi ise.", + "Connect account": "Ühenda konto", + "Connected accounts": "Ühendatud kontod", + "Could not disconnect this account.": "Selle konto ühendust ei õnnestunud katkestada.", + "Could not load your connected accounts.": "Sinu ühendatud kontode laadimine ebaõnnestus.", + "Could not start the connection. Check the provider and try again.": "Ühenduse alustamine ebaõnnestus. Kontrolli teenusepakkujat ja proovi uuesti.", + "Disconnect": "Katkesta ühendus", + "Expired": "Aegunud", + "Expires {date}": "Aegub {date}", + "No accounts are connected yet.": "Ühtegi kontot pole veel ühendatud.", + "Not connected yet": "Pole veel ühendatud", + "Reconnect": "Ühenda uuesti", + "Relink needed": "Vaja on uut sidumist", + "Server address": "Serveri aadress", + "Load example data?": "Kas laadida näidisandmed?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Näidisandmed täidavad loendid, detailvaated ja töölauad, nii et näete rakendust kohe töötamas. Tootmispaigalduses valige \"Puudub\".", + "Load the example data": "Laadi näidisandmed", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadib selle, mille valisite. Need on selgelt näidisandmed, toimingut võib ohutult korrata ja hiljem saab need kustutada.", + "None, I will set this up myself": "Puudub, seadistan ise", + "Nothing is imported. You start with an empty app and add your own data.": "Midagi ei impordita. Alustate tühja rakendusega ja lisate oma andmed.", + "Example data": "Näidisandmed", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Näidisväärtused iga skeemi kohta, mille see rakendus kaasa toob, loodud skeemide endi põhjal. Need näitavad loendeid, detailvaateid ja töölaudu töös, mitte ei jutusta lugu. Ohutu korrata ja hiljem kustutada.", + "from amount {amount}": "alates summast {amount}", + "Gated transition: {transition}": "Valvatud üleminek: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Iga deklareeritud ahel avab ülemineku proovimisel järjestatud ülesannete jada. Kinnitajad otsustavad oma ülesannete postkastis, mitte siin.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklareeri sellel skeemil x-openregister-approval-chains, et seada elutsükli üleminek kinnituse taha.", + "Approval sequences": "Kinnitusjadad", "%n entries have no hash yet": "%n kirjel pole veel räsi", "%n entry has no hash yet": "%n kirjel pole veel räsi", "(Dolphin required)": "(Dolphin nõutav)", + "No approval chains declared": "Ühtegi kinnitusahelat pole deklareeritud", "(no subject)": "(teema puudub)", "(no title)": "(pealkiri puudub)", + "Role {role}": "Roll {role}", + "The transition is applied automatically once every step approves.": "Üleminek rakendatakse automaatselt, kui iga samm on kinnitatud.", + "The requester may not decide their own approval.": "Taotleja ei tohi otsustada omaenda kinnituse üle.", + "Tier routing on field: {field}": "Astmeline suunamine välja järgi: {field}", "(unnamed)": "(nimetu)", "(untitled card)": "(pealkirjata kaart)", "({count} selected)": "({count} valitud)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Sellist ülesannet pole", + "The task does not exist, or it is not yours to see.": "Ülesannet pole olemas või teil pole õigust seda näha.", + "Details": "Üksikasjad", + "Due": "Tähtaeg", + "Assignee": "Vastutaja", + "Requested by": "Taotleja", + "Outcome": "Tulemus", + "Ready to claim": "Valmis võtmiseks", + "In progress": "Pooleli", + "Terminated": "Lõpetatud", + "Normal": "Tavaline", + "Urgent": "Kiireloomuline", + "Overdue by 1 day": "1 päev üle tähtaja", + "Overdue by {days} days": "{days} päeva üle tähtaja", + "Due today": "Tähtaeg täna", + "Due tomorrow": "Tähtaeg homme", + "Due in {days} days": "Tähtaeg {days} päeva pärast", + "Tasks": "Ülesanded", + "Task": "Ülesanne", + "Unassigned, open for claiming": "Määramata, avatud võtmiseks", + "Claim": "Võta endale", + "Complete": "Lõpeta", + "Complete: {outcome}": "Lõpeta: {outcome}", + "Could not load the task": "Ülesannet ei õnnestunud laadida", + "The task refused that action": "Ülesanne keeldus sellest toimingust", + "Store": "Pood", + "Install registers, schemas and flows that other organisations have published.": "Paigalda registrid, skeemid ja voog, mille teised organisatsioonid on avaldanud.", + "No such run": "Sellist käivitust pole", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Käivitust pole olemas või teil pole õigust seda näha. Voo kustutamine kustutab ka selle käivitused." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 14a0fd06cd..b4efde00f4 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Ühenda sotsiaal- või analüütikakonto ühe korra. OpenRegister hoiab loa alles ja uuendab seda, nii et rakendused ei hoia seda kunagi ise.", + "Connect account": "Ühenda konto", + "Connected accounts": "Ühendatud kontod", + "Could not disconnect this account.": "Selle konto ühendust ei õnnestunud katkestada.", + "Could not load your connected accounts.": "Sinu ühendatud kontode laadimine ebaõnnestus.", + "Could not start the connection. Check the provider and try again.": "Ühenduse alustamine ebaõnnestus. Kontrolli teenusepakkujat ja proovi uuesti.", + "Disconnect": "Katkesta ühendus", + "Expired": "Aegunud", + "Expires {date}": "Aegub {date}", + "No accounts are connected yet.": "Ühtegi kontot pole veel ühendatud.", + "Not connected yet": "Pole veel ühendatud", + "Reconnect": "Ühenda uuesti", + "Relink needed": "Vaja on uut sidumist", + "Server address": "Serveri aadress", + "Load example data?": "Kas laadida näidisandmed?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Näidisandmed täidavad loendid, detailvaated ja töölauad, nii et näete rakendust kohe töötamas. Tootmispaigalduses valige \"Puudub\".", + "Load the example data": "Laadi näidisandmed", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadib selle, mille valisite. Need on selgelt näidisandmed, toimingut võib ohutult korrata ja hiljem saab need kustutada.", + "None, I will set this up myself": "Puudub, seadistan ise", + "Nothing is imported. You start with an empty app and add your own data.": "Midagi ei impordita. Alustate tühja rakendusega ja lisate oma andmed.", + "Example data": "Näidisandmed", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Näidisväärtused iga skeemi kohta, mille see rakendus kaasa toob, loodud skeemide endi põhjal. Need näitavad loendeid, detailvaateid ja töölaudu töös, mitte ei jutusta lugu. Ohutu korrata ja hiljem kustutada.", + "from amount {amount}": "alates summast {amount}", + "Gated transition: {transition}": "Valvatud üleminek: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Iga deklareeritud ahel avab ülemineku proovimisel järjestatud ülesannete jada. Kinnitajad otsustavad oma ülesannete postkastis, mitte siin.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklareeri sellel skeemil x-openregister-approval-chains, et seada elutsükli üleminek kinnituse taha.", + "Approval sequences": "Kinnitusjadad", "%n entries have no hash yet": "%n kirjel pole veel räsi", "%n entry has no hash yet": "%n kirjel pole veel räsi", "(Dolphin required)": "(Dolphin nõutav)", + "No approval chains declared": "Ühtegi kinnitusahelat pole deklareeritud", "(no subject)": "(teema puudub)", "(no title)": "(pealkiri puudub)", + "Role {role}": "Roll {role}", + "The transition is applied automatically once every step approves.": "Üleminek rakendatakse automaatselt, kui iga samm on kinnitatud.", + "The requester may not decide their own approval.": "Taotleja ei tohi otsustada omaenda kinnituse üle.", + "Tier routing on field: {field}": "Astmeline suunamine välja järgi: {field}", "(unnamed)": "(nimetu)", "(untitled card)": "(pealkirjata kaart)", "({count} selected)": "({count} valitud)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Sellist ülesannet pole", + "The task does not exist, or it is not yours to see.": "Ülesannet pole olemas või teil pole õigust seda näha.", + "Details": "Üksikasjad", + "Due": "Tähtaeg", + "Assignee": "Vastutaja", + "Requested by": "Taotleja", + "Outcome": "Tulemus", + "Ready to claim": "Valmis võtmiseks", + "In progress": "Pooleli", + "Terminated": "Lõpetatud", + "Normal": "Tavaline", + "Urgent": "Kiireloomuline", + "Overdue by 1 day": "1 päev üle tähtaja", + "Overdue by {days} days": "{days} päeva üle tähtaja", + "Due today": "Tähtaeg täna", + "Due tomorrow": "Tähtaeg homme", + "Due in {days} days": "Tähtaeg {days} päeva pärast", + "Tasks": "Ülesanded", + "Task": "Ülesanne", + "Unassigned, open for claiming": "Määramata, avatud võtmiseks", + "Claim": "Võta endale", + "Complete": "Lõpeta", + "Complete: {outcome}": "Lõpeta: {outcome}", + "Could not load the task": "Ülesannet ei õnnestunud laadida", + "The task refused that action": "Ülesanne keeldus sellest toimingust", + "Store": "Pood", + "Install registers, schemas and flows that other organisations have published.": "Paigalda registrid, skeemid ja voog, mille teised organisatsioonid on avaldanud.", + "No such run": "Sellist käivitust pole", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Käivitust pole olemas või teil pole õigust seda näha. Voo kustutamine kustutab ka selle käivitused." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/fi.js b/l10n/fi.js index ff4493ddf5..eb077b0f6c 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Yhdistä sosiaalisen median tai analytiikan tili kerran. OpenRegister säilyttää tunnisteen ja uusii sen, joten sovellukset eivät koskaan pidä sitä hallussaan.", + "Connect account": "Yhdistä tili", + "Connected accounts": "Yhdistetyt tilit", + "Could not disconnect this account.": "Tämän tilin yhteyttä ei voitu katkaista.", + "Could not load your connected accounts.": "Yhdistettyjä tilejäsi ei voitu ladata.", + "Could not start the connection. Check the provider and try again.": "Yhteyttä ei voitu aloittaa. Tarkista palveluntarjoaja ja yritä uudelleen.", + "Disconnect": "Katkaise yhteys", + "Expired": "Vanhentunut", + "Expires {date}": "Vanhenee {date}", + "No accounts are connected yet.": "Yhtään tiliä ei ole vielä yhdistetty.", + "Not connected yet": "Ei vielä yhdistetty", + "Reconnect": "Yhdistä uudelleen", + "Relink needed": "Uudelleenlinkitys tarvitaan", + "Server address": "Palvelimen osoite", + "Load example data?": "Ladataanko esimerkkitiedot?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Esimerkkitiedot täyttävät luettelot, tietosivut ja koontinäytöt, joten näet sovelluksen heti toiminnassa. Valitse tuotantoasennuksessa \"Ei mitään\".", + "Load the example data": "Lataa esimerkkitiedot", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Lataa sen, minkä valitsit. Tiedot ovat selvästi esimerkkitietoja, toiminnon voi toistaa turvallisesti ja ne voi poistaa jälkeenpäin.", + "None, I will set this up myself": "Ei mitään, teen asetukset itse", + "Nothing is imported. You start with an empty app and add your own data.": "Mitään ei tuoda. Aloitat tyhjästä sovelluksesta ja lisäät omat tietosi.", + "Example data": "Esimerkkitiedot", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Esimerkkiarvot jokaiselle skeemalle, jonka tämä sovellus tuo mukanaan, luotuina skeemoista itsestään. Ne näyttävät luettelot, tietosivut ja koontinäytöt toiminnassa sen sijaan että kertoisivat tarinan. Turvallista toistaa ja poistaa jälkeenpäin.", + "from amount {amount}": "summasta {amount} alkaen", + "Gated transition: {transition}": "Valvottu siirtymä: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Jokainen määritetty ketju avaa järjestetyn tehtäväsarjan, kun siirtymää yritetään. Hyväksyjät päättävät tehtäväpostilaatikossaan, eivät täällä.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Määritä x-openregister-approval-chains tähän skeemaan, jotta elinkaarisiirtymä vaatii hyväksynnän.", + "Approval sequences": "Hyväksyntäsarjat", "%n entries have no hash yet": "%n merkinnällä ei ole vielä tiivistettä", "%n entry has no hash yet": "%n merkinnällä ei ole vielä tiivistettä", "(Dolphin required)": "(Dolphin vaaditaan)", + "No approval chains declared": "Hyväksyntäketjuja ei ole määritetty", "(no subject)": "(ei aihetta)", "(no title)": "(ei otsikkoa)", + "Role {role}": "Rooli {role}", + "The transition is applied automatically once every step approves.": "Siirtymä toteutetaan automaattisesti, kun jokainen vaihe on hyväksytty.", + "The requester may not decide their own approval.": "Pyytäjä ei saa päättää omasta hyväksynnästään.", + "Tier routing on field: {field}": "Porrastettu reititys kentän mukaan: {field}", "(unnamed)": "(nimetön)", "(untitled card)": "(nimetön kortti)", "({count} selected)": "({count} valittu)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Tehtävää ei löydy", + "The task does not exist, or it is not yours to see.": "Tehtävää ei ole, tai sinulla ei ole oikeutta nähdä sitä.", + "Details": "Tiedot", + "Due": "Määräaika", + "Assignee": "Vastuuhenkilö", + "Requested by": "Pyytäjä", + "Outcome": "Lopputulos", + "Ready to claim": "Valmis otettavaksi", + "In progress": "Käynnissä", + "Terminated": "Päätetty", + "Normal": "Normaali", + "Urgent": "Kiireellinen", + "Overdue by 1 day": "1 päivän myöhässä", + "Overdue by {days} days": "{days} päivää myöhässä", + "Due today": "Määräaika tänään", + "Due tomorrow": "Määräaika huomenna", + "Due in {days} days": "Määräaika {days} päivän päästä", + "Tasks": "Tehtävät", + "Task": "Tehtävä", + "Unassigned, open for claiming": "Ei vastuuhenkilöä, vapaasti otettavissa", + "Claim": "Ota itselle", + "Complete": "Valmis", + "Complete: {outcome}": "Valmis: {outcome}", + "Could not load the task": "Tehtävän lataus epäonnistui", + "The task refused that action": "Tehtävä hylkäsi toiminnon", + "Store": "Kauppa", + "Install registers, schemas and flows that other organisations have published.": "Asenna muiden organisaatioiden julkaisemia rekistereitä, skeemoja ja vuokaavioita.", + "No such run": "Suoritusta ei löydy", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Suoritusta ei ole, tai sinulla ei ole oikeutta nähdä sitä. Vuon poistaminen poistaa myös sen suoritukset." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 550c749be7..2b5e34b86f 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Yhdistä sosiaalisen median tai analytiikan tili kerran. OpenRegister säilyttää tunnisteen ja uusii sen, joten sovellukset eivät koskaan pidä sitä hallussaan.", + "Connect account": "Yhdistä tili", + "Connected accounts": "Yhdistetyt tilit", + "Could not disconnect this account.": "Tämän tilin yhteyttä ei voitu katkaista.", + "Could not load your connected accounts.": "Yhdistettyjä tilejäsi ei voitu ladata.", + "Could not start the connection. Check the provider and try again.": "Yhteyttä ei voitu aloittaa. Tarkista palveluntarjoaja ja yritä uudelleen.", + "Disconnect": "Katkaise yhteys", + "Expired": "Vanhentunut", + "Expires {date}": "Vanhenee {date}", + "No accounts are connected yet.": "Yhtään tiliä ei ole vielä yhdistetty.", + "Not connected yet": "Ei vielä yhdistetty", + "Reconnect": "Yhdistä uudelleen", + "Relink needed": "Uudelleenlinkitys tarvitaan", + "Server address": "Palvelimen osoite", + "Load example data?": "Ladataanko esimerkkitiedot?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Esimerkkitiedot täyttävät luettelot, tietosivut ja koontinäytöt, joten näet sovelluksen heti toiminnassa. Valitse tuotantoasennuksessa \"Ei mitään\".", + "Load the example data": "Lataa esimerkkitiedot", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Lataa sen, minkä valitsit. Tiedot ovat selvästi esimerkkitietoja, toiminnon voi toistaa turvallisesti ja ne voi poistaa jälkeenpäin.", + "None, I will set this up myself": "Ei mitään, teen asetukset itse", + "Nothing is imported. You start with an empty app and add your own data.": "Mitään ei tuoda. Aloitat tyhjästä sovelluksesta ja lisäät omat tietosi.", + "Example data": "Esimerkkitiedot", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Esimerkkiarvot jokaiselle skeemalle, jonka tämä sovellus tuo mukanaan, luotuina skeemoista itsestään. Ne näyttävät luettelot, tietosivut ja koontinäytöt toiminnassa sen sijaan että kertoisivat tarinan. Turvallista toistaa ja poistaa jälkeenpäin.", + "from amount {amount}": "summasta {amount} alkaen", + "Gated transition: {transition}": "Valvottu siirtymä: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Jokainen määritetty ketju avaa järjestetyn tehtäväsarjan, kun siirtymää yritetään. Hyväksyjät päättävät tehtäväpostilaatikossaan, eivät täällä.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Määritä x-openregister-approval-chains tähän skeemaan, jotta elinkaarisiirtymä vaatii hyväksynnän.", + "Approval sequences": "Hyväksyntäsarjat", "%n entries have no hash yet": "%n merkinnällä ei ole vielä tiivistettä", "%n entry has no hash yet": "%n merkinnällä ei ole vielä tiivistettä", "(Dolphin required)": "(Dolphin vaaditaan)", + "No approval chains declared": "Hyväksyntäketjuja ei ole määritetty", "(no subject)": "(ei aihetta)", "(no title)": "(ei otsikkoa)", + "Role {role}": "Rooli {role}", + "The transition is applied automatically once every step approves.": "Siirtymä toteutetaan automaattisesti, kun jokainen vaihe on hyväksytty.", + "The requester may not decide their own approval.": "Pyytäjä ei saa päättää omasta hyväksynnästään.", + "Tier routing on field: {field}": "Porrastettu reititys kentän mukaan: {field}", "(unnamed)": "(nimetön)", "(untitled card)": "(nimetön kortti)", "({count} selected)": "({count} valittu)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Tehtävää ei löydy", + "The task does not exist, or it is not yours to see.": "Tehtävää ei ole, tai sinulla ei ole oikeutta nähdä sitä.", + "Details": "Tiedot", + "Due": "Määräaika", + "Assignee": "Vastuuhenkilö", + "Requested by": "Pyytäjä", + "Outcome": "Lopputulos", + "Ready to claim": "Valmis otettavaksi", + "In progress": "Käynnissä", + "Terminated": "Päätetty", + "Normal": "Normaali", + "Urgent": "Kiireellinen", + "Overdue by 1 day": "1 päivän myöhässä", + "Overdue by {days} days": "{days} päivää myöhässä", + "Due today": "Määräaika tänään", + "Due tomorrow": "Määräaika huomenna", + "Due in {days} days": "Määräaika {days} päivän päästä", + "Tasks": "Tehtävät", + "Task": "Tehtävä", + "Unassigned, open for claiming": "Ei vastuuhenkilöä, vapaasti otettavissa", + "Claim": "Ota itselle", + "Complete": "Valmis", + "Complete: {outcome}": "Valmis: {outcome}", + "Could not load the task": "Tehtävän lataus epäonnistui", + "The task refused that action": "Tehtävä hylkäsi toiminnon", + "Store": "Kauppa", + "Install registers, schemas and flows that other organisations have published.": "Asenna muiden organisaatioiden julkaisemia rekistereitä, skeemoja ja vuokaavioita.", + "No such run": "Suoritusta ei löydy", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Suoritusta ei ole, tai sinulla ei ole oikeutta nähdä sitä. Vuon poistaminen poistaa myös sen suoritukset." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/fr.js b/l10n/fr.js index 4319250504..ee144723ec 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Connectez un compte social ou analytique une seule fois. OpenRegister conserve le jeton et le renouvelle, pour que les applications ne le détiennent jamais.", + "Connect account": "Connecter un compte", + "Connected accounts": "Comptes connectés", + "Could not disconnect this account.": "Impossible de déconnecter ce compte.", + "Could not load your connected accounts.": "Impossible de charger vos comptes connectés.", + "Could not start the connection. Check the provider and try again.": "Impossible de démarrer la connexion. Vérifiez le fournisseur et réessayez.", + "Disconnect": "Déconnecter", + "Expired": "Expiré", + "Expires {date}": "Expire le {date}", + "No accounts are connected yet.": "Aucun compte n’est encore connecté.", + "Not connected yet": "Pas encore connecté", + "Reconnect": "Reconnecter", + "Relink needed": "Nouvelle liaison requise", + "Server address": "Adresse du serveur", + "Load example data?": "Charger des données d’exemple ?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Les données d’exemple remplissent les listes, les pages de détail et les tableaux de bord, pour voir l’application fonctionner tout de suite. Choisissez \"Aucune\" sur une installation de production.", + "Load the example data": "Charger les données d’exemple", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Charge ce que vous avez choisi. Ce sont visiblement des données d’exemple, l’opération peut être relancée sans risque, et vous pouvez les supprimer ensuite.", + "None, I will set this up myself": "Aucune, je configure moi-même", + "Nothing is imported. You start with an empty app and add your own data.": "Rien n’est importé. Vous commencez avec une application vide et ajoutez vos propres données.", + "Example data": "Données d’exemple", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Des valeurs d’exemple pour chaque schéma fourni par cette application, générées à partir des schémas eux-mêmes. Elles montrent les listes, les pages de détail et les tableaux de bord en fonctionnement plutôt que de raconter une histoire. Relançable sans risque, et supprimable ensuite.", + "from amount {amount}": "à partir du montant {amount}", + "Gated transition: {transition}": "Transition contrôlée : {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Chaque chaîne déclarée ouvre une séquence de tâches ordonnée dès que la transition est tentée. Les approbateurs décident dans leur boîte de tâches, pas ici.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Déclarez x-openregister-approval-chains sur ce schéma pour conditionner une transition de cycle de vie à une approbation.", + "Approval sequences": "Séquences d'approbation", "%n entries have no hash yet": "%n entrées n'ont pas encore de hachage", "%n entry has no hash yet": "%n entrée n'a pas encore de hachage", "(Dolphin required)": "(Dolphin requis)", + "No approval chains declared": "Aucune chaîne d'approbation déclarée", "(no subject)": "(sans objet)", "(no title)": "(sans titre)", + "Role {role}": "Rôle {role}", + "The transition is applied automatically once every step approves.": "La transition est appliquée automatiquement dès que chaque étape est approuvée.", + "The requester may not decide their own approval.": "Le demandeur ne peut pas décider de sa propre approbation.", + "Tier routing on field: {field}": "Routage par palier sur le champ : {field}", "(unnamed)": "(sans nom)", "(untitled card)": "(carte sans titre)", "({count} selected)": "({count} sélectionné(s))", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Aucune tâche trouvée", + "The task does not exist, or it is not yours to see.": "La tâche n'existe pas, ou vous n'êtes pas autorisé à la voir.", + "Details": "Détails", + "Due": "Échéance", + "Assignee": "Assigné à", + "Requested by": "Demandé par", + "Outcome": "Résultat", + "Ready to claim": "Prêt à réclamer", + "In progress": "En cours", + "Terminated": "Terminé", + "Normal": "Normale", + "Urgent": "Urgent", + "Overdue by 1 day": "En retard de 1 jour", + "Overdue by {days} days": "En retard de {days} jours", + "Due today": "À rendre aujourd'hui", + "Due tomorrow": "À rendre demain", + "Due in {days} days": "À rendre dans {days} jours", + "Tasks": "Tâches", + "Task": "Tâche", + "Unassigned, open for claiming": "Non assignée, ouverte à la réclamation", + "Claim": "Réclamer", + "Complete": "Terminer", + "Complete: {outcome}": "Terminer : {outcome}", + "Could not load the task": "Impossible de charger la tâche", + "The task refused that action": "La tâche a refusé cette action", + "Store": "Boutique", + "Install registers, schemas and flows that other organisations have published.": "Installez des registres, schémas et flux publiés par d'autres organisations.", + "No such run": "Aucune exécution trouvée", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L'exécution n'existe pas, ou vous n'êtes pas autorisé à la voir. Supprimer un flux supprime ses exécutions." }, "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..0e2c341a4e 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Connectez un compte social ou analytique une seule fois. OpenRegister conserve le jeton et le renouvelle, pour que les applications ne le détiennent jamais.", + "Connect account": "Connecter un compte", + "Connected accounts": "Comptes connectés", + "Could not disconnect this account.": "Impossible de déconnecter ce compte.", + "Could not load your connected accounts.": "Impossible de charger vos comptes connectés.", + "Could not start the connection. Check the provider and try again.": "Impossible de démarrer la connexion. Vérifiez le fournisseur et réessayez.", + "Disconnect": "Déconnecter", + "Expired": "Expiré", + "Expires {date}": "Expire le {date}", + "No accounts are connected yet.": "Aucun compte n’est encore connecté.", + "Not connected yet": "Pas encore connecté", + "Reconnect": "Reconnecter", + "Relink needed": "Nouvelle liaison requise", + "Server address": "Adresse du serveur", + "Load example data?": "Charger des données d’exemple ?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Les données d’exemple remplissent les listes, les pages de détail et les tableaux de bord, pour voir l’application fonctionner tout de suite. Choisissez \"Aucune\" sur une installation de production.", + "Load the example data": "Charger les données d’exemple", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Charge ce que vous avez choisi. Ce sont visiblement des données d’exemple, l’opération peut être relancée sans risque, et vous pouvez les supprimer ensuite.", + "None, I will set this up myself": "Aucune, je configure moi-même", + "Nothing is imported. You start with an empty app and add your own data.": "Rien n’est importé. Vous commencez avec une application vide et ajoutez vos propres données.", + "Example data": "Données d’exemple", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Des valeurs d’exemple pour chaque schéma fourni par cette application, générées à partir des schémas eux-mêmes. Elles montrent les listes, les pages de détail et les tableaux de bord en fonctionnement plutôt que de raconter une histoire. Relançable sans risque, et supprimable ensuite.", + "from amount {amount}": "à partir du montant {amount}", + "Gated transition: {transition}": "Transition contrôlée : {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Chaque chaîne déclarée ouvre une séquence de tâches ordonnée dès que la transition est tentée. Les approbateurs décident dans leur boîte de tâches, pas ici.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Déclarez x-openregister-approval-chains sur ce schéma pour conditionner une transition de cycle de vie à une approbation.", + "Approval sequences": "Séquences d'approbation", "%n entries have no hash yet": "%n entrées n'ont pas encore de hachage", "%n entry has no hash yet": "%n entrée n'a pas encore de hachage", "(Dolphin required)": "(Dolphin requis)", + "No approval chains declared": "Aucune chaîne d'approbation déclarée", "(no subject)": "(sans objet)", "(no title)": "(sans titre)", + "Role {role}": "Rôle {role}", + "The transition is applied automatically once every step approves.": "La transition est appliquée automatiquement dès que chaque étape est approuvée.", + "The requester may not decide their own approval.": "Le demandeur ne peut pas décider de sa propre approbation.", + "Tier routing on field: {field}": "Routage par palier sur le champ : {field}", "(unnamed)": "(sans nom)", "(untitled card)": "(carte sans titre)", "({count} selected)": "({count} sélectionné(s))", @@ -2801,7 +2833,88 @@ "{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.", + "No such task": "Aucune tâche trouvée", + "The task does not exist, or it is not yours to see.": "La tâche n'existe pas, ou vous n'êtes pas autorisé à la voir.", + "Details": "Détails", + "Due": "Échéance", + "Assignee": "Assigné à", + "Requested by": "Demandé par", + "Outcome": "Résultat", + "Ready to claim": "Prêt à réclamer", + "In progress": "En cours", + "Terminated": "Terminé", + "Normal": "Normale", + "Urgent": "Urgent", + "Overdue by 1 day": "En retard de 1 jour", + "Overdue by {days} days": "En retard de {days} jours", + "Due today": "À rendre aujourd'hui", + "Due tomorrow": "À rendre demain", + "Due in {days} days": "À rendre dans {days} jours", + "Tasks": "Tâches", + "Task": "Tâche", + "Unassigned, open for claiming": "Non assignée, ouverte à la réclamation", + "Claim": "Réclamer", + "Complete": "Terminer", + "Complete: {outcome}": "Terminer : {outcome}", + "Could not load the task": "Impossible de charger la tâche", + "The task refused that action": "La tâche a refusé cette action", + "Store": "Boutique", + "Install registers, schemas and flows that other organisations have published.": "Installez des registres, schémas et flux publiés par d'autres organisations.", + "No such run": "Aucune exécution trouvée", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L'exécution n'existe pas, ou vous n'êtes pas autorisé à la voir. Supprimer un flux supprime ses exécutions." }, "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..6e6ae054e8 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Ceangail cuntas sóisialta nó anailíse uair amháin. Coinníonn OpenRegister an ceadchomhartha agus athnuann é, sa chaoi nach mbíonn sé ag na feidhmchláir riamh.", + "Connect account": "Ceangail cuntas", + "Connected accounts": "Cuntais cheangailte", + "Could not disconnect this account.": "Níorbh fhéidir an cuntas seo a dhícheangal.", + "Could not load your connected accounts.": "Níorbh fhéidir do chuntais cheangailte a lódáil.", + "Could not start the connection. Check the provider and try again.": "Níorbh fhéidir an ceangal a thosú. Seiceáil an soláthraí agus bain triail eile as.", + "Disconnect": "Dícheangail", + "Expired": "As dáta", + "Expires {date}": "Rachaidh sé in éag {date}", + "No accounts are connected yet.": "Níl aon chuntas ceangailte fós.", + "Not connected yet": "Gan cheangal fós", + "Reconnect": "Athcheangail", + "Relink needed": "Teastaíonn athnascadh", + "Server address": "Seoladh an fhreastalaí", + "Load example data?": "Luchtaigh sonraí samplacha?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Líonann sonraí samplacha na liostaí, na leathanaigh mhionsonraí agus na deais, ionas go bhfeicfidh tú an aip ag obair láithreach. Roghnaigh \"Ceann ar bith\" ar shuiteáil táirgthe.", + "Load the example data": "Luchtaigh na sonraí samplacha", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Luchtaíonn sé an rud a roghnaigh tú. Is sonraí samplacha go soiléir iad, is féidir é a rith níos mó ná uair amháin gan bhaol, agus is féidir leat iad a scriosadh ina dhiaidh.", + "None, I will set this up myself": "Ceann ar bith, cuirfidh mé féin é seo ar bun", + "Nothing is imported. You start with an empty app and add your own data.": "Ní iompórtáiltear aon rud. Tosaíonn tú le haip fholamh agus cuireann tú do shonraí féin leis.", + "Example data": "Sonraí samplacha", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Luachanna samplacha do gach scéimre a sholáthraíonn an aip seo, ginte ó na scéimrí féin. Taispeánann siad na liostaí, na leathanaigh mhionsonraí agus na deais ag obair seachas scéal a insint. Sábháilte le rith arís agus is féidir é a scriosadh ina dhiaidh.", + "from amount {amount}": "ó mhéid {amount}", + "Gated transition: {transition}": "Aistriú faoi gharda: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Osclaíonn gach slabhra dearbhaithe seicheamh ordaithe tascanna nuair a dhéantar iarracht ar an aistriú. Cinneann ceadaitheoirí ina mbosca isteach tascanna, ní anseo.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Dearbhaigh x-openregister-approval-chains ar an scéimre seo chun ceadú a éileamh roimh aistriú saolré.", + "Approval sequences": "Seichimh cheadaithe", "%n entries have no hash yet": "Níl hais ag %n iontráil fós", "%n entry has no hash yet": "Níl hais ag %n iontráil fós", "(Dolphin required)": "(Dolphin riachtanach)", + "No approval chains declared": "Níl aon slabhraí ceadaithe dearbhaithe", "(no subject)": "(gan ábhar)", "(no title)": "(gan teideal)", + "Role {role}": "Ról {role}", + "The transition is applied automatically once every step approves.": "Cuirtear an t-aistriú i bhfeidhm go huathoibríoch nuair a cheadaítear gach céim.", + "The requester may not decide their own approval.": "Ní féidir leis an iarrthóir cinneadh a dhéanamh faoina cheadú féin.", + "Tier routing on field: {field}": "Ródú de réir céime ar an réimse: {field}", "(unnamed)": "(gan ainm)", "(untitled card)": "(cárta gan teideal)", "({count} selected)": "({count} roghnaithe)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Níl a leithéid de thasc ann", + "The task does not exist, or it is not yours to see.": "Níl an tasc ann, nó níl cead agat é a fheiceáil.", + "Details": "Sonraí", + "Due": "Spriocdháta", + "Assignee": "Sannaí", + "Requested by": "Iarrtha ag", + "Outcome": "Toradh", + "Ready to claim": "Réidh le héileamh", + "In progress": "Ar siúl", + "Terminated": "Foirceanta", + "Normal": "Gnáth", + "Urgent": "Práinneach", + "Overdue by 1 day": "1 lá thar téarma", + "Overdue by {days} days": "{days} lá thar téarma", + "Due today": "Le déanamh inniu", + "Due tomorrow": "Le déanamh amárach", + "Due in {days} days": "Le déanamh i gceann {days} lá", + "Tasks": "Tascanna", + "Task": "Tasc", + "Unassigned, open for claiming": "Gan sannadh, ar oscailt le héileamh", + "Claim": "Éiligh", + "Complete": "Críochnaigh", + "Complete: {outcome}": "Críochnaigh: {outcome}", + "Could not load the task": "Níorbh fhéidir an tasc a lódáil", + "The task refused that action": "Dhiúltaigh an tasc don ghníomh sin", + "Store": "Siopa", + "Install registers, schemas and flows that other organisations have published.": "Suiteáil cláir, scéimeanna agus sruthanna a d'fhoilsigh eagraíochtaí eile.", + "No such run": "Níl a leithéid de rith ann", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Níl an rith ann, nó níl cead agat é a fheiceáil. Scriosann scriosadh srutha a chuid rití." }, "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..6fbc0c38fb 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Ceangail cuntas sóisialta nó anailíse uair amháin. Coinníonn OpenRegister an ceadchomhartha agus athnuann é, sa chaoi nach mbíonn sé ag na feidhmchláir riamh.", + "Connect account": "Ceangail cuntas", + "Connected accounts": "Cuntais cheangailte", + "Could not disconnect this account.": "Níorbh fhéidir an cuntas seo a dhícheangal.", + "Could not load your connected accounts.": "Níorbh fhéidir do chuntais cheangailte a lódáil.", + "Could not start the connection. Check the provider and try again.": "Níorbh fhéidir an ceangal a thosú. Seiceáil an soláthraí agus bain triail eile as.", + "Disconnect": "Dícheangail", + "Expired": "As dáta", + "Expires {date}": "Rachaidh sé in éag {date}", + "No accounts are connected yet.": "Níl aon chuntas ceangailte fós.", + "Not connected yet": "Gan cheangal fós", + "Reconnect": "Athcheangail", + "Relink needed": "Teastaíonn athnascadh", + "Server address": "Seoladh an fhreastalaí", + "Load example data?": "Luchtaigh sonraí samplacha?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Líonann sonraí samplacha na liostaí, na leathanaigh mhionsonraí agus na deais, ionas go bhfeicfidh tú an aip ag obair láithreach. Roghnaigh \"Ceann ar bith\" ar shuiteáil táirgthe.", + "Load the example data": "Luchtaigh na sonraí samplacha", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Luchtaíonn sé an rud a roghnaigh tú. Is sonraí samplacha go soiléir iad, is féidir é a rith níos mó ná uair amháin gan bhaol, agus is féidir leat iad a scriosadh ina dhiaidh.", + "None, I will set this up myself": "Ceann ar bith, cuirfidh mé féin é seo ar bun", + "Nothing is imported. You start with an empty app and add your own data.": "Ní iompórtáiltear aon rud. Tosaíonn tú le haip fholamh agus cuireann tú do shonraí féin leis.", + "Example data": "Sonraí samplacha", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Luachanna samplacha do gach scéimre a sholáthraíonn an aip seo, ginte ó na scéimrí féin. Taispeánann siad na liostaí, na leathanaigh mhionsonraí agus na deais ag obair seachas scéal a insint. Sábháilte le rith arís agus is féidir é a scriosadh ina dhiaidh.", + "from amount {amount}": "ó mhéid {amount}", + "Gated transition: {transition}": "Aistriú faoi gharda: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Osclaíonn gach slabhra dearbhaithe seicheamh ordaithe tascanna nuair a dhéantar iarracht ar an aistriú. Cinneann ceadaitheoirí ina mbosca isteach tascanna, ní anseo.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Dearbhaigh x-openregister-approval-chains ar an scéimre seo chun ceadú a éileamh roimh aistriú saolré.", + "Approval sequences": "Seichimh cheadaithe", "%n entries have no hash yet": "Níl hais ag %n iontráil fós", "%n entry has no hash yet": "Níl hais ag %n iontráil fós", "(Dolphin required)": "(Dolphin riachtanach)", + "No approval chains declared": "Níl aon slabhraí ceadaithe dearbhaithe", "(no subject)": "(gan ábhar)", "(no title)": "(gan teideal)", + "Role {role}": "Ról {role}", + "The transition is applied automatically once every step approves.": "Cuirtear an t-aistriú i bhfeidhm go huathoibríoch nuair a cheadaítear gach céim.", + "The requester may not decide their own approval.": "Ní féidir leis an iarrthóir cinneadh a dhéanamh faoina cheadú féin.", + "Tier routing on field: {field}": "Ródú de réir céime ar an réimse: {field}", "(unnamed)": "(gan ainm)", "(untitled card)": "(cárta gan teideal)", "({count} selected)": "({count} roghnaithe)", @@ -2833,7 +2865,88 @@ "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.", + "No such task": "Níl a leithéid de thasc ann", + "The task does not exist, or it is not yours to see.": "Níl an tasc ann, nó níl cead agat é a fheiceáil.", + "Details": "Sonraí", + "Due": "Spriocdháta", + "Assignee": "Sannaí", + "Requested by": "Iarrtha ag", + "Outcome": "Toradh", + "Ready to claim": "Réidh le héileamh", + "In progress": "Ar siúl", + "Terminated": "Foirceanta", + "Normal": "Gnáth", + "Urgent": "Práinneach", + "Overdue by 1 day": "1 lá thar téarma", + "Overdue by {days} days": "{days} lá thar téarma", + "Due today": "Le déanamh inniu", + "Due tomorrow": "Le déanamh amárach", + "Due in {days} days": "Le déanamh i gceann {days} lá", + "Tasks": "Tascanna", + "Task": "Tasc", + "Unassigned, open for claiming": "Gan sannadh, ar oscailt le héileamh", + "Claim": "Éiligh", + "Complete": "Críochnaigh", + "Complete: {outcome}": "Críochnaigh: {outcome}", + "Could not load the task": "Níorbh fhéidir an tasc a lódáil", + "The task refused that action": "Dhiúltaigh an tasc don ghníomh sin", + "Store": "Siopa", + "Install registers, schemas and flows that other organisations have published.": "Suiteáil cláir, scéimeanna agus sruthanna a d'fhoilsigh eagraíochtaí eile.", + "No such run": "Níl a leithéid de rith ann", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Níl an rith ann, nó níl cead agat é a fheiceáil. Scriosann scriosadh srutha a chuid rití." }, "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..a0e83c2dbf 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Poveži društveni ili analitički račun jednom. OpenRegister čuva token i obnavlja ga, pa ga aplikacije nikada ne drže.", + "Connect account": "Poveži račun", + "Connected accounts": "Povezani računi", + "Could not disconnect this account.": "Ovaj račun nije bilo moguće odspojiti.", + "Could not load your connected accounts.": "Nije bilo moguće učitati tvoje povezane račune.", + "Could not start the connection. Check the provider and try again.": "Nije bilo moguće pokrenuti povezivanje. Provjeri pružatelja i pokušaj ponovno.", + "Disconnect": "Odspoji", + "Expired": "Isteklo", + "Expires {date}": "Ističe {date}", + "No accounts are connected yet.": "Još nije povezan nijedan račun.", + "Not connected yet": "Još nije povezano", + "Reconnect": "Poveži ponovno", + "Relink needed": "Potrebno je ponovno povezivanje", + "Server address": "Adresa poslužitelja", + "Load example data?": "Učitati primjere podataka?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Primjeri podataka popunjavaju popise, stranice s detaljima i nadzorne ploče, pa aplikaciju odmah vidite kako radi. Odaberite \"Nema\" na produkcijskoj instalaciji.", + "Load the example data": "Učitaj primjere podataka", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Učitava ono što ste odabrali. Podaci su očito primjeri, radnja se može ponoviti bez rizika, a poslije ih možete izbrisati.", + "None, I will set this up myself": "Nema, sam ću ovo postaviti", + "Nothing is imported. You start with an empty app and add your own data.": "Ništa se ne uvozi. Počinjete s praznom aplikacijom i dodajete vlastite podatke.", + "Example data": "Primjeri podataka", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Primjeri vrijednosti za svaku shemu koju aplikacija donosi, generirani iz samih shema. Pokazuju popise, stranice s detaljima i nadzorne ploče u radu umjesto da pričaju priču. Sigurno za ponavljanje i moguće je izbrisati poslije.", + "from amount {amount}": "od iznosa {amount}", + "Gated transition: {transition}": "Nadzirani prijelaz: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Svaki deklarirani lanac otvara uređeni niz zadataka kad se prijelaz pokuša. Odobravatelji odlučuju u svojoj ulaznoj listi zadataka, ne ovdje.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarirajte x-openregister-approval-chains na ovoj shemi kako biste prijelaz životnog ciklusa uvjetovali odobrenjem.", + "Approval sequences": "Nizovi odobravanja", "%n entries have no hash yet": "%n unosa još nema hash", "%n entry has no hash yet": "%n unos još nema hash", "(Dolphin required)": "(Potreban Dolphin)", + "No approval chains declared": "Nema deklariranih lanaca odobravanja", "(no subject)": "(bez predmeta)", "(no title)": "(bez naslova)", + "Role {role}": "Uloga {role}", + "The transition is applied automatically once every step approves.": "Prijelaz se primjenjuje automatski čim je svaki korak odobren.", + "The requester may not decide their own approval.": "Podnositelj zahtjeva ne može odlučivati o vlastitom odobrenju.", + "Tier routing on field: {field}": "Usmjeravanje po razredima prema polju: {field}", "(unnamed)": "(bez naziva)", "(untitled card)": "(kartica bez naslova)", "({count} selected)": "({count} odabrano)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Nema takvog zadatka", + "The task does not exist, or it is not yours to see.": "Zadatak ne postoji ili ga ne smijete vidjeti.", + "Details": "Detalji", + "Due": "Rok", + "Assignee": "Dodijeljeno", + "Requested by": "Zatražio", + "Outcome": "Ishod", + "Ready to claim": "Spremno za preuzimanje", + "In progress": "U tijeku", + "Terminated": "Prekinuto", + "Normal": "Normalno", + "Urgent": "Hitno", + "Overdue by 1 day": "Kasni 1 dan", + "Overdue by {days} days": "Kasni {days} dana", + "Due today": "Rok je danas", + "Due tomorrow": "Rok je sutra", + "Due in {days} days": "Rok za {days} dana", + "Tasks": "Zadaci", + "Task": "Zadatak", + "Unassigned, open for claiming": "Nedodijeljeno, otvoreno za preuzimanje", + "Claim": "Preuzmi", + "Complete": "Dovrši", + "Complete: {outcome}": "Dovrši: {outcome}", + "Could not load the task": "Zadatak se ne može učitati", + "The task refused that action": "Zadatak je odbio tu radnju", + "Store": "Trgovina", + "Install registers, schemas and flows that other organisations have published.": "Instalirajte registre, sheme i tokove koje su objavile druge organizacije.", + "No such run": "Nema takvog izvođenja", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Izvođenje ne postoji ili ga ne smijete vidjeti. Brisanjem toka brišu se i njegova izvođenja." }, "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..78c01306bc 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Poveži društveni ili analitički račun jednom. OpenRegister čuva token i obnavlja ga, pa ga aplikacije nikada ne drže.", + "Connect account": "Poveži račun", + "Connected accounts": "Povezani računi", + "Could not disconnect this account.": "Ovaj račun nije bilo moguće odspojiti.", + "Could not load your connected accounts.": "Nije bilo moguće učitati tvoje povezane račune.", + "Could not start the connection. Check the provider and try again.": "Nije bilo moguće pokrenuti povezivanje. Provjeri pružatelja i pokušaj ponovno.", + "Disconnect": "Odspoji", + "Expired": "Isteklo", + "Expires {date}": "Ističe {date}", + "No accounts are connected yet.": "Još nije povezan nijedan račun.", + "Not connected yet": "Još nije povezano", + "Reconnect": "Poveži ponovno", + "Relink needed": "Potrebno je ponovno povezivanje", + "Server address": "Adresa poslužitelja", + "Load example data?": "Učitati primjere podataka?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Primjeri podataka popunjavaju popise, stranice s detaljima i nadzorne ploče, pa aplikaciju odmah vidite kako radi. Odaberite \"Nema\" na produkcijskoj instalaciji.", + "Load the example data": "Učitaj primjere podataka", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Učitava ono što ste odabrali. Podaci su očito primjeri, radnja se može ponoviti bez rizika, a poslije ih možete izbrisati.", + "None, I will set this up myself": "Nema, sam ću ovo postaviti", + "Nothing is imported. You start with an empty app and add your own data.": "Ništa se ne uvozi. Počinjete s praznom aplikacijom i dodajete vlastite podatke.", + "Example data": "Primjeri podataka", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Primjeri vrijednosti za svaku shemu koju aplikacija donosi, generirani iz samih shema. Pokazuju popise, stranice s detaljima i nadzorne ploče u radu umjesto da pričaju priču. Sigurno za ponavljanje i moguće je izbrisati poslije.", + "from amount {amount}": "od iznosa {amount}", + "Gated transition: {transition}": "Nadzirani prijelaz: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Svaki deklarirani lanac otvara uređeni niz zadataka kad se prijelaz pokuša. Odobravatelji odlučuju u svojoj ulaznoj listi zadataka, ne ovdje.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarirajte x-openregister-approval-chains na ovoj shemi kako biste prijelaz životnog ciklusa uvjetovali odobrenjem.", + "Approval sequences": "Nizovi odobravanja", "%n entries have no hash yet": "%n unosa još nema hash", "%n entry has no hash yet": "%n unos još nema hash", "(Dolphin required)": "(Potreban Dolphin)", + "No approval chains declared": "Nema deklariranih lanaca odobravanja", "(no subject)": "(bez predmeta)", "(no title)": "(bez naslova)", + "Role {role}": "Uloga {role}", + "The transition is applied automatically once every step approves.": "Prijelaz se primjenjuje automatski čim je svaki korak odobren.", + "The requester may not decide their own approval.": "Podnositelj zahtjeva ne može odlučivati o vlastitom odobrenju.", + "Tier routing on field: {field}": "Usmjeravanje po razredima prema polju: {field}", "(unnamed)": "(bez naziva)", "(untitled card)": "(kartica bez naslova)", "({count} selected)": "({count} odabrano)", @@ -2801,7 +2833,88 @@ "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.", + "No such task": "Nema takvog zadatka", + "The task does not exist, or it is not yours to see.": "Zadatak ne postoji ili ga ne smijete vidjeti.", + "Details": "Detalji", + "Due": "Rok", + "Assignee": "Dodijeljeno", + "Requested by": "Zatražio", + "Outcome": "Ishod", + "Ready to claim": "Spremno za preuzimanje", + "In progress": "U tijeku", + "Terminated": "Prekinuto", + "Normal": "Normalno", + "Urgent": "Hitno", + "Overdue by 1 day": "Kasni 1 dan", + "Overdue by {days} days": "Kasni {days} dana", + "Due today": "Rok je danas", + "Due tomorrow": "Rok je sutra", + "Due in {days} days": "Rok za {days} dana", + "Tasks": "Zadaci", + "Task": "Zadatak", + "Unassigned, open for claiming": "Nedodijeljeno, otvoreno za preuzimanje", + "Claim": "Preuzmi", + "Complete": "Dovrši", + "Complete: {outcome}": "Dovrši: {outcome}", + "Could not load the task": "Zadatak se ne može učitati", + "The task refused that action": "Zadatak je odbio tu radnju", + "Store": "Trgovina", + "Install registers, schemas and flows that other organisations have published.": "Instalirajte registre, sheme i tokove koje su objavile druge organizacije.", + "No such run": "Nema takvog izvođenja", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Izvođenje ne postoji ili ga ne smijete vidjeti. Brisanjem toka brišu se i njegova izvođenja." }, "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..023c688156 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Csatlakoztass egy közösségi vagy analitikai fiókot egyszer. Az OpenRegister megőrzi és megújítja a tokent, így az alkalmazások soha nem tárolják.", + "Connect account": "Fiók csatlakoztatása", + "Connected accounts": "Csatlakoztatott fiókok", + "Could not disconnect this account.": "Ezt a fiókot nem sikerült leválasztani.", + "Could not load your connected accounts.": "A csatlakoztatott fiókjaidat nem sikerült betölteni.", + "Could not start the connection. Check the provider and try again.": "A kapcsolatot nem sikerült elindítani. Ellenőrizd a szolgáltatót, és próbáld újra.", + "Disconnect": "Leválasztás", + "Expired": "Lejárt", + "Expires {date}": "Lejár: {date}", + "No accounts are connected yet.": "Még nincs csatlakoztatott fiók.", + "Not connected yet": "Még nincs csatlakoztatva", + "Reconnect": "Újracsatlakoztatás", + "Relink needed": "Újbóli összekapcsolás szükséges", + "Server address": "Kiszolgáló címe", + "Load example data?": "Betöltsük a mintaadatokat?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "A mintaadatok feltöltik a listákat, a részletoldalakat és az irányítópultokat, így azonnal működés közben látja az alkalmazást. Éles telepítésen válassza a \"Nincs\" lehetőséget.", + "Load the example data": "Mintaadatok betöltése", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Betölti, amit választott. Ezek nyilvánvalóan mintaadatok, a művelet biztonságosan megismételhető, és utána törölheti őket.", + "None, I will set this up myself": "Nincs, magam állítom be", + "Nothing is imported. You start with an empty app and add your own data.": "Semmi nem kerül importálásra. Üres alkalmazással kezd, és a saját adatait adja hozzá.", + "Example data": "Mintaadatok", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Mintaértékek minden sémához, amelyet ez az alkalmazás hoz magával, magukból a sémákból generálva. Működés közben mutatják a listákat, a részletoldalakat és az irányítópultokat, ahelyett hogy történetet mesélnének. Biztonságosan ismételhető és utána törölhető.", + "from amount {amount}": "{amount} összegtől", + "Gated transition: {transition}": "Őrzött átmenet: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Minden deklarált lánc rendezett feladatsorozatot nyit, amikor az átmenetet megkísérlik. A jóváhagyók a feladat-bejövőjükben döntenek, nem itt.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarálja az x-openregister-approval-chains kulcsot ezen a sémán, hogy egy életciklus-átmenet jóváhagyáshoz legyen kötve.", + "Approval sequences": "Jóváhagyási sorozatok", "%n entries have no hash yet": "%n bejegyzésnek még nincs kivonata", "%n entry has no hash yet": "%n bejegyzésnek még nincs kivonata", "(Dolphin required)": "(Dolphin szükséges)", + "No approval chains declared": "Nincs deklarált jóváhagyási lánc", "(no subject)": "(nincs tárgy)", "(no title)": "(nincs cím)", + "Role {role}": "Szerep {role}", + "The transition is applied automatically once every step approves.": "Az átmenet automatikusan végbemegy, amint minden lépést jóváhagytak.", + "The requester may not decide their own approval.": "A kérelmező nem dönthet a saját jóváhagyásáról.", + "Tier routing on field: {field}": "Sávos útválasztás a mező alapján: {field}", "(unnamed)": "(névtelen)", "(untitled card)": "(cím nélküli kártya)", "({count} selected)": "({count} kiválasztva)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Nincs ilyen feladat", + "The task does not exist, or it is not yours to see.": "A feladat nem létezik, vagy nem láthatja.", + "Details": "Részletek", + "Due": "Határidő", + "Assignee": "Felelős", + "Requested by": "Kérte", + "Outcome": "Eredmény", + "Ready to claim": "Átvehető", + "In progress": "Folyamatban", + "Terminated": "Megszakítva", + "Normal": "Normál", + "Urgent": "Sürgős", + "Overdue by 1 day": "1 napja lejárt", + "Overdue by {days} days": "{days} napja lejárt", + "Due today": "Ma esedékes", + "Due tomorrow": "Holnap esedékes", + "Due in {days} days": "{days} nap múlva esedékes", + "Tasks": "Feladatok", + "Task": "Feladat", + "Unassigned, open for claiming": "Nincs felelős, átvehető", + "Claim": "Átvétel", + "Complete": "Befejezés", + "Complete: {outcome}": "Befejezés: {outcome}", + "Could not load the task": "A feladat nem tölthető be", + "The task refused that action": "A feladat elutasította a műveletet", + "Store": "Áruház", + "Install registers, schemas and flows that other organisations have published.": "Telepítsen más szervezetek által közzétett nyilvántartásokat, sémákat és folyamatokat.", + "No such run": "Nincs ilyen futás", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "A futás nem létezik, vagy nem láthatja. Egy folyamat törlése a futásait is törli." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index f19f05a909..e8f5fd95d2 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Csatlakoztass egy közösségi vagy analitikai fiókot egyszer. Az OpenRegister megőrzi és megújítja a tokent, így az alkalmazások soha nem tárolják.", + "Connect account": "Fiók csatlakoztatása", + "Connected accounts": "Csatlakoztatott fiókok", + "Could not disconnect this account.": "Ezt a fiókot nem sikerült leválasztani.", + "Could not load your connected accounts.": "A csatlakoztatott fiókjaidat nem sikerült betölteni.", + "Could not start the connection. Check the provider and try again.": "A kapcsolatot nem sikerült elindítani. Ellenőrizd a szolgáltatót, és próbáld újra.", + "Disconnect": "Leválasztás", + "Expired": "Lejárt", + "Expires {date}": "Lejár: {date}", + "No accounts are connected yet.": "Még nincs csatlakoztatott fiók.", + "Not connected yet": "Még nincs csatlakoztatva", + "Reconnect": "Újracsatlakoztatás", + "Relink needed": "Újbóli összekapcsolás szükséges", + "Server address": "Kiszolgáló címe", + "Load example data?": "Betöltsük a mintaadatokat?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "A mintaadatok feltöltik a listákat, a részletoldalakat és az irányítópultokat, így azonnal működés közben látja az alkalmazást. Éles telepítésen válassza a \"Nincs\" lehetőséget.", + "Load the example data": "Mintaadatok betöltése", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Betölti, amit választott. Ezek nyilvánvalóan mintaadatok, a művelet biztonságosan megismételhető, és utána törölheti őket.", + "None, I will set this up myself": "Nincs, magam állítom be", + "Nothing is imported. You start with an empty app and add your own data.": "Semmi nem kerül importálásra. Üres alkalmazással kezd, és a saját adatait adja hozzá.", + "Example data": "Mintaadatok", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Mintaértékek minden sémához, amelyet ez az alkalmazás hoz magával, magukból a sémákból generálva. Működés közben mutatják a listákat, a részletoldalakat és az irányítópultokat, ahelyett hogy történetet mesélnének. Biztonságosan ismételhető és utána törölhető.", + "from amount {amount}": "{amount} összegtől", + "Gated transition: {transition}": "Őrzött átmenet: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Minden deklarált lánc rendezett feladatsorozatot nyit, amikor az átmenetet megkísérlik. A jóváhagyók a feladat-bejövőjükben döntenek, nem itt.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarálja az x-openregister-approval-chains kulcsot ezen a sémán, hogy egy életciklus-átmenet jóváhagyáshoz legyen kötve.", + "Approval sequences": "Jóváhagyási sorozatok", "%n entries have no hash yet": "%n bejegyzésnek még nincs kivonata", "%n entry has no hash yet": "%n bejegyzésnek még nincs kivonata", "(Dolphin required)": "(Dolphin szükséges)", + "No approval chains declared": "Nincs deklarált jóváhagyási lánc", "(no subject)": "(nincs tárgy)", "(no title)": "(nincs cím)", + "Role {role}": "Szerep {role}", + "The transition is applied automatically once every step approves.": "Az átmenet automatikusan végbemegy, amint minden lépést jóváhagytak.", + "The requester may not decide their own approval.": "A kérelmező nem dönthet a saját jóváhagyásáról.", + "Tier routing on field: {field}": "Sávos útválasztás a mező alapján: {field}", "(unnamed)": "(névtelen)", "(untitled card)": "(cím nélküli kártya)", "({count} selected)": "({count} kiválasztva)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Nincs ilyen feladat", + "The task does not exist, or it is not yours to see.": "A feladat nem létezik, vagy nem láthatja.", + "Details": "Részletek", + "Due": "Határidő", + "Assignee": "Felelős", + "Requested by": "Kérte", + "Outcome": "Eredmény", + "Ready to claim": "Átvehető", + "In progress": "Folyamatban", + "Terminated": "Megszakítva", + "Normal": "Normál", + "Urgent": "Sürgős", + "Overdue by 1 day": "1 napja lejárt", + "Overdue by {days} days": "{days} napja lejárt", + "Due today": "Ma esedékes", + "Due tomorrow": "Holnap esedékes", + "Due in {days} days": "{days} nap múlva esedékes", + "Tasks": "Feladatok", + "Task": "Feladat", + "Unassigned, open for claiming": "Nincs felelős, átvehető", + "Claim": "Átvétel", + "Complete": "Befejezés", + "Complete: {outcome}": "Befejezés: {outcome}", + "Could not load the task": "A feladat nem tölthető be", + "The task refused that action": "A feladat elutasította a műveletet", + "Store": "Áruház", + "Install registers, schemas and flows that other organisations have published.": "Telepítsen más szervezetek által közzétett nyilvántartásokat, sémákat és folyamatokat.", + "No such run": "Nincs ilyen futás", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "A futás nem létezik, vagy nem láthatja. Egy folyamat törlése a futásait is törli." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/is.js b/l10n/is.js index 6a0deec75d..5849f6e041 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Tengdu samfélags- eða greiningarreikning einu sinni. OpenRegister geymir teiknið og endurnýjar það, svo forrit haldi því aldrei sjálf.", + "Connect account": "Tengja reikning", + "Connected accounts": "Tengdir reikningar", + "Could not disconnect this account.": "Ekki tókst að aftengja þennan reikning.", + "Could not load your connected accounts.": "Ekki tókst að hlaða tengdum reikningum þínum.", + "Could not start the connection. Check the provider and try again.": "Ekki tókst að hefja tenginguna. Athugaðu þjónustuaðilann og reyndu aftur.", + "Disconnect": "Aftengja", + "Expired": "Útrunnið", + "Expires {date}": "Rennur út {date}", + "No accounts are connected yet.": "Engir reikningar eru tengdir enn.", + "Not connected yet": "Ekki tengt enn", + "Reconnect": "Tengja aftur", + "Relink needed": "Endurtenging nauðsynleg", + "Server address": "Vistfang þjóns", + "Load example data?": "Hlaða inn sýnigögnum?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Sýnigögn fylla listana, upplýsingasíðurnar og mælaborðin, svo þú sérð forritið virka strax. Veldu \"Engin\" á framleiðsluuppsetningu.", + "Load the example data": "Hlaða inn sýnigögnunum", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Hleður því sem þú valdir. Þetta eru augljóslega sýnigögn, óhætt er að keyra oftar en einu sinni og þú getur eytt þeim á eftir.", + "None, I will set this up myself": "Engin, ég set þetta upp sjálf", + "Nothing is imported. You start with an empty app and add your own data.": "Ekkert er flutt inn. Þú byrjar með tómt forrit og bætir við þínum eigin gögnum.", + "Example data": "Sýnigögn", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Sýnigildi fyrir hvert skema sem þetta forrit færir með sér, búin til úr skemunum sjálfum. Þau sýna listana, upplýsingasíðurnar og mælaborðin í notkun frekar en að segja sögu. Óhætt að endurtaka og eyða á eftir.", + "from amount {amount}": "frá upphæð {amount}", + "Gated transition: {transition}": "Vöktuð umskipti: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Hver skilgreind keðja opnar raðaða verkefnaröð þegar reynt er við umskiptin. Samþykkjendur ákveða í verkefnahólfinu sínu, ekki hér.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Skilgreindu x-openregister-approval-chains á þessu skema svo að umskipti í lífsferli krefjist samþykkis.", + "Approval sequences": "Samþykktarraðir", "%n entries have no hash yet": "%n færslur hafa ekki enn tætigildi", "%n entry has no hash yet": "%n færsla hefur ekki enn tætigildi", "(Dolphin required)": "(Dolphin krafist)", + "No approval chains declared": "Engar samþykktarkeðjur skilgreindar", "(no subject)": "(ekkert efni)", "(no title)": "(ekkert titill)", + "Role {role}": "Hlutverk {role}", + "The transition is applied automatically once every step approves.": "Umskiptin taka gildi sjálfkrafa þegar hvert skref hefur verið samþykkt.", + "The requester may not decide their own approval.": "Beiðandi má ekki ákveða eigin samþykki.", + "Tier routing on field: {field}": "Þrepabundin beining eftir reitnum: {field}", "(unnamed)": "(ónefnt)", "(untitled card)": "(kort án titils)", "({count} selected)": "({count} valin)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Engin slík verkefni", + "The task does not exist, or it is not yours to see.": "Verkefnið er ekki til, eða þú mátt ekki sjá það.", + "Details": "Nánar", + "Due": "Frestur", + "Assignee": "Úthlutað á", + "Requested by": "Óskað af", + "Outcome": "Niðurstaða", + "Ready to claim": "Tilbúið til að taka", + "In progress": "Í vinnslu", + "Terminated": "Hætt", + "Normal": "Venjulegt", + "Urgent": "Áríðandi", + "Overdue by 1 day": "1 degi yfir frest", + "Overdue by {days} days": "{days} dögum yfir frest", + "Due today": "Frestur í dag", + "Due tomorrow": "Frestur á morgun", + "Due in {days} days": "Frestur eftir {days} daga", + "Tasks": "Verkefni", + "Task": "Verkefni", + "Unassigned, open for claiming": "Óúthlutað, opið til að taka", + "Claim": "Taka", + "Complete": "Ljúka", + "Complete: {outcome}": "Ljúka: {outcome}", + "Could not load the task": "Gat ekki hlaðið verkefninu", + "The task refused that action": "Verkefnið hafnaði þeirri aðgerð", + "Store": "Verslun", + "Install registers, schemas and flows that other organisations have published.": "Settu upp skrár, skemu og flæði sem aðrar stofnanir hafa birt.", + "No such run": "Engin slík keyrsla", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Keyrslan er ekki til, eða þú mátt ekki sjá hana. Ef flæði er eytt eyðast keyrslur þess líka." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 47676bf7ad..a752e717d4 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Tengdu samfélags- eða greiningarreikning einu sinni. OpenRegister geymir teiknið og endurnýjar það, svo forrit haldi því aldrei sjálf.", + "Connect account": "Tengja reikning", + "Connected accounts": "Tengdir reikningar", + "Could not disconnect this account.": "Ekki tókst að aftengja þennan reikning.", + "Could not load your connected accounts.": "Ekki tókst að hlaða tengdum reikningum þínum.", + "Could not start the connection. Check the provider and try again.": "Ekki tókst að hefja tenginguna. Athugaðu þjónustuaðilann og reyndu aftur.", + "Disconnect": "Aftengja", + "Expired": "Útrunnið", + "Expires {date}": "Rennur út {date}", + "No accounts are connected yet.": "Engir reikningar eru tengdir enn.", + "Not connected yet": "Ekki tengt enn", + "Reconnect": "Tengja aftur", + "Relink needed": "Endurtenging nauðsynleg", + "Server address": "Vistfang þjóns", + "Load example data?": "Hlaða inn sýnigögnum?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Sýnigögn fylla listana, upplýsingasíðurnar og mælaborðin, svo þú sérð forritið virka strax. Veldu \"Engin\" á framleiðsluuppsetningu.", + "Load the example data": "Hlaða inn sýnigögnunum", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Hleður því sem þú valdir. Þetta eru augljóslega sýnigögn, óhætt er að keyra oftar en einu sinni og þú getur eytt þeim á eftir.", + "None, I will set this up myself": "Engin, ég set þetta upp sjálf", + "Nothing is imported. You start with an empty app and add your own data.": "Ekkert er flutt inn. Þú byrjar með tómt forrit og bætir við þínum eigin gögnum.", + "Example data": "Sýnigögn", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Sýnigildi fyrir hvert skema sem þetta forrit færir með sér, búin til úr skemunum sjálfum. Þau sýna listana, upplýsingasíðurnar og mælaborðin í notkun frekar en að segja sögu. Óhætt að endurtaka og eyða á eftir.", + "from amount {amount}": "frá upphæð {amount}", + "Gated transition: {transition}": "Vöktuð umskipti: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Hver skilgreind keðja opnar raðaða verkefnaröð þegar reynt er við umskiptin. Samþykkjendur ákveða í verkefnahólfinu sínu, ekki hér.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Skilgreindu x-openregister-approval-chains á þessu skema svo að umskipti í lífsferli krefjist samþykkis.", + "Approval sequences": "Samþykktarraðir", "%n entries have no hash yet": "%n færslur hafa ekki enn tætigildi", "%n entry has no hash yet": "%n færsla hefur ekki enn tætigildi", "(Dolphin required)": "(Dolphin krafist)", + "No approval chains declared": "Engar samþykktarkeðjur skilgreindar", "(no subject)": "(ekkert efni)", "(no title)": "(ekkert titill)", + "Role {role}": "Hlutverk {role}", + "The transition is applied automatically once every step approves.": "Umskiptin taka gildi sjálfkrafa þegar hvert skref hefur verið samþykkt.", + "The requester may not decide their own approval.": "Beiðandi má ekki ákveða eigin samþykki.", + "Tier routing on field: {field}": "Þrepabundin beining eftir reitnum: {field}", "(unnamed)": "(ónefnt)", "(untitled card)": "(kort án titils)", "({count} selected)": "({count} valin)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Engin slík verkefni", + "The task does not exist, or it is not yours to see.": "Verkefnið er ekki til, eða þú mátt ekki sjá það.", + "Details": "Nánar", + "Due": "Frestur", + "Assignee": "Úthlutað á", + "Requested by": "Óskað af", + "Outcome": "Niðurstaða", + "Ready to claim": "Tilbúið til að taka", + "In progress": "Í vinnslu", + "Terminated": "Hætt", + "Normal": "Venjulegt", + "Urgent": "Áríðandi", + "Overdue by 1 day": "1 degi yfir frest", + "Overdue by {days} days": "{days} dögum yfir frest", + "Due today": "Frestur í dag", + "Due tomorrow": "Frestur á morgun", + "Due in {days} days": "Frestur eftir {days} daga", + "Tasks": "Verkefni", + "Task": "Verkefni", + "Unassigned, open for claiming": "Óúthlutað, opið til að taka", + "Claim": "Taka", + "Complete": "Ljúka", + "Complete: {outcome}": "Ljúka: {outcome}", + "Could not load the task": "Gat ekki hlaðið verkefninu", + "The task refused that action": "Verkefnið hafnaði þeirri aðgerð", + "Store": "Verslun", + "Install registers, schemas and flows that other organisations have published.": "Settu upp skrár, skemu og flæði sem aðrar stofnanir hafa birt.", + "No such run": "Engin slík keyrsla", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Keyrslan er ekki til, eða þú mátt ekki sjá hana. Ef flæði er eytt eyðast keyrslur þess líka." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/it.js b/l10n/it.js index 52da4e1e18..3df6800784 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Collega un account social o di analisi una sola volta. OpenRegister conserva il token e lo rinnova, così le app non lo detengono mai.", + "Connect account": "Collega account", + "Connected accounts": "Account collegati", + "Could not disconnect this account.": "Impossibile scollegare questo account.", + "Could not load your connected accounts.": "Impossibile caricare i tuoi account collegati.", + "Could not start the connection. Check the provider and try again.": "Impossibile avviare la connessione. Controlla il provider e riprova.", + "Disconnect": "Scollega", + "Expired": "Scaduto", + "Expires {date}": "Scade il {date}", + "No accounts are connected yet.": "Nessun account è ancora collegato.", + "Not connected yet": "Non ancora collegato", + "Reconnect": "Ricollega", + "Relink needed": "Serve un nuovo collegamento", + "Server address": "Indirizzo del server", + "Load example data?": "Caricare dati di esempio?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "I dati di esempio riempiono elenchi, pagine di dettaglio e dashboard, così vedi subito l’app in funzione. Scegli \"Nessuno\" su un’installazione di produzione.", + "Load the example data": "Carica i dati di esempio", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Carica quello che hai scelto. Sono chiaramente dati di esempio, l’operazione si può ripetere senza rischi e puoi eliminarli in seguito.", + "None, I will set this up myself": "Nessuno, lo configuro da solo", + "Nothing is imported. You start with an empty app and add your own data.": "Non viene importato nulla. Parti da un’app vuota e aggiungi i tuoi dati.", + "Example data": "Dati di esempio", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valori di esempio per ogni schema fornito da questa app, generati dagli schemi stessi. Mostrano elenchi, pagine di dettaglio e dashboard in funzione invece di raccontare una storia. Ripetibile senza rischi ed eliminabile in seguito.", + "from amount {amount}": "a partire dall'importo {amount}", + "Gated transition: {transition}": "Transizione controllata: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Ogni catena dichiarata apre una sequenza ordinata di attività quando la transizione viene tentata. Gli approvatori decidono nella loro casella attività, non qui.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Dichiara x-openregister-approval-chains su questo schema per vincolare una transizione del ciclo di vita a un'approvazione.", + "Approval sequences": "Sequenze di approvazione", "%n entries have no hash yet": "%n voci non hanno ancora un hash", "%n entry has no hash yet": "%n voce non ha ancora un hash", "(Dolphin required)": "(Dolphin richiesto)", + "No approval chains declared": "Nessuna catena di approvazione dichiarata", "(no subject)": "(nessun oggetto)", "(no title)": "(nessun titolo)", + "Role {role}": "Ruolo {role}", + "The transition is applied automatically once every step approves.": "La transizione viene applicata automaticamente quando ogni passaggio è approvato.", + "The requester may not decide their own approval.": "Il richiedente non può decidere sulla propria approvazione.", + "Tier routing on field: {field}": "Instradamento a scaglioni sul campo: {field}", "(unnamed)": "(senza nome)", "(untitled card)": "(scheda senza titolo)", "({count} selected)": "({count} selezionati)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Nessuna attività trovata", + "The task does not exist, or it is not yours to see.": "L'attività non esiste o non puoi vederla.", + "Details": "Dettagli", + "Due": "Scadenza", + "Assignee": "Assegnatario", + "Requested by": "Richiesto da", + "Outcome": "Esito", + "Ready to claim": "Pronto da prendere in carico", + "In progress": "In corso", + "Terminated": "Terminata", + "Normal": "Normale", + "Urgent": "Urgente", + "Overdue by 1 day": "In ritardo di 1 giorno", + "Overdue by {days} days": "In ritardo di {days} giorni", + "Due today": "Scade oggi", + "Due tomorrow": "Scade domani", + "Due in {days} days": "Scade tra {days} giorni", + "Tasks": "Attività", + "Task": "Attività", + "Unassigned, open for claiming": "Non assegnata, aperta alla presa in carico", + "Claim": "Prendi in carico", + "Complete": "Completa", + "Complete: {outcome}": "Completa: {outcome}", + "Could not load the task": "Impossibile caricare l'attività", + "The task refused that action": "L'attività ha rifiutato l'azione", + "Store": "Store", + "Install registers, schemas and flows that other organisations have published.": "Installa registri, schemi e flussi pubblicati da altre organizzazioni.", + "No such run": "Nessuna esecuzione trovata", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L'esecuzione non esiste o non puoi vederla. Eliminando un flusso si eliminano le sue esecuzioni." }, "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..30e0db2d7a 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Collega un account social o di analisi una sola volta. OpenRegister conserva il token e lo rinnova, così le app non lo detengono mai.", + "Connect account": "Collega account", + "Connected accounts": "Account collegati", + "Could not disconnect this account.": "Impossibile scollegare questo account.", + "Could not load your connected accounts.": "Impossibile caricare i tuoi account collegati.", + "Could not start the connection. Check the provider and try again.": "Impossibile avviare la connessione. Controlla il provider e riprova.", + "Disconnect": "Scollega", + "Expired": "Scaduto", + "Expires {date}": "Scade il {date}", + "No accounts are connected yet.": "Nessun account è ancora collegato.", + "Not connected yet": "Non ancora collegato", + "Reconnect": "Ricollega", + "Relink needed": "Serve un nuovo collegamento", + "Server address": "Indirizzo del server", + "Load example data?": "Caricare dati di esempio?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "I dati di esempio riempiono elenchi, pagine di dettaglio e dashboard, così vedi subito l’app in funzione. Scegli \"Nessuno\" su un’installazione di produzione.", + "Load the example data": "Carica i dati di esempio", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Carica quello che hai scelto. Sono chiaramente dati di esempio, l’operazione si può ripetere senza rischi e puoi eliminarli in seguito.", + "None, I will set this up myself": "Nessuno, lo configuro da solo", + "Nothing is imported. You start with an empty app and add your own data.": "Non viene importato nulla. Parti da un’app vuota e aggiungi i tuoi dati.", + "Example data": "Dati di esempio", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valori di esempio per ogni schema fornito da questa app, generati dagli schemi stessi. Mostrano elenchi, pagine di dettaglio e dashboard in funzione invece di raccontare una storia. Ripetibile senza rischi ed eliminabile in seguito.", + "from amount {amount}": "a partire dall'importo {amount}", + "Gated transition: {transition}": "Transizione controllata: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Ogni catena dichiarata apre una sequenza ordinata di attività quando la transizione viene tentata. Gli approvatori decidono nella loro casella attività, non qui.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Dichiara x-openregister-approval-chains su questo schema per vincolare una transizione del ciclo di vita a un'approvazione.", + "Approval sequences": "Sequenze di approvazione", "%n entries have no hash yet": "%n voci non hanno ancora un hash", "%n entry has no hash yet": "%n voce non ha ancora un hash", "(Dolphin required)": "(Dolphin richiesto)", + "No approval chains declared": "Nessuna catena di approvazione dichiarata", "(no subject)": "(nessun oggetto)", "(no title)": "(nessun titolo)", + "Role {role}": "Ruolo {role}", + "The transition is applied automatically once every step approves.": "La transizione viene applicata automaticamente quando ogni passaggio è approvato.", + "The requester may not decide their own approval.": "Il richiedente non può decidere sulla propria approvazione.", + "Tier routing on field: {field}": "Instradamento a scaglioni sul campo: {field}", "(unnamed)": "(senza nome)", "(untitled card)": "(scheda senza titolo)", "({count} selected)": "({count} selezionati)", @@ -2801,7 +2833,88 @@ "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.", + "No such task": "Nessuna attività trovata", + "The task does not exist, or it is not yours to see.": "L'attività non esiste o non puoi vederla.", + "Details": "Dettagli", + "Due": "Scadenza", + "Assignee": "Assegnatario", + "Requested by": "Richiesto da", + "Outcome": "Esito", + "Ready to claim": "Pronto da prendere in carico", + "In progress": "In corso", + "Terminated": "Terminata", + "Normal": "Normale", + "Urgent": "Urgente", + "Overdue by 1 day": "In ritardo di 1 giorno", + "Overdue by {days} days": "In ritardo di {days} giorni", + "Due today": "Scade oggi", + "Due tomorrow": "Scade domani", + "Due in {days} days": "Scade tra {days} giorni", + "Tasks": "Attività", + "Task": "Attività", + "Unassigned, open for claiming": "Non assegnata, aperta alla presa in carico", + "Claim": "Prendi in carico", + "Complete": "Completa", + "Complete: {outcome}": "Completa: {outcome}", + "Could not load the task": "Impossibile caricare l'attività", + "The task refused that action": "L'attività ha rifiutato l'azione", + "Store": "Store", + "Install registers, schemas and flows that other organisations have published.": "Installa registri, schemi e flussi pubblicati da altre organizzazioni.", + "No such run": "Nessuna esecuzione trovata", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L'esecuzione non esiste o non puoi vederla. Eliminando un flusso si eliminano le sue esecuzioni." }, "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..7cebd33aab 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Verbann eemol e soziale Kont oder en Analytics-Kont. OpenRegister behält den Token a verlängert en, sou datt d’Apps en ni selwer halen.", + "Connect account": "Kont verbannen", + "Connected accounts": "Verbonne Konten", + "Could not disconnect this account.": "Dëse Kont konnt net getrennt ginn.", + "Could not load your connected accounts.": "Deng verbonne Konten konnten net gelueden ginn.", + "Could not start the connection. Check the provider and try again.": "D’Verbindung konnt net gestart ginn. Kuck de Fournisseur no a probéier nach eng Kéier.", + "Disconnect": "Trennen", + "Expired": "Ofgelaf", + "Expires {date}": "Leeft de(n) {date} of", + "No accounts are connected yet.": "Et ass nach kee Kont verbonnen.", + "Not connected yet": "Nach net verbonnen", + "Reconnect": "Nei verbannen", + "Relink needed": "Nei Verbindung néideg", + "Server address": "Serveradress", + "Load example data?": "Beispilldate lueden?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Beispilldate fëllen d’Lëschten, d’Detailsäiten an d’Dashboards, sou datt Dir d’App direkt am Betrib gesitt. Wielt \"Keng\" bei enger Produktiounsinstallatioun.", + "Load the example data": "D’Beispilldate lueden", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Lued dat, wat Dir gewielt hutt. Et sinn offensichtlech Beispilldaten, den Uwëssen kann ouni Risiko widderholl ginn, an Dir kënnt se duerno läschen.", + "None, I will set this up myself": "Keng, ech riichten dat selwer an", + "Nothing is imported. You start with an empty app and add your own data.": "Et gëtt näischt importéiert. Dir fänkt mat enger eidler App un a füügt Är eege Date bäi.", + "Example data": "Beispilldaten", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Beispillwäerter fir all Schema, deen dës App matbréngt, aus de Schemae selwer generéiert. Si weisen d’Lëschten, d’Detailsäiten an d’Dashboards am Betrib, amplaz eng Geschicht ze erzielen. Ouni Risiko ze widderhuelen an duerno ze läschen.", + "from amount {amount}": "ab Betrag {amount}", + "Gated transition: {transition}": "Iwwerwaachten Iwwergang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "All deklaréiert Kette mécht eng geuerdnet Aufgabesequenz op, wann den Iwwergang probéiert gëtt. Genehmeger entscheeden an hirer Aufgabemailbox, net hei.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklaréiert x-openregister-approval-chains op dësem Schema, fir datt en Iwwergang am Liewenszyklus eng Genehmegung brauch.", + "Approval sequences": "Genehmegungssequenzen", "%n entries have no hash yet": "%n Anträg hunn nach kee Hash", "%n entry has no hash yet": "%n Antrag huet nach kee Hash", "(Dolphin required)": "(Dolphin vereist)", + "No approval chains declared": "Keng Genehmegungsketten deklaréiert", "(no subject)": "(kee Sujet)", "(no title)": "(keen Titel)", + "Role {role}": "Roll {role}", + "The transition is applied automatically once every step approves.": "Den Iwwergang gëtt automatesch ugewannt, soubal all Schrëtt genehmegt ass.", + "The requester may not decide their own approval.": "Wien ufreet, dierf net iwwer seng eege Genehmegung entscheeden.", + "Tier routing on field: {field}": "Stufeweis Routing iwwert d'Feld: {field}", "(unnamed)": "(ouni Numm)", "(untitled card)": "(Kaart ouni Titel)", "({count} selected)": "({count} geselecteerd)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Keng esou Aufgab", + "The task does not exist, or it is not yours to see.": "D'Aufgab existéiert net, oder Dir däerft se net gesinn.", + "Details": "Detailer", + "Due": "Frist", + "Assignee": "Zougewisen un", + "Requested by": "Ugefrot vun", + "Outcome": "Resultat", + "Ready to claim": "Prett fir z'iwwerhuelen", + "In progress": "Amgaang", + "Terminated": "Ofgeschloss", + "Normal": "Normal", + "Urgent": "Dréngend", + "Overdue by 1 day": "1 Dag iwwerfälleg", + "Overdue by {days} days": "{days} Deeg iwwerfälleg", + "Due today": "Haut fälleg", + "Due tomorrow": "Muer fälleg", + "Due in {days} days": "Fälleg an {days} Deeg", + "Tasks": "Aufgaben", + "Task": "Aufgab", + "Unassigned, open for claiming": "Net zougewisen, op fir z'iwwerhuelen", + "Claim": "Iwwerhuelen", + "Complete": "Ofschléissen", + "Complete: {outcome}": "Ofschléissen: {outcome}", + "Could not load the task": "D'Aufgab konnt net geluede ginn", + "The task refused that action": "D'Aufgab huet dës Aktioun refuséiert", + "Store": "Buttek", + "Install registers, schemas and flows that other organisations have published.": "Installéiert Registeren, Schemaen a Flows déi aner Organisatiounen publizéiert hunn.", + "No such run": "Keng esou Ausféierung", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "D'Ausféierung existéiert net, oder Dir däerft se net gesinn. Wann e Flux geläscht gëtt, ginn och seng Ausféierunge geläscht." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 6be7a459f9..2926e77811 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Verbann eemol e soziale Kont oder en Analytics-Kont. OpenRegister behält den Token a verlängert en, sou datt d’Apps en ni selwer halen.", + "Connect account": "Kont verbannen", + "Connected accounts": "Verbonne Konten", + "Could not disconnect this account.": "Dëse Kont konnt net getrennt ginn.", + "Could not load your connected accounts.": "Deng verbonne Konten konnten net gelueden ginn.", + "Could not start the connection. Check the provider and try again.": "D’Verbindung konnt net gestart ginn. Kuck de Fournisseur no a probéier nach eng Kéier.", + "Disconnect": "Trennen", + "Expired": "Ofgelaf", + "Expires {date}": "Leeft de(n) {date} of", + "No accounts are connected yet.": "Et ass nach kee Kont verbonnen.", + "Not connected yet": "Nach net verbonnen", + "Reconnect": "Nei verbannen", + "Relink needed": "Nei Verbindung néideg", + "Server address": "Serveradress", + "Load example data?": "Beispilldate lueden?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Beispilldate fëllen d’Lëschten, d’Detailsäiten an d’Dashboards, sou datt Dir d’App direkt am Betrib gesitt. Wielt \"Keng\" bei enger Produktiounsinstallatioun.", + "Load the example data": "D’Beispilldate lueden", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Lued dat, wat Dir gewielt hutt. Et sinn offensichtlech Beispilldaten, den Uwëssen kann ouni Risiko widderholl ginn, an Dir kënnt se duerno läschen.", + "None, I will set this up myself": "Keng, ech riichten dat selwer an", + "Nothing is imported. You start with an empty app and add your own data.": "Et gëtt näischt importéiert. Dir fänkt mat enger eidler App un a füügt Är eege Date bäi.", + "Example data": "Beispilldaten", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Beispillwäerter fir all Schema, deen dës App matbréngt, aus de Schemae selwer generéiert. Si weisen d’Lëschten, d’Detailsäiten an d’Dashboards am Betrib, amplaz eng Geschicht ze erzielen. Ouni Risiko ze widderhuelen an duerno ze läschen.", + "from amount {amount}": "ab Betrag {amount}", + "Gated transition: {transition}": "Iwwerwaachten Iwwergang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "All deklaréiert Kette mécht eng geuerdnet Aufgabesequenz op, wann den Iwwergang probéiert gëtt. Genehmeger entscheeden an hirer Aufgabemailbox, net hei.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklaréiert x-openregister-approval-chains op dësem Schema, fir datt en Iwwergang am Liewenszyklus eng Genehmegung brauch.", + "Approval sequences": "Genehmegungssequenzen", "%n entries have no hash yet": "%n Anträg hunn nach kee Hash", "%n entry has no hash yet": "%n Antrag huet nach kee Hash", "(Dolphin required)": "(Dolphin vereist)", + "No approval chains declared": "Keng Genehmegungsketten deklaréiert", "(no subject)": "(kee Sujet)", "(no title)": "(keen Titel)", + "Role {role}": "Roll {role}", + "The transition is applied automatically once every step approves.": "Den Iwwergang gëtt automatesch ugewannt, soubal all Schrëtt genehmegt ass.", + "The requester may not decide their own approval.": "Wien ufreet, dierf net iwwer seng eege Genehmegung entscheeden.", + "Tier routing on field: {field}": "Stufeweis Routing iwwert d'Feld: {field}", "(unnamed)": "(ouni Numm)", "(untitled card)": "(Kaart ouni Titel)", "({count} selected)": "({count} geselecteerd)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Keng esou Aufgab", + "The task does not exist, or it is not yours to see.": "D'Aufgab existéiert net, oder Dir däerft se net gesinn.", + "Details": "Detailer", + "Due": "Frist", + "Assignee": "Zougewisen un", + "Requested by": "Ugefrot vun", + "Outcome": "Resultat", + "Ready to claim": "Prett fir z'iwwerhuelen", + "In progress": "Amgaang", + "Terminated": "Ofgeschloss", + "Normal": "Normal", + "Urgent": "Dréngend", + "Overdue by 1 day": "1 Dag iwwerfälleg", + "Overdue by {days} days": "{days} Deeg iwwerfälleg", + "Due today": "Haut fälleg", + "Due tomorrow": "Muer fälleg", + "Due in {days} days": "Fälleg an {days} Deeg", + "Tasks": "Aufgaben", + "Task": "Aufgab", + "Unassigned, open for claiming": "Net zougewisen, op fir z'iwwerhuelen", + "Claim": "Iwwerhuelen", + "Complete": "Ofschléissen", + "Complete: {outcome}": "Ofschléissen: {outcome}", + "Could not load the task": "D'Aufgab konnt net geluede ginn", + "The task refused that action": "D'Aufgab huet dës Aktioun refuséiert", + "Store": "Buttek", + "Install registers, schemas and flows that other organisations have published.": "Installéiert Registeren, Schemaen a Flows déi aner Organisatiounen publizéiert hunn.", + "No such run": "Keng esou Ausféierung", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "D'Ausféierung existéiert net, oder Dir däerft se net gesinn. Wann e Flux geläscht gëtt, ginn och seng Ausféierunge geläscht." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/lt.js b/l10n/lt.js index c36d576439..47855afcc3 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Prijunkite socialinio tinklo ar analitikos paskyrą vieną kartą. OpenRegister saugo prieigos raktą ir jį atnaujina, todėl programos jo niekada nelaiko.", + "Connect account": "Prijungti paskyrą", + "Connected accounts": "Prijungtos paskyros", + "Could not disconnect this account.": "Nepavyko atjungti šios paskyros.", + "Could not load your connected accounts.": "Nepavyko įkelti jūsų prijungtų paskyrų.", + "Could not start the connection. Check the provider and try again.": "Nepavyko pradėti jungimosi. Patikrinkite tiekėją ir bandykite dar kartą.", + "Disconnect": "Atjungti", + "Expired": "Nebegalioja", + "Expires {date}": "Galioja iki {date}", + "No accounts are connected yet.": "Kol kas neprijungta nė viena paskyra.", + "Not connected yet": "Dar neprijungta", + "Reconnect": "Prijungti iš naujo", + "Relink needed": "Reikia susieti iš naujo", + "Server address": "Serverio adresas", + "Load example data?": "Ar įkelti pavyzdinius duomenis?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Pavyzdiniai duomenys užpildo sąrašus, detalių puslapius ir skydelius, todėl programėlę iškart pamatysite veikiančią. Gamybinėje diegtyje pasirinkite \"Nėra\".", + "Load the example data": "Įkelti pavyzdinius duomenis", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Įkelia tai, ką pasirinkote. Tai akivaizdžiai pavyzdiniai duomenys, veiksmą galima saugiai kartoti, o vėliau juos galima ištrinti.", + "None, I will set this up myself": "Nėra, susitvarkysiu pats", + "Nothing is imported. You start with an empty app and add your own data.": "Niekas neimportuojama. Pradedate nuo tuščios programėlės ir pridedate savo duomenis.", + "Example data": "Pavyzdiniai duomenys", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Pavyzdinės reikšmės kiekvienai schemai, kurią pateikia ši programėlė, sugeneruotos iš pačių schemų. Jos rodo sąrašus, detalių puslapius ir skydelius veikiant, o ne pasakoja istoriją. Saugu kartoti ir vėliau ištrinti.", + "from amount {amount}": "nuo sumos {amount}", + "Gated transition: {transition}": "Prižiūrimas perėjimas: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Kiekviena deklaruota grandinė atveria surikiuotą užduočių seką, kai bandomas perėjimas. Tvirtintojai sprendžia savo užduočių aplanke, ne čia.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklaruokite x-openregister-approval-chains šioje schemoje, kad gyvavimo ciklo perėjimui reikėtų patvirtinimo.", + "Approval sequences": "Tvirtinimo sekos", "%n entries have no hash yet": "%n įrašų dar neturi maišos", "%n entry has no hash yet": "%n įrašas dar neturi maišos", "(Dolphin required)": "(reikalingas Dolphin)", + "No approval chains declared": "Nedeklaruota jokių tvirtinimo grandinių", "(no subject)": "(be temos)", "(no title)": "(be pavadinimo)", + "Role {role}": "Vaidmuo {role}", + "The transition is applied automatically once every step approves.": "Perėjimas pritaikomas automatiškai, kai patvirtinamas kiekvienas žingsnis.", + "The requester may not decide their own approval.": "Prašytojas negali spręsti dėl savo paties patvirtinimo.", + "Tier routing on field: {field}": "Pakopinis nukreipimas pagal lauką: {field}", "(unnamed)": "(be vardo)", "(untitled card)": "(kortelė be pavadinimo)", "({count} selected)": "(pasirinkta: {count})", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Tokios užduoties nėra", + "The task does not exist, or it is not yours to see.": "Užduotis neegzistuoja arba neturite teisės jos matyti.", + "Details": "Išsami informacija", + "Due": "Terminas", + "Assignee": "Vykdytojas", + "Requested by": "Užklausė", + "Outcome": "Rezultatas", + "Ready to claim": "Paruošta perimti", + "In progress": "Vykdoma", + "Terminated": "Nutraukta", + "Normal": "Įprastas", + "Urgent": "Skubu", + "Overdue by 1 day": "Vėluoja 1 dieną", + "Overdue by {days} days": "Vėluoja {days} dienas", + "Due today": "Terminas šiandien", + "Due tomorrow": "Terminas rytoj", + "Due in {days} days": "Terminas po {days} dienų", + "Tasks": "Užduotys", + "Task": "Užduotis", + "Unassigned, open for claiming": "Nepriskirta, galima perimti", + "Claim": "Perimti", + "Complete": "Užbaigti", + "Complete: {outcome}": "Užbaigti: {outcome}", + "Could not load the task": "Nepavyko įkelti užduoties", + "The task refused that action": "Užduotis atmetė šį veiksmą", + "Store": "Parduotuvė", + "Install registers, schemas and flows that other organisations have published.": "Įdiekite registrus, schemas ir srautus, kuriuos paskelbė kitos organizacijos.", + "No such run": "Tokio vykdymo nėra", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Vykdymas neegzistuoja arba neturite teisės jo matyti. Ištrynus srautą ištrinami ir jo vykdymai." }, "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..d0242bc8f4 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Prijunkite socialinio tinklo ar analitikos paskyrą vieną kartą. OpenRegister saugo prieigos raktą ir jį atnaujina, todėl programos jo niekada nelaiko.", + "Connect account": "Prijungti paskyrą", + "Connected accounts": "Prijungtos paskyros", + "Could not disconnect this account.": "Nepavyko atjungti šios paskyros.", + "Could not load your connected accounts.": "Nepavyko įkelti jūsų prijungtų paskyrų.", + "Could not start the connection. Check the provider and try again.": "Nepavyko pradėti jungimosi. Patikrinkite tiekėją ir bandykite dar kartą.", + "Disconnect": "Atjungti", + "Expired": "Nebegalioja", + "Expires {date}": "Galioja iki {date}", + "No accounts are connected yet.": "Kol kas neprijungta nė viena paskyra.", + "Not connected yet": "Dar neprijungta", + "Reconnect": "Prijungti iš naujo", + "Relink needed": "Reikia susieti iš naujo", + "Server address": "Serverio adresas", + "Load example data?": "Ar įkelti pavyzdinius duomenis?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Pavyzdiniai duomenys užpildo sąrašus, detalių puslapius ir skydelius, todėl programėlę iškart pamatysite veikiančią. Gamybinėje diegtyje pasirinkite \"Nėra\".", + "Load the example data": "Įkelti pavyzdinius duomenis", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Įkelia tai, ką pasirinkote. Tai akivaizdžiai pavyzdiniai duomenys, veiksmą galima saugiai kartoti, o vėliau juos galima ištrinti.", + "None, I will set this up myself": "Nėra, susitvarkysiu pats", + "Nothing is imported. You start with an empty app and add your own data.": "Niekas neimportuojama. Pradedate nuo tuščios programėlės ir pridedate savo duomenis.", + "Example data": "Pavyzdiniai duomenys", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Pavyzdinės reikšmės kiekvienai schemai, kurią pateikia ši programėlė, sugeneruotos iš pačių schemų. Jos rodo sąrašus, detalių puslapius ir skydelius veikiant, o ne pasakoja istoriją. Saugu kartoti ir vėliau ištrinti.", + "from amount {amount}": "nuo sumos {amount}", + "Gated transition: {transition}": "Prižiūrimas perėjimas: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Kiekviena deklaruota grandinė atveria surikiuotą užduočių seką, kai bandomas perėjimas. Tvirtintojai sprendžia savo užduočių aplanke, ne čia.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklaruokite x-openregister-approval-chains šioje schemoje, kad gyvavimo ciklo perėjimui reikėtų patvirtinimo.", + "Approval sequences": "Tvirtinimo sekos", "%n entries have no hash yet": "%n įrašų dar neturi maišos", "%n entry has no hash yet": "%n įrašas dar neturi maišos", "(Dolphin required)": "(reikalingas Dolphin)", + "No approval chains declared": "Nedeklaruota jokių tvirtinimo grandinių", "(no subject)": "(be temos)", "(no title)": "(be pavadinimo)", + "Role {role}": "Vaidmuo {role}", + "The transition is applied automatically once every step approves.": "Perėjimas pritaikomas automatiškai, kai patvirtinamas kiekvienas žingsnis.", + "The requester may not decide their own approval.": "Prašytojas negali spręsti dėl savo paties patvirtinimo.", + "Tier routing on field: {field}": "Pakopinis nukreipimas pagal lauką: {field}", "(unnamed)": "(be vardo)", "(untitled card)": "(kortelė be pavadinimo)", "({count} selected)": "(pasirinkta: {count})", @@ -2801,7 +2833,88 @@ "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.", + "No such task": "Tokios užduoties nėra", + "The task does not exist, or it is not yours to see.": "Užduotis neegzistuoja arba neturite teisės jos matyti.", + "Details": "Išsami informacija", + "Due": "Terminas", + "Assignee": "Vykdytojas", + "Requested by": "Užklausė", + "Outcome": "Rezultatas", + "Ready to claim": "Paruošta perimti", + "In progress": "Vykdoma", + "Terminated": "Nutraukta", + "Normal": "Įprastas", + "Urgent": "Skubu", + "Overdue by 1 day": "Vėluoja 1 dieną", + "Overdue by {days} days": "Vėluoja {days} dienas", + "Due today": "Terminas šiandien", + "Due tomorrow": "Terminas rytoj", + "Due in {days} days": "Terminas po {days} dienų", + "Tasks": "Užduotys", + "Task": "Užduotis", + "Unassigned, open for claiming": "Nepriskirta, galima perimti", + "Claim": "Perimti", + "Complete": "Užbaigti", + "Complete: {outcome}": "Užbaigti: {outcome}", + "Could not load the task": "Nepavyko įkelti užduoties", + "The task refused that action": "Užduotis atmetė šį veiksmą", + "Store": "Parduotuvė", + "Install registers, schemas and flows that other organisations have published.": "Įdiekite registrus, schemas ir srautus, kuriuos paskelbė kitos organizacijos.", + "No such run": "Tokio vykdymo nėra", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Vykdymas neegzistuoja arba neturite teisės jo matyti. Ištrynus srautą ištrinami ir jo vykdymai." }, "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..886527a08c 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Pievieno sociālo vai analītikas kontu vienu reizi. OpenRegister glabā pilnvaru un to atjauno, tāpēc lietotnes to nekad netur.", + "Connect account": "Pievienot kontu", + "Connected accounts": "Pievienotie konti", + "Could not disconnect this account.": "Neizdevās atvienot šo kontu.", + "Could not load your connected accounts.": "Neizdevās ielādēt tavus pievienotos kontus.", + "Could not start the connection. Check the provider and try again.": "Neizdevās sākt savienojumu. Pārbaudi pakalpojuma sniedzēju un mēģini vēlreiz.", + "Disconnect": "Atvienot", + "Expired": "Beidzies", + "Expires {date}": "Beidzas {date}", + "No accounts are connected yet.": "Vēl nav pievienots neviens konts.", + "Not connected yet": "Vēl nav pievienots", + "Reconnect": "Pievienot atkārtoti", + "Relink needed": "Nepieciešama atkārtota sasaiste", + "Server address": "Servera adrese", + "Load example data?": "Vai ielādēt paraugdatus?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Paraugdati aizpilda sarakstus, detalizētās lapas un informācijas paneļus, tāpēc lietotni uzreiz redzēsiet darbībā. Ražošanas instalācijā izvēlieties \"Nav\".", + "Load the example data": "Ielādēt paraugdatus", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Ielādē to, ko izvēlējāties. Tie ir acīmredzami paraugdati, darbību var droši atkārtot, un pēc tam tos var dzēst.", + "None, I will set this up myself": "Nav, iestatīšu pats", + "Nothing is imported. You start with an empty app and add your own data.": "Nekas netiek importēts. Sākat ar tukšu lietotni un pievienojat savus datus.", + "Example data": "Paraugdati", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Paraugvērtības katrai shēmai, ko šī lietotne piegādā, ģenerētas no pašām shēmām. Tās parāda sarakstus, detalizētās lapas un informācijas paneļus darbībā, nevis stāsta stāstu. Droši atkārtojams un pēc tam dzēšams.", + "from amount {amount}": "no summas {amount}", + "Gated transition: {transition}": "Uzraudzīta pāreja: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Katra deklarētā ķēde atver sakārtotu uzdevumu secību, kad pāreja tiek mēģināta. Apstiprinātāji lemj savā uzdevumu iesūtnē, nevis šeit.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarējiet x-openregister-approval-chains šajā shēmā, lai dzīves cikla pāreja prasītu apstiprinājumu.", + "Approval sequences": "Apstiprināšanas secības", "%n entries have no hash yet": "%n ierakstiem vēl nav jaucējkoda", "%n entry has no hash yet": "%n ierakstam vēl nav jaucējkoda", "(Dolphin required)": "(nepieciešams Dolphin)", + "No approval chains declared": "Nav deklarētu apstiprināšanas ķēžu", "(no subject)": "(nav temata)", "(no title)": "(nav nosaukuma)", + "Role {role}": "Loma {role}", + "The transition is applied automatically once every step approves.": "Pāreja tiek piemērota automātiski, tiklīdz katrs solis ir apstiprināts.", + "The requester may not decide their own approval.": "Pieprasītājs nedrīkst lemt par savu apstiprinājumu.", + "Tier routing on field: {field}": "Pakāpju maršrutēšana pēc lauka: {field}", "(unnamed)": "(bez nosaukuma)", "(untitled card)": "(kartīte bez nosaukuma)", "({count} selected)": "({count} atlasīti)", @@ -2738,7 +2770,88 @@ 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ā.", + "No such task": "Nav šāda uzdevuma", + "The task does not exist, or it is not yours to see.": "Uzdevums nepastāv, vai jums nav tiesību to redzēt.", + "Details": "Detaļas", + "Due": "Termiņš", + "Assignee": "Atbildīgais", + "Requested by": "Pieprasīja", + "Outcome": "Iznākums", + "Ready to claim": "Gatavs pārņemšanai", + "In progress": "Procesā", + "Terminated": "Pārtraukts", + "Normal": "Parasts", + "Urgent": "Steidzams", + "Overdue by 1 day": "Nokavēts par 1 dienu", + "Overdue by {days} days": "Nokavēts par {days} dienām", + "Due today": "Termiņš šodien", + "Due tomorrow": "Termiņš rīt", + "Due in {days} days": "Termiņš pēc {days} dienām", + "Tasks": "Uzdevumi", + "Task": "Uzdevums", + "Unassigned, open for claiming": "Nepiešķirts, atvērts pārņemšanai", + "Claim": "Pārņemt", + "Complete": "Pabeigt", + "Complete: {outcome}": "Pabeigt: {outcome}", + "Could not load the task": "Neizdevās ielādēt uzdevumu", + "The task refused that action": "Uzdevums noraidīja šo darbību", + "Store": "Veikals", + "Install registers, schemas and flows that other organisations have published.": "Instalējiet reģistrus, shēmas un plūsmas, ko publicējušas citas organizācijas.", + "No such run": "Nav šādas izpildes", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Izpilde nepastāv, vai jums nav tiesību to redzēt. Dzēšot plūsmu, tiek dzēstas arī tās izpildes." }, "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..da8f7224b5 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Pievieno sociālo vai analītikas kontu vienu reizi. OpenRegister glabā pilnvaru un to atjauno, tāpēc lietotnes to nekad netur.", + "Connect account": "Pievienot kontu", + "Connected accounts": "Pievienotie konti", + "Could not disconnect this account.": "Neizdevās atvienot šo kontu.", + "Could not load your connected accounts.": "Neizdevās ielādēt tavus pievienotos kontus.", + "Could not start the connection. Check the provider and try again.": "Neizdevās sākt savienojumu. Pārbaudi pakalpojuma sniedzēju un mēģini vēlreiz.", + "Disconnect": "Atvienot", + "Expired": "Beidzies", + "Expires {date}": "Beidzas {date}", + "No accounts are connected yet.": "Vēl nav pievienots neviens konts.", + "Not connected yet": "Vēl nav pievienots", + "Reconnect": "Pievienot atkārtoti", + "Relink needed": "Nepieciešama atkārtota sasaiste", + "Server address": "Servera adrese", + "Load example data?": "Vai ielādēt paraugdatus?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Paraugdati aizpilda sarakstus, detalizētās lapas un informācijas paneļus, tāpēc lietotni uzreiz redzēsiet darbībā. Ražošanas instalācijā izvēlieties \"Nav\".", + "Load the example data": "Ielādēt paraugdatus", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Ielādē to, ko izvēlējāties. Tie ir acīmredzami paraugdati, darbību var droši atkārtot, un pēc tam tos var dzēst.", + "None, I will set this up myself": "Nav, iestatīšu pats", + "Nothing is imported. You start with an empty app and add your own data.": "Nekas netiek importēts. Sākat ar tukšu lietotni un pievienojat savus datus.", + "Example data": "Paraugdati", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Paraugvērtības katrai shēmai, ko šī lietotne piegādā, ģenerētas no pašām shēmām. Tās parāda sarakstus, detalizētās lapas un informācijas paneļus darbībā, nevis stāsta stāstu. Droši atkārtojams un pēc tam dzēšams.", + "from amount {amount}": "no summas {amount}", + "Gated transition: {transition}": "Uzraudzīta pāreja: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Katra deklarētā ķēde atver sakārtotu uzdevumu secību, kad pāreja tiek mēģināta. Apstiprinātāji lemj savā uzdevumu iesūtnē, nevis šeit.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarējiet x-openregister-approval-chains šajā shēmā, lai dzīves cikla pāreja prasītu apstiprinājumu.", + "Approval sequences": "Apstiprināšanas secības", "%n entries have no hash yet": "%n ierakstiem vēl nav jaucējkoda", "%n entry has no hash yet": "%n ierakstam vēl nav jaucējkoda", "(Dolphin required)": "(nepieciešams Dolphin)", + "No approval chains declared": "Nav deklarētu apstiprināšanas ķēžu", "(no subject)": "(nav temata)", "(no title)": "(nav nosaukuma)", + "Role {role}": "Loma {role}", + "The transition is applied automatically once every step approves.": "Pāreja tiek piemērota automātiski, tiklīdz katrs solis ir apstiprināts.", + "The requester may not decide their own approval.": "Pieprasītājs nedrīkst lemt par savu apstiprinājumu.", + "Tier routing on field: {field}": "Pakāpju maršrutēšana pēc lauka: {field}", "(unnamed)": "(bez nosaukuma)", "(untitled card)": "(kartīte bez nosaukuma)", "({count} selected)": "({count} atlasīti)", @@ -2801,7 +2833,88 @@ "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ā.", + "No such task": "Nav šāda uzdevuma", + "The task does not exist, or it is not yours to see.": "Uzdevums nepastāv, vai jums nav tiesību to redzēt.", + "Details": "Detaļas", + "Due": "Termiņš", + "Assignee": "Atbildīgais", + "Requested by": "Pieprasīja", + "Outcome": "Iznākums", + "Ready to claim": "Gatavs pārņemšanai", + "In progress": "Procesā", + "Terminated": "Pārtraukts", + "Normal": "Parasts", + "Urgent": "Steidzams", + "Overdue by 1 day": "Nokavēts par 1 dienu", + "Overdue by {days} days": "Nokavēts par {days} dienām", + "Due today": "Termiņš šodien", + "Due tomorrow": "Termiņš rīt", + "Due in {days} days": "Termiņš pēc {days} dienām", + "Tasks": "Uzdevumi", + "Task": "Uzdevums", + "Unassigned, open for claiming": "Nepiešķirts, atvērts pārņemšanai", + "Claim": "Pārņemt", + "Complete": "Pabeigt", + "Complete: {outcome}": "Pabeigt: {outcome}", + "Could not load the task": "Neizdevās ielādēt uzdevumu", + "The task refused that action": "Uzdevums noraidīja šo darbību", + "Store": "Veikals", + "Install registers, schemas and flows that other organisations have published.": "Instalējiet reģistrus, shēmas un plūsmas, ko publicējušas citas organizācijas.", + "No such run": "Nav šādas izpildes", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Izpilde nepastāv, vai jums nav tiesību to redzēt. Dzēšot plūsmu, tiek dzēstas arī tās izpildes." }, "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..01e8afd40e 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Поврзи социјална или аналитичка сметка еднаш. OpenRegister го чува токенот и го обновува, така што апликациите никогаш не го држат.", + "Connect account": "Поврзи сметка", + "Connected accounts": "Поврзани сметки", + "Could not disconnect this account.": "Оваа сметка не можеше да се исклучи.", + "Could not load your connected accounts.": "Твоите поврзани сметки не можеа да се вчитаат.", + "Could not start the connection. Check the provider and try again.": "Поврзувањето не можеше да започне. Провери го давателот и обиди се повторно.", + "Disconnect": "Исклучи", + "Expired": "Истечено", + "Expires {date}": "Истекува {date}", + "No accounts are connected yet.": "Сè уште нема поврзани сметки.", + "Not connected yet": "Сè уште не е поврзано", + "Reconnect": "Поврзи повторно", + "Relink needed": "Потребно е ново поврзување", + "Server address": "Адреса на серверот", + "Load example data?": "Да се вчитаат примероци од податоци?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Примероците од податоци ги полнат списоците, страниците со детали и таблите, за да ја видите апликацијата како работи веднаш. Изберете \"Ништо\" на продукциска инсталација.", + "Load the example data": "Вчитај ги примероците од податоци", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Го вчитува тоа што го избравте. Тоа се очигледно примероци од податоци, дејството може безбедно да се повтори, а потоа можете да ги избришете.", + "None, I will set this up myself": "Ништо, сам ќе го поставам ова", + "Nothing is imported. You start with an empty app and add your own data.": "Ништо не се увезува. Почнувате со празна апликација и додавате свои податоци.", + "Example data": "Примероци од податоци", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Примерни вредности за секоја шема што ја носи оваа апликација, генерирани од самите шеми. Ги покажуваат списоците, страниците со детали и таблите во работа наместо да раскажуваат приказна. Безбедно за повторување и бришење потоа.", + "from amount {amount}": "од износ {amount}", + "Gated transition: {transition}": "Надгледуван премин: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Секој деклариран синџир отвора подредена низа задачи кога преминот ќе се обиде. Одобрувачите одлучуваат во своето сандаче со задачи, не овде.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Декларирајте x-openregister-approval-chains на оваа шема за преминот од животниот циклус да бара одобрение.", + "Approval sequences": "Низи на одобрување", "%n entries have no hash yet": "%n записи сè уште немаат хеш", "%n entry has no hash yet": "%n запис сè уште нема хеш", "(Dolphin required)": "(Потребен Dolphin)", + "No approval chains declared": "Нема декларирани синџири за одобрување", "(no subject)": "(без предмет)", "(no title)": "(без наслов)", + "Role {role}": "Улога {role}", + "The transition is applied automatically once every step approves.": "Преминот се применува автоматски штом секој чекор е одобрен.", + "The requester may not decide their own approval.": "Барателот не може да одлучува за сопственото одобрение.", + "Tier routing on field: {field}": "Скалесто насочување по полето: {field}", "(unnamed)": "(без име)", "(untitled card)": "(картичка без наслов)", "({count} selected)": "({count} избрани)", @@ -2738,7 +2770,88 @@ 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 на рокот или потоа.", + "No such task": "Нема таква задача", + "The task does not exist, or it is not yours to see.": "Задачата не постои или немате право да ја видите.", + "Details": "Детали", + "Due": "Рок", + "Assignee": "Доделено", + "Requested by": "Побарано од", + "Outcome": "Исход", + "Ready to claim": "Подготвено за преземање", + "In progress": "Во тек", + "Terminated": "Прекинато", + "Normal": "Нормално", + "Urgent": "Итно", + "Overdue by 1 day": "Доцни 1 ден", + "Overdue by {days} days": "Доцни {days} дена", + "Due today": "Рок денес", + "Due tomorrow": "Рок утре", + "Due in {days} days": "Рок за {days} дена", + "Tasks": "Задачи", + "Task": "Задача", + "Unassigned, open for claiming": "Недоделено, отворено за преземање", + "Claim": "Преземи", + "Complete": "Заврши", + "Complete: {outcome}": "Заврши: {outcome}", + "Could not load the task": "Задачата не може да се вчита", + "The task refused that action": "Задачата го одби тоа дејство", + "Store": "Продавница", + "Install registers, schemas and flows that other organisations have published.": "Инсталирајте регистри, шеми и текови објавени од други организации.", + "No such run": "Нема такво извршување", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Извршувањето не постои или немате право да го видите. Со бришење на тек се бришат и неговите извршувања." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 484cc82bd3..2943b6bd5a 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Поврзи социјална или аналитичка сметка еднаш. OpenRegister го чува токенот и го обновува, така што апликациите никогаш не го држат.", + "Connect account": "Поврзи сметка", + "Connected accounts": "Поврзани сметки", + "Could not disconnect this account.": "Оваа сметка не можеше да се исклучи.", + "Could not load your connected accounts.": "Твоите поврзани сметки не можеа да се вчитаат.", + "Could not start the connection. Check the provider and try again.": "Поврзувањето не можеше да започне. Провери го давателот и обиди се повторно.", + "Disconnect": "Исклучи", + "Expired": "Истечено", + "Expires {date}": "Истекува {date}", + "No accounts are connected yet.": "Сè уште нема поврзани сметки.", + "Not connected yet": "Сè уште не е поврзано", + "Reconnect": "Поврзи повторно", + "Relink needed": "Потребно е ново поврзување", + "Server address": "Адреса на серверот", + "Load example data?": "Да се вчитаат примероци од податоци?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Примероците од податоци ги полнат списоците, страниците со детали и таблите, за да ја видите апликацијата како работи веднаш. Изберете \"Ништо\" на продукциска инсталација.", + "Load the example data": "Вчитај ги примероците од податоци", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Го вчитува тоа што го избравте. Тоа се очигледно примероци од податоци, дејството може безбедно да се повтори, а потоа можете да ги избришете.", + "None, I will set this up myself": "Ништо, сам ќе го поставам ова", + "Nothing is imported. You start with an empty app and add your own data.": "Ништо не се увезува. Почнувате со празна апликација и додавате свои податоци.", + "Example data": "Примероци од податоци", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Примерни вредности за секоја шема што ја носи оваа апликација, генерирани од самите шеми. Ги покажуваат списоците, страниците со детали и таблите во работа наместо да раскажуваат приказна. Безбедно за повторување и бришење потоа.", + "from amount {amount}": "од износ {amount}", + "Gated transition: {transition}": "Надгледуван премин: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Секој деклариран синџир отвора подредена низа задачи кога преминот ќе се обиде. Одобрувачите одлучуваат во своето сандаче со задачи, не овде.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Декларирајте x-openregister-approval-chains на оваа шема за преминот од животниот циклус да бара одобрение.", + "Approval sequences": "Низи на одобрување", "%n entries have no hash yet": "%n записи сè уште немаат хеш", "%n entry has no hash yet": "%n запис сè уште нема хеш", "(Dolphin required)": "(Потребен Dolphin)", + "No approval chains declared": "Нема декларирани синџири за одобрување", "(no subject)": "(без предмет)", "(no title)": "(без наслов)", + "Role {role}": "Улога {role}", + "The transition is applied automatically once every step approves.": "Преминот се применува автоматски штом секој чекор е одобрен.", + "The requester may not decide their own approval.": "Барателот не може да одлучува за сопственото одобрение.", + "Tier routing on field: {field}": "Скалесто насочување по полето: {field}", "(unnamed)": "(без име)", "(untitled card)": "(картичка без наслов)", "({count} selected)": "({count} избрани)", @@ -2785,7 +2817,88 @@ "_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 на рокот или потоа.", + "No such task": "Нема таква задача", + "The task does not exist, or it is not yours to see.": "Задачата не постои или немате право да ја видите.", + "Details": "Детали", + "Due": "Рок", + "Assignee": "Доделено", + "Requested by": "Побарано од", + "Outcome": "Исход", + "Ready to claim": "Подготвено за преземање", + "In progress": "Во тек", + "Terminated": "Прекинато", + "Normal": "Нормално", + "Urgent": "Итно", + "Overdue by 1 day": "Доцни 1 ден", + "Overdue by {days} days": "Доцни {days} дена", + "Due today": "Рок денес", + "Due tomorrow": "Рок утре", + "Due in {days} days": "Рок за {days} дена", + "Tasks": "Задачи", + "Task": "Задача", + "Unassigned, open for claiming": "Недоделено, отворено за преземање", + "Claim": "Преземи", + "Complete": "Заврши", + "Complete: {outcome}": "Заврши: {outcome}", + "Could not load the task": "Задачата не може да се вчита", + "The task refused that action": "Задачата го одби тоа дејство", + "Store": "Продавница", + "Install registers, schemas and flows that other organisations have published.": "Инсталирајте регистри, шеми и текови објавени од други организации.", + "No such run": "Нема такво извршување", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Извршувањето не постои или немате право да го видите. Со бришење на тек се бришат и неговите извршувања." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/mt.js b/l10n/mt.js index bafeacfbbc..de357dc3b0 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Qabbad kont soċjali jew ta’ analitika darba. OpenRegister iżomm it-token u jġeddu, biex l-applikazzjonijiet qatt ma jżommuh.", + "Connect account": "Qabbad kont", + "Connected accounts": "Kontijiet imqabbda", + "Could not disconnect this account.": "Ma setax jiġi skonnettjat dan il-kont.", + "Could not load your connected accounts.": "Ma setgħux jitgħabbew il-kontijiet imqabbda tiegħek.", + "Could not start the connection. Check the provider and try again.": "Il-konnessjoni ma setgħetx tinbeda. Iċċekkja l-fornitur u erġa’ pprova.", + "Disconnect": "Skonnettja", + "Expired": "Skada", + "Expires {date}": "Jiskadi fi {date}", + "No accounts are connected yet.": "Għad m’hemm ebda kont imqabbad.", + "Not connected yet": "Għadu mhux imqabbad", + "Reconnect": "Erġa’ qabbad", + "Relink needed": "Hemm bżonn rabta ġdida", + "Server address": "Indirizz tas-server", + "Load example data?": "Ittella’ dejta ta’ eżempju?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Id-dejta ta’ eżempju timla l-listi, il-paġni tad-dettalji u d-dashboards, biex tara l-app taħdem mill-ewwel. Agħżel \"Xejn\" fuq installazzjoni tal-produzzjoni.", + "Load the example data": "Tella’ d-dejta ta’ eżempju", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Ittella’ dak li għażilt. Din hija b’mod ċar dejta ta’ eżempju, l-azzjoni tista’ terġa’ ssir bla periklu, u tista’ tħassarha wara.", + "None, I will set this up myself": "Xejn, se nissettja dan jien stess", + "Nothing is imported. You start with an empty app and add your own data.": "Ma jiġi impurtat xejn. Tibda b’app vojta u żżid id-dejta tiegħek.", + "Example data": "Dejta ta’ eżempju", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valuri ta’ eżempju għal kull skema li tipprovdi din l-app, iġġenerati mill-iskemi nfushom. Juru l-listi, il-paġni tad-dettalji u d-dashboards jaħdmu minflok jirrakkontaw storja. Bla periklu li terġa’ ssir u tista’ titħassar wara.", + "from amount {amount}": "minn ammont {amount}", + "Gated transition: {transition}": "Tranżizzjoni mgħassa: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Kull katina ddikjarata tiftaħ sekwenza ordnata ta' kompiti meta t-tranżizzjoni tiġi ppruvata. L-approvaturi jiddeċiedu fl-inbox tal-kompiti tagħhom, mhux hawn.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Iddikjara x-openregister-approval-chains fuq din l-iskema biex tranżizzjoni taċ-ċiklu tal-ħajja tkun teħtieġ approvazzjoni.", + "Approval sequences": "Sekwenzi ta' approvazzjoni", "%n entries have no hash yet": "%n entrati għadhom m'għandhomx hash", "%n entry has no hash yet": "%n entrata għadha m'għandhiex hash", "(Dolphin required)": "(Dolphin meħtieġ)", + "No approval chains declared": "Ebda katina ta' approvazzjoni ddikjarata", "(no subject)": "(l-ebda suġġett)", "(no title)": "(l-ebda titlu)", + "Role {role}": "Rwol {role}", + "The transition is applied automatically once every step approves.": "It-tranżizzjoni tiġi applikata awtomatikament malli kull pass ikun approvat.", + "The requester may not decide their own approval.": "Min jitlob ma jistax jiddeċiedi dwar l-approvazzjoni tiegħu stess.", + "Tier routing on field: {field}": "Rotot skont il-livell fuq il-qasam: {field}", "(unnamed)": "(bla isem)", "(untitled card)": "(karta bla titlu)", "({count} selected)": "({count} magħżula)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "M'hemm l-ebda kompitu bħal dan", + "The task does not exist, or it is not yours to see.": "Il-kompitu ma jeżistix, jew m'għandekx permess tarah.", + "Details": "Dettalji", + "Due": "Skadenza", + "Assignee": "Assenjat lil", + "Requested by": "Mitlub minn", + "Outcome": "Riżultat", + "Ready to claim": "Lest biex jittieħed", + "In progress": "Għaddej", + "Terminated": "Itterminat", + "Normal": "Normali", + "Urgent": "Urġenti", + "Overdue by 1 day": "Skadut b'jum 1", + "Overdue by {days} days": "Skadut b'{days} ijiem", + "Due today": "Skadenza llum", + "Due tomorrow": "Skadenza għada", + "Due in {days} days": "Skadenza fi {days} ijiem", + "Tasks": "Kompiti", + "Task": "Kompitu", + "Unassigned, open for claiming": "Mhux assenjat, miftuħ biex jittieħed", + "Claim": "Ħu", + "Complete": "Lesti", + "Complete: {outcome}": "Lesti: {outcome}", + "Could not load the task": "Ma setax jitgħabba l-kompitu", + "The task refused that action": "Il-kompitu rrifjuta dik l-azzjoni", + "Store": "Ħanut", + "Install registers, schemas and flows that other organisations have published.": "Installa reġistri, skemi u flussi ppubblikati minn organizzazzjonijiet oħra.", + "No such run": "M'hemm l-ebda eżekuzzjoni bħal din", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L-eżekuzzjoni ma teżistix, jew m'għandekx permess taraha. Meta tħassar fluss, jitħassru wkoll l-eżekuzzjonijiet tiegħu." }, "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..461c4fd90b 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Qabbad kont soċjali jew ta’ analitika darba. OpenRegister iżomm it-token u jġeddu, biex l-applikazzjonijiet qatt ma jżommuh.", + "Connect account": "Qabbad kont", + "Connected accounts": "Kontijiet imqabbda", + "Could not disconnect this account.": "Ma setax jiġi skonnettjat dan il-kont.", + "Could not load your connected accounts.": "Ma setgħux jitgħabbew il-kontijiet imqabbda tiegħek.", + "Could not start the connection. Check the provider and try again.": "Il-konnessjoni ma setgħetx tinbeda. Iċċekkja l-fornitur u erġa’ pprova.", + "Disconnect": "Skonnettja", + "Expired": "Skada", + "Expires {date}": "Jiskadi fi {date}", + "No accounts are connected yet.": "Għad m’hemm ebda kont imqabbad.", + "Not connected yet": "Għadu mhux imqabbad", + "Reconnect": "Erġa’ qabbad", + "Relink needed": "Hemm bżonn rabta ġdida", + "Server address": "Indirizz tas-server", + "Load example data?": "Ittella’ dejta ta’ eżempju?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Id-dejta ta’ eżempju timla l-listi, il-paġni tad-dettalji u d-dashboards, biex tara l-app taħdem mill-ewwel. Agħżel \"Xejn\" fuq installazzjoni tal-produzzjoni.", + "Load the example data": "Tella’ d-dejta ta’ eżempju", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Ittella’ dak li għażilt. Din hija b’mod ċar dejta ta’ eżempju, l-azzjoni tista’ terġa’ ssir bla periklu, u tista’ tħassarha wara.", + "None, I will set this up myself": "Xejn, se nissettja dan jien stess", + "Nothing is imported. You start with an empty app and add your own data.": "Ma jiġi impurtat xejn. Tibda b’app vojta u żżid id-dejta tiegħek.", + "Example data": "Dejta ta’ eżempju", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valuri ta’ eżempju għal kull skema li tipprovdi din l-app, iġġenerati mill-iskemi nfushom. Juru l-listi, il-paġni tad-dettalji u d-dashboards jaħdmu minflok jirrakkontaw storja. Bla periklu li terġa’ ssir u tista’ titħassar wara.", + "from amount {amount}": "minn ammont {amount}", + "Gated transition: {transition}": "Tranżizzjoni mgħassa: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Kull katina ddikjarata tiftaħ sekwenza ordnata ta' kompiti meta t-tranżizzjoni tiġi ppruvata. L-approvaturi jiddeċiedu fl-inbox tal-kompiti tagħhom, mhux hawn.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Iddikjara x-openregister-approval-chains fuq din l-iskema biex tranżizzjoni taċ-ċiklu tal-ħajja tkun teħtieġ approvazzjoni.", + "Approval sequences": "Sekwenzi ta' approvazzjoni", "%n entries have no hash yet": "%n entrati għadhom m'għandhomx hash", "%n entry has no hash yet": "%n entrata għadha m'għandhiex hash", "(Dolphin required)": "(Dolphin meħtieġ)", + "No approval chains declared": "Ebda katina ta' approvazzjoni ddikjarata", "(no subject)": "(l-ebda suġġett)", "(no title)": "(l-ebda titlu)", + "Role {role}": "Rwol {role}", + "The transition is applied automatically once every step approves.": "It-tranżizzjoni tiġi applikata awtomatikament malli kull pass ikun approvat.", + "The requester may not decide their own approval.": "Min jitlob ma jistax jiddeċiedi dwar l-approvazzjoni tiegħu stess.", + "Tier routing on field: {field}": "Rotot skont il-livell fuq il-qasam: {field}", "(unnamed)": "(bla isem)", "(untitled card)": "(karta bla titlu)", "({count} selected)": "({count} magħżula)", @@ -2817,7 +2849,88 @@ "{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.", + "No such task": "M'hemm l-ebda kompitu bħal dan", + "The task does not exist, or it is not yours to see.": "Il-kompitu ma jeżistix, jew m'għandekx permess tarah.", + "Details": "Dettalji", + "Due": "Skadenza", + "Assignee": "Assenjat lil", + "Requested by": "Mitlub minn", + "Outcome": "Riżultat", + "Ready to claim": "Lest biex jittieħed", + "In progress": "Għaddej", + "Terminated": "Itterminat", + "Normal": "Normali", + "Urgent": "Urġenti", + "Overdue by 1 day": "Skadut b'jum 1", + "Overdue by {days} days": "Skadut b'{days} ijiem", + "Due today": "Skadenza llum", + "Due tomorrow": "Skadenza għada", + "Due in {days} days": "Skadenza fi {days} ijiem", + "Tasks": "Kompiti", + "Task": "Kompitu", + "Unassigned, open for claiming": "Mhux assenjat, miftuħ biex jittieħed", + "Claim": "Ħu", + "Complete": "Lesti", + "Complete: {outcome}": "Lesti: {outcome}", + "Could not load the task": "Ma setax jitgħabba l-kompitu", + "The task refused that action": "Il-kompitu rrifjuta dik l-azzjoni", + "Store": "Ħanut", + "Install registers, schemas and flows that other organisations have published.": "Installa reġistri, skemi u flussi ppubblikati minn organizzazzjonijiet oħra.", + "No such run": "M'hemm l-ebda eżekuzzjoni bħal din", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L-eżekuzzjoni ma teżistix, jew m'għandekx permess taraha. Meta tħassar fluss, jitħassru wkoll l-eżekuzzjonijiet tiegħu." }, "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..af4139ca81 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Koble til en sosial konto eller analysekonto én gang. OpenRegister tar vare på tokenet og fornyer det, slik at apper aldri holder det selv.", + "Connect account": "Koble til konto", + "Connected accounts": "Tilkoblede kontoer", + "Could not disconnect this account.": "Denne kontoen kunne ikke kobles fra.", + "Could not load your connected accounts.": "Kunne ikke laste de tilkoblede kontoene dine.", + "Could not start the connection. Check the provider and try again.": "Kunne ikke starte tilkoblingen. Sjekk leverandøren og prøv igjen.", + "Disconnect": "Koble fra", + "Expired": "Utløpt", + "Expires {date}": "Utløper {date}", + "No accounts are connected yet.": "Ingen kontoer er tilkoblet ennå.", + "Not connected yet": "Ikke tilkoblet ennå", + "Reconnect": "Koble til på nytt", + "Relink needed": "Ny tilkobling kreves", + "Server address": "Serveradresse", + "Load example data?": "Laste inn eksempeldata?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Eksempeldata fyller listene, detaljsidene og dashbordene, så du ser appen virke med en gang. Velg \"Ingen\" på en produksjonsinstallasjon.", + "Load the example data": "Last inn eksempeldataene", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laster inn det du valgte. Dette er tydelig eksempeldata, handlingen kan gjentas uten risiko, og du kan slette dem etterpå.", + "None, I will set this up myself": "Ingen, jeg setter det opp selv", + "Nothing is imported. You start with an empty app and add your own data.": "Ingenting importeres. Du starter med en tom app og legger til dine egne data.", + "Example data": "Eksempeldata", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Eksempelverdier for hvert skjema denne appen leverer, generert fra skjemaene selv. De viser listene, detaljsidene og dashbordene i drift i stedet for å fortelle en historie. Kan gjentas uten risiko og slettes etterpå.", + "from amount {amount}": "fra beløp {amount}", + "Gated transition: {transition}": "Overvåket overgang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Hver deklarerte kjede åpner en ordnet oppgavesekvens når overgangen forsøkes. Godkjennere beslutter i oppgaveinnboksen sin, ikke her.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarer x-openregister-approval-chains på dette skjemaet for å kreve godkjenning før en livssyklusovergang.", + "Approval sequences": "Godkjenningssekvenser", "%n entries have no hash yet": "%n oppføringer har ennå ingen hash", "%n entry has no hash yet": "%n oppføring har ennå ingen hash", "(Dolphin required)": "(Dolphin kreves)", + "No approval chains declared": "Ingen godkjenningskjeder deklarert", "(no subject)": "(ingen emne)", "(no title)": "(ingen tittel)", + "Role {role}": "Rolle {role}", + "The transition is applied automatically once every step approves.": "Overgangen tas i bruk automatisk når hvert trinn er godkjent.", + "The requester may not decide their own approval.": "Innsenderen kan ikke avgjøre sin egen godkjenning.", + "Tier routing on field: {field}": "Trinnvis ruting på feltet: {field}", "(unnamed)": "(uten navn)", "(untitled card)": "(kort uten tittel)", "({count} selected)": "({count} valgt)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Ingen slik oppgave", + "The task does not exist, or it is not yours to see.": "Oppgaven finnes ikke, eller du har ikke tilgang til å se den.", + "Details": "Detaljer", + "Due": "Frist", + "Assignee": "Tildelt", + "Requested by": "Forespurt av", + "Outcome": "Utfall", + "Ready to claim": "Klar til å ta", + "In progress": "Pågår", + "Terminated": "Avsluttet", + "Normal": "Normal", + "Urgent": "Haster", + "Overdue by 1 day": "1 dag over fristen", + "Overdue by {days} days": "{days} dager over fristen", + "Due today": "Frist i dag", + "Due tomorrow": "Frist i morgen", + "Due in {days} days": "Frist om {days} dager", + "Tasks": "Oppgaver", + "Task": "Oppgave", + "Unassigned, open for claiming": "Ikke tildelt, åpen for å ta", + "Claim": "Ta", + "Complete": "Fullfør", + "Complete: {outcome}": "Fullfør: {outcome}", + "Could not load the task": "Kunne ikke laste oppgaven", + "The task refused that action": "Oppgaven avviste den handlingen", + "Store": "Butikk", + "Install registers, schemas and flows that other organisations have published.": "Installer registre, skjemaer og flyter som andre organisasjoner har publisert.", + "No such run": "Ingen slik kjøring", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Kjøringen finnes ikke, eller du har ikke tilgang til å se den. Sletter du en flyt, slettes kjøringene dens også." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 9183612726..aa71252c97 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Koble til en sosial konto eller analysekonto én gang. OpenRegister tar vare på tokenet og fornyer det, slik at apper aldri holder det selv.", + "Connect account": "Koble til konto", + "Connected accounts": "Tilkoblede kontoer", + "Could not disconnect this account.": "Denne kontoen kunne ikke kobles fra.", + "Could not load your connected accounts.": "Kunne ikke laste de tilkoblede kontoene dine.", + "Could not start the connection. Check the provider and try again.": "Kunne ikke starte tilkoblingen. Sjekk leverandøren og prøv igjen.", + "Disconnect": "Koble fra", + "Expired": "Utløpt", + "Expires {date}": "Utløper {date}", + "No accounts are connected yet.": "Ingen kontoer er tilkoblet ennå.", + "Not connected yet": "Ikke tilkoblet ennå", + "Reconnect": "Koble til på nytt", + "Relink needed": "Ny tilkobling kreves", + "Server address": "Serveradresse", + "Load example data?": "Laste inn eksempeldata?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Eksempeldata fyller listene, detaljsidene og dashbordene, så du ser appen virke med en gang. Velg \"Ingen\" på en produksjonsinstallasjon.", + "Load the example data": "Last inn eksempeldataene", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laster inn det du valgte. Dette er tydelig eksempeldata, handlingen kan gjentas uten risiko, og du kan slette dem etterpå.", + "None, I will set this up myself": "Ingen, jeg setter det opp selv", + "Nothing is imported. You start with an empty app and add your own data.": "Ingenting importeres. Du starter med en tom app og legger til dine egne data.", + "Example data": "Eksempeldata", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Eksempelverdier for hvert skjema denne appen leverer, generert fra skjemaene selv. De viser listene, detaljsidene og dashbordene i drift i stedet for å fortelle en historie. Kan gjentas uten risiko og slettes etterpå.", + "from amount {amount}": "fra beløp {amount}", + "Gated transition: {transition}": "Overvåket overgang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Hver deklarerte kjede åpner en ordnet oppgavesekvens når overgangen forsøkes. Godkjennere beslutter i oppgaveinnboksen sin, ikke her.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarer x-openregister-approval-chains på dette skjemaet for å kreve godkjenning før en livssyklusovergang.", + "Approval sequences": "Godkjenningssekvenser", "%n entries have no hash yet": "%n oppføringer har ennå ingen hash", "%n entry has no hash yet": "%n oppføring har ennå ingen hash", "(Dolphin required)": "(Dolphin kreves)", + "No approval chains declared": "Ingen godkjenningskjeder deklarert", "(no subject)": "(ingen emne)", "(no title)": "(ingen tittel)", + "Role {role}": "Rolle {role}", + "The transition is applied automatically once every step approves.": "Overgangen tas i bruk automatisk når hvert trinn er godkjent.", + "The requester may not decide their own approval.": "Innsenderen kan ikke avgjøre sin egen godkjenning.", + "Tier routing on field: {field}": "Trinnvis ruting på feltet: {field}", "(unnamed)": "(uten navn)", "(untitled card)": "(kort uten tittel)", "({count} selected)": "({count} valgt)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Ingen slik oppgave", + "The task does not exist, or it is not yours to see.": "Oppgaven finnes ikke, eller du har ikke tilgang til å se den.", + "Details": "Detaljer", + "Due": "Frist", + "Assignee": "Tildelt", + "Requested by": "Forespurt av", + "Outcome": "Utfall", + "Ready to claim": "Klar til å ta", + "In progress": "Pågår", + "Terminated": "Avsluttet", + "Normal": "Normal", + "Urgent": "Haster", + "Overdue by 1 day": "1 dag over fristen", + "Overdue by {days} days": "{days} dager over fristen", + "Due today": "Frist i dag", + "Due tomorrow": "Frist i morgen", + "Due in {days} days": "Frist om {days} dager", + "Tasks": "Oppgaver", + "Task": "Oppgave", + "Unassigned, open for claiming": "Ikke tildelt, åpen for å ta", + "Claim": "Ta", + "Complete": "Fullfør", + "Complete: {outcome}": "Fullfør: {outcome}", + "Could not load the task": "Kunne ikke laste oppgaven", + "The task refused that action": "Oppgaven avviste den handlingen", + "Store": "Butikk", + "Install registers, schemas and flows that other organisations have published.": "Installer registre, skjemaer og flyter som andre organisasjoner har publisert.", + "No such run": "Ingen slik kjøring", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Kjøringen finnes ikke, eller du har ikke tilgang til å se den. Sletter du en flyt, slettes kjøringene dens også." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/nl.js b/l10n/nl.js index 8b30f307d0..e2b0eae079 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -3,6 +3,38 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Koppel een social- of analytics-account één keer. OpenRegister bewaart het token en vernieuwt het, zodat apps het nooit zelf hoeven te houden.", + "Connect account": "Account koppelen", + "Connected accounts": "Gekoppelde accounts", + "Could not disconnect this account.": "Kon dit account niet ontkoppelen.", + "Could not load your connected accounts.": "Kon je gekoppelde accounts niet laden.", + "Could not start the connection. Check the provider and try again.": "Kon de koppeling niet starten. Controleer de provider en probeer het opnieuw.", + "Disconnect": "Ontkoppelen", + "Expired": "Verlopen", + "Expires {date}": "Verloopt {date}", + "No accounts are connected yet.": "Er zijn nog geen accounts gekoppeld.", + "Not connected yet": "Nog niet gekoppeld", + "Reconnect": "Opnieuw koppelen", + "Relink needed": "Opnieuw koppelen nodig", + "Server address": "Serveradres", + "Load example data?": "Voorbeeldgegevens laden?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", + "Load the example data": "Laad de voorbeeldgegevens", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "None, I will set this up myself": "Geen, ik richt dit zelf in", + "Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.", + "Example data": "Voorbeeldgegevens", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Voorbeeldwaarden voor elk schema dat deze app levert, gegenereerd uit de schema’s zelf. Het laat de lijsten, detailpagina’s en dashboards werkend zien in plaats van een verhaal te vertellen. Veilig om vaker uit te voeren, en je kunt het daarna verwijderen.", + "from amount {amount}": "vanaf bedrag {amount}", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "De overgang wordt automatisch toegepast zodra elke stap is goedgekeurd.", + "The requester may not decide their own approval.": "De aanvrager mag niet over de eigen goedkeuring beslissen.", + "Tier routing on field: {field}": "Trapsgewijze routering op veld: {field}", + "Gated transition: {transition}": "Bewaakte overgang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Elke gedeclareerde keten opent een geordende taakreeks zodra de overgang wordt geprobeerd. Goedkeurders beslissen in hun takeninbox, niet hier.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declareer x-openregister-approval-chains op dit schema om een levenscyclusovergang achter goedkeuring te zetten.", + "No approval chains declared": "Geen goedkeuringsketens gedeclareerd", + "Approval sequences": "Goedkeuringsreeksen", "Welcome": "Welkom", "A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.", "Demo data (optional)": "Demovoorbeelddata (optioneel)", @@ -2795,7 +2827,88 @@ 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:.", + "No such task": "Geen taak gevonden", + "The task does not exist, or it is not yours to see.": "De taak bestaat niet, of u mag deze niet zien.", + "Details": "Details", + "Due": "Deadline", + "Assignee": "Toegewezen aan", + "Requested by": "Aangevraagd door", + "Outcome": "Uitkomst", + "Ready to claim": "Klaar om te claimen", + "In progress": "In behandeling", + "Terminated": "Beëindigd", + "Normal": "Normaal", + "Urgent": "Urgent", + "Overdue by 1 day": "1 dag te laat", + "Overdue by {days} days": "{days} dagen te laat", + "Due today": "Vandaag af te ronden", + "Due tomorrow": "Morgen af te ronden", + "Due in {days} days": "Nog {days} dagen", + "Tasks": "Taken", + "Task": "Taak", + "Unassigned, open for claiming": "Niet toegewezen, open om te claimen", + "Claim": "Claimen", + "Complete": "Afronden", + "Complete: {outcome}": "Afronden: {outcome}", + "Could not load the task": "Kon de taak niet laden", + "The task refused that action": "De taak weigerde die actie", + "Store": "Store", + "Install registers, schemas and flows that other organisations have published.": "Installeer registers, schema's en flows die andere organisaties hebben gepubliceerd.", + "No such run": "Geen uitvoering gevonden", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "De uitvoering bestaat niet, of u mag deze niet zien. Verwijdert u een flow, dan verdwijnen ook de uitvoeringen." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 376401b669..dfbd70a3f1 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,37 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Koppel een social- of analytics-account één keer. OpenRegister bewaart het token en vernieuwt het, zodat apps het nooit zelf hoeven te houden.", + "Connect account": "Account koppelen", + "Connected accounts": "Gekoppelde accounts", + "Could not disconnect this account.": "Kon dit account niet ontkoppelen.", + "Could not load your connected accounts.": "Kon je gekoppelde accounts niet laden.", + "Could not start the connection. Check the provider and try again.": "Kon de koppeling niet starten. Controleer de provider en probeer het opnieuw.", + "Disconnect": "Ontkoppelen", + "Expired": "Verlopen", + "Expires {date}": "Verloopt {date}", + "No accounts are connected yet.": "Er zijn nog geen accounts gekoppeld.", + "Not connected yet": "Nog niet gekoppeld", + "Reconnect": "Opnieuw koppelen", + "Relink needed": "Opnieuw koppelen nodig", + "Server address": "Serveradres", + "Load example data?": "Voorbeeldgegevens laden?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", + "Load the example data": "Laad de voorbeeldgegevens", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "None, I will set this up myself": "Geen, ik richt dit zelf in", + "Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.", + "Example data": "Voorbeeldgegevens", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Voorbeeldwaarden voor elk schema dat deze app levert, gegenereerd uit de schema’s zelf. Het laat de lijsten, detailpagina’s en dashboards werkend zien in plaats van een verhaal te vertellen. Veilig om vaker uit te voeren, en je kunt het daarna verwijderen.", + "from amount {amount}": "vanaf bedrag {amount}", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "De overgang wordt automatisch toegepast zodra elke stap is goedgekeurd.", + "The requester may not decide their own approval.": "De aanvrager mag niet over de eigen goedkeuring beslissen.", + "Tier routing on field: {field}": "Trapsgewijze routering op veld: {field}", + "Gated transition: {transition}": "Bewaakte overgang: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Elke gedeclareerde keten opent een geordende taakreeks zodra de overgang wordt geprobeerd. Goedkeurders beslissen in hun takeninbox, niet hier.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declareer x-openregister-approval-chains op dit schema om een levenscyclusovergang achter goedkeuring te zetten.", + "No approval chains declared": "Geen goedkeuringsketens gedeclareerd", + "Approval sequences": "Goedkeuringsreeksen", "Welcome": "Welkom", "A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.", "Demo data (optional)": "Demovoorbeelddata (optioneel)", @@ -2842,7 +2874,88 @@ "_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:.", + "No such task": "Geen taak gevonden", + "The task does not exist, or it is not yours to see.": "De taak bestaat niet, of u mag deze niet zien.", + "Details": "Details", + "Due": "Deadline", + "Assignee": "Toegewezen aan", + "Requested by": "Aangevraagd door", + "Outcome": "Uitkomst", + "Ready to claim": "Klaar om te claimen", + "In progress": "In behandeling", + "Terminated": "Beëindigd", + "Normal": "Normaal", + "Urgent": "Urgent", + "Overdue by 1 day": "1 dag te laat", + "Overdue by {days} days": "{days} dagen te laat", + "Due today": "Vandaag af te ronden", + "Due tomorrow": "Morgen af te ronden", + "Due in {days} days": "Nog {days} dagen", + "Tasks": "Taken", + "Task": "Taak", + "Unassigned, open for claiming": "Niet toegewezen, open om te claimen", + "Claim": "Claimen", + "Complete": "Afronden", + "Complete: {outcome}": "Afronden: {outcome}", + "Could not load the task": "Kon de taak niet laden", + "The task refused that action": "De taak weigerde die actie", + "Store": "Store", + "Install registers, schemas and flows that other organisations have published.": "Installeer registers, schema's en flows die andere organisaties hebben gepubliceerd.", + "No such run": "Geen uitvoering gevonden", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "De uitvoering bestaat niet, of u mag deze niet zien. Verwijdert u een flow, dan verdwijnen ook de uitvoeringen." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/pl.js b/l10n/pl.js index 712ff4b855..a0b94f1c6f 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Połącz konto społecznościowe lub analityczne raz. OpenRegister przechowuje token i go odnawia, dzięki czemu aplikacje nigdy go nie trzymają.", + "Connect account": "Połącz konto", + "Connected accounts": "Połączone konta", + "Could not disconnect this account.": "Nie udało się odłączyć tego konta.", + "Could not load your connected accounts.": "Nie udało się wczytać Twoich połączonych kont.", + "Could not start the connection. Check the provider and try again.": "Nie udało się rozpocząć połączenia. Sprawdź dostawcę i spróbuj ponownie.", + "Disconnect": "Odłącz", + "Expired": "Wygasło", + "Expires {date}": "Wygasa {date}", + "No accounts are connected yet.": "Nie połączono jeszcze żadnego konta.", + "Not connected yet": "Jeszcze niepołączone", + "Reconnect": "Połącz ponownie", + "Relink needed": "Wymagane ponowne połączenie", + "Server address": "Adres serwera", + "Load example data?": "Wczytać dane przykładowe?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Dane przykładowe wypełniają listy, strony szczegółów i pulpity, więc od razu zobaczysz działającą aplikację. Na instalacji produkcyjnej wybierz \"Brak\".", + "Load the example data": "Wczytaj dane przykładowe", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Wczytuje to, co wybrano. To wyraźnie dane przykładowe, operację można bezpiecznie powtórzyć, a potem je usunąć.", + "None, I will set this up myself": "Brak, skonfiguruję to sam", + "Nothing is imported. You start with an empty app and add your own data.": "Nic nie jest importowane. Zaczynasz od pustej aplikacji i dodajesz własne dane.", + "Example data": "Dane przykładowe", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Wartości przykładowe dla każdego schematu dostarczanego przez tę aplikację, wygenerowane z samych schematów. Pokazują listy, strony szczegółów i pulpity w działaniu, zamiast opowiadać historię. Można bezpiecznie powtórzyć i usunąć później.", + "from amount {amount}": "od kwoty {amount}", + "Gated transition: {transition}": "Nadzorowane przejście: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Każdy zadeklarowany łańcuch otwiera uporządkowaną sekwencję zadań przy próbie przejścia. Zatwierdzający decydują w swojej skrzynce zadań, nie tutaj.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Zadeklaruj x-openregister-approval-chains w tym schemacie, aby przejście cyklu życia wymagało zatwierdzenia.", + "Approval sequences": "Sekwencje zatwierdzeń", "%n entries have no hash yet": "%n wpisów nie ma jeszcze skrótu", "%n entry has no hash yet": "%n wpis nie ma jeszcze skrótu", "(Dolphin required)": "(Wymagany Dolphin)", + "No approval chains declared": "Nie zadeklarowano łańcuchów zatwierdzeń", "(no subject)": "(brak tematu)", "(no title)": "(brak tytułu)", + "Role {role}": "Rola {role}", + "The transition is applied automatically once every step approves.": "Przejście jest stosowane automatycznie, gdy każdy krok zostanie zatwierdzony.", + "The requester may not decide their own approval.": "Wnioskujący nie może decydować o własnym zatwierdzeniu.", + "Tier routing on field: {field}": "Routing progowy według pola: {field}", "(unnamed)": "(bez nazwy)", "(untitled card)": "(karta bez tytułu)", "({count} selected)": "(wybrano: {count})", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Nie ma takiego zadania", + "The task does not exist, or it is not yours to see.": "Zadanie nie istnieje lub nie masz do niego wglądu.", + "Details": "Szczegóły", + "Due": "Termin", + "Assignee": "Przypisane do", + "Requested by": "Zgłoszone przez", + "Outcome": "Wynik", + "Ready to claim": "Gotowe do przejęcia", + "In progress": "W toku", + "Terminated": "Zakończone", + "Normal": "Normalny", + "Urgent": "Pilny", + "Overdue by 1 day": "1 dzień po terminie", + "Overdue by {days} days": "{days} dni po terminie", + "Due today": "Termin dziś", + "Due tomorrow": "Termin jutro", + "Due in {days} days": "Termin za {days} dni", + "Tasks": "Zadania", + "Task": "Zadanie", + "Unassigned, open for claiming": "Nieprzypisane, można przejąć", + "Claim": "Przejmij", + "Complete": "Zakończ", + "Complete: {outcome}": "Zakończ: {outcome}", + "Could not load the task": "Nie można wczytać zadania", + "The task refused that action": "Zadanie odrzuciło tę akcję", + "Store": "Sklep", + "Install registers, schemas and flows that other organisations have published.": "Zainstaluj rejestry, schematy i przepływy opublikowane przez inne organizacje.", + "No such run": "Nie ma takiego uruchomienia", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Uruchomienie nie istnieje lub nie masz do niego wglądu. Usunięcie przepływu usuwa też jego uruchomienia." }, "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..c6ab515d81 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Połącz konto społecznościowe lub analityczne raz. OpenRegister przechowuje token i go odnawia, dzięki czemu aplikacje nigdy go nie trzymają.", + "Connect account": "Połącz konto", + "Connected accounts": "Połączone konta", + "Could not disconnect this account.": "Nie udało się odłączyć tego konta.", + "Could not load your connected accounts.": "Nie udało się wczytać Twoich połączonych kont.", + "Could not start the connection. Check the provider and try again.": "Nie udało się rozpocząć połączenia. Sprawdź dostawcę i spróbuj ponownie.", + "Disconnect": "Odłącz", + "Expired": "Wygasło", + "Expires {date}": "Wygasa {date}", + "No accounts are connected yet.": "Nie połączono jeszcze żadnego konta.", + "Not connected yet": "Jeszcze niepołączone", + "Reconnect": "Połącz ponownie", + "Relink needed": "Wymagane ponowne połączenie", + "Server address": "Adres serwera", + "Load example data?": "Wczytać dane przykładowe?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Dane przykładowe wypełniają listy, strony szczegółów i pulpity, więc od razu zobaczysz działającą aplikację. Na instalacji produkcyjnej wybierz \"Brak\".", + "Load the example data": "Wczytaj dane przykładowe", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Wczytuje to, co wybrano. To wyraźnie dane przykładowe, operację można bezpiecznie powtórzyć, a potem je usunąć.", + "None, I will set this up myself": "Brak, skonfiguruję to sam", + "Nothing is imported. You start with an empty app and add your own data.": "Nic nie jest importowane. Zaczynasz od pustej aplikacji i dodajesz własne dane.", + "Example data": "Dane przykładowe", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Wartości przykładowe dla każdego schematu dostarczanego przez tę aplikację, wygenerowane z samych schematów. Pokazują listy, strony szczegółów i pulpity w działaniu, zamiast opowiadać historię. Można bezpiecznie powtórzyć i usunąć później.", + "from amount {amount}": "od kwoty {amount}", + "Gated transition: {transition}": "Nadzorowane przejście: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Każdy zadeklarowany łańcuch otwiera uporządkowaną sekwencję zadań przy próbie przejścia. Zatwierdzający decydują w swojej skrzynce zadań, nie tutaj.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Zadeklaruj x-openregister-approval-chains w tym schemacie, aby przejście cyklu życia wymagało zatwierdzenia.", + "Approval sequences": "Sekwencje zatwierdzeń", "%n entries have no hash yet": "%n wpisów nie ma jeszcze skrótu", "%n entry has no hash yet": "%n wpis nie ma jeszcze skrótu", "(Dolphin required)": "(Wymagany Dolphin)", + "No approval chains declared": "Nie zadeklarowano łańcuchów zatwierdzeń", "(no subject)": "(brak tematu)", "(no title)": "(brak tytułu)", + "Role {role}": "Rola {role}", + "The transition is applied automatically once every step approves.": "Przejście jest stosowane automatycznie, gdy każdy krok zostanie zatwierdzony.", + "The requester may not decide their own approval.": "Wnioskujący nie może decydować o własnym zatwierdzeniu.", + "Tier routing on field: {field}": "Routing progowy według pola: {field}", "(unnamed)": "(bez nazwy)", "(untitled card)": "(karta bez tytułu)", "({count} selected)": "(wybrano: {count})", @@ -2817,7 +2849,88 @@ "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.", + "No such task": "Nie ma takiego zadania", + "The task does not exist, or it is not yours to see.": "Zadanie nie istnieje lub nie masz do niego wglądu.", + "Details": "Szczegóły", + "Due": "Termin", + "Assignee": "Przypisane do", + "Requested by": "Zgłoszone przez", + "Outcome": "Wynik", + "Ready to claim": "Gotowe do przejęcia", + "In progress": "W toku", + "Terminated": "Zakończone", + "Normal": "Normalny", + "Urgent": "Pilny", + "Overdue by 1 day": "1 dzień po terminie", + "Overdue by {days} days": "{days} dni po terminie", + "Due today": "Termin dziś", + "Due tomorrow": "Termin jutro", + "Due in {days} days": "Termin za {days} dni", + "Tasks": "Zadania", + "Task": "Zadanie", + "Unassigned, open for claiming": "Nieprzypisane, można przejąć", + "Claim": "Przejmij", + "Complete": "Zakończ", + "Complete: {outcome}": "Zakończ: {outcome}", + "Could not load the task": "Nie można wczytać zadania", + "The task refused that action": "Zadanie odrzuciło tę akcję", + "Store": "Sklep", + "Install registers, schemas and flows that other organisations have published.": "Zainstaluj rejestry, schematy i przepływy opublikowane przez inne organizacje.", + "No such run": "Nie ma takiego uruchomienia", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Uruchomienie nie istnieje lub nie masz do niego wglądu. Usunięcie przepływu usuwa też jego uruchomienia." }, "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..6a514c12b6 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Ligue uma conta social ou de análise uma única vez. O OpenRegister guarda o token e renova-o, para que as aplicações nunca o detenham.", + "Connect account": "Ligar conta", + "Connected accounts": "Contas ligadas", + "Could not disconnect this account.": "Não foi possível desligar esta conta.", + "Could not load your connected accounts.": "Não foi possível carregar as suas contas ligadas.", + "Could not start the connection. Check the provider and try again.": "Não foi possível iniciar a ligação. Verifique o fornecedor e tente novamente.", + "Disconnect": "Desligar", + "Expired": "Expirado", + "Expires {date}": "Expira a {date}", + "No accounts are connected yet.": "Ainda não há contas ligadas.", + "Not connected yet": "Ainda não ligado", + "Reconnect": "Ligar de novo", + "Relink needed": "É preciso voltar a ligar", + "Server address": "Endereço do servidor", + "Load example data?": "Carregar dados de exemplo?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Os dados de exemplo preenchem as listas, as páginas de detalhe e os painéis, para ver a aplicação a funcionar de imediato. Escolha \"Nenhum\" numa instalação de produção.", + "Load the example data": "Carregar os dados de exemplo", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Carrega o que escolheu. São claramente dados de exemplo, a operação pode repetir-se sem risco e pode apagá-los depois.", + "None, I will set this up myself": "Nenhum, eu próprio configuro isto", + "Nothing is imported. You start with an empty app and add your own data.": "Nada é importado. Começa com uma aplicação vazia e acrescenta os seus próprios dados.", + "Example data": "Dados de exemplo", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valores de exemplo para cada esquema que esta aplicação fornece, gerados a partir dos próprios esquemas. Mostram as listas, as páginas de detalhe e os painéis a funcionar em vez de contarem uma história. Repetível sem risco e apagável depois.", + "from amount {amount}": "a partir do montante {amount}", + "Gated transition: {transition}": "Transição vigiada: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Cada cadeia declarada abre uma sequência ordenada de tarefas quando a transição é tentada. Os aprovadores decidem na sua caixa de tarefas, não aqui.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declare x-openregister-approval-chains neste esquema para condicionar uma transição do ciclo de vida a uma aprovação.", + "Approval sequences": "Sequências de aprovação", "%n entries have no hash yet": "%n entradas ainda não têm hash", "%n entry has no hash yet": "%n entrada ainda não tem hash", "(Dolphin required)": "(Dolphin necessário)", + "No approval chains declared": "Nenhuma cadeia de aprovação declarada", "(no subject)": "(sem assunto)", "(no title)": "(sem título)", + "Role {role}": "Função {role}", + "The transition is applied automatically once every step approves.": "A transição é aplicada automaticamente assim que cada passo for aprovado.", + "The requester may not decide their own approval.": "O requerente não pode decidir sobre a sua própria aprovação.", + "Tier routing on field: {field}": "Encaminhamento por escalões no campo: {field}", "(unnamed)": "(sem nome)", "(untitled card)": "(cartão sem título)", "({count} selected)": "({count} selecionados)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Tarefa não encontrada", + "The task does not exist, or it is not yours to see.": "A tarefa não existe ou não tem permissão para a ver.", + "Details": "Detalhes", + "Due": "Prazo", + "Assignee": "Atribuído a", + "Requested by": "Solicitado por", + "Outcome": "Resultado", + "Ready to claim": "Pronto a reclamar", + "In progress": "Em curso", + "Terminated": "Terminada", + "Normal": "Normal", + "Urgent": "Urgente", + "Overdue by 1 day": "Atrasada 1 dia", + "Overdue by {days} days": "Atrasada {days} dias", + "Due today": "Prazo hoje", + "Due tomorrow": "Prazo amanhã", + "Due in {days} days": "Prazo em {days} dias", + "Tasks": "Tarefas", + "Task": "Tarefa", + "Unassigned, open for claiming": "Não atribuída, aberta a reclamação", + "Claim": "Reclamar", + "Complete": "Concluir", + "Complete: {outcome}": "Concluir: {outcome}", + "Could not load the task": "Não foi possível carregar a tarefa", + "The task refused that action": "A tarefa recusou essa ação", + "Store": "Loja", + "Install registers, schemas and flows that other organisations have published.": "Instale registos, esquemas e fluxos publicados por outras organizações.", + "No such run": "Execução não encontrada", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "A execução não existe ou não tem permissão para a ver. Eliminar um fluxo elimina as suas execuções." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 46ef40223a..c3efde95d3 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Ligue uma conta social ou de análise uma única vez. O OpenRegister guarda o token e renova-o, para que as aplicações nunca o detenham.", + "Connect account": "Ligar conta", + "Connected accounts": "Contas ligadas", + "Could not disconnect this account.": "Não foi possível desligar esta conta.", + "Could not load your connected accounts.": "Não foi possível carregar as suas contas ligadas.", + "Could not start the connection. Check the provider and try again.": "Não foi possível iniciar a ligação. Verifique o fornecedor e tente novamente.", + "Disconnect": "Desligar", + "Expired": "Expirado", + "Expires {date}": "Expira a {date}", + "No accounts are connected yet.": "Ainda não há contas ligadas.", + "Not connected yet": "Ainda não ligado", + "Reconnect": "Ligar de novo", + "Relink needed": "É preciso voltar a ligar", + "Server address": "Endereço do servidor", + "Load example data?": "Carregar dados de exemplo?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Os dados de exemplo preenchem as listas, as páginas de detalhe e os painéis, para ver a aplicação a funcionar de imediato. Escolha \"Nenhum\" numa instalação de produção.", + "Load the example data": "Carregar os dados de exemplo", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Carrega o que escolheu. São claramente dados de exemplo, a operação pode repetir-se sem risco e pode apagá-los depois.", + "None, I will set this up myself": "Nenhum, eu próprio configuro isto", + "Nothing is imported. You start with an empty app and add your own data.": "Nada é importado. Começa com uma aplicação vazia e acrescenta os seus próprios dados.", + "Example data": "Dados de exemplo", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valores de exemplo para cada esquema que esta aplicação fornece, gerados a partir dos próprios esquemas. Mostram as listas, as páginas de detalhe e os painéis a funcionar em vez de contarem uma história. Repetível sem risco e apagável depois.", + "from amount {amount}": "a partir do montante {amount}", + "Gated transition: {transition}": "Transição vigiada: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Cada cadeia declarada abre uma sequência ordenada de tarefas quando a transição é tentada. Os aprovadores decidem na sua caixa de tarefas, não aqui.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declare x-openregister-approval-chains neste esquema para condicionar uma transição do ciclo de vida a uma aprovação.", + "Approval sequences": "Sequências de aprovação", "%n entries have no hash yet": "%n entradas ainda não têm hash", "%n entry has no hash yet": "%n entrada ainda não tem hash", "(Dolphin required)": "(Dolphin necessário)", + "No approval chains declared": "Nenhuma cadeia de aprovação declarada", "(no subject)": "(sem assunto)", "(no title)": "(sem título)", + "Role {role}": "Função {role}", + "The transition is applied automatically once every step approves.": "A transição é aplicada automaticamente assim que cada passo for aprovado.", + "The requester may not decide their own approval.": "O requerente não pode decidir sobre a sua própria aprovação.", + "Tier routing on field: {field}": "Encaminhamento por escalões no campo: {field}", "(unnamed)": "(sem nome)", "(untitled card)": "(cartão sem título)", "({count} selected)": "({count} selecionados)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Tarefa não encontrada", + "The task does not exist, or it is not yours to see.": "A tarefa não existe ou não tem permissão para a ver.", + "Details": "Detalhes", + "Due": "Prazo", + "Assignee": "Atribuído a", + "Requested by": "Solicitado por", + "Outcome": "Resultado", + "Ready to claim": "Pronto a reclamar", + "In progress": "Em curso", + "Terminated": "Terminada", + "Normal": "Normal", + "Urgent": "Urgente", + "Overdue by 1 day": "Atrasada 1 dia", + "Overdue by {days} days": "Atrasada {days} dias", + "Due today": "Prazo hoje", + "Due tomorrow": "Prazo amanhã", + "Due in {days} days": "Prazo em {days} dias", + "Tasks": "Tarefas", + "Task": "Tarefa", + "Unassigned, open for claiming": "Não atribuída, aberta a reclamação", + "Claim": "Reclamar", + "Complete": "Concluir", + "Complete: {outcome}": "Concluir: {outcome}", + "Could not load the task": "Não foi possível carregar a tarefa", + "The task refused that action": "A tarefa recusou essa ação", + "Store": "Loja", + "Install registers, schemas and flows that other organisations have published.": "Instale registos, esquemas e fluxos publicados por outras organizações.", + "No such run": "Execução não encontrada", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "A execução não existe ou não tem permissão para a ver. Eliminar um fluxo elimina as suas execuções." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/rm.js b/l10n/rm.js index 4cb1605430..b0491614b0 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Collia ina giada in conto social u d’analitica. OpenRegister tegna il token ed al renovescha, uschè che las applicaziuns nal tegnan mai.", + "Connect account": "Colliar il conto", + "Connected accounts": "Contos colliads", + "Could not disconnect this account.": "Betg reussì da discolliar quest conto.", + "Could not load your connected accounts.": "Betg reussì da chargiar tes contos colliads.", + "Could not start the connection. Check the provider and try again.": "Betg reussì da cumenzar la colliaziun. Controllescha il purschider ed emprova anc ina giada.", + "Disconnect": "Discolliar", + "Expired": "Scrudà", + "Expires {date}": "Scroda ils {date}", + "No accounts are connected yet.": "Anc nagin conto n’è collià.", + "Not connected yet": "Anc betg collià", + "Reconnect": "Colliar da nov", + "Relink needed": "Ina nova colliaziun è necessaria", + "Server address": "Adressa dal server", + "Load example data?": "Chargiar datas d’exempel?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Las datas d’exempel emplenischan las glistas, las paginas da detagls e las tablas da controlla, uschè che ti vesas immediatamain l’applicaziun funcziunar. Tscherna \"Nagina\" sin ina installaziun da producziun.", + "Load the example data": "Chargiar las datas d’exempel", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Chargia quai che ti has tschernì. Igl èn cleramain datas d’exempel, l’acziun po vegnir repetida senza ristg e ti pos las stizzar suenter.", + "None, I will set this up myself": "Nagina, jau fatsch quai sez", + "Nothing is imported. You start with an empty app and add your own data.": "Nagut vegn importà. Ti cumenzas cun ina applicaziun vida ed agiunthas tias atgnas datas.", + "Example data": "Datas d’exempel", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valurs d’exempel per mintga schema che questa applicaziun porta cun sai, generadas dals schemas sezs. Ellas mussan las glistas, las paginas da detagls e las tablas da controlla en funcziun enstagl da raquintar ina istorgia. Repetibla senza ristg e stizzabla suenter.", + "from amount {amount}": "a partir da l'import {amount}", + "Gated transition: {transition}": "Transiziun surveglianta: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Mintga chadaina deklarada avra ina sequenza ordinada da incumbensas cur che la transiziun vegn empruvada. Ils approvaders decidan en lur chascha d'incumbensas, betg qua.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklerai x-openregister-approval-chains sin questa schema per che ina transiziun dal ciclus da vita dovria in'approvaziun.", + "Approval sequences": "Sequenzas d'approvaziun", "%n entries have no hash yet": "%n endataziuns n'han anc nagin hash", "%n entry has no hash yet": "%n endataziun n'ha anc nagin hash", "(Dolphin required)": "(Dolphin necessário)", + "No approval chains declared": "Naginas chadainas d'approvaziun deklaradas", "(no subject)": "(nagin object)", "(no title)": "(nagin titel)", + "Role {role}": "Rolla {role}", + "The transition is applied automatically once every step approves.": "La transiziun vegn applitgada automaticamain uschespert che mintga pass è approvà.", + "The requester may not decide their own approval.": "Il petent na dastga betg decider davart sia atgna approvaziun.", + "Tier routing on field: {field}": "Rutar a stgalims tenor il champ: {field}", "(unnamed)": "(nundenominà)", "(untitled card)": "(charta senza titel)", "({count} selected)": "({count} selecionados)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Nagina tala incumbensa", + "The task does not exist, or it is not yours to see.": "L'incumbensa n'exista betg, u Vus na dastgais betg la vesair.", + "Details": "Detagls", + "Due": "Termin", + "Assignee": "Attribuì a", + "Requested by": "Dumandà da", + "Outcome": "Resultat", + "Ready to claim": "Pront per surpigliar", + "In progress": "En lavur", + "Terminated": "Terminà", + "Normal": "Normal", + "Urgent": "Urgent", + "Overdue by 1 day": "1 di en retard", + "Overdue by {days} days": "{days} dis en retard", + "Due today": "Termin oz", + "Due tomorrow": "Termin damaun", + "Due in {days} days": "Termin en {days} dis", + "Tasks": "Incumbensas", + "Task": "Incumbensa", + "Unassigned, open for claiming": "Betg attribuì, avert per surpigliar", + "Claim": "Surpigliar", + "Complete": "Terminar", + "Complete: {outcome}": "Terminar: {outcome}", + "Could not load the task": "L'incumbensa n'ha betg pudì vegnir chargiada", + "The task refused that action": "L'incumbensa ha refusà questa acziun", + "Store": "Butia", + "Install registers, schemas and flows that other organisations have published.": "Installescha registers, schemas e process ch'autras organisaziuns han publitgà.", + "No such run": "Nagina tala execuziun", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L'execuziun n'exista betg, u Vus na dastgais betg la vesair. Sche Vus stizzais in flux, vegnan er sias execuziuns stizzadas." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 7179c58875..e09afa8ec8 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Collia ina giada in conto social u d’analitica. OpenRegister tegna il token ed al renovescha, uschè che las applicaziuns nal tegnan mai.", + "Connect account": "Colliar il conto", + "Connected accounts": "Contos colliads", + "Could not disconnect this account.": "Betg reussì da discolliar quest conto.", + "Could not load your connected accounts.": "Betg reussì da chargiar tes contos colliads.", + "Could not start the connection. Check the provider and try again.": "Betg reussì da cumenzar la colliaziun. Controllescha il purschider ed emprova anc ina giada.", + "Disconnect": "Discolliar", + "Expired": "Scrudà", + "Expires {date}": "Scroda ils {date}", + "No accounts are connected yet.": "Anc nagin conto n’è collià.", + "Not connected yet": "Anc betg collià", + "Reconnect": "Colliar da nov", + "Relink needed": "Ina nova colliaziun è necessaria", + "Server address": "Adressa dal server", + "Load example data?": "Chargiar datas d’exempel?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Las datas d’exempel emplenischan las glistas, las paginas da detagls e las tablas da controlla, uschè che ti vesas immediatamain l’applicaziun funcziunar. Tscherna \"Nagina\" sin ina installaziun da producziun.", + "Load the example data": "Chargiar las datas d’exempel", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Chargia quai che ti has tschernì. Igl èn cleramain datas d’exempel, l’acziun po vegnir repetida senza ristg e ti pos las stizzar suenter.", + "None, I will set this up myself": "Nagina, jau fatsch quai sez", + "Nothing is imported. You start with an empty app and add your own data.": "Nagut vegn importà. Ti cumenzas cun ina applicaziun vida ed agiunthas tias atgnas datas.", + "Example data": "Datas d’exempel", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valurs d’exempel per mintga schema che questa applicaziun porta cun sai, generadas dals schemas sezs. Ellas mussan las glistas, las paginas da detagls e las tablas da controlla en funcziun enstagl da raquintar ina istorgia. Repetibla senza ristg e stizzabla suenter.", + "from amount {amount}": "a partir da l'import {amount}", + "Gated transition: {transition}": "Transiziun surveglianta: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Mintga chadaina deklarada avra ina sequenza ordinada da incumbensas cur che la transiziun vegn empruvada. Ils approvaders decidan en lur chascha d'incumbensas, betg qua.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklerai x-openregister-approval-chains sin questa schema per che ina transiziun dal ciclus da vita dovria in'approvaziun.", + "Approval sequences": "Sequenzas d'approvaziun", "%n entries have no hash yet": "%n endataziuns n'han anc nagin hash", "%n entry has no hash yet": "%n endataziun n'ha anc nagin hash", "(Dolphin required)": "(Dolphin necessário)", + "No approval chains declared": "Naginas chadainas d'approvaziun deklaradas", "(no subject)": "(nagin object)", "(no title)": "(nagin titel)", + "Role {role}": "Rolla {role}", + "The transition is applied automatically once every step approves.": "La transiziun vegn applitgada automaticamain uschespert che mintga pass è approvà.", + "The requester may not decide their own approval.": "Il petent na dastga betg decider davart sia atgna approvaziun.", + "Tier routing on field: {field}": "Rutar a stgalims tenor il champ: {field}", "(unnamed)": "(nundenominà)", "(untitled card)": "(charta senza titel)", "({count} selected)": "({count} selecionados)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Nagina tala incumbensa", + "The task does not exist, or it is not yours to see.": "L'incumbensa n'exista betg, u Vus na dastgais betg la vesair.", + "Details": "Detagls", + "Due": "Termin", + "Assignee": "Attribuì a", + "Requested by": "Dumandà da", + "Outcome": "Resultat", + "Ready to claim": "Pront per surpigliar", + "In progress": "En lavur", + "Terminated": "Terminà", + "Normal": "Normal", + "Urgent": "Urgent", + "Overdue by 1 day": "1 di en retard", + "Overdue by {days} days": "{days} dis en retard", + "Due today": "Termin oz", + "Due tomorrow": "Termin damaun", + "Due in {days} days": "Termin en {days} dis", + "Tasks": "Incumbensas", + "Task": "Incumbensa", + "Unassigned, open for claiming": "Betg attribuì, avert per surpigliar", + "Claim": "Surpigliar", + "Complete": "Terminar", + "Complete: {outcome}": "Terminar: {outcome}", + "Could not load the task": "L'incumbensa n'ha betg pudì vegnir chargiada", + "The task refused that action": "L'incumbensa ha refusà questa acziun", + "Store": "Butia", + "Install registers, schemas and flows that other organisations have published.": "Installescha registers, schemas e process ch'autras organisaziuns han publitgà.", + "No such run": "Nagina tala execuziun", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "L'execuziun n'exista betg, u Vus na dastgais betg la vesair. Sche Vus stizzais in flux, vegnan er sias execuziuns stizzadas." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/ro.js b/l10n/ro.js index 9c7193534d..3432e3176b 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Conectează o dată un cont social sau de analiză. OpenRegister păstrează jetonul și îl reînnoiește, astfel încât aplicațiile să nu îl dețină niciodată.", + "Connect account": "Conectează contul", + "Connected accounts": "Conturi conectate", + "Could not disconnect this account.": "Acest cont nu a putut fi deconectat.", + "Could not load your connected accounts.": "Conturile tale conectate nu au putut fi încărcate.", + "Could not start the connection. Check the provider and try again.": "Conexiunea nu a putut fi pornită. Verifică furnizorul și încearcă din nou.", + "Disconnect": "Deconectează", + "Expired": "Expirat", + "Expires {date}": "Expiră la {date}", + "No accounts are connected yet.": "Încă nu este conectat niciun cont.", + "Not connected yet": "Încă neconectat", + "Reconnect": "Reconectează", + "Relink needed": "Este necesară o nouă legătură", + "Server address": "Adresa serverului", + "Load example data?": "Se încarcă datele de exemplu?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Datele de exemplu umplu listele, paginile de detaliu și tablourile de bord, ca să vedeți imediat aplicația funcționând. Alegeți \"Niciunul\" pe o instalare de producție.", + "Load the example data": "Încarcă datele de exemplu", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Încarcă ceea ce ați ales. Sunt vizibil date de exemplu, operațiunea se poate repeta fără risc, iar apoi le puteți șterge.", + "None, I will set this up myself": "Niciunul, configurez singur", + "Nothing is imported. You start with an empty app and add your own data.": "Nu se importă nimic. Începeți cu o aplicație goală și adăugați propriile date.", + "Example data": "Date de exemplu", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valori de exemplu pentru fiecare schemă pe care o aduce această aplicație, generate din schemele înseși. Arată listele, paginile de detaliu și tablourile de bord în funcțiune, în loc să spună o poveste. Se poate repeta fără risc și șterge apoi.", + "from amount {amount}": "de la suma {amount}", + "Gated transition: {transition}": "Tranziție supravegheată: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Fiecare lanț declarat deschide o secvență ordonată de sarcini când tranziția este încercată. Aprobatorii decid în inboxul lor de sarcini, nu aici.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declarați x-openregister-approval-chains pe această schemă pentru ca o tranziție a ciclului de viață să necesite aprobare.", + "Approval sequences": "Secvențe de aprobare", "%n entries have no hash yet": "%n intrări nu au încă un hash", "%n entry has no hash yet": "%n intrare nu are încă un hash", "(Dolphin required)": "(Dolphin necesar)", + "No approval chains declared": "Nu sunt declarate lanțuri de aprobare", "(no subject)": "(fără subiect)", "(no title)": "(fără titlu)", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "Tranziția se aplică automat de îndată ce fiecare pas este aprobat.", + "The requester may not decide their own approval.": "Solicitantul nu poate decide asupra propriei aprobări.", + "Tier routing on field: {field}": "Rutare pe trepte după câmpul: {field}", "(unnamed)": "(fără nume)", "(untitled card)": "(card fără titlu)", "({count} selected)": "({count} selectate)", @@ -2738,7 +2770,88 @@ 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ă.", + "No such task": "Nu există o astfel de sarcină", + "The task does not exist, or it is not yours to see.": "Sarcina nu există sau nu aveți dreptul să o vedeți.", + "Details": "Detalii", + "Due": "Scadență", + "Assignee": "Atribuit lui", + "Requested by": "Solicitat de", + "Outcome": "Rezultat", + "Ready to claim": "Gata de preluare", + "In progress": "În curs", + "Terminated": "Încheiată", + "Normal": "Normal", + "Urgent": "Urgent", + "Overdue by 1 day": "Întârziată cu 1 zi", + "Overdue by {days} days": "Întârziată cu {days} zile", + "Due today": "Scadentă azi", + "Due tomorrow": "Scadentă mâine", + "Due in {days} days": "Scadentă în {days} zile", + "Tasks": "Sarcini", + "Task": "Sarcină", + "Unassigned, open for claiming": "Neatribuită, deschisă preluării", + "Claim": "Preia", + "Complete": "Finalizează", + "Complete: {outcome}": "Finalizează: {outcome}", + "Could not load the task": "Sarcina nu a putut fi încărcată", + "The task refused that action": "Sarcina a refuzat această acțiune", + "Store": "Magazin", + "Install registers, schemas and flows that other organisations have published.": "Instalați registre, scheme și fluxuri publicate de alte organizații.", + "No such run": "Nu există o astfel de execuție", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Execuția nu există sau nu aveți dreptul să o vedeți. Ștergerea unui flux îi șterge și execuțiile." }, "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..7d1c7cbeb9 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Conectează o dată un cont social sau de analiză. OpenRegister păstrează jetonul și îl reînnoiește, astfel încât aplicațiile să nu îl dețină niciodată.", + "Connect account": "Conectează contul", + "Connected accounts": "Conturi conectate", + "Could not disconnect this account.": "Acest cont nu a putut fi deconectat.", + "Could not load your connected accounts.": "Conturile tale conectate nu au putut fi încărcate.", + "Could not start the connection. Check the provider and try again.": "Conexiunea nu a putut fi pornită. Verifică furnizorul și încearcă din nou.", + "Disconnect": "Deconectează", + "Expired": "Expirat", + "Expires {date}": "Expiră la {date}", + "No accounts are connected yet.": "Încă nu este conectat niciun cont.", + "Not connected yet": "Încă neconectat", + "Reconnect": "Reconectează", + "Relink needed": "Este necesară o nouă legătură", + "Server address": "Adresa serverului", + "Load example data?": "Se încarcă datele de exemplu?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Datele de exemplu umplu listele, paginile de detaliu și tablourile de bord, ca să vedeți imediat aplicația funcționând. Alegeți \"Niciunul\" pe o instalare de producție.", + "Load the example data": "Încarcă datele de exemplu", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Încarcă ceea ce ați ales. Sunt vizibil date de exemplu, operațiunea se poate repeta fără risc, iar apoi le puteți șterge.", + "None, I will set this up myself": "Niciunul, configurez singur", + "Nothing is imported. You start with an empty app and add your own data.": "Nu se importă nimic. Începeți cu o aplicație goală și adăugați propriile date.", + "Example data": "Date de exemplu", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Valori de exemplu pentru fiecare schemă pe care o aduce această aplicație, generate din schemele înseși. Arată listele, paginile de detaliu și tablourile de bord în funcțiune, în loc să spună o poveste. Se poate repeta fără risc și șterge apoi.", + "from amount {amount}": "de la suma {amount}", + "Gated transition: {transition}": "Tranziție supravegheată: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Fiecare lanț declarat deschide o secvență ordonată de sarcini când tranziția este încercată. Aprobatorii decid în inboxul lor de sarcini, nu aici.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Declarați x-openregister-approval-chains pe această schemă pentru ca o tranziție a ciclului de viață să necesite aprobare.", + "Approval sequences": "Secvențe de aprobare", "%n entries have no hash yet": "%n intrări nu au încă un hash", "%n entry has no hash yet": "%n intrare nu are încă un hash", "(Dolphin required)": "(Dolphin necesar)", + "No approval chains declared": "Nu sunt declarate lanțuri de aprobare", "(no subject)": "(fără subiect)", "(no title)": "(fără titlu)", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "Tranziția se aplică automat de îndată ce fiecare pas este aprobat.", + "The requester may not decide their own approval.": "Solicitantul nu poate decide asupra propriei aprobări.", + "Tier routing on field: {field}": "Rutare pe trepte după câmpul: {field}", "(unnamed)": "(fără nume)", "(untitled card)": "(card fără titlu)", "({count} selected)": "({count} selectate)", @@ -2801,7 +2833,88 @@ "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ă.", + "No such task": "Nu există o astfel de sarcină", + "The task does not exist, or it is not yours to see.": "Sarcina nu există sau nu aveți dreptul să o vedeți.", + "Details": "Detalii", + "Due": "Scadență", + "Assignee": "Atribuit lui", + "Requested by": "Solicitat de", + "Outcome": "Rezultat", + "Ready to claim": "Gata de preluare", + "In progress": "În curs", + "Terminated": "Încheiată", + "Normal": "Normal", + "Urgent": "Urgent", + "Overdue by 1 day": "Întârziată cu 1 zi", + "Overdue by {days} days": "Întârziată cu {days} zile", + "Due today": "Scadentă azi", + "Due tomorrow": "Scadentă mâine", + "Due in {days} days": "Scadentă în {days} zile", + "Tasks": "Sarcini", + "Task": "Sarcină", + "Unassigned, open for claiming": "Neatribuită, deschisă preluării", + "Claim": "Preia", + "Complete": "Finalizează", + "Complete: {outcome}": "Finalizează: {outcome}", + "Could not load the task": "Sarcina nu a putut fi încărcată", + "The task refused that action": "Sarcina a refuzat această acțiune", + "Store": "Magazin", + "Install registers, schemas and flows that other organisations have published.": "Instalați registre, scheme și fluxuri publicate de alte organizații.", + "No such run": "Nu există o astfel de execuție", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Execuția nu există sau nu aveți dreptul să o vedeți. Ștergerea unui flux îi șterge și execuțiile." }, "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..a8a3a3e442 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Подключите аккаунт соцсети или аналитики один раз. OpenRegister хранит токен и обновляет его, поэтому приложения никогда его не держат.", + "Connect account": "Подключить аккаунт", + "Connected accounts": "Подключённые аккаунты", + "Could not disconnect this account.": "Не удалось отключить этот аккаунт.", + "Could not load your connected accounts.": "Не удалось загрузить ваши подключённые аккаунты.", + "Could not start the connection. Check the provider and try again.": "Не удалось начать подключение. Проверьте поставщика и попробуйте снова.", + "Disconnect": "Отключить", + "Expired": "Истёк", + "Expires {date}": "Истекает {date}", + "No accounts are connected yet.": "Пока не подключён ни один аккаунт.", + "Not connected yet": "Ещё не подключён", + "Reconnect": "Подключить заново", + "Relink needed": "Нужна повторная привязка", + "Server address": "Адрес сервера", + "Load example data?": "Загрузить примеры данных?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Примеры данных заполняют списки, страницы подробностей и панели, чтобы вы сразу увидели приложение в работе. На рабочей установке выберите \"Нет\".", + "Load the example data": "Загрузить примеры данных", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Загружает то, что вы выбрали. Это явно примеры данных, действие можно безопасно повторить, а потом их можно удалить.", + "None, I will set this up myself": "Нет, я настрою это сам", + "Nothing is imported. You start with an empty app and add your own data.": "Ничего не импортируется. Вы начинаете с пустого приложения и добавляете свои данные.", + "Example data": "Примеры данных", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Примеры значений для каждой схемы, которую поставляет это приложение, созданные из самих схем. Они показывают списки, страницы подробностей и панели в работе, а не рассказывают историю. Безопасно повторять и потом удалить.", + "from amount {amount}": "от суммы {amount}", + "Gated transition: {transition}": "Контролируемый переход: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Каждая объявленная цепочка открывает упорядоченную последовательность задач при попытке перехода. Согласующие решают в своём ящике задач, а не здесь.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Объявите x-openregister-approval-chains в этой схеме, чтобы переход жизненного цикла требовал согласования.", + "Approval sequences": "Последовательности согласования", "%n entries have no hash yet": "%n записей ещё не имеют хеша", "%n entry has no hash yet": "%n запись ещё не имеет хеша", "(Dolphin required)": "(требуется Dolphin)", + "No approval chains declared": "Цепочки согласования не объявлены", "(no subject)": "(без темы)", "(no title)": "(без названия)", + "Role {role}": "Роль {role}", + "The transition is applied automatically once every step approves.": "Переход применяется автоматически, как только каждый шаг согласован.", + "The requester may not decide their own approval.": "Заявитель не может решать по собственному согласованию.", + "Tier routing on field: {field}": "Ступенчатая маршрутизация по полю: {field}", "(unnamed)": "(без имени)", "(untitled card)": "(карточка без названия)", "({count} selected)": "({count} выбрано)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Нет такой задачи", + "The task does not exist, or it is not yours to see.": "Задача не существует, или у вас нет права её видеть.", + "Details": "Подробности", + "Due": "Срок", + "Assignee": "Исполнитель", + "Requested by": "Запросил", + "Outcome": "Результат", + "Ready to claim": "Готово к взятию", + "In progress": "В работе", + "Terminated": "Прекращено", + "Normal": "Обычный", + "Urgent": "Срочный", + "Overdue by 1 day": "Просрочено на 1 день", + "Overdue by {days} days": "Просрочено на {days} дн.", + "Due today": "Срок сегодня", + "Due tomorrow": "Срок завтра", + "Due in {days} days": "Срок через {days} дн.", + "Tasks": "Задачи", + "Task": "Задача", + "Unassigned, open for claiming": "Не назначено, можно взять", + "Claim": "Взять", + "Complete": "Завершить", + "Complete: {outcome}": "Завершить: {outcome}", + "Could not load the task": "Не удалось загрузить задачу", + "The task refused that action": "Задача отклонила это действие", + "Store": "Магазин", + "Install registers, schemas and flows that other organisations have published.": "Установите реестры, схемы и потоки, опубликованные другими организациями.", + "No such run": "Нет такого запуска", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Запуск не существует, или у вас нет права его видеть. Удаление потока удаляет и его запуски." }, "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..81fa57133d 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Подключите аккаунт соцсети или аналитики один раз. OpenRegister хранит токен и обновляет его, поэтому приложения никогда его не держат.", + "Connect account": "Подключить аккаунт", + "Connected accounts": "Подключённые аккаунты", + "Could not disconnect this account.": "Не удалось отключить этот аккаунт.", + "Could not load your connected accounts.": "Не удалось загрузить ваши подключённые аккаунты.", + "Could not start the connection. Check the provider and try again.": "Не удалось начать подключение. Проверьте поставщика и попробуйте снова.", + "Disconnect": "Отключить", + "Expired": "Истёк", + "Expires {date}": "Истекает {date}", + "No accounts are connected yet.": "Пока не подключён ни один аккаунт.", + "Not connected yet": "Ещё не подключён", + "Reconnect": "Подключить заново", + "Relink needed": "Нужна повторная привязка", + "Server address": "Адрес сервера", + "Load example data?": "Загрузить примеры данных?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Примеры данных заполняют списки, страницы подробностей и панели, чтобы вы сразу увидели приложение в работе. На рабочей установке выберите \"Нет\".", + "Load the example data": "Загрузить примеры данных", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Загружает то, что вы выбрали. Это явно примеры данных, действие можно безопасно повторить, а потом их можно удалить.", + "None, I will set this up myself": "Нет, я настрою это сам", + "Nothing is imported. You start with an empty app and add your own data.": "Ничего не импортируется. Вы начинаете с пустого приложения и добавляете свои данные.", + "Example data": "Примеры данных", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Примеры значений для каждой схемы, которую поставляет это приложение, созданные из самих схем. Они показывают списки, страницы подробностей и панели в работе, а не рассказывают историю. Безопасно повторять и потом удалить.", + "from amount {amount}": "от суммы {amount}", + "Gated transition: {transition}": "Контролируемый переход: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Каждая объявленная цепочка открывает упорядоченную последовательность задач при попытке перехода. Согласующие решают в своём ящике задач, а не здесь.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Объявите x-openregister-approval-chains в этой схеме, чтобы переход жизненного цикла требовал согласования.", + "Approval sequences": "Последовательности согласования", "%n entries have no hash yet": "%n записей ещё не имеют хеша", "%n entry has no hash yet": "%n запись ещё не имеет хеша", "(Dolphin required)": "(требуется Dolphin)", + "No approval chains declared": "Цепочки согласования не объявлены", "(no subject)": "(без темы)", "(no title)": "(без названия)", + "Role {role}": "Роль {role}", + "The transition is applied automatically once every step approves.": "Переход применяется автоматически, как только каждый шаг согласован.", + "The requester may not decide their own approval.": "Заявитель не может решать по собственному согласованию.", + "Tier routing on field: {field}": "Ступенчатая маршрутизация по полю: {field}", "(unnamed)": "(без имени)", "(untitled card)": "(карточка без названия)", "({count} selected)": "({count} выбрано)", @@ -2817,7 +2849,88 @@ "Успешно восстановлено {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.", + "No such task": "Нет такой задачи", + "The task does not exist, or it is not yours to see.": "Задача не существует, или у вас нет права её видеть.", + "Details": "Подробности", + "Due": "Срок", + "Assignee": "Исполнитель", + "Requested by": "Запросил", + "Outcome": "Результат", + "Ready to claim": "Готово к взятию", + "In progress": "В работе", + "Terminated": "Прекращено", + "Normal": "Обычный", + "Urgent": "Срочный", + "Overdue by 1 day": "Просрочено на 1 день", + "Overdue by {days} days": "Просрочено на {days} дн.", + "Due today": "Срок сегодня", + "Due tomorrow": "Срок завтра", + "Due in {days} days": "Срок через {days} дн.", + "Tasks": "Задачи", + "Task": "Задача", + "Unassigned, open for claiming": "Не назначено, можно взять", + "Claim": "Взять", + "Complete": "Завершить", + "Complete: {outcome}": "Завершить: {outcome}", + "Could not load the task": "Не удалось загрузить задачу", + "The task refused that action": "Задача отклонила это действие", + "Store": "Магазин", + "Install registers, schemas and flows that other organisations have published.": "Установите реестры, схемы и потоки, опубликованные другими организациями.", + "No such run": "Нет такого запуска", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Запуск не существует, или у вас нет права его видеть. Удаление потока удаляет и его запуски." }, "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..7bda636b8e 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Pripojte účet sociálnej siete alebo analytiky raz. OpenRegister uchová token a obnovuje ho, takže ho aplikácie nikdy nedržia.", + "Connect account": "Pripojiť účet", + "Connected accounts": "Pripojené účty", + "Could not disconnect this account.": "Tento účet sa nepodarilo odpojiť.", + "Could not load your connected accounts.": "Vaše pripojené účty sa nepodarilo načítať.", + "Could not start the connection. Check the provider and try again.": "Pripojenie sa nepodarilo spustiť. Skontrolujte poskytovateľa a skúste to znova.", + "Disconnect": "Odpojiť", + "Expired": "Vypršalo", + "Expires {date}": "Vyprší {date}", + "No accounts are connected yet.": "Zatiaľ nie sú pripojené žiadne účty.", + "Not connected yet": "Zatiaľ nepripojené", + "Reconnect": "Pripojiť znova", + "Relink needed": "Je potrebné znova prepojiť", + "Server address": "Adresa servera", + "Load example data?": "Načítať ukážkové údaje?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Ukážkové údaje naplnia zoznamy, stránky s podrobnosťami a nástenky, takže aplikáciu uvidíte hneď v prevádzke. Na produkčnej inštalácii zvoľte \"Žiadne\".", + "Load the example data": "Načítať ukážkové údaje", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Načíta to, čo ste vybrali. Sú to zjavne ukážkové údaje, akciu možno bezpečne zopakovať a potom ich môžete zmazať.", + "None, I will set this up myself": "Žiadne, nastavím si to sám", + "Nothing is imported. You start with an empty app and add your own data.": "Nič sa neimportuje. Začínate s prázdnou aplikáciou a pridáte vlastné údaje.", + "Example data": "Ukážkové údaje", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Ukážkové hodnoty pre každú schému, ktorú táto aplikácia prináša, vygenerované zo samotných schém. Ukazujú zoznamy, stránky s podrobnosťami a nástenky v prevádzke namiesto rozprávania príbehu. Možno bezpečne zopakovať a potom zmazať.", + "from amount {amount}": "od sumy {amount}", + "Gated transition: {transition}": "Strážený prechod: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Každý deklarovaný reťazec otvorí usporiadanú sekvenciu úloh pri pokuse o prechod. Schvaľovatelia rozhodujú vo svojej schránke úloh, nie tu.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarujte x-openregister-approval-chains na tejto schéme, aby prechod životného cyklu podliehal schváleniu.", + "Approval sequences": "Schvaľovacie sekvencie", "%n entries have no hash yet": "%n záznamov zatiaľ nemá odtlačok", "%n entry has no hash yet": "%n záznam zatiaľ nemá odtlačok", "(Dolphin required)": "(vyžaduje sa Dolphin)", + "No approval chains declared": "Nie sú deklarované žiadne schvaľovacie reťazce", "(no subject)": "(bez predmetu)", "(no title)": "(bez názvu)", + "Role {role}": "Rola {role}", + "The transition is applied automatically once every step approves.": "Prechod sa použije automaticky, len čo je každý krok schválený.", + "The requester may not decide their own approval.": "Žiadateľ nesmie rozhodovať o vlastnom schválení.", + "Tier routing on field: {field}": "Smerovanie podľa pásiem cez pole: {field}", "(unnamed)": "(bez názvu)", "(untitled card)": "(karta bez názvu)", "({count} selected)": "(vybrané: {count})", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Žiadna taká úloha", + "The task does not exist, or it is not yours to see.": "Úloha neexistuje, alebo ju nesmiete vidieť.", + "Details": "Podrobnosti", + "Due": "Termín", + "Assignee": "Priradené", + "Requested by": "Vyžiadal", + "Outcome": "Výsledok", + "Ready to claim": "Pripravené na prevzatie", + "In progress": "Prebieha", + "Terminated": "Ukončené", + "Normal": "Normálna", + "Urgent": "Naliehavé", + "Overdue by 1 day": "1 deň po termíne", + "Overdue by {days} days": "{days} dní po termíne", + "Due today": "Termín dnes", + "Due tomorrow": "Termín zajtra", + "Due in {days} days": "Termín o {days} dní", + "Tasks": "Úlohy", + "Task": "Úloha", + "Unassigned, open for claiming": "Nepriradené, možno prevziať", + "Claim": "Prevziať", + "Complete": "Dokončiť", + "Complete: {outcome}": "Dokončiť: {outcome}", + "Could not load the task": "Úlohu sa nepodarilo načítať", + "The task refused that action": "Úloha túto akciu odmietla", + "Store": "Obchod", + "Install registers, schemas and flows that other organisations have published.": "Nainštalujte registre, schémy a toky zverejnené inými organizáciami.", + "No such run": "Žiadny taký beh", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Beh neexistuje, alebo ho nesmiete vidieť. Vymazaním toku sa vymažú aj jeho behy." }, "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..7f61742fec 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Pripojte účet sociálnej siete alebo analytiky raz. OpenRegister uchová token a obnovuje ho, takže ho aplikácie nikdy nedržia.", + "Connect account": "Pripojiť účet", + "Connected accounts": "Pripojené účty", + "Could not disconnect this account.": "Tento účet sa nepodarilo odpojiť.", + "Could not load your connected accounts.": "Vaše pripojené účty sa nepodarilo načítať.", + "Could not start the connection. Check the provider and try again.": "Pripojenie sa nepodarilo spustiť. Skontrolujte poskytovateľa a skúste to znova.", + "Disconnect": "Odpojiť", + "Expired": "Vypršalo", + "Expires {date}": "Vyprší {date}", + "No accounts are connected yet.": "Zatiaľ nie sú pripojené žiadne účty.", + "Not connected yet": "Zatiaľ nepripojené", + "Reconnect": "Pripojiť znova", + "Relink needed": "Je potrebné znova prepojiť", + "Server address": "Adresa servera", + "Load example data?": "Načítať ukážkové údaje?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Ukážkové údaje naplnia zoznamy, stránky s podrobnosťami a nástenky, takže aplikáciu uvidíte hneď v prevádzke. Na produkčnej inštalácii zvoľte \"Žiadne\".", + "Load the example data": "Načítať ukážkové údaje", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Načíta to, čo ste vybrali. Sú to zjavne ukážkové údaje, akciu možno bezpečne zopakovať a potom ich môžete zmazať.", + "None, I will set this up myself": "Žiadne, nastavím si to sám", + "Nothing is imported. You start with an empty app and add your own data.": "Nič sa neimportuje. Začínate s prázdnou aplikáciou a pridáte vlastné údaje.", + "Example data": "Ukážkové údaje", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Ukážkové hodnoty pre každú schému, ktorú táto aplikácia prináša, vygenerované zo samotných schém. Ukazujú zoznamy, stránky s podrobnosťami a nástenky v prevádzke namiesto rozprávania príbehu. Možno bezpečne zopakovať a potom zmazať.", + "from amount {amount}": "od sumy {amount}", + "Gated transition: {transition}": "Strážený prechod: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Každý deklarovaný reťazec otvorí usporiadanú sekvenciu úloh pri pokuse o prechod. Schvaľovatelia rozhodujú vo svojej schránke úloh, nie tu.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarujte x-openregister-approval-chains na tejto schéme, aby prechod životného cyklu podliehal schváleniu.", + "Approval sequences": "Schvaľovacie sekvencie", "%n entries have no hash yet": "%n záznamov zatiaľ nemá odtlačok", "%n entry has no hash yet": "%n záznam zatiaľ nemá odtlačok", "(Dolphin required)": "(vyžaduje sa Dolphin)", + "No approval chains declared": "Nie sú deklarované žiadne schvaľovacie reťazce", "(no subject)": "(bez predmetu)", "(no title)": "(bez názvu)", + "Role {role}": "Rola {role}", + "The transition is applied automatically once every step approves.": "Prechod sa použije automaticky, len čo je každý krok schválený.", + "The requester may not decide their own approval.": "Žiadateľ nesmie rozhodovať o vlastnom schválení.", + "Tier routing on field: {field}": "Smerovanie podľa pásiem cez pole: {field}", "(unnamed)": "(bez názvu)", "(untitled card)": "(karta bez názvu)", "({count} selected)": "(vybrané: {count})", @@ -2817,7 +2849,88 @@ "Ú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.", + "No such task": "Žiadna taká úloha", + "The task does not exist, or it is not yours to see.": "Úloha neexistuje, alebo ju nesmiete vidieť.", + "Details": "Podrobnosti", + "Due": "Termín", + "Assignee": "Priradené", + "Requested by": "Vyžiadal", + "Outcome": "Výsledok", + "Ready to claim": "Pripravené na prevzatie", + "In progress": "Prebieha", + "Terminated": "Ukončené", + "Normal": "Normálna", + "Urgent": "Naliehavé", + "Overdue by 1 day": "1 deň po termíne", + "Overdue by {days} days": "{days} dní po termíne", + "Due today": "Termín dnes", + "Due tomorrow": "Termín zajtra", + "Due in {days} days": "Termín o {days} dní", + "Tasks": "Úlohy", + "Task": "Úloha", + "Unassigned, open for claiming": "Nepriradené, možno prevziať", + "Claim": "Prevziať", + "Complete": "Dokončiť", + "Complete: {outcome}": "Dokončiť: {outcome}", + "Could not load the task": "Úlohu sa nepodarilo načítať", + "The task refused that action": "Úloha túto akciu odmietla", + "Store": "Obchod", + "Install registers, schemas and flows that other organisations have published.": "Nainštalujte registre, schémy a toky zverejnené inými organizáciami.", + "No such run": "Žiadny taký beh", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Beh neexistuje, alebo ho nesmiete vidieť. Vymazaním toku sa vymažú aj jeho behy." }, "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..a10fb31a60 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Poveži družbeni ali analitični račun enkrat. OpenRegister hrani žeton in ga obnavlja, tako da ga aplikacije nikoli ne hranijo.", + "Connect account": "Poveži račun", + "Connected accounts": "Povezani računi", + "Could not disconnect this account.": "Tega računa ni bilo mogoče odklopiti.", + "Could not load your connected accounts.": "Vaših povezanih računov ni bilo mogoče naložiti.", + "Could not start the connection. Check the provider and try again.": "Povezave ni bilo mogoče začeti. Preveri ponudnika in poskusi znova.", + "Disconnect": "Odklopi", + "Expired": "Poteklo", + "Expires {date}": "Poteče {date}", + "No accounts are connected yet.": "Noben račun še ni povezan.", + "Not connected yet": "Še ni povezano", + "Reconnect": "Poveži znova", + "Relink needed": "Potrebna je nova povezava", + "Server address": "Naslov strežnika", + "Load example data?": "Naložim vzorčne podatke?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Vzorčni podatki napolnijo sezname, strani s podrobnostmi in nadzorne plošče, tako da aplikacijo takoj vidite delovati. Pri produkcijski namestitvi izberite \"Brez\".", + "Load the example data": "Naloži vzorčne podatke", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Naloži tisto, kar ste izbrali. To so očitno vzorčni podatki, dejanje je varno ponoviti, pozneje pa jih lahko izbrišete.", + "None, I will set this up myself": "Brez, to bom nastavil sam", + "Nothing is imported. You start with an empty app and add your own data.": "Nič se ne uvozi. Začnete s prazno aplikacijo in dodate svoje podatke.", + "Example data": "Vzorčni podatki", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Vzorčne vrednosti za vsako shemo, ki jo prinaša ta aplikacija, ustvarjene iz shem samih. Prikazujejo sezname, strani s podrobnostmi in nadzorne plošče v delovanju, namesto da bi pripovedovale zgodbo. Varno je ponoviti in pozneje izbrisati.", + "from amount {amount}": "od zneska {amount}", + "Gated transition: {transition}": "Nadzorovan prehod: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Vsaka deklarirana veriga ob poskusu prehoda odpre urejeno zaporedje nalog. Odobritelji odločajo v svojem nabiralniku nalog, ne tukaj.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarirajte x-openregister-approval-chains na tej shemi, da prehod življenjskega cikla zahteva odobritev.", + "Approval sequences": "Zaporedja odobritev", "%n entries have no hash yet": "%n vnosov še nima zgoščene vrednosti", "%n entry has no hash yet": "%n vnos še nima zgoščene vrednosti", "(Dolphin required)": "(Potreben Dolphin)", + "No approval chains declared": "Ni deklariranih verig odobritev", "(no subject)": "(brez zadeve)", "(no title)": "(brez naslova)", + "Role {role}": "Vloga {role}", + "The transition is applied automatically once every step approves.": "Prehod se uporabi samodejno, ko je vsak korak odobren.", + "The requester may not decide their own approval.": "Predlagatelj ne sme odločati o lastni odobritvi.", + "Tier routing on field: {field}": "Stopenjsko usmerjanje po polju: {field}", "(unnamed)": "(brez imena)", "(untitled card)": "(kartica brez naslova)", "({count} selected)": "({count} izbrano)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Ni takšne naloge", + "The task does not exist, or it is not yours to see.": "Naloga ne obstaja ali pa je ne smete videti.", + "Details": "Podrobnosti", + "Due": "Rok", + "Assignee": "Dodeljeno", + "Requested by": "Zahteval", + "Outcome": "Izid", + "Ready to claim": "Pripravljeno za prevzem", + "In progress": "V teku", + "Terminated": "Prekinjeno", + "Normal": "Običajno", + "Urgent": "Nujno", + "Overdue by 1 day": "1 dan čez rok", + "Overdue by {days} days": "{days} dni čez rok", + "Due today": "Rok danes", + "Due tomorrow": "Rok jutri", + "Due in {days} days": "Rok čez {days} dni", + "Tasks": "Naloge", + "Task": "Naloga", + "Unassigned, open for claiming": "Nedodeljeno, odprto za prevzem", + "Claim": "Prevzemi", + "Complete": "Dokončaj", + "Complete: {outcome}": "Dokončaj: {outcome}", + "Could not load the task": "Naloge ni bilo mogoče naložiti", + "The task refused that action": "Naloga je zavrnila to dejanje", + "Store": "Trgovina", + "Install registers, schemas and flows that other organisations have published.": "Namestite registre, sheme in tokove, ki so jih objavile druge organizacije.", + "No such run": "Ni takšnega izvajanja", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Izvajanje ne obstaja ali pa ga ne smete videti. Z brisanjem toka se izbrišejo tudi njegova izvajanja." }, "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..ce78987de2 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Poveži družbeni ali analitični račun enkrat. OpenRegister hrani žeton in ga obnavlja, tako da ga aplikacije nikoli ne hranijo.", + "Connect account": "Poveži račun", + "Connected accounts": "Povezani računi", + "Could not disconnect this account.": "Tega računa ni bilo mogoče odklopiti.", + "Could not load your connected accounts.": "Vaših povezanih računov ni bilo mogoče naložiti.", + "Could not start the connection. Check the provider and try again.": "Povezave ni bilo mogoče začeti. Preveri ponudnika in poskusi znova.", + "Disconnect": "Odklopi", + "Expired": "Poteklo", + "Expires {date}": "Poteče {date}", + "No accounts are connected yet.": "Noben račun še ni povezan.", + "Not connected yet": "Še ni povezano", + "Reconnect": "Poveži znova", + "Relink needed": "Potrebna je nova povezava", + "Server address": "Naslov strežnika", + "Load example data?": "Naložim vzorčne podatke?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Vzorčni podatki napolnijo sezname, strani s podrobnostmi in nadzorne plošče, tako da aplikacijo takoj vidite delovati. Pri produkcijski namestitvi izberite \"Brez\".", + "Load the example data": "Naloži vzorčne podatke", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Naloži tisto, kar ste izbrali. To so očitno vzorčni podatki, dejanje je varno ponoviti, pozneje pa jih lahko izbrišete.", + "None, I will set this up myself": "Brez, to bom nastavil sam", + "Nothing is imported. You start with an empty app and add your own data.": "Nič se ne uvozi. Začnete s prazno aplikacijo in dodate svoje podatke.", + "Example data": "Vzorčni podatki", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Vzorčne vrednosti za vsako shemo, ki jo prinaša ta aplikacija, ustvarjene iz shem samih. Prikazujejo sezname, strani s podrobnostmi in nadzorne plošče v delovanju, namesto da bi pripovedovale zgodbo. Varno je ponoviti in pozneje izbrisati.", + "from amount {amount}": "od zneska {amount}", + "Gated transition: {transition}": "Nadzorovan prehod: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Vsaka deklarirana veriga ob poskusu prehoda odpre urejeno zaporedje nalog. Odobritelji odločajo v svojem nabiralniku nalog, ne tukaj.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarirajte x-openregister-approval-chains na tej shemi, da prehod življenjskega cikla zahteva odobritev.", + "Approval sequences": "Zaporedja odobritev", "%n entries have no hash yet": "%n vnosov še nima zgoščene vrednosti", "%n entry has no hash yet": "%n vnos še nima zgoščene vrednosti", "(Dolphin required)": "(Potreben Dolphin)", + "No approval chains declared": "Ni deklariranih verig odobritev", "(no subject)": "(brez zadeve)", "(no title)": "(brez naslova)", + "Role {role}": "Vloga {role}", + "The transition is applied automatically once every step approves.": "Prehod se uporabi samodejno, ko je vsak korak odobren.", + "The requester may not decide their own approval.": "Predlagatelj ne sme odločati o lastni odobritvi.", + "Tier routing on field: {field}": "Stopenjsko usmerjanje po polju: {field}", "(unnamed)": "(brez imena)", "(untitled card)": "(kartica brez naslova)", "({count} selected)": "({count} izbrano)", @@ -2817,7 +2849,88 @@ "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.", + "No such task": "Ni takšne naloge", + "The task does not exist, or it is not yours to see.": "Naloga ne obstaja ali pa je ne smete videti.", + "Details": "Podrobnosti", + "Due": "Rok", + "Assignee": "Dodeljeno", + "Requested by": "Zahteval", + "Outcome": "Izid", + "Ready to claim": "Pripravljeno za prevzem", + "In progress": "V teku", + "Terminated": "Prekinjeno", + "Normal": "Običajno", + "Urgent": "Nujno", + "Overdue by 1 day": "1 dan čez rok", + "Overdue by {days} days": "{days} dni čez rok", + "Due today": "Rok danes", + "Due tomorrow": "Rok jutri", + "Due in {days} days": "Rok čez {days} dni", + "Tasks": "Naloge", + "Task": "Naloga", + "Unassigned, open for claiming": "Nedodeljeno, odprto za prevzem", + "Claim": "Prevzemi", + "Complete": "Dokončaj", + "Complete: {outcome}": "Dokončaj: {outcome}", + "Could not load the task": "Naloge ni bilo mogoče naložiti", + "The task refused that action": "Naloga je zavrnila to dejanje", + "Store": "Trgovina", + "Install registers, schemas and flows that other organisations have published.": "Namestite registre, sheme in tokove, ki so jih objavile druge organizacije.", + "No such run": "Ni takšnega izvajanja", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Izvajanje ne obstaja ali pa ga ne smete videti. Z brisanjem toka se izbrišejo tudi njegova izvajanja." }, "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..ff3a47e145 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Lidh një llogari sociale ose analitike një herë. OpenRegister e ruan token-in dhe e rinovon, që aplikacionet të mos e mbajnë kurrë.", + "Connect account": "Lidh llogarinë", + "Connected accounts": "Llogari të lidhura", + "Could not disconnect this account.": "Kjo llogari nuk u shkëput dot.", + "Could not load your connected accounts.": "Llogaritë e tua të lidhura nuk u ngarkuan dot.", + "Could not start the connection. Check the provider and try again.": "Lidhja nuk u nis dot. Kontrollo ofruesin dhe provo sërish.", + "Disconnect": "Shkëput", + "Expired": "Skaduar", + "Expires {date}": "Skadon më {date}", + "No accounts are connected yet.": "Ende nuk është lidhur asnjë llogari.", + "Not connected yet": "Ende e palidhur", + "Reconnect": "Rilidh", + "Relink needed": "Nevojitet rilidhje", + "Server address": "Adresa e serverit", + "Load example data?": "Të ngarkohen të dhëna shembull?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Të dhënat shembull mbushin listat, faqet e detajeve dhe panelet, që ta shihni aplikacionin duke punuar menjëherë. Zgjidhni \"Asnjë\" në një instalim prodhimi.", + "Load the example data": "Ngarko të dhënat shembull", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Ngarkon atë që zgjodhët. Janë qartësisht të dhëna shembull, veprimi mund të përsëritet pa rrezik dhe mund t’i fshini më pas.", + "None, I will set this up myself": "Asnjë, do ta konfiguroj vetë", + "Nothing is imported. You start with an empty app and add your own data.": "Nuk importohet asgjë. Filloni me një aplikacion bosh dhe shtoni të dhënat tuaja.", + "Example data": "Të dhëna shembull", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Vlera shembull për çdo skemë që sjell ky aplikacion, të gjeneruara nga vetë skemat. Tregojnë listat, faqet e detajeve dhe panelet në punë, në vend që të tregojnë një histori. Të përsëritshme pa rrezik dhe të fshishme më pas.", + "from amount {amount}": "nga shuma {amount}", + "Gated transition: {transition}": "Kalim i mbikëqyrur: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Çdo zinxhir i deklaruar hap një sekuencë të renditur detyrash kur kalimi provohet. Miratuesit vendosin në kutinë e tyre të detyrave, jo këtu.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklaroni x-openregister-approval-chains në këtë skemë që një kalim i ciklit jetësor të kërkojë miratim.", + "Approval sequences": "Sekuenca miratimi", "%n entries have no hash yet": "%n zëra ende nuk kanë hash", "%n entry has no hash yet": "%n zë ende nuk ka hash", "(Dolphin required)": "(Kërkohet Dolphin)", + "No approval chains declared": "Nuk ka zinxhirë miratimi të deklaruar", "(no subject)": "(pa temë)", "(no title)": "(pa titull)", + "Role {role}": "Roli {role}", + "The transition is applied automatically once every step approves.": "Kalimi zbatohet automatikisht sapo çdo hap të miratohet.", + "The requester may not decide their own approval.": "Kërkuesi nuk mund të vendosë për miratimin e vet.", + "Tier routing on field: {field}": "Drejtim me shkallë sipas fushës: {field}", "(unnamed)": "(pa emër)", "(untitled card)": "(kartë pa titull)", "({count} selected)": "({count} të zgjedhura)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Nuk ka detyrë të tillë", + "The task does not exist, or it is not yours to see.": "Detyra nuk ekziston, ose nuk keni të drejtë ta shihni.", + "Details": "Detaje", + "Due": "Afati", + "Assignee": "Caktuar", + "Requested by": "Kërkuar nga", + "Outcome": "Rezultati", + "Ready to claim": "Gati për t'u marrë", + "In progress": "Në vazhdim", + "Terminated": "Përfunduar", + "Normal": "Normale", + "Urgent": "Urgjente", + "Overdue by 1 day": "1 ditë me vonesë", + "Overdue by {days} days": "{days} ditë me vonesë", + "Due today": "Afati sot", + "Due tomorrow": "Afati nesër", + "Due in {days} days": "Afati pas {days} ditësh", + "Tasks": "Detyra", + "Task": "Detyrë", + "Unassigned, open for claiming": "E pacaktuar, e hapur për t'u marrë", + "Claim": "Merr", + "Complete": "Përfundo", + "Complete: {outcome}": "Përfundo: {outcome}", + "Could not load the task": "Detyra nuk u ngarkua dot", + "The task refused that action": "Detyra e refuzoi atë veprim", + "Store": "Dyqani", + "Install registers, schemas and flows that other organisations have published.": "Instaloni regjistra, skema dhe rrjedha të publikuara nga organizata të tjera.", + "No such run": "Nuk ka ekzekutim të tillë", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Ekzekutimi nuk ekziston, ose nuk keni të drejtë ta shihni. Fshirja e një rrjedhe fshin edhe ekzekutimet e saj." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index 05b9eb2618..b3ec20dcaa 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Lidh një llogari sociale ose analitike një herë. OpenRegister e ruan token-in dhe e rinovon, që aplikacionet të mos e mbajnë kurrë.", + "Connect account": "Lidh llogarinë", + "Connected accounts": "Llogari të lidhura", + "Could not disconnect this account.": "Kjo llogari nuk u shkëput dot.", + "Could not load your connected accounts.": "Llogaritë e tua të lidhura nuk u ngarkuan dot.", + "Could not start the connection. Check the provider and try again.": "Lidhja nuk u nis dot. Kontrollo ofruesin dhe provo sërish.", + "Disconnect": "Shkëput", + "Expired": "Skaduar", + "Expires {date}": "Skadon më {date}", + "No accounts are connected yet.": "Ende nuk është lidhur asnjë llogari.", + "Not connected yet": "Ende e palidhur", + "Reconnect": "Rilidh", + "Relink needed": "Nevojitet rilidhje", + "Server address": "Adresa e serverit", + "Load example data?": "Të ngarkohen të dhëna shembull?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Të dhënat shembull mbushin listat, faqet e detajeve dhe panelet, që ta shihni aplikacionin duke punuar menjëherë. Zgjidhni \"Asnjë\" në një instalim prodhimi.", + "Load the example data": "Ngarko të dhënat shembull", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Ngarkon atë që zgjodhët. Janë qartësisht të dhëna shembull, veprimi mund të përsëritet pa rrezik dhe mund t’i fshini më pas.", + "None, I will set this up myself": "Asnjë, do ta konfiguroj vetë", + "Nothing is imported. You start with an empty app and add your own data.": "Nuk importohet asgjë. Filloni me një aplikacion bosh dhe shtoni të dhënat tuaja.", + "Example data": "Të dhëna shembull", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Vlera shembull për çdo skemë që sjell ky aplikacion, të gjeneruara nga vetë skemat. Tregojnë listat, faqet e detajeve dhe panelet në punë, në vend që të tregojnë një histori. Të përsëritshme pa rrezik dhe të fshishme më pas.", + "from amount {amount}": "nga shuma {amount}", + "Gated transition: {transition}": "Kalim i mbikëqyrur: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Çdo zinxhir i deklaruar hap një sekuencë të renditur detyrash kur kalimi provohet. Miratuesit vendosin në kutinë e tyre të detyrave, jo këtu.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklaroni x-openregister-approval-chains në këtë skemë që një kalim i ciklit jetësor të kërkojë miratim.", + "Approval sequences": "Sekuenca miratimi", "%n entries have no hash yet": "%n zëra ende nuk kanë hash", "%n entry has no hash yet": "%n zë ende nuk ka hash", "(Dolphin required)": "(Kërkohet Dolphin)", + "No approval chains declared": "Nuk ka zinxhirë miratimi të deklaruar", "(no subject)": "(pa temë)", "(no title)": "(pa titull)", + "Role {role}": "Roli {role}", + "The transition is applied automatically once every step approves.": "Kalimi zbatohet automatikisht sapo çdo hap të miratohet.", + "The requester may not decide their own approval.": "Kërkuesi nuk mund të vendosë për miratimin e vet.", + "Tier routing on field: {field}": "Drejtim me shkallë sipas fushës: {field}", "(unnamed)": "(pa emër)", "(untitled card)": "(kartë pa titull)", "({count} selected)": "({count} të zgjedhura)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Nuk ka detyrë të tillë", + "The task does not exist, or it is not yours to see.": "Detyra nuk ekziston, ose nuk keni të drejtë ta shihni.", + "Details": "Detaje", + "Due": "Afati", + "Assignee": "Caktuar", + "Requested by": "Kërkuar nga", + "Outcome": "Rezultati", + "Ready to claim": "Gati për t'u marrë", + "In progress": "Në vazhdim", + "Terminated": "Përfunduar", + "Normal": "Normale", + "Urgent": "Urgjente", + "Overdue by 1 day": "1 ditë me vonesë", + "Overdue by {days} days": "{days} ditë me vonesë", + "Due today": "Afati sot", + "Due tomorrow": "Afati nesër", + "Due in {days} days": "Afati pas {days} ditësh", + "Tasks": "Detyra", + "Task": "Detyrë", + "Unassigned, open for claiming": "E pacaktuar, e hapur për t'u marrë", + "Claim": "Merr", + "Complete": "Përfundo", + "Complete: {outcome}": "Përfundo: {outcome}", + "Could not load the task": "Detyra nuk u ngarkua dot", + "The task refused that action": "Detyra e refuzoi atë veprim", + "Store": "Dyqani", + "Install registers, schemas and flows that other organisations have published.": "Instaloni regjistra, skema dhe rrjedha të publikuara nga organizata të tjera.", + "No such run": "Nuk ka ekzekutim të tillë", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Ekzekutimi nuk ekziston, ose nuk keni të drejtë ta shihni. Fshirja e një rrjedhe fshin edhe ekzekutimet e saj." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/sr.js b/l10n/sr.js index 41cc91ce44..d1b5a97c29 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Повежи друштвени или аналитички налог једном. OpenRegister чува токен и обнавља га, па га апликације никада не држе.", + "Connect account": "Повежи налог", + "Connected accounts": "Повезани налози", + "Could not disconnect this account.": "Овај налог није било могуће одспојити.", + "Could not load your connected accounts.": "Није било могуће учитати твоје повезане налоге.", + "Could not start the connection. Check the provider and try again.": "Није било могуће покренути повезивање. Провери пружаоца и покушај поново.", + "Disconnect": "Одспоји", + "Expired": "Истекло", + "Expires {date}": "Истиче {date}", + "No accounts are connected yet.": "Још није повезан ниједан налог.", + "Not connected yet": "Још није повезано", + "Reconnect": "Повежи поново", + "Relink needed": "Потребно је поновно повезивање", + "Server address": "Адреса сервера", + "Load example data?": "Учитати примере података?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Примери података попуњавају листе, странице са детаљима и командне табле, па апликацију одмах видите како ради. Изаберите \"Ништа\" на продукцијској инсталацији.", + "Load the example data": "Учитај примере података", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Учитава оно што сте изабрали. То су очигледно примери података, радња се може безбедно поновити, а после их можете обрисати.", + "None, I will set this up myself": "Ништа, сам ћу ово подесити", + "Nothing is imported. You start with an empty app and add your own data.": "Ништа се не увози. Почињете са празном апликацијом и додајете сопствене податке.", + "Example data": "Примери података", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Примери вредности за сваку шему коју ова апликација доноси, генерисани из самих шема. Показују листе, странице са детаљима и командне табле у раду уместо да причају причу. Безбедно за понављање и брисање после.", + "from amount {amount}": "od iznosa {amount}", + "Gated transition: {transition}": "Nadzirani prelaz: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Svaki deklarisani lanac otvara uređeni niz zadataka kada se prelaz pokuša. Odobravaoci odlučuju u svom prijemnom sandučetu zadataka, ne ovde.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarišite x-openregister-approval-chains na ovoj šemi da bi prelaz životnog ciklusa zahtevao odobrenje.", + "Approval sequences": "Nizovi odobravanja", "%n entries have no hash yet": "%n unosa još nema heš", "%n entry has no hash yet": "%n unos još nema heš", "(Dolphin required)": "(Potreban Dolphin)", + "No approval chains declared": "Nema deklarisanih lanaca odobravanja", "(no subject)": "(bez predmeta)", "(no title)": "(bez naslova)", + "Role {role}": "Uloga {role}", + "The transition is applied automatically once every step approves.": "Prelaz se primenjuje automatski čim svaki korak bude odobren.", + "The requester may not decide their own approval.": "Podnosilac zahteva ne može odlučivati o sopstvenom odobrenju.", + "Tier routing on field: {field}": "Stepenasto usmeravanje po polju: {field}", "(unnamed)": "(bez naziva)", "(untitled card)": "(kartica bez naslova)", "({count} selected)": "({count} odabrano)", @@ -2738,7 +2770,88 @@ 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 на рок или после њега.", + "No such task": "Нема таквог задатка", + "The task does not exist, or it is not yours to see.": "Задатак не постоји или немате право да га видите.", + "Details": "Детаљи", + "Due": "Рок", + "Assignee": "Додељено", + "Requested by": "Затражио", + "Outcome": "Исход", + "Ready to claim": "Спремно за преузимање", + "In progress": "У току", + "Terminated": "Прекинуто", + "Normal": "Нормално", + "Urgent": "Хитно", + "Overdue by 1 day": "Касни 1 дан", + "Overdue by {days} days": "Касни {days} дана", + "Due today": "Рок је данас", + "Due tomorrow": "Рок је сутра", + "Due in {days} days": "Рок за {days} дана", + "Tasks": "Задаци", + "Task": "Задатак", + "Unassigned, open for claiming": "Недодељено, отворено за преузимање", + "Claim": "Преузми", + "Complete": "Заврши", + "Complete: {outcome}": "Заврши: {outcome}", + "Could not load the task": "Задатак није могао да се учита", + "The task refused that action": "Задатак је одбио ту радњу", + "Store": "Продавница", + "Install registers, schemas and flows that other organisations have published.": "Инсталирајте регистре, шеме и токове које су објавиле друге организације.", + "No such run": "Нема таквог извршавања", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Извршавање не постоји или немате право да га видите. Брисањем тока бришу се и његова извршавања." }, "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..a5780c375a 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Повежи друштвени или аналитички налог једном. OpenRegister чува токен и обнавља га, па га апликације никада не држе.", + "Connect account": "Повежи налог", + "Connected accounts": "Повезани налози", + "Could not disconnect this account.": "Овај налог није било могуће одспојити.", + "Could not load your connected accounts.": "Није било могуће учитати твоје повезане налоге.", + "Could not start the connection. Check the provider and try again.": "Није било могуће покренути повезивање. Провери пружаоца и покушај поново.", + "Disconnect": "Одспоји", + "Expired": "Истекло", + "Expires {date}": "Истиче {date}", + "No accounts are connected yet.": "Још није повезан ниједан налог.", + "Not connected yet": "Још није повезано", + "Reconnect": "Повежи поново", + "Relink needed": "Потребно је поновно повезивање", + "Server address": "Адреса сервера", + "Load example data?": "Учитати примере података?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Примери података попуњавају листе, странице са детаљима и командне табле, па апликацију одмах видите како ради. Изаберите \"Ништа\" на продукцијској инсталацији.", + "Load the example data": "Учитај примере података", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Учитава оно што сте изабрали. То су очигледно примери података, радња се може безбедно поновити, а после их можете обрисати.", + "None, I will set this up myself": "Ништа, сам ћу ово подесити", + "Nothing is imported. You start with an empty app and add your own data.": "Ништа се не увози. Почињете са празном апликацијом и додајете сопствене податке.", + "Example data": "Примери података", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Примери вредности за сваку шему коју ова апликација доноси, генерисани из самих шема. Показују листе, странице са детаљима и командне табле у раду уместо да причају причу. Безбедно за понављање и брисање после.", + "from amount {amount}": "od iznosa {amount}", + "Gated transition: {transition}": "Nadzirani prelaz: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Svaki deklarisani lanac otvara uređeni niz zadataka kada se prelaz pokuša. Odobravaoci odlučuju u svom prijemnom sandučetu zadataka, ne ovde.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarišite x-openregister-approval-chains na ovoj šemi da bi prelaz životnog ciklusa zahtevao odobrenje.", + "Approval sequences": "Nizovi odobravanja", "%n entries have no hash yet": "%n unosa još nema heš", "%n entry has no hash yet": "%n unos još nema heš", "(Dolphin required)": "(Potreban Dolphin)", + "No approval chains declared": "Nema deklarisanih lanaca odobravanja", "(no subject)": "(bez predmeta)", "(no title)": "(bez naslova)", + "Role {role}": "Uloga {role}", + "The transition is applied automatically once every step approves.": "Prelaz se primenjuje automatski čim svaki korak bude odobren.", + "The requester may not decide their own approval.": "Podnosilac zahteva ne može odlučivati o sopstvenom odobrenju.", + "Tier routing on field: {field}": "Stepenasto usmeravanje po polju: {field}", "(unnamed)": "(bez naziva)", "(untitled card)": "(kartica bez naslova)", "({count} selected)": "({count} odabrano)", @@ -2801,7 +2833,88 @@ "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 на рок или после њега.", + "No such task": "Нема таквог задатка", + "The task does not exist, or it is not yours to see.": "Задатак не постоји или немате право да га видите.", + "Details": "Детаљи", + "Due": "Рок", + "Assignee": "Додељено", + "Requested by": "Затражио", + "Outcome": "Исход", + "Ready to claim": "Спремно за преузимање", + "In progress": "У току", + "Terminated": "Прекинуто", + "Normal": "Нормално", + "Urgent": "Хитно", + "Overdue by 1 day": "Касни 1 дан", + "Overdue by {days} days": "Касни {days} дана", + "Due today": "Рок је данас", + "Due tomorrow": "Рок је сутра", + "Due in {days} days": "Рок за {days} дана", + "Tasks": "Задаци", + "Task": "Задатак", + "Unassigned, open for claiming": "Недодељено, отворено за преузимање", + "Claim": "Преузми", + "Complete": "Заврши", + "Complete: {outcome}": "Заврши: {outcome}", + "Could not load the task": "Задатак није могао да се учита", + "The task refused that action": "Задатак је одбио ту радњу", + "Store": "Продавница", + "Install registers, schemas and flows that other organisations have published.": "Инсталирајте регистре, шеме и токове које су објавиле друге организације.", + "No such run": "Нема таквог извршавања", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Извршавање не постоји или немате право да га видите. Брисањем тока бришу се и његова извршавања." }, "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..1010e165b6 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Anslut ett socialt konto eller analyskonto en gång. OpenRegister förvarar token och förnyar den, så att appar aldrig håller den själva.", + "Connect account": "Anslut konto", + "Connected accounts": "Anslutna konton", + "Could not disconnect this account.": "Det gick inte att koppla från detta konto.", + "Could not load your connected accounts.": "Det gick inte att läsa in dina anslutna konton.", + "Could not start the connection. Check the provider and try again.": "Det gick inte att starta anslutningen. Kontrollera leverantören och försök igen.", + "Disconnect": "Koppla från", + "Expired": "Utgången", + "Expires {date}": "Går ut {date}", + "No accounts are connected yet.": "Inga konton är anslutna ännu.", + "Not connected yet": "Inte ansluten ännu", + "Reconnect": "Anslut igen", + "Relink needed": "Ny länkning behövs", + "Server address": "Serveradress", + "Load example data?": "Läsa in exempeldata?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Exempeldata fyller listor, detaljsidor och instrumentpaneler, så att du direkt ser appen fungera. Välj \"Inga\" vid en produktionsinstallation.", + "Load the example data": "Läs in exempeldata", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Läser in det du valde. Det är tydligt exempeldata, åtgärden kan upprepas utan risk och du kan ta bort dem efteråt.", + "None, I will set this up myself": "Inga, jag ställer in det själv", + "Nothing is imported. You start with an empty app and add your own data.": "Ingenting importeras. Du börjar med en tom app och lägger till dina egna data.", + "Example data": "Exempeldata", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Exempelvärden för varje schema som appen levererar, genererade ur schemana själva. De visar listor, detaljsidor och instrumentpaneler i drift i stället för att berätta en historia. Kan upprepas utan risk och tas bort efteråt.", + "from amount {amount}": "från belopp {amount}", + "Gated transition: {transition}": "Bevakad övergång: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Varje deklarerad kedja öppnar en ordnad uppgiftssekvens när övergången försöks. Godkännare beslutar i sin uppgiftsinkorg, inte här.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarera x-openregister-approval-chains på detta schema för att kräva godkännande före en livscykelövergång.", + "Approval sequences": "Godkännandesekvenser", "%n entries have no hash yet": "%n poster saknar ännu hash", "%n entry has no hash yet": "%n post saknar ännu hash", "(Dolphin required)": "(Dolphin krävs)", + "No approval chains declared": "Inga godkännandekedjor deklarerade", "(no subject)": "(inget ämne)", "(no title)": "(ingen titel)", + "Role {role}": "Roll {role}", + "The transition is applied automatically once every step approves.": "Övergången tillämpas automatiskt när varje steg är godkänt.", + "The requester may not decide their own approval.": "Beställaren får inte avgöra sitt eget godkännande.", + "Tier routing on field: {field}": "Stegvis dirigering på fältet: {field}", "(unnamed)": "(namnlös)", "(untitled card)": "(namnlöst kort)", "({count} selected)": "({count} valda)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Ingen sådan uppgift", + "The task does not exist, or it is not yours to see.": "Uppgiften finns inte, eller så får du inte se den.", + "Details": "Detaljer", + "Due": "Förfaller", + "Assignee": "Tilldelad", + "Requested by": "Begärd av", + "Outcome": "Utfall", + "Ready to claim": "Redo att ta", + "In progress": "Pågår", + "Terminated": "Avslutad", + "Normal": "Normal", + "Urgent": "Brådskande", + "Overdue by 1 day": "1 dag försenad", + "Overdue by {days} days": "{days} dagar försenad", + "Due today": "Förfaller i dag", + "Due tomorrow": "Förfaller i morgon", + "Due in {days} days": "Förfaller om {days} dagar", + "Tasks": "Uppgifter", + "Task": "Uppgift", + "Unassigned, open for claiming": "Otilldelad, öppen att ta", + "Claim": "Ta", + "Complete": "Slutför", + "Complete: {outcome}": "Slutför: {outcome}", + "Could not load the task": "Kunde inte läsa in uppgiften", + "The task refused that action": "Uppgiften nekade den åtgärden", + "Store": "Butik", + "Install registers, schemas and flows that other organisations have published.": "Installera register, scheman och flöden som andra organisationer har publicerat.", + "No such run": "Ingen sådan körning", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Körningen finns inte, eller så får du inte se den. Raderar du ett flöde raderas även dess körningar." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index e5f0e7cc06..a46b8cf32c 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Anslut ett socialt konto eller analyskonto en gång. OpenRegister förvarar token och förnyar den, så att appar aldrig håller den själva.", + "Connect account": "Anslut konto", + "Connected accounts": "Anslutna konton", + "Could not disconnect this account.": "Det gick inte att koppla från detta konto.", + "Could not load your connected accounts.": "Det gick inte att läsa in dina anslutna konton.", + "Could not start the connection. Check the provider and try again.": "Det gick inte att starta anslutningen. Kontrollera leverantören och försök igen.", + "Disconnect": "Koppla från", + "Expired": "Utgången", + "Expires {date}": "Går ut {date}", + "No accounts are connected yet.": "Inga konton är anslutna ännu.", + "Not connected yet": "Inte ansluten ännu", + "Reconnect": "Anslut igen", + "Relink needed": "Ny länkning behövs", + "Server address": "Serveradress", + "Load example data?": "Läsa in exempeldata?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Exempeldata fyller listor, detaljsidor och instrumentpaneler, så att du direkt ser appen fungera. Välj \"Inga\" vid en produktionsinstallation.", + "Load the example data": "Läs in exempeldata", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Läser in det du valde. Det är tydligt exempeldata, åtgärden kan upprepas utan risk och du kan ta bort dem efteråt.", + "None, I will set this up myself": "Inga, jag ställer in det själv", + "Nothing is imported. You start with an empty app and add your own data.": "Ingenting importeras. Du börjar med en tom app och lägger till dina egna data.", + "Example data": "Exempeldata", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Exempelvärden för varje schema som appen levererar, genererade ur schemana själva. De visar listor, detaljsidor och instrumentpaneler i drift i stället för att berätta en historia. Kan upprepas utan risk och tas bort efteråt.", + "from amount {amount}": "från belopp {amount}", + "Gated transition: {transition}": "Bevakad övergång: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Varje deklarerad kedja öppnar en ordnad uppgiftssekvens när övergången försöks. Godkännare beslutar i sin uppgiftsinkorg, inte här.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Deklarera x-openregister-approval-chains på detta schema för att kräva godkännande före en livscykelövergång.", + "Approval sequences": "Godkännandesekvenser", "%n entries have no hash yet": "%n poster saknar ännu hash", "%n entry has no hash yet": "%n post saknar ännu hash", "(Dolphin required)": "(Dolphin krävs)", + "No approval chains declared": "Inga godkännandekedjor deklarerade", "(no subject)": "(inget ämne)", "(no title)": "(ingen titel)", + "Role {role}": "Roll {role}", + "The transition is applied automatically once every step approves.": "Övergången tillämpas automatiskt när varje steg är godkänt.", + "The requester may not decide their own approval.": "Beställaren får inte avgöra sitt eget godkännande.", + "Tier routing on field: {field}": "Stegvis dirigering på fältet: {field}", "(unnamed)": "(namnlös)", "(untitled card)": "(namnlöst kort)", "({count} selected)": "({count} valda)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Ingen sådan uppgift", + "The task does not exist, or it is not yours to see.": "Uppgiften finns inte, eller så får du inte se den.", + "Details": "Detaljer", + "Due": "Förfaller", + "Assignee": "Tilldelad", + "Requested by": "Begärd av", + "Outcome": "Utfall", + "Ready to claim": "Redo att ta", + "In progress": "Pågår", + "Terminated": "Avslutad", + "Normal": "Normal", + "Urgent": "Brådskande", + "Overdue by 1 day": "1 dag försenad", + "Overdue by {days} days": "{days} dagar försenad", + "Due today": "Förfaller i dag", + "Due tomorrow": "Förfaller i morgon", + "Due in {days} days": "Förfaller om {days} dagar", + "Tasks": "Uppgifter", + "Task": "Uppgift", + "Unassigned, open for claiming": "Otilldelad, öppen att ta", + "Claim": "Ta", + "Complete": "Slutför", + "Complete: {outcome}": "Slutför: {outcome}", + "Could not load the task": "Kunde inte läsa in uppgiften", + "The task refused that action": "Uppgiften nekade den åtgärden", + "Store": "Butik", + "Install registers, schemas and flows that other organisations have published.": "Installera register, scheman och flöden som andra organisationer har publicerat.", + "No such run": "Ingen sådan körning", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Körningen finns inte, eller så får du inte se den. Raderar du ett flöde raderas även dess körningar." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/tr.js b/l10n/tr.js index cfd5b26051..c7b6816076 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Bir sosyal veya analitik hesabı bir kez bağlayın. OpenRegister belirteci saklar ve yeniler, böylece uygulamalar onu asla tutmaz.", + "Connect account": "Hesap bağla", + "Connected accounts": "Bağlı hesaplar", + "Could not disconnect this account.": "Bu hesabın bağlantısı kesilemedi.", + "Could not load your connected accounts.": "Bağlı hesaplarınız yüklenemedi.", + "Could not start the connection. Check the provider and try again.": "Bağlantı başlatılamadı. Sağlayıcıyı denetleyip yeniden deneyin.", + "Disconnect": "Bağlantıyı kes", + "Expired": "Süresi doldu", + "Expires {date}": "{date} tarihinde doluyor", + "No accounts are connected yet.": "Henüz bağlı hesap yok.", + "Not connected yet": "Henüz bağlı değil", + "Reconnect": "Yeniden bağla", + "Relink needed": "Yeniden bağlama gerekiyor", + "Server address": "Sunucu adresi", + "Load example data?": "Örnek veriler yüklensin mi?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Örnek veriler listeleri, ayrıntı sayfalarını ve panoları doldurur; böylece uygulamayı hemen çalışırken görürsünüz. Üretim kurulumunda \"Hiçbiri\" seçin.", + "Load the example data": "Örnek verileri yükle", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Seçtiğinizi yükler. Bunlar açıkça örnek verilerdir, işlem birden çok kez güvenle çalıştırılabilir ve sonrasında silebilirsiniz.", + "None, I will set this up myself": "Hiçbiri, bunu kendim kuracağım", + "Nothing is imported. You start with an empty app and add your own data.": "Hiçbir şey içe aktarılmaz. Boş bir uygulamayla başlar ve kendi verilerinizi eklersiniz.", + "Example data": "Örnek veriler", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Bu uygulamanın getirdiği her şema için, şemaların kendisinden üretilmiş örnek değerler. Bir hikâye anlatmak yerine listeleri, ayrıntı sayfalarını ve panoları çalışırken gösterir. Güvenle tekrarlanabilir ve sonrasında silinebilir.", + "from amount {amount}": "{amount} tutarından itibaren", + "Gated transition: {transition}": "Denetimli geçiş: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Bildirilen her zincir, geçiş denendiğinde sıralı bir görev dizisi açar. Onaylayanlar burada değil, görev gelen kutularında karar verir.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Bir yaşam döngüsü geçişini onaya bağlamak için bu şemada x-openregister-approval-chains bildirin.", + "Approval sequences": "Onay dizileri", "%n entries have no hash yet": "%n kaydın henüz karması yok", "%n entry has no hash yet": "%n kaydın henüz karması yok", "(Dolphin required)": "(Dolphin gerekli)", + "No approval chains declared": "Bildirilmiş onay zinciri yok", "(no subject)": "(konu yok)", "(no title)": "(başlık yok)", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "Her adım onaylandığında geçiş otomatik olarak uygulanır.", + "The requester may not decide their own approval.": "Talep eden, kendi onayı hakkında karar veremez.", + "Tier routing on field: {field}": "Alan üzerinden kademeli yönlendirme: {field}", "(unnamed)": "(adsız)", "(untitled card)": "(başlıksız kart)", "({count} selected)": "({count} seçildi)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Böyle bir görev yok", + "The task does not exist, or it is not yours to see.": "Görev yok ya da görme yetkiniz yok.", + "Details": "Ayrıntılar", + "Due": "Son tarih", + "Assignee": "Atanan", + "Requested by": "Talep eden", + "Outcome": "Sonuç", + "Ready to claim": "Üstlenilmeye hazır", + "In progress": "Devam ediyor", + "Terminated": "Sonlandırıldı", + "Normal": "Normal", + "Urgent": "Acil", + "Overdue by 1 day": "1 gün gecikmiş", + "Overdue by {days} days": "{days} gün gecikmiş", + "Due today": "Bugün teslim", + "Due tomorrow": "Yarın teslim", + "Due in {days} days": "{days} gün içinde teslim", + "Tasks": "Görevler", + "Task": "Görev", + "Unassigned, open for claiming": "Atanmamış, üstlenmeye açık", + "Claim": "Üstlen", + "Complete": "Tamamla", + "Complete: {outcome}": "Tamamla: {outcome}", + "Could not load the task": "Görev yüklenemedi", + "The task refused that action": "Görev bu eylemi reddetti", + "Store": "Mağaza", + "Install registers, schemas and flows that other organisations have published.": "Diğer kuruluşların yayımladığı kayıtları, şemaları ve akışları yükleyin.", + "No such run": "Böyle bir çalıştırma yok", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Çalıştırma yok ya da görme yetkiniz yok. Bir akışı silmek çalıştırmalarını da siler." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index 09c952f238..37dc818116 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Bir sosyal veya analitik hesabı bir kez bağlayın. OpenRegister belirteci saklar ve yeniler, böylece uygulamalar onu asla tutmaz.", + "Connect account": "Hesap bağla", + "Connected accounts": "Bağlı hesaplar", + "Could not disconnect this account.": "Bu hesabın bağlantısı kesilemedi.", + "Could not load your connected accounts.": "Bağlı hesaplarınız yüklenemedi.", + "Could not start the connection. Check the provider and try again.": "Bağlantı başlatılamadı. Sağlayıcıyı denetleyip yeniden deneyin.", + "Disconnect": "Bağlantıyı kes", + "Expired": "Süresi doldu", + "Expires {date}": "{date} tarihinde doluyor", + "No accounts are connected yet.": "Henüz bağlı hesap yok.", + "Not connected yet": "Henüz bağlı değil", + "Reconnect": "Yeniden bağla", + "Relink needed": "Yeniden bağlama gerekiyor", + "Server address": "Sunucu adresi", + "Load example data?": "Örnek veriler yüklensin mi?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Örnek veriler listeleri, ayrıntı sayfalarını ve panoları doldurur; böylece uygulamayı hemen çalışırken görürsünüz. Üretim kurulumunda \"Hiçbiri\" seçin.", + "Load the example data": "Örnek verileri yükle", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Seçtiğinizi yükler. Bunlar açıkça örnek verilerdir, işlem birden çok kez güvenle çalıştırılabilir ve sonrasında silebilirsiniz.", + "None, I will set this up myself": "Hiçbiri, bunu kendim kuracağım", + "Nothing is imported. You start with an empty app and add your own data.": "Hiçbir şey içe aktarılmaz. Boş bir uygulamayla başlar ve kendi verilerinizi eklersiniz.", + "Example data": "Örnek veriler", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Bu uygulamanın getirdiği her şema için, şemaların kendisinden üretilmiş örnek değerler. Bir hikâye anlatmak yerine listeleri, ayrıntı sayfalarını ve panoları çalışırken gösterir. Güvenle tekrarlanabilir ve sonrasında silinebilir.", + "from amount {amount}": "{amount} tutarından itibaren", + "Gated transition: {transition}": "Denetimli geçiş: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Bildirilen her zincir, geçiş denendiğinde sıralı bir görev dizisi açar. Onaylayanlar burada değil, görev gelen kutularında karar verir.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Bir yaşam döngüsü geçişini onaya bağlamak için bu şemada x-openregister-approval-chains bildirin.", + "Approval sequences": "Onay dizileri", "%n entries have no hash yet": "%n kaydın henüz karması yok", "%n entry has no hash yet": "%n kaydın henüz karması yok", "(Dolphin required)": "(Dolphin gerekli)", + "No approval chains declared": "Bildirilmiş onay zinciri yok", "(no subject)": "(konu yok)", "(no title)": "(başlık yok)", + "Role {role}": "Rol {role}", + "The transition is applied automatically once every step approves.": "Her adım onaylandığında geçiş otomatik olarak uygulanır.", + "The requester may not decide their own approval.": "Talep eden, kendi onayı hakkında karar veremez.", + "Tier routing on field: {field}": "Alan üzerinden kademeli yönlendirme: {field}", "(unnamed)": "(adsız)", "(untitled card)": "(başlıksız kart)", "({count} selected)": "({count} seçildi)", @@ -2785,7 +2817,88 @@ "_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.", + "No such task": "Böyle bir görev yok", + "The task does not exist, or it is not yours to see.": "Görev yok ya da görme yetkiniz yok.", + "Details": "Ayrıntılar", + "Due": "Son tarih", + "Assignee": "Atanan", + "Requested by": "Talep eden", + "Outcome": "Sonuç", + "Ready to claim": "Üstlenilmeye hazır", + "In progress": "Devam ediyor", + "Terminated": "Sonlandırıldı", + "Normal": "Normal", + "Urgent": "Acil", + "Overdue by 1 day": "1 gün gecikmiş", + "Overdue by {days} days": "{days} gün gecikmiş", + "Due today": "Bugün teslim", + "Due tomorrow": "Yarın teslim", + "Due in {days} days": "{days} gün içinde teslim", + "Tasks": "Görevler", + "Task": "Görev", + "Unassigned, open for claiming": "Atanmamış, üstlenmeye açık", + "Claim": "Üstlen", + "Complete": "Tamamla", + "Complete: {outcome}": "Tamamla: {outcome}", + "Could not load the task": "Görev yüklenemedi", + "The task refused that action": "Görev bu eylemi reddetti", + "Store": "Mağaza", + "Install registers, schemas and flows that other organisations have published.": "Diğer kuruluşların yayımladığı kayıtları, şemaları ve akışları yükleyin.", + "No such run": "Böyle bir çalıştırma yok", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Çalıştırma yok ya da görme yetkiniz yok. Bir akışı silmek çalıştırmalarını da siler." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/uk.js b/l10n/uk.js index 9b37cb19c4..5e2cb469ef 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -3,11 +3,43 @@ OC.L10N.register( { "3": "3", "30": "30", + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Підключіть обліковий запис соцмережі або аналітики один раз. OpenRegister зберігає токен і оновлює його, тож застосунки ніколи його не тримають.", + "Connect account": "Підключити обліковий запис", + "Connected accounts": "Підключені облікові записи", + "Could not disconnect this account.": "Не вдалося відʼєднати цей обліковий запис.", + "Could not load your connected accounts.": "Не вдалося завантажити ваші підключені облікові записи.", + "Could not start the connection. Check the provider and try again.": "Не вдалося почати підключення. Перевірте постачальника та спробуйте ще раз.", + "Disconnect": "Відʼєднати", + "Expired": "Прострочено", + "Expires {date}": "Спливає {date}", + "No accounts are connected yet.": "Ще не підключено жодного облікового запису.", + "Not connected yet": "Ще не підключено", + "Reconnect": "Підключити знову", + "Relink needed": "Потрібне повторне звʼязування", + "Server address": "Адреса сервера", + "Load example data?": "Завантажити приклади даних?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Приклади даних заповнюють списки, сторінки деталей і панелі, тож ви одразу побачите застосунок у роботі. На робочій інсталяції оберіть \"Немає\".", + "Load the example data": "Завантажити приклади даних", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Завантажує те, що ви обрали. Це очевидно приклади даних, дію можна безпечно повторити, а потім їх можна видалити.", + "None, I will set this up myself": "Немає, я налаштую це сам", + "Nothing is imported. You start with an empty app and add your own data.": "Нічого не імпортується. Ви починаєте з порожнього застосунку і додаєте власні дані.", + "Example data": "Приклади даних", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Приклади значень для кожної схеми, яку постачає цей застосунок, згенеровані з самих схем. Вони показують списки, сторінки деталей і панелі в роботі, а не розповідають історію. Безпечно повторювати і потім видалити.", + "from amount {amount}": "від суми {amount}", + "Gated transition: {transition}": "Контрольований перехід: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Кожен оголошений ланцюжок відкриває впорядковану послідовність завдань під час спроби переходу. Погоджувачі вирішують у своїй скриньці завдань, а не тут.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Оголосіть x-openregister-approval-chains у цій схемі, щоб перехід життєвого циклу вимагав погодження.", + "Approval sequences": "Послідовності погодження", "%n entries have no hash yet": "%n записів ще не мають хешу", "%n entry has no hash yet": "%n запис ще не має хешу", "(Dolphin required)": "(требуется Dolphin)", + "No approval chains declared": "Не оголошено жодного ланцюжка погодження", "(no subject)": "(без теми)", "(no title)": "(без назви)", + "Role {role}": "Роль {role}", + "The transition is applied automatically once every step approves.": "Перехід застосовується автоматично, щойно кожен крок погоджено.", + "The requester may not decide their own approval.": "Запитувач не може вирішувати щодо власного погодження.", + "Tier routing on field: {field}": "Ступенева маршрутизація за полем: {field}", "(unnamed)": "(без імені)", "(untitled card)": "(картка без назви)", "({count} selected)": "({count} вибрано)", @@ -2738,7 +2770,88 @@ 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.", + "No such task": "Немає такого завдання", + "The task does not exist, or it is not yours to see.": "Завдання не існує, або ви не маєте права його бачити.", + "Details": "Деталі", + "Due": "Термін", + "Assignee": "Виконавець", + "Requested by": "Запитав", + "Outcome": "Результат", + "Ready to claim": "Готово до взяття", + "In progress": "Виконується", + "Terminated": "Припинено", + "Normal": "Звичайний", + "Urgent": "Терміновий", + "Overdue by 1 day": "Прострочено на 1 день", + "Overdue by {days} days": "Прострочено на {days} дн.", + "Due today": "Термін сьогодні", + "Due tomorrow": "Термін завтра", + "Due in {days} days": "Термін через {days} дн.", + "Tasks": "Завдання", + "Task": "Завдання", + "Unassigned, open for claiming": "Не призначено, відкрито для взяття", + "Claim": "Взяти", + "Complete": "Завершити", + "Complete: {outcome}": "Завершити: {outcome}", + "Could not load the task": "Не вдалося завантажити завдання", + "The task refused that action": "Завдання відхилило цю дію", + "Store": "Магазин", + "Install registers, schemas and flows that other organisations have published.": "Встановлюйте реєстри, схеми та потоки, опубліковані іншими організаціями.", + "No such run": "Немає такого запуску", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Запуск не існує, або ви не маєте права його бачити. Видалення потоку видаляє і його запуски." }, "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..4df1ad8fb5 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1,10 +1,42 @@ { "translations": { + "Connect a social or analytics account once. OpenRegister keeps the token and refreshes it, so apps never hold it.": "Підключіть обліковий запис соцмережі або аналітики один раз. OpenRegister зберігає токен і оновлює його, тож застосунки ніколи його не тримають.", + "Connect account": "Підключити обліковий запис", + "Connected accounts": "Підключені облікові записи", + "Could not disconnect this account.": "Не вдалося відʼєднати цей обліковий запис.", + "Could not load your connected accounts.": "Не вдалося завантажити ваші підключені облікові записи.", + "Could not start the connection. Check the provider and try again.": "Не вдалося почати підключення. Перевірте постачальника та спробуйте ще раз.", + "Disconnect": "Відʼєднати", + "Expired": "Прострочено", + "Expires {date}": "Спливає {date}", + "No accounts are connected yet.": "Ще не підключено жодного облікового запису.", + "Not connected yet": "Ще не підключено", + "Reconnect": "Підключити знову", + "Relink needed": "Потрібне повторне звʼязування", + "Server address": "Адреса сервера", + "Load example data?": "Завантажити приклади даних?", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Приклади даних заповнюють списки, сторінки деталей і панелі, тож ви одразу побачите застосунок у роботі. На робочій інсталяції оберіть \"Немає\".", + "Load the example data": "Завантажити приклади даних", + "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Завантажує те, що ви обрали. Це очевидно приклади даних, дію можна безпечно повторити, а потім їх можна видалити.", + "None, I will set this up myself": "Немає, я налаштую це сам", + "Nothing is imported. You start with an empty app and add your own data.": "Нічого не імпортується. Ви починаєте з порожнього застосунку і додаєте власні дані.", + "Example data": "Приклади даних", + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Приклади значень для кожної схеми, яку постачає цей застосунок, згенеровані з самих схем. Вони показують списки, сторінки деталей і панелі в роботі, а не розповідають історію. Безпечно повторювати і потім видалити.", + "from amount {amount}": "від суми {amount}", + "Gated transition: {transition}": "Контрольований перехід: {transition}", + "Each declared chain provisions an ordered task sequence when its transition is attempted. Approvers decide in their task inbox, not here.": "Кожен оголошений ланцюжок відкриває впорядковану послідовність завдань під час спроби переходу. Погоджувачі вирішують у своїй скриньці завдань, а не тут.", + "Declare x-openregister-approval-chains on this schema to gate a lifecycle transition on approval.": "Оголосіть x-openregister-approval-chains у цій схемі, щоб перехід життєвого циклу вимагав погодження.", + "Approval sequences": "Послідовності погодження", "%n entries have no hash yet": "%n записів ще не мають хешу", "%n entry has no hash yet": "%n запис ще не має хешу", "(Dolphin required)": "(требуется Dolphin)", + "No approval chains declared": "Не оголошено жодного ланцюжка погодження", "(no subject)": "(без теми)", "(no title)": "(без назви)", + "Role {role}": "Роль {role}", + "The transition is applied automatically once every step approves.": "Перехід застосовується автоматично, щойно кожен крок погоджено.", + "The requester may not decide their own approval.": "Запитувач не може вирішувати щодо власного погодження.", + "Tier routing on field: {field}": "Ступенева маршрутизація за полем: {field}", "(unnamed)": "(без імені)", "(untitled card)": "(картка без назви)", "({count} selected)": "({count} вибрано)", @@ -2817,7 +2849,88 @@ "Успішно відновлено {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.", + "No such task": "Немає такого завдання", + "The task does not exist, or it is not yours to see.": "Завдання не існує, або ви не маєте права його бачити.", + "Details": "Деталі", + "Due": "Термін", + "Assignee": "Виконавець", + "Requested by": "Запитав", + "Outcome": "Результат", + "Ready to claim": "Готово до взяття", + "In progress": "Виконується", + "Terminated": "Припинено", + "Normal": "Звичайний", + "Urgent": "Терміновий", + "Overdue by 1 day": "Прострочено на 1 день", + "Overdue by {days} days": "Прострочено на {days} дн.", + "Due today": "Термін сьогодні", + "Due tomorrow": "Термін завтра", + "Due in {days} days": "Термін через {days} дн.", + "Tasks": "Завдання", + "Task": "Завдання", + "Unassigned, open for claiming": "Не призначено, відкрито для взяття", + "Claim": "Взяти", + "Complete": "Завершити", + "Complete: {outcome}": "Завершити: {outcome}", + "Could not load the task": "Не вдалося завантажити завдання", + "The task refused that action": "Завдання відхилило цю дію", + "Store": "Магазин", + "Install registers, schemas and flows that other organisations have published.": "Встановлюйте реєстри, схеми та потоки, опубліковані іншими організаціями.", + "No such run": "Немає такого запуску", + "The run does not exist, or it is not yours to see. Deleting a flow deletes its runs.": "Запуск не існує, або ви не маєте права його бачити. Видалення потоку видаляє і його запуски." }, "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/AppHost/Bootstrap.php b/lib/AppHost/Bootstrap.php index 64fa3a81ab..d39d4d6c10 100644 --- a/lib/AppHost/Bootstrap.php +++ b/lib/AppHost/Bootstrap.php @@ -121,6 +121,25 @@ class Bootstrap { private const GENERIC_SETTINGS_CONTROLLER = 'OCA\\OpenRegister\\AppHost\\Controller\\GenericSettingsController'; private const GENERIC_HEALTH_CONTROLLER = 'OCA\\OpenRegister\\AppHost\\Controller\\GenericHealthController'; private const GENERIC_METRICS_CONTROLLER = 'OCA\\OpenRegister\\AppHost\\Controller\\GenericMetricsController'; + private const GENERIC_STORE_CONTROLLER = 'OCA\\OpenRegister\\AppHost\\Controller\\GenericStoreController'; + private const GENERIC_STORE_SERVICE = 'OCA\\OpenRegister\\AppHost\\Service\\GenericStoreService'; + private const GENERIC_STORE_INSTALLER = 'OCA\\OpenRegister\\AppHost\\Store\\GenericStoreInstaller'; + + /** + * The catalogue serving a store that exchanges configuration. + */ + private const FEDERATED_STORE_CATALOG = 'OCA\\OpenRegister\\AppHost\\Store\\FederatedStoreCatalog'; + + /** + * The GitHub discovery source, for stores declaring `source: github`. + */ + private const GITHUB_STORE_SOURCE = 'OCA\\OpenRegister\\AppHost\\Store\\Source\\GitHubStoreSource'; + + /** + * Resolves an `installAuth: action:` posture against the leaf app. + */ + private const STORE_ACTION_AUTHORIZER = 'OCA\\OpenRegister\\AppHost\\Store\\StoreActionAuthorizer'; + private const GENERIC_SETTINGS_SERVICE = 'OCA\\OpenRegister\\AppHost\\Service\\AppHostSettingsService'; private const GENERIC_ACTION_AUTH_SERVICE = 'OCA\\OpenRegister\\AppHost\\Service\\GenericActionAuthService'; private const GENERIC_INITIALIZE_SETTINGS = 'OCA\\OpenRegister\\AppHost\\Repair\\GenericInitializeSettings'; @@ -270,6 +289,13 @@ private static function registerControllers(IRegistrationContext $context, strin } ); + // Store plane (ADR-080, ADR-114 Decision 4). `unlessLeafDefinesIt` is + // what makes this a migration rather than a flag day: dossiq ships its + // own Controller\StoreController today and keeps winning this alias + // until that class is deleted, so the engine's version takes over per + // app, on that app's own pull request. + self::aliasStoreController(context: $context, appId: $appId, controllerNs: $controllerNs); + if ($observability === false) { return; } @@ -303,6 +329,55 @@ private static function registerControllers(IRegistrationContext $context, strin ); }//end registerControllers() + /** + * Bind the store controller for one leaf app. + * + * 🔴 A ROUTE WITHOUT ITS CONTROLLER IS A DISPATCH-TIME 500, NOT A 404. + * + * `Routes::standard()` declares `/api/store/items` for EVERY app that + * adopts the canonical table, but the binding lives here. An app that took + * the route table and binds its controllers by hand, rather than calling + * {@see self::register()}, therefore serves a route it cannot resolve. + * Measured 2026-09-03 on a running instance: decidiq, filinq and planninq + * each returned HTTP 500 on `/api/store/items`, on a route none of them + * had asked for, while keepiq (which calls `register()`) answered fine. + * + * This is the one call such an app needs, and it is public for exactly + * that reason. It stays a no-op when the leaf ships its own + * `Controller\StoreController`, so calling it is always safe. + * + * @param IRegistrationContext $context Leaf registration context. + * @param string $appId Leaf app id. + * @param string $controllerNs Leaf controller namespace (e.g. `OCA\Decidiq\Controller`). + * + * @return void + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-a-leaf-app-must-declare-its-store-rather-than-implement-one + */ + public static function aliasStoreController(IRegistrationContext $context, string $appId, string $controllerNs): void { + self::aliasControllerUnlessLeafDefinesIt( + context: $context, + leafClass: rtrim($controllerNs, '\\') . '\\StoreController', + factory: static function (ContainerInterface $c) use ($appId) { + $class = self::GENERIC_STORE_CONTROLLER; + return new $class( + appName: $appId, + request: $c->get('OCP\\IRequest'), + manifestLoader: $c->get(self::OBSERVABILITY_MANIFEST_LOADER), + storeService: $c->get(self::GENERIC_STORE_SERVICE), + installer: $c->get(self::GENERIC_STORE_INSTALLER), + catalog: $c->get(self::FEDERATED_STORE_CATALOG), + gitHubSource: $c->get(self::GITHUB_STORE_SOURCE), + actionAuthorizer: $c->get(self::STORE_ACTION_AUTHORIZER), + userSession: $c->get('OCP\\IUserSession'), + groupManager: $c->get('OCP\\IGroupManager'), + logger: $c->get('Psr\\Log\\LoggerInterface') + ); + } + ); + + }//end aliasStoreController() + /** * Alias one leaf controller class name to a generic AppHost controller, * but ONLY when the leaf app does not ship a controller of that name diff --git a/lib/AppHost/Controller/GenericStoreController.php b/lib/AppHost/Controller/GenericStoreController.php new file mode 100644 index 0000000000..23db10af5d --- /dev/null +++ b/lib/AppHost/Controller/GenericStoreController.php @@ -0,0 +1,460 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\AppHost\Controller; + +use OCA\OpenRegister\AppHost\Observability\ManifestLoader; +use OCA\OpenRegister\AppHost\Service\GenericStoreService; +use OCA\OpenRegister\AppHost\Service\StoreDescriptor; +use OCA\OpenRegister\AppHost\Store\FederatedStoreCatalog; +use OCA\OpenRegister\AppHost\Store\GenericStoreInstaller; +use OCA\OpenRegister\AppHost\Store\Source\GitHubStoreSource; +use OCA\OpenRegister\AppHost\Store\StoreActionAuthorizer; +use OCA\OpenRegister\AppHost\Store\StoreManifest; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Declarative store search and install for AppHost-adopting apps. + * + * @psalm-suppress UnusedClass + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) One controller serves every + * AppHost app across BOTH store paths, so it holds the objects client, the + * objects installer and the configuration catalogue at once. Splitting it per + * path would put the route alias in two places, and an alias that resolves to + * a class the router cannot find is a dispatch-time 500 rather than a test + * failure. The coupling is the price of one alias. + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-a-leaf-app-must-declare-its-store-rather-than-implement-one + */ +class GenericStoreController extends Controller { + /** + * Kebab-case slug pattern for a remote store item. Bounds what reaches the + * registry URL and the install path. + */ + private const SLUG_PATTERN = '/^[a-z0-9][a-z0-9-]*[a-z0-9]$/'; + + /** + * Constructor. + * + * `$appName` is the CALLING leaf app id, set by that app's alias + * registration, which is what lets one controller serve every app. + * + * @param string $appName Calling leaf app id. + * @param IRequest $request HTTP request. + * @param ManifestLoader $manifestLoader Loads the leaf app's manifest. + * @param GenericStoreService $storeService Guarded remote discovery. + * @param GenericStoreInstaller $installer Declarative component install. + * @param FederatedStoreCatalog $catalog Configuration browse and install. + * @param GitHubStoreSource $gitHubSource GitHub discovery source. + * @param StoreActionAuthorizer $actionAuthorizer ADR-023 action resolver. + * @param IUserSession $userSession Current session. + * @param IGroupManager $groupManager Admin check for install. + * @param LoggerInterface $logger PSR logger. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Each collaborator is one + * discovery or install path the engine owns on a leaf app's behalf, and the + * count grew because the plane absorbed work the apps used to do + * themselves. Grouping them behind a locator would hide which paths exist + * from the one file that has to choose between them. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly ManifestLoader $manifestLoader, + private readonly GenericStoreService $storeService, + private readonly GenericStoreInstaller $installer, + private readonly FederatedStoreCatalog $catalog, + private readonly GitHubStoreSource $gitHubSource, + private readonly StoreActionAuthorizer $actionAuthorizer, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * Search the remote store. + * + * Login-required through an in-body guard so an anonymous caller gets an + * explicit 401 rather than a login redirect. Returns normalised cards and a + * generic outcome, NEVER the registry URL or token. + * + * With no registry configured the engine answers `not_configured` WITHOUT + * making a network call, which is what lets the page fall back to the app's + * built-in items (ADR-080 Decision 4). + * + * @return JSONResponse 200 with `{outcome, cards}`; 401 for anonymous. + * + * @no-admin-idor-exempt Addresses no object of this app. The query and kind + * filter are forwarded to an EXTERNAL registry, so there is nothing of + * another tenant's to reach by guessing an identifier. + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-a-leaf-app-must-declare-its-store-rather-than-implement-one + */ + #[NoAdminRequired] + public function search(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse( + data: ['outcome' => 'unauthenticated', 'cards' => []], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + $store = $this->store(); + if ($store->enabled === false) { + // The app aliased the route but declared no `store` block. Report + // not_configured rather than 404: the page then renders its + // (empty) built-in list instead of reading as a broken endpoint. + return new JSONResponse( + data: [ + 'outcome' => GenericStoreService::OUTCOME_NOT_CONFIGURED, + 'cards' => [], + 'kinds' => [], + 'builtIn' => [], + ], + statusCode: Http::STATUS_OK + ); + } + + $query = $this->request->getParam('q'); + if (is_string($query) === false) { + $query = null; + } + + $kind = $this->request->getParam('kind'); + if (is_string($kind) === false) { + $kind = null; + } + + try { + $descriptor = $this->descriptor(store: $store); + $result = $this->searchFor(store: $store, descriptor: $descriptor, query: $query, kind: $kind); + } catch (Throwable $e) { + // Detail to the log, generic outcome to the browser: a registry's + // internals are not the caller's business. + $this->logger->error( + message: sprintf('[AppHost\\Store] search failed for %s: %s', $this->appName, $e->getMessage()), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return new JSONResponse( + data: [ + 'outcome' => GenericStoreService::OUTCOME_UNREACHABLE, + 'cards' => [], + 'kinds' => $store->kinds, + 'builtIn' => $store->builtIn, + ], + statusCode: Http::STATUS_OK + ); + } + + // `kinds` rides back with the cards so the page can offer the filters the + // APP declared, rather than a copy kept in its page config. Empty when + // the app names none, and the page falls back to the shared vocabulary. + // A federated store's cards are discriminated by TYPE, so the declared + // type ids are the honest filter set when the app names no kinds of its + // own. Falling through to the shared kind vocabulary would offer chips + // (`adapter`, `agent-template`) that match nothing on this page. + $kinds = $store->kinds; + if ($kinds === [] && $store->isFederated() === true) { + $kinds = $store->declaredTypes(); + } + + // 🔴 `builtIn` RIDES BACK FOR THE SAME REASON `kinds` DOES. + // + // An app declares its own items ONCE, in the `store` block beside the + // allowlist. StoreManifest parses them and, until now, nothing ever + // sent them anywhere: `CnStorePage`'s `builtIn` prop is fed from the + // PAGE CONFIG, which is a second place to write the same list. An app + // that declared them only in the `store` block therefore rendered an + // empty store while its manifest said otherwise. + // + // Measured on decidiq, which declares four example sets and showed + // none of them: the search response carried `outcome`, `cards` and + // `kinds`, and no `builtIn` key at all. ADR-080 Decision 4 exists to + // stop precisely that blank surface. + return new JSONResponse( + data: [ + 'outcome' => $result['outcome'], + 'cards' => $result['cards'], + 'kinds' => $kinds, + 'builtIn' => $store->builtIn, + ], + statusCode: Http::STATUS_OK + ); + }//end search() + + /** + * Install one store item into this instance. + * + * Administrative: the components written are the shape of the work every + * handler then operates against. + * + * The write runs as the calling administrator and NOT through + * `runAsSystem()`. That elevation is for operations whose inputs originate + * from code or the app's own seed data; a store payload is neither, it + * comes off the network. + * + * @param string $slug The remote item slug. + * + * @return JSONResponse 200 with a per-component report; 400 bad slug; 403 non-admin; 404 unresolved. + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-an-install-must-refuse-every-schema-the-manifest-does-not-allow + */ + #[NoAdminRequired] + public function install(string $slug): JSONResponse { + // The gate is an in-body guard rather than #[AuthorizedAdminSetting], + // because that attribute names a leaf app's settings CLASS and this + // controller serves every app. Resolved at request time instead of at + // annotation time — which is also what lets the APP declare the + // posture rather than the engine assuming one. + $user = $this->userSession->getUser(); + + if (preg_match(self::SLUG_PATTERN, $slug) !== 1) { + return new JSONResponse( + data: ['success' => false, 'message' => 'Malformed item slug.'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $store = $this->store(); + if ($store->enabled === false) { + return new JSONResponse( + data: ['success' => false, 'message' => 'This app declares no store.'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + // A posture this engine cannot yet enforce is REFUSED, never + // downgraded. `action:` needs an ADR-023 resolver that lives in + // the leaf apps; until that lands, treating it as `authenticated` + // would quietly install on a weaker gate than the app asked for. + if ($store->isInstallAuthEnforceable() === false) { + $this->logger->error( + message: sprintf( + '[AppHost\\Store] %s declares an installAuth this engine cannot enforce: %s', + $this->appName, + $store->installAuth + ), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return new JSONResponse( + data: ['success' => false, 'message' => 'This store declares an install posture this server cannot enforce.'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + if ($this->mayInstall(store: $store, user: $user) === false) { + return new JSONResponse( + data: ['success' => false, 'message' => 'Installing a store item requires an administrator.'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + $descriptor = $this->descriptor(store: $store); + + try { + $item = $this->resolveFor(descriptor: $descriptor, slug: $slug); + } catch (Throwable $e) { + $this->logger->error( + message: sprintf('[AppHost\\Store] resolve failed for %s: %s', $this->appName, $e->getMessage()), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + $item = null; + } + + if ($item === null) { + return new JSONResponse( + data: ['success' => false, 'message' => 'The store item could not be resolved.'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + // A configuration bundle is applied by the type that owns it, so that a + // set arrives as registers, schemas, flows and objects rather than as + // rows of whichever schema the plane happened to allow. + if ($descriptor->isFederated() === true) { + return new JSONResponse(data: $this->catalog->install(ref: $item), statusCode: Http::STATUS_OK); + } + + return new JSONResponse( + data: $this->installer->install(store: $store, item: $item), + statusCode: Http::STATUS_OK + ); + }//end install() + + /** + * Whether this caller may install, per the posture the app declared. + * + * WHO may install is the app's declaration; WHAT may be written stays the + * `installable` allowlist, which the installer enforces regardless. + * Anonymous is refused whatever the posture: `authenticated` is the weakest + * the vocabulary offers and it still means signed in. + * + * An action posture is resolved against the LEAF app's own ADR-023 matrix. + * StoreManifest deliberately answers false for it, because that matrix is + * not visible from there and assuming `admin` would turn "the operators who + * hold this action" into "instance administrators" — the capability loss + * this key exists to prevent. + * + * @param StoreManifest $store The declared store block. + * @param IUser|null $user The signed-in user, or null. + * + * @return bool + * + * @spec openspec/changes/store-plane-action-auth/specs/apphost-store-plane/spec.md#requirement-an-action-posture-must-resolve-against-the-declaring-app + */ + private function mayInstall(StoreManifest $store, ?IUser $user): bool { + $action = $store->installAction(); + if ($action !== null) { + // StoreManifest answers false for an action posture by design, so + // this is the only arm that can grant one. + return ($user !== null + && $this->actionAuthorizer->can(appId: $this->appName, action: $action, user: $user)); + } + + return $store->permitsInstall( + isSignedIn: ($user !== null), + isAdmin: ($user !== null && $this->groupManager->isAdmin($user->getUID())) + ); + }//end mayInstall() + + /** + * Search whichever catalogue this app declared. + * + * An app that declares shareable types exchanges CONFIGURATION, so its + * catalogue is what publishers have published, not one remote instance's + * rows. Selected by declaration, never by probing: an app that declares + * none makes no discovery call at all. + * + * @param StoreManifest $store The calling app's declared store block. + * @param StoreDescriptor $descriptor The calling app's store parameters. + * @param string|null $query Optional free-text search term. + * @param string|null $kind Optional kind filter. + * + * @return array{outcome: string, cards: array>} + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#scenario-an-app-that-declares-no-types-keeps-the-object-store + */ + private function searchFor( + StoreManifest $store, + StoreDescriptor $descriptor, + ?string $query, + ?string $kind + ): array { + // The declared source is checked FIRST. A github store has no registry + // URL at all, so falling through to the OpenRegister path would report + // it as `not_configured` — true of a registry it never declared, and + // useless to whoever has to act on it. + if ($store->source === 'github') { + return $this->gitHubSource->search( + manifest: $store, + query: ($query ?? ''), + kind: $kind + ); + } + + if ($descriptor->isFederated() === true) { + return $this->catalog->search(descriptor: $descriptor, query: $query, kind: $kind); + } + + return $this->storeService->search(descriptor: $descriptor, query: $query, kind: $kind); + }//end searchFor() + + /** + * Resolve a slug against whichever catalogue this app declared. + * + * @param StoreDescriptor $descriptor The calling app's store parameters. + * @param string $slug The item slug. + * + * @return array|null The resolved item, or null when unresolved. + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#requirement-a-configuration-install-must-run-through-its-owning-type + */ + private function resolveFor(StoreDescriptor $descriptor, string $slug): ?array { + if ($descriptor->isFederated() === true) { + return $this->catalog->resolve(descriptor: $descriptor, slug: $slug); + } + + return $this->storeService->resolve(descriptor: $descriptor, slug: $slug); + }//end resolveFor() + + /** + * The calling app's declared store configuration. + * + * @return StoreManifest + */ + private function store(): StoreManifest { + return $this->manifestLoader->loadStore(appId: $this->appName); + }//end store() + + /** + * Build the discovery descriptor from the declared block. + * + * The registry URL and token stay in the CALLING app's IAppConfig, so each + * app connects its own registry and one app's token never reaches another. + * + * @param StoreManifest $store The declared store config. + * + * @return StoreDescriptor + */ + private function descriptor(StoreManifest $store): StoreDescriptor { + return new StoreDescriptor( + appId: $this->appName, + schema: $store->schema, + defaultRegister: $store->register, + cardFields: $store->cardFields, + types: $store->declaredTypes() + ); + }//end descriptor() +}//end class diff --git a/lib/AppHost/Observability/ManifestLoader.php b/lib/AppHost/Observability/ManifestLoader.php index 112729507a..91e9a17e8a 100644 --- a/lib/AppHost/Observability/ManifestLoader.php +++ b/lib/AppHost/Observability/ManifestLoader.php @@ -27,6 +27,7 @@ namespace OCA\OpenRegister\AppHost\Observability; +use OCA\OpenRegister\AppHost\Store\StoreManifest; use OCP\App\IAppManager; use Psr\Log\LoggerInterface; use Throwable; @@ -72,6 +73,32 @@ public function load(string $appId): ObservabilityManifest { return ObservabilityManifest::fromManifest(appId: $appId, manifest: $manifest); }//end load() + /** + * Load the declarative `store` block for an app id. + * + * Reuses the same bundled-manifest read as `load()`, so there is exactly + * one place that knows where a leaf app's manifest lives. A missing or + * unreadable manifest yields a DISABLED store rather than a defaulted one: + * the word Store promises a registry (ADR-080 Decision 4) and the engine + * must not promise it on an app's behalf. + * + * @param string $appId The calling app's id. + * + * @return StoreManifest + * + * @SuppressWarnings(PHPMD.StaticAccess) + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-a-leaf-app-must-declare-its-store-rather-than-implement-one + */ + public function loadStore(string $appId): StoreManifest { + $manifest = $this->loadBundledManifest(appId: $appId); + if ($manifest === null) { + return new StoreManifest(appId: $appId, enabled: false); + } + + return StoreManifest::fromManifest(appId: $appId, manifest: $manifest); + }//end loadStore() + /** * Resolve the installed version of an app (for the implicit `{app}_info`). * diff --git a/lib/AppHost/Routes.php b/lib/AppHost/Routes.php index 8a8b56f4eb..0506be161f 100644 --- a/lib/AppHost/Routes.php +++ b/lib/AppHost/Routes.php @@ -151,6 +151,34 @@ private static function canonicalRoutes(): array { // Observability (ADR-006 / ADR-040). ['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'], ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'], + + // Store plane (ADR-080, ADR-114 Decision 4). Declaring these here + // is only HALF the wiring: the leaf app's `Controller\StoreController` + // must ALSO be aliased at the engine's GenericStoreController, or + // the router resolves a class that does not exist and every store + // request 500s at dispatch time. + // + // 🔴 AND NOT EVERY ADOPTER CALLS `Bootstrap::register()`. This + // table is adopted independently of that bootstrap, and an app that + // binds its controllers by hand gets the routes and none of the + // bindings. Measured 2026-09-03 on a running instance: decidiq, + // filinq and planninq each returned HTTP 500 here, on a route none + // of them had asked for, while keepiq answered fine because it does + // call `register()`. Such an app needs exactly one line, + // `Bootstrap::aliasStoreController()`, which is public for this. + // + // An app that declares no `store` block in its manifest still gets + // these routes, and the controller answers `not_configured` for + // them. That is deliberate: a route table that varies per app by + // manifest content would make the SPA catch-all's position depend + // on configuration, and the catch-all's position is load-bearing. + ['name' => 'store#search', 'url' => '/api/store/items', 'verb' => 'GET'], + [ + 'name' => 'store#install', + 'url' => '/api/store/items/{slug}/install', + 'verb' => 'POST', + 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]'], + ], ]; }//end canonicalRoutes() @@ -165,7 +193,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/AppHost/Service/GenericStoreService.php b/lib/AppHost/Service/GenericStoreService.php index f6d6168d82..b5e007e2a0 100644 --- a/lib/AppHost/Service/GenericStoreService.php +++ b/lib/AppHost/Service/GenericStoreService.php @@ -82,6 +82,17 @@ class GenericStoreService { */ public const OUTCOME_INVALID = 'store_invalid_response'; + /** + * Outcome: the source refused because a rate limit is in force. + * + * 🔴 NOT INTERCHANGEABLE WITH `store_unreachable`. The remedy differs and + * the reader acts on it: rate limited means wait, or add a credential to + * raise the limit; unreachable means the network or the registry is + * broken. Reporting the first as the second sends somebody to debug a + * network that is fine. + */ + public const OUTCOME_RATE_LIMITED = 'rate_limited'; + /** * Connect + request timeout (seconds) for every remote fetch. */ diff --git a/lib/AppHost/Service/StoreDescriptor.php b/lib/AppHost/Service/StoreDescriptor.php index 01eb842e92..3e15aff3c0 100644 --- a/lib/AppHost/Service/StoreDescriptor.php +++ b/lib/AppHost/Service/StoreDescriptor.php @@ -48,6 +48,11 @@ final class StoreDescriptor { * normalisation; a missing property yields an empty * string rather than a missing key, so the frontend * never has to null-check a card. + * @param array $types Shareable configuration type ids the calling app declared. + * A non-empty list selects federated discovery, where an item + * is a configuration set, a flow or a schema that marked + * itself shareable. An empty list keeps the remote objects + * API, so an app that has not moved is untouched. * * @return void */ @@ -62,6 +67,18 @@ public function __construct( 'category' => 'category', 'version' => 'version', ], + public readonly array $types = [], ) { }//end __construct() + + /** + * Whether this descriptor selects federated configuration discovery. + * + * @return bool True when the app declared at least one shareable type. + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#requirement-a-store-descriptor-must-carry-every-per-app-parameter + */ + public function isFederated(): bool { + return $this->types !== []; + }//end isFederated() }//end class diff --git a/lib/AppHost/Store/FederatedStoreCatalog.php b/lib/AppHost/Store/FederatedStoreCatalog.php new file mode 100644 index 0000000000..5070ee8b00 --- /dev/null +++ b/lib/AppHost/Store/FederatedStoreCatalog.php @@ -0,0 +1,384 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.nl + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\AppHost\Store; + +use OCA\OpenRegister\AppHost\Service\GenericStoreService; +use OCA\OpenRegister\AppHost\Service\StoreDescriptor; +use OCA\OpenRegister\Service\Config\FederatedConfigService; +use OCA\OpenRegister\Service\Config\ShareableConfigTypeRegistry; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Browse and install published configuration through the store surface. + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#requirement-a-store-must-be-able-to-offer-configuration-not-only-objects + */ +class FederatedStoreCatalog { + /** + * Where a published repository carries its bundle. + * + * Discovery returns repositories, and a repository is not a bundle, so + * something has to say where the bundle lives. `publish()` takes a + * caller-supplied path, which is fine for a link the publisher hands you + * and useless for a browsable store: a card with no path cannot be + * installed. This is that convention, tried in order. + * + * @var array + */ + public const BUNDLE_PATHS = [ + 'openregister.json', + '.openregister/config.json', + ]; + + /** + * Constructor. + * + * @param ShareableConfigTypeRegistry $registry Resolves a declared type id to its type. + * @param FederatedConfigService $federated Discovery, fetch, trust and install. + * @param LoggerInterface $logger PSR logger, server-side detail only. + */ + public function __construct( + private readonly ShareableConfigTypeRegistry $registry, + private readonly FederatedConfigService $federated, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * The stable card slug for a published repository of one type. + * + * A card slug travels through a URL path segment, so it must satisfy the + * controller's slug pattern: lowercase alphanumerics and hyphens. The + * encoding is deterministic rather than reversible, and `resolve()` + * recomputes every candidate's slug and compares, exactly as the objects + * path compares the slug the registry actually returned. + * + * @param string $typeId The shareable type id. + * @param string $repo The `owner/repo`. + * + * @return string The card slug. + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#requirement-a-configuration-install-must-run-through-its-owning-type + */ + public static function slugFor(string $typeId, string $repo): string { + $raw = strtolower($typeId . '-' . $repo); + $slug = preg_replace('/[^a-z0-9]+/', '-', $raw); + return trim((string)$slug, '-'); + }//end slugFor() + + /** + * Browse every declared type's published configuration. + * + * @param StoreDescriptor $descriptor The calling app's store parameters. + * @param string|null $query Optional free-text filter. + * @param string|null $kind Optional type-id filter, matching the surface's kind chips. + * + * @return array{outcome: string, cards: array>} + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#scenario-a-configuration-set-reaches-the-store-surface + */ + public function search(StoreDescriptor $descriptor, ?string $query = null, ?string $kind = null): array { + $cards = []; + + foreach ($descriptor->types as $typeId) { + $type = $this->registry->get(id: $typeId); + if ($type === null) { + // A declared type nothing owns is a manifest error, not a + // runtime failure: name it in the log and carry on, so one + // stale id does not blank the whole store. + $this->logger->warning( + message: sprintf( + '[AppHost\\Store] %s declares shareable type %s, which no app owns', + $descriptor->appId, + $typeId + ), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + continue; + } + + if ($kind !== null && trim($kind) !== '' && trim($kind) !== $typeId) { + continue; + } + + try { + $found = $this->federated->discover(topic: $type->getTopic()); + } catch (Throwable $e) { + $this->logger->warning( + message: sprintf('[AppHost\\Store] discovery failed for %s: %s', $typeId, $e->getMessage()), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + continue; + } + + foreach ($found as $entry) { + $card = $this->toCard(typeId: $typeId, displayName: $type->getDisplayName(), entry: $entry); + if ($this->matches(card: $card, query: $query) === true) { + $cards[] = $card; + } + } + }//end foreach + + return ['outcome' => GenericStoreService::OUTCOME_OK, 'cards' => $cards]; + }//end search() + + /** + * Resolve a card slug back to the bundle it names. + * + * @param StoreDescriptor $descriptor The calling app's store parameters. + * @param string $slug The card slug. + * + * @return array{typeId: string, repo: string, source: string, bundle: array}|null + * The resolved bundle, or null when nothing matches. + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#requirement-a-configuration-install-must-run-through-its-owning-type + */ + public function resolve(StoreDescriptor $descriptor, string $slug): ?array { + foreach ($descriptor->types as $typeId) { + $type = $this->registry->get(id: $typeId); + if ($type === null) { + continue; + } + + try { + $found = $this->federated->discover(topic: $type->getTopic()); + } catch (Throwable $e) { + $this->logger->warning( + message: sprintf('[AppHost\\Store] discovery failed for %s: %s', $typeId, $e->getMessage()), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + continue; + } + + foreach ($found as $entry) { + $repo = (string)($entry['repo'] ?? ''); + if ($repo === '' || self::slugFor(typeId: $typeId, repo: $repo) !== $slug) { + continue; + } + + $bundle = $this->fetch(repo: $repo); + if ($bundle === null) { + return null; + } + + return [ + 'typeId' => $typeId, + 'repo' => $repo, + 'source' => (string)($entry['url'] ?? $repo), + 'bundle' => $bundle, + ]; + } + }//end foreach + + return null; + }//end resolve() + + /** + * Install a resolved bundle through the type that owns it. + * + * The source check runs BEFORE the install rather than inside it. A bundle + * from a publisher this organisation has not trusted is refused whole: a + * half-applied configuration set is worse than none, because nothing then + * says which half arrived. + * + * @param array $ref The resolved bundle from {@see self::resolve()}, shaped + * `{typeId: string, repo: string, source: string, bundle: array}`. + * + * @return array{success: bool, components: array>} The install report. + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#scenario-an-untrusted-publisher-is-refused + */ + public function install(array $ref): array { + $source = (string)($ref['source'] ?? ''); + + if ($this->federated->isSourceAllowed(source: $source) === false) { + return [ + 'success' => false, + 'components' => [ + [ + 'schema' => (string)($ref['typeId'] ?? ''), + 'status' => 'refused', + 'message' => sprintf('This organisation does not trust %s as a store source.', $source), + ], + ], + ]; + } + + try { + $result = $this->federated->install( + typeId: (string)$ref['typeId'], + bundle: (array)$ref['bundle'], + source: $source + ); + } catch (Throwable $e) { + $this->logger->error( + message: sprintf('[AppHost\\Store] installing %s failed: %s', (string)$ref['typeId'], $e->getMessage()), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return [ + 'success' => false, + 'components' => [ + [ + 'schema' => (string)$ref['typeId'], + 'status' => 'error', + 'message' => 'The configuration could not be installed.', + ], + ], + ]; + }//end try + + $installed = ($result['installed'] ?? []); + if (is_array($installed) === false) { + $installed = []; + } + + $components = []; + foreach ($installed as $entry) { + $components[] = [ + 'schema' => $this->componentName(entry: $entry, fallback: (string)$ref['typeId']), + 'status' => 'installed', + 'message' => '', + ]; + } + + if ($components === []) { + $components[] = ['schema' => (string)$ref['typeId'], 'status' => 'installed', 'message' => '']; + } + + return ['success' => true, 'components' => $components]; + }//end install() + + /** + * Name one installed component for the report. + * + * A type reports what it installed in its own terms: some return plain + * names, others return descriptors. Both are read here rather than + * assuming one shape. + * + * @param mixed $entry One entry from the type's install result. + * @param string $fallback The type id, used when the entry names nothing. + * + * @return string The component name. + */ + private function componentName(mixed $entry, string $fallback): string { + if (is_string($entry) === true) { + return $entry; + } + + if (is_array($entry) === true) { + return (string)($entry['type'] ?? $fallback); + } + + return $fallback; + }//end componentName() + + /** + * Read a repository's bundle from the first conventional path that answers. + * + * @param string $repo The `owner/repo`. + * + * @return array|null The decoded bundle, or null when no path answers. + */ + private function fetch(string $repo): ?array { + foreach (self::BUNDLE_PATHS as $path) { + try { + $bundle = $this->federated->fetchBundle(repo: $repo, path: $path); + } catch (Throwable $e) { + continue; + } + + if ($bundle !== []) { + return $bundle; + } + } + + $this->logger->warning( + message: sprintf('[AppHost\\Store] %s carries no bundle at a conventional path', $repo), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + + return null; + }//end fetch() + + /** + * Turn one discovered repository into a store card. + * + * @param string $typeId The shareable type id. + * @param string $displayName The type's human name. + * @param array $entry One discovery result. + * + * @return array The card. + */ + private function toCard(string $typeId, string $displayName, array $entry): array { + $repo = (string)($entry['repo'] ?? ''); + $owner = explode('/', $repo)[0]; + + return [ + 'slug' => self::slugFor(typeId: $typeId, repo: $repo), + 'title' => (string)($entry['name'] ?? $repo), + 'description' => (string)($entry['description'] ?? ''), + // The surface's kind chip is the type, because that is the honest + // discriminator here: a set, a flow and a schema are what differ. + 'kind' => $typeId, + 'type' => $typeId, + 'typeName' => $displayName, + 'publisher' => $owner, + 'source' => (string)($entry['url'] ?? ''), + 'updated' => (string)($entry['updated'] ?? ''), + 'category' => '', + 'version' => '', + ]; + }//end toCard() + + /** + * Whether a card survives the free-text filter. + * + * @param array $card The card. + * @param string|null $query The filter, or null for no filter. + * + * @return bool + */ + private function matches(array $card, ?string $query): bool { + if ($query === null || trim($query) === '') { + return true; + } + + $needle = mb_strtolower(trim($query)); + $hay = mb_strtolower( + (string)$card['title'] . ' ' . (string)$card['description'] . ' ' . (string)$card['publisher'] + ); + + return str_contains($hay, $needle); + }//end matches() +}//end class diff --git a/lib/AppHost/Store/GenericStoreInstaller.php b/lib/AppHost/Store/GenericStoreInstaller.php new file mode 100644 index 0000000000..7b425c3141 --- /dev/null +++ b/lib/AppHost/Store/GenericStoreInstaller.php @@ -0,0 +1,303 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\AppHost\Store; + +use OCA\OpenRegister\Service\ObjectService; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Declarative install of a store item's components. + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-an-install-must-refuse-every-schema-the-manifest-does-not-allow + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-an-install-must-create-a-new-object-never-replace-one + */ +class GenericStoreInstaller { + /** + * Constructor. + * + * @param ObjectService $objectService The OpenRegister write path. + * @param IAppConfig $appConfig The declaring app's config store. + * @param LoggerInterface $logger PSR logger, server-side detail only. + */ + public function __construct( + private readonly ObjectService $objectService, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Install every allowed component and report every refused one. + * + * A refusal does NOT abort the install. The remaining components still + * arrive and the report names what did not: an item that is half + * configuration and half records is the registry's mistake, not a reason to + * deny an administrator the half they may have. + * + * @param StoreManifest $store The calling app's declared store config. + * @param array $item The resolved remote item. + * + * @return array{success: bool, components: array>} + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-an-install-must-refuse-every-schema-the-manifest-does-not-allow + */ + public function install(StoreManifest $store, array $item): array { + $report = []; + $failed = false; + + foreach ($this->components(item: $item) as $component) { + $result = $this->installComponent(store: $store, component: $component); + if ($result['status'] !== 'installed') { + $failed = true; + } + + $report[] = $result; + } + + return ['success' => ($failed === false && $report !== []), 'components' => $report]; + }//end install() + + /** + * Install one component, whatever operation it declares. + * + * An unrecognised `op` is refused for THAT component only. A component + * whose operation this server does not understand is the registry's + * mistake, not a reason to deny an administrator the components it does + * understand. + * + * @param StoreManifest $store The declared store block. + * @param array $component One component of the item. + * + * @return array The per-component report row. + * + * @spec openspec/changes/store-plane-install-ops/specs/apphost-store-plane/spec.md#requirement-a-component-must-declare-its-install-operation-defaulting-to-writing-an-object + */ + private function installComponent(StoreManifest $store, array $component): array { + $op = (string)($component['op'] ?? StoreManifest::DEFAULT_INSTALL_OP); + + if ($op === 'setAppConfig') { + return $this->setAppConfig(store: $store, component: $component); + } + + if ($op !== StoreManifest::DEFAULT_INSTALL_OP) { + return [ + 'schema' => (string)($component['schema'] ?? ''), + 'status' => 'refused', + 'message' => 'This server does not understand that install operation.', + ]; + } + + return $this->writeObject(store: $store, component: $component); + }//end installComponent() + + /** + * Write a component's object into an allowed schema. + * + * @param StoreManifest $store The declared store block. + * @param array $component One component of the item. + * + * @return array + */ + private function writeObject(StoreManifest $store, array $component): array { + $slug = (string)($component['schema'] ?? ''); + $object = ($component['object'] ?? null); + + if ($store->isInstallable(slug: $slug) === false) { + return [ + 'schema' => $slug, + 'status' => 'refused', + 'message' => 'This app does not allow the store to write into that schema.', + ]; + } + + if (is_array($object) === false) { + return [ + 'schema' => $slug, + 'status' => 'refused', + 'message' => 'The component declares no object to install.', + ]; + } + + try { + $this->objectService->saveObject( + object: $this->asNewObject(object: $object), + register: $store->localRegister, + schema: $slug + ); + return ['schema' => $slug, 'status' => 'installed', 'message' => '']; + } catch (Throwable $e) { + $this->logger->error( + message: sprintf( + '[AppHost\\Store] installing %s for %s failed: %s', + $slug, + $store->appId, + $e->getMessage() + ), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return [ + 'schema' => $slug, + 'status' => 'error', + 'message' => 'The component could not be written.', + ]; + } + }//end writeObject() + + /** + * Write an allowlisted config key into the DECLARING app's namespace. + * + * 🔴 The app id comes from the manifest, never from the component. A + * registry naming another app's namespace would otherwise be writing into + * an app that never opted into a store at all. + * + * @param StoreManifest $store The declared store block. + * @param array $component One component of the item. + * + * @return array + * + * @spec openspec/changes/store-plane-install-ops/specs/apphost-store-plane/spec.md#requirement-a-config-write-must-be-allowlisted-by-key-and-scoped-to-the-declaring-app + */ + private function setAppConfig(StoreManifest $store, array $component): array { + $key = (string)($component['key'] ?? ''); + + if ($store->isConfigurable(key: $key) === false) { + return [ + 'schema' => $key, + 'status' => 'refused', + 'message' => 'This app does not allow the store to write that setting.', + ]; + } + + $value = ($component['value'] ?? null); + if (is_scalar($value) === false) { + // Config is a flat key-value store. Serialising a structure into it + // produces a value the app later reads back as a string it never + // wrote. + return [ + 'schema' => $key, + 'status' => 'refused', + 'message' => 'A setting must be a boolean, string or number.', + ]; + } + + try { + $this->appConfig->setValueString($store->appId, $key, (string)$value); + return ['schema' => $key, 'status' => 'installed', 'message' => '']; + } catch (Throwable $e) { + $this->logger->error( + message: sprintf( + '[AppHost\\Store] setting %s for %s failed: %s', + $key, + $store->appId, + $e->getMessage() + ), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return [ + 'schema' => $key, + 'status' => 'error', + 'message' => 'The setting could not be written.', + ]; + } + }//end setAppConfig() + + /** + * Read the components a resolved item declares. + * + * A registry may ship the list as a JSON string rather than an array, the + * same way a workflow template stores its steps. + * + * @param array $item The resolved remote object. + * + * @return array> + */ + private function components(array $item): array { + $components = ($item['components'] ?? null); + if (is_string($components) === true) { + $components = json_decode($components, associative: true); + } + + if (is_array($components) === false) { + return []; + } + + return array_values(array_filter($components, static fn ($c): bool => is_array($c) === true)); + }//end components() + + /** + * Strip every identity the remote payload carries, so an install CREATES. + * + * 🔴 WITHOUT THIS, "INSTALL" IS AN OVERWRITE PRIMITIVE. + * + * `ObjectService::saveObject()` resolves the object it is writing FROM the + * payload: `extractUuidAndNormalizeObject()` reads + * `$object['@self']['id'] ?? $object['id']` and treats a match as the uuid + * to UPDATE. So a store item whose component carried the uuid of this + * instance's live configuration would replace it rather than add one, and + * the write is PUT-semantic, so keys the payload omits are nulled rather + * than left alone. The object would not merely change, it would be gutted. + * + * The schema allowlist does not cover this. It governs WHICH schema a + * component may write, never whether the write creates or replaces, so an + * entirely legitimate component is the attack. + * + * Identity is not a remote registry's to supply. An installed item is a NEW + * local object, and if install ever needs to be idempotent it must key on + * something the app controls rather than on a remote id. + * + * @param array $object The component's object. + * + * @return array The object with every identity key removed. + */ + private function asNewObject(array $object): array { + unset($object['id'], $object['uuid'], $object['@self']); + + return $object; + }//end asNewObject() +}//end class diff --git a/lib/AppHost/Store/Source/GitHubStoreSource.php b/lib/AppHost/Store/Source/GitHubStoreSource.php new file mode 100644 index 0000000000..d149995750 --- /dev/null +++ b/lib/AppHost/Store/Source/GitHubStoreSource.php @@ -0,0 +1,333 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\AppHost\Store\Source; + +use OCA\OpenRegister\AppHost\Service\GenericStoreService; +use OCA\OpenRegister\AppHost\Store\StoreManifest; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\ICache; +use OCP\ICacheFactory; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Discovery against the GitHub repository search API. + * + * 🔴 THE HOST IS A COMPILE-TIME CONSTANT AND MUST STAY ONE. + * + * The `openregister` source reads an admin-configured URL, which is exactly + * why that path carries an SSRF guard and refuses redirects. Here there is no + * URL for an app or an admin to influence, so there is nothing to guard — a + * stronger position, and one that only holds while the constant is a constant. + * Making the host configurable would reintroduce the whole SSRF surface for + * the sake of a case nobody has asked for. + * + * @spec openspec/changes/store-plane-declarative-sources/specs/apphost-store-plane/spec.md#requirement-a-github-source-must-discover-by-topic-against-a-compile-time-host + */ +class GitHubStoreSource implements StoreSourceInterface { + /** + * The only host this source will ever contact. + */ + private const API_HOST = 'https://api.github.com'; + + /** + * Connect + request timeout, seconds. + */ + private const TIMEOUT = 10; + + /** + * Cards returned by a single search, across every topic. + */ + private const MAX_HITS = 30; + + /** + * Seconds a search answer stays fresh. + * + * 🔴 THIS IS NOT AN OPTIMISATION. GitHub's unauthenticated search limit is + * ten requests a minute for the whole instance, so without a cache one + * person typing past the debounce exhausts it and the store turns + * `rate_limited` for everybody. buildiq and hermiq both already cache for + * this reason. + */ + private const SEARCH_TTL = 60; + + /** + * Cache for search answers, or null when no distributed cache is available. + * + * @var ICache|null + */ + private ?ICache $cache = null; + + /** + * Constructor. + * + * @param IClientService $clientService Nextcloud HTTP client factory. + * @param ICacheFactory $cacheFactory Distributed cache factory. + * @param LoggerInterface $logger PSR logger, server-side only. + */ + public function __construct( + private readonly IClientService $clientService, + ICacheFactory $cacheFactory, + private readonly LoggerInterface $logger, + ) { + if ($cacheFactory->isAvailable() === true) { + $this->cache = $cacheFactory->createDistributed('openregister_store_github'); + } + }//end __construct() + + /** + * {@inheritDoc} + * + * @return string + */ + public function sourceId(): string { + return 'github'; + }//end sourceId() + + /** + * Search every declared topic and merge the results. + * + * @param StoreManifest $manifest The declaring app's store block. + * @param string $query Free-text search, possibly empty. + * @param string|null $kind Kind filter, or null for every kind. + * + * @return array{outcome: string, cards: array>} + */ + public function search(StoreManifest $manifest, string $query, ?string $kind): array { + $topics = $this->topicsFor(manifest: $manifest, kind: $kind); + if ($topics === []) { + // A kind that maps to no topic found nothing. That is an empty + // answer, not a broken one. + return ['outcome' => GenericStoreService::OUTCOME_OK, 'cards' => []]; + } + + $key = $this->cacheKey(appId: $manifest->appId, topics: $topics, query: $query); + $hit = ($this->cache?->get($key) ?? null); + if (is_string($hit) === true) { + $decoded = json_decode($hit, true); + if (is_array($decoded) === true && isset($decoded['outcome'], $decoded['cards']) === true) { + return ['outcome' => (string)$decoded['outcome'], 'cards' => (array)$decoded['cards']]; + } + } + + $answer = $this->fetchTopics(topics: $topics, query: $query); + + // 🔴 CACHE THE FAILURES TOO. A rate-limited answer that is not cached + // sends the very next keystroke back at an API that just said stop, + // which is how a brief limit becomes a persistent one. + $this->cache?->set($key, json_encode($answer), self::SEARCH_TTL); + + return $answer; + }//end search() + + /** + * Which topics to search for a given kind filter. + * + * A store may declare one topic per kind, positionally, or a single topic + * for everything. An out-of-range kind narrows to nothing rather than + * quietly searching every topic, because "no results for that kind" is + * true and "here is everything" is not. + * + * @param StoreManifest $manifest The store block. + * @param string|null $kind Kind filter, or null. + * + * @return array + */ + private function topicsFor(StoreManifest $manifest, ?string $kind): array { + if ($kind === null || $kind === '' || $manifest->kinds === []) { + return $manifest->topics; + } + + $index = array_search(needle: $kind, haystack: $manifest->kinds, strict: true); + if ($index === false) { + return []; + } + + return [($manifest->topics[$index] ?? $manifest->topics[0] ?? '')]; + }//end topicsFor() + + /** + * Issue one search per topic and merge, de-duplicating by owner/repo. + * + * @param array $topics Topics to search. + * @param string $query Free-text search. + * + * @return array{outcome: string, cards: array>} + */ + private function fetchTopics(array $topics, string $query): array { + $client = $this->clientService->newClient(); + $cards = []; + $outcome = GenericStoreService::OUTCOME_OK; + + foreach ($topics as $topic) { + if ($topic === '') { + continue; + } + + $one = $this->fetchTopic(client: $client, topic: $topic, query: $query); + if ($one['outcome'] !== GenericStoreService::OUTCOME_OK) { + // Worst outcome wins across topics: one reachable topic does + // not make a half-answer look complete. + $outcome = $one['outcome']; + continue; + } + + foreach ($one['cards'] as $card) { + // De-duplicate by full name: a repository carrying two of the + // declared topics is one item, not two. + $cards[$card['slug']] = $card; + } + } + + return [ + 'outcome' => $outcome, + 'cards' => array_slice(array_values($cards), 0, self::MAX_HITS), + ]; + }//end fetchTopics() + + /** + * Search a single topic. + * + * @param IClient $client The HTTP client. + * @param string $topic The topic to search. + * @param string $query Free-text search. + * + * @return array{outcome: string, cards: array>} + */ + private function fetchTopic(IClient $client, string $topic, string $query): array { + $search = 'topic:' . $topic; + if ($query !== '') { + $search .= ' ' . $query; + } + + try { + $response = $client->get( + self::API_HOST . '/search/repositories', + [ + 'query' => ['q' => $search, 'per_page' => self::MAX_HITS], + 'headers' => ['Accept' => 'application/vnd.github+json'], + 'timeout' => self::TIMEOUT, + 'connect_timeout' => self::TIMEOUT, + 'allow_redirects' => false, + 'http_errors' => false, + ] + ); + } catch (Throwable $e) { + $this->logger->warning( + '[store/github] search failed: ' . $e->getMessage(), + ['topic' => $topic] + ); + return ['outcome' => GenericStoreService::OUTCOME_UNREACHABLE, 'cards' => []]; + } + + $outcome = $this->outcomeFor(status: $response->getStatusCode()); + if ($outcome !== GenericStoreService::OUTCOME_OK) { + return ['outcome' => $outcome, 'cards' => []]; + } + + $body = json_decode((string)$response->getBody(), true); + if (is_array($body) === false || is_array(($body['items'] ?? null)) === false) { + return ['outcome' => GenericStoreService::OUTCOME_INVALID, 'cards' => []]; + } + + $cards = []; + foreach ($body['items'] as $item) { + if (is_array($item) === true) { + $cards[] = $this->toCard(repo: $item, topic: $topic); + } + } + + return ['outcome' => GenericStoreService::OUTCOME_OK, 'cards' => $cards]; + }//end fetchTopic() + + /** + * Map an HTTP status onto a store outcome. + * + * 🔴 403 and 429 are RATE LIMITING, not unreachability. GitHub answers 403 + * for an exhausted limit, which is the case a reader can actually do + * something about. + * + * @param int $status The HTTP status code. + * + * @return string + */ + private function outcomeFor(int $status): string { + if ($status === 403 || $status === 429) { + return GenericStoreService::OUTCOME_RATE_LIMITED; + } + + if ($status < 200 || $status >= 300) { + return GenericStoreService::OUTCOME_UNREACHABLE; + } + + return GenericStoreService::OUTCOME_OK; + }//end outcomeFor() + + /** + * Normalise one repository into a store card. + * + * The slug is `owner/repo`, not the bare name: repository names are not + * unique across owners, and a card list keyed on the bare name silently + * collapses two different apps into one. + * + * @param array $repo One GitHub search result. + * @param string $topic The topic it was found under. + * + * @return array + */ + private function toCard(array $repo, string $topic): array { + return [ + 'slug' => (string)($repo['full_name'] ?? ''), + 'title' => (string)($repo['name'] ?? ''), + 'description' => (string)($repo['description'] ?? ''), + 'kind' => $topic, + 'version' => (string)($repo['default_branch'] ?? ''), + 'publisher' => (string)(($repo['owner'] ?? [])['login'] ?? ''), + 'stars' => (int)($repo['stargazers_count'] ?? 0), + 'url' => (string)($repo['html_url'] ?? ''), + ]; + }//end toCard() + + /** + * Cache key for one search. + * + * Scoped by app id: two apps searching the same topic still get their own + * entry, so a change to one app's block cannot serve stale cards to the + * other. + * + * @param string $appId Declaring app. + * @param array $topics Topics searched. + * @param string $query Free-text search. + * + * @return string + */ + private function cacheKey(string $appId, array $topics, string $query): string { + return $appId . ':' . md5(implode(',', $topics) . '|' . $query); + }//end cacheKey() +} diff --git a/lib/AppHost/Store/Source/StoreSourceInterface.php b/lib/AppHost/Store/Source/StoreSourceInterface.php new file mode 100644 index 0000000000..75effbb856 --- /dev/null +++ b/lib/AppHost/Store/Source/StoreSourceInterface.php @@ -0,0 +1,65 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\AppHost\Store\Source; + +use OCA\OpenRegister\AppHost\Store\StoreManifest; + +/** + * One way of finding store items. + * + * 🔴 A SOURCE NEVER THROWS FOR AN UPSTREAM PROBLEM. A registry that is down, + * rate limited or talking nonsense is a fact to report to the reader, not a + * server error in the app hosting the page: every implementation answers an + * outcome and an empty card list instead. The only exceptions worth raising + * are programming errors in the caller. + * + * @spec openspec/changes/store-plane-declarative-sources/specs/apphost-store-plane/spec.md#requirement-a-store-must-declare-its-discovery-source-defaulting-to-openregister + */ +interface StoreSourceInterface { + /** + * The `source` value this implementation answers to. + * + * Matched against StoreManifest::SOURCES, so a source that names something + * outside that list can never be selected — the manifest would already + * have been rejected as malformed. + * + * @return string + */ + public function sourceId(): string; + + /** + * Search this source for store items. + * + * @param StoreManifest $manifest The declaring app's store block. + * @param string $query Free-text search, possibly empty. + * @param string|null $kind Kind filter, or null for every kind. + * + * @return array{outcome: string, cards: array>} + */ + public function search(StoreManifest $manifest, string $query, ?string $kind): array; +} diff --git a/lib/AppHost/Store/StoreActionAuthorizer.php b/lib/AppHost/Store/StoreActionAuthorizer.php new file mode 100644 index 0000000000..21a894b5dc --- /dev/null +++ b/lib/AppHost/Store/StoreActionAuthorizer.php @@ -0,0 +1,158 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\AppHost\Store; + +use OCP\IUser; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Asks a leaf app whether a user may perform a named action. + * + * 🔴 AN UNRESOLVABLE AUTHORIZER REFUSES. IT NEVER PERMITS. + * + * This is a duck-typed lookup by convention: `OCA\\Service\ + * ActionAuthService::can()`. The fleet has been bitten repeatedly by exactly + * this shape — `isInstalled('docudesk')`, `class_exists('OCA\DocuDesk\…')` — + * where pointing a runtime lookup at a name nothing answers to makes the + * integration a SILENT NO-OP rather than an error. + * + * A no-op here would be an install that skipped its authorization check and + * reported success, so every failure to resolve — class absent, not + * constructible, no `can()` method, `can()` throwing — is a refusal, and each + * one is logged with the name that could not be resolved. + * + * @spec openspec/changes/store-plane-action-auth/specs/apphost-store-plane/spec.md#requirement-an-action-posture-must-resolve-against-the-declaring-app + */ +class StoreActionAuthorizer { + /** + * Constructor. + * + * @param ContainerInterface $container Server container, for the leaf service. + * @param LoggerInterface $logger PSR logger, server-side only. + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether the user may perform the action the store declared. + * + * @param string $appId The declaring leaf app id. + * @param string $action The dot-separated action name. + * @param IUser $user The signed-in user. + * + * @return bool True only when the leaf app's own matrix says yes. + */ + public function can(string $appId, string $action, IUser $user): bool { + $class = 'OCA\\' . $this->studly(appId: $appId) . '\\Service\\ActionAuthService'; + + try { + $service = $this->container->get($class); + } catch (Throwable $e) { + $this->refuse( + appId: $appId, + action: $action, + reason: sprintf('%s is not resolvable (%s)', $class, $e->getMessage()) + ); + return false; + } + + if (method_exists($service, 'can') === false) { + $this->refuse( + appId: $appId, + action: $action, + reason: sprintf('%s has no can() method', $class) + ); + return false; + } + + try { + return ($service->can($user, $action) === true); + } catch (Throwable $e) { + // A throwing matrix is a refusal, not a pass. ADR-023's own + // requireAction() throws to deny, and a `can()` that propagates + // anything must not be read as consent. + $this->refuse( + appId: $appId, + action: $action, + reason: sprintf('can() threw: %s', $e->getMessage()) + ); + return false; + } + }//end can() + + /** + * Log a refusal with the reason it could not be decided. + * + * Logged at ERROR, not WARNING: an app declared a posture this server then + * could not honour, which is a misconfiguration somebody has to fix rather + * than a user being told no. + * + * @param string $appId The declaring app. + * @param string $action The action name. + * @param string $reason Why it could not be decided. + * + * @return void + */ + private function refuse(string $appId, string $action, string $reason): void { + $this->logger->error( + message: sprintf( + '[AppHost\\Store] refusing install for %s: action "%s" could not be authorised — %s', + $appId, + $action, + $reason + ), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + }//end refuse() + + /** + * StudlyCase an app id, matching Bootstrap's own convention. + * + * @param string $appId The app id. + * + * @return string + */ + private function studly(string $appId): string { + $parts = preg_split(pattern: '/[_\-]+/', subject: $appId); + if ($parts === false || count($parts) === 0) { + $parts = [$appId]; + } + + $studly = ''; + foreach ($parts as $part) { + $studly .= ucfirst($part); + } + + return $studly; + }//end studly() +} diff --git a/lib/AppHost/Store/StoreManifest.php b/lib/AppHost/Store/StoreManifest.php new file mode 100644 index 0000000000..f35a5995e3 --- /dev/null +++ b/lib/AppHost/Store/StoreManifest.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 + * + * @version GIT: + * + * @link https://www.OpenRegister.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\AppHost\Store; + +/** + * A leaf app's declarative store configuration. + * + * 🔴 `installable` IS A SECURITY BOUNDARY, NOT A CONVENIENCE. + * + * It is the allowlist of schema slugs a store item may write into this + * instance. A registry is somebody else's server: without the allowlist, an + * item could declare a component naming ANY schema the app owns and have it + * written. dossiq's hand-written version of this refused every record schema + * and permitted only case CONFIGURATION, which is the shape every app wants. + * + * An EMPTY allowlist therefore means "install nothing", never "install + * anything". `isInstallable()` returns false for every slug when the list is + * empty, so an app that declares a store and forgets the allowlist gets a + * browsable store whose installs are all refused, rather than an open door. + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-a-leaf-app-must-declare-its-store-rather-than-implement-one + */ +class StoreManifest { + /** + * Card fields used when an app declares none. These are the field names + * the fleet's registries already publish (ADR-080 Decision 5). + * + * @var array + */ + public const DEFAULT_CARD_FIELDS = [ + 'slug' => 'slug', + 'title' => 'title', + 'description' => 'description', + 'kind' => 'kind', + 'category' => 'category', + 'version' => 'version', + 'publisher' => 'publisher', + ]; + + /** + * Discovery sources a store may declare. + * + * `openregister` reads another OpenRegister's objects endpoint at an + * admin-configured URL, which is why that path is SSRF-guarded and refuses + * redirects. `github` searches the repository API by topic at a COMPILE-TIME + * host, so there is no URL for an app to influence and nothing to guard. + * + * An unknown value is malformed, never a fallback: defaulting to + * `openregister` would silently point an app at a registry it never asked + * for. + * + * @var array + */ + public const SOURCES = ['openregister', 'github']; + + /** + * Config keys an install may write, when a component declares + * `op: setAppConfig`. + * + * 🔴 A SECOND SECURITY BOUNDARY, NOT A CONVENIENCE. `installable` stops a + * remote registry naming any schema the app owns; this stops it naming any + * config key. An app's config namespace holds registry URLs, tokens and + * feature flags, so an unallowlisted write is a remote actor toggling + * whatever it names. An empty list refuses every key, exactly as an empty + * `installable` refuses every schema. + * + * @var array + */ + public const INSTALL_OPS = ['writeObject', 'setAppConfig']; + + /** + * The operation assumed when a component declares none. + */ + public const DEFAULT_INSTALL_OP = 'writeObject'; + + /** + * Install postures a store may declare. + * + * `admin` is an instance administrator. `authenticated` is any signed-in + * user — hermiq and buildiq both rely on it, hermiq because the install + * lands quarantined and the gate that matters is approval. `action:` + * defers to ADR-023, which is integriq's posture. + * + * 🔴 THIS DECIDES WHO, NEVER WHAT. `installable` remains the security + * boundary: an `authenticated` install refuses every schema the allowlist + * omits, exactly as an admin install does. The two keys sit side by side and + * read like a pair; they are not one. + * + * @var array + */ + public const INSTALL_AUTH = ['admin', 'authenticated']; + + /** + * Prefix of the ADR-023 action posture. + */ + public const INSTALL_AUTH_ACTION_PREFIX = 'action:'; + + /** + * The posture assumed when a block declares none. + * + * Deliberately the STRICTEST of them. A block that says nothing gets the + * gate it had before the key existed. + */ + public const DEFAULT_INSTALL_AUTH = 'admin'; + + /** + * The source assumed when a block declares none. + * + * Every store declared before the discriminator existed keeps its behaviour + * exactly, which is the only reason this default may exist at all. + */ + public const DEFAULT_SOURCE = 'openregister'; + + /** + * Constructor. + * + * @param string $appId The calling leaf app id. + * @param bool $enabled Whether the app declared a store block at all. + * @param string $schema Remote schema slug the registry serves. + * @param string $register Default remote register slug. + * @param string $localRegister Local register an install writes into. + * @param array $installable Schema slugs an install may write. + * @param array $kinds Kind quick-filters offered on the page. + * @param array $cardFields Remote field to card field map. + * @param array> $builtIn The app's own items, for the no-registry case. + * @param array $types Shareable configuration type ids this store surfaces. + * @param string $source Discovery source: one of self::SOURCES. + * @param array $topics GitHub topics searched when $source is `github`. + * @param string $installAuth Who may install: see self::INSTALL_AUTH. + * @param array $configurable Config keys an install may write. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) This is a value object + * mirroring the manifest's `store` block one key to one parameter, so the + * count is the block's, not a design choice. Grouping them into sub-objects + * would put a translation layer between what an app declares and what the + * engine reads, which is exactly where a silently dropped key hides. + */ + public function __construct( + public readonly string $appId, + public readonly bool $enabled, + public readonly string $schema = '', + public readonly string $register = '', + public readonly string $localRegister = '', + public readonly array $installable = [], + public readonly array $kinds = [], + public readonly array $cardFields = self::DEFAULT_CARD_FIELDS, + public readonly array $builtIn = [], + public readonly array $types = [], + public readonly string $source = self::DEFAULT_SOURCE, + public readonly array $topics = [], + public readonly string $installAuth = self::DEFAULT_INSTALL_AUTH, + public readonly array $configurable = [], + ) { + }//end __construct() + + /** + * Parse the `store` block out of a leaf app's manifest. + * + * An absent or malformed block yields a DISABLED manifest rather than a + * default-enabled one. A store that nobody declared must not appear: the + * word Store promises a registry (ADR-080 Decision 4), and inventing one + * from defaults would promise it on an app's behalf. + * + * @param string $appId The calling leaf app id. + * @param array $manifest The decoded src/manifest.json. + * + * @return self + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-a-leaf-app-must-declare-its-store-rather-than-implement-one + */ + public static function fromManifest(string $appId, array $manifest): self { + $block = ($manifest['store'] ?? null); + if (is_array($block) === false) { + return new self(appId: $appId, enabled: false); + } + + // An unknown source is MALFORMED, not a fallback. Reporting it as + // `openregister` would point the app at a registry it never declared, and + // the store would then answer `not_configured` for a reason that has + // nothing to do with what the app got wrong. + $source = (string)($block['source'] ?? self::DEFAULT_SOURCE); + if (in_array(needle: $source, haystack: self::SOURCES, strict: true) === false) { + return new self(appId: $appId, enabled: false); + } + + // A github store is configured by its TOPICS, not by a registry URL, so + // declaring none leaves it with nothing to search. That is a malformed + // block rather than an empty store: an empty store is a store that found + // nothing, which is a different thing to report. + // An unrecognised posture DISABLES the block. Falling back to `admin` + // would silently remove a capability from an app that asked for a weaker + // gate: the store still works, for fewer people, for no stated reason, + // which is harder to notice than an outright refusal. + $installAuth = (string)($block['installAuth'] ?? self::DEFAULT_INSTALL_AUTH); + if (self::isKnownInstallAuth(value: $installAuth) === false) { + return new self(appId: $appId, enabled: false); + } + + $topics = self::stringList(value: ($block['topics'] ?? [])); + if ($source === 'github' && $topics === []) { + return new self(appId: $appId, enabled: false); + } + + $cardFields = ($block['cardFields'] ?? null); + if (is_array($cardFields) === false || $cardFields === []) { + $cardFields = self::DEFAULT_CARD_FIELDS; + } + + return new self( + appId: $appId, + enabled: true, + schema: (string)($block['schema'] ?? ''), + register: (string)($block['register'] ?? ''), + // 🔴 SCOPE THE LOCAL WRITE BY REGISTER. A schema slug is NOT unique + // across the fleet — `case`, `task` and `document` exist in several + // apps at once — so resolving an install target by slug alone can + // land in another app's register. Defaults to the app id, which is + // the fleet's register-slug convention, and an app whose register + // slug differs from its app id says so here. + localRegister: (string)($block['localRegister'] ?? $appId), + installable: self::stringList(value: ($block['installable'] ?? [])), + // Declared ONCE, beside the allowlist, and served to the page in the + // search response. An app that names its kinds here and nowhere else + // must get them: a schema key nothing reads is the silent-no-op this + // whole plane is built to avoid. + kinds: self::stringList(value: ($block['kinds'] ?? [])), + cardFields: array_map(callback: static fn ($v): string => (string)$v, array: $cardFields), + builtIn: self::objectList(value: ($block['builtIn'] ?? [])), + source: $source, + topics: $topics, + installAuth: $installAuth, + configurable: self::stringList(value: ($block['configurable'] ?? [])), + // Shareable configuration type ids (store-over-federated-config). + // Declaring these selects federated discovery, where an item is a + // configuration set, a flow or a schema that marked itself + // shareable. An app that declares none keeps the remote objects + // API it has today, so nothing that ships now changes. + types: self::stringList(value: ($block['types'] ?? [])), + ); + }//end fromManifest() + + /** + * The shareable configuration type ids this store surfaces. + * + * @return array The declared type ids, in declaration order. + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#requirement-a-store-must-be-able-to-offer-configuration-not-only-objects + */ + public function declaredTypes(): array { + return $this->types; + }//end declaredTypes() + + /** + * Whether this store exchanges configuration rather than objects. + * + * The two paths are selected by declaration, never by a runtime probe: an + * app that declares types gets federated discovery, and one that declares + * none never makes a discovery call at all. + * + * @return bool True when the app declared at least one shareable type. + * + * @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#requirement-a-store-must-be-able-to-offer-configuration-not-only-objects + */ + public function isFederated(): bool { + return $this->types !== []; + }//end isFederated() + + /** + * Whether an install may write into this schema slug. + * + * @param string $slug The schema slug a store component names. + * + * @return bool + * + * @spec openspec/specs/apphost-store-plane/spec.md#requirement-an-install-must-refuse-every-schema-the-manifest-does-not-allow + */ + public function isInstallable(string $slug): bool { + return $slug !== '' && in_array($slug, $this->installable, true) === true; + }//end isInstallable() + + /** + * Coerce a declared value to a list of non-empty strings. + * + * @param mixed $value The declared value. + * + * @return array + */ + private static function stringList(mixed $value): array { + if (is_array($value) === false) { + return []; + } + + $out = []; + foreach ($value as $entry) { + if (is_string($entry) === true && trim($entry) !== '') { + $out[] = $entry; + } + } + + return array_values(array_unique($out)); + }//end stringList() + + /** + * Coerce a declared value to a list of associative arrays. + * + * @param mixed $value The declared value. + * + * @return array> + */ + private static function objectList(mixed $value): array { + if (is_array($value) === false) { + return []; + } + + return array_values(array_filter($value, static fn ($e): bool => is_array($e) === true)); + }//end objectList() + /** + * Whether a declared install posture is one this engine understands. + * + * The `action:` arm is recognised here but NOT yet enforceable: resolving + * an ADR-023 action needs a resolver that lives in the leaf apps. Until + * that lands, isInstallAuthEnforceable() reports it as unenforceable and + * the controller refuses the install rather than quietly downgrading it to + * `authenticated`. + * + * @param string $value The declared posture. + * + * @return bool + */ + private static function isKnownInstallAuth(string $value): bool { + if (in_array(needle: $value, haystack: self::INSTALL_AUTH, strict: true) === true) { + return true; + } + + return str_starts_with($value, self::INSTALL_AUTH_ACTION_PREFIX) === true + && strlen($value) > strlen(self::INSTALL_AUTH_ACTION_PREFIX); + }//end isKnownInstallAuth() + + /** + * Whether this engine can enforce the declared posture today. + * + * @return bool + */ + public function isInstallAuthEnforceable(): bool { + return $this->isKnownInstallAuth(value: $this->installAuth); + }//end isInstallAuthEnforceable() + + /** + * The ADR-023 action name this store gates its install on, if any. + * + * @return string|null The action name, or null when the posture is not an + * action posture. + */ + public function installAction(): ?string { + if (str_starts_with($this->installAuth, self::INSTALL_AUTH_ACTION_PREFIX) === false) { + return null; + } + + return substr($this->installAuth, strlen(self::INSTALL_AUTH_ACTION_PREFIX)); + }//end installAction() + + /** + * Whether an install by this user is permitted by the declared posture. + * + * 🔴 ANONYMOUS IS REFUSED WHATEVER THE POSTURE. `authenticated` is the + * weakest gate the vocabulary offers, and it still means signed in. + * + * @param bool $isSignedIn Whether a user is signed in. + * @param bool $isAdmin Whether that user is an instance administrator. + * + * @return bool + */ + public function permitsInstall(bool $isSignedIn, bool $isAdmin): bool { + if ($isSignedIn === false) { + return false; + } + + if ($this->installAuth === 'authenticated') { + return true; + } + + // An action posture is NOT decided here. The matrix lives in the leaf + // app, so this object cannot see the answer; returning $isAdmin would + // quietly turn "the operators who hold this action" into "instance + // administrators", which is the capability loss this key exists to + // prevent. The controller resolves it and must not reach this line. + if ($this->installAction() !== null) { + return false; + } + + return $isAdmin; + }//end permitsInstall() + + /** + * Whether an install may write this config key. + * + * 🔴 An EMPTY allowlist refuses everything, never permits everything. An + * app that declares a store and forgets `configurable` gets config writes + * that are all refused, rather than an open door onto its own settings. + * + * @param string $key The config key a component names. + * + * @return bool + * + * @spec openspec/changes/store-plane-install-ops/specs/apphost-store-plane/spec.md#requirement-a-config-write-must-be-allowlisted-by-key-and-scoped-to-the-declaring-app + */ + public function isConfigurable(string $key): bool { + if ($key === '' || $this->configurable === []) { + return false; + } + + return in_array(needle: $key, haystack: $this->configurable, strict: true); + }//end isConfigurable() + +}//end class diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 45973c2c9c..78d81a518f 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -54,7 +54,6 @@ // failed. The first person to actually reference one would have got a fatal at // boot, in the app's own bootstrap, from a line that looks like every other // import in the file. -use OCA\OpenRegister\Db\DeployedWorkflowMapper; use OCA\OpenRegister\Db\EntityRelationMapper; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\MappingMapper; @@ -67,7 +66,6 @@ use OCA\OpenRegister\Db\WebhookMapper; use OCA\OpenRegister\Event\AgentCreatedEvent; use OCA\OpenRegister\Event\AgentUpdatedEvent; -use OCA\OpenRegister\Event\ApprovalStepCompletedEvent; use OCA\OpenRegister\Event\ConfigurationCreatedEvent; use OCA\OpenRegister\Event\ConfigurationUpdatedEvent; use OCA\OpenRegister\Event\DeepLinkRegistrationEvent; @@ -91,7 +89,6 @@ use OCA\OpenRegister\Event\SourceUpdatedEvent; use OCA\OpenRegister\Event\ToolRegistrationEvent; use OCA\OpenRegister\Federation\OpenRegisterCloudFederationProvider; -use OCA\OpenRegister\Listener\ActionListener; use OCA\OpenRegister\Listener\AggregationCacheInvalidationListener; use OCA\OpenRegister\Listener\AggregationThresholdListener; use OCA\OpenRegister\Listener\AnnotationNotificationListener; @@ -101,7 +98,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; @@ -110,7 +106,6 @@ use OCA\OpenRegister\Listener\GrantableRightsInvalidationListener; use OCA\OpenRegister\Listener\HandoffLifecycleListener; use OCA\OpenRegister\Listener\HandoffQueueDrainListener; -use OCA\OpenRegister\Listener\HookListener; use OCA\OpenRegister\Listener\LifecycleActionListener; use OCA\OpenRegister\Listener\LifecycleInitialStateListener; use OCA\OpenRegister\Listener\LifecycleValidationListener; @@ -224,6 +219,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; @@ -235,6 +231,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 +239,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; @@ -268,7 +266,6 @@ use OCA\OpenRegister\Service\Vectorization\VectorEmbeddings; use OCA\OpenRegister\Service\VectorizationService; use OCA\OpenRegister\Service\WebPush\HexIconService; -use OCA\OpenRegister\Service\WorkflowEngineRegistry; use OCA\OpenRegister\Service\XwikiLinkService; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -526,7 +523,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) ); } ); @@ -822,6 +823,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, @@ -881,6 +909,7 @@ function (ContainerInterface $container) { groupManager: $container->get('OCP\IGroupManager'), logger: $container->get('Psr\Log\LoggerInterface'), auditTrailMapper: $container->get(\OCA\OpenRegister\Db\AuditTrailMapper::class), + mountCache: $container->get('OCP\Files\Config\IUserMountCache'), fileService: null ); } @@ -951,9 +980,6 @@ function (ContainerInterface $container) { // Inject MagicMapper for routing seed data to correct magic table. $importHandler->setObjectMapper($container->get(MagicMapper::class)); - // Inject workflow dependencies for deploying workflows during import. - $importHandler->setWorkflowEngineRegistry($container->get(WorkflowEngineRegistry::class)); - $importHandler->setDeployedWorkflowMapper($container->get(DeployedWorkflowMapper::class)); // Optional: services used by seed-related-items to attach files / // notes / tasks. Wrapped in try/catch so a missing dependency @@ -1033,8 +1059,6 @@ function (ContainerInterface $container): ConfigurationExportHandler { logger: $container->get('Psr\Log\LoggerInterface') ); - $exportHandler->setWorkflowEngineRegistry($container->get(WorkflowEngineRegistry::class)); - $exportHandler->setDeployedWorkflowMapper($container->get(DeployedWorkflowMapper::class)); return $exportHandler; } @@ -1110,7 +1134,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', @@ -1181,11 +1204,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 ); } ); @@ -1431,6 +1467,19 @@ 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'), + organisationService: $container->get('OCA\OpenRegister\Service\OrganisationService') + ); + } + ); + $context->registerService( ContactsObjectSourceProvider::class, function (ContainerInterface $container) { @@ -2521,17 +2570,159 @@ 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 + ); + + // Release layer 1 of run-scoped object locking: a run that holds object + // locks releases every one of them when it ends, on ANY of the four + // terminal statuses. Hooked here rather than in a node because a run + // that failed or was reaped never reaches another step, and that is + // exactly the run whose lock most needs releasing. Idempotent, and it + // never rethrows: the dispatch happens inside the run's own terminal + // write. + $context->registerEventListener( + \OCA\OpenRegister\Event\FlowRunTerminalEvent::class, + \OCA\OpenRegister\Listener\FlowRunLockReleaseListener::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 (the listener skips uncommitted dispatches); + // a failure here costs latency, never the completion. + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTerminalEvent::class, + \OCA\OpenRegister\Listener\UserTaskTerminalListener::class + ); + + // Business-timer cancellation propagation (flow-business-timers, design + // D-9): a terminal task (TaskMapper, both write paths) or a terminal run + // (FlowRunMapper) cancels its open timers INSIDE the write that made the + // subject terminal, so no escalation goes out about finished work. The + // listener is idempotent and never deletes. + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTerminalEvent::class, + \OCA\OpenRegister\Listener\FlowTimerSubjectTerminalListener::class + ); + $context->registerEventListener( + \OCA\OpenRegister\Event\FlowRunTerminalEvent::class, + \OCA\OpenRegister\Listener\FlowTimerSubjectTerminalListener::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 + // 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 + ); + + // 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); + // 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\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); $context->registerEventListener(ObjectUpdatingEvent::class, LifecycleValidationListener::class); // Approval-chains declarative wiring — see x-openregister-approval-chains. - // Provisions ApprovalChain rows from the annotation (schema save), then - // gates any lifecycle transition it names until the provisioned chain's - // steps are all approved. Registered immediately after - // LifecycleValidationListener: transition legality must be established - // before approval-chain gating runs against it. + // The annotation is validated at schema save; the gate compiles it into + // a task template on demand and blocks any lifecycle transition it + // names until the object's approval SEQUENCE completes with an + // approving outcome (flow-approval-consolidation). Registered + // immediately after LifecycleValidationListener: transition legality + // must be established before approval gating runs against it. // Declared flows (`x-openregister-flows`) are MATERIALISED into the flow // store on schema save, because a flow lives in its own table rather // than being read off the schema at runtime like every other @@ -2544,7 +2735,18 @@ private function registerEventListeners(IRegistrationContext $context): void { $context->registerEventListener(SchemaCreatedEvent::class, ApprovalChainAnnotationInstaller::class); $context->registerEventListener(SchemaUpdatedEvent::class, ApprovalChainAnnotationInstaller::class); $context->registerEventListener(ObjectUpdatingEvent::class, ApprovalChainGateListener::class); - $context->registerEventListener(ApprovalStepCompletedEvent::class, ApprovalChainAdvanceListener::class); + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskSequenceCompletedEvent::class, + ApprovalChainAdvanceListener::class + ); + + // Sequence progression (flow-approval-consolidation): a committed + // terminal task that belongs to a sequence enables the next position, + // or closes the sequence, in the SAME request as the decision. + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTerminalEvent::class, + \OCA\OpenRegister\Listener\TaskSequenceProgressListener::class + ); // Lifecycle action executor — see x-openregister-lifecycle.transitions[*].actions[]. // Runs the declared actions of a matched transition on the save path, so @@ -2616,22 +2818,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 @@ -2713,14 +2911,6 @@ private function registerEventListeners(IRegistrationContext $context): void { $context->registerEventListener(ObjectDeletedEvent::class, AggregationThresholdListener::class); $context->registerEventListener(ObjectTransitionedEvent::class, AggregationThresholdListener::class); - // HookListener for schema hook execution on lifecycle events. - $context->registerEventListener(ObjectCreatingEvent::class, HookListener::class); - $context->registerEventListener(ObjectUpdatingEvent::class, HookListener::class); - $context->registerEventListener(ObjectDeletingEvent::class, HookListener::class); - $context->registerEventListener(ObjectCreatedEvent::class, HookListener::class); - $context->registerEventListener(ObjectUpdatedEvent::class, HookListener::class); - $context->registerEventListener(ObjectDeletedEvent::class, HookListener::class); - // WebhookEventListener for webhook delivery. // OPS-2: register for EVERY event the listener's extractPayload() handles, // not just create — otherwise update/delete/lock/revert/register/schema @@ -2738,15 +2928,6 @@ private function registerEventListeners(IRegistrationContext $context): void { $context->registerEventListener(SchemaUpdatedEvent::class, WebhookEventListener::class); $context->registerEventListener(SchemaDeletedEvent::class, WebhookEventListener::class); - // OPS-1: ActionListener drives the event-driven Actions feature. It is - // event-agnostic (resolves the payload from the dispatched event), so it - // must be wired to the object lifecycle events or configured Actions - // never fire. - $context->registerEventListener(ObjectCreatedEvent::class, ActionListener::class); - $context->registerEventListener(ObjectUpdatedEvent::class, ActionListener::class); - $context->registerEventListener(ObjectDeletedEvent::class, ActionListener::class); - $context->registerEventListener(ObjectTransitionedEvent::class, ActionListener::class); - // GraphQL subscription event listeners. $context->registerEventListener(ObjectCreatedEvent::class, GraphQLSubscriptionListener::class); $context->registerEventListener(ObjectUpdatedEvent::class, GraphQLSubscriptionListener::class); @@ -2777,6 +2958,28 @@ private function registerEventListeners(IRegistrationContext $context): void { \OCA\OpenRegister\Listener\IntegrationGlobalScriptListener::class ); + // LeafScriptListener is the OTHER half of that, and without it the + // listener above delivers an empty promise for cross-app leaves. It + // installs and populates the registry with OpenRegister's OWN built-ins + // — it cannot contain a sibling app's Vue components, because those + // live in that app's bundle and Nextcloud serves only the current app's + // scripts. + // + // Measured 2026-09-04: no code path anywhere enqueued a providing app's + // bundle on a consuming page. Every addScript() here names + // 'openregister', nc-vue injects no scripts, and the providing apps + // register no template listener. So an ADR-066 leaf reached OCS + // discovery, satisfied gate-24 on both halves, and rendered NOTHING — + // `humaniq-hours` has been dark on dossiq case pages for as long as it + // has shipped. + // + // This enqueues each providing app's dedicated `leaves` entry, scoped + // to pages of apps that ship a register descriptor of their own. + $context->registerEventListener( + \OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent::class, + \OCA\OpenRegister\Listener\LeafScriptListener::class + ); + // PushClientScriptListener loads the always-on, opt-in Web Push // subscribe client on EVERY full-page render (openregister-web-push-engine). // The client never prompts on load — it only subscribes on a user @@ -4256,6 +4459,7 @@ private function bootObjectSourceProviders($server): void { CalDavVtodoObjectSourceProvider::class, UserDirectoryObjectSourceProvider::class, GroupObjectSourceProvider::class, + OrganisationObjectSourceProvider::class, ContactsObjectSourceProvider::class, CalendarEventObjectSourceProvider::class, FilesObjectSourceProvider::class, diff --git a/lib/BackgroundJob/ActionRetryJob.php b/lib/BackgroundJob/ActionRetryJob.php deleted file mode 100644 index 7f1b487a6f..0000000000 --- a/lib/BackgroundJob/ActionRetryJob.php +++ /dev/null @@ -1,192 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - * @spec openspec/specs/actions/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\BackgroundJob; - -use Exception; -use OCA\OpenRegister\Db\ActionLog; -use OCA\OpenRegister\Db\ActionLogMapper; -use OCA\OpenRegister\Db\ActionMapper; -use OCA\OpenRegister\Service\ActionExecutor; -use OCA\OpenRegister\Service\ActionService; -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\IJobList; -use OCP\BackgroundJob\QueuedJob; -use OCP\EventDispatcher\Event; -use Psr\Log\LoggerInterface; - -/** - * Queued job for retrying failed action executions with backoff - * - * @psalm-suppress UnusedClass - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class ActionRetryJob extends QueuedJob { - /** - * Constructor - * - * @param ITimeFactory $time Time factory - * @param ActionMapper $actionMapper Action mapper - * @param ActionExecutor $actionExecutor Action executor - * @param ActionLogMapper $actionLogMapper Action log mapper - * @param ActionService $actionService Action service - * @param IJobList $jobList Job list for re-queuing - * @param LoggerInterface $logger Logger - */ - public function __construct( - ITimeFactory $time, - private readonly ActionMapper $actionMapper, - private readonly ActionExecutor $actionExecutor, - private readonly ActionLogMapper $actionLogMapper, - private readonly ActionService $actionService, - private readonly IJobList $jobList, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - }//end __construct() - - /** - * Run the retry job - * - * @param mixed $arguments Job arguments containing action_id, payload, attempt, max_retries, retry_policy - * - * @return void - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * - * @spec openspec/specs/actions/spec.md - */ - protected function run($arguments): void { - $actionId = $arguments['action_id'] ?? 0; - $payload = $arguments['payload'] ?? []; - $attempt = $arguments['attempt'] ?? 2; - $maxRetries = $arguments['max_retries'] ?? 3; - $retryPolicy = $arguments['retry_policy'] ?? 'exponential'; - - try { - $action = $this->actionMapper->find(id: $actionId); - } catch (Exception $e) { - $this->logger->error( - message: '[ActionRetryJob] Action not found for retry', - context: ['actionId' => $actionId, 'error' => $e->getMessage()] - ); - return; - } - - // Check if max retries exceeded. - if ($attempt > $maxRetries) { - $this->logger->warning( - message: '[ActionRetryJob] Max retries exceeded, abandoning action', - context: [ - 'actionId' => $actionId, - 'actionName' => $action->getName(), - 'attempt' => $attempt, - 'maxRetries' => $maxRetries, - ] - ); - - // Create final log entry with abandoned status. - $log = new ActionLog(); - $log->setActionId($action->getId()); - $log->setActionUuid($action->getUuid()); - $log->setEventType('retry'); - $log->setEngine($action->getEngine()); - $log->setWorkflowId($action->getWorkflowId()); - $log->setStatus('abandoned'); - $log->setAttempt($attempt); - $log->setErrorMessage('Max retries exceeded (' . $maxRetries . ')'); - $log->setRequestPayload(json_encode($payload)); - - $this->actionLogMapper->insert(entity: $log); - $this->actionService->updateStatistics($actionId, 'abandoned'); - - return; - }//end if - - $this->logger->info( - message: '[ActionRetryJob] Retrying action execution', - context: [ - 'actionId' => $actionId, - 'attempt' => $attempt, - ] - ); - - try { - // Execute the action. - $syntheticEvent = new Event(); - - $this->actionExecutor->executeActions( - actions: [$action], - event: $syntheticEvent, - payload: $payload, - eventType: 'retry' - ); - } catch (Exception $e) { - $this->logger->error( - message: '[ActionRetryJob] Retry failed, re-queuing', - context: [ - 'actionId' => $actionId, - 'attempt' => $attempt, - 'error' => $e->getMessage(), - ] - ); - - // Re-queue with incremented attempt. - $this->jobList->add( - self::class, - [ - 'action_id' => $actionId, - 'payload' => $payload, - 'attempt' => ($attempt + 1), - 'max_retries' => $maxRetries, - 'retry_policy' => $retryPolicy, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end run() - - /** - * Calculate retry delay in seconds based on retry policy - * - * @param string $policy Retry policy (exponential, linear, fixed) - * @param int $attempt Current attempt number - * - * @return int Delay in seconds - * - * @spec openspec/specs/actions/spec.md - */ - public static function calculateDelay(string $policy, int $attempt): int { - return match ($policy) { - 'exponential' => (int)pow(2, $attempt) * 60, - 'linear' => $attempt * 300, - 'fixed' => 300, - default => 300, - }; - }//end calculateDelay() -}//end class diff --git a/lib/BackgroundJob/ActionScheduleJob.php b/lib/BackgroundJob/ActionScheduleJob.php deleted file mode 100644 index b2e9141e89..0000000000 --- a/lib/BackgroundJob/ActionScheduleJob.php +++ /dev/null @@ -1,169 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/actions/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\BackgroundJob; - -use Cron\CronExpression; -use DateTime; -use OCA\OpenRegister\Db\ActionMapper; -use OCA\OpenRegister\Service\ActionExecutor; -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\TimedJob; -use OCP\EventDispatcher\Event; -use Psr\Log\LoggerInterface; - -/** - * Timed job that evaluates cron-scheduled actions and executes them when due - * - * Runs every 60 seconds. Queries all actions with non-null schedule field that - * are enabled and active, evaluates their cron expressions, and executes via - * ActionExecutor when due. - * - * @psalm-suppress UnusedClass - */ -class ActionScheduleJob extends TimedJob { - /** - * Constructor - * - * @param ITimeFactory $time Time factory - * @param ActionMapper $actionMapper Action mapper - * @param ActionExecutor $actionExecutor Action executor - * @param LoggerInterface $logger Logger - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function __construct( - ITimeFactory $time, - private readonly ActionMapper $actionMapper, - private readonly ActionExecutor $actionExecutor, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - $this->setInterval(seconds: 60); - }//end __construct() - - /** - * Run the schedule evaluation - * - * @param mixed $argument Job arguments (unused) - * - * @return void - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - * @spec openspec/specs/actions/spec.md - */ - protected function run($argument): void { - try { - $actions = $this->actionMapper->findAll( - filters: [ - 'enabled' => true, - 'status' => 'active', - 'schedule' => 'IS NOT NULL', - ] - ); - - // Further filter to ensure schedule is actually non-null (the mapper filter - // uses IS NOT NULL which is correct, but double-check in PHP). - $scheduledActions = array_filter( - $actions, - function ($action) { - return $action->getSchedule() !== null - && $action->getSchedule() !== '' - && $action->getDeleted() === null; - } - ); - - $now = new DateTime(); - - foreach ($scheduledActions as $action) { - try { - /* - * @psalm-suppress UndefinedClass CronExpression is an optional runtime dependency - */ - - $cron = new CronExpression($action->getSchedule()); - - $lastExecuted = $action->getLastExecutedAt(); - $isDue = true; - - if ($lastExecuted !== null) { - /* - * @psalm-suppress UndefinedClass - */ - - $nextRun = $cron->getNextRunDate($lastExecuted); - $isDue = $nextRun <= $now; - } - - if ($isDue === false) { - continue; - } - - $this->logger->info( - message: '[ActionScheduleJob] Executing scheduled action', - context: [ - 'actionId' => $action->getId(), - 'actionName' => $action->getName(), - 'schedule' => $action->getSchedule(), - ] - ); - - // Build synthetic scheduled event payload. - $payload = [ - 'schedule' => $action->getSchedule(), - 'schemas' => $action->getSchemasArray(), - 'registers' => $action->getRegistersArray(), - ]; - - // Create a synthetic event for scheduled execution. - $syntheticEvent = new Event(); - - $this->actionExecutor->executeActions( - actions: [$action], - event: $syntheticEvent, - payload: $payload, - eventType: 'nl.openregister.action.scheduled' - ); - } catch (\Exception $e) { - $this->logger->error( - message: '[ActionScheduleJob] Error executing scheduled action', - context: [ - 'actionId' => $action->getId(), - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end foreach - } catch (\Exception $e) { - $this->logger->error( - message: '[ActionScheduleJob] Error in schedule evaluation', - context: ['error' => $e->getMessage()] - ); - }//end try - }//end run() -}//end class diff --git a/lib/BackgroundJob/AvgRetentionJob.php b/lib/BackgroundJob/AvgRetentionJob.php index 5a1826d492..357f1ec760 100644 --- a/lib/BackgroundJob/AvgRetentionJob.php +++ b/lib/BackgroundJob/AvgRetentionJob.php @@ -142,6 +142,10 @@ protected function run($argument): void { 'evaluatedActivities' => $summary['evaluatedActivities'], 'skippedActivities' => $summary['skippedActivities'], 'objectsErased' => $summary['objectsErased'], + // A record the archival obligation kept must be visible from + // the cron log. Counting it as erased, or not counting it at + // all, is how a refusal turns back into a silent skip. + 'objectsWithheld' => $summary['objectsWithheld'], ] ); } catch (\Throwable $e) { diff --git a/lib/BackgroundJob/DsarRetentionSweepJob.php b/lib/BackgroundJob/DsarRetentionSweepJob.php index d7f0b3914e..e8a5be4598 100644 --- a/lib/BackgroundJob/DsarRetentionSweepJob.php +++ b/lib/BackgroundJob/DsarRetentionSweepJob.php @@ -141,6 +141,9 @@ protected function run($argument): void { 'purgedCount' => count($summary['purged']), 'skippedHeld' => count($summary['skippedHeld']), 'withinWindow' => $summary['withinWindow'], + // Cases the archival obligation kept. Without this the sweep + // log reads as if they simply were not due. + 'withheldCount' => $summary['withheldCount'], ] ); } catch (\Throwable $e) { diff --git a/lib/BackgroundJob/ExecutionHistoryCleanupJob.php b/lib/BackgroundJob/ExecutionHistoryCleanupJob.php deleted file mode 100644 index bafc547b48..0000000000 --- a/lib/BackgroundJob/ExecutionHistoryCleanupJob.php +++ /dev/null @@ -1,111 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\BackgroundJob; - -use DateTime; -use OCA\OpenRegister\Db\WorkflowExecutionMapper; -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\TimedJob; -use OCP\IAppConfig; -use Psr\Log\LoggerInterface; - -/** - * TimedJob that prunes old workflow execution history records. - * - * Runs once daily. Reads the retention period from IAppConfig - * (key: workflow_execution_retention_days, default: 90). - * - * @psalm-suppress UnusedClass - */ -class ExecutionHistoryCleanupJob extends TimedJob { - /** - * Default retention period in days. - */ - private const DEFAULT_RETENTION_DAYS = 90; - - /** - * Constructor for ExecutionHistoryCleanupJob. - * - * @param ITimeFactory $time Time factory - * @param WorkflowExecutionMapper $executionMapper Execution mapper - * @param IAppConfig $appConfig App configuration - * @param LoggerInterface $logger Logger - */ - public function __construct( - ITimeFactory $time, - private readonly WorkflowExecutionMapper $executionMapper, - private readonly IAppConfig $appConfig, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - // Run once daily (86400 seconds). - $this->setInterval(seconds: 86400); - }//end __construct() - - /** - * Execute the cleanup job. - * - * @param mixed $argument Job argument (unused for TimedJob) - * - * @return void - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - * - * @spec openspec/specs/workflow-operations/spec.md - */ - protected function run($argument): void { - $retentionDays = (int)$this->appConfig->getValueString( - 'openregister', - 'workflow_execution_retention_days', - (string)self::DEFAULT_RETENTION_DAYS - ); - - if ($retentionDays <= 0) { - $retentionDays = self::DEFAULT_RETENTION_DAYS; - } - - $cutoff = new DateTime("-{$retentionDays} days"); - - try { - $deleted = $this->executionMapper->deleteOlderThan($cutoff); - - $this->logger->info( - message: '[ExecutionHistoryCleanupJob] Pruned execution history', - context: [ - 'retentionDays' => $retentionDays, - 'deleted' => $deleted, - 'cutoff' => $cutoff->format('c'), - ] - ); - } catch (\Exception $e) { - $this->logger->error( - message: '[ExecutionHistoryCleanupJob] Failed to prune execution history', - context: ['error' => $e->getMessage()] - ); - } - }//end run() -}//end class diff --git a/lib/BackgroundJob/FlowRunWorker.php b/lib/BackgroundJob/FlowRunWorker.php index 0d1925824f..c261ce7848 100644 --- a/lib/BackgroundJob/FlowRunWorker.php +++ b/lib/BackgroundJob/FlowRunWorker.php @@ -34,11 +34,16 @@ 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\Object\RunLockRegistry; 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 +55,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 +69,7 @@ class FlowRunWorker extends TimedJob { * * @var int */ - private const BATCH = 25; + public const BATCH = 25; /** * Default retention for terminal runs, in days. @@ -165,6 +173,18 @@ 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. + * @param RunLockRegistry|null $locks Run-held object locks, for the orphaned-lock sweep. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) This worker is the one + * cron entry point for run lifecycle work, and each pass it makes needs a + * different collaborator: reaping, claim release, stream failure, consent, + * and now the orphaned-lock sweep. Every one after the fifth is nullable + * with a default so an older DI graph still constructs it. Splitting the + * worker to satisfy the count would buy a second cron job and a second + * ordering to reason about, which is the more expensive of the two. */ public function __construct( ITimeFactory $time, @@ -173,6 +193,10 @@ 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, + private readonly ?RunLockRegistry $locks = null, ) { parent::__construct(time: $time); // A FLOOR, not a schedule. `setInterval` says "not more often than @@ -216,6 +240,7 @@ protected function run($argument): void { $this->advance(run: $run); } + $this->sweepOrphanedLocks(now: $now); $this->prune(now: $now); }//end run() @@ -276,6 +301,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 +329,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 +712,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', @@ -566,4 +729,45 @@ private function prune(DateTime $now): void { }//end try }//end prune() + + /** + * Release object locks whose holding run is terminal, gone or expired. + * + * Release layer 2. Layer 1, the `FlowRunTerminalEvent` listener, covers + * every run that reaches a terminal status, and the reapers above do + * eventually terminate a run whose worker was killed. What neither covers + * is a release that itself failed, or a run row deleted out from under an + * outstanding lock. This collects those. + * + * It reads the run-lock registry, not the objects: locks live in the + * `_locked` column of magic tables, and an instance-wide scan over those + * costs seconds just to PLAN. Never throws: a sweep that can fail the + * whole worker pass would take the queue down with it, and the lock TTL is + * still the backstop underneath. + * + * @param DateTime $now The pass clock. + * + * @return void + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + private function sweepOrphanedLocks(DateTime $now): void { + if ($this->locks === null) { + return; + } + + try { + $released = $this->locks->sweepOrphaned(now: $now, limit: self::BATCH); + if ($released > 0) { + $this->logger->info( + sprintf('[FlowRunWorker] Swept %d orphaned object lock(s).', $released) + ); + } + } catch (\Throwable $e) { + $this->logger->warning( + '[FlowRunWorker] Orphaned-lock sweep failed: ' . $e->getMessage(), + ['exception' => $e] + ); + }//end try + }//end sweepOrphanedLocks() }//end class diff --git a/lib/BackgroundJob/FlowTimerWorker.php b/lib/BackgroundJob/FlowTimerWorker.php new file mode 100644 index 0000000000..f9d1519958 --- /dev/null +++ b/lib/BackgroundJob/FlowTimerWorker.php @@ -0,0 +1,140 @@ + + * 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()); + + $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), %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'] + ), + 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/BackgroundJob/HookRetryJob.php b/lib/BackgroundJob/HookRetryJob.php deleted file mode 100644 index 2989dd339f..0000000000 --- a/lib/BackgroundJob/HookRetryJob.php +++ /dev/null @@ -1,213 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-retry-via-background-job - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\BackgroundJob; - -use Exception; -use OCA\OpenRegister\Db\MagicMapper; -use OCA\OpenRegister\Db\SchemaMapper; -use OCA\OpenRegister\Service\Webhook\CloudEventFormatter; -use OCA\OpenRegister\Service\WorkflowEngineRegistry; -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\IJobList; -use OCP\BackgroundJob\QueuedJob; -use Psr\Log\LoggerInterface; - -/** - * Background job for retrying queued schema hooks - * - * When a hook with `onEngineDown: "queue"` fails because the engine is unreachable, - * this job is scheduled to retry execution later. On success, it updates the - * object's `_validationStatus` to "passed". On failure, it re-queues with backoff. - * - * @psalm-suppress UnusedClass - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ -class HookRetryJob extends QueuedJob { - - /** - * Maximum number of retry attempts before giving up - */ - private const MAX_RETRIES = 5; - - /** - * Constructor for HookRetryJob - * - * @param ITimeFactory $time Time factory - * @param MagicMapper $objectEntityMapper Object mapper - * @param SchemaMapper $schemaMapper Schema mapper - * @param WorkflowEngineRegistry $engineRegistry Engine registry - * @param CloudEventFormatter $cloudEventFormatter CloudEvent formatter - * @param IJobList $jobList Job list for re-queuing - * @param LoggerInterface $logger Logger - */ - public function __construct( - ITimeFactory $time, - private readonly MagicMapper $objectEntityMapper, - private readonly SchemaMapper $schemaMapper, - private readonly WorkflowEngineRegistry $engineRegistry, - private readonly CloudEventFormatter $cloudEventFormatter, - private readonly IJobList $jobList, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - }//end __construct() - - /** - * Run the retry job - * - * @param array $argument Job arguments containing objectId, schemaId, and hook config - * - * @return void - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-retry-via-background-job - */ - protected function run($argument): void { - $objectId = ($argument['objectId'] ?? null); - $schemaId = ($argument['schemaId'] ?? null); - $hook = ($argument['hook'] ?? []); - $attempt = ($argument['attempt'] ?? 1); - $hookId = ($hook['id'] ?? 'unknown'); - - if ($objectId === null || $schemaId === null || empty($hook) === true) { - $this->logger->error( - message: '[HookRetryJob] Missing required arguments', - context: ['argument' => $argument] - ); - return; - } - - $this->logger->info( - message: "[HookRetryJob] Retrying hook '$hookId' for object $objectId (attempt $attempt)", - context: ['hookId' => $hookId, 'objectId' => $objectId, 'attempt' => $attempt] - ); - - try { - $object = $this->objectEntityMapper->find( - identifier: $objectId - ); - $schema = $this->schemaMapper->find(id: (int)$schemaId); - } catch (Exception $e) { - $this->logger->error( - message: "[HookRetryJob] Could not load object or schema: {$e->getMessage()}", - context: ['objectId' => $objectId, 'schemaId' => $schemaId] - ); - return; - } - - $engineType = ($hook['engine'] ?? ''); - $workflowId = ($hook['workflowId'] ?? ''); - $timeout = ($hook['timeout'] ?? 30); - - try { - $engines = $this->engineRegistry->getEnginesByType(engineType: $engineType); - if (empty($engines) === true) { - throw new Exception("No engine found for type '$engineType'"); - } - - $adapter = $this->engineRegistry->resolveAdapter(engine: $engines[0]); - - $payload = $this->cloudEventFormatter->formatAsCloudEvent( - eventType: 'nl.openregister.object.hook-retry', - payload: [ - 'object' => $object->getObject(), - 'schema' => ($schema->getSlug() ?? $schema->getTitle()), - 'register' => $object->getRegister(), - 'action' => 'retry', - 'hookMode' => 'sync', - ], - source: '/apps/openregister/schemas/' . $schema->getId(), - subject: 'object:' . ($object->getUuid() ?? (string)$object->getId()) - ); - - $result = $adapter->executeWorkflow( - workflowId: $workflowId, - data: $payload, - timeout: $timeout - ); - - if ($result->isApproved() === true || $result->isModified() === true) { - $objectData = ($object->getObject() ?? []); - $objectData['_validationStatus'] = 'passed'; - unset($objectData['_validationErrors']); - - if ($result->isModified() === true && $result->getData() !== null) { - $objectData = array_merge($objectData, $result->getData()); - } - - $object->setObject(object: $objectData); - $this->objectEntityMapper->update(entity: $object); - - $this->logger->info( - message: "[HookRetryJob] Hook '$hookId' succeeded on retry for object $objectId", - context: ['hookId' => $hookId, 'objectId' => $objectId] - ); - return; - } - - // Rejected or error — keep failed status. - $this->logger->warning( - message: "[HookRetryJob] Hook '$hookId' returned status '{$result->getStatus()}' on retry", - context: [ - 'hookId' => $hookId, - 'objectId' => $objectId, - 'status' => $result->getStatus(), - ] - ); - } catch (Exception $e) { - $this->logger->warning( - message: "[HookRetryJob] Retry failed for hook '$hookId': {$e->getMessage()}", - context: ['hookId' => $hookId, 'objectId' => $objectId, 'attempt' => $attempt] - ); - - if ($attempt >= self::MAX_RETRIES) { - $this->logger->error( - message: "[HookRetryJob] Max retries reached for hook '$hookId' on object $objectId", - context: ['hookId' => $hookId, 'objectId' => $objectId, 'maxRetries' => self::MAX_RETRIES] - ); - return; - } - - $this->jobList->add( - job: self::class, - argument: [ - 'objectId' => $objectId, - 'schemaId' => $schemaId, - 'hook' => $hook, - 'attempt' => ($attempt + 1), - ] - ); - $this->logger->info( - message: "[HookRetryJob] Re-queued hook '$hookId' for attempt " . ($attempt + 1), - context: ['hookId' => $hookId, 'objectId' => $objectId] - ); - }//end try - }//end run() -}//end class diff --git a/lib/BackgroundJob/LogCleanUpTask.php b/lib/BackgroundJob/LogCleanUpTask.php index 6132f787c9..5320e56a52 100644 --- a/lib/BackgroundJob/LogCleanUpTask.php +++ b/lib/BackgroundJob/LogCleanUpTask.php @@ -3,8 +3,8 @@ /** * OpenRegister Log Cleanup Task * - * This file contains the background job for cleaning up expired audit trail logs - * in the OpenRegister application. + * This file contains the background job that enforces the configured retention + * on the audit trail and the search trail in the OpenRegister application. * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -21,16 +21,27 @@ namespace OCA\OpenRegister\BackgroundJob; use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\SearchTrailMapper; +use OCA\OpenRegister\Service\Settings\ObjectRetentionHandler; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; use OCP\BackgroundJob\TimedJob; use Psr\Log\LoggerInterface; /** - * Background job for cleaning up expired audit trail logs + * Background job that enforces log retention * - * This job runs periodically to remove expired audit trail entries from the database - * to prevent the database from growing indefinitely and maintain performance. + * Runs hourly and performs two INDEPENDENT sweeps: + * + * 1. Audit trail: expired rows are tombstoned (payload destroyed, row and + * hash-chain links kept) through AuditTrailMapper::clearLogs(). + * 2. Search trail: rows past the configured `searchTrailRetention` are + * deleted. Search trail rows are inserted WITHOUT an expiry, so the job + * stamps `expires` first and sweeps second; before this job did that, the + * retention setting was echoed to the admin UI and enforced by nothing, + * the only caller of the sweep being a manual admin endpoint. + * + * A failure in one sweep is logged and never skips the other. * * @package OCA\OpenRegister\BackgroundJob * @@ -38,6 +49,15 @@ */ class LogCleanUpTask extends TimedJob { + /** + * Fallback search trail retention when the setting is absent: 30 days in milliseconds. + * + * Mirrors the default ObjectRetentionHandler::getRetentionSettingsOnly() serves. + * + * @var int + */ + private const DEFAULT_SEARCH_TRAIL_RETENTION_MS = 2592000000; + /** * The audit trail mapper for database operations * @@ -45,6 +65,20 @@ class LogCleanUpTask extends TimedJob { */ private readonly AuditTrailMapper $auditTrailMapper; + /** + * The search trail mapper for database operations + * + * @var SearchTrailMapper + */ + private readonly SearchTrailMapper $searchTrailMapper; + + /** + * The retention settings handler that carries `searchTrailRetention` + * + * @var ObjectRetentionHandler + */ + private readonly ObjectRetentionHandler $retentionHandler; + /** * The logger for logging operations * @@ -55,21 +89,27 @@ class LogCleanUpTask extends TimedJob { /** * Constructor for the LogCleanUpTask * - * @param ITimeFactory $time The time factory for time operations - * @param AuditTrailMapper $auditTrailMapper The audit trail mapper for database operations - * @param LoggerInterface $logger The logger for logging operations + * @param ITimeFactory $time The time factory for time operations + * @param AuditTrailMapper $auditTrailMapper The audit trail mapper for database operations + * @param SearchTrailMapper $searchTrailMapper The search trail mapper for database operations + * @param ObjectRetentionHandler $retentionHandler The retention settings handler + * @param LoggerInterface $logger The logger for logging operations * * @return void * - * @spec openspec/specs/retention-management/spec.md + * @spec openspec/specs/retention-management/spec.md#requirement-the-hourly-log-cleanup-job-must-enforce-the-configured-search-trail-retention */ public function __construct( ITimeFactory $time, AuditTrailMapper $auditTrailMapper, + SearchTrailMapper $searchTrailMapper, + ObjectRetentionHandler $retentionHandler, LoggerInterface $logger, ) { parent::__construct(time: $time); $this->auditTrailMapper = $auditTrailMapper; + $this->searchTrailMapper = $searchTrailMapper; + $this->retentionHandler = $retentionHandler; $this->logger = $logger; // Run every hour (3600 seconds). @@ -85,8 +125,9 @@ public function __construct( /** * Execute the log cleanup task * - * This method is called by the Nextcloud background job system to clean up - * expired audit trail logs from the database. + * Each sweep contains its own failures, so the order below is not a + * dependency: the search trail sweep runs whether or not the audit sweep + * succeeded, and vice versa. * * @param mixed $argument The job argument (not used in this implementation). * @@ -94,9 +135,21 @@ public function __construct( * * @SuppressWarnings(PHPMD.UnusedFormalParameter) * - * @spec openspec/specs/retention-management/spec.md + * @spec openspec/specs/retention-management/spec.md#requirement-the-hourly-log-cleanup-job-must-enforce-the-configured-search-trail-retention */ protected function run($argument): void { + $this->clearAuditTrails(); + $this->clearSearchTrails(); + }//end run() + + /** + * Tombstone expired audit trail rows + * + * @return void + * + * @spec openspec/specs/retention-management/spec.md#requirement-the-hourly-log-cleanup-job-must-enforce-the-configured-search-trail-retention + */ + private function clearAuditTrails(): void { try { // Tombstone expired audit rows. Since or#2265 this destroys the // payload and stamps `purged_at` rather than deleting the row, so @@ -126,7 +179,7 @@ protected function run($argument): void { 'app' => 'openregister', ] ); - } catch (\Exception $e) { + } catch (\Throwable $e) { // Log any errors that occur during cleanup. $this->logger->error( message: '[LogCleanUpTask] Failed to clear expired audit trail logs: ' . $e->getMessage(), @@ -138,5 +191,76 @@ protected function run($argument): void { ] ); }//end try - }//end run() + }//end clearAuditTrails() + + /** + * Delete search trail rows past the configured retention + * + * Two statements, in this order: stamp `expires` on rows that have none + * (SearchTrailMapper::createSearchTrail() never sets it), then delete rows + * whose `expires` has passed. A row stamped under an earlier retention + * keeps that expiry, the same rule the audit trail follows. A non-positive + * retention means "keep": nothing is stamped and nothing is deleted. + * + * @return void + * + * @spec openspec/specs/retention-management/spec.md#requirement-the-hourly-log-cleanup-job-must-enforce-the-configured-search-trail-retention + */ + private function clearSearchTrails(): void { + try { + $settings = $this->retentionHandler->getRetentionSettingsOnly(); + $retentionMs = (int) ($settings['searchTrailRetention'] ?? self::DEFAULT_SEARCH_TRAIL_RETENTION_MS); + + if ($retentionMs <= 0) { + $this->logger->debug( + message: '[LogCleanUpTask] Search trail retention is not positive, search trails are kept', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'app' => 'openregister', + 'searchTrailRetention' => $retentionMs, + ] + ); + return; + } + + $stamped = $this->searchTrailMapper->setExpiryDate(retentionMs: $retentionMs); + $logsCleared = $this->searchTrailMapper->clearLogs(); + + if ($logsCleared === true) { + $this->logger->info( + message: '[LogCleanUpTask] Deleted search trail rows past the configured retention', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'app' => 'openregister', + 'searchTrailRetention' => $retentionMs, + 'newlyStamped' => $stamped, + ] + ); + return; + } + + $this->logger->debug( + message: '[LogCleanUpTask] No expired search trail logs found to clear', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'app' => 'openregister', + 'searchTrailRetention' => $retentionMs, + 'newlyStamped' => $stamped, + ] + ); + } catch (\Throwable $e) { + $this->logger->error( + message: '[LogCleanUpTask] Failed to clear expired search trail logs: ' . $e->getMessage(), + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'app' => 'openregister', + 'exception' => $e, + ] + ); + }//end try + }//end clearSearchTrails() }//end class diff --git a/lib/BackgroundJob/OAuth2TokenRefreshJob.php b/lib/BackgroundJob/OAuth2TokenRefreshJob.php new file mode 100644 index 0000000000..dc7de7018f --- /dev/null +++ b/lib/BackgroundJob/OAuth2TokenRefreshJob.php @@ -0,0 +1,184 @@ + + * 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/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-daily-job-refreshes-active-token-sets-before-they-expire + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\BackgroundJob; + +use OCA\OpenRegister\Service\Credential\CredentialBrokerService; +use OCA\OpenRegister\Service\Credential\CredentialRelinkRequiredException; +use OCA\OpenRegister\Service\Credential\OAuth2RefreshService; +use OCA\OpenRegister\Service\Credential\ProviderCatalogue; +use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Daily sweep that rotates active OAuth2 token sets ahead of their expiry. + */ +class OAuth2TokenRefreshJob extends TimedJob { + + /** + * How often the sweep runs, in seconds. + * + * Once a day. The window the sweep refreshes within is two days wide, so a + * missed tick, on an instance whose cron was down, still leaves a full day of + * slack before anything lapses. + * + * @var integer + */ + private const INTERVAL_SECONDS = 86400; + + /** + * The credential kind this job is about. + * + * @var string + */ + private const KIND = 'oauth2-token-set'; + + /** + * Constructor. + * + * @param ITimeFactory $time Time factory for TimedJob. + * @param ObjectService $objectService Lists the brokered credentials. + * @param ProviderCatalogue $catalogue Resolves each credential's provider entry. + * @param OAuth2RefreshService $refresh Performs the rotation, under the shared lock. + * @param LoggerInterface $logger Reports what was swept and what failed. + * + * @return void + */ + public function __construct( + ITimeFactory $time, + private readonly ObjectService $objectService, + private readonly ProviderCatalogue $catalogue, + private readonly OAuth2RefreshService $refresh, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: self::INTERVAL_SECONDS); + + }//end __construct() + + /** + * Refresh every active token set whose expiry is inside the sweep window. + * + * @param mixed $argument Job argument (unused). + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-daily-job-refreshes-active-token-sets-before-they-expire + */ + protected function run($argument): void { + try { + $credentials = $this->activeTokenSetCredentials(); + } catch (Throwable $listFailure) { + $this->logger->warning('[OAuth2TokenRefreshJob] could not list credentials: ' . $listFailure->getMessage()); + return; + } + + $refreshed = 0; + $failed = 0; + foreach ($credentials as $credentialId => $data) { + try { + $provider = $this->catalogue->get((string)($data['provider'] ?? '')); + if ($provider === null || (string)($provider['kind'] ?? '') !== self::KIND) { + continue; + } + + $didRefresh = $this->refresh->sweepCredential( + credential: $data, + provider: $provider, + credentialId: (string)$credentialId, + scope: (string)($data['scope'] ?? 'personal') + ); + + if ($didRefresh === true) { + $refreshed++; + } + } catch (CredentialRelinkRequiredException $relink) { + // Already recorded on the credential and already notified to its + // owner by the refresh service. Counting it as a failure here would + // make a healthy sweep of a dead grant look like a broken sweep. + $this->logger->info('[OAuth2TokenRefreshJob] credential ' . $credentialId . ' needs re-authorisation'); + } catch (Throwable $failure) { + // One credential's provider being down must not stop the others. + $failed++; + $this->logger->warning( + '[OAuth2TokenRefreshJob] refresh failed for ' . $credentialId . ': ' . $failure->getMessage() + ); + }//end try + }//end foreach + + $this->logger->info( + '[OAuth2TokenRefreshJob] swept ' . count($credentials) . ' connection(s), refreshed ' . $refreshed + . ', failed ' . $failed + ); + }//end run() + + /** + * List the credentials this sweep is allowed to touch, keyed by UUID. + * + * Runs without RBAC because there is no session: the sweep acts for the instance, + * and the authorization that matters (who may USE a credential) is enforced at + * call time by the broker's guard chain, not here. A credential that is not an + * active token set is dropped before anything is read from the custody leaf. + * + * @return array> The candidate credentials. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-daily-job-refreshes-active-token-sets-before-they-expire + */ + private function activeTokenSetCredentials(): array { + $objects = $this->objectService + ->setRegister(CredentialBrokerService::REGISTER) + ->setSchema(CredentialBrokerService::SCHEMA) + ->findAll(config: [], _rbac: false, _multitenancy: false); + + $candidates = []; + foreach ($objects as $object) { + $data = $object->jsonSerialize(); + if ((string)($data['kind'] ?? '') !== self::KIND || (string)($data['status'] ?? '') !== 'active') { + continue; + } + + $uuid = (string)$object->getUuid(); + if ($uuid !== '') { + $candidates[$uuid] = $data; + } + } + + return $candidates; + }//end activeTokenSetCredentials() +}//end class diff --git a/lib/BackgroundJob/ScheduleReconcilerJob.php b/lib/BackgroundJob/ScheduleReconcilerJob.php deleted file mode 100644 index f053b22109..0000000000 --- a/lib/BackgroundJob/ScheduleReconcilerJob.php +++ /dev/null @@ -1,85 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.nl - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\BackgroundJob; - -use OCA\OpenRegister\AppHost\Scheduling\ScheduleReconciler; -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\TimedJob; -use Psr\Log\LoggerInterface; -use Throwable; - -/** - * TimedJob that reconciles manifest schedules into OpenConnector jobs. - * - * @psalm-suppress UnusedClass - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class ScheduleReconcilerJob extends TimedJob { - /** - * Constructor for ScheduleReconcilerJob. - * - * @param ITimeFactory $time Time factory. - * @param ScheduleReconciler $reconciler The AppHost scheduling engine. - * @param LoggerInterface $logger Logger. - */ - public function __construct( - ITimeFactory $time, - private readonly ScheduleReconciler $reconciler, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - // Run every 60 seconds; the reconcile is a cheap set-diff (execution is - // OpenConnector's separate JobTask). - $this->setInterval(seconds: 60); - }//end __construct() - - /** - * Execute one reconciliation sweep. - * - * @param mixed $argument Job argument (unused for TimedJob). - * - * @return void - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - * - * @spec openspec/changes/apphost-manifest-schedules/specs/apphost-scheduling/spec.md - */ - protected function run($argument): void { - try { - $this->reconciler->reconcile(); - } catch (Throwable $e) { - // Defence in depth — reconcile() already never throws. - $this->logger->error( - message: '[ScheduleReconcilerJob] Reconcile sweep failed: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - } - }//end run() -}//end class diff --git a/lib/BackgroundJob/ScheduledWorkflowJob.php b/lib/BackgroundJob/ScheduledWorkflowJob.php deleted file mode 100644 index 485c8976be..0000000000 --- a/lib/BackgroundJob/ScheduledWorkflowJob.php +++ /dev/null @@ -1,261 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-workflow-execution-api-sync-and-async - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\BackgroundJob; - -use DateTime; -use Exception; -use OCA\OpenRegister\Db\ScheduledWorkflow; -use OCA\OpenRegister\Db\ScheduledWorkflowMapper; -use OCA\OpenRegister\Db\WorkflowExecutionMapper; -use OCA\OpenRegister\Service\WorkflowEngineRegistry; -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\TimedJob; -use Psr\Log\LoggerInterface; - -/** - * TimedJob that evaluates and executes scheduled workflows. - * - * Runs every 60 seconds. For each enabled scheduled workflow, checks if the - * configured interval has elapsed since lastRun, and if so, executes the - * workflow via the engine adapter. - * - * @psalm-suppress UnusedClass - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class ScheduledWorkflowJob extends TimedJob { - /** - * Constructor for ScheduledWorkflowJob. - * - * @param ITimeFactory $time Time factory - * @param ScheduledWorkflowMapper $workflowMapper Scheduled workflow mapper - * @param WorkflowEngineRegistry $engineRegistry Engine registry - * @param WorkflowExecutionMapper $executionMapper Execution history mapper - * @param LoggerInterface $logger Logger - */ - public function __construct( - ITimeFactory $time, - private readonly ScheduledWorkflowMapper $workflowMapper, - private readonly WorkflowEngineRegistry $engineRegistry, - private readonly WorkflowExecutionMapper $executionMapper, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - // Run every 60 seconds; individual schedules are checked internally. - $this->setInterval(seconds: 60); - }//end __construct() - - /** - * Execute the scheduled workflow evaluation. - * - * @param mixed $argument Job argument (unused for TimedJob) - * - * @return void - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - * - * @spec openspec/specs/workflow-operations/spec.md - */ - protected function run($argument): void { - $schedules = $this->workflowMapper->findAllEnabled(); - - foreach ($schedules as $schedule) { - try { - $this->evaluateSchedule(schedule: $schedule); - } catch (Exception $e) { - $this->logger->error( - message: '[ScheduledWorkflowJob] Error processing schedule', - context: [ - 'scheduleId' => $schedule->getId(), - 'name' => $schedule->getName(), - 'error' => $e->getMessage(), - ] - ); - } - } - }//end run() - - /** - * Evaluate a single scheduled workflow and execute if due. - * - * @param ScheduledWorkflow $schedule The scheduled workflow entity - * - * @return void - */ - private function evaluateSchedule(ScheduledWorkflow $schedule): void { - $now = new DateTime(); - $lastRun = $schedule->getLastRun(); - - // Check if interval has elapsed since last run. - if ($lastRun !== null) { - $elapsed = ($now->getTimestamp() - $lastRun->getTimestamp()); - if ($elapsed < $schedule->getIntervalSec()) { - return; - } - } - - $startTime = hrtime(true); - $engineType = $schedule->getEngine(); - - try { - $engines = $this->engineRegistry->getEnginesByType($engineType); - if (empty($engines) === true) { - $errorMessage = "No engine found for type '$engineType'"; - $this->handleError(schedule: $schedule, startTime: $startTime, error: $errorMessage); - return; - } - - $engine = $engines[0]; - $adapter = $this->engineRegistry->resolveAdapter($engine); - - $payloadData = []; - if ($schedule->getPayload() !== null) { - $payloadData = (json_decode($schedule->getPayload(), true) ?? []); - } - - $data = array_merge( - $payloadData, - [ - 'scheduledWorkflowId' => $schedule->getId(), - 'registerId' => $schedule->getRegisterId(), - 'schemaId' => $schedule->getSchemaId(), - ] - ); - - $result = $adapter->executeWorkflow( - workflowId: $schedule->getWorkflowId(), - data: $data, - timeout: 120 - ); - - $durationMs = (int)((hrtime(true) - $startTime) / 1_000_000); - - $schedule->setLastRun($now); - $schedule->setLastStatus($result->getStatus()); - $schedule->setUpdated($now); - $this->workflowMapper->update($schedule); - - // Persist execution history. - $errors = null; - if ($result->isError() === true) { - $errors = json_encode($result->getErrors()); - } - - $this->executionMapper->createFromArray( - [ - 'hookId' => 'scheduled-' . $schedule->getId(), - 'eventType' => 'scheduled', - 'objectUuid' => $schedule->getUuid(), - 'schemaId' => $schedule->getSchemaId(), - 'registerId' => $schedule->getRegisterId(), - 'engine' => $engineType, - 'workflowId' => $schedule->getWorkflowId(), - 'mode' => 'sync', - 'status' => $result->getStatus(), - 'durationMs' => $durationMs, - 'errors' => $errors, - 'metadata' => json_encode($result->getMetadata()), - 'executedAt' => $now, - ] - ); - - $this->logger->info( - message: '[ScheduledWorkflowJob] Executed schedule', - context: [ - 'scheduleId' => $schedule->getId(), - 'name' => $schedule->getName(), - 'status' => $result->getStatus(), - 'durationMs' => $durationMs, - ] - ); - } catch (Exception $e) { - $this->handleError(schedule: $schedule, startTime: $startTime, error: $e->getMessage()); - }//end try - }//end evaluateSchedule() - - /** - * Handle an error during scheduled workflow execution. - * - * @param ScheduledWorkflow $schedule The scheduled workflow - * @param int|float $startTime Start time from hrtime - * @param string $error Error message - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-workflow-execution-api-sync-and-async - */ - private function handleError(ScheduledWorkflow $schedule, $startTime, string $error): void { - $now = new DateTime(); - $durationMs = (int)((hrtime(true) - $startTime) / 1_000_000); - - $schedule->setLastRun($now); - $schedule->setLastStatus('error'); - $schedule->setUpdated($now); - - try { - $this->workflowMapper->update($schedule); - } catch (Exception $e) { - $this->logger->error( - message: '[ScheduledWorkflowJob] Failed to update schedule after error', - context: ['scheduleId' => $schedule->getId(), 'error' => $e->getMessage()] - ); - } - - try { - $this->executionMapper->createFromArray( - [ - 'hookId' => 'scheduled-' . $schedule->getId(), - 'eventType' => 'scheduled', - 'objectUuid' => $schedule->getUuid(), - 'schemaId' => $schedule->getSchemaId(), - 'registerId' => $schedule->getRegisterId(), - 'engine' => $schedule->getEngine(), - 'workflowId' => $schedule->getWorkflowId(), - 'mode' => 'sync', - 'status' => 'error', - 'durationMs' => $durationMs, - 'errors' => json_encode([['message' => $error]]), - 'executedAt' => $now, - ] - ); - } catch (Exception $e) { - $this->logger->error( - message: '[ScheduledWorkflowJob] Failed to persist error execution', - context: ['scheduleId' => $schedule->getId(), 'error' => $e->getMessage()] - ); - }//end try - - $this->logger->error( - message: '[ScheduledWorkflowJob] Schedule execution failed', - context: [ - 'scheduleId' => $schedule->getId(), - 'name' => $schedule->getName(), - 'error' => $error, - ] - ); - }//end handleError() -}//end 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/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/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/lib/Command/PruneRetiredSchemasCommand.php b/lib/Command/PruneRetiredSchemasCommand.php new file mode 100644 index 0000000000..a29a883578 --- /dev/null +++ b/lib/Command/PruneRetiredSchemasCommand.php @@ -0,0 +1,403 @@ + + * @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\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 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 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: it drops those objects. ' + . 'Has no effect without --apply.' + ) + ->addOption( + 'force-archival', + null, + InputOption::VALUE_NONE, + 'Also delete a schema declaring x-openregister-archival, destroying records the ' + . 'operator may be legally required to retain. Deliberately separate from --force: ' + . 'an operator pruning a retired schema that happens to hold a few leftover rows has ' + . 'said nothing about retained records, and this flag is where they say it. Requires ' + . '--force and --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'); + $forceArchival = (bool)$input->getOption('force-archival'); + $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; + + // EVERY row under this (application, slug), not the first. + // + // `findByApplicationAndSlug()` caps at one, which is correct where + // the pair is unique. It is not unique in practice, and this command + // exists because it is not: the import unions schema ids and never + // removes one, so a descriptor edit leaves the old row behind and a + // later import can add a second under the same pair. Reading one row + // meant removing one of two and reporting success. Measured on a live + // instance: opencatalogi owned two `document` rows (39 and 40), this + // pruned 40, printed `Pruned=1, skipped=0`, and left 39 answering + // every lookup. + $schemas = $this->schemaMapper->findAllByApplicationAndSlug(slug: $slug, application: $appId); + + if ($schemas === []) { + $missing++; + $output->writeln( + sprintf( + 'SKIP "%s": app "%s" owns no schema with that slug (already pruned, or never imported).', + $slug, + $appId + ) + ); + continue; + } + + if (count($schemas) > 1) { + $output->writeln( + sprintf( + '"%s": app "%s" owns %d rows under this slug. All of them are handled below.', + $slug, + $appId, + count($schemas) + ) + ); + } + + foreach ($schemas as $schema) { + $schemaId = (int)$schema->getId(); + // The count the DELETE would produce, from the deletion service that + // enumerates the tables — not a second, narrower count of its own. + // The guard and the delete disagreeing is the whole defect. + $objectCount = $this->deletionService->countObjectsCascadeWouldDelete(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; + } + + // The archival refusal is answered here as well as in the service, so the + // operator is told which flag lifts it instead of reading a raw exception + // out of the FAILED branch below. The condition comes from the production + // predicate the other four delete doors read, not from a second reading of + // the annotation. + $isArchival = $schema->hasArchivalAnnotation(); + if ($isArchival === true && $forceArchival === false) { + $skipped++; + $output->writeln( + ' SKIP: declares x-openregister-archival, so its objects are ' + . 'legally retained records. Re-run with --force-archival to destroy them.' + ); + continue; + } + + $archivalNote = ''; + if ($isArchival === true) { + $archivalNote = ' (ARCHIVAL RECORDS)'; + } + + if ($dryRun === true) { + $output->writeln( + sprintf(' WOULD DELETE (objects=%d)%s', $objectCount, $archivalNote) + ); + 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, + archivalOverride: $forceArchival + ); + } 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)%s', + (int)$result['deletedCount'], + $tableDropped, + $archivalNote + ) + ); + + // The count that gated the deletion and the count the deletion + // reports must agree. When they do not, the guard above passed on a + // number that did not describe what was destroyed, and the operator + // who read "0 object(s)" in the dry run needs to know that. + if ((int)$result['deletedCount'] !== $objectCount) { + $output->writeln( + sprintf( + ' COUNT MISMATCH: the guard saw %d object(s), the delete removed %d. ' + . 'The guard was wrong about what it was protecting.', + $objectCount, + (int)$result['deletedCount'] + ) + ); + } + }//end foreach + }//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() + + + /** + * 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/lib/Command/PurgeObjectCommand.php b/lib/Command/PurgeObjectCommand.php new file mode 100644 index 0000000000..0344d3801d --- /dev/null +++ b/lib/Command/PurgeObjectCommand.php @@ -0,0 +1,254 @@ + + * @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\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Permanently destroy object rows by UUID. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ +class PurgeObjectCommand extends Command { + + /** + * Wire the mappers. + * + * @param MagicMapper $objectMapper Magic-table object lookup and delete. + * @param SchemaMapper $schemaMapper Schema lookup, to read the archival annotation. + * + * @return void + */ + public function __construct( + private readonly MagicMapper $objectMapper, + private readonly SchemaMapper $schemaMapper, + ) { + parent::__construct(); + }//end __construct() + + /** + * Configure the command. + * + * @return void + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + protected function configure(): void { + $this->setName(name: 'openregister:objects:purge') + ->setDescription( + description: 'Permanently destroy object rows by UUID, including archival records with --force' + ) + ->addArgument( + name: 'uuid', + mode: (InputArgument::REQUIRED | InputArgument::IS_ARRAY), + description: 'One or more object UUIDs to purge' + ) + ->addOption( + name: 'force', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Also purge records on archival schemas, and records that are still live' + ) + ->addOption( + name: 'apply', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Actually destroy the rows. Without it the command reports what it would do' + ); + }//end configure() + + /** + * Purge each named object. + * + * Dry-run by default: an operator sees exactly which rows would be + * destroyed, and which are archival, before anything is written. + * + * @param InputInterface $input Console input. + * @param OutputInterface $output Console output. + * + * @return int 0 when every named object was handled, 1 when any was refused or failed. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $uuids = $input->getArgument('uuid'); + $force = (bool)$input->getOption('force'); + $apply = (bool)$input->getOption('apply'); + + $failures = 0; + foreach ($uuids as $uuid) { + $failures += $this->purgeOne( + uuid: (string)$uuid, + force: $force, + apply: $apply, + output: $output + ); + } + + if ($apply === false) { + $output->writeln(''); + $output->writeln('Dry run. Re-run with --apply to destroy these rows.'); + } + + if ($failures > 0) { + return 1; + } + + return 0; + }//end execute() + + /** + * Handle a single UUID. + * + * @param string $uuid The object UUID. + * @param bool $force Whether archival and live rows may be purged. + * @param bool $apply Whether to actually write. + * @param OutputInterface $output Console output. + * + * @return int 1 when the object was refused or could not be handled, 0 otherwise. + */ + private function purgeOne(string $uuid, bool $force, bool $apply, OutputInterface $output): int { + try { + $object = $this->objectMapper->find( + identifier: $uuid, + register: null, + schema: null, + includeDeleted: true, + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable $e) { + $output->writeln(sprintf('%s: not found (%s)', $uuid, $e->getMessage())); + return 1; + } + + $schema = $this->resolveSchema(object: $object); + $isArchival = ($schema !== null && $schema->hasArchivalAnnotation() === true); + $label = ($schema?->getSlug() ?? (string)$object->getSchema()); + + $refusal = $this->refusalReason(object: $object, isArchival: $isArchival, force: $force, label: $label); + if ($refusal !== null) { + $output->writeln(sprintf('%s: refused — %s', $uuid, $refusal)); + return 1; + } + + $note = ''; + if ($isArchival === true) { + $note = ' (ARCHIVAL RECORD)'; + } + + if ($apply === false) { + $output->writeln(sprintf('would purge %s from "%s"%s', $uuid, $label, $note)); + return 0; + } + + try { + $this->objectMapper->delete($object); + } catch (\Throwable $e) { + $output->writeln(sprintf('%s: purge failed (%s)', $uuid, $e->getMessage())); + return 1; + } + + $output->writeln(sprintf('purged %s from "%s"%s', $uuid, $label, $note)); + return 0; + }//end purgeOne() + + /** + * Why this object may not be purged, or null when it may. + * + * Both refusals are lifted by --force, and both exist for the same reason: + * the routine paths that create and remove rows have already declined, so + * the operator has to say out loud that they are overriding them. + * + * @param ObjectEntity $object The object being purged. + * @param bool $isArchival Whether its schema declares the archival annotation. + * @param bool $force Whether the operator passed --force. + * @param string $label The schema slug or id, for the message. + * + * @return string|null The refusal reason, or null when the purge may proceed. + */ + private function refusalReason(ObjectEntity $object, bool $isArchival, bool $force, string $label): ?string { + if ($force === true) { + return null; + } + + if ($isArchival === true) { + return sprintf( + 'schema "%s" declares x-openregister-archival. ' + . 'Pass --force to purge a legally retained record.', + $label + ); + } + + if ($object->isSoftDeleted() === false) { + return 'the object is live, not in the trash. Delete it first, or pass --force.'; + } + + return null; + }//end refusalReason() + + /** + * Resolve an object's schema, or null when it cannot be found. + * + * @param ObjectEntity $object The object whose schema to resolve. + * + * @return Schema|null The schema, or null. + */ + private function resolveSchema(ObjectEntity $object): ?Schema { + $schemaId = $object->getSchema(); + if ($schemaId === null || $schemaId === '') { + return null; + } + + try { + return $this->schemaMapper->find((int)$schemaId); + } catch (\Throwable $e) { + return null; + } + }//end resolveSchema() +}//end class diff --git a/lib/Command/RollbackApprovalMigrationCommand.php b/lib/Command/RollbackApprovalMigrationCommand.php new file mode 100644 index 0000000000..f056ebe08c --- /dev/null +++ b/lib/Command/RollbackApprovalMigrationCommand.php @@ -0,0 +1,234 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.nl + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-every-in-flight-approval-survives-the-migration-at-the-same-position + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Command; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Task\TaskState; +use OCP\IDBConnection; +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; + +/** + * Write migrated decisions back onto the kept approval-step rows. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-every-in-flight-approval-survives-the-migration-at-the-same-position + * + * @SuppressWarnings(PHPMD.StaticAccess) TaskState is the published, + * stateless state vocabulary; calling it statically is the point. + */ +class RollbackApprovalMigrationCommand extends Command { + + /** + * Constructor. + * + * @param IDBConnection $db Reads the step rows and writes the decisions back. + * @param TaskMapper $tasks Reads the migrated tasks. + */ + public function __construct( + private readonly IDBConnection $db, + private readonly TaskMapper $tasks, + ) { + parent::__construct(); + }//end __construct() + + /** + * Configure the command. + * + * @return void + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-every-in-flight-approval-survives-the-migration-at-the-same-position + */ + protected function configure(): void { + $this->setName(name: 'openregister:approval:rollback-to-steps') + ->setDescription( + description: 'Write decisions taken on migrated approval tasks back onto the legacy step rows, and report what the legacy schema cannot hold' + ) + ->addOption( + name: 'dry-run', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Report what would be written back without changing any row' + ); + }//end configure() + + /** + * Execute the reverse repair. + * + * @param InputInterface $input Command input. + * @param OutputInterface $output Command output. + * + * @return int Zero on success. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-every-in-flight-approval-survives-the-migration-at-the-same-position + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $dryRun = ((bool)$input->getOption('dry-run') === true); + + try { + $result = $this->db->executeQuery( + 'SELECT * FROM `*PREFIX*openregister_approval_steps` WHERE `migrated_task_uuid` IS NOT NULL' + ); + $steps = $result->fetchAll(); + $result->closeCursor(); + } catch (Throwable $absent) { + $output->writeln('The legacy approval tables are gone; there is nothing to roll back onto.'); + + return Command::FAILURE; + } + + $written = 0; + $notCarriable = []; + foreach ($steps as $step) { + $written += $this->carryBack(step: $step, dryRun: $dryRun, output: $output, notCarriable: $notCarriable); + } + + $mode = 'written back'; + if ($dryRun === true) { + $mode = 'would be written back'; + } + + $output->writeln(sprintf('%d decision(s) %s onto legacy step rows.', $written, $mode)); + + if ($notCarriable !== []) { + $output->writeln('The legacy schema cannot hold the following facts; they stay ONLY on the task side:'); + foreach ($notCarriable as $line) { + $output->writeln(' - ' . $line); + } + } + + $output->writeln('The legacy tables were not dropped, and dropping them is not part of any rollback path.'); + + return Command::SUCCESS; + }//end execute() + + /** + * Carry ONE step's task-side decision back, when there is one. + * + * @param array $step The legacy step row. + * @param bool $dryRun Whether to report without writing. + * @param OutputInterface $output Command output. + * @param array $notCarriable Collects the facts left behind. + * + * @return int One when a decision was carried back, zero otherwise. + */ + private function carryBack(array $step, bool $dryRun, OutputInterface $output, array &$notCarriable): int { + $taskUuid = trim((string)$step['migrated_task_uuid']); + try { + $task = $this->tasks->findByUuid(uuid: $taskUuid); + } catch (Throwable $gone) { + $output->writeln(sprintf('Step %d: migrated task %s no longer exists; row left as it is.', (int)$step['id'], $taskUuid)); + + return 0; + } + + if ($task->isInTerminalState() === false || (string)$task->getState() !== Task::STATE_COMPLETED) { + // No decision was taken on the task side; the step keeps + // whatever it already recorded. + return 0; + } + + $status = 'approved'; + if (TaskState::isRejectingOutcome(outcome: $task->getOutcome()) === true) { + $status = 'rejected'; + } + + if ((string)$step['status'] === $status && trim((string)($step['decided_by'] ?? '')) !== '') { + // Already decided identically before migration; nothing new to + // carry back. + return 0; + } + + if ($dryRun === false) { + $this->writeDecision(step: $step, task: $task, status: $status); + } + + foreach ($this->lostFacts(task: $task) as $lost) { + $notCarriable[] = sprintf('step %d (task %s): %s', (int)$step['id'], $taskUuid, $lost); + } + + return 1; + }//end carryBack() + + /** + * Write one decision onto its legacy step row. + * + * @param array $step The legacy step row. + * @param Task $task The decided task. + * @param string $status The legacy status to record. + * + * @return void + */ + private function writeDecision(array $step, Task $task, string $status): void { + $update = $this->db->getQueryBuilder(); + $update->update('openregister_approval_steps') + ->set('status', $update->createNamedParameter($status)) + ->set('decided_by', $update->createNamedParameter((string)($task->getCompletedBy() ?? ''))) + ->set('comment', $update->createNamedParameter((string)($task->getComment() ?? ''))) + ->set('decided_at', $update->createNamedParameter($task->getCompletedAt()?->format('Y-m-d H:i:s'))) + ->where($update->expr()->eq('id', $update->createNamedParameter((int)$step['id']))); + $update->executeStatement(); + }//end writeDecision() + + /** + * The facts about a decision the legacy step schema cannot express. + * + * @param Task $task The migrated, decided task. + * + * @return array One line per fact that stays behind. + */ + private function lostFacts(Task $task): array { + $lost = ['the per-entry audit trail']; + if ((string)$task->getPerformerType() !== '' && (string)$task->getPerformerType() !== Task::PERFORMER_GROUP) { + $lost[] = sprintf("performer type '%s'", (string)$task->getPerformerType()); + } + + if (trim((string)$task->getOnBehalfOf()) !== '') { + $lost[] = sprintf("on-behalf-of identity '%s'", (string)$task->getOnBehalfOf()); + } + + if (trim((string)$task->getMandate()) !== '') { + $lost[] = sprintf("mandate '%s'", (string)$task->getMandate()); + } + + return $lost; + }//end lostFacts() +}//end class diff --git a/lib/Contract/ObjectServiceInterface.php b/lib/Contract/ObjectServiceInterface.php index de64a049e0..a6a56a2e61 100644 --- a/lib/Contract/ObjectServiceInterface.php +++ b/lib/Contract/ObjectServiceInterface.php @@ -61,6 +61,12 @@ * this contract. It is published because `updateObject()` REPLACES, so a * consumer holding a partial payload had no correct method to call and either * hand-rolled read-merge-write or silently erased the fields it omitted. + * `appendObjectsRaw()` and `purgeExpiredObjectsRaw()` were added 2026-09-04 + * for the traffic-analytics collector, which writes thousands of rows a + * minute that no person edits: the normal save path's per-row RBAC, audit, + * validation and events are exactly the cost it cannot pay. They are the raw + * bulk path made reachable through the contract, and they carry the same + * responsibility model as `_rbac: false`. * The four it omits are omitted for a reason, and the ten classes they hold * back are listed rather than left to be discovered: * @@ -132,7 +138,7 @@ * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) RBAC and multitenancy flags — see above. * @SuppressWarnings(PHPMD.ExcessiveParameterList) Mirrors ObjectService::saveObject(). - * @SuppressWarnings(PHPMD.ExcessivePublicCount) 26 methods, measured — see Scope above. + * @SuppressWarnings(PHPMD.ExcessivePublicCount) 28 methods: 26 measured (see Scope above) plus the two raw entry points. */ interface ObjectServiceInterface { @@ -374,6 +380,61 @@ public function saveObjects( bool $_audit=true ): array; + /** + * Append rows with every safeguard switched OFF. + * + * No RBAC, no multitenancy, no validation, no audit trail, no lifecycle + * events, no search index, no relations, no files. Calling it is the same + * declaration as `_rbac: false`: the caller has taken the authorisation + * responsibility. It exists for high-volume, low-value rows such as traffic + * events and telemetry; use saveObjects() for anything a person will edit, + * audit or revert. + * + * Each row is a plain property map plus the optional metadata keys `uuid` + * (generated when absent), `expires` (ISO 8601 or DateTimeInterface, swept + * by purgeExpiredObjectsRaw()), `owner` and `organisation`. + * + * @param array $objects Rows to append, as plain arrays. + * @param string|int $register Register id, uuid or slug. + * @param string|int $schema Schema id, uuid or slug, resolved within the register. + * + * @return int The number of rows written. + * + * @contract-shift announced — openregister#3406 names the fleet test doubles that + * must declare this method or fatal at class load: pipelinq + * tests/Stubs/Service/ObjectService.php (with its paired + * tests/Stubs/Contract/ObjectServiceInterface.php) and shillinq + * tests/Unit/Service/Support/{InMemoryObjectServiceStub,DuckObjectServiceAdapter}.php. + * createMock() sites are unaffected. The break lands on the + * `conduction/hydra-gates` RELEASE carrying this contract, not on this + * merge, because leaf apps read it from vendor/: land the doubles before + * the release, or pin. + */ + public function appendObjectsRaw(array $objects, string|int $register, string|int $schema): int; + + /** + * Hard-delete the rows of a register+schema whose `expires` has passed. + * + * The sweep for appendObjectsRaw(). Bypasses soft-delete and the audit + * trail on purpose: raw rows never had either. + * + * @param string|int $register Register id, uuid or slug. + * @param string|int $schema Schema id, uuid or slug, resolved within the register. + * + * @return int The number of rows removed. + * + * @contract-shift announced — openregister#3406 names the fleet test doubles that + * must declare this method or fatal at class load: pipelinq + * tests/Stubs/Service/ObjectService.php (with its paired + * tests/Stubs/Contract/ObjectServiceInterface.php) and shillinq + * tests/Unit/Service/Support/{InMemoryObjectServiceStub,DuckObjectServiceAdapter}.php. + * createMock() sites are unaffected. The break lands on the + * `conduction/hydra-gates` RELEASE carrying this contract, not on this + * merge, because leaf apps read it from vendor/: land the doubles before + * the release, or pin. + */ + public function purgeExpiredObjectsRaw(string|int $register, string|int $schema): int; + /** * Run an operation with system privileges, bypassing user scoping. * @@ -397,10 +458,11 @@ public function count(array $config=[]): int; * * @param string|int $identifier The object id or UUID. * @param bool $advisory Take an advisory (non-blocking) lock. + * @param ?string $runUuid The flow run releasing the lock, for a run-scoped lock. * * @return bool True when the lock was released. */ - public function unlockObject(string|int $identifier, bool $advisory=false): bool; + public function unlockObject(string|int $identifier, bool $advisory=false, ?string $runUuid=null): bool; /** * Take a lock on an object. @@ -409,6 +471,10 @@ public function unlockObject(string|int $identifier, bool $advisory=false): bool * @param ?string $process A label for the process holding the lock. * @param ?int $duration Lock duration in seconds. * @param bool $advisory Take an advisory (non-blocking) lock. + * @param ?string $runUuid The flow run taking the lock. A run-scoped lock + * refuses every other caller, the run's own runAs + * user included. + * @param ?string $nodeId The flow node that took it, recorded for the sweep. * * @return array The resulting lock state. */ @@ -416,7 +482,9 @@ public function lockObject( string $identifier, ?string $process=null, ?int $duration=null, - bool $advisory=false + bool $advisory=false, + ?string $runUuid=null, + ?string $nodeId=null ): array; /** diff --git a/lib/Controller/ActionsController.php b/lib/Controller/ActionsController.php deleted file mode 100644 index 61dd88b67e..0000000000 --- a/lib/Controller/ActionsController.php +++ /dev/null @@ -1,595 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/actions/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Controller; - -use OCA\OpenRegister\Db\ActionLogMapper; -use OCA\OpenRegister\Db\ActionMapper; -use OCA\OpenRegister\Service\ActionService; -use OCP\AppFramework\Controller; -use OCP\AppFramework\Db\DoesNotExistException; -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; - -/** - * ActionsController handles action CRUD and utility operations - * - * @psalm-suppress UnusedClass - * - * @SuppressWarnings(PHPMD.TooManyPublicMethods) Actions CRUD exposes - * index/show/create/update/patch/destroy/test/logs/migrateFromHooks — each maps to a distinct REST - * verb or utility route required by the Actions feature; collapsing routes would break the REST contract. - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Controller composes ActionMapper + ActionLogMapper + - * ActionService + IUserSession + IGroupManager + LoggerInterface; each dependency serves a distinct - * responsibility (persistence, logging, business logic, auth) and cannot be removed without losing functionality. - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Complexity is spread across 9 thin public action - * methods; each method is independently simple. PHPMD accumulates per-method scores into the class total. - */ -class ActionsController extends Controller { - - /** - * Action mapper - * - * @var ActionMapper - */ - private ActionMapper $actionMapper; - - /** - * Action service - * - * @var ActionService - */ - private ActionService $actionService; - - /** - * Action log mapper - * - * @var ActionLogMapper - */ - private ActionLogMapper $actionLogMapper; - - /** - * Logger - * - * @var LoggerInterface - */ - private LoggerInterface $logger; - - /** - * Constructor - * - * @param string $appName Application name - * @param IRequest $request HTTP request - * @param ActionMapper $actionMapper Action mapper - * @param ActionLogMapper $actionLogMapper Action log mapper - * @param ActionService $actionService Action service - * @param LoggerInterface $logger Logger - * @param IUserSession $userSession Active session for caller identity. - * @param IGroupManager $groupManager Group manager for admin gating. - */ - public function __construct( - string $appName, - IRequest $request, - ActionMapper $actionMapper, - ActionLogMapper $actionLogMapper, - ActionService $actionService, - LoggerInterface $logger, - private readonly IUserSession $userSession, - private readonly IGroupManager $groupManager, - ) { - parent::__construct(appName: $appName, request: $request); - $this->actionMapper = $actionMapper; - $this->actionLogMapper = $actionLogMapper; - $this->actionService = $actionService; - $this->logger = $logger; - }//end __construct() - - /** - * Gate Actions mutations to admin group members. - * - * SECURITY: Actions persist as workflow hooks that fire on every - * matching object lifecycle event. A non-admin who briefly auth-es - * could otherwise register an attacker-chosen workflow that - * survives password reset, session revocation, and even the source - * account being disabled (the action row carries no owner check on - * execution). Every write surface (`create`/`update`/`patch`/ - * `destroy`/`test`/`migrateFromHooks`) is admin-only at the - * framework level (the methods carry no `@NoAdminRequired`); this - * helper stays as defence-in-depth so a future refactor that - * silently re-adds `@NoAdminRequired` does not open the surface. - * - * @return JSONResponse|null 403 response when not admin, null when allowed. - * - * @spec openspec/specs/actions/spec.md - */ - private function requireAdmin(): ?JSONResponse { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse( - data: ['error' => 'Authentication required'], - statusCode: 401 - ); - } - - if ($this->groupManager->isAdmin($user->getUID()) === false) { - return new JSONResponse( - data: ['error' => 'Forbidden: Actions management is admin-only'], - statusCode: 403 - ); - } - - return null; - }//end requireAdmin() - - /** - * List all actions with pagination and filtering - * - * @return JSONResponse - * - * @NoAdminRequired - * - * @NoCSRFRequired - * - * @SuppressWarnings(PHPMD.NPathComplexity) index() handles limit/offset/page/search/filters in one - * pass to avoid a second DB round-trip; extracting each branch into helpers would add indirection - * without reducing total paths. - * @SuppressWarnings(PHPMD.CyclomaticComplexity) Branches cover five independent optional query - * parameters (limit, offset, page, search, field filters); each is a single isset/cast guard and - * cannot be split without duplicating the parameter-parsing logic. - * - * @spec openspec/specs/actions/spec.md - * @spec openspec/specs/actions/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function index(): JSONResponse { - try { - $params = $this->request->getParams(); - - $limit = null; - if (isset($params['_limit']) === true) { - $limit = (int)$params['_limit']; - } - - $offset = null; - if (isset($params['_offset']) === true) { - $offset = (int)$params['_offset']; - } - - if (isset($params['_page']) === true && $limit !== null) { - $offset = ((int)$params['_page'] - 1) * $limit; - } - - // Build filters from known filterable fields. - $filters = []; - $filterableFields = ['status', 'event_type', 'engine', 'enabled', 'mode']; - foreach ($filterableFields as $field) { - if (isset($params[$field]) === true) { - $filters[$field] = $params[$field]; - } - } - - // Search support. - $search = $params['_search'] ?? null; - - $actions = $this->actionMapper->findAll( - limit: $limit, - offset: $offset, - filters: $filters - ); - - // Apply search filter in PHP if provided. - if ($search !== null && $search !== '') { - $searchLower = strtolower($search); - $actions = array_values( - array_filter( - $actions, - function ($action) use ($searchLower) { - return str_contains(strtolower($action->getName()), $searchLower) - || str_contains(strtolower($action->getSlug() ?? ''), $searchLower); - } - ) - ); - } - - // Get total count. - $allActions = $this->actionMapper->findAll(filters: $filters); - if ($search !== null && $search !== '') { - $searchLower = strtolower($search); - $allActions = array_filter( - $allActions, - function ($action) use ($searchLower) { - return str_contains(strtolower($action->getName()), $searchLower) - || str_contains(strtolower($action->getSlug() ?? ''), $searchLower); - } - ); - } - - $total = count($allActions); - - $actionsArr = array_map( - function ($action) { - return $action->jsonSerialize(); - }, - $actions - ); - - return new JSONResponse( - data: [ - 'results' => array_values($actionsArr), - 'total' => $total, - ], - statusCode: 200 - ); - } catch (\Exception $e) { - $this->logger->error( - message: '[ActionsController] Error listing actions: ' . $e->getMessage() - ); - - return new JSONResponse( - data: ['error' => 'Failed to list actions'], - statusCode: 500 - ); - }//end try - }//end index() - - /** - * Get a single action - * - * @param int $id Action ID - * - * @return JSONResponse - * - * @NoAdminRequired - * - * @NoCSRFRequired - * - * @spec openspec/specs/actions/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function show(int $id): JSONResponse { - try { - $action = $this->actionMapper->find($id); - - return new JSONResponse(data: $action); - } catch (DoesNotExistException $e) { - return new JSONResponse( - data: ['error' => 'Action not found'], - statusCode: 404 - ); - } catch (\Exception $e) { - return new JSONResponse( - data: ['error' => 'Failed to retrieve action'], - statusCode: 500 - ); - } - }//end show() - - /** - * Create a new action - * - * Admin-only at the framework level (no @NoAdminRequired). Body - * `requireAdmin()` stays as defence-in-depth. - * - * @return JSONResponse - * - * @NoCSRFRequired - * - * @spec openspec/specs/actions/spec.md - */ - #[NoCSRFRequired] - public function create(): JSONResponse { - $denial = $this->requireAdmin(); - if ($denial !== null) { - return $denial; - } - - try { - $data = $this->request->getParams(); - - // Remove internal parameters. - foreach (array_keys($data) as $key) { - if (str_starts_with($key, '_') === true) { - unset($data[$key]); - } - } - - unset($data['id'], $data['organisation']); - - $action = $this->actionService->createAction($data); - - return new JSONResponse(data: $action, statusCode: 201); - } catch (\InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: 400 - ); - } catch (\Exception $e) { - $this->logger->error( - message: '[ActionsController] Error creating action: ' . $e->getMessage() - ); - - return new JSONResponse( - data: ['error' => 'Failed to create action: ' . $e->getMessage()], - statusCode: 500 - ); - }//end try - }//end create() - - /** - * Update an action (full replacement) - * - * Admin-only at the framework level (no @NoAdminRequired). Body - * `requireAdmin()` stays as defence-in-depth. - * - * @param int $id Action ID - * - * @return JSONResponse - * - * @NoCSRFRequired - * - * @spec openspec/specs/actions/spec.md - */ - #[NoCSRFRequired] - public function update(int $id): JSONResponse { - $denial = $this->requireAdmin(); - if ($denial !== null) { - return $denial; - } - - try { - $data = $this->request->getParams(); - - foreach (array_keys($data) as $key) { - if (str_starts_with($key, '_') === true) { - unset($data[$key]); - } - } - - unset($data['organisation']); - - $action = $this->actionService->updateAction($id, $data); - - return new JSONResponse(data: $action); - } catch (DoesNotExistException $e) { - return new JSONResponse( - data: ['error' => 'Action not found'], - statusCode: 404 - ); - } catch (\Exception $e) { - return new JSONResponse( - data: ['error' => 'Failed to update action: ' . $e->getMessage()], - statusCode: 500 - ); - }//end try - }//end update() - - /** - * Partial update an action - * - * Admin-only at the framework level (no @NoAdminRequired); update() - * also runs requireAdmin() as defence-in-depth. - * - * @param int $id Action ID - * - * @return JSONResponse - * - * @NoCSRFRequired - * - * @spec openspec/specs/actions/spec.md - */ - #[NoCSRFRequired] - public function patch(int $id): JSONResponse { - // RequireAdmin() runs inside update() — no need to duplicate here. - return $this->update(id: $id); - }//end patch() - - /** - * Soft-delete an action - * - * Admin-only at the framework level (no @NoAdminRequired). Body - * `requireAdmin()` stays as defence-in-depth. - * - * @param int $id Action ID - * - * @return JSONResponse - * - * @NoCSRFRequired - * - * @spec openspec/specs/actions/spec.md - */ - #[NoCSRFRequired] - public function destroy(int $id): JSONResponse { - $denial = $this->requireAdmin(); - if ($denial !== null) { - return $denial; - } - - try { - $action = $this->actionService->deleteAction($id); - - return new JSONResponse(data: $action); - } catch (DoesNotExistException $e) { - return new JSONResponse( - data: ['error' => 'Action not found'], - statusCode: 404 - ); - } catch (\Exception $e) { - return new JSONResponse( - data: ['error' => 'Failed to delete action'], - statusCode: 500 - ); - } - }//end destroy() - - /** - * Test action with dry-run simulation - * - * Admin-only at the framework level (no @NoAdminRequired). Body - * `requireAdmin()` stays as defence-in-depth. - * - * @param int $id Action ID - * - * @return JSONResponse - * - * @NoCSRFRequired - * - * @spec openspec/specs/actions/spec.md - */ - #[NoCSRFRequired] - public function test(int $id): JSONResponse { - $denial = $this->requireAdmin(); - if ($denial !== null) { - return $denial; - } - - try { - $data = $this->request->getParams(); - - foreach (array_keys($data) as $key) { - if (str_starts_with($key, '_') === true) { - unset($data[$key]); - } - } - - $result = $this->actionService->testAction($id, $data); - - return new JSONResponse(data: $result); - } catch (DoesNotExistException $e) { - return new JSONResponse( - data: ['error' => 'Action not found'], - statusCode: 404 - ); - } catch (\Exception $e) { - return new JSONResponse( - data: ['error' => 'Failed to test action: ' . $e->getMessage()], - statusCode: 500 - ); - }//end try - }//end test() - - /** - * Get action execution logs - * - * @param int $id Action ID - * - * @return JSONResponse - * - * @NoAdminRequired - * - * @NoCSRFRequired - * - * @spec openspec/specs/actions/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function logs(int $id): JSONResponse { - try { - $params = $this->request->getParams(); - $limit = 25; - if (isset($params['_limit']) === true) { - $limit = (int)$params['_limit']; - } - - $offset = 0; - if (isset($params['_offset']) === true) { - $offset = (int)$params['_offset']; - } - - $logs = $this->actionLogMapper->findByActionId( - actionId: $id, - limit: $limit, - offset: $offset - ); - - $stats = $this->actionLogMapper->getStatsByActionId($id); - - $logsArr = array_map( - function ($log) { - return $log->jsonSerialize(); - }, - $logs - ); - - return new JSONResponse( - data: [ - 'results' => $logsArr, - 'total' => $stats['total'], - 'statistics' => $stats, - ] - ); - } catch (\Exception $e) { - return new JSONResponse( - data: ['error' => 'Failed to retrieve action logs'], - statusCode: 500 - ); - }//end try - }//end logs() - - /** - * Migrate inline hooks from a schema to Action entities - * - * Admin-only at the framework level (no @NoAdminRequired). Body - * `requireAdmin()` stays as defence-in-depth. - * - * @param int $schemaId Schema ID - * - * @return JSONResponse - * - * @NoCSRFRequired - * - * @spec openspec/specs/actions/spec.md - */ - #[NoCSRFRequired] - public function migrateFromHooks(int $schemaId): JSONResponse { - $denial = $this->requireAdmin(); - if ($denial !== null) { - return $denial; - } - - try { - $report = $this->actionService->migrateFromHooks($schemaId); - - return new JSONResponse(data: $report); - } catch (DoesNotExistException $e) { - return new JSONResponse( - data: ['error' => 'Schema not found'], - statusCode: 404 - ); - } catch (\Exception $e) { - return new JSONResponse( - data: ['error' => 'Migration failed: ' . $e->getMessage()], - statusCode: 500 - ); - } - }//end migrateFromHooks() -}//end class 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/ApprovalController.php b/lib/Controller/ApprovalController.php deleted file mode 100644 index 9f53fe7244..0000000000 --- a/lib/Controller/ApprovalController.php +++ /dev/null @@ -1,361 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - * - * @spec openspec/specs/approval-workflow/spec.md - * @spec openspec/specs/approval-workflow/spec.md - * @spec openspec/specs/approval-workflow/spec.md - * @spec openspec/specs/approval-workflow/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Controller; - -use Exception; -use OCA\OpenRegister\Db\ApprovalChainMapper; -use OCA\OpenRegister\Db\ApprovalStepMapper; -use OCA\OpenRegister\Service\ApprovalService; -use OCP\AppFramework\Controller; -use OCP\AppFramework\Http\JSONResponse; -use OCP\IGroupManager; -use OCP\IRequest; -use OCP\IUserSession; -use Psr\Log\LoggerInterface; - -/** - * Controller for approval chain CRUD and step approve/reject. - * - * @psalm-suppress UnusedClass - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class ApprovalController extends Controller { - /** - * Constructor for ApprovalController. - * - * @param string $appName App name - * @param IRequest $request Request - * @param ApprovalChainMapper $chainMapper Chain mapper - * @param ApprovalStepMapper $stepMapper Step mapper - * @param ApprovalService $approvalService Approval service - * @param IUserSession $userSession User session - * @param LoggerInterface $logger Logger - * @param IGroupManager $groupManager Group manager for admin checks - */ - public function __construct( - string $appName, - IRequest $request, - private readonly ApprovalChainMapper $chainMapper, - private readonly ApprovalStepMapper $stepMapper, - private readonly ApprovalService $approvalService, - private readonly IUserSession $userSession, - private readonly LoggerInterface $logger, - private readonly IGroupManager $groupManager, - ) { - parent::__construct(appName: $appName, request: $request); - }//end __construct() - - /** - * Determine whether the current user is a Nextcloud admin. - * - * @return bool True when the active session user belongs to the admin group. - */ - private function isCurrentUserAdmin(): bool { - $user = $this->userSession->getUser(); - if ($user === null) { - return false; - } - - return $this->groupManager->isAdmin($user->getUID()); - }//end isCurrentUserAdmin() - - /** - * List all approval chains. - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/specs/approval-workflow/spec.md - */ - public function index(): JSONResponse { - $chains = $this->chainMapper->findAll(); - - return new JSONResponse( - array_map(fn ($c) => $c->jsonSerialize(), $chains) - ); - }//end index() - - /** - * Get a single approval chain. - * - * @param int $id Chain ID - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/specs/approval-workflow/spec.md - */ - public function show(int $id): JSONResponse { - try { - $chain = $this->chainMapper->find($id); - - return new JSONResponse($chain->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => 'Approval chain not found'], 404); - } - }//end show() - - /** - * Create a new approval chain. - * - * @return JSONResponse - * - * @spec openspec/specs/approval-workflow/spec.md - * - * @NoAdminRequired - */ - public function create(): JSONResponse { - // SECURITY (M1): approval chain writes are admin-only. - if ($this->isCurrentUserAdmin() === false) { - return new JSONResponse(['error' => 'Admin privileges required'], 403); - } - - $data = $this->request->getParams(); - - try { - $chain = $this->chainMapper->createFromArray($data); - - return new JSONResponse($chain->jsonSerialize(), 201); - } catch (Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], 500); - } - }//end create() - - /** - * Update an approval chain. - * - * @param int $id Chain ID - * - * @return JSONResponse - * - * @spec openspec/specs/approval-workflow/spec.md - * - * @NoAdminRequired - */ - public function update(int $id): JSONResponse { - // SECURITY (M1): approval chain writes are admin-only. - if ($this->isCurrentUserAdmin() === false) { - return new JSONResponse(['error' => 'Admin privileges required'], 403); - } - - try { - $data = $this->request->getParams(); - $chain = $this->chainMapper->updateFromArray($id, $data); - - return new JSONResponse($chain->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => 'Approval chain not found'], 404); - } catch (Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], 500); - } - }//end update() - - /** - * Delete an approval chain. - * - * @param int $id Chain ID - * - * @return JSONResponse - * - * @spec openspec/specs/approval-workflow/spec.md - * - * @NoAdminRequired - */ - public function destroy(int $id): JSONResponse { - // SECURITY (M1): approval chain writes are admin-only. - if ($this->isCurrentUserAdmin() === false) { - return new JSONResponse(['error' => 'Admin privileges required'], 403); - } - - try { - $chain = $this->chainMapper->find($id); - $this->chainMapper->delete($chain); - - return new JSONResponse($chain->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => 'Approval chain not found'], 404); - } - }//end destroy() - - /** - * List objects in an approval chain with their progress. - * - * @param int $id Chain ID - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/specs/approval-workflow/spec.md - */ - public function objects(int $id): JSONResponse { - try { - $this->chainMapper->find($id); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => 'Approval chain not found'], 404); - } - - $steps = $this->stepMapper->findByChain($id); - - // Group steps by object UUID. - $objectProgress = []; - foreach ($steps as $step) { - $uuid = $step->getObjectUuid(); - if (isset($objectProgress[$uuid]) === false) { - $objectProgress[$uuid] = [ - 'objectUuid' => $uuid, - 'steps' => [], - 'approved' => 0, - 'total' => 0, - ]; - } - - $objectProgress[$uuid]['steps'][] = $step->jsonSerialize(); - $objectProgress[$uuid]['total']++; - if ($step->getStatus() === 'approved') { - $objectProgress[$uuid]['approved']++; - } - } - - return new JSONResponse(array_values($objectProgress)); - }//end objects() - - /** - * List approval steps with optional filters. - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/specs/approval-workflow/spec.md - * @spec openspec/specs/approval-workflow/spec.md - */ - public function steps(): JSONResponse { - $filters = []; - - $status = $this->request->getParam('status'); - if ($status !== null) { - $filters['status'] = $status; - } - - $role = $this->request->getParam('role'); - if ($role !== null) { - $filters['role'] = $role; - } - - $chainId = $this->request->getParam('chainId'); - if ($chainId !== null) { - $filters['chainId'] = (int)$chainId; - } - - $objectUuid = $this->request->getParam('objectUuid'); - if ($objectUuid !== null) { - $filters['objectUuid'] = $objectUuid; - } - - $steps = $this->stepMapper->findAllFiltered($filters); - - return new JSONResponse( - array_map(fn ($step) => $step->jsonSerialize(), $steps) - ); - }//end steps() - - /** - * Approve a pending approval step. - * - * @param int $id Step ID - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/specs/approval-workflow/spec.md - */ - public function approve(int $id): JSONResponse { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], 401); - } - - $comment = (string)($this->request->getParam('comment', '')); - - try { - $result = $this->approvalService->approveStep($id, $user->getUID(), $comment); - $step = $result['step']; - - $response = $step->jsonSerialize(); - if ($result['nextStep'] !== null) { - $response['nextStep'] = $result['nextStep']->jsonSerialize(); - } - - return new JSONResponse($response); - } catch (Exception $e) { - if (str_contains($e->getMessage(), 'not authorised') === true) { - return new JSONResponse(['error' => $e->getMessage()], 403); - } - - return new JSONResponse(['error' => $e->getMessage()], 400); - } - }//end approve() - - /** - * Reject a pending approval step. - * - * @param int $id Step ID - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/specs/approval-workflow/spec.md - */ - public function reject(int $id): JSONResponse { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], 401); - } - - $comment = (string)($this->request->getParam('comment', '')); - - try { - $result = $this->approvalService->rejectStep($id, $user->getUID(), $comment); - $step = $result['step']; - - return new JSONResponse($step->jsonSerialize()); - } catch (Exception $e) { - if (str_contains($e->getMessage(), 'not authorised') === true) { - return new JSONResponse(['error' => $e->getMessage()], 403); - } - - return new JSONResponse(['error' => $e->getMessage()], 400); - } - }//end reject() -}//end class 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/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/BulkController.php b/lib/Controller/BulkController.php index 8bf99b675e..6caca4d0a1 100644 --- a/lib/Controller/BulkController.php +++ b/lib/Controller/BulkController.php @@ -34,6 +34,7 @@ use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Dto\BulkSaveOutcome; use OCA\OpenRegister\Controller\Trait\ResolvesRegisterAndSchemaTrait; +use OCA\OpenRegister\Exception\ArchivalImmutableException; use OCA\OpenRegister\Exception\RegisterNotFoundException; use OCA\OpenRegister\Exception\SchemaNotFoundException; use OCA\OpenRegister\Service\ObjectService; @@ -269,6 +270,13 @@ public function delete(string $register, string $schema): JSONResponse { ); } + // DEDUPLICATE BEFORE COUNTING. Naming the same object twice is one + // deletion, and the service resolves each UUID once — so counting the + // raw list as `requested_count` would leave `requested = deleted + + // skipped` unsatisfiable for a caller that repeated an entry, which is + // exactly the arithmetic this endpoint is supposed to make legible. + $uuids = array_values(array_unique($uuids)); + // Set register and schema context using resolved IDs. $this->objectService->setRegister((string)$resolved['register']); $this->objectService->setSchema((string)$resolved['schema']); @@ -277,17 +285,33 @@ public function delete(string $register, string $schema): JSONResponse { $result = $this->objectService->deleteObjects($uuids); $deletedUuids = $result['deleted_uuids']; $skippedUuids = $result['skipped_uuids']; + $skippedReasons = ($result['skipped_reasons'] ?? []); $cascadeCount = $result['cascade_count']; + // `success` REPORTS THE SHORTFALL, as the bulk save path already does + // (see writeBatch()). A row this endpoint refused is a row the caller + // still believes it deleted, and a constant `true` said exactly that: + // the live symptom of the filter bug was a 200 reading `success: true, + // requested_count: 1, deleted_count: 0` over an untouched object. + $message = 'Bulk delete operation completed successfully'; + if (empty($skippedUuids) === false) { + $message = sprintf( + 'Bulk delete completed with %d of %d objects refused; see skipped_reasons.', + count($skippedUuids), + count($uuids) + ); + } + return new JSONResponse( data: [ - 'success' => true, - 'message' => 'Bulk delete operation completed successfully', + 'success' => empty($skippedUuids), + 'message' => $message, 'deleted_count' => count($deletedUuids), 'deleted_uuids' => $deletedUuids, 'requested_count' => count($uuids), 'skipped_count' => count($skippedUuids), 'skipped_uuids' => $skippedUuids, + 'skipped_reasons' => $skippedReasons, 'cascade_count' => $cascadeCount, 'total_affected' => count($deletedUuids) + $cascadeCount, ] @@ -672,6 +696,10 @@ public function deleteSchema(string $register, string $schema): JSONResponse { 'hard_delete' => $hardDelete, ] ); + } catch (ArchivalImmutableException $e) { + // The schema holds legally retained records. Caught ahead of the generic + // handler below, which would have reported a deliberate refusal as a 500. + return new JSONResponse(data: $e->toResponseBody(), statusCode: Http::STATUS_FORBIDDEN); } catch (Exception $e) { return new JSONResponse( data: ['error' => 'Schema objects deletion failed: ' . $e->getMessage()], @@ -759,6 +787,10 @@ public function deleteSchemaObjects(string $register, string $schema): JSONRespo 'hard_delete' => $hardDelete, ] ); + } catch (ArchivalImmutableException $e) { + // The schema holds legally retained records. Caught ahead of the generic + // handler below, which would have reported a deliberate refusal as a 500. + return new JSONResponse(data: $e->toResponseBody(), statusCode: Http::STATUS_FORBIDDEN); } catch (Exception $e) { return new JSONResponse( data: ['error' => 'Objects deletion failed: ' . $e->getMessage()], 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/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/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/ConfigurationsController.php b/lib/Controller/ConfigurationsController.php index d0ba2bd2d2..747efa2f61 100644 --- a/lib/Controller/ConfigurationsController.php +++ b/lib/Controller/ConfigurationsController.php @@ -23,9 +23,7 @@ namespace OCA\OpenRegister\Controller; -use DateTime; use Exception; -use OCA\OpenRegister\Db\Configuration; use OCA\OpenRegister\Db\ConfigurationMapper; use OCA\OpenRegister\Service\ConfigurationService; use OCA\OpenRegister\Service\UploadService; @@ -375,11 +373,13 @@ public function export(int $id, bool $includeObjects = false): JSONResponse|Data throw new Exception('Failed to encode configuration data to JSON'); } - // Generate filename. + // Generate filename. `date()` rather than a DateTime instance: the + // two produce the same string from the same default timezone, and + // this was the class's fourteenth coupled type. $filename = sprintf( 'configuration_%s_%s.json', $configuration->getTitle() ?? 'unknown', - (new DateTime())->format('Y-m-d_His') + date('Y-m-d_His') ); // Return as downloadable file. @@ -435,23 +435,25 @@ public function import(): JSONResponse { return $jsonData; } - // Create a Configuration entity from the JSON data. - // This is required for proper entity tracking in ImportHandler. - $configuration = new Configuration(); - $configuration->setTitle($jsonData['info']['title'] ?? 'Imported Configuration'); - $configuration->setDescription($jsonData['info']['description'] ?? ''); - $configuration->setVersion($jsonData['info']['version'] ?? '1.0.0'); - $configuration->setSourceType('upload'); - $configuration->setApp($this->request->getParam('appId') ?? ($jsonData['x-openregister']['app'] ?? 'unknown')); - $configuration->setOwner($this->request->getParam('owner') ?? $this->userId); - $configuration->setCreated(new DateTime()); - $configuration->setUpdated(new DateTime()); - $configuration->setRegisters([]); - $configuration->setSchemas([]); - $configuration->setObjects([]); - - // Persist the configuration entity so it appears in the configurations list. - $configuration = $this->configurationMapper->insert($configuration); + // Create and persist the Configuration entity from the JSON data. + // ImportHandler needs the entity for its tracking, and it has to be + // in the list afterwards, so it is built and inserted in one call. + // + // `created` and `updated` are NOT set here: the mapper stamps both + // on insert, so setting them was writing values it overwrote. + $configuration = $this->configurationMapper->createFromArray( + [ + 'title' => ($jsonData['info']['title'] ?? 'Imported Configuration'), + 'description' => ($jsonData['info']['description'] ?? ''), + 'version' => ($jsonData['info']['version'] ?? '1.0.0'), + 'sourceType' => 'upload', + 'app' => ($this->request->getParam('appId') ?? ($jsonData['x-openregister']['app'] ?? 'unknown')), + 'owner' => ($this->request->getParam('owner') ?? $this->userId), + 'registers' => [], + 'schemas' => [], + 'objects' => [], + ] + ); // Import the data. $force = $this->request->getParam('force') === 'true' || $this->request->getParam('force') === true; @@ -465,9 +467,19 @@ public function import(): JSONResponse { ); // Link the imported registers, schemas and objects to the configuration. - $registerIds = array_map(static fn ($r) => $r->getId(), $result['registers']); - $schemaIds = array_map(static fn ($schema) => $schema->getId(), $result['schemas']); - $objectIds = array_map(static fn ($obj) => $obj->getId(), $result['objects']); + // + // `$result['objects']` IS NOT UNIFORMLY ENTITIES. ImportHandler + // appends an ObjectEntity in two places and a bare id in two others + // (`$result['objects'][] = $existingObject->getId()`), so a + // descriptor that carries seed objects reached + // `$obj->getId()` on an int and died with a TypeError. That is an + // `Error`, not an `Exception`, so the catch below did not see it + // either: the endpoint answered 500 with Nextcloud's HTML error page + // instead of the JSON it documents. Measured on stackiq's register, + // whose import was the only one of eighteen to fail this way. + $registerIds = self::idsOf(items: $result['registers']); + $schemaIds = self::idsOf(items: $result['schemas']); + $objectIds = self::idsOf(items: $result['objects']); $configuration->setRegisters(array_values(array_unique($registerIds))); $configuration->setSchemas(array_values(array_unique($schemaIds))); @@ -480,8 +492,42 @@ public function import(): JSONResponse { 'imported' => $result, ] ); - } catch (Exception $e) { + } catch (\Throwable $e) { + // Throwable, not Exception. A TypeError in this method is a bug in + // OpenRegister rather than bad input, and answering it with a 500 + // HTML page hides which of the two it was — the caller cannot tell a + // malformed descriptor from a crash, and neither could the operator + // reading the response. return new JSONResponse(data: ['error' => 'Failed to import configuration: ' . $e->getMessage()], statusCode: 400); }//end try }//end import() + + /** + * The ids of an import result list, whether it holds entities or ids. + * + * @param mixed $items Entities, ids, or a mix of the two. + * + * @return array The ids, with anything unusable dropped. + * + * @spec openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/specs/configuration-import/spec.md#requirement-the-import-endpoint-answers-in-json-req-cim-001 + */ + private static function idsOf(mixed $items): array { + if (is_array($items) === false) { + return []; + } + + $ids = []; + foreach ($items as $item) { + if (is_object($item) === true && method_exists($item, 'getId') === true) { + $ids[] = $item->getId(); + continue; + } + + if (is_int($item) === true || is_string($item) === true) { + $ids[] = $item; + } + } + + return $ids; + }//end idsOf() }//end class 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/CredentialController.php b/lib/Controller/CredentialController.php index 7c3004e4c2..c1f9d205e0 100644 --- a/lib/Controller/CredentialController.php +++ b/lib/Controller/CredentialController.php @@ -35,11 +35,14 @@ namespace OCA\OpenRegister\Controller; +use InvalidArgumentException; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Service\Credential\CredentialAccessDeniedException; use OCA\OpenRegister\Service\Credential\CredentialAppTokenService; use OCA\OpenRegister\Service\Credential\CredentialBrokerService; +use OCA\OpenRegister\Service\Credential\CredentialRelinkRequiredException; use OCA\OpenRegister\Service\Credential\CredentialStore; +use OCA\OpenRegister\Service\Credential\CredentialUpdateRequest; use OCA\OpenRegister\Service\Credential\CredentialUpstreamException; use OCA\OpenRegister\Service\Credential\ProviderCatalogue; use OCA\OpenRegister\Service\ObjectService; @@ -225,9 +228,23 @@ public function providers(): JSONResponse { continue; } + // `kind` and `requiresInstanceBaseUrl` are the two things a picker needs to + // know to offer the right form, and neither is a secret: one says which + // shape of credential this provider takes, the other says whether the + // person has to name their own server. The allow-rules and the endpoints + // stay behind, as they always have: they are an internal guardrail, and + // publishing them would tell a caller exactly which paths to probe. + $kind = 'secret'; + if (trim((string)($entry['kind'] ?? '')) === 'oauth2-token-set') { + $kind = 'oauth2-token-set'; + } + $out[] = [ 'identifier' => $identifier, 'title' => (string)($entry['title'] ?? $identifier), + 'kind' => $kind, + 'requiresInstanceBaseUrl' => (trim((string)($entry['baseUrlFrom'] ?? '')) !== ''), + 'preview' => (($entry['preview'] ?? false) === true), ]; } @@ -296,8 +313,14 @@ public function create(): JSONResponse { allowedApps: $allowed, secret: $secret, scope: $scope, - organisation: $organisation + organisation: $organisation, + metadata: $this->connectionMetadataParams() ); + } catch (InvalidArgumentException $invalid) { + // The only InvalidArgumentException a mint raises for a well-formed + // request is a rejected instance host, and that IS a bad request rather + // than a server fault: answering 500 would read as "try again later". + return new JSONResponse(['message' => 'Invalid credential request'], Http::STATUS_BAD_REQUEST); } catch (Throwable $e) { return new JSONResponse(['message' => 'Unable to create credential'], Http::STATUS_INTERNAL_SERVER_ERROR); } @@ -327,13 +350,11 @@ public function update(string $id): JSONResponse { $data = $existing->getObject(); $scope = $this->scopeOf(data: $data); - $nameParam = $this->request->getParam('name'); - if (is_string($nameParam) === true && trim($nameParam) !== '') { - $data['name'] = trim($nameParam); - } + $update = new CredentialUpdateRequest(request: $this->request); + $data = $update->applyTo(data: $data); - if ($this->request->getParam('allowedApps') !== null) { - $data['allowedApps'] = $this->normaliseAllowedApps(value: $this->request->getParam('allowedApps', [])); + if ($update->wouldRepointHost(data: $data) === true) { + return new JSONResponse(['message' => 'Invalid credential request'], Http::STATUS_BAD_REQUEST); } try { @@ -347,19 +368,9 @@ public function update(string $id): JSONResponse { return new JSONResponse(['message' => 'Unable to update credential'], Http::STATUS_INTERNAL_SERVER_ERROR); } - $secret = $this->request->getParam('secret'); - if (is_string($secret) === true) { - // Garbage-in prevention: this rotation path writes straight to the - // vault and does NOT go through CredentialBrokerService::mint(), so - // it needs its own trim (credential-broker-upstream-diagnostics D3) - // — a copy-pasted secret with a trailing newline previously reached - // the vault byte-for-byte and later failed header injection at call - // time with no usable diagnostic. - $secret = trim($secret); - } - - if (is_string($secret) === true && $secret !== '') { - $this->credentialStore->put($id, $secret, $scope); + $rotated = $update->rotatedSecret(); + if ($rotated !== null) { + $this->credentialStore->put($id, $rotated, $scope); } return new JSONResponse($this->serialise(object: $saved)); @@ -653,6 +664,11 @@ public function brokerRequest(string $id): JSONResponse { headers: $this->normaliseHeaders(value: $this->request->getParam('headers', [])), body: $this->normaliseBody(value: $this->request->getParam('body')) ); + } catch (CredentialRelinkRequiredException $e) { + // A distinct status, because this is the one broker refusal a client can + // actually act on: reconnect the account. It stays static about WHY, so + // it is not an oracle for which guard or which provider error fired. + return new JSONResponse(['message' => 'Credential must be reconnected'], Http::STATUS_CONFLICT); } catch (CredentialAccessDeniedException $e) { return new JSONResponse(['message' => 'Request not permitted'], Http::STATUS_FORBIDDEN); } catch (CredentialUpstreamException $e) { @@ -710,6 +726,11 @@ public function sessionBrokerRequest(string $id): JSONResponse { headers: $this->normaliseHeaders(value: $this->request->getParam('headers', [])), body: $this->normaliseBody(value: $this->request->getParam('body')) ); + } catch (CredentialRelinkRequiredException $e) { + // A distinct status, because this is the one broker refusal a client can + // actually act on: reconnect the account. It stays static about WHY, so + // it is not an oracle for which guard or which provider error fired. + return new JSONResponse(['message' => 'Credential must be reconnected'], Http::STATUS_CONFLICT); } catch (CredentialAccessDeniedException $e) { return new JSONResponse(['message' => 'Request not permitted'], Http::STATUS_FORBIDDEN); } catch (CredentialUpstreamException $e) { @@ -721,6 +742,29 @@ public function sessionBrokerRequest(string $id): JSONResponse { return new JSONResponse($result); }//end sessionBrokerRequest() + /** + * Read the non-secret connection metadata a create request may carry. + * + * Only the keys an OAuth2 connection needs are read; everything else in the body + * is ignored here and dropped again by the broker's own allow-list, so the two + * layers agree by construction rather than by memory. + * + * @return array The proposed metadata, unvalidated (the broker validates). + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-non-secret-connection-metadata-lives-on-the-credential-object + */ + private function connectionMetadataParams(): array { + $metadata = []; + foreach (['kind', 'status', 'instanceBaseUrl', 'clientId', 'clientCredentialRef'] as $key) { + $value = $this->request->getParam($key); + if (is_string($value) === true && trim($value) !== '') { + $metadata[$key] = trim($value); + } + } + + return $metadata; + }//end connectionMetadataParams() + /** * Resolve the current user's id, or null when unauthenticated. * diff --git a/lib/Controller/CredentialOauth2Controller.php b/lib/Controller/CredentialOauth2Controller.php new file mode 100644 index 0000000000..3e1f335be8 --- /dev/null +++ b/lib/Controller/CredentialOauth2Controller.php @@ -0,0 +1,466 @@ + + * @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://conduction.nl + * + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use InvalidArgumentException; +use OCA\OpenRegister\Service\Credential\OAuth2ConnectionRepository; +use OCA\OpenRegister\Service\Credential\OAuth2ConnectService; +use OCA\OpenRegister\Service\Credential\OAuth2Endpoints; +use OCA\OpenRegister\Service\Credential\OAuth2InstanceHost; +use OCA\OpenRegister\Service\Credential\OAuth2RelayGuard; +use OCA\OpenRegister\Service\Credential\OAuth2StateService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\Security\Bruteforce\IThrottler; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Connects, reconnects and disconnects a tenant's OAuth2 accounts. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) A connect endpoint is by nature the + * seam between the connect service, the flow state, the relay guard, the throttler + * and this instance's own endpoints, plus the response types it returns. The storage + * and authorization questions moved out into {@see OAuth2ConnectionRepository} and + * the URL ones into {@see OAuth2Endpoints}; what remains is what a connect genuinely + * touches. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The class is one request shape + * with a long list of ways to refuse it, and every refusal is a separate branch on + * purpose: a callback that answered fewer questions would be answering some of them + * by accident. + * @SuppressWarnings(PHPMD.StaticAccess) One call, to {@see OAuth2InstanceHost}, a + * stateless security predicate; injecting it would make the host-lock substitutable. + */ +class CredentialOauth2Controller extends Controller { + /** + * The brute-force action every failed callback is registered under. + * + * @var string + */ + private const THROTTLE_ACTION = 'openregisterOauth2Callback'; + + /** + * Constructor. + * + * @param string $appName The app id. + * @param IRequest $request The current request. + * @param OAuth2ConnectService $connect Builds authorization URLs and redeems codes. + * @param OAuth2StateService $states Issues and redeems the signed state. + * @param OAuth2RelayGuard $relay Decides where a relay may forward. + * @param OAuth2ConnectionRepository $connections Loads, gates and disables a stored connection. + * @param OAuth2Endpoints $endpoints This instance's own callback, metadata and return URLs. + * @param IUserSession $userSession The current session. + * @param IThrottler $throttler Registers failed callback attempts (ADR-082). + * @param LoggerInterface $logger Secret-free diagnostics. + * + * @return void + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Ten, of which two — `$appName` + * and `$request` — are the framework's and cannot be removed from a Nextcloud + * controller. The remaining eight are each a distinct role, and the list is + * already the result of splitting two of them out: the storage and authorization + * questions went to {@see OAuth2ConnectionRepository} and the URL ones to + * {@see OAuth2Endpoints}. Bundling any two of what is left — the connect service, + * the flow state and the relay guard in particular — would join things the design + * deliberately keeps apart, because the relay's ignorance of the state's signature + * is the security property, not an accident of layering. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly OAuth2ConnectService $connect, + private readonly OAuth2StateService $states, + private readonly OAuth2RelayGuard $relay, + private readonly OAuth2ConnectionRepository $connections, + private readonly OAuth2Endpoints $endpoints, + private readonly IUserSession $userSession, + private readonly IThrottler $throttler, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * POST /api/credentials/oauth2/start — begin connecting an account. + * + * @return JSONResponse `{authorizationUrl, expiresIn}`, or a static error. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-starting-a-connection-returns-an-authorization-url-bound-to-the-caller + */ + #[NoAdminRequired] + public function start(): JSONResponse { + $uid = $this->userSession->getUser()?->getUID(); + if ($uid === null) { + return new JSONResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + $providerId = (string)$this->request->getParam('provider', ''); + $requestedScope = (string)$this->request->getParam('scope', 'personal'); + + try { + $provider = $this->connect->oauth2Provider(providerId: $providerId); + $organisation = $this->connections->gatedOrganisation(uid: $uid, requestedScope: $requestedScope); + $host = $this->requestedHost(provider: $provider); + $claims = $this->buildClaims( + uid: $uid, + providerId: $providerId, + provider: $provider, + requestedScope: $requestedScope, + organisation: $organisation, + host: $host + ); + } catch (InvalidArgumentException $invalid) { + return new JSONResponse(['message' => 'Invalid connection request'], Http::STATUS_BAD_REQUEST); + } catch (Throwable $refused) { + return new JSONResponse(['message' => 'Connection not permitted'], Http::STATUS_FORBIDDEN); + } + + try { + // A per-instance provider has no application to bring, so one is created at + // the account's own server HERE, before the URL that names its client id is + // built. The client secret it issues goes straight to the broker as its own + // credential; only the non-secret client id travels on in the claims. + $claims = $this->connect->ensureInstanceClient( + provider: $provider, + claims: $claims, + redirectUri: $this->endpoints->callbackUrl() + ); + $issued = $this->states->issue(claims: $claims); + $url = $this->connect->authorizationUrl( + provider: $provider, + claims: $claims, + redirectUri: $this->endpoints->callbackUrl(), + state: $issued['state'], + challenge: $issued['challenge'] + ); + } catch (Throwable $failure) { + $this->logger->warning('[CredentialOauth2Controller] could not start a connection: ' . $failure->getMessage()); + + return new JSONResponse(['message' => 'Unable to start the connection'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new JSONResponse(['authorizationUrl' => $url, 'expiresIn' => OAuth2StateService::STATE_TTL_SECONDS]); + }//end start() + + /** + * GET /oauth2/callback — receive a provider's redirect, or relay it onward. + * + * @return RedirectResponse|JSONResponse A redirect to the return URL or the relay target, or a static error. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-callback-exchanges-the-code-and-mints-a-token-set-credential + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-a-relay-forwards-a-code-and-never-exchanges-it + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 30, period: 60)] + #[BruteForceProtection(action: self::THROTTLE_ACTION)] + public function callback(): RedirectResponse | JSONResponse { + $state = (string)$this->request->getParam('state', ''); + $code = (string)$this->request->getParam('code', ''); + + $shape = $this->states->parseUnverified(state: $state); + if ($shape === null || $code === '') { + return $this->refuse(); + } + + $destination = (string)($shape['cb'] ?? ''); + if ($destination !== '' && rtrim($destination, '/') !== rtrim($this->endpoints->callbackUrl(), '/')) { + return $this->forward(destination: $destination, code: $code, state: $state); + } + + $redeemed = $this->states->consume(state: $state); + if ($redeemed === null) { + return $this->refuse(); + } + + $returnUrl = $this->endpoints->safeReturnUrl(candidate: (string)($redeemed['claims']['r'] ?? '')); + + try { + $this->connect->complete( + claims: $redeemed['claims'], + code: $code, + verifier: $redeemed['verifier'], + redirectUri: $this->endpoints->callbackUrl() + ); + } catch (Throwable $failure) { + // Nothing was written: complete() mints only once the token response is + // in hand. Report failure without echoing the provider's own words. + $this->logger->warning('[CredentialOauth2Controller] connect failed: ' . $failure->getMessage()); + + return new RedirectResponse($returnUrl . '?connected=failed'); + } + + return new RedirectResponse($returnUrl . '?connected=ok'); + }//end callback() + + /** + * DELETE /api/credentials/oauth2/{id} — revoke upstream and disable locally. + * + * @param string $id The credential UUID. + * + * @return JSONResponse The disabled credential's status, or a static error. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-disconnecting-revokes-upstream-where-it-can-and-disables-locally + */ + #[NoAdminRequired] + public function disconnect(string $id): JSONResponse { + $uid = $this->userSession->getUser()?->getUID(); + if ($uid === null) { + return new JSONResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + $entity = $this->connections->findManageable(credentialId: $id, uid: $uid); + if ($entity === null) { + return new JSONResponse(['message' => 'Request not permitted'], Http::STATUS_FORBIDDEN); + } + + $data = $entity->jsonSerialize(); + unset($data['@self']); + + $lastError = ''; + try { + $lastError = $this->connect->revokeUpstream( + provider: $this->connect->oauth2Provider(providerId: (string)($data['provider'] ?? '')), + credential: $data, + credentialId: $id, + scope: (string)($data['scope'] ?? 'personal') + ); + } catch (Throwable $failure) { + // An unreachable or unknown provider must not keep a tenant connected. + $lastError = 'revoke_failed'; + } + + try { + $this->connections->disable(credentialId: $id, data: $data, lastError: $lastError); + } catch (Throwable $failure) { + return new JSONResponse(['message' => 'Unable to disconnect'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new JSONResponse(['status' => 'disabled', 'revoked' => ($lastError === '')]); + }//end disconnect() + + /** + * GET /oauth2/client-metadata.json — this instance's own AT Protocol client metadata. + * + * AT Protocol has no client registry: a client IS the JSON document it publishes, + * and its client identifier is that document's own URL. So the tenant's own + * Nextcloud is its own OAuth client and needs no relay at all. + * + * @return JSONResponse The client metadata document. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-bluesky-is-its-own-client-and-mastodon-registers-per-instance + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 60, period: 60)] + public function clientMetadata(): JSONResponse { + $metadataUrl = $this->endpoints->clientMetadataUrl(); + + return new JSONResponse( + [ + 'client_id' => $metadataUrl, + 'client_name' => 'Nextcloud OpenRegister', + 'client_uri' => $this->endpoints->instanceUrl(), + 'redirect_uris' => [$this->endpoints->callbackUrl()], + 'grant_types' => ['authorization_code', 'refresh_token'], + 'response_types' => ['code'], + 'scope' => 'atproto transition:generic', + 'token_endpoint_auth_method' => 'none', + 'application_type' => 'web', + 'dpop_bound_access_tokens' => true, + ] + ); + }//end clientMetadata() + + /** + * Relay a code onward, without exchanging it. + * + * @param string $destination The receiving instance's callback, from the state. + * @param string $code The authorization code. + * @param string $state The state, forwarded unchanged. + * + * @return RedirectResponse|JSONResponse The forward, or a refusal. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-a-relay-forwards-a-code-and-never-exchanges-it + */ + private function forward(string $destination, string $code, string $state): RedirectResponse | JSONResponse { + if ($this->relay->permits(callbackUrl: $destination) === false) { + return $this->refuse(); + } + + return new RedirectResponse( + $destination . '?' . http_build_query(['code' => $code, 'state' => $state]) + ); + }//end forward() + + /** + * Refuse a callback uniformly, registering the attempt. + * + * @return JSONResponse The static refusal. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-callback-exchanges-the-code-and-mints-a-token-set-credential + */ + private function refuse(): JSONResponse { + try { + $this->throttler->registerAttempt( + action: self::THROTTLE_ACTION, + ip: $this->request->getRemoteAddress() + ); + } catch (Throwable $throttlerFailure) { + $this->logger->warning('[CredentialOauth2Controller] registerAttempt failed: ' . $throttlerFailure->getMessage()); + } + + // Uniform, and deliberately silent about which check failed. + return new JSONResponse(['message' => 'Bad Request'], Http::STATUS_BAD_REQUEST); + }//end refuse() + + /** + * Assemble the claims a state carries. + * + * @param string $uid The initiating user. + * @param string $providerId The catalogue provider identifier. + * @param array $provider The catalogue provider entry. + * @param string $requestedScope The requested credential scope. + * @param string|null $organisation The gated organisation, for an organisation-scoped connect. + * @param string|null $host The validated per-account host, when the provider has one. + * + * @return array The claims, plus a `_credential` shape for client resolution. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-starting-a-connection-returns-an-authorization-url-bound-to-the-caller + */ + private function buildClaims( + string $uid, + string $providerId, + array $provider, + string $requestedScope, + ?string $organisation, + ?string $host, + ): array { + $clientRef = trim((string)$this->request->getParam('credentialRef', '')); + $clientId = trim((string)$this->request->getParam('clientId', '')); + $reauthorise = trim((string)$this->request->getParam('credentialId', '')); + + if ($reauthorise !== '' && $this->connections->findManageable(credentialId: $reauthorise, uid: $uid) === null) { + throw new InvalidArgumentException(message: 'the credential named for re-authorisation is not manageable by this caller'); + } + + $scopes = $this->request->getParam('scopes'); + if (is_array($scopes) === false || $scopes === []) { + $scopes = ($provider['oauth2']['defaultScopes'] ?? []); + } + + return [ + 'u' => $uid, + 'p' => $providerId, + 's' => $requestedScope, + 'o' => $organisation, + 'h' => $host, + 'sc' => array_values(array_map('strval', $scopes)), + 'a' => $this->requestedAllowedApps(), + 'nm' => trim((string)$this->request->getParam('name', $providerId)), + 'cl' => $clientId, + 'cr' => $clientRef, + 'cid' => $reauthorise, + 'r' => $this->endpoints->safeReturnUrl(candidate: (string)$this->request->getParam('returnUrl', '')), + 'cb' => $this->endpoints->callbackUrl(), + ]; + }//end buildClaims() + + /** + * The per-account host a start supplied, validated, or null when the provider has none. + * + * @param array $provider The catalogue provider entry. + * + * @return string|null The normalised origin, or null. + * + * @throws InvalidArgumentException When the provider needs a host and none was supplied or it is unsafe. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-per-account-host-is-pinned-at-mint-and-immutable-afterwards + */ + private function requestedHost(array $provider): ?string { + if (trim((string)($provider['baseUrlFrom'] ?? '')) === '') { + return null; + } + + return OAuth2InstanceHost::normalise(candidate: (string)$this->request->getParam('instanceBaseUrl', '')); + }//end requestedHost() + + /** + * The app ids a start asked to grant the new connection to. + * + * @return array The app ids. + * + * @spec exclude private request accessor with no behaviour of its own + */ + private function requestedAllowedApps(): array { + $apps = $this->request->getParam('allowedApps', []); + if (is_array($apps) === false) { + return []; + } + + $normalised = []; + foreach ($apps as $app) { + $candidate = trim((string)$app); + if ($candidate !== '' && preg_match('/^[a-z0-9_-]+$/', $candidate) === 1) { + $normalised[] = $candidate; + } + } + + return $normalised; + }//end requestedAllowedApps() + +}//end class 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/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/DeletedController.php b/lib/Controller/DeletedController.php index d31043e1ec..a6fa11ef60 100644 --- a/lib/Controller/DeletedController.php +++ b/lib/Controller/DeletedController.php @@ -31,7 +31,9 @@ use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\ArchivalImmutableException; use OCA\OpenRegister\Service\Object\PermissionHandler; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\JSONResponse; @@ -45,6 +47,12 @@ * Controller for managing soft deleted objects * * @psalm-suppress UnusedClass + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) One over, from the archival + * gate: refusing a retained record needs the Schema it is declared on and the + * ArchivalImmutableException that carries the wire contract. Both come from the + * sanctioned delete path, which is the point — the alternative to that coupling + * is a second, quietly different rule. */ class DeletedController extends Controller { @@ -144,6 +152,66 @@ private function userMayActOnDeletedObject(ObjectEntity $object, string $action) } }//end userMayActOnDeletedObject() + /** + * Resolve a soft-deleted object's schema, or null when it cannot be found. + * + * @param ObjectEntity $object The object whose schema to resolve. + * + * @return Schema|null The schema, or null when it cannot be resolved. + */ + private function resolveSchema(ObjectEntity $object): ?Schema { + $schemaId = $object->getSchema(); + if ($schemaId === null || $schemaId === '') { + return null; + } + + try { + return $this->schemaMapper->find((int)$schemaId); + } catch (\Throwable $e) { + return null; + } + }//end resolveSchema() + + /** + * Refuse a purge when the object is a legally retained archival record. + * + * `DELETE /api/objects/{register}/{schema}/{id}` rejects a delete on an + * archival schema with 403 SCHEMA_ARCHIVAL_IMMUTABLE + * ({@see \OCA\OpenRegister\Service\ObjectService::deleteObject()}). Purging + * is strictly more destructive than deleting, so it answers on the same + * terms, from the same definition ({@see Schema::hasArchivalAnnotation()}) — + * otherwise the trash is a second door onto the records the first door + * exists to protect. + * + * Fails CLOSED: an object whose schema cannot be resolved is refused, since + * an unresolvable schema is exactly the case where the annotation cannot be + * read and the row might be retained. + * + * @param ObjectEntity $object The object being purged. + * + * @return ArchivalImmutableException|null The refusal, or null when the purge may proceed. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + private function archivalRefusal(ObjectEntity $object): ?ArchivalImmutableException { + $schema = $this->resolveSchema(object: $object); + if ($schema === null) { + return new ArchivalImmutableException( + schemaIdentifier: (string)($object->getSchema() ?? 'unknown'), + operation: 'purge' + ); + } + + if ($schema->hasArchivalAnnotation() === false) { + return null; + } + + return new ArchivalImmutableException( + schemaIdentifier: ($schema->getSlug() ?? (string)$schema->getId()), + operation: 'purge' + ); + }//end archivalRefusal() + /** * Helper method to extract request parameters for deleted objects * @@ -399,7 +467,7 @@ public function restore(string $id): JSONResponse { try { $object = $this->objectEntityMapper->find($id, null, null, true); - if ($object->getDeleted() === null || $object->getDeleted() === []) { + if ($object->isSoftDeleted() === false) { return new JSONResponse( data: [ 'error' => 'Object is not deleted', @@ -487,7 +555,7 @@ public function restoreMultiple(): JSONResponse { $foundIds[] = $object->getUuid(); try { - if ($object->getDeleted() === null) { + if ($object->isSoftDeleted() === false) { // Object exists but is not deleted. $failed++; continue; @@ -563,7 +631,24 @@ public function destroy(string $id): JSONResponse { try { $object = $this->objectEntityMapper->find(identifier: $id, register: null, schema: null, includeDeleted: true); - if ($object->getDeleted() === null) { + // An archival record is refused here on the same terms the normal + // delete path refuses it. Answered BEFORE the trash check, so a + // caller holding a live archival record is told the record is + // immutable rather than "not deleted yet" — which would read as an + // invitation to soft-delete it first and come back. + $refusal = $this->archivalRefusal(object: $object); + if ($refusal !== null) { + return new JSONResponse( + data: $refusal->toResponseBody(), + statusCode: 403 + ); + } + + // A purge empties the TRASH. `getDeleted() === null` never answered + // that question — the property defaults to `[]` and a live row keeps + // that default — so this guard let every live object through and + // destroyed it. See ObjectEntity::isSoftDeleted(). + if ($object->isSoftDeleted() === false) { return new JSONResponse( data: [ 'error' => 'Object is not deleted', @@ -658,7 +743,15 @@ public function destroyMultiple(): JSONResponse { $foundIds[] = $object->getUuid(); try { - if ($object->getDeleted() === null) { + // An archival record is never purged, in bulk or singly. + // Counted as failed rather than aborting the batch, so one + // retained row does not strand the rest of the cleanup. + if ($this->archivalRefusal(object: $object) !== null) { + $failed++; + continue; + } + + if ($object->isSoftDeleted() === false) { // Object exists but is not deleted. $failed++; continue; diff --git a/lib/Controller/DsarCaseController.php b/lib/Controller/DsarCaseController.php index 8594d9b658..dcc1b39a5e 100644 --- a/lib/Controller/DsarCaseController.php +++ b/lib/Controller/DsarCaseController.php @@ -117,8 +117,17 @@ 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. + $self = (array)($body['@self'] ?? []); + unset($self['id'], $self['uuid']); + unset($body['_route'], $body['id'], $body['uuid'], $body['@self']); + if ($self !== []) { + $body['@self'] = $self; + } try { $saved = $this->objectService->saveObject( 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/FederationController.php b/lib/Controller/FederationController.php index 82ac171210..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] @@ -332,9 +334,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/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/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/FlowRunController.php b/lib/Controller/FlowRunController.php index e6902d8118..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); @@ -213,26 +223,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 +366,7 @@ public function active(): JSONResponse { 'limit' => $limit, ] ); - - }//end active() + }//end page() /** * The caller's active organisation uuid, or null when none resolves. @@ -566,25 +691,95 @@ 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) { + // 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()); + }//end resume() + + /** + * Deliver a signal addressed by BUSINESS KEY instead of run uuid. + * + * The caller that knows "the vote on proposal X closed" does not know a + * run uuid; an await-signal step that declared a `correlationKey` made + * its suspension addressable by that key. Resolution is FAIL-CLOSED in + * both directions (flow-approval-consolidation design D-7): + * + * - zero matches is a 404 and the signal is NOT buffered: a run that + * suspends with that key later was never the addressee + * - more than one match is a 409 and wakes NOTHING: picking one is + * picking wrong half the time, silently + * + * The authority is exactly {@see resume()}'s — the same flow-level check + * and the same recorded-assignee check, on the resolved run. A + * correlated signal cannot complete, claim or advance a user task: a run + * suspended on a user-task node treats any signal as a nudge, and a + * nudge is not an answer. + * + * @param string $key The business key. + * + * @return JSONResponse The signalled run, 404 when unmatched, 409 when + * ambiguous or not suspended. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @contract tests/integration/openregister-integrations.postman_collection.json + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-signal-node-keeps-machine-to-machine-work-and-gains-a-correlation-key + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function signalByKey(string $key): JSONResponse { + $key = trim($key); + $matches = []; + if ($key !== '') { + $matches = $this->mapper->findSuspendedByCorrelationKey(correlationKey: $key); + } + + if ($matches === []) { return new JSONResponse( - ['error' => 'Only a suspended run can be resumed; this one is ' . $run->getStatus() . '.'], + ['error' => 'No suspended run is waiting for this key. The signal was not stored.'], + Http::STATUS_NOT_FOUND + ); + } + + if (count($matches) > 1) { + return new JSONResponse( + ['error' => 'More than one suspended run carries this key. Nothing was signalled; address the run by its uuid instead.'], Http::STATUS_CONFLICT ); } + $run = $matches[0]; + + $refusal = $this->refuseUnlessRunnable(flowId: (string)$run->getFlowId()); + 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']); + + // 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()); - }//end resume() + }//end signalByKey() /** * Refuse unless the caller may RUN this flow. @@ -627,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. * - * 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". + * 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. * - * 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. + * 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 FlowRun $run The suspended run. + * @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'). * - * @return JSONResponse|null A 403 when the caller is not the assignee. + * @return JSONResponse The 403 or 409 the refusal maps to. + * + * @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/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..21af5a8ee9 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -34,12 +34,14 @@ use DateTime; use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Exception\AppendOnlyException; use OCA\OpenRegister\Exception\CustomValidationException; use OCA\OpenRegister\Exception\ExportTooLargeException; use OCA\OpenRegister\Exception\FolderAccessDeniedException; +use OCA\OpenRegister\Exception\LockedException; use OCA\OpenRegister\Exception\NotAuthorizedException; use OCA\OpenRegister\Exception\ReferentialIntegrityException; use OCA\OpenRegister\Controller\Trait\ResolvesRegisterAndSchemaTrait; @@ -2709,6 +2711,13 @@ public function create( ], statusCode: 422 ); + } catch (LockedException $exception) { + // A lock taken between this handler's pre-read and the save reaches + // here as the service-layer guard's typed refusal. Caught before + // generic \Exception so it answers 423 naming the holder rather + // than falling into the hardcoded 500 that made a locked PATCH + // look like a server fault. + return $this->lockedResponse(exception: $exception); } catch (FolderAccessDeniedException $exception) { // MUST be caught before generic \Exception to avoid being absorbed as a 403 with // a non-structured body. See the `self-folder-access-control` capability spec. @@ -2851,18 +2860,14 @@ public function update( return new JSONResponse(data: ['error' => 'Object not found in specified register/schema'], statusCode: 404); } - // Check if the object is locked. - if ($existingObject->isLocked() === true - && $existingObject->getLockedBy() !== $this->container->get('userId') - ) { - // Return a "locked" error with the user who has the lock. - return new JSONResponse( - data: [ - 'error' => 'Object is locked by ' . $existingObject->getLockedBy(), - 'lockedBy' => $existingObject->getLockedBy(), - ], - statusCode: 423 - ); + // Check if the object is locked. This used to be the ONE live lock + // guard in the codebase, written inline here; PATCH and POST-patch + // had none and answered 400 or 500 instead. The guard now lives in + // `lockRefusalResponse()` and all three doors call it, so there is + // one comparison and one refusal rather than three spellings. + $lockRefusal = $this->lockRefusalResponse(existingObject: $existingObject); + if ($lockRefusal !== null) { + return $lockRefusal; } } catch (DoesNotExistException $exception) { return new JSONResponse(data: ['error' => 'Not Found'], statusCode: 404); @@ -2916,7 +2921,21 @@ public function update( // locked" question is free here and the scan is pure waste — measured // at ~780 ms of a ~1.3 s update. try { - if ($objectEntity->isLocked() === true) { + // Release ONLY a lock this writer actually holds. A run-held + // lock must survive somebody else's write: without this test + // an administrator's write would silently strip a run's lock + // as a side effect of a guard it had just passed. + // + // NO `runUuid` HERE, DELIBERATELY, and it is the one guard in + // this file that omits it. This decides a RELEASE, not a + // refusal: asking it as the run would make a run's own write + // drop the run's own lock the moment it saved — the lock is + // meant to outlive every write the run makes. Asked as a + // person, a run-held lock reads as somebody else's and is + // left alone, which is what this test is for. + if ($objectEntity->isLocked() === true + && $objectEntity->isLockedBySomeoneElse(userId: $this->container->get('userId')) === false + ) { $this->objectService->unlockObject($objectEntity->getUuid()); } } catch (\Exception $e) { @@ -2953,6 +2972,13 @@ public function update( data: ['error' => $exception->getMessage(), 'errors' => $exception->getErrors()], statusCode: 422 ); + } catch (LockedException $exception) { + // A lock taken between this handler's pre-read and the save reaches + // here as the service-layer guard's typed refusal. Caught before + // generic \Exception so it answers 423 naming the holder rather + // than falling into the hardcoded 500 that made a locked PATCH + // look like a server fault. + return $this->lockedResponse(exception: $exception); } catch (FolderAccessDeniedException $exception) { // MUST be caught before generic \Exception. See `self-folder-access-control` spec. return $this->folderAccessDeniedResponse(exception: $exception); @@ -3082,6 +3108,16 @@ public function patch( return new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404); }//end try + // The lock answers BEFORE the optimistic-concurrency check, before + // the merge and before validation, exactly as it does on PUT. + // Ordering is the whole point: a caller told their payload is + // invalid, or handed a bare 500, when the real answer is "alice + // holds this" has been sent to fix the wrong thing. + $lockRefusal = $this->lockRefusalResponse(existingObject: $existingObject); + if ($lockRefusal !== null) { + return $lockRefusal; + } + // Optimistic concurrency (fix-object-patch-lost-update): PATCH is a // read-merge-write, so two concurrent PATCHes can silently clobber each // other's untouched fields. A caller that read the object may pass the @@ -3139,7 +3175,21 @@ public function patch( // locked" question is free here and the scan is pure waste — measured // at ~780 ms of a ~1.3 s update. try { - if ($objectEntity->isLocked() === true) { + // Release ONLY a lock this writer actually holds. A run-held + // lock must survive somebody else's write: without this test + // an administrator's write would silently strip a run's lock + // as a side effect of a guard it had just passed. + // + // NO `runUuid` HERE, DELIBERATELY, and it is the one guard in + // this file that omits it. This decides a RELEASE, not a + // refusal: asking it as the run would make a run's own write + // drop the run's own lock the moment it saved — the lock is + // meant to outlive every write the run makes. Asked as a + // person, a run-held lock reads as somebody else's and is + // left alone, which is what this test is for. + if ($objectEntity->isLocked() === true + && $objectEntity->isLockedBySomeoneElse(userId: $this->container->get('userId')) === false + ) { $this->objectService->unlockObject($objectEntity->getUuid()); } } catch (\Exception $e) { @@ -3188,6 +3238,13 @@ public function patch( data: ['error' => $exception->getMessage(), 'errors' => $exception->getErrors()], statusCode: 422 ); + } catch (LockedException $exception) { + // A lock taken between this handler's pre-read and the save reaches + // here as the service-layer guard's typed refusal. Caught before + // generic \Exception so it answers 423 naming the holder rather + // than falling into the hardcoded 500 that made a locked PATCH + // look like a server fault. + return $this->lockedResponse(exception: $exception); } catch (FolderAccessDeniedException $exception) { // MUST be caught before generic \Exception. See `self-folder-access-control` spec. return $this->folderAccessDeniedResponse(exception: $exception); @@ -3288,6 +3345,14 @@ public function postPatch( return new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404); } + // The lock answers BEFORE the merge and before validation, exactly + // as it does on PUT. Reached through the same guard, so the two + // doors to one object cannot drift apart again. + $lockRefusal = $this->lockRefusalResponse(existingObject: $existingObject); + if ($lockRefusal !== null) { + return $lockRefusal; + } + // Merge existing data with patch data (patch semantics). $existingData = $existingObject->getObject(); $mergedData = array_merge($existingData ?? [], $patchData); @@ -3315,7 +3380,21 @@ public function postPatch( // locked" question is free here and the scan is pure waste — measured // at ~780 ms of a ~1.3 s update. try { - if ($objectEntity->isLocked() === true) { + // Release ONLY a lock this writer actually holds. A run-held + // lock must survive somebody else's write: without this test + // an administrator's write would silently strip a run's lock + // as a side effect of a guard it had just passed. + // + // NO `runUuid` HERE, DELIBERATELY, and it is the one guard in + // this file that omits it. This decides a RELEASE, not a + // refusal: asking it as the run would make a run's own write + // drop the run's own lock the moment it saved — the lock is + // meant to outlive every write the run makes. Asked as a + // person, a run-held lock reads as somebody else's and is + // left alone, which is what this test is for. + if ($objectEntity->isLocked() === true + && $objectEntity->isLockedBySomeoneElse(userId: $this->container->get('userId')) === false + ) { $this->objectService->unlockObject($objectEntity->getUuid()); } } catch (\Exception $e) { @@ -3340,6 +3419,13 @@ public function postPatch( data: ['error' => $exception->getMessage(), 'errors' => $exception->getErrors()], statusCode: 422 ); + } catch (LockedException $exception) { + // A lock taken between this handler's pre-read and the save reaches + // here as the service-layer guard's typed refusal. Caught before + // generic \Exception so it answers 423 naming the holder rather + // than falling into the hardcoded 500 that made a locked PATCH + // look like a server fault. + return $this->lockedResponse(exception: $exception); } catch (FolderAccessDeniedException $exception) { // MUST be caught before generic \Exception so a @self.folder // denial on the post-patch path returns 403 with the structured @@ -3538,9 +3624,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 +3842,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(); @@ -3911,8 +4003,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); @@ -4817,4 +4909,108 @@ private function folderAccessDeniedResponse(FolderAccessDeniedException $excepti statusCode: FolderAccessDeniedException::HTTP_STATUS ); }//end folderAccessDeniedResponse() + + /** + * The flow run this write is being made for, or null when a person is + * writing. + * + * A run-scoped lock refuses every caller but the holding run, so a guard + * that cannot name the caller's run refuses the run that took the lock. + * Resolved from the container rather than injected because the ambient + * stack is a shared service and this is the only thing here that needs it. + * + * A container that cannot serve it answers "a person", which is the + * FAIL-CLOSED direction: a run lock refuses a caller with no run uuid, so + * the worst outcome is a refusal, never a lock walked through. + * + * @return string|null The executing run's uuid, or null. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-ownership-is-decided-by-one-predicate + */ + private function callerRunUuid(): ?string { + try { + $flowContext = $this->container->get(\OCA\OpenRegister\Service\Flow\FlowRunContext::class); + } catch (\Throwable $unavailable) { + return null; + } + + if ($flowContext instanceof \OCA\OpenRegister\Service\Flow\FlowRunContext === false) { + return null; + } + + return $flowContext->currentRunUuid(); + }//end callerRunUuid() + + /** + * Refuse a write to a locked object, or return null when it may proceed. + * + * ONE guard for all three write doors. PUT carried this inline and PATCH + * and POST-patch carried nothing, so the two doors to the same object gave + * two different answers: 423 naming the holder on PUT, and on PATCH + * whatever the payload happened to produce first — 400 from validation, or + * 500 when the payload was valid and the service-layer guard's untyped + * exception fell through to the generic handler. Neither said anything + * about a lock. + * + * The condition is not restated here: `isLockedBySomeoneElse()` is the + * single predicate (openregister#3454), run identity reaches it ambiently + * through `FlowRunContext::currentRunUuid()`, and the refusal itself is + * built by `LockedException::forObject()` so the words and the holder + * fields are the same wherever a lock refuses a write. + * + * Callers MUST invoke this immediately after the existence pre-read and + * BEFORE merging, validating or saving. A caller who is told their payload + * is malformed when the real answer is "someone else holds this" has been + * sent to fix the wrong thing. + * + * @param ObjectEntity $existingObject The object as stored, from the pre-read. + * + * @return JSONResponse|null The 423 refusal, or null when the caller may write. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-refuses-a-write-and-names-its-holder + */ + private function lockRefusalResponse(ObjectEntity $existingObject): ?JSONResponse { + try { + $callerUserId = $this->container->get('userId'); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface $unavailable) { + // No resolvable caller identity. A user lock is held against + // "nobody" just as it is against anyone who is not the holder, so + // the predicate still answers correctly with a null user id. + $callerUserId = null; + } + + if ($existingObject->isLockedBySomeoneElse(userId: $callerUserId, runUuid: $this->callerRunUuid()) === false) { + return null; + } + + return $this->lockedResponse(exception: LockedException::forObject($existingObject)); + }//end lockRefusalResponse() + + /** + * Render a lock refusal as the canonical 423 response body. + * + * Kept separate from `lockRefusalResponse()` so a catch site that receives + * a `LockedException` thrown further down (the service-layer guard in + * `SaveObject::findAndValidateExistingObject()`) answers with the SAME body + * rather than a second spelling of it. + * + * The `error` string keeps the wording PUT has always returned, because + * clients match on it. + * + * @param LockedException $exception The refusal carrying the holder. + * + * @return JSONResponse The 423 response naming the lock holder. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-refuses-a-write-and-names-its-holder + */ + private function lockedResponse(LockedException $exception): JSONResponse { + return new JSONResponse( + data: [ + 'error' => 'Object is locked by ' . (string)$exception->getHolder(), + 'lockedBy' => $exception->getLockedBy(), + 'lockedByRun' => $exception->getLockedByRun(), + ], + statusCode: LockedException::HTTP_STATUS + ); + }//end lockedResponse() }//end class 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/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/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/ScheduledWorkflowController.php b/lib/Controller/ScheduledWorkflowController.php deleted file mode 100644 index ba19c52e2b..0000000000 --- a/lib/Controller/ScheduledWorkflowController.php +++ /dev/null @@ -1,185 +0,0 @@ - - * @copyright 2024 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\Controller; - -use OCA\OpenRegister\Db\ScheduledWorkflowMapper; -use OCP\AppFramework\Controller; -use OCP\AppFramework\Http\JSONResponse; -use OCP\IRequest; -use Psr\Log\LoggerInterface; - -/** - * Controller for scheduled workflow CRUD. - * - * @psalm-suppress UnusedClass - */ -class ScheduledWorkflowController extends Controller { - /** - * Constructor for ScheduledWorkflowController. - * - * @param string $appName App name - * @param IRequest $request Request - * @param ScheduledWorkflowMapper $workflowMapper Scheduled workflow mapper - * @param LoggerInterface $logger Logger - */ - public function __construct( - string $appName, - IRequest $request, - private readonly ScheduledWorkflowMapper $workflowMapper, - private readonly LoggerInterface $logger, - ) { - parent::__construct(appName: $appName, request: $request); - }//end __construct() - - /** - * List all scheduled workflows. - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-17 - */ - public function index(): JSONResponse { - $workflows = $this->workflowMapper->findAll(); - - return new JSONResponse( - array_map(fn ($workflow) => $workflow->jsonSerialize(), $workflows) - ); - }//end index() - - /** - * Get a single scheduled workflow. - * - * @param int $id Scheduled workflow ID - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-17 - */ - public function show(int $id): JSONResponse { - try { - $workflow = $this->workflowMapper->find($id); - - return new JSONResponse($workflow->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => 'Scheduled workflow not found'], 404); - } - }//end show() - - /** - * Create a new scheduled workflow. - * - * @auth admin-only A scheduled workflow is instance-wide: ScheduledWorkflow carries no owner - * column, and the TimedJob that runs it runs for the whole instance, not for the caller. - * There is therefore no per-object guard to write here — nothing scopes a row to a user — - * so admin is the only posture that is not a privilege escalation. Adding - * #[NoAdminRequired] would let any authenticated user schedule work on the instance. - * - * @return JSONResponse - * - * @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-17 - */ - public function create(): JSONResponse { - $data = $this->request->getParams(); - - // Encode payload if it is an array. - if (isset($data['payload']) === true && is_array($data['payload']) === true) { - $data['payload'] = json_encode($data['payload']); - } - - // Map 'interval' to 'intervalSec' for convenience. - if (isset($data['interval']) === true && isset($data['intervalSec']) === false) { - $data['intervalSec'] = (int)$data['interval']; - } - - try { - $workflow = $this->workflowMapper->createFromArray($data); - - return new JSONResponse($workflow->jsonSerialize(), 201); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], 500); - } - }//end create() - - /** - * Update a scheduled workflow. - * - * @param int $id Scheduled workflow ID - * - * @auth admin-only ScheduledWorkflow has no owner column, so "the caller's own workflow" is not - * a thing that can be expressed, let alone guarded. Rescheduling or re-pointing a job - * changes instance-wide behaviour for every user, so admin is the correct posture. - * - * @return JSONResponse - * - * @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-17 - */ - public function update(int $id): JSONResponse { - try { - $data = $this->request->getParams(); - - if (isset($data['payload']) === true && is_array($data['payload']) === true) { - $data['payload'] = json_encode($data['payload']); - } - - if (isset($data['interval']) === true && isset($data['intervalSec']) === false) { - $data['intervalSec'] = (int)$data['interval']; - } - - $workflow = $this->workflowMapper->updateFromArray($id, $data); - - return new JSONResponse($workflow->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => 'Scheduled workflow not found'], 404); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], 500); - } - }//end update() - - /** - * Delete a scheduled workflow. - * - * @param int $id Scheduled workflow ID - * - * @auth admin-only Deletes an instance-wide scheduled job by id. ScheduledWorkflow has no owner - * column, so a per-object guard has nothing to compare the caller against; under - * #[NoAdminRequired] this would be a direct object reference any user could delete. - * - * @return JSONResponse - * - * @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-17 - */ - public function destroy(int $id): JSONResponse { - try { - $workflow = $this->workflowMapper->find($id); - $this->workflowMapper->delete($workflow); - - return new JSONResponse($workflow->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => 'Scheduled workflow not found'], 404); - } - }//end destroy() -}//end class diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php index 4eb2694ce5..2fdda66352 100644 --- a/lib/Controller/SchemasController.php +++ b/lib/Controller/SchemasController.php @@ -32,6 +32,7 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\ArchivalImmutableException; use OCA\OpenRegister\Exception\BreakingSchemaChangeException; use OCA\OpenRegister\Exception\DatabaseConstraintException; use OCA\OpenRegister\Exception\RegisterNotFoundException; @@ -1110,6 +1111,13 @@ public function destroy(int $id): JSONResponse { objectCount: $objectCount, force: $force ); + } catch (ArchivalImmutableException $e) { + // The cascade refused: this schema holds legally retained records. An HTTP + // caller has no way to override that — `occ openregister:schemas:prune-retired + // --force-archival` is the sanctioned path, because shell access is an + // authorization boundary a request cannot cross. Caught ahead of the generic + // handler, which would have reported a deliberate refusal as a 500. + return new JSONResponse(data: $e->toResponseBody(), statusCode: Http::STATUS_FORBIDDEN); } catch (\OCA\OpenRegister\Exception\ValidationException $e) { // Return 409 Conflict for cascade protection (objects still attached). return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 409); diff --git a/lib/Controller/Settings/N8nSettingsController.php b/lib/Controller/Settings/N8nSettingsController.php deleted file mode 100644 index d6f9a54e05..0000000000 --- a/lib/Controller/Settings/N8nSettingsController.php +++ /dev/null @@ -1,507 +0,0 @@ - - * @copyright 2025 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @version GIT: - * @link https://www.OpenRegister.app - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Controller\Settings; - -use Exception; -use OCA\OpenRegister\Service\Settings\ConfigurationSettingsHandler; -use OCA\OpenRegister\Service\SettingsService; -use OCP\AppFramework\Controller; -use OCP\AppFramework\Http\JSONResponse; -use OCP\Http\Client\IClientService; -use OCP\IRequest; -use Psr\Log\LoggerInterface; - -/** - * Controller for n8n workflow integration settings. - * - * Handles: - * - n8n connection configuration - * - Connection testing - * - Project initialization - * - Workflow management - * - * @category Controller - * @package OCA\OpenRegister\Controller\Settings - */ -class N8nSettingsController extends Controller { - - /** - * Configuration settings handler. - * - * @var ConfigurationSettingsHandler - */ - private ConfigurationSettingsHandler $configHandler; - - /** - * Settings service. - * - * @var SettingsService - */ - private SettingsService $settingsService; - - /** - * Logger. - * - * @var LoggerInterface - */ - private LoggerInterface $logger; - - /** - * HTTP client service. - * - * @var IClientService - */ - private IClientService $clientService; - - /** - * Constructor. - * - * @param string $appName The app name. - * @param IRequest $request The request. - * @param ConfigurationSettingsHandler $configHandler Configuration settings handler. - * @param SettingsService $settingsService Settings service. - * @param LoggerInterface $logger Logger. - * @param IClientService $clientService HTTP client service. - * - * @return void - */ - public function __construct( - $appName, - IRequest $request, - ConfigurationSettingsHandler $configHandler, - SettingsService $settingsService, - LoggerInterface $logger, - IClientService $clientService, - ) { - parent::__construct(appName: $appName, request: $request); - $this->configHandler = $configHandler; - $this->settingsService = $settingsService; - $this->logger = $logger; - $this->clientService = $clientService; - }//end __construct() - - /** - * Get n8n settings. - * - * Retrieves the current n8n workflow integration configuration. - * - * @NoCSRFRequired - * - * @return JSONResponse The n8n settings. - * - * @psalm-return JSONResponse<200|500, array, array> - * - * @spec openspec/specs/production-observability/spec.md - */ - public function getN8nSettings(): JSONResponse { - try { - $settings = $this->configHandler->getN8nSettingsOnly(); - - // Mask API key for security. - if (empty($settings['apiKey']) === false) { - $settings['apiKey'] = $this->settingsService->maskToken($settings['apiKey']); - } - - return new JSONResponse(data: $settings); - } catch (Exception $e) { - $this->logger->error( - message: '[N8nSettingsController] Failed to retrieve n8n settings: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - return new JSONResponse( - data: ['error' => 'Failed to retrieve n8n settings: ' . $e->getMessage()], - statusCode: 500 - ); - } - }//end getN8nSettings() - - /** - * Update n8n settings. - * - * Updates the n8n workflow integration configuration. - * - * @NoCSRFRequired - * - * @return JSONResponse JSON response with updated n8n settings - * - * @spec openspec/specs/production-observability/spec.md - */ - public function updateN8nSettings(): JSONResponse { - try { - $data = $this->request->getParams(); - - // Only save API key if not masked. - if (isset($data['apiKey']) === true && str_contains($data['apiKey'], '***') === true) { - // Get current settings and preserve existing API key. - $currentSettings = $this->configHandler->getN8nSettingsOnly(); - $data['apiKey'] = $currentSettings['apiKey']; - } - - $settings = $this->configHandler->updateN8nSettingsOnly($data); - - // Mask API key before returning. - if (empty($settings['apiKey']) === false) { - $settings['apiKey'] = $this->settingsService->maskToken($settings['apiKey']); - } - - return new JSONResponse( - data: [ - 'success' => true, - 'message' => 'n8n settings saved successfully', - 'data' => $settings, - ] - ); - } catch (Exception $e) { - $this->logger->error( - message: '[N8nSettingsController] Failed to update n8n settings: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - return new JSONResponse( - data: ['error' => 'Failed to update n8n settings: ' . $e->getMessage()], - statusCode: 500 - ); - }//end try - }//end updateN8nSettings() - - /** - * Test n8n connection. - * - * Tests the connection to the n8n instance using the provided credentials. - * - * @NoCSRFRequired - * - * @return JSONResponse JSON response with connection test result - * - * @spec openspec/specs/production-observability/spec.md - */ - public function testN8nConnection(): JSONResponse { - try { - $data = $this->request->getParams(); - $url = $data['url'] ?? ''; - $apiKey = $data['apiKey'] ?? ''; - - if (empty($url) === true || empty($apiKey) === true) { - return new JSONResponse( - data: [ - 'success' => false, - 'message' => 'n8n URL and API key are required', - ], - statusCode: 400 - ); - } - - // Ensure URL doesn't end with slash. - $url = rtrim($url, '/'); - - // Test the connection by getting the current user. - $client = $this->clientService->newClient(); - $response = $client->get( - $url . '/api/v1/users', - [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-N8N-API-KEY' => $apiKey, - ], - 'timeout' => 10, - ] - ); - - $statusCode = $response->getStatusCode(); - if ($statusCode >= 200 && $statusCode < 300) { - $responseData = json_decode($response->getBody(), true); - - return new JSONResponse( - data: [ - 'success' => true, - 'message' => 'n8n connection successful', - 'details' => [ - 'version' => 'Connected', - 'users' => count($responseData['data'] ?? []), - ], - ] - ); - } - - return new JSONResponse( - data: [ - 'success' => false, - 'message' => 'n8n connection failed with status code: ' . $statusCode, - ], - statusCode: 400 - ); - } catch (Exception $e) { - $this->logger->error( - message: '[N8nSettingsController] n8n connection test failed: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - return new JSONResponse( - data: [ - 'success' => false, - 'message' => 'n8n connection test failed: ' . $e->getMessage(), - ], - statusCode: 400 - ); - }//end try - }//end testN8nConnection() - - /** - * Initialize n8n project. - * - * Creates a project in n8n for OpenRegister workflows. - * - * @NoCSRFRequired - * - * @return JSONResponse JSON response with initialization result - * - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - * - * @spec openspec/specs/production-observability/spec.md - */ - public function initializeN8n(): JSONResponse { - try { - $data = $this->request->getParams(); - $project = $data['project'] ?? 'openregister'; - - // Get current settings. - $settings = $this->configHandler->getN8nSettingsOnly(); - $url = $settings['url'] ?? ''; - $apiKey = $settings['apiKey'] ?? ''; - - if (empty($url) === true || empty($apiKey) === true) { - return new JSONResponse( - data: [ - 'success' => false, - 'message' => 'n8n connection not configured', - ], - statusCode: 400 - ); - } - - // Ensure URL doesn't end with slash. - $url = rtrim($url, '/'); - - $client = $this->clientService->newClient(); - - // Check if project already exists. - try { - $response = $client->get( - $url . '/api/v1/projects', - [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-N8N-API-KEY' => $apiKey, - ], - ] - ); - - $projects = json_decode($response->getBody(), true); - $projectId = null; - $projectObj = null; - - // Check if our project exists. - foreach ($projects['data'] ?? [] as $proj) { - if ($proj['name'] === $project) { - $projectId = $proj['id']; - $projectObj = $proj; - break; - } - } - - // Create project if it doesn't exist. - if ($projectId === null) { - $createResponse = $client->post( - $url . '/api/v1/projects', - [ - 'headers' => [ - 'Accept' => 'application/json', - 'Content-Type' => 'application/json', - 'X-N8N-API-KEY' => $apiKey, - ], - 'json' => [ - 'name' => $project, - ], - ] - ); - - $projectObj = json_decode($createResponse->getBody(), true); - $projectId = $projectObj['id'] ?? null; - } - - if ($projectId === null) { - return new JSONResponse( - data: [ - 'success' => false, - 'message' => 'Failed to create or find project', - ], - statusCode: 500 - ); - } - - // Get workflow count for this project. - $workflowsResponse = $client->get( - $url . '/api/v1/workflows?projectId=' . $projectId, - [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-N8N-API-KEY' => $apiKey, - ], - ] - ); - - $workflows = json_decode($workflowsResponse->getBody(), true); - - return new JSONResponse( - data: [ - 'success' => true, - 'message' => 'n8n project initialized successfully', - 'details' => [ - 'project' => $project, - 'projectId' => $projectId, - 'workflows' => count($workflows['data'] ?? []), - ], - ] - ); - } catch (Exception $e) { - $this->logger->error( - message: '[N8nSettingsController] Project initialization failed: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - return new JSONResponse( - data: [ - 'success' => false, - 'message' => 'Project initialization failed: ' . $e->getMessage(), - ], - statusCode: 500 - ); - }//end try - } catch (Exception $e) { - $this->logger->error( - message: '[N8nSettingsController] n8n initialization failed: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - return new JSONResponse( - data: [ - 'success' => false, - 'message' => 'n8n initialization failed: ' . $e->getMessage(), - ], - statusCode: 500 - ); - }//end try - }//end initializeN8n() - - /** - * Get workflows from n8n. - * - * Retrieves the list of workflows in the configured project. - * - * @NoCSRFRequired - * - * @return JSONResponse JSON response with workflows list - * - * @spec openspec/specs/production-observability/spec.md - */ - public function getWorkflows(): JSONResponse { - try { - // Get current settings. - $settings = $this->configHandler->getN8nSettingsOnly(); - $url = $settings['url'] ?? ''; - $apiKey = $settings['apiKey'] ?? ''; - $project = $settings['project'] ?? 'openregister'; - - if (empty($url) === true || empty($apiKey) === true) { - return new JSONResponse( - data: [ - 'success' => false, - 'message' => 'n8n connection not configured', - ], - statusCode: 400 - ); - } - - // Ensure URL doesn't end with slash. - $url = rtrim($url, '/'); - - $client = $this->clientService->newClient(); - - // Get project ID. - $projectsResponse = $client->get( - $url . '/api/v1/projects', - [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-N8N-API-KEY' => $apiKey, - ], - ] - ); - - $projects = json_decode($projectsResponse->getBody(), true); - $projectId = null; - - foreach ($projects['data'] ?? [] as $proj) { - if ($proj['name'] === $project) { - $projectId = $proj['id']; - break; - } - } - - if ($projectId === null) { - return new JSONResponse( - data: [ - 'success' => true, - 'workflows' => [], - 'message' => 'Project not found. Please initialize first.', - ] - ); - } - - // Get workflows. - $workflowsResponse = $client->get( - $url . '/api/v1/workflows?projectId=' . $projectId, - [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-N8N-API-KEY' => $apiKey, - ], - ] - ); - - $workflows = json_decode($workflowsResponse->getBody(), true); - - return new JSONResponse( - data: [ - 'success' => true, - 'workflows' => $workflows['data'] ?? [], - ] - ); - } catch (Exception $e) { - $this->logger->error( - message: '[N8nSettingsController] Failed to get workflows: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - return new JSONResponse( - data: [ - 'success' => false, - 'message' => 'Failed to get workflows: ' . $e->getMessage(), - ], - statusCode: 500 - ); - }//end try - }//end getWorkflows() -}//end class 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/Controller/SetupController.php b/lib/Controller/SetupController.php index 65db0bd995..347cf56c1c 100644 --- a/lib/Controller/SetupController.php +++ b/lib/Controller/SetupController.php @@ -65,6 +65,19 @@ class SetupController extends Controller { */ private const DEMO_DECIDED_KEY = 'demo_data_decided'; + /** + * App-config key holding the dataset the operator picked. + * + * The wizard's `choice` step writes it through `POST /api/setup/config`, and + * the `run-action` step that follows reads it back. Two steps rather than + * one because `CnSetupWizard::runAction()` posts to + * `/api/setup/action/{action}` with no body: an action cannot carry the + * answer, so the answer has to be stored before the action runs. + * + * @var string + */ + private const DATASET_KEY = 'demo_dataset'; + /** * Constructor. * @@ -99,19 +112,77 @@ public function __construct( #[AuthorizedAdminSetting(OpenRegisterAdmin::class)] public function status(): JSONResponse { $demoDecided = $this->appConfig->getValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, '') !== ''; + $picked = $this->appConfig->getValueString(Application::APP_ID, self::DATASET_KEY, ''); return new JSONResponse( data: [ 'version' => self::SETUP_VERSION, 'completed' => true, + // The choice step reads its options from here: it declares + // `optionsSource: datasets` and no options of its own, so a + // dataset missing from this list is a dataset nobody can pick. + 'datasets' => $this->demoDataService->listChoices(), 'steps' => [ - 'demo-data' => ['done' => $demoDecided], + 'demo-data' => ['done' => ($picked !== '')], + // "None" is an ANSWER, so the load step is finished the moment + // it is chosen: there is nothing left for the operator to run. + 'load-demo-data' => [ + 'done' => ($demoDecided === true || $picked === DemoDataService::NONE_DATASET), + ], ], ] ); }//end status() + /** + * Persist the wizard's `choice` answer. + * + * @return JSONResponse `{ success, config }`. + * + * @spec exclude Setup config write; ADR-042 contract, no per-app behavioural spec. + */ + #[AuthorizedAdminSetting(OpenRegisterAdmin::class)] + public function saveConfig(): JSONResponse { + // 🔴 ONE NAMED KEY, NEVER A CALLER-SUPPLIED ONE. The body arrives from + // the browser and this app's own settings share the appconfig namespace, + // so looping over the posted keys would let this endpoint write any of + // them. The key is written in the source; only its value comes from the + // request. + $value = $this->request->getParam(self::DATASET_KEY); + if ($value === null) { + return new JSONResponse(data: ['success' => true, 'config' => []]); + } + + // The step is not `multiple`, but the wizard's contract allows a list, so + // both shapes are read rather than one of them reaching `(string)`. + $submitted = $value; + if (is_array($value) === true) { + $submitted = ($value[0] ?? null); + } + + if (is_scalar($submitted) === false) { + return new JSONResponse( + data: ['success' => false, 'message' => 'A dataset is named by a string.'], + statusCode: Http::STATUS_BAD_REQUEST, + ); + } + + $datasetId = (string)$submitted; + $known = array_column($this->demoDataService->listChoices(), 'id'); + if (in_array($datasetId, $known, true) === false) { + return new JSONResponse( + data: ['success' => false, 'message' => 'No dataset is called "' . $datasetId . '".'], + statusCode: Http::STATUS_BAD_REQUEST, + ); + } + + $this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, $datasetId); + + return new JSONResponse(data: ['success' => true, 'config' => [self::DATASET_KEY => $datasetId]]); + + }//end saveConfig() + /** * Run a privileged server-side setup action. * @@ -125,15 +196,23 @@ public function status(): JSONResponse { */ #[AuthorizedAdminSetting(OpenRegisterAdmin::class)] public function runAction(string $actionId): JSONResponse { - if ($actionId === 'install-demo-data') { - return $this->installDemoData(); + // `install-demo-data` is the id the step used before it asked WHICH + // dataset, and it still means "import the one this app ships". Kept so + // an older manifest, a runbook or a script that posts it keeps working. + if ($actionId === 'load-demo-data' || $actionId === 'install-demo-data') { + return $this->loadDataset(actionId: $actionId); } // DECLINING IS AN ANSWER — see DEMO_DECIDED_KEY. + // + // 🔴 AND IT ANSWERS *BOTH* STEPS. The wizard now has a choice step and a + // run-action step; closing only the second leaves the first outstanding, + // and CnAppRoot opens the wizard while ANY optional step is outstanding. if ($actionId === 'skip-demo-data') { + $this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, DemoDataService::NONE_DATASET); $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped'); - return new JSONResponse(data: ['success' => true, 'message' => 'Demo data skipped.']); + return new JSONResponse(data: ['success' => true, 'message' => 'No example data was loaded.']); } return new JSONResponse( @@ -144,7 +223,10 @@ public function runAction(string $actionId): JSONResponse { }//end runAction() /** - * Import the shipped demo dataset. + * Import the dataset the operator picked in the previous step. + * + * @param string $actionId The action that asked, which decides whether an + * unanswered choice is refused or means the shipped set. * * Reports the FAILURE rather than a quiet success: an operator who asked for * demo data and got none must be told, which is why DemoDataService::install() @@ -152,7 +234,31 @@ public function runAction(string $actionId): JSONResponse { * * @return JSONResponse `{ success, message }`. */ - private function installDemoData(): JSONResponse { + private function loadDataset(string $actionId): JSONResponse { + $picked = $this->appConfig->getValueString(Application::APP_ID, self::DATASET_KEY, ''); + + // The legacy id carries no answer, so it means the shipped dataset. A + // caller that posts it has said which one by posting it. + if ($actionId === 'install-demo-data' && $picked === '') { + $picked = DemoDataService::DEMO_DATASET; + } + + // 🔴 NO SILENT DEFAULT. Importing here because the operator clicked Run + // one step early would plant example objects nobody asked for, which is + // the failure this whole step exists to avoid. + if ($picked === '') { + return new JSONResponse( + data: ['success' => false, 'message' => 'Pick a dataset first.'], + statusCode: Http::STATUS_BAD_REQUEST, + ); + } + + if ($picked === DemoDataService::NONE_DATASET) { + $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped'); + + return new JSONResponse(data: ['success' => true, 'message' => 'No example data was loaded.']); + } + try { $imported = $this->demoDataService->install(); } catch (\Throwable $e) { @@ -176,5 +282,5 @@ private function installDemoData(): JSONResponse { ] ); - }//end installDemoData() + }//end loadDataset() }//end class 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/TaskController.php b/lib/Controller/TaskController.php new file mode 100644 index 0000000000..45e153a2dc --- /dev/null +++ b/lib/Controller/TaskController.php @@ -0,0 +1,740 @@ + + * 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\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; +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\ContentSecurityPolicy; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * 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.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 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 + * 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 TaskFormResolver $forms, + private readonly TaskFormCompletion $completion, + private readonly ?LoggerInterface $logger = null, + private readonly ?IGroupManager $groupManager = 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 serves + * the SPA shell directly, exactly like `DashboardController::catchAll()` + * serves every other deep sub-path: the router runs in HISTORY mode + * (src/main.js), so the path itself is the route and the SPA's + * `flow-task-detail` route (`/flow-tasks/:uuid`, src/main.js) renders + * the task. It used to redirect to a HASH path instead, which the + * history-mode router never resolved: every notification button and + * VTODO URL landed on the dashboard. + * + * @param string $uuid The task uuid, consumed by the SPA route, not here. + * + * @return TemplateResponse The SPA shell. + * + * @no-admin-idor-exempt Reads nothing: the shell is identical for every + * uuid, and the SPA performs the visibility-checked read + * (TaskController::show, 404 for the invisible). + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The route binds {uuid}, + * the SPA consumes it from the URL; dropping the parameter would + * unbind the route. + * + * @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): TemplateResponse { + $response = new TemplateResponse( + appName: $this->appName, + templateName: 'index', + params: [] + ); + + // The same relaxation the dashboard shell carries: the SPA calls the + // API from this page, so the shell's CSP must allow it. + $csp = new ContentSecurityPolicy(); + $csp->addAllowedConnectDomain('*'); + $response->setContentSecurityPolicy($csp); + + return $response; + }//end open() + + /** + * 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 $runUuid Restrict to the tasks one flow run raised. + * This ANCHORS the read rather than filtering + * it: `scope` is ignored, because the run's + * view asks what the run asked, not what it + * asked the caller. Visibility still applies. + * @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 $runUuid = 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, + runUuid: $this->trimmedOrNull(value: $runUuid), + overdueAt: $overdueAt, + sort: $sortKey, + sortDescending: $descending, + ); + + return new JSONResponse($this->inbox->inbox(criteria: $criteria, limit: $limit, offset: $offset)); + }//end index() + + /** + * 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] + 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( + array_merge( + $this->inbox->row(task: $task, subjects: [], now: $this->temporal->now()), + $this->forms->describe(task: $task) + ) + ); + }//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. + * @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, + 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->completion->complete( + uuid: $uuid, + outcome: $outcome, + resultText: $resultText, + comment: $comment, + data: ($data ?? []), + 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, 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. + * @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 + * @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 { + $task = $verb(); + + return new JSONResponse( + $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); + } + + 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() + + /** + * A query parameter that anchors a read, or null when it says nothing. + * + * 🔴 AN EMPTY STRING IS NOT AN ANCHOR. A query string carries + * `?runUuid=` as `''`, never as null, and `''` reaching the criteria + * would be applied as a real predicate — `run_uuid = ''` matches no row, + * so a tab that forgot to fill the uuid in would report a run as having + * asked nobody rather than reporting that it asked nothing of the server. + * + * @param string|null $value The raw parameter. + * + * @return string|null The trimmed value, or null when it is blank. + */ + private function trimmedOrNull(?string $value): ?string { + if ($value === null) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '') { + return null; + } + + return $trimmed; + }//end trimmedOrNull() + + /** + * 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/Controller/TasksController.php b/lib/Controller/TasksController.php index d11e6cdd60..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 { - $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 + $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 { + $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. * @@ -363,8 +491,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/Trait/HandlesExceptionsTrait.php b/lib/Controller/Trait/HandlesExceptionsTrait.php index 290f87fe9e..3cdf3c787b 100644 --- a/lib/Controller/Trait/HandlesExceptionsTrait.php +++ b/lib/Controller/Trait/HandlesExceptionsTrait.php @@ -127,6 +127,10 @@ protected function handleApiException(Throwable $e, string $context = ''): JSONR // Forbidden → 403. NotAuthorizedException::class => Http::STATUS_FORBIDDEN, FolderAccessDeniedException::class => Http::STATUS_FORBIDDEN, + // Locked → 423. Untyped before this, so a lock refusal thrown from the + // service layer reached the generic 500 handler and told the caller + // nothing about the lock. + \OCA\OpenRegister\Exception\LockedException::class => Http::STATUS_LOCKED, // Validation → 422 typed / 400 argument-shaped. ValidationException::class => Http::STATUS_UNPROCESSABLE_ENTITY, \OCA\OpenRegister\Exception\CustomValidationException::class => Http::STATUS_UNPROCESSABLE_ENTITY, diff --git a/lib/Controller/VocabularyController.php b/lib/Controller/VocabularyController.php index b15202b2d1..12edf3c768 100644 --- a/lib/Controller/VocabularyController.php +++ b/lib/Controller/VocabularyController.php @@ -216,11 +216,20 @@ public function listConcepts(): JSONResponse { $offset = max(0, (int)$this->request->getParam('_offset', 0)); try { + // The pair goes UNDER `filters`. `ObjectService::prepareFindAllConfig()` + // reads `$config['filters']['register']` and + // `$config['filters']['schema']` and no other key, so a top-level pair + // is read by nothing: the read then ran on whatever register/schema + // the last write left on the shared service. See openregister#3408, + // where exactly this shape made a repair step copy two + // `brokeredcredential` examples into the flow table. $all = $this->objectService->findAll( config: [ - 'register' => self::REGISTER, - 'schema' => self::SCHEMA_CONCEPT, - 'filters' => ['inScheme' => $schemeUuid], + 'filters' => [ + 'register' => self::REGISTER, + 'schema' => self::SCHEMA_CONCEPT, + 'inScheme' => $schemeUuid, + ], 'limit' => self::MAX_SCHEME_CONCEPTS, ] ); @@ -296,11 +305,18 @@ private function resolveSchemeUuid(string $schemeUriOrUuid): ?string { */ private function findOneBy(string $schema, array $filters): ?ObjectEntity { try { + // The pair goes UNDER `filters` — see listConcepts() for why a + // top-level pair is inert. `$filters` is spread after the pair so a + // caller can never shadow the scope with a field of the same name. $results = $this->objectService->findAll( config: [ - 'register' => self::REGISTER, - 'schema' => $schema, - 'filters' => $filters, + 'filters' => array_merge( + $filters, + [ + 'register' => self::REGISTER, + 'schema' => $schema, + ] + ), 'limit' => 1, ] ); diff --git a/lib/Controller/WorkflowEngineController.php b/lib/Controller/WorkflowEngineController.php deleted file mode 100644 index dbd9e21710..0000000000 --- a/lib/Controller/WorkflowEngineController.php +++ /dev/null @@ -1,384 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-health-monitoring - * @spec openspec/specs/workflow-engine-abstraction/spec.md - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Controller; - -use OCA\OpenRegister\Service\WorkflowEngineRegistry; -use OCP\AppFramework\Controller; -use OCP\AppFramework\Http\JSONResponse; -use OCP\IGroupManager; -use OCP\IL10N; -use OCP\IRequest; -use OCP\IUserSession; -use Psr\Log\LoggerInterface; - -/** - * Controller for workflow engine CRUD, health checks, and test hooks. - * - * @psalm-suppress UnusedClass - * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) - */ -class WorkflowEngineController extends Controller { - /** - * Constructor for WorkflowEngineController. - * - * @param string $appName App name - * @param IRequest $request Request - * @param WorkflowEngineRegistry $registry Engine registry - * @param LoggerInterface $logger Logger - * @param IL10N $l10n Localization service - * @param IUserSession $userSession User session for admin checks - * @param IGroupManager $groupManager Group manager for admin checks - */ - public function __construct( - string $appName, - IRequest $request, - private readonly WorkflowEngineRegistry $registry, - private readonly LoggerInterface $logger, - private readonly IL10N $l10n, - private readonly IUserSession $userSession, - private readonly IGroupManager $groupManager, - ) { - parent::__construct(appName: $appName, request: $request); - }//end __construct() - - /** - * Check whether the currently authenticated user is a Nextcloud administrator. - * - * @return bool True if a user is signed in and belongs to the admin group. - */ - private function isCurrentUserAdmin(): bool { - $user = $this->userSession->getUser(); - if ($user === null) { - return false; - } - - return $this->groupManager->isAdmin($user->getUID()); - }//end isCurrentUserAdmin() - - /** - * List all registered engines. - * - * @auth admin-only Engine metadata serialises baseUrl, authType and healthStatus of an - * instance-wide integration. The body already rejected non-admins; @NoAdminRequired said - * the opposite, so the declared posture contradicted the enforced one. Declaring it here - * makes middleware reject the request before the controller runs, which is the same - * outcome one layer earlier. - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function index(): JSONResponse { - // SEC-CTRL: admin-only — workflow engines are instance-wide integration - // config; the serialized metadata exposes internal baseUrl/healthStatus. - // Reads are gated like the create/update/delete siblings. - if ($this->isCurrentUserAdmin() === false) { - return new JSONResponse(['error' => 'Admin privileges required'], 403); - } - - $engines = $this->registry->getEngines(); - - return new JSONResponse( - array_map(fn ($engine) => $engine->jsonSerialize(), $engines) - ); - }//end index() - - /** - * Get a single engine. - * - * @param int $id Engine ID - * - * @auth admin-only Same as index(): engine metadata exposes baseUrl and healthStatus of an - * instance-wide integration, and the body already rejected non-admins while the removed - * attribute declared the opposite. - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function show(int $id): JSONResponse { - // SEC-CTRL: admin-only — see index(); engine metadata exposes internal - // baseUrl/healthStatus of an instance-wide integration. - if ($this->isCurrentUserAdmin() === false) { - return new JSONResponse(['error' => 'Admin privileges required'], 403); - } - - try { - $engine = $this->registry->getEngine($id); - - return new JSONResponse($engine->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => $this->l10n->t('Engine not found')], 404); - } - }//end show() - - /** - * Register a new engine. - * - * @param string $name Engine name - * @param string $engineType Engine type (n8n, windmill) - * @param string $baseUrl Base URL - * @param string|null $authType Auth type - * @param array|null $authConfig Auth configuration - * @param bool $enabled Whether enabled - * @param int $defaultTimeout Default timeout - * - * @auth admin-only Registers an outbound integration target: baseUrl plus authType/authConfig - * credentials that every later workflow execution will use. WorkflowEngine has no owner - * column, so there is no per-object guard to write; a non-admin creating one would be - * choosing where this instance sends data. - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function create( - string $name, - string $engineType, - string $baseUrl, - ?string $authType = 'none', - ?array $authConfig = null, - bool $enabled = true, - int $defaultTimeout = 30, - ): JSONResponse { - if ($this->isCurrentUserAdmin() === false) { - return new JSONResponse(['error' => 'Admin privileges required'], 403); - } - - $validTypes = ['n8n', 'windmill']; - if (in_array(needle: $engineType, haystack: $validTypes, strict: true) === false) { - return new JSONResponse( - ['error' => "Invalid engine type '$engineType'. Must be one of: " . implode(', ', $validTypes)], - 400 - ); - } - - try { - $engine = $this->registry->createEngine( - [ - 'name' => $name, - 'engineType' => $engineType, - 'baseUrl' => $baseUrl, - 'authType' => $authType ?? 'none', - 'authConfig' => $authConfig, - 'enabled' => $enabled, - 'defaultTimeout' => $defaultTimeout, - ] - ); - - // Run initial health check. - try { - $this->registry->healthCheck($engine->getId()); - $engine = $this->registry->getEngine($engine->getId()); - } catch (\Exception $e) { - $this->logger->warning( - message: '[WorkflowEngineController] Initial health check failed', - context: ['engineId' => $engine->getId(), 'error' => $e->getMessage()] - ); - } - - return new JSONResponse($engine->jsonSerialize(), 201); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], 500); - }//end try - }//end create() - - /** - * Update an engine. - * - * @param int $id Engine ID - * - * @auth admin-only Can re-point baseUrl and replace the stored credentials of an instance-wide - * integration, which redirects every subsequent execution. WorkflowEngine has no owner - * column, so no per-object guard is expressible. The body enforces this too. - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function update(int $id): JSONResponse { - if ($this->isCurrentUserAdmin() === false) { - return new JSONResponse(['error' => 'Admin privileges required'], 403); - } - - try { - $data = $this->request->getParams(); - $engine = $this->registry->updateEngine($id, $data); - - return new JSONResponse($engine->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => $this->l10n->t('Engine not found')], 404); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], 500); - } - }//end update() - - /** - * Delete an engine. - * - * @param int $id Engine ID - * - * @auth admin-only Removes an instance-wide integration by id, breaking every workflow bound to - * it. WorkflowEngine has no owner column, so a per-object guard has nothing to compare the - * caller against. The body enforces this too. - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function destroy(int $id): JSONResponse { - if ($this->isCurrentUserAdmin() === false) { - return new JSONResponse(['error' => 'Admin privileges required'], 403); - } - - try { - $engine = $this->registry->deleteEngine($id); - - return new JSONResponse($engine->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => $this->l10n->t('Engine not found')], 404); - } - }//end destroy() - - /** - * Run a health check on an engine. - * - * @param int $id Engine ID - * - * @return JSONResponse - * - * @auth admin-only Makes this instance issue an outbound request to a stored baseUrl on demand - * and reports whether it answered. That is a probe primitive, so it stays with the - * administrator who configured the target rather than any authenticated user. - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-health-monitoring - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function health(int $id): JSONResponse { - try { - $result = $this->registry->healthCheck($id); - - return new JSONResponse($result); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => $this->l10n->t('Engine not found')], 404); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], 500); - } - }//end health() - - /** - * List auto-discovered engine types from installed ExApps. - * - * @auth admin-only Enumerates which ExApps are installed on this instance. That inventory is - * administrative information and is only consumed by the engine admin UI, so it is gated - * with the rest of that surface rather than exposed to every authenticated user. - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function available(): JSONResponse { - $engines = $this->registry->discoverEngines(); - - return new JSONResponse($engines); - }//end available() - - /** - * Test a hook by executing a workflow with sample data (dry-run). - * - * No database writes occur. The response includes dryRun: true. - * - * @param int $id Engine ID - * - * @auth admin-only "Dry run" describes this app's database, not the far side: the request is - * really executed against the configured engine with caller-supplied sampleData, a - * caller-supplied workflowId and a caller-supplied timeout. Under #[NoAdminRequired] any - * authenticated user could drive outbound requests through a stored, credentialed target. - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function testHook(int $id): JSONResponse { - $workflowId = $this->request->getParam('workflowId'); - $sampleData = $this->request->getParam('sampleData', []); - $timeout = (int)$this->request->getParam('timeout', 30); - - if (empty($workflowId) === true) { - return new JSONResponse(['error' => 'workflowId is required'], 400); - } - - if (is_array($sampleData) === false) { - $sampleData = json_decode((string)$sampleData, true) ?? []; - } - - try { - $adapter = $this->registry->resolveAdapterById($id); - $result = $adapter->executeWorkflow( - workflowId: $workflowId, - data: $sampleData, - timeout: $timeout - ); - - $response = $result->toArray(); - $response['dryRun'] = true; - - return new JSONResponse($response); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => $this->l10n->t('Engine not found')], 404); - } catch (\Exception $e) { - $message = $e->getMessage(); - $lower = strtolower($message); - - // Connectivity errors return 502. - if (str_contains($lower, 'connection') === true - || str_contains($lower, 'unreachable') === true - || str_contains($lower, 'refused') === true - ) { - return new JSONResponse( - [ - 'status' => 'error', - 'errors' => [['message' => $message]], - 'dryRun' => true, - ], - 502 - ); - } - - // Workflow errors return 422. - return new JSONResponse( - [ - 'status' => 'error', - 'errors' => [['message' => $message]], - 'dryRun' => true, - ], - 422 - ); - }//end try - }//end testHook() -}//end class diff --git a/lib/Controller/WorkflowExecutionController.php b/lib/Controller/WorkflowExecutionController.php deleted file mode 100644 index 281f40df16..0000000000 --- a/lib/Controller/WorkflowExecutionController.php +++ /dev/null @@ -1,162 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-workflow-execution-api-sync-and-async - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Controller; - -use OCA\OpenRegister\Db\WorkflowExecutionMapper; -use OCP\AppFramework\Controller; -use OCP\AppFramework\Http\JSONResponse; -use OCP\IRequest; -use Psr\Log\LoggerInterface; - -/** - * Controller for workflow execution history API. - * - * @psalm-suppress UnusedClass - */ -class WorkflowExecutionController extends Controller { - /** - * Constructor for WorkflowExecutionController. - * - * @param string $appName App name - * @param IRequest $request Request - * @param WorkflowExecutionMapper $executionMapper Execution mapper - * @param LoggerInterface $logger Logger - */ - public function __construct( - string $appName, - IRequest $request, - private readonly WorkflowExecutionMapper $executionMapper, - private readonly LoggerInterface $logger, - ) { - parent::__construct(appName: $appName, request: $request); - }//end __construct() - - /** - * List workflow executions with filters and pagination. - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-workflow-execution-api-sync-and-async - */ - public function index(): JSONResponse { - $filters = []; - - $objectUuid = $this->request->getParam('objectUuid'); - if ($objectUuid !== null) { - $filters['objectUuid'] = $objectUuid; - } - - $schemaId = $this->request->getParam('schemaId'); - if ($schemaId !== null) { - $filters['schemaId'] = (int)$schemaId; - } - - $hookId = $this->request->getParam('hookId'); - if ($hookId !== null) { - $filters['hookId'] = $hookId; - } - - $status = $this->request->getParam('status'); - if ($status !== null) { - $filters['status'] = $status; - } - - $engine = $this->request->getParam('engine'); - if ($engine !== null) { - $filters['engine'] = $engine; - } - - $since = $this->request->getParam('since'); - if ($since !== null) { - $filters['since'] = $since; - } - - $limit = (int)($this->request->getParam('limit', '50')); - $offset = (int)($this->request->getParam('offset', '0')); - - $limit = min(max($limit, 1), 500); - $offset = max($offset, 0); - - $results = $this->executionMapper->findAll($filters, $limit, $offset); - $total = $this->executionMapper->countAll($filters); - - return new JSONResponse( - [ - 'results' => array_map(fn ($e) => $e->jsonSerialize(), $results), - 'total' => $total, - 'limit' => $limit, - 'offset' => $offset, - ] - ); - }//end index() - - /** - * Get a single execution detail. - * - * @param int $id Execution ID - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-workflow-execution-api-sync-and-async - */ - public function show(int $id): JSONResponse { - try { - $execution = $this->executionMapper->find($id); - - return new JSONResponse($execution->jsonSerialize()); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => 'Execution not found'], 404); - } - }//end show() - - /** - * Delete an execution record (admin only). - * - * @param int $id Execution ID - * - * @auth admin-only Execution rows are the audit trail of what this instance sent to an external - * engine and what came back. Deleting audit history is an administrative act, and the - * prose above already said "admin only" without anything enforcing or declaring it — - * WorkflowExecution has no owner column, so no per-object guard is expressible either. - * - * @return JSONResponse - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-workflow-execution-api-sync-and-async - */ - public function destroy(int $id): JSONResponse { - try { - $execution = $this->executionMapper->find($id); - $this->executionMapper->delete($execution); - - return new JSONResponse(['message' => 'Execution deleted']); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return new JSONResponse(['error' => 'Execution not found'], 404); - } - }//end destroy() -}//end class 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/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/Action.php b/lib/Db/Action.php deleted file mode 100644 index 296cda5153..0000000000 --- a/lib/Db/Action.php +++ /dev/null @@ -1,787 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use JsonSerializable; -use OCP\AppFramework\Db\Entity; - -/** - * Action entity for workflow automation - * - * @method int getId() - * @method void setId(int $id) - * @method string getUuid() - * @method void setUuid(string $uuid) - * @method string getName() - * @method void setName(string $name) - * @method string|null getSlug() - * @method void setSlug(?string $slug) - * @method string|null getDescription() - * @method void setDescription(?string $description) - * @method string|null getVersion() - * @method void setVersion(?string $version) - * @method string getStatus() - * @method void setStatus(string $status) - * @method string getEventType() - * @method void setEventType(string $eventType) - * @method string getEngine() - * @method void setEngine(string $engine) - * @method string getWorkflowId() - * @method void setWorkflowId(string $workflowId) - * @method string getMode() - * @method void setMode(string $mode) - * @method int getExecutionOrder() - * @method void setExecutionOrder(int $executionOrder) - * @method int getTimeout() - * @method void setTimeout(int $timeout) - * @method string getOnFailure() - * @method void setOnFailure(string $onFailure) - * @method string getOnTimeout() - * @method void setOnTimeout(string $onTimeout) - * @method string getOnEngineDown() - * @method void setOnEngineDown(string $onEngineDown) - * @method string|null getFilterCondition() - * @method void setFilterCondition(?string $filterCondition) - * @method string|null getConfiguration() - * @method void setConfiguration(?string $configuration) - * @method int|null getMapping() - * @method void setMapping(?int $mapping) - * @method string|null getSchemas() - * @method void setSchemas(?string $schemas) - * @method string|null getRegisters() - * @method void setRegisters(?string $registers) - * @method string|null getSchedule() - * @method void setSchedule(?string $schedule) - * @method int getMaxRetries() - * @method void setMaxRetries(int $maxRetries) - * @method string getRetryPolicy() - * @method void setRetryPolicy(string $retryPolicy) - * @method bool getEnabled() - * @method void setEnabled(bool $enabled) - * @method string|null getOwner() - * @method void setOwner(?string $owner) - * @method string|null getApplication() - * @method void setApplication(?string $application) - * @method string|null getOrganisation() - * @method void setOrganisation(?string $organisation) - * @method DateTime|null getLastExecutedAt() - * @method void setLastExecutedAt(?DateTime $lastExecutedAt) - * @method int getExecutionCount() - * @method void setExecutionCount(int $executionCount) - * @method int getSuccessCount() - * @method void setSuccessCount(int $successCount) - * @method int getFailureCount() - * @method void setFailureCount(int $failureCount) - * @method DateTime|null getCreated() - * @method void setCreated(?DateTime $created) - * @method DateTime|null getUpdated() - * @method void setUpdated(?DateTime $updated) - * @method DateTime|null getDeleted() - * @method void setDeleted(?DateTime $deleted) - * - * @SuppressWarnings(PHPMD.TooManyFields) - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) - * - * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class - */ -class Action extends Entity implements JsonSerializable { - - /** - * The uuid. - * - * @var string - */ - protected string $uuid = ''; - - /** - * The name. - * - * @var string - */ - protected string $name = ''; - - /** - * The slug. - * - * @var string|null - */ - protected ?string $slug = null; - - /** - * The description. - * - * @var string|null - */ - protected ?string $description = null; - - /** - * The version. - * - * @var string|null - */ - protected ?string $version = '1.0.0'; - - /** - * The status. - * - * @var string - */ - protected string $status = 'draft'; - - /** - * The event type. - * - * @var string - */ - protected string $eventType = ''; - - /** - * The engine. - * - * @var string - */ - protected string $engine = ''; - - /** - * The workflow id. - * - * @var string - */ - protected string $workflowId = ''; - - /** - * The mode. - * - * @var string - */ - protected string $mode = 'sync'; - - /** - * The execution order. - * - * @var integer - */ - protected int $executionOrder = 0; - - /** - * The timeout. - * - * @var integer - */ - protected int $timeout = 30; - - /** - * The on failure. - * - * @var string - */ - protected string $onFailure = 'reject'; - - /** - * The on timeout. - * - * @var string - */ - protected string $onTimeout = 'reject'; - - /** - * The on engine down. - * - * @var string - */ - protected string $onEngineDown = 'allow'; - - /** - * The filter condition. - * - * @var string|null - */ - protected ?string $filterCondition = null; - - /** - * The configuration. - * - * @var string|null - */ - protected ?string $configuration = null; - - /** - * The mapping. - * - * @var integer|null - */ - protected ?int $mapping = null; - - /** - * The schemas. - * - * @var string|null - */ - protected ?string $schemas = null; - - /** - * The registers. - * - * @var string|null - */ - protected ?string $registers = null; - - /** - * The schedule. - * - * @var string|null - */ - protected ?string $schedule = null; - - /** - * The max retries. - * - * @var integer - */ - protected int $maxRetries = 3; - - /** - * The retry policy. - * - * @var string - */ - protected string $retryPolicy = 'exponential'; - - /** - * The enabled. - * - * @var boolean - */ - protected bool $enabled = true; - - /** - * The owner. - * - * @var string|null - */ - protected ?string $owner = null; - - /** - * The application. - * - * @var string|null - */ - protected ?string $application = null; - - /** - * The organisation. - * - * @var string|null - */ - protected ?string $organisation = null; - - /** - * The last executed at. - * - * @var DateTime|null - */ - protected ?DateTime $lastExecutedAt = null; - - /** - * The execution count. - * - * @var integer - */ - protected int $executionCount = 0; - - /** - * The success count. - * - * @var integer - */ - protected int $successCount = 0; - - /** - * The failure count. - * - * @var integer - */ - protected int $failureCount = 0; - - /** - * The created. - * - * @var DateTime|null - */ - protected ?DateTime $created = null; - - /** - * The updated. - * - * @var DateTime|null - */ - protected ?DateTime $updated = null; - - /** - * The deleted. - * - * @var DateTime|null - */ - protected ?DateTime $deleted = null; - - /** - * Constructor - */ - public function __construct() { - $this->addType(fieldName: 'uuid', type: 'string'); - $this->addType(fieldName: 'name', type: 'string'); - $this->addType(fieldName: 'slug', type: 'string'); - $this->addType(fieldName: 'description', type: 'string'); - $this->addType(fieldName: 'version', type: 'string'); - $this->addType(fieldName: 'status', type: 'string'); - $this->addType(fieldName: 'eventType', type: 'string'); - $this->addType(fieldName: 'engine', type: 'string'); - $this->addType(fieldName: 'workflowId', type: 'string'); - $this->addType(fieldName: 'mode', type: 'string'); - $this->addType(fieldName: 'executionOrder', type: 'integer'); - $this->addType(fieldName: 'timeout', type: 'integer'); - $this->addType(fieldName: 'onFailure', type: 'string'); - $this->addType(fieldName: 'onTimeout', type: 'string'); - $this->addType(fieldName: 'onEngineDown', type: 'string'); - $this->addType(fieldName: 'filterCondition', type: 'string'); - $this->addType(fieldName: 'configuration', type: 'string'); - $this->addType(fieldName: 'mapping', type: 'integer'); - $this->addType(fieldName: 'schemas', type: 'string'); - $this->addType(fieldName: 'registers', type: 'string'); - $this->addType(fieldName: 'schedule', type: 'string'); - $this->addType(fieldName: 'maxRetries', type: 'integer'); - $this->addType(fieldName: 'retryPolicy', type: 'string'); - $this->addType(fieldName: 'enabled', type: 'boolean'); - $this->addType(fieldName: 'owner', type: 'string'); - $this->addType(fieldName: 'application', type: 'string'); - $this->addType(fieldName: 'organisation', type: 'string'); - $this->addType(fieldName: 'lastExecutedAt', type: 'datetime'); - $this->addType(fieldName: 'executionCount', type: 'integer'); - $this->addType(fieldName: 'successCount', type: 'integer'); - $this->addType(fieldName: 'failureCount', type: 'integer'); - $this->addType(fieldName: 'created', type: 'datetime'); - $this->addType(fieldName: 'updated', type: 'datetime'); - $this->addType(fieldName: 'deleted', type: 'datetime'); - }//end __construct() - - /** - * Get event type as array (handles JSON array or single string) - * - * @return array - */ - public function getEventTypeArray(): array { - $decoded = json_decode($this->eventType, true); - if (is_array($decoded) === true) { - return $decoded; - } - - return [$this->eventType]; - }//end getEventTypeArray() - - /** - * Get schemas as array - * - * @return array - */ - public function getSchemasArray(): array { - if ($this->schemas === null) { - return []; - } - - return json_decode($this->schemas, true) ?? []; - }//end getSchemasArray() - - /** - * Set schemas from array - * - * @param array|null $schemas Schemas array - * - * @return void - */ - public function setSchemasArray(?array $schemas): void { - // phpcs:disable CustomSniffs.Functions.NamedParameters -- Entity __call breaks with named args. - if ($schemas === null) { - $this->setSchemas(null); - return; - } - - $this->setSchemas(json_encode(value: $schemas)); - // phpcs:enable CustomSniffs.Functions.NamedParameters - }//end setSchemasArray() - - /** - * Get registers as array - * - * @return array - */ - public function getRegistersArray(): array { - if ($this->registers === null) { - return []; - } - - return json_decode($this->registers, true) ?? []; - }//end getRegistersArray() - - /** - * Set registers from array - * - * @param array|null $registers Registers array - * - * @return void - */ - public function setRegistersArray(?array $registers): void { - // phpcs:disable CustomSniffs.Functions.NamedParameters -- Entity __call breaks with named args. - if ($registers === null) { - $this->setRegisters(null); - return; - } - - $this->setRegisters(json_encode(value: $registers)); - // phpcs:enable CustomSniffs.Functions.NamedParameters - }//end setRegistersArray() - - /** - * Get filter condition as array - * - * @return array - */ - public function getFilterConditionArray(): array { - if ($this->filterCondition === null) { - return []; - } - - return json_decode($this->filterCondition, true) ?? []; - }//end getFilterConditionArray() - - /** - * Set filter condition from array - * - * @param array|null $filterCondition Filter condition array - * - * @return void - */ - public function setFilterConditionArray(?array $filterCondition): void { - // phpcs:disable CustomSniffs.Functions.NamedParameters -- Entity __call breaks with named args. - if ($filterCondition === null) { - $this->setFilterCondition(null); - return; - } - - $this->setFilterCondition(json_encode(value: $filterCondition)); - // phpcs:enable CustomSniffs.Functions.NamedParameters - }//end setFilterConditionArray() - - /** - * Get configuration as array - * - * @return array - */ - public function getConfigurationArray(): array { - if ($this->configuration === null) { - return []; - } - - return json_decode($this->configuration, true) ?? []; - }//end getConfigurationArray() - - /** - * Set configuration from array - * - * @param array|null $configuration Configuration array - * - * @return void - */ - public function setConfigurationArray(?array $configuration): void { - // phpcs:disable CustomSniffs.Functions.NamedParameters -- Entity __call breaks with named args. - if ($configuration === null) { - $this->setConfiguration(null); - return; - } - - $this->setConfiguration(json_encode(value: $configuration)); - // phpcs:enable CustomSniffs.Functions.NamedParameters - }//end setConfigurationArray() - - /** - * Check if event matches this action - * - * @param string $eventClass Event class name - * - * @return bool - */ - public function matchesEvent(string $eventClass): bool { - $eventTypes = $this->getEventTypeArray(); - - if (empty($eventTypes) === true) { - return true; - } - - if (in_array($eventClass, $eventTypes) === true) { - return true; - } - - foreach ($eventTypes as $pattern) { - if (fnmatch($pattern, $eventClass) === true) { - return true; - } - } - - return false; - }//end matchesEvent() - - /** - * Check if action matches a schema UUID - * - * @param string|null $schemaUuid Schema UUID to check - * - * @return bool - */ - public function matchesSchema(?string $schemaUuid): bool { - $schemas = $this->getSchemasArray(); - - if (empty($schemas) === true) { - return true; - } - - if ($schemaUuid === null) { - return false; - } - - return in_array($schemaUuid, $schemas); - }//end matchesSchema() - - /** - * Check if action matches a register UUID - * - * @param string|null $registerUuid Register UUID to check - * - * @return bool - */ - public function matchesRegister(?string $registerUuid): bool { - $registers = $this->getRegistersArray(); - - if (empty($registers) === true) { - return true; - } - - if ($registerUuid === null) { - return false; - } - - return in_array($registerUuid, $registers); - }//end matchesRegister() - - /** - * JSON serialize the entity - * - * @return array - * - * @SuppressWarnings(PHPMD.NPathComplexity) - */ - public function jsonSerialize(): array { - return [ - 'id' => $this->id, - 'uuid' => $this->uuid, - 'name' => $this->name, - 'slug' => $this->slug, - 'description' => $this->description, - 'version' => $this->version, - 'status' => $this->status, - 'eventType' => $this->getEventTypeArray(), - 'engine' => $this->engine, - 'workflowId' => $this->workflowId, - 'mode' => $this->mode, - 'executionOrder' => $this->executionOrder, - 'timeout' => $this->timeout, - 'onFailure' => $this->onFailure, - 'onTimeout' => $this->onTimeout, - 'onEngineDown' => $this->onEngineDown, - 'filterCondition' => $this->getFilterConditionArray(), - 'configuration' => $this->getConfigurationArray(), - 'mapping' => $this->mapping, - 'schemas' => $this->getSchemasArray(), - 'registers' => $this->getRegistersArray(), - 'schedule' => $this->schedule, - 'maxRetries' => $this->maxRetries, - 'retryPolicy' => $this->retryPolicy, - 'enabled' => $this->enabled, - 'owner' => $this->owner, - 'application' => $this->application, - 'organisation' => $this->organisation, - 'lastExecutedAt' => $this->lastExecutedAt?->format('c'), - 'executionCount' => $this->executionCount, - 'successCount' => $this->successCount, - 'failureCount' => $this->failureCount, - 'created' => $this->created?->format('c'), - 'updated' => $this->updated?->format('c'), - 'deleted' => $this->deleted?->format('c'), - ]; - }//end jsonSerialize() - - /** - * Hydrate entity from array - * - * @param array $object Object data - * - * @return static The hydrated entity - * - * @SuppressWarnings(PHPMD.NPathComplexity) - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ - public function hydrate(array $object): static { - // phpcs:disable CustomSniffs.Functions.NamedParameters -- Entity __call breaks with named args. - if (($object['id'] ?? null) !== null) { - $this->setId($object['id']); - } - - if (($object['uuid'] ?? null) !== null) { - $this->setUuid($object['uuid']); - } - - if (($object['name'] ?? null) !== null) { - $this->setName($object['name']); - } - - if (($object['slug'] ?? null) !== null) { - $this->setSlug($object['slug']); - } - - if (($object['description'] ?? null) !== null) { - $this->setDescription($object['description']); - } - - if (($object['version'] ?? null) !== null) { - $this->setVersion($object['version']); - } - - if (($object['status'] ?? null) !== null) { - $this->setStatus($object['status']); - } - - if (($object['eventType'] ?? null) !== null) { - $eventTypeValue = $object['eventType']; - if (is_array($eventTypeValue) === true) { - $eventTypeValue = json_encode(value: $eventTypeValue); - } - - $this->setEventType($eventTypeValue); - } - - if (($object['engine'] ?? null) !== null) { - $this->setEngine($object['engine']); - } - - if (($object['workflowId'] ?? null) !== null) { - $this->setWorkflowId($object['workflowId']); - } - - if (($object['mode'] ?? null) !== null) { - $this->setMode($object['mode']); - } - - if (($object['executionOrder'] ?? null) !== null) { - $this->setExecutionOrder((int)$object['executionOrder']); - } - - if (($object['timeout'] ?? null) !== null) { - $this->setTimeout((int)$object['timeout']); - } - - if (($object['onFailure'] ?? null) !== null) { - $this->setOnFailure($object['onFailure']); - } - - if (($object['onTimeout'] ?? null) !== null) { - $this->setOnTimeout($object['onTimeout']); - } - - if (($object['onEngineDown'] ?? null) !== null) { - $this->setOnEngineDown($object['onEngineDown']); - } - - if (($object['filterCondition'] ?? null) !== null && is_array($object['filterCondition']) === true) { - $this->setFilterConditionArray($object['filterCondition']); - } elseif (($object['filterCondition'] ?? null) !== null) { - $this->setFilterCondition($object['filterCondition']); - } - - if (($object['configuration'] ?? null) !== null && is_array($object['configuration']) === true) { - $this->setConfigurationArray($object['configuration']); - } elseif (($object['configuration'] ?? null) !== null) { - $this->setConfiguration($object['configuration']); - } - - if (($object['mapping'] ?? null) !== null) { - $this->setMapping((int)$object['mapping']); - } - - if (($object['schemas'] ?? null) !== null && is_array($object['schemas']) === true) { - $this->setSchemasArray($object['schemas']); - } elseif (($object['schemas'] ?? null) !== null) { - $this->setSchemas($object['schemas']); - } - - if (($object['registers'] ?? null) !== null && is_array($object['registers']) === true) { - $this->setRegistersArray($object['registers']); - } elseif (($object['registers'] ?? null) !== null) { - $this->setRegisters($object['registers']); - } - - if (($object['schedule'] ?? null) !== null) { - $this->setSchedule($object['schedule']); - } - - if (($object['maxRetries'] ?? null) !== null) { - $this->setMaxRetries((int)$object['maxRetries']); - } - - if (($object['retryPolicy'] ?? null) !== null) { - $this->setRetryPolicy($object['retryPolicy']); - } - - if (($object['enabled'] ?? null) !== null) { - $this->setEnabled((bool)$object['enabled']); - } - - if (($object['owner'] ?? null) !== null) { - $this->setOwner($object['owner']); - } - - if (($object['application'] ?? null) !== null) { - $this->setApplication($object['application']); - } - - if (($object['organisation'] ?? null) !== null) { - $this->setOrganisation($object['organisation']); - } - - if (($object['schedule'] ?? null) !== null) { - $this->setSchedule($object['schedule']); - } - - return $this; - // phpcs:enable - }//end hydrate() -}//end class diff --git a/lib/Db/ActionLog.php b/lib/Db/ActionLog.php deleted file mode 100644 index 7494c0cde5..0000000000 --- a/lib/Db/ActionLog.php +++ /dev/null @@ -1,252 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Db; - -use DateTime; -use JsonSerializable; -use OCP\AppFramework\Db\Entity; - -/** - * ActionLog entity for tracking action execution history - * - * @method int getId() - * @method void setId(int $id) - * @method int getActionId() - * @method void setActionId(int $actionId) - * @method string getActionUuid() - * @method void setActionUuid(string $actionUuid) - * @method string getEventType() - * @method void setEventType(string $eventType) - * @method string|null getObjectUuid() - * @method void setObjectUuid(?string $objectUuid) - * @method int|null getSchemaId() - * @method void setSchemaId(?int $schemaId) - * @method int|null getRegisterId() - * @method void setRegisterId(?int $registerId) - * @method string getEngine() - * @method void setEngine(string $engine) - * @method string getWorkflowId() - * @method void setWorkflowId(string $workflowId) - * @method string getStatus() - * @method void setStatus(string $status) - * @method int|null getDurationMs() - * @method void setDurationMs(?int $durationMs) - * @method string|null getRequestPayload() - * @method void setRequestPayload(?string $requestPayload) - * @method string|null getResponsePayload() - * @method void setResponsePayload(?string $responsePayload) - * @method string|null getErrorMessage() - * @method void setErrorMessage(?string $errorMessage) - * @method int getAttempt() - * @method void setAttempt(int $attempt) - * @method DateTime getCreated() - * @method void setCreated(DateTime $created) - * - * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class - */ -class ActionLog extends Entity implements JsonSerializable { - - /** - * The action id. - * - * @var integer - */ - protected int $actionId = 0; - - /** - * The action uuid. - * - * @var string - */ - protected string $actionUuid = ''; - - /** - * The event type. - * - * @var string - */ - protected string $eventType = ''; - - /** - * The object uuid. - * - * @var string|null - */ - protected ?string $objectUuid = null; - - /** - * The schema id. - * - * @var integer|null - */ - protected ?int $schemaId = null; - - /** - * The register id. - * - * @var integer|null - */ - protected ?int $registerId = null; - - /** - * The engine. - * - * @var string - */ - protected string $engine = ''; - - /** - * The workflow id. - * - * @var string - */ - protected string $workflowId = ''; - - /** - * The status. - * - * @var string - */ - protected string $status = ''; - - /** - * The duration ms. - * - * @var integer|null - */ - protected ?int $durationMs = null; - - /** - * The request payload. - * - * @var string|null - */ - protected ?string $requestPayload = null; - - /** - * The response payload. - * - * @var string|null - */ - protected ?string $responsePayload = null; - - /** - * The error message. - * - * @var string|null - */ - protected ?string $errorMessage = null; - - /** - * The attempt. - * - * @var integer - */ - protected int $attempt = 1; - - /** - * The created. - * - * @var DateTime - */ - protected DateTime $created; - - /** - * Constructor - * - * @return void - */ - public function __construct() { - $this->addType(fieldName: 'actionId', type: 'integer'); - $this->addType(fieldName: 'actionUuid', type: 'string'); - $this->addType(fieldName: 'eventType', type: 'string'); - $this->addType(fieldName: 'objectUuid', type: 'string'); - $this->addType(fieldName: 'schemaId', type: 'integer'); - $this->addType(fieldName: 'registerId', type: 'integer'); - $this->addType(fieldName: 'engine', type: 'string'); - $this->addType(fieldName: 'workflowId', type: 'string'); - $this->addType(fieldName: 'status', type: 'string'); - $this->addType(fieldName: 'durationMs', type: 'integer'); - $this->addType(fieldName: 'requestPayload', type: 'string'); - $this->addType(fieldName: 'responsePayload', type: 'string'); - $this->addType(fieldName: 'errorMessage', type: 'string'); - $this->addType(fieldName: 'attempt', type: 'integer'); - $this->addType(fieldName: 'created', type: 'datetime'); - - $this->created = new DateTime(); - }//end __construct() - - /** - * Get request payload as array - * - * @return array - */ - public function getRequestPayloadArray(): array { - if ($this->requestPayload === null) { - return []; - } - - return json_decode($this->requestPayload, true) ?? []; - }//end getRequestPayloadArray() - - /** - * Get response payload as array - * - * @return array - */ - public function getResponsePayloadArray(): array { - if ($this->responsePayload === null) { - return []; - } - - return json_decode($this->responsePayload, true) ?? []; - }//end getResponsePayloadArray() - - /** - * JSON serialize the entity - * - * @return array - */ - public function jsonSerialize(): array { - return [ - 'id' => $this->id, - 'actionId' => $this->actionId, - 'actionUuid' => $this->actionUuid, - 'eventType' => $this->eventType, - 'objectUuid' => $this->objectUuid, - 'schemaId' => $this->schemaId, - 'registerId' => $this->registerId, - 'engine' => $this->engine, - 'workflowId' => $this->workflowId, - 'status' => $this->status, - 'durationMs' => $this->durationMs, - 'requestPayload' => $this->getRequestPayloadArray(), - 'responsePayload' => $this->getResponsePayloadArray(), - 'errorMessage' => $this->errorMessage, - 'attempt' => $this->attempt, - 'created' => $this->created->format('c'), - ]; - }//end jsonSerialize() -}//end class diff --git a/lib/Db/ActionLogMapper.php b/lib/Db/ActionLogMapper.php deleted file mode 100644 index 6397facda9..0000000000 --- a/lib/Db/ActionLogMapper.php +++ /dev/null @@ -1,185 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Db; - -use DateTime; -use OCP\AppFramework\Db\Entity; -use OCP\AppFramework\Db\QBMapper; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; - -/** - * ActionLogMapper handles database operations for ActionLog entities - * - * @method ActionLog insert(Entity $entity) - * @method ActionLog update(Entity $entity) - * @method ActionLog delete(Entity $entity) - * @method ActionLog find(int $id) - * @method ActionLog findEntity(IQueryBuilder $query) - * @method list findEntities(IQueryBuilder $query) - * - * @template-extends QBMapper - */ -class ActionLogMapper extends QBMapper { - /** - * Constructor - * - * @param IDBConnection $db Database connection - * - * @return void - */ - public function __construct(IDBConnection $db) { - parent::__construct(db: $db, tableName: 'openregister_action_logs', entityClass: ActionLog::class); - }//end __construct() - - /** - * Find a log by ID - * - * @param int $id Log entry ID - * - * @return ActionLog - * - * @throws \OCP\AppFramework\Db\DoesNotExistException - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException - */ - public function find(int $id): ActionLog { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); - - return $this->findEntity(query: $qb); - }//end find() - - /** - * Find logs for a specific action by action ID - * - * @param int $actionId Action ID - * @param int|null $limit Limit results - * @param int|null $offset Offset results - * - * @return ActionLog[] - * - * @psalm-return list - */ - public function findByActionId(int $actionId, ?int $limit = null, ?int $offset = null): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->eq('action_id', $qb->createNamedParameter($actionId, IQueryBuilder::PARAM_INT))) - ->orderBy('created', 'DESC'); - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - return $this->findEntities(query: $qb); - }//end findByActionId() - - /** - * Find logs for a specific action by action UUID - * - * @param string $actionUuid Action UUID - * @param int|null $limit Limit results - * @param int|null $offset Offset results - * - * @return ActionLog[] - * - * @psalm-return list - */ - public function findByActionUuid(string $actionUuid, ?int $limit = null, ?int $offset = null): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->eq('action_uuid', $qb->createNamedParameter($actionUuid))) - ->orderBy('created', 'DESC'); - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - return $this->findEntities(query: $qb); - }//end findByActionUuid() - - /** - * Get aggregate statistics for a specific action - * - * @param int $actionId Action ID - * - * @return array Statistics array with total, successful, failed counts - * - * @psalm-return array{total: int, successful: int, failed: int} - */ - public function getStatsByActionId(int $actionId): array { - $qb = $this->db->getQueryBuilder(); - - $successCase = "SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successful"; - $failedCase = "SUM(CASE WHEN status IN ('failure', 'abandoned') THEN 1 ELSE 0 END) as failed"; - - $qb->select($qb->createFunction('COUNT(*) as total')) - ->addSelect($qb->createFunction($successCase)) - ->addSelect($qb->createFunction($failedCase)) - ->from($this->getTableName()) - ->where($qb->expr()->eq('action_id', $qb->createNamedParameter($actionId, IQueryBuilder::PARAM_INT))); - - $result = $qb->executeQuery(); - $row = $result->fetch(); - $result->closeCursor(); - - return [ - 'total' => (int)($row['total'] ?? 0), - 'successful' => (int)($row['successful'] ?? 0), - 'failed' => (int)($row['failed'] ?? 0), - ]; - }//end getStatsByActionId() - - /** - * Insert a new action log - * - * @param Entity $entity ActionLog entity to insert - * - * @return ActionLog - * - * @psalm-suppress PossiblyUnusedReturnValue - */ - public function insert(Entity $entity): Entity { - if ($entity instanceof ActionLog) { - $entity->setCreated(new DateTime()); - } - - return parent::insert(entity: $entity); - }//end insert() -}//end class diff --git a/lib/Db/ActionMapper.php b/lib/Db/ActionMapper.php deleted file mode 100644 index c6d8b13e10..0000000000 --- a/lib/Db/ActionMapper.php +++ /dev/null @@ -1,410 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Db; - -use DateTime; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\AppFramework\Db\Entity; -use OCP\AppFramework\Db\MultipleObjectsReturnedException; -use OCP\AppFramework\Db\QBMapper; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IAppConfig; -use OCP\IDBConnection; -use OCP\IGroupManager; -use OCP\IUserSession; -use Symfony\Component\Uid\Uuid; - -/** - * ActionMapper handles database operations for Action entities - * - * @method Action insert(Entity $entity) - * @method Action update(Entity $entity) - * @method Action insertOrUpdate(Entity $entity) - * @method Action delete(Entity $entity) - * @method Action find(int $id) - * @method Action findEntity(IQueryBuilder $query) - * @method Action[] findAll(int|null $limit=null, int|null $offset=null, array|null $filters=[]) - * @method list findEntities(IQueryBuilder $query) - * - * @template-extends QBMapper - * - * @SuppressWarnings(PHPMD.TooManyPublicMethods) - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class ActionMapper extends QBMapper { - use MultiTenancyTrait; - - /** - * Organisation mapper for multi-tenancy - * - * @var OrganisationMapper Organisation mapper instance - */ - protected OrganisationMapper $organisationMapper; - - /** - * App configuration for multitenancy settings - * - * @var IAppConfig App configuration instance - */ - protected IAppConfig $appConfig; - - /** - * User session for current user - * - * @var IUserSession User session instance - */ - private readonly IUserSession $userSession; - - /** - * Group manager for RBAC - * - * @var IGroupManager Group manager instance - */ - private readonly IGroupManager $groupManager; - - /** - * Constructor - * - * @param IDBConnection $db Database connection - * @param OrganisationMapper $organisationMapper Organisation mapper - * @param IUserSession $userSession User session - * @param IGroupManager $groupManager Group manager - * @param IAppConfig $appConfig App configuration - * - * @return void - */ - public function __construct( - IDBConnection $db, - OrganisationMapper $organisationMapper, - IUserSession $userSession, - IGroupManager $groupManager, - IAppConfig $appConfig, - ) { - parent::__construct(db: $db, tableName: 'openregister_actions', entityClass: Action::class); - - $this->organisationMapper = $organisationMapper; - $this->userSession = $userSession; - $this->groupManager = $groupManager; - $this->appConfig = $appConfig; - }//end __construct() - - /** - * Find all actions - * - * @param int|null $limit Maximum number of results - * @param int|null $offset Number of results to skip - * @param array $filters Optional filters - * - * @return Action[] - * - * @psalm-return list - */ - public function findAll(?int $limit = null, ?int $offset = null, ?array $filters = []): array { - if ($this->tableExists() === false) { - return []; - } - - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->isNull('deleted')); - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - foreach ($filters ?? [] as $filter => $value) { - if ($value === 'IS NOT NULL') { - $qb->andWhere($qb->expr()->isNotNull($filter)); - continue; - } - - if ($value === 'IS NULL') { - $qb->andWhere($qb->expr()->isNull($filter)); - continue; - } - - $qb->andWhere($qb->expr()->eq($filter, $qb->createNamedParameter($value))); - } - - $this->applyOrganisationFilter(qb: $qb); - - return $this->findEntities(query: $qb); - }//end findAll() - - /** - * Find a single action by ID - * - * @param int $id Action ID - * - * @return Action - * - * @throws DoesNotExistException - * @throws MultipleObjectsReturnedException - */ - public function find(int $id): Action { - if ($this->tableExists() === false) { - throw new DoesNotExistException('Actions table does not exist. Please run migrations.'); - } - - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); - - $this->applyOrganisationFilter(qb: $qb); - - return $this->findEntity(query: $qb); - }//end find() - - /** - * Find action by UUID - * - * @param string $uuid Action UUID - * - * @return Action - * - * @throws DoesNotExistException - * @throws MultipleObjectsReturnedException - */ - public function findByUuid(string $uuid): Action { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))); - - $this->applyOrganisationFilter(qb: $qb); - - return $this->findEntity(query: $qb); - }//end findByUuid() - - /** - * Find action by slug - * - * @param string $slug Action slug - * - * @return Action - * - * @throws DoesNotExistException - * @throws MultipleObjectsReturnedException - */ - public function findBySlug(string $slug): Action { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->eq('slug', $qb->createNamedParameter($slug))); - - $this->applyOrganisationFilter(qb: $qb); - - return $this->findEntity(query: $qb); - }//end findBySlug() - - /** - * Find actions by event type - * - * @param string $eventType Event type to match - * - * @return Action[] - * - * @psalm-return list - */ - public function findByEventType(string $eventType): array { - // Since event_type can be JSON array or single string, we need to fetch enabled actions - // and filter in PHP to support fnmatch patterns. - $actions = $this->findAll(filters: ['status' => 'active', 'enabled' => true]); - - return array_values( - array_filter( - $actions, - function (Action $action) use ($eventType) { - return $action->matchesEvent($eventType); - } - ) - ); - }//end findByEventType() - - /** - * Find matching actions for a given event, schema, and register - * - * Queries for all enabled, active, non-deleted actions, then filters by - * event type (exact + fnmatch wildcard), schema binding, and register binding. - * - * @param string $eventType Event type class name - * @param string|null $schemaUuid Schema UUID to filter by - * @param string|null $registerUuid Register UUID to filter by - * - * @return Action[] Sorted by execution_order ASC - * - * @psalm-return list - */ - public function findMatchingActions(string $eventType, ?string $schemaUuid = null, ?string $registerUuid = null): array { - if ($this->tableExists() === false) { - return []; - } - - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->eq('enabled', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) - ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter('active'))) - ->andWhere($qb->expr()->isNull('deleted')) - ->orderBy('execution_order', 'ASC'); - - $actions = $this->findEntities(query: $qb); - - // Filter by event type, schema, and register in PHP (supports fnmatch patterns). - return array_values( - array_filter( - $actions, - function (Action $action) use ($eventType, $schemaUuid, $registerUuid) { - return $action->matchesEvent($eventType) - && $action->matchesSchema($schemaUuid) - && $action->matchesRegister($registerUuid); - } - ) - ); - }//end findMatchingActions() - - /** - * Insert a new action - * - * @param Entity $entity Action entity to insert - * - * @return Action - * - * @throws \Exception - */ - public function insert(Entity $entity): Entity { - $this->verifyRbacPermission(action: 'create', entityType: 'action'); - - if ($entity instanceof Action) { - if (empty($entity->getUuid()) === true) { - $entity->setUuid(Uuid::v4()->toRfc4122()); - } - - $entity->setCreated(new DateTime()); - $entity->setUpdated(new DateTime()); - } - - $this->setOrganisationOnCreate(entity: $entity); - - return parent::insert(entity: $entity); - }//end insert() - - /** - * Update an existing action - * - * @param Entity $entity Action entity to update - * - * @return Action - * - * @throws \Exception - */ - public function update(Entity $entity): Entity { - $this->verifyRbacPermission(action: 'update', entityType: 'action'); - $this->verifyOrganisationAccess(entity: $entity); - - if ($entity instanceof Action) { - $entity->setUpdated(new DateTime()); - } - - return parent::update(entity: $entity); - }//end update() - - /** - * Delete an action - * - * @param Entity $entity Action entity to delete - * - * @return Action - * - * @throws \Exception - */ - public function delete(Entity $entity): Entity { - $this->verifyRbacPermission(action: 'delete', entityType: 'action'); - $this->verifyOrganisationAccess(entity: $entity); - - return parent::delete(entity: $entity); - }//end delete() - - /** - * Create action from array - * - * @param array $data Action data - * - * @return Action - */ - public function createFromArray(array $data): Action { - $action = new Action(); - $action->hydrate($data); - - return $this->insert(entity: $action); - }//end createFromArray() - - /** - * Update action from array - * - * @param int $id Action ID - * @param array $data Action data - * - * @return Action - * - * @throws DoesNotExistException - * @throws MultipleObjectsReturnedException - */ - public function updateFromArray(int $id, array $data): Action { - $action = $this->find(id: $id); - $action->hydrate($data); - - return $this->update(entity: $action); - }//end updateFromArray() - - /** - * Check if the actions table exists - * - * @return bool - */ - private function tableExists(): bool { - try { - $qb = $this->db->getQueryBuilder(); - $qb->select($qb->createFunction('COUNT(*)')) - ->from($this->getTableName()) - ->setMaxResults(1); - $qb->executeQuery(); - return true; - } catch (\Exception $e) { - return false; - } - }//end tableExists() -}//end class diff --git a/lib/Db/ApprovalChain.php b/lib/Db/ApprovalChain.php deleted file mode 100644 index b4c7a4fd9f..0000000000 --- a/lib/Db/ApprovalChain.php +++ /dev/null @@ -1,185 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use JsonSerializable; -use OCP\AppFramework\Db\Entity; - -/** - * Entity class representing a multi-step approval chain configuration. - * - * @method string|null getUuid() - * @method void setUuid(?string $uuid) - * @method string|null getName() - * @method void setName(?string $name) - * @method int|null getSchemaId() - * @method void setSchemaId(?int $schemaId) - * @method string|null getStatusField() - * @method void setStatusField(?string $statusField) - * @method string|null getSteps() - * @method void setSteps(?string $steps) - * @method bool getEnabled() - * @method void setEnabled(bool $enabled) - * @method DateTime|null getCreated() - * @method void setCreated(?DateTime $created) - * @method DateTime|null getUpdated() - * @method void setUpdated(?DateTime $updated) - * - * @psalm-suppress PropertyNotSetInConstructor - */ -class ApprovalChain extends Entity implements JsonSerializable { - - /** - * The uuid. - * - * @var string|null - */ - protected ?string $uuid = null; - - /** - * The name. - * - * @var string|null - */ - protected ?string $name = null; - - /** - * The schema id. - * - * @var integer|null - */ - protected ?int $schemaId = null; - - /** - * The status field. - * - * @var string|null - */ - protected ?string $statusField = 'status'; - - /** - * The steps. - * - * @var string|null - */ - protected ?string $steps = null; - - /** - * The enabled. - * - * @var boolean - */ - protected bool $enabled = true; - - /** - * The created. - * - * @var DateTime|null - */ - protected ?DateTime $created = null; - - /** - * The updated. - * - * @var DateTime|null - */ - protected ?DateTime $updated = null; - - /** - * Constructor for ApprovalChain entity. - */ - public function __construct() { - $this->addType(fieldName: 'uuid', type: 'string'); - $this->addType(fieldName: 'name', type: 'string'); - $this->addType(fieldName: 'schemaId', type: 'integer'); - $this->addType(fieldName: 'statusField', type: 'string'); - $this->addType(fieldName: 'steps', type: 'string'); - $this->addType(fieldName: 'enabled', type: 'boolean'); - $this->addType(fieldName: 'created', type: 'datetime'); - $this->addType(fieldName: 'updated', type: 'datetime'); - }//end __construct() - - /** - * Get the steps as a decoded array. - * - * @return array> - */ - public function getStepsArray(): array { - if ($this->steps === null) { - return []; - } - - return json_decode($this->steps, true) ?? []; - }//end getStepsArray() - - /** - * Hydrate entity from array. - * - * @param array $object Data to hydrate from - * - * @return self - */ - public function hydrate(array $object): self { - $fields = [ - 'uuid', - 'name', - 'schemaId', - 'statusField', - 'steps', - 'enabled', - 'created', - 'updated', - ]; - - foreach ($object as $key => $value) { - if (in_array($key, $fields, true) === true) { - $setter = 'set' . ucfirst($key); - if ($key === 'steps' && is_array($value) === true) { - $value = json_encode($value); - } - - $this->$setter($value); - } - } - - return $this; - }//end hydrate() - - /** - * Serialize to JSON. - * - * @return array - */ - public function jsonSerialize(): array { - return [ - 'id' => $this->id, - 'uuid' => $this->uuid, - 'name' => $this->name, - 'schemaId' => $this->schemaId, - 'statusField' => $this->statusField, - 'steps' => $this->getStepsArray(), - 'enabled' => $this->enabled, - 'created' => $this->created?->format('c'), - 'updated' => $this->updated?->format('c'), - ]; - }//end jsonSerialize() -}//end class diff --git a/lib/Db/ApprovalChainMapper.php b/lib/Db/ApprovalChainMapper.php deleted file mode 100644 index f36f87caa4..0000000000 --- a/lib/Db/ApprovalChainMapper.php +++ /dev/null @@ -1,189 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use OCP\AppFramework\Db\QBMapper; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; -use Symfony\Component\Uid\Uuid; - -/** - * Mapper for ApprovalChain entities. - * - * @extends QBMapper - */ -class ApprovalChainMapper extends QBMapper { - /** - * Constructor for ApprovalChainMapper. - * - * @param IDBConnection $db Database connection - */ - public function __construct(IDBConnection $db) { - parent::__construct( - db: $db, - tableName: 'openregister_approval_chains', - entityClass: ApprovalChain::class - ); - }//end __construct() - - /** - * Find an approval chain by ID. - * - * @param int $id Chain ID - * - * @return ApprovalChain - */ - public function find(int $id): ApprovalChain { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter(value: $id, type: IQueryBuilder::PARAM_INT)) - ); - - return $this->findEntity(query: $qb); - }//end find() - - /** - * Find all approval chains. - * - * @param int|null $limit Maximum results - * @param int|null $offset Offset for pagination - * - * @return array - */ - public function findAll(?int $limit = null, ?int $offset = null): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->orderBy('name', 'ASC'); - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - return $this->findEntities(query: $qb); - }//end findAll() - - /** - * Find approval chains by schema ID. - * - * @param int $schemaId Schema ID - * - * @return array - */ - public function findBySchema(int $schemaId): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq( - 'schema_id', - $qb->createNamedParameter(value: $schemaId, type: IQueryBuilder::PARAM_INT) - ) - ) - ->orderBy('name', 'ASC'); - - return $this->findEntities(query: $qb); - }//end findBySchema() - - /** - * Find the chain provisioned for a schema under a given declarative chain - * key (the `x-openregister-approval-chains` entry name, stored as `name`). - * - * @param int $schemaId Schema ID. - * @param string $name Chain key / name. - * - * @return ApprovalChain|null The matching chain, or null when none exists yet. - * - * @spec openspec/specs/approval-workflow/spec.md - */ - public function findBySchemaAndName(int $schemaId, string $name): ?ApprovalChain { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq( - 'schema_id', - $qb->createNamedParameter(value: $schemaId, type: IQueryBuilder::PARAM_INT) - ) - ) - ->andWhere( - $qb->expr()->eq('name', $qb->createNamedParameter($name)) - ) - ->setMaxResults(1); - - try { - return $this->findEntity(query: $qb); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - return null; - } catch (\Throwable $e) { - return null; - } - }//end findBySchemaAndName() - - /** - * Create an approval chain from an array. - * - * @param array $data Chain data - * - * @return ApprovalChain - */ - public function createFromArray(array $data): ApprovalChain { - $chain = new ApprovalChain(); - $chain->hydrate($data); - - if ($chain->getUuid() === null) { - $chain->setUuid(Uuid::v4()->toRfc4122()); - } - - $now = new DateTime(); - $chain->setCreated($now); - $chain->setUpdated($now); - - return $this->insert(entity: $chain); - }//end createFromArray() - - /** - * Update an approval chain from an array. - * - * @param int $id Chain ID - * @param array $data Updated data - * - * @return ApprovalChain - */ - public function updateFromArray(int $id, array $data): ApprovalChain { - $chain = $this->find(id: $id); - $chain->hydrate($data); - $chain->setUpdated(new DateTime()); - - return $this->update(entity: $chain); - }//end updateFromArray() -}//end class diff --git a/lib/Db/ApprovalStep.php b/lib/Db/ApprovalStep.php deleted file mode 100644 index 73c8f82b0f..0000000000 --- a/lib/Db/ApprovalStep.php +++ /dev/null @@ -1,208 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use JsonSerializable; -use OCP\AppFramework\Db\Entity; - -/** - * Entity class representing a single approval step for an object in a chain. - * - * @method string|null getUuid() - * @method void setUuid(?string $uuid) - * @method int|null getChainId() - * @method void setChainId(?int $chainId) - * @method string|null getObjectUuid() - * @method void setObjectUuid(?string $objectUuid) - * @method int getStepOrder() - * @method void setStepOrder(int $stepOrder) - * @method string|null getRole() - * @method void setRole(?string $role) - * @method string|null getStatus() - * @method void setStatus(?string $status) - * @method string|null getDecidedBy() - * @method void setDecidedBy(?string $decidedBy) - * @method string|null getComment() - * @method void setComment(?string $comment) - * @method DateTime|null getDecidedAt() - * @method void setDecidedAt(?DateTime $decidedAt) - * @method DateTime|null getCreated() - * @method void setCreated(?DateTime $created) - * @method string|null getRequesterId() - * @method void setRequesterId(?string $requesterId) - * - * @psalm-suppress PropertyNotSetInConstructor - */ -class ApprovalStep extends Entity implements JsonSerializable { - - /** - * The uuid. - * - * @var string|null - */ - protected ?string $uuid = null; - - /** - * The chain id. - * - * @var integer|null - */ - protected ?int $chainId = null; - - /** - * The object uuid. - * - * @var string|null - */ - protected ?string $objectUuid = null; - - /** - * The step order. - * - * @var integer - */ - protected int $stepOrder = 0; - - /** - * The role. - * - * @var string|null - */ - protected ?string $role = null; - - /** - * The status. - * - * @var string|null - */ - protected ?string $status = 'pending'; - - /** - * The decided by. - * - * @var string|null - */ - protected ?string $decidedBy = null; - - /** - * The comment. - * - * @var string|null - */ - protected ?string $comment = null; - - /** - * The decided at. - * - * @var DateTime|null - */ - protected ?DateTime $decidedAt = null; - - /** - * The created. - * - * @var DateTime|null - */ - protected ?DateTime $created = null; - - /** - * Uid of the user whose attempted transition provisioned this step, when the - * step was created via the declarative approval-chains gate. `null` for steps - * created through the pure `POST /api/approval-chains` CRUD flow. Used by - * `ApprovalService::resolveSeparationOfDuties()` to reject a decision made by - * the same user who triggered the gate. - * - * @var string|null - */ - protected ?string $requesterId = null; - - /** - * Constructor for ApprovalStep entity. - */ - public function __construct() { - $this->addType(fieldName: 'uuid', type: 'string'); - $this->addType(fieldName: 'chainId', type: 'integer'); - $this->addType(fieldName: 'objectUuid', type: 'string'); - $this->addType(fieldName: 'stepOrder', type: 'integer'); - $this->addType(fieldName: 'role', type: 'string'); - $this->addType(fieldName: 'status', type: 'string'); - $this->addType(fieldName: 'decidedBy', type: 'string'); - $this->addType(fieldName: 'comment', type: 'string'); - $this->addType(fieldName: 'decidedAt', type: 'datetime'); - $this->addType(fieldName: 'created', type: 'datetime'); - $this->addType(fieldName: 'requesterId', type: 'string'); - }//end __construct() - - /** - * Hydrate entity from array. - * - * @param array $object Data to hydrate from - * - * @return self - */ - public function hydrate(array $object): self { - $fields = [ - 'uuid', - 'chainId', - 'objectUuid', - 'stepOrder', - 'role', - 'status', - 'decidedBy', - 'comment', - 'decidedAt', - 'created', - 'requesterId', - ]; - - foreach ($object as $key => $value) { - if (in_array($key, $fields, true) === true) { - $setter = 'set' . ucfirst($key); - $this->$setter($value); - } - } - - return $this; - }//end hydrate() - - /** - * Serialize to JSON. - * - * @return array - */ - public function jsonSerialize(): array { - return [ - 'id' => $this->id, - 'uuid' => $this->uuid, - 'chainId' => $this->chainId, - 'objectUuid' => $this->objectUuid, - 'stepOrder' => $this->stepOrder, - 'role' => $this->role, - 'status' => $this->status, - 'decidedBy' => $this->decidedBy, - 'comment' => $this->comment, - 'decidedAt' => $this->decidedAt?->format('c'), - 'created' => $this->created?->format('c'), - 'requesterId' => $this->requesterId, - ]; - }//end jsonSerialize() -}//end class diff --git a/lib/Db/ApprovalStepMapper.php b/lib/Db/ApprovalStepMapper.php deleted file mode 100644 index 1d7998384e..0000000000 --- a/lib/Db/ApprovalStepMapper.php +++ /dev/null @@ -1,268 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use OCP\AppFramework\Db\QBMapper; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; -use Symfony\Component\Uid\Uuid; - -/** - * Mapper for ApprovalStep entities. - * - * @extends QBMapper - */ -class ApprovalStepMapper extends QBMapper { - /** - * Constructor for ApprovalStepMapper. - * - * @param IDBConnection $db Database connection - */ - public function __construct(IDBConnection $db) { - parent::__construct( - db: $db, - tableName: 'openregister_approval_steps', - entityClass: ApprovalStep::class - ); - }//end __construct() - - /** - * Find an approval step by ID. - * - * @param int $id Step ID - * - * @return ApprovalStep - */ - public function find(int $id): ApprovalStep { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter(value: $id, type: IQueryBuilder::PARAM_INT)) - ); - - return $this->findEntity(query: $qb); - }//end find() - - /** - * Find all steps for a chain and object combination. - * - * @param int $chainId Chain ID - * @param string $objectUuid Object UUID - * - * @return array - */ - public function findByChainAndObject(int $chainId, string $objectUuid): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq( - 'chain_id', - $qb->createNamedParameter(value: $chainId, type: IQueryBuilder::PARAM_INT) - ) - ) - ->andWhere( - $qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid)) - ) - ->orderBy('step_order', 'ASC'); - - return $this->findEntities(query: $qb); - }//end findByChainAndObject() - - /** - * Find all pending steps matching a given role. - * - * @param string $role Role (Nextcloud group ID) - * - * @return array - */ - public function findPendingByRole(string $role): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('status', $qb->createNamedParameter('pending')) - ) - ->andWhere( - $qb->expr()->eq('role', $qb->createNamedParameter($role)) - ) - ->orderBy('created', 'ASC'); - - return $this->findEntities(query: $qb); - }//end findPendingByRole() - - /** - * Find all approval steps for a given object UUID. - * - * @param string $objectUuid Object UUID - * - * @return array - */ - public function findByObjectUuid(string $objectUuid): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid)) - ) - ->orderBy('step_order', 'ASC'); - - return $this->findEntities(query: $qb); - }//end findByObjectUuid() - - /** - * Find all steps with optional filters. - * - * @param array $filters Filter parameters - * @param int|null $limit Maximum results - * @param int|null $offset Pagination offset - * - * @return array - * - * @spec openspec/specs/approval-workflow/spec.md - */ - public function findAllFiltered(array $filters = [], ?int $limit = null, ?int $offset = null): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->orderBy('created', 'ASC'); - - if (isset($filters['status']) === true) { - $qb->andWhere($qb->expr()->eq('status', $qb->createNamedParameter($filters['status']))); - } - - if (isset($filters['role']) === true) { - $qb->andWhere($qb->expr()->eq('role', $qb->createNamedParameter($filters['role']))); - } - - if (isset($filters['chainId']) === true) { - $qb->andWhere( - $qb->expr()->eq( - 'chain_id', - $qb->createNamedParameter(value: (int)$filters['chainId'], type: IQueryBuilder::PARAM_INT) - ) - ); - } - - if (isset($filters['objectUuid']) === true) { - $qb->andWhere($qb->expr()->eq('object_uuid', $qb->createNamedParameter($filters['objectUuid']))); - } - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - return $this->findEntities(query: $qb); - }//end findAllFiltered() - - /** - * Find distinct object UUIDs in a chain with their step progress. - * - * @param int $chainId Chain ID - * - * @return array - */ - public function findByChain(int $chainId): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq( - 'chain_id', - $qb->createNamedParameter(value: $chainId, type: IQueryBuilder::PARAM_INT) - ) - ) - ->orderBy('object_uuid', 'ASC') - ->addOrderBy('step_order', 'ASC'); - - return $this->findEntities(query: $qb); - }//end findByChain() - - /** - * Create an approval step from an array. - * - * @param array $data Step data - * - * @return ApprovalStep - */ - public function createFromArray(array $data): ApprovalStep { - $step = new ApprovalStep(); - $step->hydrate($data); - - if ($step->getUuid() === null) { - $step->setUuid(Uuid::v4()->toRfc4122()); - } - - if ($step->getCreated() === null) { - $step->setCreated(new DateTime()); - } - - return $this->insert(entity: $step); - }//end createFromArray() - - /** - * Delete every step for a chain and object combination. - * - * Used by `ApprovalChainGateListener` to clear a `rejected` cycle before - * provisioning a fresh one on the next gated-transition attempt - * (resubmission). `workflow_executions` (`WorkflowExecutionMapper`) remains - * the durable audit trail for the cleared cycle's decisions — `ApprovalStep` - * rows are working state, not audit log. - * - * @param int $chainId Chain ID - * @param string $objectUuid Object UUID - * - * @return int Number of rows deleted (0 on error). - * - * @spec openspec/specs/approval-workflow/spec.md - */ - public function deleteByChainAndObject(int $chainId, string $objectUuid): int { - try { - $qb = $this->db->getQueryBuilder(); - $qb->delete($this->getTableName()) - ->where( - $qb->expr()->eq( - 'chain_id', - $qb->createNamedParameter(value: $chainId, type: IQueryBuilder::PARAM_INT) - ) - ) - ->andWhere( - $qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid)) - ); - - return (int)$qb->executeStatement(); - } catch (\Throwable $e) { - return 0; - } - }//end deleteByChainAndObject() -}//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/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/Db/DeployedWorkflow.php b/lib/Db/DeployedWorkflow.php deleted file mode 100644 index f6ee2b3245..0000000000 --- a/lib/Db/DeployedWorkflow.php +++ /dev/null @@ -1,195 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use JsonSerializable; -use OCP\AppFramework\Db\Entity; - -/** - * Entity class representing a deployed workflow tracked through the import system. - * - * @method string|null getUuid() - * @method void setUuid(?string $uuid) - * @method string|null getName() - * @method void setName(?string $name) - * @method string|null getEngine() - * @method void setEngine(?string $engine) - * @method string|null getEngineWorkflowId() - * @method void setEngineWorkflowId(?string $engineWorkflowId) - * @method string|null getSourceHash() - * @method void setSourceHash(?string $sourceHash) - * @method string|null getAttachedSchema() - * @method void setAttachedSchema(?string $attachedSchema) - * @method string|null getAttachedEvent() - * @method void setAttachedEvent(?string $attachedEvent) - * @method string|null getImportSource() - * @method void setImportSource(?string $importSource) - * @method int getVersion() - * @method void setVersion(int $version) - * @method DateTime|null getCreated() - * @method void setCreated(?DateTime $created) - * @method DateTime|null getUpdated() - * @method void setUpdated(?DateTime $updated) - * - * @psalm-suppress PropertyNotSetInConstructor - */ -class DeployedWorkflow extends Entity implements JsonSerializable { - - /** - * UUID for external reference. - * - * @var string|null - */ - protected ?string $uuid = null; - - /** - * Human-readable name from import. - * - * @var string|null - */ - protected ?string $name = null; - - /** - * Engine identifier (e.g., "n8n", "windmill"). - * - * @var string|null - */ - protected ?string $engine = null; - - /** - * ID returned by the engine after deploy. - * - * @var string|null - */ - protected ?string $engineWorkflowId = null; - - /** - * SHA-256 hash of the workflow definition. - * - * @var string|null - */ - protected ?string $sourceHash = null; - - /** - * Schema slug that this workflow is attached to (null if no attachTo). - * - * @var string|null - */ - protected ?string $attachedSchema = null; - - /** - * Hook event type (e.g., "creating", "created"). - * - * @var string|null - */ - protected ?string $attachedEvent = null; - - /** - * Filename or identifier of the import source. - * - * @var string|null - */ - protected ?string $importSource = null; - - /** - * Version number, incremented on each update (starts at 1). - * - * @var integer - */ - protected int $version = 1; - - /** - * Timestamp when the entity was created. - * - * @var DateTime|null - */ - protected ?DateTime $created = null; - - /** - * Timestamp when the entity was last updated. - * - * @var DateTime|null - */ - protected ?DateTime $updated = null; - - /** - * Constructor for DeployedWorkflow entity. - * - * Registers column types for the database mapper. - */ - public function __construct() { - $this->addType(fieldName: 'uuid', type: 'string'); - $this->addType(fieldName: 'name', type: 'string'); - $this->addType(fieldName: 'engine', type: 'string'); - $this->addType(fieldName: 'engineWorkflowId', type: 'string'); - $this->addType(fieldName: 'sourceHash', type: 'string'); - $this->addType(fieldName: 'attachedSchema', type: 'string'); - $this->addType(fieldName: 'attachedEvent', type: 'string'); - $this->addType(fieldName: 'importSource', type: 'string'); - $this->addType(fieldName: 'version', type: 'integer'); - $this->addType(fieldName: 'created', type: 'datetime'); - $this->addType(fieldName: 'updated', type: 'datetime'); - }//end __construct() - - /** - * Hydrate entity from array. - * - * @param array $object Data to hydrate from - * - * @return self - */ - public function hydrate(array $object): self { - foreach ($object as $key => $value) { - $method = 'set' . ucfirst($key); - - try { - $this->$method($value); - } catch (\Exception $exception) { - // Silently ignore invalid properties. - } - } - - return $this; - }//end hydrate() - - /** - * Serialize to JSON. - * - * @return array - */ - public function jsonSerialize(): array { - return [ - 'id' => $this->id, - 'uuid' => $this->uuid, - 'name' => $this->name, - 'engine' => $this->engine, - 'engineWorkflowId' => $this->engineWorkflowId, - 'sourceHash' => $this->sourceHash, - 'attachedSchema' => $this->attachedSchema, - 'attachedEvent' => $this->attachedEvent, - 'importSource' => $this->importSource, - 'version' => $this->version, - 'created' => $this->created?->format('c'), - 'updated' => $this->updated?->format('c'), - ]; - }//end jsonSerialize() -}//end class diff --git a/lib/Db/DeployedWorkflowMapper.php b/lib/Db/DeployedWorkflowMapper.php deleted file mode 100644 index 65b10ec691..0000000000 --- a/lib/Db/DeployedWorkflowMapper.php +++ /dev/null @@ -1,192 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\AppFramework\Db\QBMapper; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; -use Symfony\Component\Uid\Uuid; - -/** - * Mapper for DeployedWorkflow entities. - * - * @extends QBMapper - */ -class DeployedWorkflowMapper extends QBMapper { - /** - * Constructor for DeployedWorkflowMapper. - * - * @param IDBConnection $db Database connection - */ - public function __construct(IDBConnection $db) { - parent::__construct(db: $db, tableName: 'openregister_deployed_workflows', entityClass: DeployedWorkflow::class); - }//end __construct() - - /** - * Find a deployed workflow by ID. - * - * @param int $id Deployed workflow ID - * - * @return DeployedWorkflow - */ - public function find(int $id): DeployedWorkflow { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter(value: $id, type: IQueryBuilder::PARAM_INT)) - ); - - return $this->findEntity(query: $qb); - }//end find() - - /** - * Find all deployed workflows. - * - * @param int|null $limit Maximum results - * @param int|null $offset Offset for pagination - * - * @return array - */ - public function findAll(?int $limit = null, ?int $offset = null): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->orderBy('name', 'ASC'); - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - return $this->findEntities(query: $qb); - }//end findAll() - - /** - * Find a deployed workflow by name and engine type. - * - * @param string $name Workflow name - * @param string $engine Engine type identifier - * - * @return DeployedWorkflow|null The matching entity or null if not found - */ - public function findByNameAndEngine(string $name, string $engine): ?DeployedWorkflow { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('name', $qb->createNamedParameter(value: $name)) - ) - ->andWhere( - $qb->expr()->eq('engine', $qb->createNamedParameter(value: $engine)) - ); - - try { - return $this->findEntity(query: $qb); - } catch (DoesNotExistException $e) { - return null; - } - }//end findByNameAndEngine() - - /** - * Find all deployed workflows attached to a schema. - * - * @param string $schemaSlug Schema slug - * - * @return array - */ - public function findBySchema(string $schemaSlug): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('attached_schema', $qb->createNamedParameter(value: $schemaSlug)) - ); - - return $this->findEntities(query: $qb); - }//end findBySchema() - - /** - * Find all deployed workflows from a specific import source. - * - * @param string $source Import source identifier - * - * @return array - */ - public function findByImportSource(string $source): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('import_source', $qb->createNamedParameter(value: $source)) - ); - - return $this->findEntities(query: $qb); - }//end findByImportSource() - - /** - * Create a deployed workflow from an array. - * - * @param array $data Workflow data - * - * @return DeployedWorkflow - */ - public function createFromArray(array $data): DeployedWorkflow { - $workflow = new DeployedWorkflow(); - $workflow->hydrate($data); - - if ($workflow->getUuid() === null) { - $workflow->setUuid(Uuid::v4()->toRfc4122()); - } - - $now = new DateTime(); - $workflow->setCreated($now); - $workflow->setUpdated($now); - - return $this->insert(entity: $workflow); - }//end createFromArray() - - /** - * Update a deployed workflow from an array. - * - * @param int $id Workflow ID - * @param array $data Updated data - * - * @return DeployedWorkflow - */ - public function updateFromArray(int $id, array $data): DeployedWorkflow { - $workflow = $this->find(id: $id); - $workflow->hydrate($data); - $workflow->setUpdated(new DateTime()); - - return $this->update(entity: $workflow); - }//end updateFromArray() -}//end class 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/Db/Flow.php b/lib/Db/Flow.php index a33ae6495f..091c0ceeee 100644 --- a/lib/Db/Flow.php +++ b/lib/Db/Flow.php @@ -96,6 +96,18 @@ * @method void setCreated(?DateTime $created) * @method DateTime|null getUpdated() * @method void setUpdated(?DateTime $updated) + * @method string|null getStatus() + * @method void setStatus(?string $status) + * @method string|null getStatusMessage() + * @method void setStatusMessage(?string $statusMessage) + * @method string|null getLastRunUuid() + * @method void setLastRunUuid(?string $lastRunUuid) + * @method string|null getLastRunStatus() + * @method void setLastRunStatus(?string $lastRunStatus) + * @method string|null getLastRunMessage() + * @method void setLastRunMessage(?string $lastRunMessage) + * @method DateTime|null getLastRunAt() + * @method void setLastRunAt(?DateTime $lastRunAt) * * An Entity mirrors its table one field per column, so the field count below is * the schema's rather than a design choice. Splitting it would need a second 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..8c60d1b7b2 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) @@ -78,6 +82,8 @@ * @method void setResumeAt(?DateTime $resumeAt) * @method string|null getParentRunUuid() * @method void setParentRunUuid(?string $parentRunUuid) + * @method string|null getCorrelationKey() + * @method void setCorrelationKey(?string $correlationKey) * @method DateTime|null getCreated() * @method void setCreated(?DateTime $created) * @method DateTime|null getUpdated() @@ -212,6 +218,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. * @@ -321,6 +345,16 @@ class FlowRun extends Entity implements JsonSerializable { */ protected ?string $parentRunUuid = null; + /** + * The business key an await-signal step is waiting to be told about, + * populated at suspension and cleared when the run leaves suspension + * (flow-approval-consolidation design D-7). Indexed, so a signal + * addressed by key resolves without a JSON scan. + * + * @var string|null + */ + protected ?string $correlationKey = null; + /** * Creation timestamp. * @@ -344,6 +378,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'); @@ -357,6 +393,7 @@ public function __construct() { $this->addType(fieldName: 'error', type: 'string'); $this->addType(fieldName: 'resumeAt', type: 'datetime'); $this->addType(fieldName: 'parentRunUuid', type: 'string'); + $this->addType(fieldName: 'correlationKey', type: 'string'); $this->addType(fieldName: 'created', type: 'datetime'); $this->addType(fieldName: 'updated', type: 'datetime'); @@ -403,6 +440,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 ?? []), @@ -416,6 +455,7 @@ public function jsonSerialize(): array { 'error' => $this->error, 'resumeAt' => $resumeAt, 'parentRunUuid' => $this->parentRunUuid, + 'correlationKey' => $this->correlationKey, 'created' => $created, 'updated' => $updated, ]; diff --git a/lib/Db/FlowRunMapper.php b/lib/Db/FlowRunMapper.php index 84a7d1b6b0..6ed9cf32f6 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; /** @@ -36,6 +40,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 * @@ -47,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. * @@ -73,6 +134,58 @@ public function findByUuid(string $uuid): FlowRun { return $this->findEntity(query: $qb); }//end findByUuid() + /** + * The suspended runs carrying a correlation key, capped at two. + * + * Two is all a fail-closed resolution needs (flow-approval-consolidation + * design D-7): zero is "not found", one is the addressee, and a second + * row already proves ambiguity, which refuses without caring whether a + * third exists. Only SUSPENDED runs are consulted, on the + * (status, correlation_key) index. + * + * @param string $correlationKey The business key. + * + * @return array Zero, one or two suspended runs. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-signal-node-keeps-machine-to-machine-work-and-gains-a-correlation-key + */ + public function findSuspendedByCorrelationKey(string $correlationKey): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('status', $qb->createNamedParameter(FlowRun::STATUS_SUSPENDED))) + ->andWhere($qb->expr()->eq('correlation_key', $qb->createNamedParameter($correlationKey))) + ->orderBy('id', 'ASC') + ->setMaxResults(2); + + return $this->findEntities(query: $qb); + }//end findSuspendedByCorrelationKey() + + /** + * 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. * @@ -467,14 +580,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 +614,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() @@ -549,6 +674,41 @@ public function hasActiveRun(string $flowId): bool { return $row !== false; }//end hasActiveRun() + /** + * How many runs this flow has EVER had, terminal ones included. + * + * {@see hasActiveRun} answers a different question — whether a run is going + * right now — and a flow that ran to completion last year has none. Deleting + * a flow is irreversible, so the guard that authorises it has to ask about + * the flow's whole history, not its current activity. + * + * Used by {@see \OCA\OpenRegister\Repair\PurgePhantomMigratedFlows} as one + * conjunct of the never-dispatched proof: a row with even one run row has + * been part of something that happened and is never removed. + * + * @param string $flowId The flow's uuid. + * + * @return integer The number of run rows recorded against this flow. + * + * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md + */ + public function countRunsForFlow(string $flowId): int { + if (trim($flowId) === '') { + return 0; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->createFunction('COUNT(*) AS `total`')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('flow_id', $qb->createNamedParameter($flowId))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return (int)($row['total'] ?? 0); + }//end countRunsForFlow() + /** * How many runs are still going, for one organisation. * @@ -556,13 +716,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 +743,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/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/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/Db/MagicMapper.php b/lib/Db/MagicMapper.php index 67d75a8127..cf147cab51 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', @@ -7517,6 +7528,36 @@ public function deleteObjectsBySchema( return $deletedCount; }//end deleteObjectsBySchema() + /** + * Hard-delete the rows of a register+schema table whose `_expires` has passed. + * + * The sweep for rows written through bulkUpsert() with an expiry, i.e. raw + * appends. There is no soft-delete and no audit entry: those rows never had + * either, and keeping expired telemetry as tombstones would defeat the + * retention the expiry expresses. Rows with a NULL `_expires` are never + * touched. A table that does not exist yet has nothing to purge and answers 0. + * + * @param Register $register The register context. + * @param Schema $schema The schema context. + * + * @return int The number of rows removed. + * + * @throws Exception If the delete fails. + * + * @spec openspec/specs/data-import-export/spec.md#requirement-raw-append-for-high-volume-writers + */ + public function purgeExpired(Register $register, Schema $schema): int { + if ($this->tableExistsForRegisterSchema(register: $register, schema: $schema) === false) { + return 0; + } + + return $this->bulkHandler->purgeExpired( + register: $register, + schema: $schema, + tableName: $this->getTableNameForRegisterSchema(register: $register, schema: $schema) + ); + }//end purgeExpired() + /** * Batch delete objects by UUID list from a register+schema magic table. * @@ -7583,6 +7624,8 @@ public function deleteObjectsByUuids( * @param Register $register The register context. * @param Schema $schema The schema context. * @param int|null $lockDuration Lock duration in seconds (null for default). + * @param string|null $process What the lock was taken for. + * @param string|null $runUuid Flow run taking the lock, for a run-scoped lock. * * @throws Exception If locking fails. * @@ -7593,9 +7636,22 @@ public function lockObjectEntity( Register $register, Schema $schema, ?int $lockDuration = null, + ?string $process = null, + ?string $runUuid = null, ): ObjectEntity { // Lock using entity method. - $entity->lock(userSession: $this->userSession, process: 'MagicMapper lock', duration: $lockDuration); + // + // `$process` used to be dropped here: this method hardcoded the + // literal 'MagicMapper lock', so every caller's process tag was + // discarded before it reached the payload. integriq mints a fresh + // UUID per lock and passes it in, and no caller has ever been able to + // read back what a lock was taken FOR. + $entity->lock( + userSession: $this->userSession, + process: ($process ?? 'MagicMapper lock'), + duration: $lockDuration, + runUuid: $runUuid + ); // Update entity in table with locked field set. $this->updateObjectEntity(entity: $entity, register: $register, schema: $schema); @@ -7622,6 +7678,8 @@ public function lockObjectEntity( * @param ObjectEntity $entity The object entity to unlock. * @param Register $register The register context. * @param Schema $schema The schema context. + * @param string|null $runUuid Flow run releasing the lock, for a run-scoped lock. + * @param bool $break Release regardless of holder (administrator break-lock). * * @throws Exception If unlocking fails. * @@ -7631,9 +7689,11 @@ public function unlockObjectEntity( ObjectEntity $entity, Register $register, Schema $schema, + ?string $runUuid = null, + bool $break = false, ): ObjectEntity { // Unlock using entity method. - $entity->unlock($this->userSession); + $entity->unlock($this->userSession, $runUuid, $break); // Update entity in table with locked field cleared. $this->updateObjectEntity(entity: $entity, register: $register, schema: $schema); @@ -7716,6 +7776,23 @@ public function bulkUpsert( ] ); + // ONLY CREATE A TABLE THAT IS ACTUALLY MISSING. `force: true` makes + // ensureTableForRegisterSchema() DROP an existing table before + // recreating it, which destroys every row with no audit entry — and + // the message test above is wider than its name: PostgreSQL says + // `column "x" of relation "y" does not exist` for a missing COLUMN + // (42703) as well as for a missing table (42P01), so a schema whose + // magic table was merely out of sync could reach this recovery and + // come back empty. If the table is there, the failure was something + // else, so surface it instead of resolving it destructively. + // + // Asked of the connection rather than of tableExistsForRegisterSchema(), + // whose positive answers are cached for TABLE_CACHE_TIMEOUT: a stale + // "yes" here would turn a genuine missing-table recovery into an error. + if ($this->db->tableExists($tableName) === true) { + throw $e; + } + // Create the table. $this->ensureTableForRegisterSchema(register: $register, schema: $schema, force: true); diff --git a/lib/Db/MagicMapper/MagicBulkHandler.php b/lib/Db/MagicMapper/MagicBulkHandler.php index 85b8723718..3e65263303 100644 --- a/lib/Db/MagicMapper/MagicBulkHandler.php +++ b/lib/Db/MagicMapper/MagicBulkHandler.php @@ -45,6 +45,7 @@ use OCA\OpenRegister\Db\Register; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Service\DateTimeNormalizer; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IDBConnection; @@ -169,6 +170,7 @@ private function prepareObjectsForDynamicTable(array $objects, Register $registe ); $preparedObject['_updated'] = $now->format('Y-m-d H:i:s'); + $preparedObject = $this->withExpiry(prepared: $preparedObject, object: $object, selfData: $selfData); $preparedObject['_name'] = $selfData['name'] ?? $object['name'] ?? null; $preparedObject['_description'] = $selfData['description'] ?? $object['description'] ?? null; @@ -224,6 +226,39 @@ private function prepareObjectsForDynamicTable(array $objects, Register $registe return $prepared; }//end prepareObjectsForDynamicTable() + /** + * Copy an object's expiry into the prepared row's `_expires` column, when it has one. + * + * An expiry is METADATA, not a property: it lives in the metadata block + * (`@self`, or the flat selfData shape whose properties sit under `object`) + * and lands in the indexed `_expires` column that purgeExpired() sweeps. It + * is deliberately NOT read from a bare property map: a schema is free to + * declare its own `expires` property, and that must stay a plain data + * column rather than silently scheduling the row for deletion. A row that + * carries no expiry gets NO `_expires` key, so an upsert of an existing row + * leaves its expiry alone; a row inserted without one keeps NULL and is + * never purged. + * + * @param array $prepared The prepared row so far. + * @param array $object The object as passed to bulkUpsert(). + * @param array $selfData The object's metadata block. + * + * @return array The prepared row, with `_expires` set when the object carries an expiry. + * + * @spec openspec/specs/data-import-export/spec.md#requirement-raw-append-for-high-volume-writers + */ + private function withExpiry(array $prepared, array $object, array $selfData): array { + $hasMetadataBlock = (($object['@self'] ?? null) !== null || isset($object['object']) === true); + $expiresValue = $selfData['expires'] ?? null; + if ($expiresValue === null || $hasMetadataBlock === false) { + return $prepared; + } + + $prepared['_expires'] = $this->formatDateTimeForDatabase(value: $expiresValue, default: null); + + return $prepared; + }//end withExpiry() + /** * Calculate optimal chunk size for bulk operations * @@ -919,4 +954,50 @@ private function formatDateTimeForDatabase(mixed $value, ?string $default): ?str $formatted = $this->dateTimeNormalizer->formatForDatabase($value); return $formatted ?? $default; }//end formatDateTimeForDatabase() + + /** + * Hard-delete every row of a register+schema table whose `_expires` has passed. + * + * The counterpart of a raw append: rows written through `bulkUpsert()` with an + * expiry carry no audit trail, so there is nothing to tombstone and nothing to + * soft-delete. They are simply removed. Rows with a NULL `_expires` are never + * touched, and the comparison is done by the database clock (`NOW()`), the + * same clock that `SearchTrailMapper::clearLogs()` sweeps by, so a PHP/DB + * timezone mismatch cannot widen the window. + * + * @param Register $register The register context. + * @param Schema $schema The schema context. + * @param string $tableName The bare (unprefixed) table name to sweep. + * + * @return int The number of rows removed. + * + * @throws \OCP\DB\Exception If the delete fails. + * + * @spec openspec/specs/data-import-export/spec.md#requirement-raw-append-for-high-volume-writers + */ + public function purgeExpired(Register $register, Schema $schema, string $tableName): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($tableName) + ->where($qb->expr()->eq('_register', $qb->createNamedParameter($register->getId(), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('_schema', $qb->createNamedParameter($schema->getId(), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNotNull('_expires')) + ->andWhere($qb->expr()->lt('_expires', $qb->createFunction('NOW()'))); + + $purged = $qb->executeStatement(); + + // Info, not debug: this is destruction, and the count is the only record of it. + $this->logger->info( + message: '[MagicBulkHandler] Purged expired raw rows', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'register' => $register->getId(), + 'schema' => $schema->getId(), + 'table' => $tableName, + 'purged' => $purged, + ] + ); + + return $purged; + }//end purgeExpired() }//end class diff --git a/lib/Db/MagicMapper/MagicSearchHandler.php b/lib/Db/MagicMapper/MagicSearchHandler.php index 5aa634d51f..0f5fee5855 100644 --- a/lib/Db/MagicMapper/MagicSearchHandler.php +++ b/lib/Db/MagicMapper/MagicSearchHandler.php @@ -79,7 +79,7 @@ class MagicSearchHandler { * * @var string[] */ - private const COMPARISON_OPERATORS = ['gte', 'lte', 'gt', 'lt', 'in', 'notIn', 'ne']; + private const COMPARISON_OPERATORS = ['gte', 'lte', 'gt', 'lt', 'in', 'notIn', 'ne', 'isnull']; /** * Metadata columns every magic table carries. @@ -738,6 +738,24 @@ private function buildSearchConditionSql( return '(' . implode(' OR ', $searchConditions) . ')'; }//end buildSearchConditionSql() + /** + * Whether an `isnull` operator value asks for IS NULL rather than IS NOT NULL. + * + * The operator is documented as a QUERY-STRING filter (`?afgehandeld_op_isnull=true`, + * openspec/specs/zoeken-filteren), and a query string can only ever deliver + * strings — so a strict `=== true` comparison against a PHP boolean can never + * match, and reading the value as raw truthiness would make the string "false" + * mean true. `FILTER_VALIDATE_BOOLEAN` accepts true/"true"/"1"/1/"on"/"yes" and + * reads everything else, "false"/"0"/"" included, as false. + * + * @param mixed $value The raw operator value as the request layer delivered it. + * + * @return bool True when the caller asked for IS NULL. + */ + private function isNullOperatorAsksForNull(mixed $value): bool { + return filter_var($value, FILTER_VALIDATE_BOOLEAN) === true; + }//end isNullOperatorAsksForNull() + /** * Build object field filter SQL conditions for non-reserved query parameters * @@ -885,6 +903,18 @@ private function buildObjectFilterConditionsSql( ); $conditions[] = "{$colRef} <> " . $connection->quote((string)$value['ne']); } + + if (isset($value['isnull']) === true) { + // No column-ref cast: a null check is about the column's + // presence, not its type, and casting a NULL would only + // obscure it. + $predicate = 'IS NOT NULL'; + if ($this->isNullOperatorAsksForNull(value: $value['isnull']) === true) { + $predicate = 'IS NULL'; + } + + $conditions[] = "{$quotedCol} {$predicate}"; + } } elseif (empty($value) === false) { $colRef = $this->buildFilterColumnRef( columnRef: $quotedCol, @@ -1031,29 +1061,70 @@ private function buildMetadataFilterConditionsSql( * @return string[] Array of SQL conditions */ private function buildMetadataOperatorConditionsSql(string $column, array $value, object $connection): array { - $quoteList = static function (mixed $values) use ($connection): array { - if (is_array($values) === false) { - $values = [$values]; - } - - return array_map( - static fn (mixed $item): string => $connection->quote((string)$item), - $values - ); - }; - // No operator key at all: a bare list keeps its historical IN(...) meaning. if (empty(array_intersect(array_keys($value), self::COMPARISON_OPERATORS)) === true) { - $quoted = $quoteList($value); - if (empty($quoted) === true) { - // An empty IN () is a syntax error and matches nothing by definition. - return ['1 = 0']; - } + return [$this->metadataInConditionSql(column: $column, values: $value, connection: $connection)]; + } - return [$column . ' IN (' . implode(', ', $quoted) . ')']; + // Three families, in the order they have always been emitted. They were + // one method until phpmd counted 486 paths through it; each family is + // independent of the others, so splitting them changes nothing but the + // number of ways to read the code. + return array_merge( + $this->metadataComparisonConditionsSql(column: $column, value: $value, connection: $connection), + $this->metadataListConditionsSql(column: $column, value: $value, connection: $connection), + $this->metadataNullConditionsSql(column: $column, value: $value) + ); + }//end buildMetadataOperatorConditionsSql() + + /** + * Quote every value in a bag, accepting a bare scalar as a one-item list + * + * @param mixed $values A list of values, or a single value + * @param object $connection Database connection for value quoting + * + * @return string[] The quoted values + */ + private function quoteMetadataValues(mixed $values, object $connection): array { + if (is_array($values) === false) { + $values = [$values]; } - $conditions = []; + return array_map( + static fn (mixed $item): string => $connection->quote((string)$item), + $values + ); + }//end quoteMetadataValues() + + /** + * `IN (...)` for one bag, or the always-false condition an empty bag means + * + * @param string $column Quoted column identifier + * @param mixed $values A list of values, or a single value + * @param object $connection Database connection for value quoting + * + * @return string One SQL condition + */ + private function metadataInConditionSql(string $column, mixed $values, object $connection): string { + $quoted = $this->quoteMetadataValues(values: $values, connection: $connection); + if (empty($quoted) === true) { + // An empty IN () is a syntax error and matches nothing by definition. + return '1 = 0'; + } + + return $column . ' IN (' . implode(', ', $quoted) . ')'; + }//end metadataInConditionSql() + + /** + * The scalar comparison operators, in their emitted order + * + * @param string $column Quoted column identifier + * @param array $value Operator bag + * @param object $connection Database connection for value quoting + * + * @return string[] Array of SQL conditions + */ + private function metadataComparisonConditionsSql(string $column, array $value, object $connection): array { $simple = [ 'gte' => '>=', 'lte' => '<=', @@ -1062,23 +1133,33 @@ private function buildMetadataOperatorConditionsSql(string $column, array $value 'ne' => '<>', ]; + $conditions = []; foreach ($simple as $operator => $sqlOperator) { if (isset($value[$operator]) === true) { $conditions[] = "{$column} {$sqlOperator} " . $connection->quote((string)$value[$operator]); } } + return $conditions; + }//end metadataComparisonConditionsSql() + + /** + * The `in` and `notIn` operators + * + * @param string $column Quoted column identifier + * @param array $value Operator bag + * @param object $connection Database connection for value quoting + * + * @return string[] Array of SQL conditions + */ + private function metadataListConditionsSql(string $column, array $value, object $connection): array { + $conditions = []; if (isset($value['in']) === true) { - $quoted = $quoteList($value['in']); - if (empty($quoted) === true) { - $conditions[] = '1 = 0'; - } else { - $conditions[] = $column . ' IN (' . implode(', ', $quoted) . ')'; - } + $conditions[] = $this->metadataInConditionSql(column: $column, values: $value['in'], connection: $connection); } if (isset($value['notIn']) === true) { - $quoted = $quoteList($value['notIn']); + $quoted = $this->quoteMetadataValues(values: $value['notIn'], connection: $connection); // An empty NOT IN () is a syntax error and means "exclude nothing". if (empty($quoted) === false) { $conditions[] = $column . ' NOT IN (' . implode(', ', $quoted) . ')'; @@ -1086,7 +1167,28 @@ private function buildMetadataOperatorConditionsSql(string $column, array $value } return $conditions; - }//end buildMetadataOperatorConditionsSql() + }//end metadataListConditionsSql() + + /** + * The `isnull` operator + * + * @param string $column Quoted column identifier + * @param array $value Operator bag + * + * @return string[] Array of SQL conditions + */ + private function metadataNullConditionsSql(string $column, array $value): array { + if (isset($value['isnull']) === false) { + return []; + } + + $predicate = 'IS NOT NULL'; + if ($this->isNullOperatorAsksForNull(value: $value['isnull']) === true) { + $predicate = 'IS NULL'; + } + + return [$column . ' ' . $predicate]; + }//end metadataNullConditionsSql() /** * Build SQL conditions for TMLO metadata JSON field filters. @@ -1477,6 +1579,14 @@ private function applyMetadataOperators(IQueryBuilder $qb, string $columnRef, ar ); } } + + if (isset($value['isnull']) === true) { + if ($this->isNullOperatorAsksForNull(value: $value['isnull']) === true) { + $qb->andWhere($qb->expr()->isNull($columnRef)); + } else { + $qb->andWhere($qb->expr()->isNotNull($columnRef)); + } + } }//end applyMetadataOperators() /** @@ -1696,6 +1806,17 @@ private function applyObjectFilters(IQueryBuilder $qb, array $filters, Schema $s $qb->andWhere($qb->expr()->neq($columnRef, $qb->createNamedParameter($value['ne']))); } + if (isset($value['isnull']) === true) { + // The bare column, not `buildFilterColumnRef`: a null check + // asks whether the column has a value at all, and a cast + // would only obscure that. + if ($this->isNullOperatorAsksForNull(value: $value['isnull']) === true) { + $qb->andWhere($qb->expr()->isNull("t.{$columnName}")); + } else { + $qb->andWhere($qb->expr()->isNotNull("t.{$columnName}")); + } + } + continue; }//end if diff --git a/lib/Db/ObjectEntity.php b/lib/Db/ObjectEntity.php index 136472ab4d..5a631429d4 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,9 +265,34 @@ 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. * + * ⚠️ This property defaults to `[]`, NOT `null`, and the row hydrator + * (`MagicStatisticsHandler::convertRowToObjectEntity()`) skips every NULL + * column rather than calling its setter — so a LIVE object never has + * `setDeleted()` called on it and keeps this default. `getDeleted() === null` + * is therefore false for every object that has ever existed, and a guard + * written that way does not guard. Ask {@see self::isSoftDeleted()} instead. + * * @var array|null Array describing deletion details */ protected ?array $deleted = []; @@ -694,6 +721,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 +765,7 @@ protected function getter(string $name): mixed { 'relations', 'authorization', 'validation', + 'quality', 'deleted', 'groups', 'geo', @@ -1004,6 +1033,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(), @@ -1093,20 +1123,176 @@ private function getFormattedDate(?DateTime $date): ?string { return $date->format('c'); }//end getFormattedDate() + /** + * Holder kind for a lock taken by a flow run. + * + * @var string + */ + public const LOCK_KIND_RUN = 'run'; + + /** + * Holder kind for a lock taken by a person. + * + * A payload with no `kind` at all is read as this one, which is how every + * lock written before run-scoped locking keeps its meaning without a + * migration or a back-fill. No such record can be misclassified, because + * no run has ever been able to take a lock. + * + * @var string + */ + public const LOCK_KIND_USER = 'user'; + + /** + * Whether the live lock on this object is held AGAINST the given caller. + * + * This is the ONE production predicate for lock ownership. Every write + * guard, every unlock authorization and every flow node calls it, and + * nothing restates the comparison, tests included. It used to be spelled + * three different ways in three places and two of them were wrong. + * + * The rules: + * - No live lock: held against nobody. An EXPIRED lock is not a lock. + * - User lock: held against everyone whose uid differs from the recorded + * one. This is the historical behaviour, unchanged. + * - Run lock: held against everyone whose run uuid differs from the + * recorded one, INCLUDING a caller presenting the run's own `runAs` + * user and no run uuid. That exclusion is the point: if the runAs user + * were admitted, a lock taken by a run under `alice` would be no + * obstacle to alice, and on an instance where flows run as one service + * account it would be no obstacle to anybody. + * - Run lock with no `runUuid`: held against everybody. A malformed lock + * fails CLOSED, because the alternative is silently converting a + * writer's bug into an open door. + * - User lock, run caller: held against the run. A run is not the person + * it runs as, in EITHER direction. Reading them as one holder let a run + * walk into a person's lock, take the extend branch and rewrite the + * payload as its own — and then release it at the end of the run. The + * person's lock was destroyed by a flow merely passing over the object, + * with no error and no audit of a displacement. + * + * @param string|null $userId The caller's user id, or null when anonymous. + * @param string|null $runUuid The caller's flow-run uuid, when the caller is a run. + * + * @return bool True when the caller must be refused. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-ownership-is-decided-by-one-predicate + */ + public function isLockedBySomeoneElse(?string $userId, ?string $runUuid = null): bool { + if ($this->isLocked() === false) { + return false; + } + + $lock = ($this->locked ?? []); + $kind = ($lock['kind'] ?? self::LOCK_KIND_USER); + + if ($kind === self::LOCK_KIND_RUN) { + $holder = ($lock['runUuid'] ?? null); + if (is_string($holder) === false || trim($holder) === '') { + // Malformed run lock: admit nobody. + return true; + } + + return ($runUuid === null || trim($runUuid) !== trim($holder)); + } + + // A USER lock and a caller acting for a run: different holders, so the + // lock is held against it. The kinds are compared BEFORE the user id + // because they are what distinguishes the holders — a run under + // `alice` matching a lock alice took is exactly the confusion that let + // a flow take over a person's lock. + if ($runUuid !== null && trim($runUuid) !== '') { + return true; + } + + return (($lock['user'] ?? null) !== $userId); + }//end isLockedBySomeoneElse() + + /** + * Describe the live lock's holder for a refusal message. + * + * A refusal that does not say who holds the lock leaves the reader with + * nowhere to go, which is why the message is built here rather than at + * each of the four guards. + * + * @return string|null The holder description, or null when not locked. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-refuses-a-write-and-names-its-holder + */ + public function describeLockHolder(): ?string { + if ($this->isLocked() === false) { + return null; + } + + $lock = ($this->locked ?? []); + if (($lock['kind'] ?? self::LOCK_KIND_USER) !== self::LOCK_KIND_RUN) { + return (string)($lock['user'] ?? 'another user'); + } + + $run = trim((string)($lock['runUuid'] ?? '')); + if ($run === '') { + return 'a flow run that did not record its identity'; + } + + $runAs = trim((string)($lock['user'] ?? '')); + if ($runAs === '') { + return sprintf('flow run %s', $run); + } + + return sprintf('flow run %s, running as %s', $run, $runAs); + }//end describeLockHolder() + + /** + * Get the flow run holding this object, when a run holds it. + * + * @return string|null The holding run's uuid, or null when the lock is a + * user lock, malformed, expired or absent. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-records-whether-a-run-or-a-person-holds-it + */ + public function getLockedByRun(): ?string { + if ($this->isLocked() === false) { + return null; + } + + $lock = ($this->locked ?? []); + if (($lock['kind'] ?? self::LOCK_KIND_USER) !== self::LOCK_KIND_RUN) { + return null; + } + + $run = trim((string)($lock['runUuid'] ?? '')); + if ($run === '') { + return null; + } + + return $run; + }//end getLockedByRun() + /** * Lock the object for a specific duration * + * Passing `$runUuid` takes a RUN-scoped lock: one that refuses every + * caller but that run, the run's own `runAs` user included. Omitting it + * takes the user lock this method has always taken. + * * @param IUserSession $userSession Current user session * @param string|null $process Optional process identifier * @param int|null $duration Lock duration in seconds (default: 1 hour) + * @param string|null $runUuid Flow run taking the lock, for a run-scoped lock * - * @throws Exception If object is already locked by another user + * @throws Exception If the object is already locked by another holder * * @return true True if lock was successful * * @psalm-suppress PossiblyUnusedReturnValue + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-ownership-is-decided-by-one-predicate */ - public function lock(IUserSession $userSession, ?string $process = null, ?int $duration = 3600): bool { + public function lock( + IUserSession $userSession, + ?string $process = null, + ?int $duration = 3600, + ?string $runUuid = null, + ): bool { $currentUser = $userSession->getUser(); if ($currentUser === null) { throw new Exception('No user logged in'); @@ -1115,24 +1301,35 @@ public function lock(IUserSession $userSession, ?string $process = null, ?int $d $userId = $currentUser->getUID(); $now = new DateTime(); - // If already locked, check if it's the same user and not expired. + $kind = self::LOCK_KIND_USER; + if ($runUuid !== null && trim($runUuid) !== '') { + $kind = self::LOCK_KIND_RUN; + $runUuid = trim($runUuid); + } + + // If already locked, check whether this caller is the holder. if ($this->isLocked() === true) { $lock = $this->getLocked(); if ($lock === null) { throw new Exception('Lock data is missing'); } - // If locked by different user. - if ($lock['user'] !== $userId) { - throw new Exception('Object is locked by another user'); + // Held against this caller. Note that two runs under ONE user land + // here now: ownership is no longer keyed on the user alone, so the + // second run is refused instead of silently extending the first's + // lock and being handed the object. + if ($this->isLockedBySomeoneElse(userId: $userId, runUuid: $runUuid) === true) { + throw new Exception(sprintf('Object is locked by %s', (string)$this->describeLockHolder())); } - // If same user, extend the lock. + // Same holder: extend the lock. $newExpiration = clone $now; $newExpiration->add(new DateInterval('PT' . ($duration ?? 0) . 'S')); $this->setLocked( [ + 'kind' => $kind, + 'runUuid' => ($runUuid ?? null), 'user' => $userId, 'process' => ($process ?? $lock['process']), 'created' => $lock['created'], @@ -1149,6 +1346,8 @@ public function lock(IUserSession $userSession, ?string $process = null, ?int $d $this->setLocked( [ + 'kind' => $kind, + 'runUuid' => ($runUuid ?? null), 'user' => $userId, 'process' => $process, 'created' => $now->format('c'), @@ -1164,32 +1363,50 @@ public function lock(IUserSession $userSession, ?string $process = null, ?int $d * Unlock the object * * @param IUserSession $userSession Current user session + * @param string|null $runUuid Flow run releasing the lock, for a run-scoped lock + * @param bool $break Release the lock regardless of who holds it. Reserved + * for the administrator break-lock, which audits the + * displacement at its call site. * - * @throws Exception If object is locked by another user + * @throws Exception If the object is locked by another holder * * @return true True if unlock was successful * * @psalm-suppress PossiblyUnusedReturnValue + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The break flag follows the established RBAC-bypass pattern. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-ownership-is-decided-by-one-predicate */ - public function unlock(IUserSession $userSession): bool { + public function unlock(IUserSession $userSession, ?string $runUuid = null, bool $break = false): bool { if ($this->isLocked() === false) { return true; } $currentUser = $userSession->getUser(); - if ($currentUser === null) { + if ($currentUser === null && $break === false) { + // A break has no session requirement. The engine releases a run's + // locks from a terminal-event listener and from the cron sweep, + // and neither has a session: `occ` and background jobs run as + // nobody. Requiring a user there would mean the release layers + // that exist for crashed runs could never fire, which is the + // whole case they were built for. Authorization for a break lives + // at the call site (administrator, or the engine releasing a lock + // whose holding run it has already matched). throw new Exception('No user logged in'); } - $userId = $currentUser->getUID(); + $userId = $currentUser?->getUID(); // Check if locked by different user. if ($this->locked === null) { throw new Exception('Object is not locked'); } - if ($this->locked['user'] !== $userId) { - throw new Exception('Object is locked by another user'); + if ($break === false + && $this->isLockedBySomeoneElse(userId: $userId, runUuid: $runUuid) === true + ) { + throw new Exception(sprintf('Object is locked by %s', (string)$this->describeLockHolder())); } $this->setLocked(null); @@ -1295,6 +1512,23 @@ public function delete(IUserSession $userSession, ?string $deletedReason = null, return $this; }//end delete() + /** + * Whether this object is in the trash. + * + * The one honest answer to "has this been soft-deleted?". The raw + * `getDeleted()` accessor cannot be compared with `null`: the property + * defaults to `[]` and the hydrator never overwrites that default for a + * live row, so `getDeleted() === null` is false for every object, deleted + * or not. Every guard that means "already in the trash" MUST call this. + * + * @return bool True when the object carries deletion metadata. + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + public function isSoftDeleted(): bool { + return $this->deleted !== null && $this->deleted !== []; + }//end isSoftDeleted() + /** * Get the last log entry for this object (runtime only) * diff --git a/lib/Db/Organisation.php b/lib/Db/Organisation.php index 237c2eb77a..0ec9cf1fdc 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'); @@ -692,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; @@ -990,6 +1053,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/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/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/Db/RunObjectLock.php b/lib/Db/RunObjectLock.php new file mode 100644 index 0000000000..646a45f361 --- /dev/null +++ b/lib/Db/RunObjectLock.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/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * One run-held object lock. + * + * @method string|null getRunUuid() + * @method void setRunUuid(?string $runUuid) + * @method string|null getObjectUuid() + * @method void setObjectUuid(?string $objectUuid) + * @method string|null getRegisterId() + * @method void setRegisterId(?string $registerId) + * @method string|null getSchemaId() + * @method void setSchemaId(?string $schemaId) + * @method string|null getNodeId() + * @method void setNodeId(?string $nodeId) + * @method DateTime|null getLockedAt() + * @method void setLockedAt(?DateTime $lockedAt) + * @method DateTime|null getExpiresAt() + * @method void setExpiresAt(?DateTime $expiresAt) + */ +class RunObjectLock extends Entity implements JsonSerializable { + + /** + * The run holding the lock. + * + * @var string|null + */ + protected ?string $runUuid = null; + + /** + * The locked object. + * + * @var string|null + */ + protected ?string $objectUuid = null; + + /** + * The object's register, so a release can resolve it without a scan. + * + * @var string|null + */ + protected ?string $registerId = null; + + /** + * The object's schema, for the same reason. + * + * @var string|null + */ + protected ?string $schemaId = null; + + /** + * The flow node that took the lock, so an operator can see WHICH step wedged. + * + * @var string|null + */ + protected ?string $nodeId = null; + + /** + * When the lock was taken. + * + * @var DateTime|null + */ + protected ?DateTime $lockedAt = null; + + /** + * When the underlying lock expires on its own. The sweep's second read. + * + * @var DateTime|null + */ + protected ?DateTime $expiresAt = null; + + /** + * Constructor. + */ + public function __construct() { + $this->addType(fieldName: 'runUuid', type: 'string'); + $this->addType(fieldName: 'objectUuid', type: 'string'); + $this->addType(fieldName: 'registerId', type: 'string'); + $this->addType(fieldName: 'schemaId', type: 'string'); + $this->addType(fieldName: 'nodeId', type: 'string'); + $this->addType(fieldName: 'lockedAt', type: 'datetime'); + $this->addType(fieldName: 'expiresAt', type: 'datetime'); + }//end __construct() + + /** + * JSON shape. + * + * @return array The row as an array. + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'runUuid' => $this->runUuid, + 'objectUuid' => $this->objectUuid, + 'registerId' => $this->registerId, + 'schemaId' => $this->schemaId, + 'nodeId' => $this->nodeId, + 'lockedAt' => $this->lockedAt?->format('c'), + 'expiresAt' => $this->expiresAt?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/RunObjectLockMapper.php b/lib/Db/RunObjectLockMapper.php new file mode 100644 index 0000000000..6820f01344 --- /dev/null +++ b/lib/Db/RunObjectLockMapper.php @@ -0,0 +1,226 @@ + + * 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/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + +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; + +/** + * Run-held object locks. + * + * @template-extends QBMapper + */ +class RunObjectLockMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db Database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct( + db: $db, + tableName: 'openregister_run_object_locks', + entityClass: RunObjectLock::class + ); + }//end __construct() + + /** + * Record that a run holds a lock, or refresh the record it already has. + * + * A run re-locking an object it already holds extends the lock rather + * than taking a second one, so the registry must behave the same way and + * not trip its own unique index. + * + * @param RunObjectLock $lock The lock to record. + * + * @return void + * + * @throws DbException On any database failure other than the duplicate. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + public function record(RunObjectLock $lock): void { + try { + $this->insert(entity: $lock); + return; + } catch (DbException $e) { + if ($e->getReason() !== DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + } + + // Already recorded: refresh the expiry, which the extend branch moved. + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('expires_at', $qb->createNamedParameter($lock->getExpiresAt(), IQueryBuilder::PARAM_DATETIME_MUTABLE)) + ->set('node_id', $qb->createNamedParameter($lock->getNodeId())) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($lock->getRunUuid()))) + ->andWhere($qb->expr()->eq('object_uuid', $qb->createNamedParameter($lock->getObjectUuid()))); + $qb->executeStatement(); + }//end record() + + /** + * Every lock one run holds. + * + * @param string $runUuid The run. + * + * @return array The run's locks. + */ + public function findByRun(string $runUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))); + + return $this->findEntities(query: $qb); + }//end findByRun() + + /** + * Forget every lock one run holds. + * + * @param string $runUuid The run. + * + * @return int Rows deleted. + */ + public function forgetRun(string $runUuid): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))); + + return $qb->executeStatement(); + }//end forgetRun() + + /** + * Forget one run's lock on one object. + * + * @param string $runUuid The run. + * @param string $objectUuid The object. + * + * @return int Rows deleted. + */ + public function forget(string $runUuid, string $objectUuid): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('run_uuid', $qb->createNamedParameter($runUuid))) + ->andWhere($qb->expr()->eq('object_uuid', $qb->createNamedParameter($objectUuid))); + + return $qb->executeStatement(); + }//end forget() + + /** + * Locks whose holding run is terminal, gone, or whose lock has expired. + * + * The sweep's read. One indexed query against the runs table rather than + * a scan across every magic table on the instance. + * + * A lock is orphaned when ANY of these hold: + * - its run row no longer exists (retention deleted it, or it was never + * written), so no terminal event can ever fire for it; + * - its run reached a terminal status without the release landing; + * - the underlying lock has expired anyway, so the row is stale + * bookkeeping for a lock that already blocks nobody. + * + * @param DateTime $now The sweep's clock. + * @param int $limit Batch ceiling. + * + * @return array The orphaned locks. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + public function findOrphaned(DateTime $now, int $limit = 100): array { + // TWO plain queries rather than one composite. + // + // The obvious single query is an orX() of a `run_uuid NOT IN + // (sub-select)` built with createFunction() and an `expires_at <` + // comparison. On PostgreSQL that composes to `(a) OR (b)` where the + // createFunction half is not recognised as a boolean, and the whole + // sweep dies with "argument of OR must be type boolean, not type + // record" — which the sweep's own catch would have swallowed into a + // log line while releasing nothing, forever. Two readable queries and + // a merge cost one extra round trip per cron tick and cannot fail + // that way. + $byRun = $this->findNotHeldByAnActiveRun(limit: $limit); + $byExpiry = $this->findExpired(now: $now, limit: $limit); + + $merged = []; + foreach (array_merge($byRun, $byExpiry) as $row) { + $merged[(string)$row->getId()] = $row; + } + + return array_slice(array_values($merged), 0, $limit); + }//end findOrphaned() + + /** + * Locks whose holding run is terminal or no longer exists. + * + * @param int $limit Batch ceiling. + * + * @return array The rows. + */ + private function findNotHeldByAnActiveRun(int $limit): array { + $qb = $this->db->getQueryBuilder(); + $active = $this->db->getQueryBuilder(); + $active->select('uuid') + ->from('openregister_flow_runs') + ->where( + $active->expr()->in( + 'status', + $qb->createNamedParameter(FlowRun::ACTIVE, IQueryBuilder::PARAM_STR_ARRAY) + ) + ); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->createFunction('run_uuid NOT IN (' . $active->getSQL() . ')')) + ->setMaxResults($limit); + + return $this->findEntities(query: $qb); + }//end findNotHeldByAnActiveRun() + + /** + * Rows whose underlying lock has expired anyway. + * + * @param DateTime $now The sweep's clock. + * @param int $limit Batch ceiling. + * + * @return array The rows. + */ + private function findExpired(DateTime $now, int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->lt( + 'expires_at', + $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE) + ) + ) + ->setMaxResults($limit); + + return $this->findEntities(query: $qb); + }//end findExpired() +}//end class diff --git a/lib/Db/ScheduledWorkflow.php b/lib/Db/ScheduledWorkflow.php deleted file mode 100644 index e2deeb6808..0000000000 --- a/lib/Db/ScheduledWorkflow.php +++ /dev/null @@ -1,233 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use JsonSerializable; -use OCP\AppFramework\Db\Entity; - -/** - * Entity class representing a scheduled workflow configuration. - * - * @method string|null getUuid() - * @method void setUuid(?string $uuid) - * @method string|null getName() - * @method void setName(?string $name) - * @method string|null getEngine() - * @method void setEngine(?string $engine) - * @method string|null getWorkflowId() - * @method void setWorkflowId(?string $workflowId) - * @method int|null getRegisterId() - * @method void setRegisterId(?int $registerId) - * @method int|null getSchemaId() - * @method void setSchemaId(?int $schemaId) - * @method int getIntervalSec() - * @method void setIntervalSec(int $intervalSec) - * @method bool getEnabled() - * @method void setEnabled(bool $enabled) - * @method string|null getPayload() - * @method void setPayload(?string $payload) - * @method DateTime|null getLastRun() - * @method void setLastRun(?DateTime $lastRun) - * @method string|null getLastStatus() - * @method void setLastStatus(?string $lastStatus) - * @method DateTime|null getCreated() - * @method void setCreated(?DateTime $created) - * @method DateTime|null getUpdated() - * @method void setUpdated(?DateTime $updated) - * - * @psalm-suppress PropertyNotSetInConstructor - */ -class ScheduledWorkflow extends Entity implements JsonSerializable { - - /** - * The uuid. - * - * @var string|null - */ - protected ?string $uuid = null; - - /** - * The name. - * - * @var string|null - */ - protected ?string $name = null; - - /** - * The engine. - * - * @var string|null - */ - protected ?string $engine = null; - - /** - * The workflow id. - * - * @var string|null - */ - protected ?string $workflowId = null; - - /** - * The register id. - * - * @var integer|null - */ - protected ?int $registerId = null; - - /** - * The schema id. - * - * @var integer|null - */ - protected ?int $schemaId = null; - - /** - * The interval sec. - * - * @var integer - */ - protected int $intervalSec = 86400; - - /** - * The enabled. - * - * @var boolean - */ - protected bool $enabled = true; - - /** - * The payload. - * - * @var string|null - */ - protected ?string $payload = null; - - /** - * The last run. - * - * @var DateTime|null - */ - protected ?DateTime $lastRun = null; - - /** - * The last status. - * - * @var string|null - */ - protected ?string $lastStatus = null; - - /** - * The created. - * - * @var DateTime|null - */ - protected ?DateTime $created = null; - - /** - * The updated. - * - * @var DateTime|null - */ - protected ?DateTime $updated = null; - - /** - * Constructor for ScheduledWorkflow entity. - */ - public function __construct() { - $this->addType(fieldName: 'uuid', type: 'string'); - $this->addType(fieldName: 'name', type: 'string'); - $this->addType(fieldName: 'engine', type: 'string'); - $this->addType(fieldName: 'workflowId', type: 'string'); - $this->addType(fieldName: 'registerId', type: 'integer'); - $this->addType(fieldName: 'schemaId', type: 'integer'); - $this->addType(fieldName: 'intervalSec', type: 'integer'); - $this->addType(fieldName: 'enabled', type: 'boolean'); - $this->addType(fieldName: 'payload', type: 'string'); - $this->addType(fieldName: 'lastRun', type: 'datetime'); - $this->addType(fieldName: 'lastStatus', type: 'string'); - $this->addType(fieldName: 'created', type: 'datetime'); - $this->addType(fieldName: 'updated', type: 'datetime'); - }//end __construct() - - /** - * Hydrate entity from array. - * - * @param array $object Data to hydrate from - * - * @return self - */ - public function hydrate(array $object): self { - $fields = [ - 'uuid', - 'name', - 'engine', - 'workflowId', - 'registerId', - 'schemaId', - 'intervalSec', - 'enabled', - 'payload', - 'lastRun', - 'lastStatus', - 'created', - 'updated', - ]; - - foreach ($object as $key => $value) { - if (in_array($key, $fields, true) === true) { - $setter = 'set' . ucfirst($key); - $this->$setter($value); - } - } - - return $this; - }//end hydrate() - - /** - * Serialize to JSON. - * - * @return array - */ - public function jsonSerialize(): array { - $payload = null; - if ($this->payload !== null) { - $payload = json_decode($this->payload, true); - } - - return [ - 'id' => $this->id, - 'uuid' => $this->uuid, - 'name' => $this->name, - 'engine' => $this->engine, - 'workflowId' => $this->workflowId, - 'registerId' => $this->registerId, - 'schemaId' => $this->schemaId, - 'intervalSec' => $this->intervalSec, - 'enabled' => $this->enabled, - 'payload' => $payload, - 'lastRun' => $this->lastRun?->format('c'), - 'lastStatus' => $this->lastStatus, - 'created' => $this->created?->format('c'), - 'updated' => $this->updated?->format('c'), - ]; - }//end jsonSerialize() -}//end class diff --git a/lib/Db/ScheduledWorkflowMapper.php b/lib/Db/ScheduledWorkflowMapper.php deleted file mode 100644 index c0aa2b91d7..0000000000 --- a/lib/Db/ScheduledWorkflowMapper.php +++ /dev/null @@ -1,151 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use OCP\AppFramework\Db\QBMapper; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; -use Symfony\Component\Uid\Uuid; - -/** - * Mapper for ScheduledWorkflow entities. - * - * @extends QBMapper - */ -class ScheduledWorkflowMapper extends QBMapper { - /** - * Constructor for ScheduledWorkflowMapper. - * - * @param IDBConnection $db Database connection - */ - public function __construct(IDBConnection $db) { - parent::__construct( - db: $db, - tableName: 'openregister_scheduled_workflows', - entityClass: ScheduledWorkflow::class - ); - }//end __construct() - - /** - * Find a scheduled workflow by ID. - * - * @param int $id Scheduled workflow ID - * - * @return ScheduledWorkflow - */ - public function find(int $id): ScheduledWorkflow { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter(value: $id, type: IQueryBuilder::PARAM_INT)) - ); - - return $this->findEntity(query: $qb); - }//end find() - - /** - * Find all scheduled workflows. - * - * @param int|null $limit Maximum results - * @param int|null $offset Offset for pagination - * - * @return array - */ - public function findAll(?int $limit = null, ?int $offset = null): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->orderBy('name', 'ASC'); - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - return $this->findEntities(query: $qb); - }//end findAll() - - /** - * Find all enabled scheduled workflows. - * - * @return array - */ - public function findAllEnabled(): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq( - 'enabled', - $qb->createNamedParameter(value: true, type: IQueryBuilder::PARAM_BOOL) - ) - ) - ->orderBy('name', 'ASC'); - - return $this->findEntities(query: $qb); - }//end findAllEnabled() - - /** - * Create a scheduled workflow from an array. - * - * @param array $data Workflow data - * - * @return ScheduledWorkflow - */ - public function createFromArray(array $data): ScheduledWorkflow { - $workflow = new ScheduledWorkflow(); - $workflow->hydrate($data); - - if ($workflow->getUuid() === null) { - $workflow->setUuid(Uuid::v4()->toRfc4122()); - } - - $now = new DateTime(); - $workflow->setCreated($now); - $workflow->setUpdated($now); - - return $this->insert(entity: $workflow); - }//end createFromArray() - - /** - * Update a scheduled workflow from an array. - * - * @param int $id Workflow ID - * @param array $data Updated data - * - * @return ScheduledWorkflow - */ - public function updateFromArray(int $id, array $data): ScheduledWorkflow { - $workflow = $this->find(id: $id); - $workflow->hydrate($data); - $workflow->setUpdated(new DateTime()); - - return $this->update(entity: $workflow); - }//end updateFromArray() -}//end class diff --git a/lib/Db/Schema.php b/lib/Db/Schema.php index 3b4aa1adb9..ea668514ad 100644 --- a/lib/Db/Schema.php +++ b/lib/Db/Schema.php @@ -2717,6 +2717,29 @@ public function isAppendOnly(): bool { return $this->appendOnly; }//end isAppendOnly() + /** + * Check whether this schema declares `x-openregister-archival`. + * + * An archival schema holds legally retained records: user-driven deletes are + * rejected with HTTP 403 and error code SCHEMA_ARCHIVAL_IMMUTABLE, and rows + * leave only through {@see \OCA\OpenRegister\BackgroundJob\ArchivalRetentionTask} + * when they pass their retention, or through the administrative + * `openregister:objects:purge --force` CLI path. + * + * This is the single definition of "is archival". Every gate MUST ask here + * rather than re-reading the annotation, so a second delete route cannot + * quietly disagree with the first. + * + * @return bool True if the schema declares the archival annotation. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + public function hasArchivalAnnotation(): bool { + $configuration = ($this->getConfiguration() ?? []); + + return is_array($configuration['x-openregister-archival'] ?? null); + }//end hasArchivalAnnotation() + /** * String representation of the schema * diff --git a/lib/Db/SchemaMapper.php b/lib/Db/SchemaMapper.php index 22d4a2b958..3d55328d04 100644 --- a/lib/Db/SchemaMapper.php +++ b/lib/Db/SchemaMapper.php @@ -561,6 +561,55 @@ public function findByApplicationAndSlug(string $slug, string $application): ?Sc return $this->resolveSchemaExtension(schema: Schema::fromRow($row)); }//end findByApplicationAndSlug() + /** + * EVERY schema an application owns under a slug, not just the first. + * + * `findByApplicationAndSlug()` caps at one row, which is correct where the + * pair is treated as unique. It is not unique in practice: the import unions + * schema ids and never removes one, so a descriptor edit that renames or + * retires a schema leaves the old row behind and a later import can add a + * second under the same pair. + * + * That is exactly the situation `openregister:schemas:prune-retired` exists + * to clean up, and reading one row per run meant it removed one of two and + * reported success. Measured on a live instance: opencatalogi owned two + * `document` rows (ids 39 and 40), the command pruned 40, said + * `Pruned=1, skipped=0`, and left 39 answering every lookup. + * + * @param string $slug The schema slug, matched case-insensitively. + * @param string $application The owning application id. + * + * @return array Every matching schema, oldest id first. + * + * @spec openspec/specs/schema-import/spec.md#requirement-a-schema-retired-from-a-descriptor-must-be-removable-from-the-instance + */ + public function findAllByApplicationAndSlug(string $slug, string $application): array { + $this->traceRead(method: 'findAllByApplicationAndSlug'); + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('openregister_schemas') + ->where( + $qb->expr()->eq( + $qb->func()->lower('slug'), + $qb->createNamedParameter(value: strtolower($slug), type: IQueryBuilder::PARAM_STR) + ) + ) + ->andWhere( + $qb->expr()->eq('application', $qb->createNamedParameter(value: $application, type: IQueryBuilder::PARAM_STR)) + ) + ->orderBy('id', 'ASC'); + + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + return array_map( + fn (array $row): Schema => $this->resolveSchemaExtension(schema: Schema::fromRow($row)), + $rows + ); + }//end findAllByApplicationAndSlug() + /** * Resolve a schema by slug, scoped to a set of schema ids. * diff --git a/lib/Db/SearchTrailMapper.php b/lib/Db/SearchTrailMapper.php index 129fe56f63..fe945e8bb4 100644 --- a/lib/Db/SearchTrailMapper.php +++ b/lib/Db/SearchTrailMapper.php @@ -1005,14 +1005,17 @@ public function setExpiryDate(int $retentionMs): int { // Get the query builder. $qb = $this->db->getQueryBuilder(); + // DATE_ADD is MySQL/MariaDB only. Now that the hourly LogCleanUpTask + // calls this, a MySQL-only expression would fail every hour on a + // PostgreSQL install, so the interval is spelled per platform. + $expiresExpression = sprintf('DATE_ADD(created, INTERVAL %d SECOND)', $retentionSeconds); + if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) { + $expiresExpression = sprintf("created + INTERVAL '%d seconds'", $retentionSeconds); + } + // Update search trails that don't have an expiry date set. $qb->update($this->getTableName()) - ->set( - 'expires', - $qb->createFunction( - sprintf('DATE_ADD(created, INTERVAL %d SECOND)', $retentionSeconds) - ) - ) + ->set('expires', $qb->createFunction($expiresExpression)) ->where($qb->expr()->isNull('expires')); // Execute the update and return number of affected rows. diff --git a/lib/Db/Task.php b/lib/Db/Task.php new file mode 100644 index 0000000000..a7f6a4065f --- /dev/null +++ b/lib/Db/Task.php @@ -0,0 +1,936 @@ + + * 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 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() + * @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 string|null getSequenceUuid() + * @method void setSequenceUuid(?string $sequenceUuid) + * @method integer|null getSequencePosition() + * @method void setSequencePosition(?int $sequencePosition) + * @method integer|null getLegacyStepId() + * @method void setLegacyStepId(?int $legacyStepId) + * @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, + ]; + + /** + * 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). + * + * 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. `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'; + + public const PERFORMER_GROUP = 'group'; + + public const PERFORMER_AGENT = 'agent'; + + 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. + * + * @var array + */ + public const PERFORMER_TYPES = [ + self::PERFORMER_USER, + self::PERFORMER_GROUP, + self::PERFORMER_AGENT, + self::PERFORMER_WORKER, + self::PERFORMER_EXTERNAL, + ]; + + /** + * 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; + + /** + * 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. + * + * @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; + + /** + * The sequence this task is a position of, when any + * (flow-approval-consolidation). Null for every task outside an ordered + * approval. + * + * @var string|null + */ + protected ?string $sequenceUuid = null; + + /** + * This task's ordinal within its sequence. Stable and unique per + * sequence, enforced by a unique index. + * + * @var integer|null + */ + protected ?int $sequencePosition = null; + + /** + * The legacy approval step this task was migrated from, when any. The + * reconciliation pair with `migrated_task_uuid` on the kept step row. + * + * @var integer|null + */ + protected ?int $legacyStepId = 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: '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'); + $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: 'sequenceUuid', type: 'string'); + $this->addType(fieldName: 'sequencePosition', type: 'integer'); + $this->addType(fieldName: 'legacyStepId', type: 'integer'); + $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'), + 'onTimeout' => $this->onTimeout, + 'onReject' => $this->onReject, + '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, + 'sequenceUuid' => $this->sequenceUuid, + 'sequencePosition' => $this->sequencePosition, + 'legacyStepId' => $this->legacyStepId, + '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..a27a7468d1 --- /dev/null +++ b/lib/Db/TaskInboxCriteria.php @@ -0,0 +1,122 @@ + + * 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 string|null $runUuid Restrict to the tasks one flow run raised. + * ANCHORS the read the way `objectUuid` does: + * the question is what the RUN asked, not what + * it asked ME, so the scope narrowing is + * dropped. Visibility is not — a caller still + * sees only tasks they hold a sanctioned + * relationship to, and for a run's own tasks + * that relationship is `requester`, which the + * engine stamps with the run's acting identity. + * @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 ?string $runUuid = 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..d12602580b --- /dev/null +++ b/lib/Db/TaskMapper.php @@ -0,0 +1,892 @@ + + * 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 OCA\OpenRegister\Event\TaskTerminalEvent; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; + +/** + * 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.TooManyMethods) Same rule as the public count + * below: one query per distinct question, and the sequence's ordinal read + * (flow-approval-consolidation) is the twenty-seventh question, not a + * design change. + * @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. + * @param IEventDispatcher|null $dispatcher Publishes task terminality + * ({@see TaskTerminalEvent}) so + * business timers are cancelled in + * the same operation. Nullable so + * the mapper stays constructible + * without a container. + */ + public function __construct( + IDBConnection $db, + private readonly ?IEventDispatcher $dispatcher = null, + ) { + 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()); + + $updated = parent::update(entity: $entity); + $this->announceTerminality(task: $updated); + + return $updated; + }//end update() + + /** + * Announce a terminal write (flow-business-timers D-9). + * + * Called from BOTH persistence paths — {@see update()} and + * {@see updateIfOpen()} — so the two choke points every terminal task + * write passes both dispatch, inside the caller's transaction, and a + * listener cancelling the task's business timers does so in the same + * operation that made the subject terminal. Idempotent listeners only: + * the event can fire more than once for one task. + * + * @param Task $task The task as persisted. + * + * @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 announceTerminality(Task $task): void { + if ($this->dispatcher === null || $task->isInTerminalState() === false) { + return; + } + + $this->dispatcher->dispatchTyped( + new TaskTerminalEvent(task: $task, committed: false) + ); + }//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. + * + * Ordinal order is the ONLY order that means anything to a sequence: + * never creation time, never id (flow-approval-consolidation). + * + * @param string $sequenceUuid The sequence uuid. + * + * @return array The sequence's tasks, lowest ordinal first. + * + * @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 findBySequence(string $sequenceUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('sequence_uuid', $qb->createNamedParameter($sequenceUuid))) + ->orderBy('sequence_position', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findBySequence() + + /** + * 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))); + + $won = ($qb->executeStatement() === 1); + if ($won === true) { + $this->announceTerminality(task: $task); + } + + return $won; + }//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); + $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 || $criteria->runUuid !== null) { + // The run anchor joins the object anchor for the same reason. A + // run that asked a resident through the portal DID ask somebody, + // and a run's task list that hid the ask would report the run as + // waiting on nothing. This is an audit view of one run, not an + // inbox and not a badge count, so the rule the exclusion exists + // to protect is untouched. + 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. + * + * @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 { + if ($criteria->runUuid !== null) { + // A RUN ANCHOR REPLACES THE SCOPE. The run's tab asks what this + // run asked, not what it asked me, and `scope` defaults to + // `assigned` — so leaving the narrowing in place would answer a + // different question and answer it with an empty list, which + // reads as "this run asked nobody". Visibility is deliberately + // NOT relaxed below: a caller still needs one of the sanctioned + // relationships, and for the tasks a run raised that + // relationship is `requester`, stamped by the engine with the + // run's acting identity. + return; + } + + 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))); + } + + if ($criteria->runUuid !== null) { + $qb->andWhere($qb->expr()->eq('run_uuid', $qb->createNamedParameter($criteria->runUuid))); + } + + // 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/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/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/Db/TaskSequence.php b/lib/Db/TaskSequence.php new file mode 100644 index 0000000000..2c3dcc05f8 --- /dev/null +++ b/lib/Db/TaskSequence.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-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-an-approval-is-an-ordered-task-sequence-with-one-position-enabled-at-a-time + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * Class TaskSequence + * + * @method string|null getUuid() + * @method void setUuid(?string $uuid) + * @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 string|null getAnchorObjectUuid() + * @method void setAnchorObjectUuid(?string $anchorObjectUuid) + * @method integer|null getRegisterId() + * @method void setRegisterId(?int $registerId) + * @method integer|null getSchemaId() + * @method void setSchemaId(?int $schemaId) + * @method string|null getChainKey() + * @method void setChainKey(?string $chainKey) + * @method string|null getRequesterId() + * @method void setRequesterId(?string $requesterId) + * @method array|null getResolvedTier() + * @method void setResolvedTier(?array $resolvedTier) + * @method integer|null getPositionCursor() + * @method void setPositionCursor(?int $positionCursor) + * @method string|null getStatus() + * @method void setStatus(?string $status) + * @method string|null getOutcome() + * @method void setOutcome(?string $outcome) + * @method string|null getRunUuid() + * @method void setRunUuid(?string $runUuid) + * @method string|null getNodeId() + * @method void setNodeId(?string $nodeId) + * @method DateTime|null getOpenedAt() + * @method void setOpenedAt(?DateTime $openedAt) + * @method DateTime|null getClosedAt() + * @method void setClosedAt(?DateTime $closedAt) + * + * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class + * + * @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 + * + * @SuppressWarnings(PHPMD.TooManyFields) One property per column of the + * sequence table, the same rule Task itself carries: an Entity mirrors its + * row, and hiding columns in a JSON bag would trade a countable shape for + * an invisible one. + */ +class TaskSequence extends Entity implements JsonSerializable { + + /** + * A sequence with a running position. + * + * @var string + */ + public const STATUS_RUNNING = 'running'; + + /** + * Every position completed with an approving outcome. + * + * @var string + */ + public const STATUS_COMPLETED = 'completed'; + + /** + * A position completed with a rejecting outcome. + * + * @var string + */ + public const STATUS_REJECTED = 'rejected'; + + /** + * Ended without a decision (a task was cancelled or terminated). + * + * @var string + */ + public const STATUS_TERMINATED = 'terminated'; + + /** + * The terminal statuses. A sequence in one of these is never re-opened. + * + * @var array + */ + public const TERMINAL_STATUSES = [ + self::STATUS_COMPLETED, + self::STATUS_REJECTED, + self::STATUS_TERMINATED, + ]; + + /** + * Public identifier. + * + * @var string|null + */ + protected ?string $uuid = null; + + /** + * The compiled template this sequence was provisioned from. + * + * @var string|null + */ + protected ?string $templateId = null; + + /** + * The template version at provisioning. + * + * @var integer|null + */ + protected ?int $templateVersion = null; + + /** + * The template content FROZEN at provisioning. All later evaluation + * reads this, never the live schema annotation. + * + * @var array|null + */ + protected ?array $templateSnapshot = null; + + /** + * The object this approval is about. + * + * @var string|null + */ + protected ?string $anchorObjectUuid = null; + + /** + * The anchor's register. + * + * @var integer|null + */ + protected ?int $registerId = null; + + /** + * The anchor's schema. + * + * @var integer|null + */ + protected ?int $schemaId = null; + + /** + * The declarative chain key this sequence executes. + * + * @var string|null + */ + protected ?string $chainKey = null; + + /** + * Who attempted the gated transition. Separation of duties refuses this + * identity as a decider unless the declaration opts out. + * + * @var string|null + */ + protected ?string $requesterId = null; + + /** + * The amount tier the sequence was opened under, FROZEN at provisioning + * so a mid-cycle amount edit cannot re-route a running approval. + * + * @var array|null + */ + protected ?array $resolvedTier = null; + + /** + * The ordinal of the currently enabled position. + * + * @var integer|null + */ + protected ?int $positionCursor = 1; + + /** + * running, completed, rejected or terminated. + * + * @var string|null + */ + protected ?string $status = null; + + /** + * The recorded outcome, resolved from the frozen snapshot. + * + * @var string|null + */ + protected ?string $outcome = null; + + /** + * Optional provenance: the run that opened this sequence. + * + * @var string|null + */ + protected ?string $runUuid = null; + + /** + * Optional provenance: the node that opened this sequence. + * + * @var string|null + */ + protected ?string $nodeId = null; + + /** + * When the sequence opened. A new attempt after a rejection is + * distinguishable from the rejected one by this timestamp. + * + * @var DateTime|null + */ + protected ?DateTime $openedAt = null; + + /** + * When the sequence reached a terminal status. + * + * @var DateTime|null + */ + protected ?DateTime $closedAt = null; + + /** + * Constructor: registers the column types. + */ + public function __construct() { + $this->addType(fieldName: 'uuid', type: 'string'); + $this->addType(fieldName: 'templateId', type: 'string'); + $this->addType(fieldName: 'templateVersion', type: 'integer'); + $this->addType(fieldName: 'templateSnapshot', type: 'json'); + $this->addType(fieldName: 'anchorObjectUuid', type: 'string'); + $this->addType(fieldName: 'registerId', type: 'integer'); + $this->addType(fieldName: 'schemaId', type: 'integer'); + $this->addType(fieldName: 'chainKey', type: 'string'); + $this->addType(fieldName: 'requesterId', type: 'string'); + $this->addType(fieldName: 'resolvedTier', type: 'json'); + $this->addType(fieldName: 'positionCursor', type: 'integer'); + $this->addType(fieldName: 'status', type: 'string'); + $this->addType(fieldName: 'outcome', type: 'string'); + $this->addType(fieldName: 'runUuid', type: 'string'); + $this->addType(fieldName: 'nodeId', type: 'string'); + $this->addType(fieldName: 'openedAt', type: 'datetime'); + $this->addType(fieldName: 'closedAt', type: 'datetime'); + }//end __construct() + + /** + * Whether this sequence is in a terminal status. + * + * @return bool True when terminal. + * + * @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 isTerminal(): bool { + return in_array((string)$this->status, self::TERMINAL_STATUSES, true); + }//end isTerminal() + + /** + * Serialize for API and log consumption. + * + * @return array The sequence as an array. + * + * @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 jsonSerialize(): array { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'templateId' => $this->templateId, + 'templateVersion' => $this->templateVersion, + 'templateSnapshot' => $this->templateSnapshot, + 'anchorObjectUuid' => $this->anchorObjectUuid, + 'registerId' => $this->registerId, + 'schemaId' => $this->schemaId, + 'chainKey' => $this->chainKey, + 'requesterId' => $this->requesterId, + 'resolvedTier' => $this->resolvedTier, + 'positionCursor' => $this->positionCursor, + 'status' => $this->status, + 'outcome' => $this->outcome, + 'runUuid' => $this->runUuid, + 'nodeId' => $this->nodeId, + 'openedAt' => $this->openedAt?->format('c'), + 'closedAt' => $this->closedAt?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/TaskSequenceMapper.php b/lib/Db/TaskSequenceMapper.php new file mode 100644 index 0000000000..bae3a38643 --- /dev/null +++ b/lib/Db/TaskSequenceMapper.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-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-an-approval-is-an-ordered-task-sequence-with-one-position-enabled-at-a-time + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Persistence for task sequences. + * + * @template-extends QBMapper + */ +class TaskSequenceMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db Database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_task_sequences', entityClass: TaskSequence::class); + }//end __construct() + + /** + * Find a sequence by its public uuid. + * + * @param string $uuid The sequence uuid. + * + * @return TaskSequence The sequence. + * + * @throws DoesNotExistException When no such sequence exists. + * + * @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 findByUuid(string $uuid): TaskSequence { + $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 RUNNING sequence for an anchor and template, or null. + * + * At most one exists by contract: the gate refuses a second provisioning + * while one runs, and the migration verifies the same invariant. + * + * @param string $anchorObjectUuid The object the approval is about. + * @param string $templateId The compiled template id. + * + * @return TaskSequence|null The running sequence, or null. + * + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-007 + */ + public function findRunning(string $anchorObjectUuid, string $templateId): ?TaskSequence { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('anchor_object_uuid', $qb->createNamedParameter($anchorObjectUuid))) + ->andWhere($qb->expr()->eq('template_id', $qb->createNamedParameter($templateId))) + ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(TaskSequence::STATUS_RUNNING))) + ->orderBy('id', 'DESC') + ->setMaxResults(1); + + $rows = $this->findEntities(query: $qb); + + 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-one-position-enabled-at-a-time + */ + 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. + * + * A rejected cycle stays readable here after a resubmission opened a new + * one; the two are distinguishable by their open time. + * + * @param string $anchorObjectUuid The object the approvals are about. + * @param string $templateId The compiled template id. + * + * @return array The sequences, newest first. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-a-rejection-terminates-the-sequence-and-every-task-it-still-owns + */ + public function findForAnchor(string $anchorObjectUuid, string $templateId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('anchor_object_uuid', $qb->createNamedParameter($anchorObjectUuid))) + ->andWhere($qb->expr()->eq('template_id', $qb->createNamedParameter($templateId))) + ->orderBy('opened_at', 'DESC') + ->addOrderBy('id', 'DESC'); + + return $this->findEntities(query: $qb); + }//end findForAnchor() + + /** + * The most recent sequence for an anchor and template, or null. + * + * @param string $anchorObjectUuid The object the approval is about. + * @param string $templateId The compiled template id. + * + * @return TaskSequence|null The newest sequence, or null. + * + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-007 + */ + public function findNewestForAnchor(string $anchorObjectUuid, string $templateId): ?TaskSequence { + $rows = $this->findForAnchor(anchorObjectUuid: $anchorObjectUuid, templateId: $templateId); + + return ($rows[0] ?? null); + }//end findNewestForAnchor() +}//end class diff --git a/lib/Db/WorkflowEngine.php b/lib/Db/WorkflowEngine.php deleted file mode 100644 index 119f953661..0000000000 --- a/lib/Db/WorkflowEngine.php +++ /dev/null @@ -1,217 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use JsonSerializable; -use OCP\AppFramework\Db\Entity; - -/** - * Entity class representing a workflow engine configuration. - * - * @method string|null getUuid() - * @method void setUuid(?string $uuid) - * @method string|null getName() - * @method void setName(?string $name) - * @method string|null getEngineType() - * @method void setEngineType(?string $engineType) - * @method string|null getBaseUrl() - * @method void setBaseUrl(?string $baseUrl) - * @method string|null getAuthType() - * @method void setAuthType(?string $authType) - * @method string|null getAuthConfig() - * @method void setAuthConfig(?string $authConfig) - * @method bool getEnabled() - * @method void setEnabled(bool $enabled) - * @method int getDefaultTimeout() - * @method void setDefaultTimeout(int $defaultTimeout) - * @method bool|null getHealthStatus() - * @method void setHealthStatus(?bool $healthStatus) - * @method DateTime|null getLastHealthCheck() - * @method void setLastHealthCheck(?DateTime $lastHealthCheck) - * @method DateTime|null getCreated() - * @method void setCreated(?DateTime $created) - * @method DateTime|null getUpdated() - * @method void setUpdated(?DateTime $updated) - * - * @psalm-suppress PropertyNotSetInConstructor - */ -class WorkflowEngine extends Entity implements JsonSerializable { - - /** - * Unique identifier for the workflow engine. - * - * @var string|null - */ - protected ?string $uuid = null; - - /** - * Name of the workflow engine. - * - * @var string|null - */ - protected ?string $name = null; - - /** - * Type of workflow engine (e.g., n8n, windmill). - * - * @var string|null - */ - protected ?string $engineType = null; - - /** - * Base URL of the workflow engine API. - * - * @var string|null - */ - protected ?string $baseUrl = null; - - /** - * Authentication type for the engine connection. - * - * @var string|null - */ - protected ?string $authType = 'none'; - - /** - * Authentication configuration (JSON). - * - * @var string|null - */ - protected ?string $authConfig = null; - - /** - * Whether the workflow engine is enabled. - * - * @var boolean - */ - protected bool $enabled = true; - - /** - * Default timeout in seconds for workflow execution. - * - * @var integer - */ - protected int $defaultTimeout = 30; - - /** - * Current health status of the engine. - * - * @var boolean|null - */ - protected ?bool $healthStatus = null; - - /** - * Timestamp of the last health check. - * - * @var DateTime|null - */ - protected ?DateTime $lastHealthCheck = null; - - /** - * Timestamp when the entity was created. - * - * @var DateTime|null - */ - protected ?DateTime $created = null; - - /** - * Timestamp when the entity was last updated. - * - * @var DateTime|null - */ - protected ?DateTime $updated = null; - - /** - * Constructor for WorkflowEngine entity. - * - * Registers column types for the database mapper. - */ - public function __construct() { - $this->addType(fieldName: 'uuid', type: 'string'); - $this->addType(fieldName: 'name', type: 'string'); - $this->addType(fieldName: 'engineType', type: 'string'); - $this->addType(fieldName: 'baseUrl', type: 'string'); - $this->addType(fieldName: 'authType', type: 'string'); - $this->addType(fieldName: 'authConfig', type: 'string'); - $this->addType(fieldName: 'enabled', type: 'boolean'); - $this->addType(fieldName: 'defaultTimeout', type: 'integer'); - $this->addType(fieldName: 'healthStatus', type: 'boolean'); - $this->addType(fieldName: 'lastHealthCheck', type: 'datetime'); - $this->addType(fieldName: 'created', type: 'datetime'); - $this->addType(fieldName: 'updated', type: 'datetime'); - }//end __construct() - - /** - * Hydrate entity from array. - * - * @param array $object Data to hydrate from - * - * @return self - */ - public function hydrate(array $object): self { - $fields = [ - 'uuid', - 'name', - 'engineType', - 'baseUrl', - 'authType', - 'authConfig', - 'enabled', - 'defaultTimeout', - 'healthStatus', - 'lastHealthCheck', - 'created', - 'updated', - ]; - - foreach ($object as $key => $value) { - if (in_array($key, $fields, true) === true) { - $setter = 'set' . ucfirst($key); - $this->$setter($value); - } - } - - return $this; - }//end hydrate() - - /** - * Serialize to JSON. Credentials are always excluded. - * - * @return array - */ - public function jsonSerialize(): array { - return [ - 'id' => $this->id, - 'uuid' => $this->uuid, - 'name' => $this->name, - 'engineType' => $this->engineType, - 'baseUrl' => $this->baseUrl, - 'authType' => $this->authType, - 'enabled' => $this->enabled, - 'defaultTimeout' => $this->defaultTimeout, - 'healthStatus' => $this->healthStatus, - 'lastHealthCheck' => $this->lastHealthCheck?->format('c'), - 'created' => $this->created?->format('c'), - 'updated' => $this->updated?->format('c'), - ]; - }//end jsonSerialize() -}//end class diff --git a/lib/Db/WorkflowEngineMapper.php b/lib/Db/WorkflowEngineMapper.php deleted file mode 100644 index 9f9c196c37..0000000000 --- a/lib/Db/WorkflowEngineMapper.php +++ /dev/null @@ -1,145 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use OCP\AppFramework\Db\QBMapper; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; -use Symfony\Component\Uid\Uuid; - -/** - * Mapper for WorkflowEngine entities. - * - * @extends QBMapper - */ -class WorkflowEngineMapper extends QBMapper { - /** - * Constructor for WorkflowEngineMapper. - * - * @param IDBConnection $db Database connection - */ - public function __construct(IDBConnection $db) { - parent::__construct(db: $db, tableName: 'openregister_workflow_engines', entityClass: WorkflowEngine::class); - }//end __construct() - - /** - * Find a workflow engine by ID. - * - * @param int $id Engine ID - * - * @return WorkflowEngine - */ - public function find(int $id): WorkflowEngine { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter(value: $id, type: IQueryBuilder::PARAM_INT)) - ); - - return $this->findEntity(query: $qb); - }//end find() - - /** - * Find all workflow engines. - * - * @param int|null $limit Maximum results - * @param int|null $offset Offset for pagination - * - * @return array - */ - public function findAll(?int $limit = null, ?int $offset = null): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->orderBy('name', 'ASC'); - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - return $this->findEntities(query: $qb); - }//end findAll() - - /** - * Find engines by type. - * - * @param string $engineType Engine type (e.g., 'n8n', 'windmill') - * - * @return array - */ - public function findByType(string $engineType): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('engine_type', $qb->createNamedParameter(value: $engineType)) - ); - - return $this->findEntities(query: $qb); - }//end findByType() - - /** - * Create a workflow engine from an array. - * - * @param array $data Engine data - * - * @return WorkflowEngine - */ - public function createFromArray(array $data): WorkflowEngine { - $engine = new WorkflowEngine(); - $engine->hydrate($data); - - if ($engine->getUuid() === null) { - $engine->setUuid(Uuid::v4()->toRfc4122()); - } - - $now = new DateTime(); - $engine->setCreated($now); - $engine->setUpdated($now); - - return $this->insert(entity: $engine); - }//end createFromArray() - - /** - * Update a workflow engine from an array. - * - * @param int $id Engine ID - * @param array $data Updated data - * - * @return WorkflowEngine - */ - public function updateFromArray(int $id, array $data): WorkflowEngine { - $engine = $this->find(id: $id); - $engine->hydrate($data); - $engine->setUpdated(new DateTime()); - - return $this->update(entity: $engine); - }//end updateFromArray() -}//end class diff --git a/lib/Db/WorkflowExecution.php b/lib/Db/WorkflowExecution.php deleted file mode 100644 index fc9ae8a80e..0000000000 --- a/lib/Db/WorkflowExecution.php +++ /dev/null @@ -1,267 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use JsonSerializable; -use OCP\AppFramework\Db\Entity; - -/** - * Entity class representing a workflow execution history record. - * - * @method string|null getUuid() - * @method void setUuid(?string $uuid) - * @method string|null getHookId() - * @method void setHookId(?string $hookId) - * @method string|null getEventType() - * @method void setEventType(?string $eventType) - * @method string|null getObjectUuid() - * @method void setObjectUuid(?string $objectUuid) - * @method int|null getSchemaId() - * @method void setSchemaId(?int $schemaId) - * @method int|null getRegisterId() - * @method void setRegisterId(?int $registerId) - * @method string|null getEngine() - * @method void setEngine(?string $engine) - * @method string|null getWorkflowId() - * @method void setWorkflowId(?string $workflowId) - * @method string|null getMode() - * @method void setMode(?string $mode) - * @method string|null getStatus() - * @method void setStatus(?string $status) - * @method int getDurationMs() - * @method void setDurationMs(int $durationMs) - * @method string|null getErrors() - * @method void setErrors(?string $errors) - * @method string|null getMetadata() - * @method void setMetadata(?string $metadata) - * @method string|null getPayload() - * @method void setPayload(?string $payload) - * @method DateTime|null getExecutedAt() - * @method void setExecutedAt(?DateTime $executedAt) - * - * @psalm-suppress PropertyNotSetInConstructor - */ -class WorkflowExecution extends Entity implements JsonSerializable { - - /** - * The uuid. - * - * @var string|null - */ - protected ?string $uuid = null; - - /** - * The hook id. - * - * @var string|null - */ - protected ?string $hookId = null; - - /** - * The event type. - * - * @var string|null - */ - protected ?string $eventType = null; - - /** - * The object uuid. - * - * @var string|null - */ - protected ?string $objectUuid = null; - - /** - * The schema id. - * - * @var integer|null - */ - protected ?int $schemaId = null; - - /** - * The register id. - * - * @var integer|null - */ - protected ?int $registerId = null; - - /** - * The engine. - * - * @var string|null - */ - protected ?string $engine = null; - - /** - * The workflow id. - * - * @var string|null - */ - protected ?string $workflowId = null; - - /** - * The mode. - * - * @var string|null - */ - protected ?string $mode = 'sync'; - - /** - * The status. - * - * @var string|null - */ - protected ?string $status = null; - - /** - * The duration ms. - * - * @var integer - */ - protected int $durationMs = 0; - - /** - * The errors. - * - * @var string|null - */ - protected ?string $errors = null; - - /** - * The metadata. - * - * @var string|null - */ - protected ?string $metadata = null; - - /** - * The payload. - * - * @var string|null - */ - protected ?string $payload = null; - - /** - * The executed at. - * - * @var DateTime|null - */ - protected ?DateTime $executedAt = null; - - /** - * Constructor for WorkflowExecution entity. - */ - public function __construct() { - $this->addType(fieldName: 'uuid', type: 'string'); - $this->addType(fieldName: 'hookId', type: 'string'); - $this->addType(fieldName: 'eventType', type: 'string'); - $this->addType(fieldName: 'objectUuid', type: 'string'); - $this->addType(fieldName: 'schemaId', type: 'integer'); - $this->addType(fieldName: 'registerId', type: 'integer'); - $this->addType(fieldName: 'engine', type: 'string'); - $this->addType(fieldName: 'workflowId', type: 'string'); - $this->addType(fieldName: 'mode', type: 'string'); - $this->addType(fieldName: 'status', type: 'string'); - $this->addType(fieldName: 'durationMs', type: 'integer'); - $this->addType(fieldName: 'errors', type: 'string'); - $this->addType(fieldName: 'metadata', type: 'string'); - $this->addType(fieldName: 'payload', type: 'string'); - $this->addType(fieldName: 'executedAt', type: 'datetime'); - }//end __construct() - - /** - * Hydrate entity from array. - * - * @param array $object Data to hydrate from - * - * @return self - */ - public function hydrate(array $object): self { - $fields = [ - 'uuid', - 'hookId', - 'eventType', - 'objectUuid', - 'schemaId', - 'registerId', - 'engine', - 'workflowId', - 'mode', - 'status', - 'durationMs', - 'errors', - 'metadata', - 'payload', - 'executedAt', - ]; - - foreach ($object as $key => $value) { - if (in_array($key, $fields, true) === true) { - $setter = 'set' . ucfirst($key); - $this->$setter($value); - } - } - - return $this; - }//end hydrate() - - /** - * Serialize to JSON. - * - * @return array - */ - public function jsonSerialize(): array { - $errors = null; - if ($this->errors !== null) { - $errors = json_decode(json: $this->errors, associative: true); - } - - $metadata = null; - if ($this->metadata !== null) { - $metadata = json_decode(json: $this->metadata, associative: true); - } - - $payload = null; - if ($this->payload !== null) { - $payload = json_decode(json: $this->payload, associative: true); - } - - return [ - 'id' => $this->id, - 'uuid' => $this->uuid, - 'hookId' => $this->hookId, - 'eventType' => $this->eventType, - 'objectUuid' => $this->objectUuid, - 'schemaId' => $this->schemaId, - 'registerId' => $this->registerId, - 'engine' => $this->engine, - 'workflowId' => $this->workflowId, - 'mode' => $this->mode, - 'status' => $this->status, - 'durationMs' => $this->durationMs, - 'errors' => $errors, - 'metadata' => $metadata, - 'payload' => $payload, - 'executedAt' => $this->executedAt?->format('c'), - ]; - }//end jsonSerialize() -}//end class diff --git a/lib/Db/WorkflowExecutionMapper.php b/lib/Db/WorkflowExecutionMapper.php deleted file mode 100644 index 6b9b245f7b..0000000000 --- a/lib/Db/WorkflowExecutionMapper.php +++ /dev/null @@ -1,212 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Db; - -use DateTime; -use OCP\AppFramework\Db\QBMapper; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; -use Symfony\Component\Uid\Uuid; - -/** - * Mapper for WorkflowExecution entities. - * - * @extends QBMapper - */ -class WorkflowExecutionMapper extends QBMapper { - /** - * Constructor for WorkflowExecutionMapper. - * - * @param IDBConnection $db Database connection - */ - public function __construct(IDBConnection $db) { - parent::__construct( - db: $db, - tableName: 'openregister_workflow_executions', - entityClass: WorkflowExecution::class - ); - }//end __construct() - - /** - * Find a workflow execution by ID. - * - * @param int $id Execution ID - * - * @return WorkflowExecution - */ - public function find(int $id): WorkflowExecution { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter(value: $id, type: IQueryBuilder::PARAM_INT)) - ); - - return $this->findEntity(query: $qb); - }//end find() - - /** - * Find all workflow executions with optional filters and pagination. - * - * @param array $filters Filter parameters - * @param int|null $limit Maximum results (default 50) - * @param int|null $offset Pagination offset - * - * @return array - */ - public function findAll(array $filters = [], ?int $limit = 50, ?int $offset = 0): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select('*') - ->from($this->getTableName()) - ->orderBy('executed_at', 'DESC'); - - $this->applyFilters(qb: $qb, filters: $filters); - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - return $this->findEntities(query: $qb); - }//end findAll() - - /** - * Count all workflow executions matching the given filters. - * - * @param array $filters Filter parameters - * - * @return int Total count - */ - public function countAll(array $filters = []): int { - $qb = $this->db->getQueryBuilder(); - - $qb->select($qb->createFunction('COUNT(*)')) - ->from($this->getTableName()); - - $this->applyFilters(qb: $qb, filters: $filters); - - $result = $qb->executeQuery(); - $count = (int)$result->fetchOne(); - $result->closeCursor(); - - return $count; - }//end countAll() - - /** - * Delete all records older than the given cutoff date. - * - * @param DateTime $cutoff Records older than this are deleted - * - * @return int Number of deleted rows - */ - public function deleteOlderThan(DateTime $cutoff): int { - $qb = $this->db->getQueryBuilder(); - - $qb->delete($this->getTableName()) - ->where( - $qb->expr()->lt( - 'executed_at', - $qb->createNamedParameter( - value: $cutoff->format('Y-m-d H:i:s'), - type: IQueryBuilder::PARAM_STR - ) - ) - ); - - return $qb->executeStatement(); - }//end deleteOlderThan() - - /** - * Create a workflow execution from an array. - * - * @param array $data Execution data - * - * @return WorkflowExecution - */ - public function createFromArray(array $data): WorkflowExecution { - $execution = new WorkflowExecution(); - $execution->hydrate($data); - - if ($execution->getUuid() === null) { - $execution->setUuid(Uuid::v4()->toRfc4122()); - } - - if ($execution->getExecutedAt() === null) { - $execution->setExecutedAt(new DateTime()); - } - - return $this->insert(entity: $execution); - }//end createFromArray() - - /** - * Apply filter parameters to a query builder. - * - * @param IQueryBuilder $qb Query builder - * @param array $filters Filter parameters - * - * @return void - */ - private function applyFilters(IQueryBuilder $qb, array $filters): void { - if (isset($filters['objectUuid']) === true) { - $qb->andWhere( - $qb->expr()->eq('object_uuid', $qb->createNamedParameter($filters['objectUuid'])) - ); - } - - if (isset($filters['schemaId']) === true) { - $qb->andWhere( - $qb->expr()->eq( - 'schema_id', - $qb->createNamedParameter(value: (int)$filters['schemaId'], type: IQueryBuilder::PARAM_INT) - ) - ); - } - - if (isset($filters['hookId']) === true) { - $qb->andWhere( - $qb->expr()->eq('hook_id', $qb->createNamedParameter($filters['hookId'])) - ); - } - - if (isset($filters['status']) === true) { - $qb->andWhere( - $qb->expr()->eq('status', $qb->createNamedParameter($filters['status'])) - ); - } - - if (isset($filters['engine']) === true) { - $qb->andWhere( - $qb->expr()->eq('engine', $qb->createNamedParameter($filters['engine'])) - ); - } - - if (isset($filters['since']) === true) { - $qb->andWhere( - $qb->expr()->gte('executed_at', $qb->createNamedParameter($filters['since'])) - ); - } - }//end applyFilters() -}//end class diff --git a/lib/Event/ActionCreatedEvent.php b/lib/Event/ActionCreatedEvent.php deleted file mode 100644 index d9ed811a45..0000000000 --- a/lib/Event/ActionCreatedEvent.php +++ /dev/null @@ -1,62 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Event; - -use OCA\OpenRegister\Db\Action; -use OCP\EventDispatcher\Event; - -/** - * Event dispatched when an action is created - */ -class ActionCreatedEvent extends Event { - - /** - * The action - * - * @var Action The action entity - */ - private Action $action; - - /** - * Constructor for ActionCreatedEvent - * - * @param Action $action The action entity - * - * @return void - */ - public function __construct(Action $action) { - parent::__construct(); - $this->action = $action; - }//end __construct() - - /** - * Get the action - * - * @return Action The action entity - * - * @spec openspec/specs/event-driven-architecture/spec.md#requirement-event-payloads-for-webhook-delivery-must-include-register-and-schema-context-for-object-events - */ - public function getAction(): Action { - return $this->action; - }//end getAction() -}//end class diff --git a/lib/Event/ActionDeletedEvent.php b/lib/Event/ActionDeletedEvent.php deleted file mode 100644 index abc3b3ad91..0000000000 --- a/lib/Event/ActionDeletedEvent.php +++ /dev/null @@ -1,62 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Event; - -use OCA\OpenRegister\Db\Action; -use OCP\EventDispatcher\Event; - -/** - * Event dispatched when an action is deleted - */ -class ActionDeletedEvent extends Event { - - /** - * The action - * - * @var Action The action entity - */ - private Action $action; - - /** - * Constructor for ActionDeletedEvent - * - * @param Action $action The action entity - * - * @return void - */ - public function __construct(Action $action) { - parent::__construct(); - $this->action = $action; - }//end __construct() - - /** - * Get the action - * - * @return Action The action entity - * - * @spec openspec/specs/event-driven-architecture/spec.md#requirement-event-payloads-for-webhook-delivery-must-include-register-and-schema-context-for-object-events - */ - public function getAction(): Action { - return $this->action; - }//end getAction() -}//end class diff --git a/lib/Event/ActionUpdatedEvent.php b/lib/Event/ActionUpdatedEvent.php deleted file mode 100644 index 66a40e04ab..0000000000 --- a/lib/Event/ActionUpdatedEvent.php +++ /dev/null @@ -1,62 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - */ - -namespace OCA\OpenRegister\Event; - -use OCA\OpenRegister\Db\Action; -use OCP\EventDispatcher\Event; - -/** - * Event dispatched when an action is updated - */ -class ActionUpdatedEvent extends Event { - - /** - * The action - * - * @var Action The action entity - */ - private Action $action; - - /** - * Constructor for ActionUpdatedEvent - * - * @param Action $action The action entity - * - * @return void - */ - public function __construct(Action $action) { - parent::__construct(); - $this->action = $action; - }//end __construct() - - /** - * Get the action - * - * @return Action The action entity - * - * @spec openspec/specs/event-driven-architecture/spec.md#requirement-event-payloads-for-webhook-delivery-must-include-register-and-schema-context-for-object-events - */ - public function getAction(): Action { - return $this->action; - }//end getAction() -}//end class diff --git a/lib/Event/ApprovalStepApprovedEvent.php b/lib/Event/ApprovalStepApprovedEvent.php deleted file mode 100644 index 7b7fa1b3ac..0000000000 --- a/lib/Event/ApprovalStepApprovedEvent.php +++ /dev/null @@ -1,127 +0,0 @@ - - * @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 - * - * @spec openspec/changes/add-approval-step-events/tasks.md#task-1 - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Event; - -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; -use OCP\EventDispatcher\Event; - -/** - * Event dispatched when an approval step is approved. - * - * Carries the step, chain, the user who approved, the `statusOnApprove` - * resolved from the chain definition, and the next step (if any). Downstream - * apps (docudesk signing) use this to advance their own workflow state. - */ -class ApprovalStepApprovedEvent extends Event { - /** - * Constructor. - * - * @param ApprovalChain $chain The approval chain. - * @param ApprovalStep $step The step that was approved. - * @param string $userId ID of the user who approved. - * @param string $statusOnApprove The configured `statusOnApprove` - * from the chain step definition. - * @param ApprovalStep|null $nextStep The next step now in `pending`, or - * null if this was the final step. - * - * @return void - */ - public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $step, - private readonly string $userId, - private readonly string $statusOnApprove, - private readonly ?ApprovalStep $nextStep, - ) { - parent::__construct(); - }//end __construct() - - /** - * Get the approval chain. - * - * @return ApprovalChain The approval chain configuration. - */ - public function getChain(): ApprovalChain { - return $this->chain; - }//end getChain() - - /** - * Get the approved step. - * - * @return ApprovalStep The step that was approved. - */ - public function getStep(): ApprovalStep { - return $this->step; - }//end getStep() - - /** - * Get the ID of the approving user. - * - * @return string Nextcloud user ID. - */ - public function getUserId(): string { - return $this->userId; - }//end getUserId() - - /** - * Get the configured status the parent object should adopt on approval. - * - * @return string Status string from the chain step's `statusOnApprove`. - */ - public function getStatusOnApprove(): string { - return $this->statusOnApprove; - }//end getStatusOnApprove() - - /** - * Get the next step now in `pending`, if any. - * - * @return ApprovalStep|null The next step or null if the chain has ended. - */ - public function getNextStep(): ?ApprovalStep { - return $this->nextStep; - }//end getNextStep() - - /** - * Convenience: is this the final step of the chain? - * - * @return bool True when no next step is pending. - */ - public function isFinalStep(): bool { - return $this->nextStep === null; - }//end isFinalStep() - - /** - * Convenience: the object UUID being approved. - * - * @return string Object UUID (empty string if the step has none yet). - */ - public function getObjectUuid(): string { - return $this->step->getObjectUuid() ?? ''; - }//end getObjectUuid() -}//end class diff --git a/lib/Event/ApprovalStepCompletedEvent.php b/lib/Event/ApprovalStepCompletedEvent.php deleted file mode 100644 index 549e95d4aa..0000000000 --- a/lib/Event/ApprovalStepCompletedEvent.php +++ /dev/null @@ -1,110 +0,0 @@ - - * @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 - * - * @spec openspec/changes/add-approval-step-events/tasks.md#task-1 - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Event; - -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; -use OCP\EventDispatcher\Event; - -/** - * Event dispatched when an approval chain completes (final step approved). - * - * Fired ONCE per chain per object — at the moment the last step transitions - * to `approved` and there is no next step. A separate - * `ApprovalStepApprovedEvent` is also fired for that final step; downstream - * apps that only care about full-chain completion should listen here. - */ -class ApprovalStepCompletedEvent extends Event { - /** - * Constructor. - * - * @param ApprovalChain $chain The approval chain that completed. - * @param ApprovalStep $finalStep The last step (approved) of the chain. - * @param string $userId ID of the user who approved the final step. - * @param string $statusOnApprove The configured `statusOnApprove` from - * the final step's chain definition — - * i.e. the terminal status of the chain. - * - * @return void - */ - public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $finalStep, - private readonly string $userId, - private readonly string $statusOnApprove, - ) { - parent::__construct(); - }//end __construct() - - /** - * Get the approval chain. - * - * @return ApprovalChain The approval chain configuration. - */ - public function getChain(): ApprovalChain { - return $this->chain; - }//end getChain() - - /** - * Get the final step that closed the chain. - * - * @return ApprovalStep The final approved step. - */ - public function getFinalStep(): ApprovalStep { - return $this->finalStep; - }//end getFinalStep() - - /** - * Get the ID of the user who closed the chain. - * - * @return string Nextcloud user ID. - */ - public function getUserId(): string { - return $this->userId; - }//end getUserId() - - /** - * Get the terminal status the parent object should adopt. - * - * @return string Status string from the final step's `statusOnApprove`. - */ - public function getStatusOnApprove(): string { - return $this->statusOnApprove; - }//end getStatusOnApprove() - - /** - * Convenience: the object UUID whose chain is now complete. - * - * @return string Object UUID (empty string if the step has none yet). - */ - public function getObjectUuid(): string { - return $this->finalStep->getObjectUuid() ?? ''; - }//end getObjectUuid() -}//end class diff --git a/lib/Event/ApprovalStepInitiatedEvent.php b/lib/Event/ApprovalStepInitiatedEvent.php deleted file mode 100644 index 7fc3889858..0000000000 --- a/lib/Event/ApprovalStepInitiatedEvent.php +++ /dev/null @@ -1,86 +0,0 @@ - - * @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 - * - * @spec openspec/changes/add-approval-step-events/tasks.md#task-1 - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Event; - -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; -use OCP\EventDispatcher\Event; - -/** - * Event dispatched when an approval step is initiated (becomes `pending`). - * - * Downstream apps (docudesk signing workflow, decidesk multi-step decisions, - * any approval-driven leaf app) subscribe to this event to notify the - * authorised role group, queue reminders, or kick off integrations. - */ -class ApprovalStepInitiatedEvent extends Event { - /** - * Constructor. - * - * @param ApprovalChain $chain The approval chain the step belongs to. - * @param ApprovalStep $step The step that has just transitioned to `pending`. - * @param string $objectUuid UUID of the object being approved. - * - * @return void - */ - public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $step, - private readonly string $objectUuid, - ) { - parent::__construct(); - }//end __construct() - - /** - * Get the approval chain. - * - * @return ApprovalChain The approval chain configuration. - */ - public function getChain(): ApprovalChain { - return $this->chain; - }//end getChain() - - /** - * Get the approval step that has been initiated. - * - * @return ApprovalStep The step now in `pending`. - */ - public function getStep(): ApprovalStep { - return $this->step; - }//end getStep() - - /** - * Get the UUID of the object whose chain is progressing. - * - * @return string Object UUID. - */ - public function getObjectUuid(): string { - return $this->objectUuid; - }//end getObjectUuid() -}//end class diff --git a/lib/Event/ApprovalStepRejectedEvent.php b/lib/Event/ApprovalStepRejectedEvent.php deleted file mode 100644 index 0658cd818c..0000000000 --- a/lib/Event/ApprovalStepRejectedEvent.php +++ /dev/null @@ -1,105 +0,0 @@ - - * @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 - * - * @spec openspec/changes/add-approval-step-events/tasks.md#task-1 - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Event; - -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; -use OCP\EventDispatcher\Event; - -/** - * Event dispatched when an approval step is rejected. - * - * A rejection terminates the chain (no next step is advanced). Downstream - * apps can subscribe to roll back state, notify the requester, or trigger - * a `rejected` notification. - */ -class ApprovalStepRejectedEvent extends Event { - /** - * Constructor. - * - * @param ApprovalChain $chain The approval chain. - * @param ApprovalStep $step The step that was rejected. - * @param string $userId ID of the user who rejected. - * @param string $statusOnReject The configured `statusOnReject` from - * the chain step definition. - * - * @return void - */ - public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $step, - private readonly string $userId, - private readonly string $statusOnReject, - ) { - parent::__construct(); - }//end __construct() - - /** - * Get the approval chain. - * - * @return ApprovalChain The approval chain configuration. - */ - public function getChain(): ApprovalChain { - return $this->chain; - }//end getChain() - - /** - * Get the rejected step. - * - * @return ApprovalStep The step that was rejected. - */ - public function getStep(): ApprovalStep { - return $this->step; - }//end getStep() - - /** - * Get the ID of the rejecting user. - * - * @return string Nextcloud user ID. - */ - public function getUserId(): string { - return $this->userId; - }//end getUserId() - - /** - * Get the configured status the parent object should adopt on rejection. - * - * @return string Status string from the chain step's `statusOnReject`. - */ - public function getStatusOnReject(): string { - return $this->statusOnReject; - }//end getStatusOnReject() - - /** - * Convenience: the object UUID being rejected. - * - * @return string Object UUID (empty string if the step has none yet). - */ - public function getObjectUuid(): string { - return $this->step->getObjectUuid() ?? ''; - }//end getObjectUuid() -}//end class diff --git a/lib/Event/CaseItemTransitionedEvent.php b/lib/Event/CaseItemTransitionedEvent.php new file mode 100644 index 0000000000..5c167ad550 --- /dev/null +++ b/lib/Event/CaseItemTransitionedEvent.php @@ -0,0 +1,113 @@ + + * 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 + * `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. + * + * @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/Event/CredentialRelinkRequiredEvent.php b/lib/Event/CredentialRelinkRequiredEvent.php new file mode 100644 index 0000000000..7a7229d091 --- /dev/null +++ b/lib/Event/CredentialRelinkRequiredEvent.php @@ -0,0 +1,101 @@ + + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCP\EventDispatcher\Event; + +/** + * Signals that a connected account must be re-authorised. + */ +class CredentialRelinkRequiredEvent extends Event { + /** + * Constructor. + * + * @param string $credentialId The `brokeredcredential` object UUID, which is the credentialRef apps hold. + * @param string $provider The catalogue provider identifier the connection targets. + * @param string $owner The owning user id, who is the person able to re-authorise. + * @param string $reason A short, secret-free reason (the provider's OAuth2 error code). + * + * @return void + */ + public function __construct( + private readonly string $credentialId, + private readonly string $provider, + private readonly string $owner, + private readonly string $reason, + ) { + parent::__construct(); + }//end __construct() + + /** + * The credential that needs re-authorising. + * + * @return string The credential UUID. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-invalid-grant-moves-the-credential-to-relink-needed-and-fails-closed + */ + public function getCredentialId(): string { + return $this->credentialId; + }//end getCredentialId() + + /** + * The catalogue provider the connection targets. + * + * @return string The provider identifier. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-invalid-grant-moves-the-credential-to-relink-needed-and-fails-closed + */ + public function getProvider(): string { + return $this->provider; + }//end getProvider() + + /** + * The user who can re-authorise the connection. + * + * @return string The owning user id. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-invalid-grant-moves-the-credential-to-relink-needed-and-fails-closed + */ + public function getOwner(): string { + return $this->owner; + }//end getOwner() + + /** + * The secret-free reason the grant was refused. + * + * @return string The provider's OAuth2 error code. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-invalid-grant-moves-the-credential-to-relink-needed-and-fails-closed + */ + public function getReason(): string { + return $this->reason; + }//end getReason() +}//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/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/Event/TaskSequenceCompletedEvent.php b/lib/Event/TaskSequenceCompletedEvent.php new file mode 100644 index 0000000000..3957f000a4 --- /dev/null +++ b/lib/Event/TaskSequenceCompletedEvent.php @@ -0,0 +1,109 @@ + + * 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 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-four-approval-events-are-replaced-by-a-named-complete-mapping + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskSequence; +use OCP\EventDispatcher\Event; + +/** + * Carries the completed sequence, its final task and the resolved status. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-four-approval-events-are-replaced-by-a-named-complete-mapping + */ +class TaskSequenceCompletedEvent extends Event { + + /** + * Constructor. + * + * @param TaskSequence $sequence The completed sequence, as persisted. + * @param Task $finalTask The final position's task. + * @param string|null $decider The identity that decided the final position. + * @param string $statusOnApprove The resolved approving status. + */ + public function __construct( + private readonly TaskSequence $sequence, + private readonly Task $finalTask, + private readonly ?string $decider, + private readonly string $statusOnApprove, + ) { + parent::__construct(); + + }//end __construct() + + /** + * The completed sequence. + * + * @return TaskSequence The sequence as persisted. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-four-approval-events-are-replaced-by-a-named-complete-mapping + */ + public function getSequence(): TaskSequence { + return $this->sequence; + }//end getSequence() + + /** + * The final position's task. + * + * @return Task The task whose completion completed the sequence. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-four-approval-events-are-replaced-by-a-named-complete-mapping + */ + public function getFinalTask(): Task { + return $this->finalTask; + }//end getFinalTask() + + /** + * The deciding identity. + * + * @return string|null Who decided the final position. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-four-approval-events-are-replaced-by-a-named-complete-mapping + */ + public function getDecider(): ?string { + return $this->decider; + }//end getDecider() + + /** + * The resolved approving status. + * + * @return string The `statusOnApprove` the frozen declaration resolves to. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-four-approval-events-are-replaced-by-a-named-complete-mapping + */ + public function getStatusOnApprove(): string { + return $this->statusOnApprove; + }//end getStatusOnApprove() +}//end class diff --git a/lib/Event/TaskTerminalEvent.php b/lib/Event/TaskTerminalEvent.php new file mode 100644 index 0000000000..9b8a91a9fd --- /dev/null +++ b/lib/Event/TaskTerminalEvent.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 + * + * @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 + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The flag IS the contract: + * one event class serves the in-transaction dispatch (timer cancellation) + * and the after-commit dispatch (run continuation), and listeners filter on + * it. Two classes would force every subscriber to know both names. + */ +class TaskTerminalEvent extends Event { + + /** + * Constructor. + * + * @param Task $task The task, already persisted in a terminal state. + * @param bool $committed TRUE when the terminal write's transaction has + * closed; FALSE when dispatched inside it. + */ + public function __construct( + private readonly Task $task, + private readonly bool $committed = true, + ) { + 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() + + /** + * Whether the terminal write has committed. + * + * @return bool TRUE after commit (TaskService), FALSE inside the + * transaction (TaskMapper). + * + * @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 isCommitted(): bool { + return $this->committed; + }//end isCommitted() + + /** + * The task's public uuid. + * + * @return string The uuid. + * + * @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 getTaskUuid(): string { + return (string)$this->task->getUuid(); + }//end getTaskUuid() + + /** + * The terminal state. + * + * @return string One of Task::TERMINAL_STATES. + * + * @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 getState(): string { + return (string)$this->task->getState(); + }//end getState() + + /** + * The recorded outcome. + * + * @return string|null The outcome. + * + * @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 getOutcome(): ?string { + return $this->task->getOutcome(); + }//end getOutcome() +}//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/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/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/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/Exception/LockedException.php b/lib/Exception/LockedException.php index e5c386629f..a075846947 100644 --- a/lib/Exception/LockedException.php +++ b/lib/Exception/LockedException.php @@ -23,6 +23,7 @@ namespace OCA\OpenRegister\Exception; use Exception; +use OCA\OpenRegister\Db\ObjectEntity; use Throwable; /** @@ -44,6 +45,104 @@ * @link https://OpenRegister.app */ class LockedException extends Exception { + + /** + * The HTTP status controllers MUST map this exception to. + * + * A constant rather than the exception's `code` for the same reason + * `FolderAccessDeniedException::HTTP_STATUS` is one: `getCode()` is an + * application error code, not an HTTP status. The constructor's legacy + * `$code = 423` default is left alone so nothing reading `getCode()` + * changes underneath it. + * + * @var integer + */ + public const HTTP_STATUS = 423; + + /** + * The user holding the lock, when a person holds it. + * + * @var string|null + */ + private ?string $lockedBy = null; + + /** + * The flow run holding the lock, when a run holds it. + * + * @var string|null + */ + private ?string $lockedByRun = null; + + /** + * The holder description used in the message. + * + * @var string|null + */ + private ?string $holder = null; + + /** + * Build the canonical refusal for an object a caller may not write. + * + * The refusal is built HERE, from `ObjectEntity`'s own accessors, so that + * every guard refuses in the same words and names the same holder. Before + * this, PUT built the message and the holder fields inline in the + * controller while the service-layer guard built a different sentence and + * carried no holder at all, so the two doors to the same object answered + * differently: 423 naming the holder on one, 400 or 500 naming nothing on + * the other. + * + * The CONDITION is not restated here — callers ask + * `ObjectEntity::isLockedBySomeoneElse()` and, only once it has said yes, + * ask this factory for the refusal. + * + * @param ObjectEntity $object The locked object, already found to be locked against the caller. + * + * @return self The refusal carrying the holder. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-refuses-a-write-and-names-its-holder + */ + public static function forObject(ObjectEntity $object): self { + $holder = (string)$object->describeLockHolder(); + + $exception = new self( + message: 'Cannot update object: Object is locked by ' . $holder + . '. Please unlock the object before attempting to update it.' + ); + + $exception->lockedBy = $object->getLockedBy(); + $exception->lockedByRun = $object->getLockedByRun(); + $exception->holder = $holder; + + return $exception; + }//end forObject() + + /** + * Get the user holding the lock, when a person holds it. + * + * @return string|null The holding user id, or null when a run holds it. + */ + public function getLockedBy(): ?string { + return $this->lockedBy; + }//end getLockedBy() + + /** + * Get the flow run holding the lock, when a run holds it. + * + * @return string|null The holding run uuid, or null when a person holds it. + */ + public function getLockedByRun(): ?string { + return $this->lockedByRun; + }//end getLockedByRun() + + /** + * Get the human-readable holder description. + * + * @return string|null The holder description, or null when not built by `forObject()`. + */ + public function getHolder(): ?string { + return $this->holder; + }//end getHolder() + /** * Constructor for LockedException * 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/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/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/TaskSeparationOfDutiesException.php b/lib/Exception/TaskSeparationOfDutiesException.php new file mode 100644 index 0000000000..9b232c5cfd --- /dev/null +++ b/lib/Exception/TaskSeparationOfDutiesException.php @@ -0,0 +1,45 @@ + + * @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-approval-consolidation/specs/approval-workflow/spec.md#req-009 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * A refused self-decision on an approval sequence. + * + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-009 + */ +class TaskSeparationOfDutiesException extends RuntimeException { +}//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/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/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/Listener/ActionListener.php b/lib/Listener/ActionListener.php deleted file mode 100644 index 7d9ac05e18..0000000000 --- a/lib/Listener/ActionListener.php +++ /dev/null @@ -1,307 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/actions/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Listener; - -use OCA\OpenRegister\Db\ActionMapper; -use OCA\OpenRegister\Service\ActionExecutor; -use OCP\EventDispatcher\Event; -use OCP\EventDispatcher\IEventListener; -use Psr\Log\LoggerInterface; - -/** - * ActionListener handles events by finding and executing matching actions - * - * Registered for ALL event types in Application::registerEventListeners(). - * Coexists with HookListener (inline hooks execute first). - * - * @template-implements IEventListener - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ -class ActionListener implements IEventListener { - /** - * Constructor - * - * @param ActionMapper $actionMapper Action mapper for finding matching actions - * @param ActionExecutor $actionExecutor Action executor for running actions - * @param LoggerInterface $logger Logger - * - * @spec openspec/specs/event-driven-architecture/spec.md - */ - public function __construct( - private readonly ActionMapper $actionMapper, - private readonly ActionExecutor $actionExecutor, - private readonly LoggerInterface $logger, - ) { - }//end __construct() - - /** - * Handle event by finding and executing matching actions - * - * @param Event $event The lifecycle event - * - * @return void - * - * @spec openspec/specs/event-driven-architecture/spec.md - */ - public function handle(Event $event): void { - // Respect propagation stop from inline hooks or previous listeners. - if ($event->isPropagationStopped() === true) { - $this->logger->debug( - message: '[ActionListener] Propagation already stopped, skipping action execution' - ); - return; - } - - try { - // Determine event type from class name (short name). - $eventType = $this->getEventTypeName(event: $event); - - // Extract payload from event. - $payload = $this->extractPayload(event: $event); - $schemaUuid = $payload['schemaUuid'] ?? null; - $registerUuid = $payload['registerUuid'] ?? null; - - // Find matching actions. - $actions = $this->actionMapper->findMatchingActions( - eventType: $eventType, - schemaUuid: $schemaUuid, - registerUuid: $registerUuid - ); - - if (empty($actions) === true) { - return; - } - - // Apply filter_condition matching. - $filteredActions = $this->applyFilterConditions(actions: $actions, payload: $payload); - - if (empty($filteredActions) === true) { - return; - } - - $this->logger->debug( - message: '[ActionListener] Executing actions for event', - context: [ - 'eventType' => $eventType, - 'actionCount' => count($filteredActions), - ] - ); - - // Delegate to ActionExecutor. - $this->actionExecutor->executeActions( - actions: $filteredActions, - event: $event, - payload: $payload, - eventType: $eventType - ); - } catch (\Throwable $e) { - // Never let listener failures affect other listeners. Catch - // \Throwable (not just Exception) so a PHP Error raised inside a - // listener — e.g. a type mismatch on an event getter — cannot - // bubble up and turn an otherwise-successful object operation into - // a 500. - $this->logger->error( - message: '[ActionListener] Error handling event', - context: [ - 'error' => $e->getMessage(), - 'eventType' => get_class($event), - ] - ); - }//end try - }//end handle() - - /** - * Get the short event type name from an event class - * - * @param Event $event The event - * - * @return string Short class name (e.g., 'ObjectCreatingEvent') - * - * @spec openspec/specs/event-driven-architecture/spec.md - */ - private function getEventTypeName(Event $event): string { - $class = get_class($event); - $parts = explode('\\', $class); - - return end($parts); - }//end getEventTypeName() - - /** - * Extract payload data from an event - * - * @param Event $event The event - * - * @return array Payload data - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) - * - * @spec openspec/specs/event-driven-architecture/spec.md - */ - private function extractPayload(Event $event): array { - $payload = []; - - // Object events. - if (method_exists($event, 'getObject') === true) { - $object = $event->getObject(); - if ($object !== null) { - $payload['object'] = $object->jsonSerialize(); - $payload['schemaUuid'] = $object->getSchema() ?? null; - $payload['registerUuid'] = $object->getRegister() ?? null; - } - } - - // For update events, try to get the new object. - if (method_exists($event, 'getNewObject') === true) { - $newObject = $event->getNewObject(); - if ($newObject !== null) { - $payload['object'] = $newObject->jsonSerialize(); - $payload['schemaUuid'] = $newObject->getSchema() ?? null; - $payload['registerUuid'] = $newObject->getRegister() ?? null; - } - } - - // Register events. Some events (e.g. ObjectTransitionedEvent) expose - // getRegister() as a plain string id/uuid rather than a Register entity; - // guard the type so calling jsonSerialize() on a string never fatals - // (Error, not Exception, so the handle() try/catch would not absorb it). - if (method_exists($event, 'getRegister') === true) { - $register = $event->getRegister(); - if (is_object($register) === true) { - $payload['register'] = $register->jsonSerialize(); - $payload['registerUuid'] = $register->getUuid() ?? null; - } elseif (is_string($register) === true && $register !== '') { - $payload['registerUuid'] = $register; - } - } - - // Schema events. As above, ObjectTransitionedEvent's getSchema() is a - // string id/uuid, not a Schema entity. - if (method_exists($event, 'getSchema') === true) { - $schema = $event->getSchema(); - if (is_object($schema) === true) { - $payload['schema'] = $schema->jsonSerialize(); - $payload['schemaUuid'] = $schema->getUuid() ?? null; - } elseif (is_string($schema) === true && $schema !== '') { - $payload['schemaUuid'] = $schema; - } - } - - // Action events. ObjectTransitionedEvent's getAction() is the string - // transition name (e.g. 'park'), not an Action entity. - if (method_exists($event, 'getAction') === true) { - $action = $event->getAction(); - if (is_object($action) === true) { - $payload['action'] = $action->jsonSerialize(); - } elseif (is_string($action) === true && $action !== '') { - $payload['action'] = $action; - } - } - - // Source events. - if (method_exists($event, 'getSource') === true) { - $source = $event->getSource(); - if (is_object($source) === true) { - $payload['source'] = $source->jsonSerialize(); - } - } - - // Configuration events. - if (method_exists($event, 'getConfiguration') === true) { - $configuration = $event->getConfiguration(); - if (is_object($configuration) === true) { - $payload['configuration'] = $configuration->jsonSerialize(); - } - } - - return $payload; - }//end extractPayload() - - /** - * Apply filter_condition matching against the payload - * - * @param array $actions Array of actions to filter - * @param array $payload Event payload - * - * @return array Filtered actions that match their filter conditions - * - * @spec openspec/specs/event-driven-architecture/spec.md - */ - private function applyFilterConditions(array $actions, array $payload): array { - return array_values( - array_filter( - $actions, - function ($action) use ($payload) { - $conditions = $action->getFilterConditionArray(); - - if (empty($conditions) === true) { - return true; - } - - foreach ($conditions as $key => $expected) { - $actual = $this->getNestedValue(data: $payload, key: $key); - - if (is_array($expected) === true) { - if (in_array($actual, $expected) === false) { - return false; - } - } elseif ($actual !== $expected) { - return false; - } - } - - return true; - } - ) - ); - }//end applyFilterConditions() - - /** - * Get a nested value from an array using dot notation - * - * @param array $data Array to search - * @param string $key Dot-notation key - * - * @return mixed The value or null - * - * @spec openspec/specs/event-driven-architecture/spec.md - */ - private function getNestedValue(array $data, string $key): mixed { - $keys = explode('.', $key); - - foreach ($keys as $segment) { - if (is_array($data) === false || array_key_exists($segment, $data) === false) { - return null; - } - - $data = $data[$segment]; - } - - return $data; - }//end getNestedValue() -}//end class diff --git a/lib/Listener/ApprovalChainAdvanceListener.php b/lib/Listener/ApprovalChainAdvanceListener.php index bd29129613..8e814189a7 100644 --- a/lib/Listener/ApprovalChainAdvanceListener.php +++ b/lib/Listener/ApprovalChainAdvanceListener.php @@ -3,11 +3,13 @@ /** * OpenRegister ApprovalChainAdvanceListener * - * Subscribes to the existing `ApprovalStepCompletedEvent` and, when the - * completed chain's schema declares `onApprove: advanceTransition` for that - * chain, invokes `TransitionEngine::transition()` for the gated action — the - * SAME action `ApprovalChainGateListener` blocked. Reuses the already-shipped - * event and transition engine verbatim; no new mutate/save/dispatch logic. + * Subscribes to {@see TaskSequenceCompletedEvent} and, when the completed + * sequence's schema declares `onApprove: advanceTransition` for that chain, + * invokes `TransitionEngine::transition()` for the gated action — the SAME + * action `ApprovalChainGateListener` blocked. Only the subscription changed + * in the consolidation (flow-approval-consolidation task 3.3): the lookup + * and the fail-soft transition call are the retired implementation's, + * verbatim in behaviour. * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -23,25 +25,24 @@ * * @link https://OpenRegister.app * - * @spec openspec/specs/approval-workflow/spec.md + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-010 */ declare(strict_types=1); namespace OCA\OpenRegister\Listener; -use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; -use OCA\OpenRegister\Event\ApprovalStepCompletedEvent; +use OCA\OpenRegister\Event\TaskSequenceCompletedEvent; use OCA\OpenRegister\Service\Lifecycle\TransitionEngine; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use Psr\Log\LoggerInterface; /** - * Auto-advances a gated transition when its approval chain completes. + * Auto-advances a gated transition when its approval sequence completes. * - * @template-implements IEventListener + * @template-implements IEventListener */ class ApprovalChainAdvanceListener implements IEventListener { /** @@ -59,21 +60,21 @@ public function __construct( }//end __construct() /** - * Advance the gated transition when the completed chain declares it. + * Advance the gated transition when the completed sequence declares it. * * @param Event $event Inbound dispatcher event. * * @return void * - * @spec openspec/specs/approval-workflow/spec.md + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-010 */ public function handle(Event $event): void { - if (($event instanceof ApprovalStepCompletedEvent) === false) { + if (($event instanceof TaskSequenceCompletedEvent) === false) { return; } - $chain = $event->getChain(); - $schemaId = $chain->getSchemaId(); + $sequence = $event->getSequence(); + $schemaId = $sequence->getSchemaId(); if ($schemaId === null) { return; } @@ -92,7 +93,7 @@ public function handle(Event $event): void { return; } - $entry = ($chains[(string)$chain->getName()] ?? null); + $entry = ($chains[(string)$sequence->getChainKey()] ?? null); if (is_array($entry) === false) { return; } @@ -102,7 +103,7 @@ public function handle(Event $event): void { } $action = (string)($entry['transition'] ?? ''); - $objectUuid = $event->getObjectUuid(); + $objectUuid = (string)$sequence->getAnchorObjectUuid(); if ($action === '' || $objectUuid === '') { return; } @@ -110,10 +111,10 @@ public function handle(Event $event): void { try { $this->transitionEngine->transition(objectId: $objectUuid, action: $action); } catch (\Throwable $e) { - // Fail-soft: the chain is correctly `approved` regardless. A failed - // auto-advance leaves the object at its pre-gate state; the gate - // listener will allow a subsequent manual transition attempt since - // every provisioned step is already approved. + // Fail-soft: the sequence is correctly `completed` regardless. A + // failed auto-advance leaves the object at its pre-gate state; the + // gate listener will allow a subsequent manual transition attempt + // since the sequence is already complete. $this->logger->warning( sprintf( '[ApprovalChainAdvanceListener] auto-advance "%s" for object %s failed: %s', diff --git a/lib/Listener/ApprovalChainGateListener.php b/lib/Listener/ApprovalChainGateListener.php index 1c39b15e5e..0c8c600181 100644 --- a/lib/Listener/ApprovalChainGateListener.php +++ b/lib/Listener/ApprovalChainGateListener.php @@ -4,11 +4,18 @@ * OpenRegister ApprovalChainGateListener * * Subscribes to ObjectUpdatingEvent and blocks a lifecycle transition named by a - * schema's `x-openregister-approval-chains` declaration until the provisioned - * `ApprovalChain`'s steps are all approved. Mirrors - * `Listener\LifecycleValidationListener`'s schema-parse-off-`getConfiguration()` - * shape exactly, re-deriving the matched transition independently rather than - * refactoring the shipped lifecycle listener. + * schema's `x-openregister-approval-chains` declaration until the object's + * approval SEQUENCE for that chain is complete with an approving outcome + * (flow-approval-consolidation). The refusal contract is unchanged from the + * retired step-scanning implementation: the same two error codes + * (`approval-chain-pending`, `approval-chain-misconfigured`), the same + * fail-closed policy on a chain that cannot be provisioned, and the same + * transition matching, re-derived independently of + * `Listener\LifecycleValidationListener` exactly as before. + * + * One deliberate behavioural difference (design D-5): a rejected cycle is + * CLOSED and kept, never deleted. The next attempt opens a NEW sequence and + * the rejected one, its decisions and its comments stay readable. * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -24,28 +31,28 @@ * * @link https://OpenRegister.app * - * @spec openspec/specs/approval-workflow/spec.md + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-007 */ declare(strict_types=1); namespace OCA\OpenRegister\Listener; -use OCA\OpenRegister\Db\ApprovalChainMapper; -use OCA\OpenRegister\Db\ApprovalStepMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Db\TaskSequence; +use OCA\OpenRegister\Db\TaskSequenceMapper; use OCA\OpenRegister\Event\ObjectUpdatingEvent; use OCA\OpenRegister\Service\ApprovalChainAnnotationInstaller; -use OCA\OpenRegister\Service\ApprovalService; +use OCA\OpenRegister\Service\Task\TaskSequenceService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\IUserSession; use Psr\Log\LoggerInterface; /** - * Gates a declared lifecycle transition on approval-chain completion. + * Gates a declared lifecycle transition on approval-sequence completion. * * @template-implements IEventListener * @@ -56,18 +63,16 @@ class ApprovalChainGateListener implements IEventListener { * Constructor. * * @param SchemaMapper $schemaMapper Schema lookup mapper. - * @param ApprovalChainMapper $chainMapper Chain mapper. - * @param ApprovalStepMapper $stepMapper Step mapper. - * @param ApprovalService $approvalService Provisions/inspects chain steps. - * @param ApprovalChainAnnotationInstaller $installer Ensures the declared chain is provisioned. + * @param TaskSequenceMapper $sequenceMapper Reads the anchor's sequences. + * @param TaskSequenceService $sequenceService Provisions a sequence on the first attempt. + * @param ApprovalChainAnnotationInstaller $installer Compiles the declared chain into a task template. * @param IUserSession $userSession Current user session (requester identity). * @param LoggerInterface $logger Logger for gate diagnostics. */ public function __construct( private readonly SchemaMapper $schemaMapper, - private readonly ApprovalChainMapper $chainMapper, - private readonly ApprovalStepMapper $stepMapper, - private readonly ApprovalService $approvalService, + private readonly TaskSequenceMapper $sequenceMapper, + private readonly TaskSequenceService $sequenceService, private readonly ApprovalChainAnnotationInstaller $installer, private readonly IUserSession $userSession, private readonly LoggerInterface $logger, @@ -81,7 +86,7 @@ public function __construct( * * @return void * - * @spec openspec/specs/approval-workflow/spec.md + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-007 */ public function handle(Event $event): void { if (($event instanceof ObjectUpdatingEvent) === false) { @@ -145,7 +150,6 @@ public function handle(Event $event): void { event: $event, schema: $schema, chainKey: $chainKey, - spec: $spec, object: $newObject, newData: $newData ); @@ -160,42 +164,39 @@ public function handle(Event $event): void { * transition. Returns true when the event was rejected (short-circuits the * caller's loop). * + * The store consulted is the SEQUENCE, not step rows: complete releases, + * running refuses without provisioning a duplicate, rejected or + * terminated (or none) provisions a NEW sequence and refuses. The tier + * is resolved from the attempted write ONCE, here, and frozen onto the + * sequence, so a mid-cycle amount edit cannot re-route a running + * approval. + * * @param ObjectUpdatingEvent $event The event being evaluated. * @param Schema $schema The object's schema. * @param string $chainKey Declarative chain key. - * @param array $spec The chain's declared spec. * @param ObjectEntity $object The object attempting the transition. * @param array $newData The object's new (attempted) data. * * @return bool True when the transition was blocked. * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-007 */ private function evaluateGate( ObjectUpdatingEvent $event, Schema $schema, string $chainKey, - array $spec, ObjectEntity $object, array $newData, ): bool { - // Idempotent: guarantees the chain row exists even if the installer - // hasn't run yet for this schema revision (e.g. schema saved before - // this capability existed). - $this->installer->installSchema(schema: $schema); - - $schemaId = $schema->getId(); - $chain = null; - if ($schemaId !== null) { - $chain = $this->chainMapper->findBySchemaAndName(schemaId: (int)$schemaId, name: $chainKey); - } + // Compiled on demand, so the gate never depends on a save-time + // listener having run for the current schema revision. + $template = $this->installer->compile(schema: $schema, chainKey: $chainKey); - if ($chain === null) { - // Declared but couldn't be provisioned (e.g. no valid approvers) — - // fail closed, mirroring LifecycleGuardRegistry's missing-tag policy. + if ($template === null) { + // Declared but not compilable (e.g. no valid approvers) — fail + // closed, mirroring LifecycleGuardRegistry's missing-tag policy. $this->logger->error( - sprintf('[ApprovalChainGateListener] chain "%s" could not be resolved for schema %s', $chainKey, (string)$schemaId) + sprintf('[ApprovalChainGateListener] chain "%s" could not be compiled for schema %s', $chainKey, (string)$schema->getId()) ); $this->reject( event: $event, @@ -206,51 +207,42 @@ private function evaluateGate( } $objectUuid = (string)$object->getUuid(); - $steps = $this->stepMapper->findByChainAndObject($chain->getId(), $objectUuid); - - if ($steps !== []) { - $anyRejected = false; - $allApproved = true; - foreach ($steps as $step) { - if ($step->getStatus() === 'rejected') { - $anyRejected = true; - } - - if ($step->getStatus() !== 'approved') { - $allApproved = false; - } - } + $newest = $this->sequenceMapper->findNewestForAnchor( + anchorObjectUuid: $objectUuid, + templateId: (string)$template['templateId'] + ); - if ($allApproved === true) { - // Release: every provisioned step is approved. - return false; - } + if ($newest !== null && $newest->getStatus() === TaskSequence::STATUS_COMPLETED) { + // Release: the approval completed with an approving outcome. + return false; + } - if ($anyRejected === true) { - // Terminal-failed cycle — clear it so a fresh attempt opens a - // new cycle (resubmission), then fall through to provision. - $this->stepMapper->deleteByChainAndObject($chain->getId(), $objectUuid); - } else { - // Still in progress — block again, no new rows. - $this->reject( - event: $event, - code: 'approval-chain-pending', - message: sprintf('Approval chain "%s" is still pending a decision.', $chainKey) - ); - return true; - } - }//end if + if ($newest !== null && $newest->getStatus() === TaskSequence::STATUS_RUNNING) { + // Still in progress — refuse again, provision nothing. + $this->reject( + event: $event, + code: 'approval-chain-pending', + message: sprintf('Approval chain "%s" is still pending a decision.', $chainKey) + ); + return true; + } - // No steps yet (first attempt, or a rejected cycle was just cleared): - // provision the applicable tier and block. + // No sequence yet, or the last one was rejected or terminated. The + // rejected cycle is KEPT (design D-5): a fresh attempt opens a NEW + // sequence beside it rather than deleting the record of who refused. $requesterId = $this->userSession->getUser()?->getUID(); - $stepsOverride = $this->resolveStepsOverride(spec: $spec, newData: $newData); + $register = (string)$object->getRegister(); + $registerId = null; + if (is_numeric($register) === true) { + $registerId = (int)$register; + } - $this->approvalService->initializeChain( - chain: $chain, - objectUuid: $objectUuid, + $this->sequenceService->provision( + template: $template, + anchorObjectUuid: $objectUuid, requesterId: $requesterId, - stepsOverride: $stepsOverride + tierPositions: $this->resolveTierPositions(template: $template, newData: $newData), + registerId: $registerId ); $this->reject( @@ -262,22 +254,23 @@ private function evaluateGate( }//end evaluateGate() /** - * Resolve the applicable step definitions for this object. + * Resolve the applicable tier for this object, frozen at provisioning. * - * When the spec declares `amountField`, selects the single `approvers` tier - * with the highest `minAmount` that is `<=` the object's value for that - * field. Otherwise returns `null` so `initializeChain()` falls back to the - * chain's own static `steps` (every declared tier, unchanged behaviour). + * When the declaration carries `amountField`, selects the single + * position with the highest `minAmount` that is `<=` the object's value + * for that field, re-based at order 1. Otherwise returns `null` so + * provisioning uses every declared position in order, unchanged. * - * @param array $spec The chain's declared spec. + * @param array $template The compiled template. * @param array $newData The object's new (attempted) data. * - * @return array>|null Step override, or null for no routing. + * @return array>|null The tier, or null for no routing. + * + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-008 */ - private function resolveStepsOverride(array $spec, array $newData): ?array { - $amountField = ($spec['amountField'] ?? null); - $approvers = ($spec['approvers'] ?? []); - if (is_string($amountField) === false || $amountField === '' || is_array($approvers) === false) { + private function resolveTierPositions(array $template, array $newData): ?array { + $amountField = (string)($template['amountField'] ?? ''); + if ($amountField === '') { return null; } @@ -285,19 +278,18 @@ private function resolveStepsOverride(array $spec, array $newData): ?array { $best = null; $bestMinAmount = -1.0; - foreach ($approvers as $tier) { - if (is_array($tier) === false) { + foreach ((array)($template['positions'] ?? []) as $position) { + if (is_array($position) === false) { continue; } - $role = (string)($tier['role'] ?? ''); - $minAmount = (float)($tier['minAmount'] ?? 0); - if ($role === '' || $minAmount > $amount) { + $minAmount = (float)($position['minAmount'] ?? 0); + if ($minAmount > $amount) { continue; } if ($best === null || $minAmount > $bestMinAmount) { - $best = $role; + $best = $position; $bestMinAmount = $minAmount; } } @@ -306,8 +298,10 @@ private function resolveStepsOverride(array $spec, array $newData): ?array { return null; } - return [['order' => 1, 'role' => $best]]; - }//end resolveStepsOverride() + $best['order'] = 1; + + return [$best]; + }//end resolveTierPositions() /** * Find the transition (action name) whose `to` matches the new value AND 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 deleted file mode 100644 index 67983fa00c..0000000000 --- a/lib/Listener/EventCatalogListener.php +++ /dev/null @@ -1,138 +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\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 - */ - public function handle(Event $event): void { - 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/FlowNodeRegistrationListener.php b/lib/Listener/FlowNodeRegistrationListener.php index 82c9b665d2..4d32a6fa97 100644 --- a/lib/Listener/FlowNodeRegistrationListener.php +++ b/lib/Listener/FlowNodeRegistrationListener.php @@ -29,23 +29,31 @@ 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; use OCA\OpenRegister\Service\Flow\Nodes\FlowStateNode; use OCA\OpenRegister\Service\Flow\Nodes\IterateNode; +use OCA\OpenRegister\Service\Flow\Nodes\LockObjectNode; 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\UnlockObjectNode; +use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; use OCA\OpenRegister\Service\Flow\Nodes\WaitNode; use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; use OCP\EventDispatcher\Event; @@ -68,6 +76,8 @@ class FlowNodeRegistrationListener implements IEventListener { * @param SwitchNode $switch The built-in "Switch" node. * @param EndNode $end The built-in "End" node. * @param MergeNode $merge The built-in "Merge" node. + * @param LockObjectNode $lockObject The built-in "Lock an object" node. + * @param UnlockObjectNode $unlockObject The built-in "Unlock an object" node. * @param LoopNode $loop The built-in "Loop over items" node. * @param SubFlowNode $subFlow The built-in "Run a flow" node. * @param RouterNode $router The built-in "Route items" node. @@ -76,9 +86,15 @@ 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. + * @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, @@ -89,6 +105,8 @@ public function __construct( private readonly SwitchNode $switch, private readonly EndNode $end, private readonly MergeNode $merge, + private readonly LockObjectNode $lockObject, + private readonly UnlockObjectNode $unlockObject, private readonly LoopNode $loop, private readonly SubFlowNode $subFlow, private readonly RouterNode $router, @@ -97,9 +115,15 @@ 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, + private readonly UserTaskNode $userTask, + private readonly PortalTaskNode $portalTask, + private readonly DecisionTableNode $decisionTable, ) { }//end __construct() @@ -135,6 +159,42 @@ 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 + // 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); + + // 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); + + // 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); + + // Mutual exclusion. Both are ordinary STEPS: a lock step is work the + // run performs, and the release that matters is the engine's terminal + // hook rather than the unlock node, so neither is an end node. + $event->registerNode(node: $this->lockObject); + $event->registerNode(node: $this->unlockObject); + // 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/FlowRunLockReleaseListener.php b/lib/Listener/FlowRunLockReleaseListener.php new file mode 100644 index 0000000000..a766514bc9 --- /dev/null +++ b/lib/Listener/FlowRunLockReleaseListener.php @@ -0,0 +1,107 @@ + + * 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/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Event\FlowRunTerminalEvent; +use OCA\OpenRegister\Service\Object\RunLockRegistry; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Releases every object lock a terminal run holds. + * + * @template-implements IEventListener + */ +class FlowRunLockReleaseListener implements IEventListener { + + /** + * Constructor. + * + * @param RunLockRegistry $locks The run-lock lifecycle. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly RunLockRegistry $locks, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Release the run's locks. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + public function handle(Event $event): void { + if ($event instanceof FlowRunTerminalEvent === false) { + return; + } + + try { + $released = $this->locks->releaseRunLocks(runUuid: $event->getRunUuid()); + if ($released > 0) { + $this->logger->info( + sprintf( + '[FlowRunLockReleaseListener] Released %d object lock(s) of run %s (%s).', + $released, + $event->getRunUuid(), + $event->getStatus() + ) + ); + } + } catch (Throwable $failure) { + // Never rethrow: see the class docblock. The TTL and the sweep + // remain as layers 3 and 2. + $this->logger->error( + '[FlowRunLockReleaseListener] Lock release failed: ' . $failure->getMessage(), + ['run' => $event->getRunUuid(), 'exception' => $failure] + ); + }//end try + }//end handle() +}//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/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/Listener/HookListener.php b/lib/Listener/HookListener.php deleted file mode 100644 index 6146f3ebe8..0000000000 --- a/lib/Listener/HookListener.php +++ /dev/null @@ -1,145 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/schema-hooks/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Listener; - -use Exception; -use OCA\OpenRegister\Db\ObjectEntity; -use OCA\OpenRegister\Db\SchemaMapper; -use OCA\OpenRegister\Event\ObjectCreatedEvent; -use OCA\OpenRegister\Event\ObjectCreatingEvent; -use OCA\OpenRegister\Event\ObjectDeletedEvent; -use OCA\OpenRegister\Event\ObjectDeletingEvent; -use OCA\OpenRegister\Event\ObjectUpdatedEvent; -use OCA\OpenRegister\Event\ObjectUpdatingEvent; -use OCA\OpenRegister\Service\HookExecutor; -use OCP\EventDispatcher\Event; -use OCP\EventDispatcher\IEventListener; -use Psr\Log\LoggerInterface; - -/** - * HookListener delegates object lifecycle events to HookExecutor - * - * Listens for ObjectCreatingEvent, ObjectUpdatingEvent, and ObjectDeletingEvent, - * resolves the schema from the object, and calls HookExecutor to run any - * configured hooks. - * - * @template-implements IEventListener - */ -class HookListener implements IEventListener { - /** - * Constructor for HookListener - * - * @param HookExecutor $hookExecutor Hook executor service - * @param SchemaMapper $schemaMapper Schema mapper for loading schemas - * @param LoggerInterface $logger Logger - */ - public function __construct( - private readonly HookExecutor $hookExecutor, - private readonly SchemaMapper $schemaMapper, - private readonly LoggerInterface $logger, - ) { - }//end __construct() - - /** - * Handle event by delegating to HookExecutor - * - * @param Event $event The lifecycle event - * - * @return void - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-configuration-on-schema - * @spec openspec/specs/schema-hooks/spec.md - */ - public function handle(Event $event): void { - $object = $this->getObjectFromEvent(event: $event); - if ($object === null) { - return; - } - - $schemaId = $object->getSchema(); - if ($schemaId === null || $schemaId === '' || $schemaId === '0') { - return; - } - - try { - $schema = $this->schemaMapper->find(id: (int)$schemaId); - } catch (Exception $e) { - $this->logger->debug( - message: '[HookListener] Could not load schema for hook execution', - context: [ - 'schemaId' => $schemaId, - 'error' => $e->getMessage(), - ] - ); - return; - } - - $hooks = ($schema->getHooks() ?? []); - if (empty($hooks) === true) { - return; - } - - $this->hookExecutor->executeHooks(event: $event, schema: $schema); - }//end handle() - - /** - * Extract the ObjectEntity from the event - * - * @param Event $event The lifecycle event - * - * @return ObjectEntity|null The object entity or null - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-configuration-on-schema - */ - private function getObjectFromEvent(Event $event): ?ObjectEntity { - if ($event instanceof ObjectCreatingEvent) { - return $event->getObject(); - } - - if ($event instanceof ObjectUpdatingEvent) { - return $event->getNewObject(); - } - - if ($event instanceof ObjectDeletingEvent) { - return $event->getObject(); - } - - if ($event instanceof ObjectCreatedEvent) { - return $event->getObject(); - } - - if ($event instanceof ObjectUpdatedEvent) { - return $event->getNewObject(); - } - - if ($event instanceof ObjectDeletedEvent) { - return $event->getObject(); - } - - return null; - }//end getObjectFromEvent() -}//end class diff --git a/lib/Listener/LeafScriptListener.php b/lib/Listener/LeafScriptListener.php new file mode 100644 index 0000000000..8cd33fb790 --- /dev/null +++ b/lib/Listener/LeafScriptListener.php @@ -0,0 +1,311 @@ +` injection at all, + * - humaniq and planninq register no `BeforeTemplateRenderedEvent` listener. + * + * So a descriptor reached OCS capability discovery and every server-side + * consumer, `getLeaves()` returned it, gate-24 went green on both halves — and + * the surface rendered NOTHING, on every consuming page, for as long as leaves + * have shipped. `humaniq-hours` has been dark on dossiq case pages the whole + * time. That is the failure shape ADR-113 is about: everything reports success + * and the feature is absent. + * + * WHAT IT LOADS, AND WHERE + * ------------------------ + * A dedicated `leaves` webpack entry per providing app, NOT the app's main + * bundle. A main bundle is megabytes and carries a whole SPA; putting one on + * another app's page would be a performance regression traded for a feature. + * An app that ships no `-leaves` build artifact is skipped, exactly as + * FilesSidebarListener skips its own missing bundle — so this can never enqueue + * a 404. + * + * Scope is deliberately narrow on both axes: + * + * - ONLY on pages of an app that itself ships an OpenRegister register + * descriptor. That is the same signal gate-55 uses to decide "is this + * register mine", it costs one filesystem check, and it keeps leaf bundles + * off Files, Photos, Mail and Settings, which host no OpenRegister objects. + * - NEVER the current app's own leaf: its bundle is already on its own page, + * and loading a second copy would register the id twice, which the AD-13 + * collision policy warns about in production and throws on in development. + * + * @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 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\AppInfo\Application; +use OCA\OpenRegister\Service\Integration\LeafDescriptor; +use OCA\OpenRegister\Service\Integration\LeafRegistry; +use OCA\OpenRegister\Service\ScriptManifestLoader; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IRequest; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Enqueues each providing app's leaf bundle on consuming apps' pages. + * + * @template-implements IEventListener + */ +class LeafScriptListener implements IEventListener { + + /** + * The webpack entry name a providing app must expose. + * + * @var string + */ + public const LEAF_ENTRY = 'leaves'; + + /** + * Constructor. + * + * @param LeafRegistry $leaves The collected leaf catalogue. + * @param IAppManager $appManager App installation + path lookups. + * @param IRequest $request The current request, for the app id. + * @param LoggerInterface $logger PSR-3 logger. + */ + public function __construct( + private readonly LeafRegistry $leaves, + private readonly IAppManager $appManager, + private readonly IRequest $request, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Enqueue the leaf bundles this page needs. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @SuppressWarnings(PHPMD.StaticAccess) ScriptManifestLoader is static for + * the same reason \OCP\Util::addScript it wraps is: there is no + * injectable DI equivalent in the AppFramework. + * + * @spec openspec/specs/integration-registry/spec.md#requirement-a-leafs-client-half-must-be-loaded-on-consuming-pages + */ + public function handle(Event $event): void { + if ($event instanceof BeforeTemplateRenderedEvent === false) { + return; + } + + // A public page has no OpenRegister objects to hang a leaf on, and + // loading another app's bundle there would widen the anonymous surface. + if ($event->isLoggedIn() === false) { + return; + } + + try { + $currentApp = $this->currentAppId(); + if ($currentApp === null || $currentApp === Application::APP_ID) { + return; + } + + foreach ($this->leafAppsFor(currentApp: $currentApp) as $providingApp) { + ScriptManifestLoader::addEntryScripts( + appId: $providingApp, + entry: self::LEAF_ENTRY, + fallbackScript: $providingApp . '-' . self::LEAF_ENTRY + ); + } + } catch (Throwable $e) { + // A page must render even when the catalogue cannot be read. The + // leaf is then absent, which is the pre-existing behaviour. + $this->logger->warning( + 'OpenRegister could not enqueue leaf scripts: ' . $e->getMessage(), + ['exception' => $e] + ); + }//end try + + }//end handle() + + /** + * The providing apps whose leaf bundles belong on this page. + * + * This is the whole rule, deliberately separated from the enqueuing so it + * can be tested: `ScriptManifestLoader` is static over `\OCP\Util`, and a + * listener that decides and acts in one method can only be verified by + * booting Nextcloud. + * + * @param string $currentApp The app whose page is rendering. + * + * @return string[] App ids to load, in catalogue order, without duplicates. + * + * @spec openspec/specs/integration-registry/spec.md#requirement-a-leafs-client-half-must-be-loaded-on-consuming-pages + */ + public function leafAppsFor(string $currentApp): array { + if ($currentApp === '' || $currentApp === Application::APP_ID) { + return []; + } + + // A page with no OpenRegister objects has nothing for a leaf to attach + // to. This keeps sibling bundles off Files, Photos, Mail and Settings. + if ($this->shipsRegisterDescriptor(appId: $currentApp) === false) { + return []; + } + + $apps = []; + foreach ($this->leaves->getDescriptors() as $descriptor) { + $providingApp = $this->bundleAppFor(descriptor: $descriptor, currentApp: $currentApp); + if ($providingApp === null || in_array($providingApp, $apps, true) === true) { + // One app may contribute several leaves; its bundle carries all + // of them and must be enqueued once. + continue; + } + + $apps[] = $providingApp; + } + + return $apps; + }//end leafAppsFor() + + /** + * The app whose bundle one descriptor needs on this page, if any. + * + * Split out of `leafAppsFor()` so that method reads as "collect the apps" + * and this one holds the four separate reasons a descriptor contributes + * nothing. Keeping all four in the loop body put `leafAppsFor()` over both + * the cyclomatic and the NPath threshold; the rule itself is unchanged. + * + * @param LeafDescriptor $descriptor The descriptor being considered. + * @param string $currentApp The app whose page is rendering. + * + * @return string|null The providing app id, or null when nothing is needed. + */ + private function bundleAppFor(LeafDescriptor $descriptor, string $currentApp): ?string { + if (in_array(LeafDescriptor::KIND_RENDER_SURFACE, $descriptor->getKinds(), true) === false) { + // A data-provider leaf has no client half to load. + return null; + } + + $providingApp = $descriptor->getRequiredApp(); + if ($providingApp === null || $providingApp === $currentApp) { + // Built-in leaves ride on OpenRegister's own bundle, and an + // app's own leaf is already loaded by its own page. Loading a + // second copy would register the id twice, which the AD-13 + // collision policy warns about in production and throws on in + // development. + return null; + } + + if ($this->appManager->isEnabledForUser($providingApp) === false) { + return null; + } + + if ($this->hasLeafBundle(appId: $providingApp) === false) { + // Enqueuing a script that does not exist is a 404 in the page, + // so an app that ships no leaf entry is simply skipped. + return null; + } + + return $providingApp; + }//end bundleAppFor() + + /** + * The app whose page is being rendered, from the request path. + * + * Nextcloud serves an app under BOTH `/apps//…` and + * `/index.php/apps//…`; the pattern below accepts either, because a + * visitor arriving on the other form is a real case and matching only one + * would silently drop the leaf for them. + * + * @return string|null The app id, or null when this is not an app page. + */ + private function currentAppId(): ?string { + $path = (string)$this->request->getPathInfo(); + if (preg_match('#(?:^|/)apps/([a-z0-9_.-]+)(?:/|$)#', $path, $matches) !== 1) { + return null; + } + + return $matches[1]; + }//end currentAppId() + + /** + * Whether an app ships an OpenRegister register descriptor of its own. + * + * The same signal gate-55 uses to decide which registers an app owns: + * a `lib/Settings/*register*.json`. An app with one consumes OpenRegister + * and may render objects; an app without one (Files, Mail, Settings) has + * nothing for a leaf to attach to. + * + * @param string $appId The app to test. + * + * @return boolean Whether it ships a register descriptor. + */ + private function shipsRegisterDescriptor(string $appId): bool { + $path = $this->appPath(appId: $appId); + if ($path === null) { + return false; + } + return glob($path . '/lib/Settings/*register*.json') !== []; + }//end shipsRegisterDescriptor() + + /** + * Whether an app ships a built leaf bundle. + * + * @param string $appId The providing app. + * + * @return boolean Whether `js/-leaves.js` exists. + */ + private function hasLeafBundle(string $appId): bool { + $path = $this->appPath(appId: $appId); + if ($path === null) { + return false; + } + return file_exists($path . '/js/' . $appId . '-' . self::LEAF_ENTRY . '.js'); + }//end hasLeafBundle() + + /** + * An app's filesystem path, or null when it cannot be resolved. + * + * @param string $appId The app. + * + * @return string|null The path. + */ + private function appPath(string $appId): ?string { + try { + return $this->appManager->getAppPath($appId); + } catch (Throwable) { + return null; + } + }//end appPath() +}//end class 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/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/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/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/Listener/TaskSequenceProgressListener.php b/lib/Listener/TaskSequenceProgressListener.php new file mode 100644 index 0000000000..14b0456fe5 --- /dev/null +++ b/lib/Listener/TaskSequenceProgressListener.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 + * + * @link https://OpenRegister.app + * + * @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 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Service\Task\TaskSequenceService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Drives sequence advance, rejection propagation and termination. + * + * @template-implements IEventListener + * + * @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 + */ +class TaskSequenceProgressListener implements IEventListener { + + /** + * Constructor. + * + * @param TaskSequenceService $sequences The sequence progression. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly TaskSequenceService $sequences, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle the event. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @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 handle(Event $event): void { + if ($event instanceof TaskTerminalEvent === false || $event->isCommitted() === false) { + return; + } + + try { + $this->sequences->onTaskTerminal(task: $event->getTask()); + } catch (Throwable $failure) { + $this->logger->error( + '[TaskSequenceProgressListener] Sequence progression failed for task ' + . $event->getTaskUuid() . ': ' . $failure->getMessage(), + ['exception' => $failure] + ); + } + }//end handle() +}//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/Listener/UserTaskTerminalListener.php b/lib/Listener/UserTaskTerminalListener.php new file mode 100644 index 0000000000..fcd1376836 --- /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 || $event->isCommitted() === 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/Migration/NAMING.md b/lib/Migration/NAMING.md index 34f2ab6137..3941b8de8a 100644 --- a/lib/Migration/NAMING.md +++ b/lib/Migration/NAMING.md @@ -30,3 +30,78 @@ hazard. **These files are therefore intentionally left as-is.** This is a documentation-only note (per OPS-12 in `CODE-REVIEW-IMPROVEMENT-PLAN.md`): record the inconsistency, standardise *going forward*, and never rename already-applied migrations. + +## A migration without a version bump reaches nobody + +**Every change that adds a file here MUST also move `` in +`appinfo/info.xml`, in the same change.** Not in the release bump PR that +follows it — in the same change. + +Nextcloud decides whether to read an app's migration directory at all by +comparing `` against the `installed_version` it recorded for that app. +Equal versions mean "already up to date": `occ upgrade` answers +`No upgrade required.`, **exits 0**, and never opens a single migration file. +Nothing is logged. Nothing fails. The table the feature needs is simply not +there, and the feature is absent with no error anywhere. + +Measured 2026-09-05 on a throwaway Nextcloud 34.0.3 rig running openregister +`2.0.15-unstable.20260905134511` from its release tarball: + +| step | result | +|---|---| +| add a migration creating a table, leave `` alone, `occ upgrade` | `No upgrade required.`, exit 0 | +| … table created? | no | +| … row in `oc_migrations`? | no | +| change **nothing but ``**, `occ upgrade` again | `Updated to 2.0.16` | +| … table created, migration recorded? | yes | + +The code was byte-identical across those two runs. One line of XML is the +entire gate. + +This is not hypothetical: on 2026-09-05 `development` carried four migrations +added since `` last moved, one of them the run-lock table +openregister#3444 depends on, and a live instance updated to that code got none +of them. + +`scripts/check-migration-version-bump.php` enforces the rule. It runs in +`Merge Hygiene` on every push and PR, in `composer check:strict`, and as a +warning from `.githooks/pre-commit`. + +## Do not trust `occ migrations:status` — it will tell you nothing is pending + +The command is Nextcloud's, and three of its five counting fields are wrong. +On the rig above, with one migration genuinely unrun, it reported: + +``` + >> Executed Migrations: 204 + >> Executed Unavailable Migrations: 204 + >> Available Migrations: 205 + >> New Migrations: 205 + >> Pending Migrations: None +``` + +Three separate defects, all in core: + +- **`Pending Migrations: None`** — `MigrationService::describeMigrationStep()` + builds the list as `if ($migration->name())`, and + `SimpleMigrationStep::name()` returns `''` unless a step overrides it. None of + openregister's 204 migrations override it, so this field reads `None` for this + app **always**, whatever the state of the database. +- **`New Migrations` and `Executed Unavailable Migrations`** — + `StatusCommand::getMigrationsInfos()` calls `array_keys()` on + `getAvailableVersions()`, which returns a **list**, so both fields diff + version strings against the integers `0..n` and report the full count on every + run. Hence 204 and 205 on an instance where nothing is wrong with either. +- `describeMigrationStep('lastest')` is called with the alias misspelled. Benign + here only by luck: `sortMigrations()` falls through to `strnatcmp`, and + `Version…` sorts below `lastest`, so nothing is filtered out. + +**The only honest pair in that output is `Executed Migrations` against +`Available Migrations`.** 204 of 205 means one migration has not run, no matter +what the line underneath says. Read those two numbers and ignore the rest. + +To recover an instance that is already in this state, either bump `` +and run `occ upgrade`, or run the migration directly with +`occ migrations:migrate openregister` (both `migrations:*` commands need +`debug` set to `true` in `config.php`, or they are not registered at all and +`occ` answers `Command "migrations:status" is not defined`). 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/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/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/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/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/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/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/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/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/Migration/Version1Date20260901180000.php b/lib/Migration/Version1Date20260901180000.php new file mode 100644 index 0000000000..d1950d82c7 --- /dev/null +++ b/lib/Migration/Version1Date20260901180000.php @@ -0,0 +1,206 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Storage for the approval consolidation (flow-approval-consolidation): + * + * - `openregister_task_sequences`: the ordered approval cycle. One row per + * attempt at a gated transition, holding the frozen template snapshot and + * the resolved tier, so a schema edit or an amount edit cannot re-shape an + * approval that is already running (design D-3). + * - `openregister_tasks` gains `sequence_uuid`, `sequence_position` and + * `legacy_step_id`. The unique index on (sequence_uuid, sequence_position) + * enforces ordinal stability at the database, because two writers + * provisioning the same gated write is the concurrent case. + * - `openregister_approval_steps` gains `migrated_task_uuid`: the + * reconciliation column the data migration writes, so migrated steps and + * the tasks they became reconcile by identity rather than by count. The + * legacy tables are NOT dropped. + * - `openregister_flow_runs` gains an indexed `correlation_key`, populated + * when an await-signal step suspends, so a signal addressed by business key + * resolves through an index instead of a JSON scan (design D-7). + * + * @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-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-an-approval-is-an-ordered-task-sequence-with-one-position-enabled-at-a-time + */ + +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-sequence table and the reconciliation and correlation columns. + * + * @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 + */ +class Version1Date20260901180000 extends SimpleMigrationStep { + + /** + * The sequence table. + */ + private const TABLE_SEQUENCES = 'openregister_task_sequences'; + + /** + * The task table. + */ + private const TABLE_TASKS = 'openregister_tasks'; + + /** + * The legacy step table. Kept, not dropped: the reconciliation column + * lands on it and the rollback path reads it. + */ + private const TABLE_STEPS = 'openregister_approval_steps'; + + /** + * The flow-run table. + */ + private const TABLE_RUNS = 'openregister_flow_runs'; + + /** + * Apply the schema changes. + * + * @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. + * + * @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 changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + + $this->createSequenceTable(schema: $schema); + $this->extendTaskTable(schema: $schema); + $this->extendStepTable(schema: $schema); + $this->extendRunTable(schema: $schema); + + return $schema; + }//end changeSchema() + + /** + * Create `openregister_task_sequences`. + * + * @param ISchemaWrapper $schema The schema to change. + * + * @return void + */ + private function createSequenceTable(ISchemaWrapper $schema): void { + if ($schema->hasTable(tableName: self::TABLE_SEQUENCES) === true) { + return; + } + + $table = $schema->createTable(tableName: self::TABLE_SEQUENCES); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('template_id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('template_version', Types::INTEGER, ['notnull' => false]); + $table->addColumn('template_snapshot', Types::JSON, ['notnull' => false]); + $table->addColumn('anchor_object_uuid', Types::STRING, ['notnull' => false, 'length' => 36]); + $table->addColumn('register_id', Types::BIGINT, ['notnull' => false]); + $table->addColumn('schema_id', Types::BIGINT, ['notnull' => false]); + $table->addColumn('chain_key', Types::STRING, ['notnull' => false, 'length' => 255]); + $table->addColumn('requester_id', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('resolved_tier', Types::JSON, ['notnull' => false]); + $table->addColumn('position_cursor', Types::INTEGER, ['notnull' => true, 'default' => 1]); + $table->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 20]); + $table->addColumn('outcome', 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('opened_at', Types::DATETIME_MUTABLE, ['notnull' => true]); + $table->addColumn('closed_at', Types::DATETIME_MUTABLE, ['notnull' => false]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['uuid'], 'or_taskseq_uuid'); + $table->addIndex(['anchor_object_uuid', 'template_id'], 'or_taskseq_anchor'); + $table->addIndex(['status', 'template_id'], 'or_taskseq_status'); + }//end createSequenceTable() + + /** + * Add the sequence and reconciliation columns to `openregister_tasks`. + * + * @param ISchemaWrapper $schema The schema to change. + * + * @return void + */ + private function extendTaskTable(ISchemaWrapper $schema): void { + if ($schema->hasTable(tableName: self::TABLE_TASKS) === false) { + return; + } + + $table = $schema->getTable(tableName: self::TABLE_TASKS); + if ($table->hasColumn('sequence_uuid') === false) { + $table->addColumn('sequence_uuid', Types::STRING, ['notnull' => false, 'length' => 36]); + } + + if ($table->hasColumn('sequence_position') === false) { + $table->addColumn('sequence_position', Types::INTEGER, ['notnull' => false]); + } + + if ($table->hasColumn('legacy_step_id') === false) { + $table->addColumn('legacy_step_id', Types::BIGINT, ['notnull' => false]); + } + + if ($table->hasIndex('or_tasks_sequence') === false) { + // UNIQUE: two positions must not share an ordinal within one + // sequence. Rows with a null sequence_uuid stay unconstrained. + $table->addUniqueIndex(['sequence_uuid', 'sequence_position'], 'or_tasks_sequence'); + } + }//end extendTaskTable() + + /** + * Add `migrated_task_uuid` to the kept legacy step table. + * + * @param ISchemaWrapper $schema The schema to change. + * + * @return void + */ + private function extendStepTable(ISchemaWrapper $schema): void { + if ($schema->hasTable(tableName: self::TABLE_STEPS) === false) { + return; + } + + $table = $schema->getTable(tableName: self::TABLE_STEPS); + if ($table->hasColumn('migrated_task_uuid') === false) { + $table->addColumn('migrated_task_uuid', Types::STRING, ['notnull' => false, 'length' => 36]); + } + }//end extendStepTable() + + /** + * Add the indexed `correlation_key` to `openregister_flow_runs`. + * + * @param ISchemaWrapper $schema The schema to change. + * + * @return void + */ + private function extendRunTable(ISchemaWrapper $schema): void { + if ($schema->hasTable(tableName: self::TABLE_RUNS) === false) { + return; + } + + $table = $schema->getTable(tableName: self::TABLE_RUNS); + if ($table->hasColumn('correlation_key') === false) { + $table->addColumn('correlation_key', Types::STRING, ['notnull' => false, 'length' => 255]); + } + + if ($table->hasIndex('or_flow_runs_correlation') === false) { + $table->addIndex(['status', 'correlation_key'], 'or_flow_runs_correlation'); + } + }//end extendRunTable() +}//end class 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/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/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/Migration/Version1Date20260903150000.php b/lib/Migration/Version1Date20260903150000.php new file mode 100644 index 0000000000..afe0567bf8 --- /dev/null +++ b/lib/Migration/Version1Date20260903150000.php @@ -0,0 +1,166 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Drop the external workflow-engine tables. + * + * OpenRegister is the only home for a flow engine in this fleet (ADR-065), and + * the external-engine layer that predated that decision has been removed. These + * six tables have nothing left to read or write them. + * + * The step REPORTS the scheduled workflows it is about to destroy before it + * drops anything. They are not recoverable state — every one measured on the + * dev instance named `engine=openconnector`, an engine type the registry never + * supported, and had `last_status=error` — but they do encode INTENT that + * somebody wrote down, and that intent is worth putting in the upgrade output + * rather than deleting in silence. Re-create them as flows. + * + * @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/retire-external-workflow-engines/specs/single-flow-engine/spec.md#requirement-the-external-engine-tables-are-removed-req-sfe-102 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; +use Throwable; + +/** + * Removes the tables the external engine layer owned. + * + * @spec openspec/changes/retire-external-workflow-engines/specs/single-flow-engine/spec.md#requirement-the-external-engine-tables-are-removed-req-sfe-102 + */ +class Version1Date20260903150000 extends SimpleMigrationStep { + + /** + * The tables the external engine layer owned. + * + * @var array + */ + private const TABLES = [ + 'openregister_workflow_engines', + 'openregister_deployed_workflows', + 'openregister_scheduled_workflows', + 'openregister_workflow_executions', + 'openregister_actions', + 'openregister_action_logs', + ]; + + /** + * Wire the database connection, used only to report before dropping. + * + * @param IDBConnection $db The database connection. + * + * @return void + */ + public function __construct(private readonly IDBConnection $db) { + }//end __construct() + + /** + * Name every scheduled workflow that is about to be destroyed. + * + * Runs BEFORE the schema change, because afterwards there is nothing left to + * read. A schedule is a statement of intent — "submit the quarterly CBS + * figures" — and dropping the row without printing it makes that intent + * disappear with no trace in the upgrade log. + * + * @param IOutput $output Migration output. + * @param Closure(): ISchemaWrapper $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The interface fixes the signature. + * + * @spec openspec/changes/retire-external-workflow-engines/specs/single-flow-engine/spec.md#requirement-the-external-engine-tables-are-removed-req-sfe-102 + */ + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + try { + // Built through the query builder, not raw SQL. The first version of + // this used backticks around the table name, which is MySQL syntax: + // on Postgres it threw, the catch below swallowed it, and the report + // never fired — silently destroying the very intent this method + // exists to preserve. Measured on a Postgres instance. + $qb = $this->db->getQueryBuilder(); + $qb->select('name', 'engine', 'interval_sec') + ->from('openregister_scheduled_workflows'); + $rows = $qb->executeQuery()->fetchAll(); + } catch (Throwable $e) { + // No table, or it is already gone. Nothing to report. + return; + } + + if ($rows === []) { + return; + } + + $output->warning( + message: sprintf( + '%d scheduled workflow(s) are being removed with the external engine layer. ' + . 'Re-create them as OpenRegister flows:', + count($rows) + ) + ); + + foreach ($rows as $row) { + $output->warning( + message: sprintf( + ' %s (engine=%s, every %ds)', + (string)($row['name'] ?? 'unnamed'), + (string)($row['engine'] ?? 'unknown'), + (int)($row['interval_sec'] ?? 0) + ) + ); + } + }//end preSchemaChange() + + /** + * Drop each table, 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/retire-external-workflow-engines/specs/single-flow-engine/spec.md#requirement-the-external-engine-tables-are-removed-req-sfe-102 + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + $dropped = []; + + foreach (self::TABLES as $table) { + if ($schema->hasTable($table) === false) { + continue; + } + + $schema->dropTable($table); + $dropped[] = $table; + } + + if ($dropped === []) { + return null; + } + + $output->info(message: 'External workflow engine tables dropped: ' . implode(', ', $dropped)); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version1Date20260905120000.php b/lib/Migration/Version1Date20260905120000.php new file mode 100644 index 0000000000..de10d644ca --- /dev/null +++ b/lib/Migration/Version1Date20260905120000.php @@ -0,0 +1,89 @@ + + * 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/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + +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 `openregister_run_object_locks`. + */ +class Version1Date20260905120000 extends SimpleMigrationStep { + + private const LOCKS = 'openregister_run_object_locks'; + + /** + * Create the table. + * + * @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. + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + + $schema = $schemaClosure(); + + if ($schema->hasTable(self::LOCKS) === true) { + return null; + } + + $table = $schema->createTable(self::LOCKS); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20]); + $table->addColumn('run_uuid', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('object_uuid', Types::STRING, ['notnull' => true, 'length' => 40]); + $table->addColumn('register_id', Types::STRING, ['notnull' => false, 'length' => 64, 'default' => null]); + $table->addColumn('schema_id', Types::STRING, ['notnull' => false, 'length' => 64, 'default' => null]); + $table->addColumn('node_id', Types::STRING, ['notnull' => false, 'length' => 255, 'default' => null]); + $table->addColumn('locked_at', Types::DATETIME_MUTABLE, ['notnull' => true]); + $table->addColumn('expires_at', Types::DATETIME_MUTABLE, ['notnull' => false, 'default' => null]); + $table->setPrimaryKey(['id']); + // A run holds an object once: re-locking extends rather than stacks. + $table->addUniqueIndex(['run_uuid', 'object_uuid'], 'or_runlock_run_obj_uq'); + // The terminal listener's read. + $table->addIndex(['run_uuid'], 'or_runlock_run_idx'); + // The sweep's second predicate. + $table->addIndex(['expires_at'], 'or_runlock_exp_idx'); + + $output->info(message: 'Created ' . self::LOCKS); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Notification/AnnotationNotifier.php b/lib/Notification/AnnotationNotifier.php index d320aa1a30..7a9c37a51d 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; } @@ -190,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). @@ -202,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; @@ -228,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/Notification/Notifier.php b/lib/Notification/Notifier.php index 3d40bc679e..e4b391a80c 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -153,6 +153,8 @@ public function prepare(INotification $notification, string $languageCode): INot return $this->prepareScheduledReportFailed(notification: $notification, l: $l); case 'delegation_consent_requested': return $this->prepareDelegationConsentRequested(notification: $notification, l: $l); + case 'credential_relink_needed': + return $this->prepareCredentialRelinkNeeded(notification: $notification, l: $l); default: // Unknown subject. Object-lifecycle subjects // (object_created / object_updated / object_transitioned) @@ -256,6 +258,62 @@ private function prepareHandoffDrainFailed(INotification $notification, $l): INo return $notification; }//end prepareHandoffDrainFailed() + /** + * Render a connected account whose grant the provider has revoked. + * + * THIS CASE IS WHAT MAKES THE NOTIFICATION REAL. `IManager::notify()` accepts a + * subject no notifier prepares and then drops it on the way to the screen, so a + * relink announcement without an arm here would fire, be logged as sent, and be + * seen by nobody. That is the shape of failure the whole relink lifecycle exists + * to avoid: a connection that quietly stops working. + * + * Every word is server-authored, and the provider is a catalogue identifier + * rather than anything a person typed. The message says what stopped, why it + * cannot fix itself, and what to do, because a person meeting this in a + * notification list has no other context for it. + * + * There is deliberately NO action button. Reconnecting means being sent to the + * provider's own consent screen, which is a `POST` returning a URL to follow + * rather than a link a notification can carry; a button that appeared to do it + * and did not would be worse than the sentence that names the page. + * + * @param INotification $notification The notification to prepare. + * @param mixed $l The localization instance. + * + * @return INotification The prepared notification. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-invalid-grant-moves-the-credential-to-relink-needed-and-fails-closed + */ + private function prepareCredentialRelinkNeeded(INotification $notification, $l): INotification { + $provider = (string)($notification->getSubjectParameters()['provider'] ?? ''); + + $notification->setParsedSubject( + $l->t('Reconnect your %s account', [$provider]) + ); + + $notification->setParsedMessage( + $l->t( + 'The connection to %s is no longer accepted by the provider, so anything using it has ' + . 'stopped. This cannot be repaired automatically: open Connected accounts in your ' + . 'personal settings and reconnect the account.', + [$provider] + ) + ); + + $notification->setLink( + $this->urlGenerator->linkToRouteAbsolute( + 'settings.PersonalSettings.index', + ['section' => 'additional'] + ) + ); + + $notification->setIcon( + $this->urlGenerator->imagePath(appName: 'openregister', file: 'app.svg') + ); + + return $notification; + }//end prepareCredentialRelinkNeeded() + /** * Render a request to act on somebody's behalf. * 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/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/MigrateApprovalChainsToTasks.php b/lib/Repair/MigrateApprovalChainsToTasks.php new file mode 100644 index 0000000000..626bf518b7 --- /dev/null +++ b/lib/Repair/MigrateApprovalChainsToTasks.php @@ -0,0 +1,749 @@ + + * 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-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-every-in-flight-approval-survives-the-migration-at-the-same-position + */ + +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 OCA\OpenRegister\Db\TaskSequence; +use OCA\OpenRegister\Db\TaskSequenceMapper; +use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Symfony\Component\Uid\Uuid; +use Throwable; + +/** + * Converts chains to templates, step sets to sequences and steps to tasks. + * + * @SuppressWarnings(PHPMD.StaticAccess) Uuid::v4() is the codebase's uuid idiom. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The migration touches both + * worlds by definition: the legacy tables it reads and every store a task is + * made of. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The branching IS the + * contract: per-status mapping, per-stage idempotency guards and a five-part + * verification, each required by the spec to exist separately. + */ +class MigrateApprovalChainsToTasks implements IRepairStep { + + /** + * The acting identity recorded on migration-created audit entries. + * + * @var string + */ + public const ACTOR = 'migration:flow-approval-consolidation'; + + /** + * The app-config key carrying the cutover timestamp. + * + * @var string + */ + public const CUTOVER_CONFIG_KEY = 'approval_consolidation_cutover'; + + /** + * Deterministic template-id namespace. MUST equal the one + * {@see \OCA\OpenRegister\Service\ApprovalChainAnnotationInstaller} + * derives from, so the gate finds the sequences this step opens. + * + * @var string + */ + private const TEMPLATE_ID_NS = 'or-approval-template'; + + /** + * Constructor. + * + * @param IDBConnection $db Reads the legacy tables and writes the reconciliation column. + * @param TaskBuilder $builder Validates and builds each migrated task. + * @param TaskMapper $tasks Inserts tasks (insert dispatches nothing). + * @param TaskCandidateMapper $candidates Maintains the candidate index. + * @param TaskAuditMapper $audits Appends the migrated decision history. + * @param TaskSequenceMapper $sequences Inserts one sequence per (chain, object). + * @param IAppConfig $config Records the cutover timestamp. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly IDBConnection $db, + private readonly TaskBuilder $builder, + private readonly TaskMapper $tasks, + private readonly TaskCandidateMapper $candidates, + private readonly TaskAuditMapper $audits, + private readonly TaskSequenceMapper $sequences, + private readonly IAppConfig $config, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The repair step's name, shown by `occ maintenance:repair`. + * + * @return string The name. + */ + public function getName(): string { + return 'Migrate OpenRegister approval chains and steps onto task sequences'; + }//end getName() + + /** + * Run the migration: chains to templates, steps to tasks, then verify. + * + * @param IOutput $output Repair output. + * + * @return void + * + * @throws RuntimeException When the verification cannot reconcile. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-every-in-flight-approval-survives-the-migration-at-the-same-position + */ + public function run(IOutput $output): void { + try { + $chains = $this->rows(sql: 'SELECT * FROM `*PREFIX*openregister_approval_chains`'); + } catch (Throwable $absent) { + // A fresh install may not carry the legacy tables at repair time. + $output->info('No legacy approval tables found; nothing to migrate.'); + + return; + } + + $migrated = 0; + foreach ($chains as $chain) { + $migrated += $this->migrateChain(chain: $chain); + } + + $this->verify(); + + if ($migrated > 0 && $this->config->getValueString('openregister', self::CUTOVER_CONFIG_KEY, '') === '') { + $this->config->setValueString('openregister', self::CUTOVER_CONFIG_KEY, (new DateTime())->format('c')); + } + + $output->info(sprintf('Approval migration verified; %d step(s) migrated onto tasks this run.', $migrated)); + }//end run() + + /** + * Migrate every (object) step set of one chain. + * + * @param array $chain The legacy chain row. + * + * @return int How many steps were migrated this run. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-every-in-flight-approval-survives-the-migration-at-the-same-position + */ + private function migrateChain(array $chain): int { + $steps = $this->rows( + sql: 'SELECT * FROM `*PREFIX*openregister_approval_steps` WHERE `chain_id` = ? ORDER BY `object_uuid`, `step_order`', + params: [(int)$chain['id']] + ); + + $byObject = []; + foreach ($steps as $step) { + $byObject[(string)$step['object_uuid']][] = $step; + } + + $template = $this->templateFor(chain: $chain); + $migrated = 0; + foreach ($byObject as $objectUuid => $objectSteps) { + $migrated += $this->migrateStepSet( + chain: $chain, + template: $template, + objectUuid: (string)$objectUuid, + steps: $objectSteps + ); + } + + return $migrated; + }//end migrateChain() + + /** + * Migrate one (chain, object) step set onto one sequence. + * + * Idempotent per step: a step carrying `migrated_task_uuid` is never + * touched again, and the sequence is recovered from an already-migrated + * sibling rather than opened a second time. + * + * @param array $chain The legacy chain row. + * @param array $template The derived template. + * @param string $objectUuid The object the approval is about. + * @param array> $steps The object's step rows, ordinal order. + * + * @return int How many steps were migrated this run. + */ + private function migrateStepSet(array $chain, array $template, string $objectUuid, array $steps): int { + $pending = array_filter($steps, static fn (array $step): bool => (string)$step['status'] === 'pending'); + $unmigrated = array_filter($steps, static fn (array $step): bool => trim((string)($step['migrated_task_uuid'] ?? '')) === ''); + if ($unmigrated === []) { + return 0; + } + + $sequence = $this->sequenceFor( + chain: $chain, + template: $template, + objectUuid: $objectUuid, + steps: $steps, + pendingOrdinal: $this->firstOrdinal(steps: $pending) + ); + + $count = 0; + foreach ($unmigrated as $step) { + $this->migrateStep(sequence: $sequence, step: $step, chainName: (string)$chain['name']); + $count++; + } + + return $count; + }//end migrateStepSet() + + /** + * Recover or open the sequence for a step set. + * + * @param array $chain The legacy chain row. + * @param array $template The derived template. + * @param string $objectUuid The anchor. + * @param array> $steps All the set's step rows. + * @param int|null $pendingOrdinal The ordinal that was pending, when any. + * + * @return TaskSequence The sequence the set's tasks belong to. + */ + private function sequenceFor(array $chain, array $template, string $objectUuid, array $steps, ?int $pendingOrdinal): TaskSequence { + foreach ($steps as $step) { + $taskUuid = trim((string)($step['migrated_task_uuid'] ?? '')); + if ($taskUuid === '') { + continue; + } + + try { + $existing = $this->tasks->findByUuid(uuid: $taskUuid); + $sequenceUuid = trim((string)$existing->getSequenceUuid()); + if ($sequenceUuid !== '') { + return $this->sequences->findByUuid(uuid: $sequenceUuid); + } + } catch (Throwable $gone) { + $this->logger->warning( + '[MigrateApprovalChainsToTasks] Reconciliation points at a missing task or sequence: ' . $gone->getMessage() + ); + } + } + + [$status, $outcome] = $this->sequenceOutcome(steps: $steps); + $requester = $this->firstRequester(steps: $steps); + + $sequence = new TaskSequence(); + $sequence->setUuid(Uuid::v4()->toRfc4122()); + $sequence->setTemplateId((string)$template['templateId']); + $sequence->setTemplateVersion((int)$template['templateVersion']); + $sequence->setTemplateSnapshot($template); + $sequence->setAnchorObjectUuid($objectUuid); + $schemaId = ($chain['schema_id'] ?? null); + if ($schemaId !== null) { + $schemaId = (int)$schemaId; + } + + $sequence->setSchemaId($schemaId); + $sequence->setChainKey((string)$chain['name']); + $sequence->setRequesterId($requester); + $sequence->setPositionCursor($pendingOrdinal ?? (int)($steps[0]['step_order'] ?? 1)); + $sequence->setStatus($status); + $sequence->setOutcome($outcome); + $sequence->setOpenedAt($this->parseDbDate(value: ($steps[0]['created'] ?? null)) ?? new DateTime()); + if ($status !== TaskSequence::STATUS_RUNNING) { + $sequence->setClosedAt(new DateTime()); + } + + return $this->sequences->insert($sequence); + }//end sequenceFor() + + /** + * The (status, outcome) a migrated step set closes with. + * + * Rejected wins, then fully approved, then running while anything is + * open; a set with nothing open and nothing approved is closed as + * terminated, never left running (Migration Plan step 5). + * + * @param array> $steps The set's step rows. + * + * @return array{0: string, 1: string|null} Status and outcome. + */ + private function sequenceOutcome(array $steps): array { + $statuses = array_map(static fn (array $step): string => (string)$step['status'], $steps); + if (in_array('rejected', $statuses, true) === true) { + return [TaskSequence::STATUS_REJECTED, 'rejected']; + } + + if ($statuses !== [] && array_diff($statuses, ['approved']) === []) { + return [TaskSequence::STATUS_COMPLETED, 'approved']; + } + + if (in_array('pending', $statuses, true) === false && in_array('waiting', $statuses, true) === false) { + return [TaskSequence::STATUS_TERMINATED, 'terminated']; + } + + return [TaskSequence::STATUS_RUNNING, null]; + }//end sequenceOutcome() + + /** + * The first recorded requester in a step set, or null. + * + * @param array> $steps The set's step rows. + * + * @return string|null The requester uid. + */ + private function firstRequester(array $steps): ?string { + foreach ($steps as $step) { + $candidate = trim((string)($step['requester_id'] ?? '')); + if ($candidate !== '') { + return $candidate; + } + } + + return null; + }//end firstRequester() + + /** + * Convert ONE step row into a task, its candidates and its audit. + * + * @param TaskSequence $sequence The owning sequence. + * @param array $step The legacy step row. + * @param string $chainName The chain's name, for the task title. + * + * @return void + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-a-decided-approval-keeps-its-decision-its-actor-and-its-comment + */ + private function migrateStep(TaskSequence $sequence, array $step, string $chainName): void { + $status = (string)$step['status']; + $role = trim((string)$step['role']); + $candidateGroups = null; + if ($role !== '') { + $candidateGroups = [$role]; + } + + $stateByStatus = [ + 'pending' => Task::STATE_ENABLED, + 'waiting' => Task::STATE_AVAILABLE, + 'approved' => 'approved', + 'rejected' => 'rejected', + ]; + + $task = $this->builder->fromData( + data: [ + 'title' => sprintf('Approve %s (step %d)', $chainName, (int)$step['step_order']), + 'description' => sprintf( + 'Approval step %d for %s on object %s, migrated from the retired approval chain engine.', + (int)$step['step_order'], + $chainName, + (string)$step['object_uuid'] + ), + 'state' => ($stateByStatus[$status] ?? Task::STATE_AVAILABLE), + 'performerType' => Task::PERFORMER_GROUP, + 'candidateGroups' => $candidateGroups, + 'routingStrategy' => 'single-role', + 'requester' => $sequence->getRequesterId(), + 'objectUuid' => (string)$step['object_uuid'], + 'schemaId' => $sequence->getSchemaId(), + 'templateId' => $sequence->getTemplateId(), + 'templateVersion' => $sequence->getTemplateVersion(), + 'sequenceUuid' => $sequence->getUuid(), + 'sequencePosition' => (int)$step['step_order'], + 'legacyStepId' => (int)$step['id'], + ], + actor: self::ACTOR + ); + + // Provenance the builder does not take from boundary data: the + // ORIGINAL creation time, decider and decision time survive. + $task->setCreated($this->parseDbDate(value: ($step['created'] ?? null))); + if ($task->isInTerminalState() === true) { + $task->setCompletedBy($this->nullableString(value: ($step['decided_by'] ?? null))); + $task->setCompletedAt($this->parseDbDate(value: ($step['decided_at'] ?? null))); + $task->setComment($this->nullableString(value: ($step['comment'] ?? null))); + } + + $persisted = $this->tasks->insert($task); + $this->indexCandidates(task: $persisted); + + $this->appendAudit( + task: $persisted, + action: 'create', + stateAfter: (string)$persisted->getState(), + actor: self::ACTOR, + reason: sprintf('Migrated from approval step %d.', (int)$step['id']), + created: $persisted->getCreated() + ); + + if ($persisted->isInTerminalState() === true) { + $decisionActor = self::ACTOR; + if ((string)($step['decided_by'] ?? '') !== '') { + $decisionActor = (string)$step['decided_by']; + } + + $this->appendAudit( + task: $persisted, + action: 'complete', + stateAfter: (string)$persisted->getState(), + actor: $decisionActor, + reason: trim(sprintf('[migrated decision] %s', (string)($step['comment'] ?? ''))), + created: $this->parseDbDate(value: ($step['decided_at'] ?? null)) + ); + } + + $update = $this->db->getQueryBuilder(); + $update->update('openregister_approval_steps') + ->set('migrated_task_uuid', $update->createNamedParameter((string)$persisted->getUuid())) + ->where($update->expr()->eq('id', $update->createNamedParameter((int)$step['id']))); + $update->executeStatement(); + }//end migrateStep() + + /** + * Verify the migration reconciles, or STOP naming the failure. + * + * @return void + * + * @throws RuntimeException Naming the chain, the object and the step. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-every-in-flight-approval-survives-the-migration-at-the-same-position + */ + private function verify(): void { + $steps = $this->rows(sql: 'SELECT * FROM `*PREFIX*openregister_approval_steps`'); + + $enabledExpected = 0; + foreach ($steps as $step) { + $enabledExpected += $this->verifyStep(step: $step); + } + + $this->verifySingleRunningSequences(); + $this->verifyEnabledCount(expected: $enabledExpected); + }//end verify() + + /** + * Verify ONE step's reconciliation, or STOP naming it. + * + * @param array $step The legacy step row. + * + * @return int One when the step was `pending` (it must be the enabled + * task), zero otherwise. + * + * @throws RuntimeException Naming the chain, the object and the step. + */ + private function verifyStep(array $step): int { + $status = (string)$step['status']; + $where = sprintf( + 'chain %d, object %s, step %d', + (int)$step['chain_id'], + (string)$step['object_uuid'], + (int)$step['id'] + ); + + $taskUuid = trim((string)($step['migrated_task_uuid'] ?? '')); + if ($taskUuid === '') { + throw new RuntimeException( + 'Approval migration failed: no migrated task recorded for ' . $where . '. The migration did NOT succeed.' + ); + } + + try { + $task = $this->tasks->findByUuid(uuid: $taskUuid); + } catch (Throwable $gone) { + throw new RuntimeException( + 'Approval migration failed: migrated task ' . $taskUuid . ' for ' . $where . ' does not exist. The migration did NOT succeed.' + ); + } + + if ((int)$task->getSequencePosition() !== (int)$step['step_order']) { + throw new RuntimeException(sprintf( + 'Approval migration failed: task %s sits at ordinal %d while its step holds %d (%s). The migration did NOT succeed.', + $taskUuid, + (int)$task->getSequencePosition(), + (int)$step['step_order'], + $where + )); + } + + $openStep = ($status === 'pending' || $status === 'waiting'); + if ($openStep === true && $task->isInTerminalState() === true) { + throw new RuntimeException( + 'Approval migration failed: non-terminal ' . $where . ' maps to terminal task ' . $taskUuid . '. The migration did NOT succeed.' + ); + } + + if ($status !== 'pending') { + return 0; + } + + if ((string)$task->getState() !== Task::STATE_ENABLED) { + throw new RuntimeException( + 'Approval migration failed: pending ' . $where . ' maps to a task that is not enabled. The migration did NOT succeed.' + ); + } + + return 1; + }//end verifyStep() + + /** + * No (anchor, template) may hold two running sequences. + * + * @return void + * + * @throws RuntimeException Naming the anchor and template. + */ + private function verifySingleRunningSequences(): void { + $rows = $this->rows( + sql: 'SELECT `anchor_object_uuid`, `template_id`, COUNT(*) AS `n` FROM `*PREFIX*openregister_task_sequences`' + . " WHERE `status` = 'running' GROUP BY `anchor_object_uuid`, `template_id` HAVING COUNT(*) > 1" + ); + if ($rows !== []) { + throw new RuntimeException(sprintf( + 'Approval migration failed: object %s holds %d running sequences for template %s. The migration did NOT succeed.', + (string)$rows[0]['anchor_object_uuid'], + (int)$rows[0]['n'], + (string)$rows[0]['template_id'] + )); + } + }//end verifySingleRunningSequences() + + /** + * The enabled migrated-task count must equal the pending step count. + * + * @param int $expected The number of steps that were `pending`. + * + * @return void + * + * @throws RuntimeException Naming both counts. + */ + private function verifyEnabledCount(int $expected): void { + $rows = $this->rows( + sql: 'SELECT COUNT(*) AS `n` FROM `*PREFIX*openregister_tasks`' + . " WHERE `legacy_step_id` IS NOT NULL AND `state` = 'enabled'" + ); + $actual = (int)($rows[0]['n'] ?? 0); + if ($actual !== $expected) { + throw new RuntimeException(sprintf( + 'Approval migration failed: %d enabled migrated task(s) against %d pending step(s). The migration did NOT succeed.', + $actual, + $expected + )); + } + }//end verifyEnabledCount() + + /** + * The derived template for a chain row. + * + * Deterministic and identical to what the annotation compiler derives + * for the same (schema, chain key), so gate lookups and migrated + * sequences meet on one template id. Separation of duties is recorded as + * OFF for a chain the migration cannot see a declaration for, which is + * exactly the retired engine's behaviour for pure-CRUD chains. + * + * @param array $chain The legacy chain row. + * + * @return array The template. + */ + private function templateFor(array $chain): array { + $positions = []; + $declared = json_decode((string)($chain['steps'] ?? '[]'), true); + if (is_array($declared) === true) { + $order = 1; + foreach ($declared as $entry) { + if (is_array($entry) === false || trim((string)($entry['role'] ?? '')) === '') { + continue; + } + + $positions[] = [ + 'order' => (int)($entry['order'] ?? $order), + 'role' => trim((string)$entry['role']), + 'statusOnApprove' => ($entry['statusOnApprove'] ?? null), + 'statusOnReject' => ($entry['statusOnReject'] ?? null), + ]; + $order++; + } + } + + $schemaId = ($chain['schema_id'] ?? null); + $schemaPart = 'legacy:' . (string)$chain['id']; + if ($schemaId !== null) { + $schemaId = (int)$schemaId; + $schemaPart = (string)$schemaId; + } + + $hash = md5(self::TEMPLATE_ID_NS . ':' . $schemaPart . ':' . (string)$chain['name']); + + return [ + 'templateId' => sprintf( + '%s-%s-%s-%s-%s', + substr($hash, 0, 8), + substr($hash, 8, 4), + substr($hash, 12, 4), + substr($hash, 16, 4), + substr($hash, 20, 12) + ), + 'templateVersion' => 1, + 'name' => (string)$chain['name'], + 'schemaId' => $schemaId, + 'separationOfDuties' => false, + 'migratedFromChainId' => (int)$chain['id'], + 'positions' => $positions, + ]; + }//end templateFor() + + /** + * Rebuild the candidate index rows for a migrated task. + * + * @param Task $task The persisted task. + * + * @return void + */ + private function indexCandidates(Task $task): void { + $rows = []; + foreach (($task->getCandidateGroups() ?? []) as $groupId) { + $rows[] = [ + 'kind' => 'group', + 'ref' => (string)$groupId, + ]; + } + + if ($rows !== []) { + $this->candidates->replaceForTask(taskId: (int)$task->getId(), candidates: $rows); + } + }//end indexCandidates() + + /** + * Append one audit entry. + * + * @param Task $task The task audited. + * @param string $action The audited action. + * @param string $stateAfter The state the entry records. + * @param string $actor The acting identity — the ORIGINAL decider for a + * migrated decision, kept even when no such user + * exists any more. + * @param string $reason The reason or migrated comment. + * @param DateTime|null $created The original time; now when unknown. + * + * @return void + */ + private function appendAudit(Task $task, string $action, string $stateAfter, string $actor, string $reason, ?DateTime $created): void { + $entry = new TaskAudit(); + $entry->setTaskId((int)$task->getId()); + $entry->setAction($action); + $entry->setStateAfter($stateAfter); + $entry->setActor($actor); + $entry->setPerformerType(Task::PERFORMER_GROUP); + $entry->setReason($reason); + $entry->setAuthorized(true); + $entry->setCreated($created); + $this->audits->insert($entry); + }//end appendAudit() + + /** + * The lowest ordinal among a set of step rows, or null. + * + * @param array> $steps The step rows. + * + * @return int|null The lowest `step_order`, or null for an empty set. + */ + private function firstOrdinal(array $steps): ?int { + $ordinals = array_map(static fn (array $step): int => (int)$step['step_order'], $steps); + if ($ordinals === []) { + return null; + } + + return min($ordinals); + }//end firstOrdinal() + + /** + * Run a read query and fetch every row. + * + * @param string $sql The statement. + * @param array $params Positional parameters. + * + * @return array> The rows. + */ + private function rows(string $sql, array $params = []): array { + $result = $this->db->executeQuery($sql, $params); + $rows = $result->fetchAll(); + $result->closeCursor(); + + return $rows; + }//end rows() + + /** + * Parse a database datetime string, tolerating null. + * + * @param mixed $value The raw column value. + * + * @return DateTime|null The parsed time, or null. + */ + private function parseDbDate(mixed $value): ?DateTime { + $text = trim((string)($value ?? '')); + if ($text === '') { + return null; + } + + try { + return new DateTime($text); + } catch (Throwable $unparsable) { + return null; + } + }//end parseDbDate() + + /** + * A trimmed string, or null when empty. + * + * @param mixed $value The raw value. + * + * @return string|null The string, or null. + */ + private function nullableString(mixed $value): ?string { + $text = trim((string)($value ?? '')); + if ($text === '') { + return null; + } + + return $text; + }//end nullableString() +}//end class diff --git a/lib/Repair/MigrateRegisterFlowsToTable.php b/lib/Repair/MigrateRegisterFlowsToTable.php index 3f7bc7f376..180e0be169 100644 --- a/lib/Repair/MigrateRegisterFlowsToTable.php +++ b/lib/Repair/MigrateRegisterFlowsToTable.php @@ -175,6 +175,7 @@ public function run(IOutput $output): void { $moved = 0; $already = 0; $failed = 0; + $notFlows = 0; foreach ($objects as $object) { $row = $this->normalise(row: $object); @@ -184,6 +185,16 @@ public function run(IOutput $output): void { continue; } + // The second, independent guard — see isFlowDefinition(). + if ($this->isFlowDefinition(object: $row['data']) === false) { + $notFlows++; + $this->logger->warning( + '[MigrateRegisterFlowsToTable] refused ' . $uuid + . ': no nodes, no edges and no trigger, so it is not a flow definition' + ); + continue; + } + try { $this->flowMapper->findByUuid($uuid); $already++; @@ -211,26 +222,87 @@ public function run(IOutput $output): void { // the defect this repairs stayed invisible. $output->info( sprintf( - 'Register-authored flows: %d migrated, %d already in the table, %d failed (register rows left in place).', + 'Register-authored flows: %d migrated, %d already in the table, %d not a flow definition, %d failed (register rows left in place).', $moved, $already, + $notFlows, $failed ) ); }//end run() + /** + * Whether this object is a flow definition at all. + * + * 🔴 BOTH GUARDS, DELIBERATELY. Scoping the read (see registerFlows()) fixes + * the wrong POPULATION being selected; this fixes a wrong ROW inside the + * selected one being written. They fail independently, which is the point: + * the scoping guard depends on shared, mutable service state staying correct + * for the whole of an `occ upgrade`, and that state is exactly what broke. + * This one depends on nothing but the row in front of it. + * + * The test is what the flow engine needs to do anything: a graph to walk + * (`nodes` or `edges`) or something to start it (`trigger`). A row with none + * of the three cannot run, cannot be made to run, and is not a flow — it is + * whatever else the read handed us. It is not a completeness check: an empty + * graph that already names a trigger is a half-authored flow and crosses. + * + * @param array $object The register object's payload. + * + * @return boolean True when the payload is a flow definition. + */ + private function isFlowDefinition(array $object): bool { + if (empty($object['nodes']) === false) { + return true; + } + + if (empty($object['edges']) === false) { + return true; + } + + $trigger = ($object['trigger'] ?? null); + + return is_string($trigger) === true && trim($trigger) !== ''; + }//end isFlowDefinition() + /** * Every object in the flows register. * * @return array The rows, each an ObjectEntity (see normalise()). */ private function registerFlows(): array { + // 🔴 `register` / `schema` GO UNDER `filters`, AND NOWHERE ELSE. + // + // This step used to pass them at the top level of the config, which + // reads correctly and does nothing: `ObjectService::prepareFindAllConfig()` + // inspects `$config['filters']['register']` and `$config['filters']['schema']` + // and no other key, so `setRegister()` / `setSchema()` were never called + // and the read ran against whatever `$currentRegister` / `$currentSchema` + // the SHARED ObjectService instance was still carrying. + // + // MEASURED, not inferred. `saveObject()` sets that context and never + // restores it — `find()` does, in a `finally`; `saveObject()` has no such + // block. `ImportCredentialBrokerRegister` runs four repair steps ahead of + // this one and saves `credential_broker_register.json`'s two example + // objects through it. So this step inherited `credential-broker` / + // `brokeredcredential` and copied both examples into `openregister_flows`: + // empty nodes, empty edges, no trigger, `_owner` = `__system__` (which is + // only what OpenRegister stamps on a sessionless write, not a convention). + // + // Every other `findAll()` caller under `lib/` nests the pair. This step + // was the outlier, and `_rbac` / `_multitenancy` are off because a repair + // step has no session — `occ` runs as Anonymous, so a scoped read would + // return nothing at all. $result = $this->objectService->findAll( - [ - 'register' => self::FLOW_REGISTER, - 'schema' => self::FLOW_SCHEMA, - 'limit' => 1000, - ] + config: [ + 'filters' => [ + 'register' => self::FLOW_REGISTER, + 'schema' => self::FLOW_SCHEMA, + ], + 'limit' => 1000, + ], + _rbac: false, + _multitenancy: false ); // No `is_array($result)` guard: `findAll()` is typed to return an array, diff --git a/lib/Repair/PurgePhantomMigratedFlows.php b/lib/Repair/PurgePhantomMigratedFlows.php new file mode 100644 index 0000000000..bf88cbbaf2 --- /dev/null +++ b/lib/Repair/PurgePhantomMigratedFlows.php @@ -0,0 +1,357 @@ + 'flows', 'schema' => 'flow']` at the TOP LEVEL of the config. + * `prepareFindAllConfig()` reads `$config['filters']['register']` and + * `$config['filters']['schema']` and no other key, so both were inert: the read + * was never scoped and ran against whatever `$currentRegister` / `$currentSchema` + * the SHARED ObjectService instance was still carrying. + * + * `saveObject()` sets that context and never restores it — `find()` puts it back + * in a `finally`, `saveObject()` has no such block — and + * `ImportCredentialBrokerRegister` runs four repair steps ahead of the migration, + * saving `credential_broker_register.json`'s two example objects through it. So + * the migration inherited `credential-broker` / `brokeredcredential` and copied + * both examples into `openregister_flows`. + * + * The rows it left behind have empty nodes, empty edges, no trigger, no trigger + * schema and `_owner` = `__system__` — which is only what OpenRegister stamps on + * a sessionless write, not a convention for a shipped flow. They cost twice: + * they are listed forever as flows that can never run, and they were read as + * precedent by a later diagnosis that nearly copied `owner=__system__` into + * another app's shipped flow declarations. + * + * 🔴 DELETING ROWS IS IRREVERSIBLE, SO THE PREDICATE IS PROOF, NOT A HEURISTIC. + * + * A row is removed only when ALL of the following hold: + * + * 1. it is owned by `openregister` — the only `app` value the migration writes; + * 2. it is disabled; + * 3. `nodes` is empty AND `edges` is empty — nothing to walk; + * 4. `trigger`, `triggerRegister`, `triggerSchema` and `cron` are all empty — + * nothing that could ever start it; + * 5. it has never been dispatched: no `lastRunUuid`, no `lastRunAt`, no + * `lastRunStatus`, and zero rows in `openregister_flow_runs`; + * 6. and — the provenance proof — its uuid still resolves to a register object + * whose schema is NOT `flow`. That is the defect's signature: the row is a + * copy of something that was never a flow. + * + * (1)–(5) establish that the row cannot run and never has. (6) establishes that + * it is this defect's artefact rather than somebody's empty draft. Only the + * conjunction deletes. + * + * 🔴 WHAT IT DELIBERATELY SPARES, and reports instead: + * + * - any flow with one node or one edge, whatever else is missing; + * - any flow naming a trigger, trigger register/schema or cron — including a + * graph-less draft someone is part-way through authoring; + * - any enabled flow, whatever its shape; + * - any flow with run history, even a single terminal run; + * - any flow owned by another app; + * - AND the ambiguous case: an unrunnable shell whose uuid resolves to nothing, + * or to a real `flow` object. Its source may have been deleted since, or it + * may be a genuine empty draft, and the two are indistinguishable from here. + * Reported by uuid, never removed. Erring toward keeping an unrunnable row is + * recoverable; erring the other way is not. + * + * Idempotent: a second run finds nothing left to match and reports zero. + * + * @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 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/register-descriptor-admin/specs/register-descriptor-admin/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Repair; + +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowMapper; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\SchemaMapper; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Removes non-flow rows the mis-scoped flow migration copied into the flow table. + * + * @spec openspec/changes/register-descriptor-admin/specs/register-descriptor-admin/spec.md + * + * @psalm-suppress UnusedClass Instantiated by the NC repair framework (appinfo/info.xml). + */ +class PurgePhantomMigratedFlows implements IRepairStep { + /** + * The only `app` value MigrateRegisterFlowsToTable writes. + * + * Scoping to it spares every flow another app owns without having to reason + * about that app's conventions at all. + * + * @var string + */ + private const MIGRATED_APP = 'openregister'; + + /** + * The schema slug a real flow definition lives under. + * + * @var string + */ + private const FLOW_SCHEMA = 'flow'; + + /** + * Page size for the table walk. + * + * @var int + */ + private const PAGE = 200; + + /** + * Constructor. + * + * @param FlowMapper $flowMapper Reads and removes rows in the flow table. + * @param FlowRunMapper $flowRunMapper Proves a row has never been dispatched. + * @param MagicMapper $objectMapper Resolves a uuid back to the object it was copied from. + * @param SchemaMapper $schemaMapper Turns that object's schema reference into a slug. + * @param LoggerInterface $logger Records every removal and every spared ambiguity. + * + * @return void + */ + public function __construct( + private readonly FlowMapper $flowMapper, + private readonly FlowRunMapper $flowRunMapper, + private readonly MagicMapper $objectMapper, + private readonly SchemaMapper $schemaMapper, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Get the name of this repair step. + * + * @return string The step name. + * + * @spec openspec/changes/register-descriptor-admin/specs/register-descriptor-admin/spec.md + */ + public function getName(): string { + return 'Remove non-flow rows copied into the flow table by the mis-scoped flow migration'; + }//end getName() + + /** + * Walk the flow table and remove only what is provably this defect's artefact. + * + * Never throws: a failure here must not take an upgrade down, and a row left + * in place is the safe direction anyway. + * + * @param IOutput $output Output interface for status messages. + * + * @return void + * + * @spec openspec/changes/register-descriptor-admin/specs/register-descriptor-admin/spec.md + */ + public function run(IOutput $output): void { + $removed = 0; + $reported = []; + + try { + foreach ($this->migratedFlows() as $flow) { + if ($this->isUnrunnableShell(flow: $flow) === false) { + continue; + } + + $uuid = (string)$flow->getUuid(); + + if ($this->hasEverRun(flow: $flow) === true) { + $reported[] = $uuid . ' (unrunnable, but it has run history)'; + continue; + } + + $sourceSchema = $this->sourceSchemaSlug(uuid: $uuid); + if ($sourceSchema === null) { + $reported[] = $uuid . ' (unrunnable, but no source object to identify it by)'; + continue; + } + + if ($sourceSchema === self::FLOW_SCHEMA) { + $reported[] = $uuid . ' (unrunnable, but it really is a flow object — an empty draft)'; + continue; + } + + $this->flowMapper->delete($flow); + $removed++; + $this->logger->warning( + '[PurgePhantomMigratedFlows] removed ' . $uuid + . ': an unrunnable, never-dispatched copy of a `' . $sourceSchema . '` object' + ); + }//end foreach + } catch (Throwable $e) { + $this->logger->warning('[PurgePhantomMigratedFlows] aborted: ' . $e->getMessage()); + $output->warning('Phantom-flow purge stopped early: ' . $e->getMessage()); + }//end try + + // 🔴 THE COUNTS AND THE SPARED UUIDS, ALWAYS. A destructive step that + // says only "done" cannot be told from one that matched nothing, and the + // reported rows are the ones a human has to look at — naming them here is + // the whole point of not deleting them. + $output->info( + sprintf('Phantom flow rows: %d removed, %d reported for review.', $removed, count($reported)) + ); + + foreach ($reported as $line) { + $output->info(' needs review: ' . $line); + } + }//end run() + + /** + * Every flow this migration could have written, page by page. + * + * @return iterable The candidate rows. + */ + private function migratedFlows(): iterable { + $offset = 0; + + while (true) { + $page = $this->flowMapper->findAllFlows( + app: self::MIGRATED_APP, + limit: self::PAGE, + offset: $offset + ); + + if (empty($page) === true) { + return; + } + + yield from $page; + + if (count($page) < self::PAGE) { + return; + } + + $offset += self::PAGE; + } + }//end migratedFlows() + + /** + * Whether this row has nothing to walk and nothing that could start it. + * + * Conjuncts (2)–(4) of the predicate in the class docblock. Every one of them + * spares something: `enabled` spares a switched-on flow whatever its shape, + * the graph pair spares anything with a single node or edge, and the trigger + * group spares a graph-less draft that already names how it should start. + * + * @param Flow $flow The row under test. + * + * @return boolean True when the row cannot run and cannot be started. + */ + private function isUnrunnableShell(Flow $flow): bool { + if ($flow->getEnabled() === true) { + return false; + } + + if (empty($flow->getNodes()) === false || empty($flow->getEdges()) === false) { + return false; + } + + $starters = [ + $flow->getTrigger(), + $flow->getTriggerRegister(), + $flow->getTriggerSchema(), + $flow->getCron(), + ]; + + foreach ($starters as $starter) { + if ($starter !== null && trim($starter) !== '') { + return false; + } + } + + return true; + }//end isUnrunnableShell() + + /** + * Whether this flow has ever been dispatched. + * + * Conjunct (5). Both halves are load-bearing: the `lastRun*` columns are the + * flow's own memory of a dispatch, and the run table is the record of one. + * A run row can be pruned by retention while the columns survive, and the + * columns were only added in a later migration while older run rows remain — + * so either alone would miss a flow that has demonstrably run. + * + * @param Flow $flow The row under test. + * + * @return boolean True when anything at all says this flow has run. + */ + private function hasEverRun(Flow $flow): bool { + if ($flow->getLastRunUuid() !== null || $flow->getLastRunAt() !== null || $flow->getLastRunStatus() !== null) { + return true; + } + + return $this->flowRunMapper->countRunsForFlow((string)$flow->getUuid()) > 0; + }//end hasEverRun() + + /** + * The schema slug of the register object this uuid came from, if any. + * + * Conjunct (6), the provenance proof. The migration preserves the source + * object's uuid and leaves the register rows in place, so the object it was + * copied from is still there to be asked. `findMultipleAcrossAllMagicTables()` + * is used rather than `ObjectService` on purpose: it takes no register/schema + * context, and inheriting a stale one is the bug being repaired. + * + * @param string $uuid The flow row's uuid. + * + * @return string|null The source object's schema slug, or null when nothing resolves. + */ + private function sourceSchemaSlug(string $uuid): ?string { + if (trim($uuid) === '') { + return null; + } + + try { + $objects = $this->objectMapper->findMultipleAcrossAllMagicTables(uuids: [$uuid]); + } catch (Throwable $e) { + $this->logger->warning('[PurgePhantomMigratedFlows] could not resolve ' . $uuid . ': ' . $e->getMessage()); + return null; + } + + $object = ($objects[0] ?? null); + if ($object === null) { + return null; + } + + $schemaRef = $object->getSchema(); + if ($schemaRef === null || trim((string)$schemaRef) === '') { + return null; + } + + try { + $schema = $this->schemaMapper->find(id: $schemaRef, _rbac: false, _multitenancy: false); + } catch (Throwable $e) { + $this->logger->warning('[PurgePhantomMigratedFlows] could not resolve schema for ' . $uuid . ': ' . $e->getMessage()); + return null; + } + + $slug = $schema->getSlug(); + if ($slug === null || trim($slug) === '') { + return null; + } + + return $slug; + }//end sourceSchemaSlug() +}//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/Repair/SeedDirectoryVirtualSchemas.php b/lib/Repair/SeedDirectoryVirtualSchemas.php index fcabacced1..dacbfad77c 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.'], + ], ]; /** @@ -171,34 +195,128 @@ private function ensureRegister(): Register { /** * Find or create one virtual schema for a semantic-map row. * - * @param array{register: string, schema: string, schemaOrg: string, provider: string, requiredApp: string|null} $row The semantic-map row. + * @param array $row One {@see NcEntitySemanticMap::ENTITIES} row. * * @return Schema The existing or newly created schema. * * @spec openspec/changes/virtual-schema-semantic-providers/tasks.md#task-2.2 */ private function ensureSchema(array $row): Schema { + $readOnly = (($row['writable'] ?? false) === false); + try { - return $this->schemaMapper->find($row['schema'], _rbac: false, _multitenancy: false); + $schema = $this->schemaMapper->find($row['schema'], _rbac: false, _multitenancy: false); + + return $this->reconcileReadOnly(schema: $schema, readOnly: $readOnly); } catch (Throwable $e) { - // Not found — create it as a read-only object-source-backed schema. $properties = (self::SCHEMA_PROPERTIES[$row['schema']] ?? []); return $this->schemaMapper->createFromArray( object: [ 'title' => $row['schema'], 'slug' => $row['schema'], - 'description' => sprintf('Read-only virtual schema for Nextcloud %s (%s).', $row['schema'], $row['schemaOrg']), + 'description' => sprintf( + '%s virtual schema for Nextcloud %s (%s).', + ucfirst($this->readOnlyLabel(readOnly: $readOnly)), + $row['schema'], + $row['schemaOrg'] + ), 'properties' => $properties, 'configuration' => [ 'x-schema-org' => $row['schemaOrg'], 'x-openregister-object-source' => [ 'provider' => $row['provider'], - 'readOnly' => true, + 'readOnly' => $readOnly, ], ], ] ); }//end try }//end ensureSchema() + + /** + * Bring an EXISTING virtual schema's `readOnly` annotation in step with the map. + * + * WITHOUT THIS THE FLAG ONLY EVER REACHES A FRESH INSTALL. `ensureSchema()` + * returns early the moment the schema is found, so every instance that + * already seeded `nc-organisation` would keep the `readOnly: true` it was + * created with — and `delegateObjectSourceWrite()` reads exactly that + * annotation. The provider would implement the write interface, the seed + * would report success, and every write would still be refused, with nothing + * naming the annotation as the reason. + * + * Only the `readOnly` key is touched. The rest of the configuration is + * whatever the instance has, including anything an administrator set. + * + * @param Schema $schema The existing schema. + * @param bool $readOnly What the map says it should be. + * + * @return Schema The schema, saved when it had drifted. + * + * @spec openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md#requirement-an-organisation-can-be-created-through-the-projection-req-orp-104 + */ + private function reconcileReadOnly(Schema $schema, bool $readOnly): Schema { + $configuration = ($schema->getConfiguration() ?? []); + $source = ($configuration['x-openregister-object-source'] ?? null); + + if (is_array($source) === false || ($source['readOnly'] ?? null) === $readOnly) { + return $schema; + } + + $source['readOnly'] = $readOnly; + $configuration['x-openregister-object-source'] = $source; + $schema->setConfiguration($configuration); + + try { + $updated = $this->schemaMapper->update($schema); + } catch (Throwable $e) { + // LOUD, not swallowed. `run()` catches everything and degrades to a + // warning, which is right for "the seed could not create a schema" and + // wrong here: the schema exists and works, it just refuses writes, and + // the caller sees a generic "read-only projection" rejection with + // nothing pointing back at this annotation. + $this->logger->error( + sprintf( + '[SeedDirectoryVirtualSchemas] could not set %s to %s: %s. Writes through this schema ' + .'will be refused until its x-openregister-object-source.readOnly is corrected.', + (string)$schema->getSlug(), + $this->readOnlyLabel(readOnly: $readOnly), + $e->getMessage() + ) + ); + + return $schema; + } + + $this->logger->info( + sprintf( + '[SeedDirectoryVirtualSchemas] %s is now %s', + (string)$schema->getSlug(), + $this->readOnlyLabel(readOnly: $readOnly) + ) + ); + + if ($updated instanceof Schema === true) { + return $updated; + } + + return $schema; + }//end reconcileReadOnly() + + /** + * Name a `readOnly` value for a log line or a schema description. + * + * @param bool $readOnly The flag. + * + * @return string The label, lowercase; `ucfirst()` it for sentence position. + * + * @spec openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md#requirement-an-organisation-can-be-created-through-the-projection-req-orp-104 + */ + private function readOnlyLabel(bool $readOnly): string { + if ($readOnly === true) { + return 'read-only'; + } + + return 'writable'; + }//end readOnlyLabel() }//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/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/ActionExecutor.php b/lib/Service/ActionExecutor.php deleted file mode 100644 index d2fd766679..0000000000 --- a/lib/Service/ActionExecutor.php +++ /dev/null @@ -1,368 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/actions/spec.md - * @spec openspec/specs/actions/spec.md - * @spec openspec/specs/actions/spec.md - * @spec openspec/specs/actions/spec.md - * @spec openspec/specs/actions/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Service; - -use DateTime; -use Exception; -use OCA\OpenRegister\BackgroundJob\ActionRetryJob; -use OCA\OpenRegister\Db\Action; -use OCA\OpenRegister\Db\ActionLog; -use OCA\OpenRegister\Db\ActionLogMapper; -use OCA\OpenRegister\Service\Webhook\CloudEventFormatter; -use OCA\OpenRegister\WorkflowEngine\WorkflowResult; -use OCP\BackgroundJob\IJobList; -use OCP\EventDispatcher\Event; -use Psr\Log\LoggerInterface; -use Symfony\Component\Uid\Uuid; - -/** - * ActionExecutor orchestrates action execution for events - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) - */ -class ActionExecutor { - /** - * Constructor - * - * @param WorkflowEngineRegistry $engineRegistry Engine registry - * @param CloudEventFormatter $cloudEventFormatter CloudEvent formatter - * @param ActionLogMapper $actionLogMapper Action log mapper - * @param ActionService $actionService Action service for statistics - * @param IJobList $jobList Job list for retry queue - * @param LoggerInterface $logger Logger - */ - public function __construct( - private readonly WorkflowEngineRegistry $engineRegistry, - private readonly CloudEventFormatter $cloudEventFormatter, - private readonly ActionLogMapper $actionLogMapper, - private readonly ActionService $actionService, - private readonly IJobList $jobList, - private readonly LoggerInterface $logger, - ) { - }//end __construct() - - /** - * Execute a list of matching actions for an event - * - * @param Action[] $actions Sorted actions to execute - * @param Event $event The triggering event - * @param array $payload Event payload data - * @param string $eventType Event type string - * - * @return void - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) - * - * @spec openspec/specs/actions/spec.md - */ - public function executeActions(array $actions, Event $event, array $payload, string $eventType): void { - foreach ($actions as $action) { - // Check if propagation was stopped by a previous action or inline hook. - if ($event->isPropagationStopped() === true) { - $this->logger->debug( - message: '[ActionExecutor] Propagation stopped, skipping remaining actions', - context: ['skippedAction' => $action->getName()] - ); - break; - } - - $this->executeSingleAction(action: $action, event: $event, payload: $payload, eventType: $eventType); - }//end foreach - }//end executeActions() - - /** - * Execute a single action - * - * @param Action $action The action to execute - * @param Event $event The triggering event - * @param array $payload Event payload data - * @param string $eventType Event type string - * - * @return void - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - * - * @spec openspec/specs/actions/spec.md - */ - private function executeSingleAction(Action $action, Event $event, array $payload, string $eventType): void { - $startTime = microtime(true); - $status = 'success'; - $error = null; - $response = null; - - try { - // Build CloudEvents payload. - $cloudEventPayload = $this->buildCloudEventPayload(action: $action, payload: $payload, eventType: $eventType); - - // Resolve engine adapter. - $engine = $this->engineRegistry->getEngine((int)$action->getEngine()); - if ($engine === null) { - throw new Exception("Engine '{$action->getEngine()}' not available"); - } - - // Execute workflow. - $isAsync = ($action->getMode() === 'async'); - - if ($isAsync === true) { - // Fire-and-forget: execute but don't process response for event modification. - try { - $result = $engine->execute( - $action->getWorkflowId(), - $cloudEventPayload, - $action->getTimeout() - ); - $response = (array)$result; - if ($result instanceof WorkflowResult) { - $response = $result->toArray(); - } - } catch (Exception $e) { - $status = 'failure'; - $error = $e->getMessage(); - $this->handleFailure(action: $action, payload: $cloudEventPayload, error: $error); - } - } - - if ($isAsync === false) { - // Sync mode: execute and process response. - $result = $engine->execute( - $action->getWorkflowId(), - $cloudEventPayload, - $action->getTimeout() - ); - $response = (array)$result; - - if ($result instanceof WorkflowResult) { - $response = $result->toArray(); - $this->processWorkflowResult(result: $result, action: $action, event: $event); - } - }//end if - } catch (Exception $e) { - $status = 'failure'; - $error = $e->getMessage(); - - $this->logger->error( - message: '[ActionExecutor] Action execution failed', - context: [ - 'actionId' => $action->getId(), - 'actionName' => $action->getName(), - 'error' => $e->getMessage(), - ] - ); - - $this->handleFailure(action: $action, payload: $payload, error: $error); - }//end try - - // Calculate duration. - $durationMs = (int)((microtime(true) - $startTime) * 1000); - - // Create log entry. - $this->createLogEntry( - action: $action, - eventType: $eventType, - payload: $payload, - response: $response, - status: $status, - durationMs: $durationMs, - error: $error - ); - - // Update statistics. - $this->actionService->updateStatistics($action->getId(), $status); - }//end executeSingleAction() - - /** - * Build CloudEvent payload for an action execution - * - * @param Action $action The action being executed - * @param array $payload Event payload data - * @param string $eventType Event type string - * - * @return array The CloudEvent-formatted payload - * - * @spec openspec/specs/actions/spec.md - */ - public function buildCloudEventPayload(Action $action, array $payload, string $eventType): array { - return [ - 'specversion' => '1.0', - 'type' => 'nl.openregister.action.' . $eventType, - 'source' => '/openregister/actions/' . $action->getUuid(), - 'id' => Uuid::v4()->toRfc4122(), - 'time' => (new DateTime())->format('c'), - 'datacontenttype' => 'application/json', - 'data' => $payload, - 'action' => [ - 'id' => $action->getId(), - 'uuid' => $action->getUuid(), - 'name' => $action->getName(), - 'engine' => $action->getEngine(), - 'workflowId' => $action->getWorkflowId(), - 'mode' => $action->getMode(), - ], - ]; - }//end buildCloudEventPayload() - - /** - * Process a workflow result from sync execution - * - * @param WorkflowResult $result The workflow result - * @param Action $action The action that was executed - * @param Event $event The original event - * - * @return void - * - * @spec openspec/specs/actions/spec.md - */ - private function processWorkflowResult(WorkflowResult $result, Action $action, Event $event): void { - if ($result->isRejected() === true) { - $this->logger->info( - message: '[ActionExecutor] Action rejected operation', - context: ['actionName' => $action->getName()] - ); - - // Stop propagation for pre-mutation events. OCP\EventDispatcher\Event - // declares stopPropagation(), so no method_exists() probe is needed — - // unlike setErrors() below, which is not on the base class. - $event->stopPropagation(); - - if (method_exists($event, 'setErrors') === true) { - $event->setErrors($result->getErrors()); - } - } - - if ($result->isModified() === true && method_exists($event, 'setModifiedData') === true) { - $event->setModifiedData($result->getModifiedData()); - } - }//end processWorkflowResult() - - /** - * Handle action execution failure based on failure mode - * - * @param Action $action The failed action - * @param array $payload The payload that was being sent - * @param string $error The error message - * - * @return void - * - * @spec openspec/specs/actions/spec.md - */ - private function handleFailure(Action $action, array $payload, string $error): void { - $failureMode = $action->getOnFailure(); - - if ($failureMode === 'queue' || $action->getOnEngineDown() === 'queue') { - $this->jobList->add( - ActionRetryJob::class, - [ - 'action_id' => $action->getId(), - 'payload' => $payload, - 'attempt' => 2, - 'max_retries' => $action->getMaxRetries(), - 'retry_policy' => $action->getRetryPolicy(), - 'error' => $error, - ] - ); - - $this->logger->info( - message: '[ActionExecutor] Failed action queued for retry', - context: ['actionId' => $action->getId(), 'actionName' => $action->getName()] - ); - } - }//end handleFailure() - - /** - * Create an ActionLog entry for an execution - * - * @param Action $action The action that was executed - * @param string $eventType Event type - * @param array $payload Request payload - * @param array|null $response Response payload - * @param string $status Execution status - * @param int $durationMs Duration in milliseconds - * @param string|null $error Error message if failed - * - * @return void - * - * @SuppressWarnings(PHPMD.ExcessiveParameterList) Log entries require many fields - * - * @spec openspec/specs/actions/spec.md - */ - private function createLogEntry( - Action $action, - string $eventType, - array $payload, - ?array $response, - string $status, - int $durationMs, - ?string $error, - ): void { - try { - $log = new ActionLog(); - $log->setActionId($action->getId()); - $log->setActionUuid($action->getUuid()); - $log->setEventType($eventType); - $log->setObjectUuid($payload['data']['object']['uuid'] ?? $payload['objectUuid'] ?? null); - $schemaId = null; - if (isset($payload['data']['schema']) === true) { - $schemaId = (int)$payload['data']['schema']; - } - - $registerId = null; - if (isset($payload['data']['register']) === true) { - $registerId = (int)$payload['data']['register']; - } - - $log->setSchemaId($schemaId); - $log->setRegisterId($registerId); - $log->setEngine($action->getEngine()); - $log->setWorkflowId($action->getWorkflowId()); - $log->setStatus($status); - $log->setDurationMs($durationMs); - $log->setRequestPayload(json_encode($payload)); - $responsePayload = null; - if ($response !== null) { - $responsePayload = json_encode($response); - } - - $log->setResponsePayload($responsePayload); - $log->setErrorMessage($error); - - $this->actionLogMapper->insert(entity: $log); - } catch (Exception $e) { - $this->logger->error( - message: '[ActionExecutor] Failed to create action log entry', - context: ['error' => $e->getMessage()] - ); - }//end try - }//end createLogEntry() -}//end class diff --git a/lib/Service/ActionService.php b/lib/Service/ActionService.php deleted file mode 100644 index a5dfd048f8..0000000000 --- a/lib/Service/ActionService.php +++ /dev/null @@ -1,410 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/actions/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Service; - -use DateTime; -use InvalidArgumentException; -use OCA\OpenRegister\Db\Action; -use OCA\OpenRegister\Db\ActionMapper; -use OCA\OpenRegister\Db\SchemaMapper; -use OCA\OpenRegister\Event\ActionCreatedEvent; -use OCA\OpenRegister\Event\ActionDeletedEvent; -use OCA\OpenRegister\Event\ActionUpdatedEvent; -use OCP\EventDispatcher\IEventDispatcher; -use Psr\Log\LoggerInterface; -use Symfony\Component\Uid\Uuid; - -/** - * ActionService provides business logic for Action CRUD and utilities - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class ActionService { - /** - * Hook event mapping for migration - * - * @var array - */ - private const HOOK_EVENT_MAP = [ - 'creating' => 'ObjectCreatingEvent', - 'created' => 'ObjectCreatedEvent', - 'updating' => 'ObjectUpdatingEvent', - 'updated' => 'ObjectUpdatedEvent', - 'deleting' => 'ObjectDeletingEvent', - 'deleted' => 'ObjectDeletedEvent', - ]; - - /** - * Constructor - * - * @param ActionMapper $actionMapper Action mapper - * @param SchemaMapper $schemaMapper Schema mapper - * @param IEventDispatcher $eventDispatcher Event dispatcher - * @param LoggerInterface $logger Logger - */ - public function __construct( - private readonly ActionMapper $actionMapper, - private readonly SchemaMapper $schemaMapper, - private readonly IEventDispatcher $eventDispatcher, - private readonly LoggerInterface $logger, - ) { - }//end __construct() - - /** - * Create a new action - * - * Validates required fields, generates UUID, sets defaults, persists, and dispatches event. - * - * @param array $data Action data - * - * @return Action The created action - * - * @throws InvalidArgumentException If required fields are missing - * - * @spec openspec/specs/actions/spec.md - */ - public function createAction(array $data): Action { - // Validate required fields. - if (empty($data['name']) === true) { - throw new InvalidArgumentException('Action name is required'); - } - - if (empty($data['eventType']) === true) { - throw new InvalidArgumentException('Action eventType is required'); - } - - if (empty($data['engine']) === true) { - throw new InvalidArgumentException('Action engine is required'); - } - - if (empty($data['workflowId']) === true) { - throw new InvalidArgumentException('Action workflowId is required'); - } - - // Remove ID to ensure new record. - unset($data['id']); - - // Generate UUID if not provided. - if (empty($data['uuid']) === true) { - $data['uuid'] = Uuid::v4()->toRfc4122(); - } - - // Set defaults for optional fields. - $data['status'] = $data['status'] ?? 'draft'; - $data['mode'] = $data['mode'] ?? 'sync'; - $data['executionOrder'] = $data['executionOrder'] ?? 0; - $data['timeout'] = $data['timeout'] ?? 30; - $data['onFailure'] = $data['onFailure'] ?? 'reject'; - $data['onTimeout'] = $data['onTimeout'] ?? 'reject'; - $data['onEngineDown'] = $data['onEngineDown'] ?? 'allow'; - $data['maxRetries'] = $data['maxRetries'] ?? 3; - $data['retryPolicy'] = $data['retryPolicy'] ?? 'exponential'; - $data['enabled'] = $data['enabled'] ?? true; - $data['version'] = $data['version'] ?? '1.0.0'; - - $action = new Action(); - $action->hydrate($data); - - $action = $this->actionMapper->insert(entity: $action); - - $this->eventDispatcher->dispatchTyped(new ActionCreatedEvent(action: $action)); - - $this->logger->info( - message: '[ActionService] Action created', - context: ['id' => $action->getId(), 'name' => $action->getName()] - ); - - return $action; - }//end createAction() - - /** - * Update an existing action - * - * @param int $id Action ID - * @param array $data Partial update data - * - * @return Action The updated action - * - * @spec openspec/specs/actions/spec.md - */ - public function updateAction(int $id, array $data): Action { - $action = $this->actionMapper->find(id: $id); - - // Remove fields that should not be user-overridable. - unset($data['id'], $data['uuid'], $data['created']); - - $action->hydrate($data); - $action->setUpdated(new DateTime()); - - $action = $this->actionMapper->update(entity: $action); - - $this->eventDispatcher->dispatchTyped(new ActionUpdatedEvent(action: $action)); - - return $action; - }//end updateAction() - - /** - * Soft-delete an action - * - * Sets deleted timestamp and changes status to archived. - * - * @param int $id Action ID - * - * @return Action The deleted action - * - * @spec openspec/specs/actions/spec.md - */ - public function deleteAction(int $id): Action { - $action = $this->actionMapper->find(id: $id); - - $action->setDeleted(new DateTime()); - $action->setStatus('archived'); - $action->setUpdated(new DateTime()); - - $action = $this->actionMapper->update(entity: $action); - - $this->eventDispatcher->dispatchTyped(new ActionDeletedEvent(action: $action)); - - $this->logger->info( - message: '[ActionService] Action soft-deleted', - context: ['id' => $action->getId(), 'name' => $action->getName()] - ); - - return $action; - }//end deleteAction() - - /** - * Test an action with a dry-run simulation - * - * Validates matching and builds the payload without executing side effects. - * - * @param int $id Action ID - * @param array $samplePayload Sample event payload - * - * @return array Test result with match info and payload - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * - * @spec openspec/specs/actions/spec.md - */ - public function testAction(int $id, array $samplePayload): array { - $action = $this->actionMapper->find(id: $id); - - $eventType = $samplePayload['eventType'] ?? ''; - $schemaUuid = $samplePayload['schemaUuid'] ?? null; - $registerUuid = $samplePayload['registerUuid'] ?? null; - - // Check event type match. - $eventMatch = $action->matchesEvent($eventType); - - // Check schema match. - $schemaMatch = $action->matchesSchema($schemaUuid); - - // Check register match. - $registerMatch = $action->matchesRegister($registerUuid); - - // Check filter condition match. - $filterMatch = true; - $filterReasons = []; - $conditions = $action->getFilterConditionArray(); - if (empty($conditions) === false) { - foreach ($conditions as $key => $expected) { - $actual = $this->getNestedValue(data: $samplePayload, key: $key); - if (is_array($expected) === true) { - if (in_array($actual, $expected) === false) { - $filterMatch = false; - $expectedList = implode(', ', $expected); - $filterReasons[] = sprintf( - "filter_condition mismatch: %s expected one of [%s], got '%s'", - $key, - $expectedList, - (string)$actual - ); - } - } elseif ($actual !== $expected) { - $filterMatch = false; - $filterReasons[] = "filter_condition mismatch: {$key} expected '{$expected}', got '{$actual}'"; - } - } - } - - $matched = $eventMatch && $schemaMatch && $registerMatch && $filterMatch; - - $builtPayload = null; - if ($matched === true) { - $builtPayload = $samplePayload; - } - - return [ - 'matched' => $matched, - 'action' => $action->jsonSerialize(), - 'eventMatch' => $eventMatch, - 'schemaMatch' => $schemaMatch, - 'registerMatch' => $registerMatch, - 'filterMatch' => $filterMatch, - 'filterReasons' => $filterReasons, - 'builtPayload' => $builtPayload, - ]; - }//end testAction() - - /** - * Migrate inline hooks from a schema to Action entities - * - * @param int $schemaId Schema ID - * - * @return array Migration report - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * - * @spec openspec/specs/actions/spec.md - */ - public function migrateFromHooks(int $schemaId): array { - $schema = $this->schemaMapper->find(id: $schemaId); - $hooks = $schema->getHooks() ?? []; - - $report = [ - 'created' => [], - 'skipped' => [], - 'errors' => [], - ]; - - if (empty($hooks) === true) { - return $report; - } - - $schemaUuid = $schema->getUuid() ?? (string)$schemaId; - - foreach ($hooks as $index => $hook) { - try { - $name = $hook['id'] ?? "Hook {$index} for " . ($schema->getName() ?? 'Unknown'); - $eventKey = $hook['event'] ?? 'creating'; - $eventType = self::HOOK_EVENT_MAP[$eventKey] ?? $eventKey; - - // Check for duplicates. - $existing = $this->actionMapper->findAll( - filters: ['status' => 'active'] - ); - - $isDuplicate = false; - foreach ($existing as $existingAction) { - if ($existingAction->getName() === $name - && $existingAction->matchesEvent($eventType) === true - && in_array($schemaUuid, $existingAction->getSchemasArray()) === true - ) { - $isDuplicate = true; - break; - } - } - - if ($isDuplicate === true) { - $report['skipped'][] = ['name' => $name, 'reason' => 'duplicate']; - continue; - } - - $action = $this->createAction( - data: [ - 'name' => $name, - 'eventType' => $eventType, - 'engine' => $hook['engine'] ?? 'n8n', - 'workflowId' => $hook['workflowId'] ?? '', - 'mode' => $hook['mode'] ?? 'sync', - 'executionOrder' => $hook['order'] ?? 0, - 'timeout' => $hook['timeout'] ?? 30, - 'onFailure' => $hook['onFailure'] ?? 'reject', - 'schemas' => [$schemaUuid], - 'status' => 'active', - ] - ); - - $report['created'][] = $action->jsonSerialize(); - } catch (\Exception $e) { - $report['errors'][] = [ - 'hook' => $hook, - 'error' => $e->getMessage(), - ]; - }//end try - }//end foreach - - return $report; - }//end migrateFromHooks() - - /** - * Update statistics for an action after execution - * - * @param int $actionId Action ID - * @param string $status Execution status (success, failure, abandoned) - * - * @return void - * - * @spec openspec/specs/actions/spec.md - */ - public function updateStatistics(int $actionId, string $status): void { - try { - $action = $this->actionMapper->find(id: $actionId); - - $action->setExecutionCount($action->getExecutionCount() + 1); - $action->setLastExecutedAt(new DateTime()); - - if ($status === 'success') { - $action->setSuccessCount($action->getSuccessCount() + 1); - } - - if ($status !== 'success') { - $action->setFailureCount($action->getFailureCount() + 1); - } - - $this->actionMapper->update(entity: $action); - } catch (\Exception $e) { - $this->logger->warning( - message: '[ActionService] Failed to update action statistics', - context: ['actionId' => $actionId, 'error' => $e->getMessage()] - ); - }//end try - }//end updateStatistics() - - /** - * Get a nested value from an array using dot notation - * - * @param array $data Array to search - * @param string $key Dot-notation key - * - * @return mixed The value or null - */ - private function getNestedValue(array $data, string $key): mixed { - $keys = explode('.', $key); - - foreach ($keys as $segment) { - if (is_array($data) === false || array_key_exists($segment, $data) === false) { - return null; - } - - $data = $data[$segment]; - } - - return $data; - }//end getNestedValue() -}//end class diff --git a/lib/Service/ApprovalChainAnnotationInstaller.php b/lib/Service/ApprovalChainAnnotationInstaller.php index 0e004f7dcb..52a7a7d2ce 100644 --- a/lib/Service/ApprovalChainAnnotationInstaller.php +++ b/lib/Service/ApprovalChainAnnotationInstaller.php @@ -3,13 +3,21 @@ /** * OpenRegister ApprovalChainAnnotationInstaller * - * Runs at schema-save time. For every chain declared in - * `x-openregister-approval-chains`, upserts an `ApprovalChain` config row — the - * same row shape `POST /api/approval-chains` already produces — so the - * declarative annotation is a provisioning source for the existing approval - * engine instead of a second, parallel one. Mirrors - * `Service\Notification\NotificationsAnnotationInstaller` exactly (same - * SchemaCreatedEvent/SchemaUpdatedEvent hook, same find-then-upsert shape). + * The compiler for `x-openregister-approval-chains`. The annotation's + * declared shape is unchanged and stays registered in the schema annotation + * vocabulary; what changed is what a declaration provisions + * (flow-approval-consolidation design D-2). Where this class used to upsert + * an `ApprovalChain` row, it now compiles the declaration into a task + * TEMPLATE: a deterministic template id, one ordered position per + * `approvers` entry, and the chain-level policy (`transition`, + * `separationOfDuties`, `onApprove`, `amountField`). The template is a pure + * function of the schema, so provisioning is idempotent by construction: a + * re-save produces the same template, no second one and no new version. + * + * The class keeps its name and its schema-save subscription on purpose: the + * refusal path and its callers stay recognisable, which is what makes the + * behaviour-preservation argument reviewable (design D-2). At save time it + * only validates and reports; the gate compiles on demand. * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -25,47 +33,64 @@ * * @link https://OpenRegister.app * - * @spec openspec/specs/approval-workflow/spec.md + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-006 */ declare(strict_types=1); namespace OCA\OpenRegister\Service; -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalChainMapper; use OCA\OpenRegister\Db\Schema; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use Psr\Log\LoggerInterface; /** - * Listener that provisions `ApprovalChain` rows from a schema's declarative - * `x-openregister-approval-chains` block. + * Compiles `x-openregister-approval-chains` declarations into task templates. * * @template-implements IEventListener */ class ApprovalChainAnnotationInstaller implements IEventListener { + + /** + * The compiled template's version. The template is a pure function of + * the declaration, so there is exactly one version; the load-bearing + * freeze is the SNAPSHOT written onto each sequence at provisioning. + * + * @var integer + */ + public const TEMPLATE_VERSION = 1; + + /** + * Namespace prefix for the deterministic template id. + * + * @var string + */ + private const TEMPLATE_ID_NS = 'or-approval-template'; + /** * Constructor. * - * @param ApprovalChainMapper $chainMapper Mapper used to upsert ApprovalChain rows. - * @param LoggerInterface $logger Logger for installer diagnostics. + * @param LoggerInterface $logger Logger for compile diagnostics. */ public function __construct( - private readonly ApprovalChainMapper $chainMapper, private readonly LoggerInterface $logger, ) { }//end __construct() /** - * Listener entry point: dispatches schema-saved events to the installer. + * Listener entry point: validates declared chains at schema-save time. + * + * Nothing is written. The template is derived, so there is no row to + * upsert; what a save CAN surface early is a declaration that will fail + * the gate closed (`approval-chain-misconfigured`), and that is reported + * here where the schema author still has the save in front of them. * * @param Event $event The event carrying the saved schema. * * @return void * - * @spec openspec/specs/approval-workflow/spec.md + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-006 */ public function handle(Event $event): void { $schema = null; @@ -79,110 +104,140 @@ public function handle(Event $event): void { return; } - $this->installSchema(schema: $schema); + $config = ($schema->getConfiguration() ?? []); + $chains = ($config['x-openregister-approval-chains'] ?? null); + if (is_array($chains) === false) { + return; + } + + foreach (array_keys($chains) as $chainKey) { + if (is_string($chainKey) === false) { + continue; + } + + if ($this->compile(schema: $schema, chainKey: $chainKey) === null) { + $this->logger->warning( + sprintf( + '[ApprovalChainAnnotationInstaller] chain "%s" on schema %s declares no usable approver; its gate will refuse the transition as misconfigured.', + $chainKey, + (string)$schema->getId() + ) + ); + } + } }//end handle() /** - * Upsert an `ApprovalChain` row for every declared chain. + * Compile ONE declared chain into a task template. * - * Idempotent: safe to call repeatedly (also called defensively by - * `ApprovalChainGateListener` immediately before resolving a chain, so a - * gate evaluation never depends on this listener having already run for the - * current schema revision). + * Null when the chain cannot be compiled: undeclared, not an array, or + * without a single usable `{role}` entry. The gate treats null as + * fail-closed misconfiguration. * - * @param Schema $schema The schema whose annotations should be installed. + * @param Schema $schema The schema carrying the declaration. + * @param string $chainKey The declarative chain key. * - * @return void + * @return array|null The compiled template, or null. * - * @spec openspec/specs/approval-workflow/spec.md + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-006 */ - public function installSchema(Schema $schema): void { + public function compile(Schema $schema, string $chainKey): ?array { + $schemaId = $schema->getId(); + if ($schemaId === null) { + return null; + } + $config = ($schema->getConfiguration() ?? []); $chains = ($config['x-openregister-approval-chains'] ?? null); if (is_array($chains) === false) { - return; + return null; } - $schemaId = $schema->getId(); - if ($schemaId === null) { - return; + $spec = ($chains[$chainKey] ?? null); + if (is_array($spec) === false) { + return null; } - foreach ($chains as $chainKey => $spec) { - if (is_string($chainKey) === false || is_array($spec) === false) { - continue; - } - - $approvers = ($spec['approvers'] ?? null); - if (is_array($approvers) === false || $approvers === []) { - continue; - } - - $this->upsertChain(schemaId: (int)$schemaId, chainKey: $chainKey, approvers: $approvers); + $positions = $this->compilePositions(approvers: (array)($spec['approvers'] ?? [])); + if ($positions === []) { + return null; } - }//end installSchema() + + return [ + 'templateId' => $this->templateIdFor(schemaId: (int)$schemaId, chainKey: $chainKey), + 'templateVersion' => self::TEMPLATE_VERSION, + 'name' => $chainKey, + 'schemaId' => (int)$schemaId, + 'transition' => (string)($spec['transition'] ?? ''), + 'separationOfDuties' => (($spec['separationOfDuties'] ?? true) !== false), + 'onApprove' => (string)($spec['onApprove'] ?? ''), + 'amountField' => (string)($spec['amountField'] ?? ''), + 'positions' => $positions, + ]; + }//end compile() /** - * Upsert a single `ApprovalChain` row from one chain's `approvers` list. + * One ordered position per usable `approvers` entry. * - * @param int $schemaId Owning schema id. - * @param string $chainKey Declarative chain key (becomes `name`). - * @param array> $approvers Declared `{role, min, minAmount?}` tiers. + * @param array $approvers The declared approver tiers. * - * @return void + * @return array> The ordered positions. */ - private function upsertChain(int $schemaId, string $chainKey, array $approvers): void { - try { - $steps = []; - $order = 1; - foreach ($approvers as $tier) { - if (is_array($tier) === false) { - continue; - } - - $role = (string)($tier['role'] ?? ''); - if ($role === '') { - continue; - } - - $steps[] = [ - 'order' => $order, - 'role' => $role, - ]; - $order++; + private function compilePositions(array $approvers): array { + $positions = []; + $order = 1; + foreach ($approvers as $tier) { + if (is_array($tier) === false) { + continue; } - if ($steps === []) { - return; + $role = trim((string)($tier['role'] ?? '')); + if ($role === '') { + continue; } - $existing = $this->chainMapper->findBySchemaAndName(schemaId: $schemaId, name: $chainKey); - - $payload = [ - 'name' => $chainKey, - 'schemaId' => $schemaId, - 'steps' => json_encode($steps), - 'enabled' => true, + $position = [ + 'order' => $order, + 'role' => $role, ]; - - if ($existing === null) { - $this->chainMapper->createFromArray($payload); - $this->logger->info( - sprintf('[ApprovalChainAnnotationInstaller] provisioned chain "%s" for schema %d', $chainKey, $schemaId) - ); - return; + foreach (['min', 'minAmount', 'statusOnApprove', 'statusOnReject'] as $carry) { + if (array_key_exists($carry, $tier) === true) { + $position[$carry] = $tier[$carry]; + } } - $this->chainMapper->updateFromArray($existing->getId(), $payload); - } catch (\Throwable $e) { - $this->logger->warning( - sprintf( - '[ApprovalChainAnnotationInstaller] upsert "%s" (schema %d) failed: %s', - $chainKey, - $schemaId, - $e->getMessage() - ) - ); - }//end try - }//end upsertChain() + $positions[] = $position; + $order++; + }//end foreach + + return $positions; + }//end compilePositions() + + /** + * The deterministic template id for a (schema, chain key) pair. + * + * Uuid-shaped so it fits the 36-character `template_id` columns, and a + * pure function of its inputs so every compile, every gate evaluation + * and every migration run derives the SAME id: "exactly one template per + * chain" holds by construction rather than by a guarded insert. + * + * @param int $schemaId The owning schema id. + * @param string $chainKey The declarative chain key. + * + * @return string The template id, RFC-4122 shaped. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-every-in-flight-approval-survives-the-migration-at-the-same-position + */ + public function templateIdFor(int $schemaId, string $chainKey): string { + $hash = md5(self::TEMPLATE_ID_NS . ':' . $schemaId . ':' . $chainKey); + + return sprintf( + '%s-%s-%s-%s-%s', + substr($hash, 0, 8), + substr($hash, 8, 4), + substr($hash, 12, 4), + substr($hash, 16, 4), + substr($hash, 20, 12) + ); + }//end templateIdFor() }//end class diff --git a/lib/Service/ApprovalService.php b/lib/Service/ApprovalService.php deleted file mode 100644 index 8fa9e3c459..0000000000 --- a/lib/Service/ApprovalService.php +++ /dev/null @@ -1,464 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/approval-workflow/spec.md - * @spec openspec/specs/approval-workflow/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Service; - -use DateTime; -use Exception; -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalChainMapper; -use OCA\OpenRegister\Db\ApprovalStep; -use OCA\OpenRegister\Db\ApprovalStepMapper; -use OCA\OpenRegister\Db\Schema; -use OCA\OpenRegister\Db\SchemaMapper; -use OCA\OpenRegister\Db\WorkflowExecutionMapper; -use OCA\OpenRegister\Event\ApprovalStepApprovedEvent; -use OCA\OpenRegister\Event\ApprovalStepCompletedEvent; -use OCA\OpenRegister\Event\ApprovalStepInitiatedEvent; -use OCA\OpenRegister\Event\ApprovalStepRejectedEvent; -use OCP\EventDispatcher\IEventDispatcher; -use OCP\IGroupManager; -use Psr\Log\LoggerInterface; - -/** - * Service for managing multi-step approval chains. - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ -class ApprovalService { - /** - * Constructor for ApprovalService. - * - * @param ApprovalChainMapper $chainMapper Chain mapper - * @param ApprovalStepMapper $stepMapper Step mapper - * @param WorkflowExecutionMapper $executionMapper Execution history mapper - * @param IGroupManager $groupManager Group manager for role checks - * @param LoggerInterface $logger Logger - * @param IEventDispatcher $eventDispatcher Event dispatcher for approval step events - * @param SchemaMapper $schemaMapper Schema lookup, used to resolve the declarative - * `x-openregister-approval-chains` entry (if any) that - * provisioned a chain, for separation-of-duties enforcement. - */ - public function __construct( - private readonly ApprovalChainMapper $chainMapper, - private readonly ApprovalStepMapper $stepMapper, - private readonly WorkflowExecutionMapper $executionMapper, - private readonly IGroupManager $groupManager, - private readonly LoggerInterface $logger, - private readonly IEventDispatcher $eventDispatcher, - private readonly SchemaMapper $schemaMapper, - ) { - }//end __construct() - - /** - * Initialize approval steps for an object entering a chain. - * - * Creates ApprovalStep entities for each step in the chain: step 1 as - * 'pending', all others as 'waiting'. - * - * @param ApprovalChain $chain The approval chain - * @param string $objectUuid The object's UUID - * @param string|null $requesterId Uid of the user whose attempted transition - * triggered provisioning, when called from - * the declarative gate - * (`ApprovalChainGateListener`). Stamped - * onto every created step so {@see - * resolveSeparationOfDuties()} can later - * reject a self-decision. `null` for the - * pure-CRUD flow. - * @param array>|null $stepsOverride Step definitions to use instead of the - * chain's own static `steps` — used by the declarative - * gate's amount-threshold tier routing, where the - * applicable tier is resolved per-object rather than - * baked into the persisted chain config. - * - * @return array Created steps - * - * @spec openspec/specs/approval-workflow/spec.md - * @spec openspec/changes/add-approval-step-events/tasks.md#task-3 - * @spec openspec/specs/approval-workflow/spec.md - */ - public function initializeChain( - ApprovalChain $chain, - string $objectUuid, - ?string $requesterId = null, - ?array $stepsOverride = null, - ): array { - $steps = ($stepsOverride ?? $chain->getStepsArray()); - $createdSteps = []; - - foreach ($steps as $index => $stepDef) { - $status = 'waiting'; - if ($index === 0) { - $status = 'pending'; - } - - $step = $this->stepMapper->createFromArray( - [ - 'chainId' => $chain->getId(), - 'objectUuid' => $objectUuid, - 'stepOrder' => ($stepDef['order'] ?? ($index + 1)), - 'role' => ($stepDef['role'] ?? ''), - 'status' => $status, - 'requesterId' => $requesterId, - ] - ); - - $createdSteps[] = $step; - - // Dispatch initiated event for the first step (now `pending`). - if ($index === 0) { - $this->eventDispatcher->dispatchTyped( - new ApprovalStepInitiatedEvent(chain: $chain, step: $step, objectUuid: $objectUuid) - ); - } - }//end foreach - - return $createdSteps; - }//end initializeChain() - - /** - * Approve a pending approval step. - * - * Returns an array with the updated step and any next step info. - * - * @param int $stepId Step ID - * @param string $userId Current user ID - * @param string $comment Approval comment - * - * @return array{step: ApprovalStep, nextStep: ApprovalStep|null, statusOnApprove: string} - * - * @throws Exception If user is not authorised or step is not pending - * - * @spec openspec/specs/approval-workflow/spec.md - * @spec openspec/changes/add-approval-step-events/tasks.md#task-4 - */ - public function approveStep(int $stepId, string $userId, string $comment = ''): array { - $step = $this->stepMapper->find($stepId); - - if ($step->getStatus() !== 'pending') { - throw new Exception('Step is not in pending status'); - } - - // Separation of duties is evaluated BEFORE the role check so a - // self-decision attempt gets a distinct, honest error rather than being - // masked by (or coincidentally passing) the group-membership check. - $this->verifySeparationOfDuties(step: $step, userId: $userId); - - // Verify role membership. - $this->verifyRole(userId: $userId, role: $step->getRole()); - - // Update the step. - $step->setStatus('approved'); - $step->setDecidedBy($userId); - $step->setComment($comment); - $step->setDecidedAt(new DateTime()); - $this->stepMapper->update($step); - - // Load the chain to get step definitions. - $chain = $this->chainMapper->find($step->getChainId()); - $chainSteps = $chain->getStepsArray(); - - // Find the current step definition for statusOnApprove. - $statusOnApprove = 'approved'; - foreach ($chainSteps as $def) { - if (($def['order'] ?? 0) === $step->getStepOrder()) { - $statusOnApprove = ($def['statusOnApprove'] ?? 'approved'); - break; - } - } - - // Advance the next step to 'pending'. - $nextStep = null; - $allSteps = $this->stepMapper->findByChainAndObject($chain->getId(), $step->getObjectUuid()); - foreach ($allSteps as $candidate) { - if ($candidate->getStepOrder() > $step->getStepOrder() && $candidate->getStatus() === 'waiting') { - $candidate->setStatus('pending'); - $this->stepMapper->update($candidate); - $nextStep = $candidate; - break; - } - } - - // Persist execution history. - $this->persistApprovalExecution(chain: $chain, step: $step, status: 'approved'); - - // Dispatch the approved event. - $this->eventDispatcher->dispatchTyped( - new ApprovalStepApprovedEvent( - chain: $chain, - step: $step, - userId: $userId, - statusOnApprove: $statusOnApprove, - nextStep: $nextStep - ) - ); - - // Dispatch follow-up: either initiate the next step or complete the chain. - if ($nextStep !== null) { - $this->eventDispatcher->dispatchTyped( - new ApprovalStepInitiatedEvent( - chain: $chain, - step: $nextStep, - objectUuid: $step->getObjectUuid() - ) - ); - } else { - $this->eventDispatcher->dispatchTyped( - new ApprovalStepCompletedEvent( - chain: $chain, - finalStep: $step, - userId: $userId, - statusOnApprove: $statusOnApprove - ) - ); - } - - return [ - 'step' => $step, - 'nextStep' => $nextStep, - 'statusOnApprove' => $statusOnApprove, - 'chain' => $chain, - ]; - }//end approveStep() - - /** - * Reject a pending approval step. - * - * @param int $stepId Step ID - * @param string $userId Current user ID - * @param string $comment Rejection comment - * - * @return array{step: ApprovalStep, statusOnReject: string} - * - * @throws Exception If user is not authorised or step is not pending - * - * @spec openspec/specs/approval-workflow/spec.md - * @spec openspec/changes/add-approval-step-events/tasks.md#task-5 - */ - public function rejectStep(int $stepId, string $userId, string $comment = ''): array { - $step = $this->stepMapper->find($stepId); - - if ($step->getStatus() !== 'pending') { - throw new Exception('Step is not in pending status'); - } - - // Separation of duties is evaluated BEFORE the role check (see - // approveStep()). - $this->verifySeparationOfDuties(step: $step, userId: $userId); - - // Verify role membership. - $this->verifyRole(userId: $userId, role: $step->getRole()); - - // Update the step. - $step->setStatus('rejected'); - $step->setDecidedBy($userId); - $step->setComment($comment); - $step->setDecidedAt(new DateTime()); - $this->stepMapper->update($step); - - // Load the chain to get step definitions. - $chain = $this->chainMapper->find($step->getChainId()); - $chainSteps = $chain->getStepsArray(); - - // Find the current step definition for statusOnReject. - $statusOnReject = 'rejected'; - foreach ($chainSteps as $def) { - if (($def['order'] ?? 0) === $step->getStepOrder()) { - $statusOnReject = ($def['statusOnReject'] ?? 'rejected'); - break; - } - } - - // Persist execution history. - $this->persistApprovalExecution(chain: $chain, step: $step, status: 'rejected'); - - // Dispatch the rejected event — chain is terminated; no next-step event. - $this->eventDispatcher->dispatchTyped( - new ApprovalStepRejectedEvent( - chain: $chain, - step: $step, - userId: $userId, - statusOnReject: $statusOnReject - ) - ); - - return [ - 'step' => $step, - 'statusOnReject' => $statusOnReject, - 'chain' => $chain, - ]; - }//end rejectStep() - - /** - * Reject a decision made by the same user who triggered the chain, when the - * chain's schema declares separation of duties. - * - * The chain is resolved lazily — only when the decider actually IS the - * recorded requester — so the pure-CRUD flow (steps with no `requesterId`, - * and callers whose step carries no `chainId`) reaches the role check on - * exactly the code path it always did, before this change existed. - * - * @param ApprovalStep $step The step being decided. - * @param string $userId The deciding user's id. - * - * @return void - * - * @throws Exception When the decider is the chain's own requester and - * separation of duties applies. - * - * @spec openspec/specs/approval-workflow/spec.md - */ - private function verifySeparationOfDuties(ApprovalStep $step, string $userId): void { - $requesterId = $step->getRequesterId(); - if ($requesterId === null || $requesterId === '' || $requesterId !== $userId) { - // No requester recorded (pure-CRUD step), or the decider is not the - // requester — nothing to enforce either way. - return; - } - - $chainId = $step->getChainId(); - if ($chainId === null) { - return; - } - - $chain = $this->chainMapper->find((int)$chainId); - - if ($this->resolveSeparationOfDuties(chain: $chain) === true) { - throw new Exception('You may not decide an approval step you requested yourself'); - } - }//end verifySeparationOfDuties() - - /** - * Resolve whether a chain's declarative entry requires separation of duties. - * - * Loads the chain's schema and looks for an `x-openregister-approval-chains` - * entry whose key equals the chain's `name` (the shape - * `ApprovalChainAnnotationInstaller` provisions from). When found, - * `separationOfDuties` defaults to `true` (fail-safe) unless the schema - * explicitly sets it `false`. When no matching declarative entry exists — - * the pre-existing pure-CRUD-provisioned chain — this returns `false`, so - * that flow is completely unaffected by this change. - * - * @param ApprovalChain $chain The chain to resolve. - * - * @return bool True when separation of duties applies. - * - * @spec openspec/specs/approval-workflow/spec.md - */ - private function resolveSeparationOfDuties(ApprovalChain $chain): bool { - $schemaId = $chain->getSchemaId(); - if ($schemaId === null) { - return false; - } - - try { - $schema = $this->schemaMapper->find($schemaId, _multitenancy: false); - } catch (\Throwable $e) { - return false; - } - - // No instanceof re-check: the lookup above is declared to return Schema - // and throws otherwise, which the catch already handles. - $config = ($schema->getConfiguration() ?? []); - $chains = ($config['x-openregister-approval-chains'] ?? null); - if (is_array($chains) === false) { - return false; - } - - $entry = ($chains[(string)$chain->getName()] ?? null); - if (is_array($entry) === false) { - return false; - } - - return ($entry['separationOfDuties'] ?? true) !== false; - }//end resolveSeparationOfDuties() - - /** - * Verify that a user is a member of the required group/role. - * - * @param string $userId User ID - * @param string $role Required role (Nextcloud group ID) - * - * @return void - * - * @throws Exception If user is not in the required group - * - * @spec openspec/specs/approval-workflow/spec.md - */ - private function verifyRole(string $userId, string $role): void { - if ($this->groupManager->isInGroup($userId, $role) === false) { - throw new Exception('You are not authorised for this approval step'); - } - }//end verifyRole() - - /** - * Persist an approval action to the execution history. - * - * @param ApprovalChain $chain The approval chain - * @param ApprovalStep $step The approval step - * @param string $status The approval status - * - * @return void - * - * @spec openspec/specs/approval-workflow/spec.md - */ - private function persistApprovalExecution( - ApprovalChain $chain, - ApprovalStep $step, - string $status, - ): void { - try { - $this->executionMapper->createFromArray( - [ - 'hookId' => 'approval-chain-' . $chain->getId(), - 'eventType' => 'approval', - 'objectUuid' => $step->getObjectUuid(), - 'schemaId' => $chain->getSchemaId(), - 'engine' => 'approval', - 'workflowId' => 'chain-' . $chain->getId() . '-step-' . $step->getStepOrder(), - 'mode' => 'sync', - 'status' => $status, - 'durationMs' => 0, - 'metadata' => json_encode( - [ - 'chainName' => $chain->getName(), - 'stepOrder' => $step->getStepOrder(), - 'role' => $step->getRole(), - 'decidedBy' => $step->getDecidedBy(), - 'comment' => $step->getComment(), - ] - ), - 'executedAt' => new DateTime(), - ] - ); - } catch (Exception $e) { - $this->logger->warning( - message: '[ApprovalService] Failed to persist approval execution', - context: ['chainId' => $chain->getId(), 'stepId' => $step->getId(), 'error' => $e->getMessage()] - ); - }//end try - }//end persistApprovalExecution() -}//end class diff --git a/lib/Service/Archival/ArchivalRetentionGuard.php b/lib/Service/Archival/ArchivalRetentionGuard.php new file mode 100644 index 0000000000..715b1bacca --- /dev/null +++ b/lib/Service/Archival/ArchivalRetentionGuard.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 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Archival; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use Psr\Log\LoggerInterface; + +/** + * Decide whether a record is retained, and say so in words a handler can use. + * + * RETENTION WINS OVER ERASURE. A record on a schema declaring + * `x-openregister-archival` is held under a legal obligation, and an erasure + * request does not lift that obligation: GDPR art. 17(3)(b) exempts processing + * required by law, and for a Dutch government record the Archiefwet is that law. + * So the erasure paths REFUSE the row, RECORD the refusal, and REPORT it back + * with the rest of the request's answer. + * + * The refusal is per row. A request over a mixed set still erases everything + * that is not retained: one held record must never block a lawful erasure of + * the others. + * + * FOUR DELETE DOORS ALREADY ASK THIS QUESTION — `objects#destroy`, + * `deleted#destroy`, `bulk#delete` and `bulk#deleteSchema` via + * {@see \OCA\OpenRegister\Service\SchemaDeletionService}. They ask it of + * {@see Schema::hasArchivalAnnotation()}, the single definition of "is + * archival". This guard is the fifth consumer of that same method, not a fifth + * definition of the rule: it does not re-read the annotation, and it does not + * restate the condition. Change the rule in one place and every door moves. + * + * FAILS CLOSED. A record whose schema cannot be resolved is refused, because an + * unresolvable schema is precisely the case where the annotation cannot be read + * and the record might be retained. That refusal is reported under its own + * ground so a handler can tell "the law holds this" from "we could not tell". + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ +class ArchivalRetentionGuard { + + /** + * Ground: the record is retained under a legal archival obligation. + * + * @var string + */ + public const GROUND_ARCHIVAL = 'ARCHIVAL_RETENTION_OBLIGATION'; + + /** + * Ground: the record's schema could not be resolved, so it was left alone. + * + * @var string + */ + public const GROUND_UNRESOLVED = 'SCHEMA_UNRESOLVED'; + + /** + * Context: an erasure request reached this record. + * + * @var string + */ + public const CONTEXT_ERASURE = 'erasure'; + + /** + * Context: a parent's delete cascaded onto this record. + * + * @var string + */ + public const CONTEXT_CASCADE = 'cascade'; + + /** + * What each refusal says, per context and ground. + * + * This text reaches a citizen exercising a legal right, so it names the + * obligation rather than the mechanism, and it ends on what happens next. + * + * @var array>> + */ + private const WORDING = [ + self::CONTEXT_ERASURE => [ + self::GROUND_ARCHIVAL => [ + 'message' => 'The law requires us to keep this record, so we did not erase it.', + 'basis' => 'GDPR art. 17(3)(b) and the Archiefwet.', + 'action' => 'Name this record in your answer to the requester. ' + . 'A records officer decides when it may be destroyed.', + ], + self::GROUND_UNRESOLVED => [ + 'message' => 'We could not read the retention rules for this record, so we left it alone.', + 'basis' => 'Precaution, because a retention rule we cannot read may be a legal one.', + 'action' => 'Ask an administrator to repair the schema, then run the request again.', + ], + ], + self::CONTEXT_CASCADE => [ + self::GROUND_ARCHIVAL => [ + 'message' => 'The law requires us to keep this record. Its parent is gone, this record stays.', + 'basis' => 'GDPR art. 17(3)(b) and the Archiefwet.', + 'action' => 'Point this record at a live parent, or record why the reference may dangle.', + ], + self::GROUND_UNRESOLVED => [ + 'message' => 'We could not read the retention rules for this record, so we left it alone.', + 'basis' => 'Precaution, because a retention rule we cannot read may be a legal one.', + 'action' => 'Ask an administrator to repair the schema, then clean up this record by hand.', + ], + ], + ]; + + /** + * Memoised schema lookups, keyed by the raw schema identifier. + * + * Holds `false` for an identifier that could not be resolved, so a repeated + * miss inside one request does not re-hit the mapper. + * + * @var array + */ + private array $schemaMemo = []; + + /** + * Constructor. + * + * @param SchemaMapper $schemaMapper Schema reader, used unscoped (see resolveSchema()). + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly SchemaMapper $schemaMapper, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Decide whether an erasure request may destroy this record. + * + * @param ObjectEntity $object The record an erasure request has reached. + * + * @return array|null The withheld report, or null when the erasure may proceed. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + public function erasureRefusal(ObjectEntity $object): ?array { + return $this->refusalFor( + uuid: (string)($object->getUuid() ?? ''), + schemaIdentifier: $object->getSchema(), + registerIdentifier: $object->getRegister(), + context: self::CONTEXT_ERASURE + ); + }//end erasureRefusal() + + /** + * Decide whether a parent's delete may cascade onto this record. + * + * A RETAINED RECORD STAYS LIVE EVEN WHEN ITS PARENT GOES. The cascade skips + * it and the parent delete still proceeds, so a retained child never blocks + * the delete that reached it. + * + * @param string $uuid UUID of the cascade target. + * @param string|int|null $schemaIdentifier Schema identifier carried on the cascade target. + * + * @return array|null The retained report, or null when the cascade may proceed. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + public function cascadeRefusal(string $uuid, string|int|null $schemaIdentifier): ?array { + return $this->refusalFor( + uuid: $uuid, + schemaIdentifier: $schemaIdentifier, + registerIdentifier: null, + context: self::CONTEXT_CASCADE + ); + }//end cascadeRefusal() + + /** + * Build the refusal for one record, or null when it may be deleted. + * + * @param string $uuid Record UUID. + * @param string|int|null $schemaIdentifier Schema identifier of the record. + * @param string|int|null $registerIdentifier Register identifier of the record, when known. + * @param string $context One of the CONTEXT_* constants. + * + * @return array|null + */ + private function refusalFor( + string $uuid, + string|int|null $schemaIdentifier, + string|int|null $registerIdentifier, + string $context, + ): ?array { + $schema = $this->resolveSchema(schemaIdentifier: $schemaIdentifier); + + if ($schema === null) { + $this->logger->warning( + message: '[ArchivalRetentionGuard] Left a record alone because its schema could not be resolved', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'uuid' => $uuid, + 'schema' => $schemaIdentifier, + 'operation' => $context, + ] + ); + + return $this->report( + uuid: $uuid, + schemaLabel: (string)($schemaIdentifier ?? 'unknown'), + registerIdentifier: $registerIdentifier, + ground: self::GROUND_UNRESOLVED, + context: $context + ); + } + + // THE SINGLE DEFINITION OF "IS ARCHIVAL", asked here and nowhere else in + // this class. Restating the condition is what let a test agree with + // itself in openregister#3428; this guard has no opinion of its own. + if ($schema->hasArchivalAnnotation() === false) { + return null; + } + + return $this->report( + uuid: $uuid, + schemaLabel: ($schema->getSlug() ?? (string)$schema->getId()), + registerIdentifier: $registerIdentifier, + ground: self::GROUND_ARCHIVAL, + context: $context + ); + }//end refusalFor() + + /** + * Resolve a schema identifier to its entity, unscoped and memoised. + * + * READ UNSCOPED, DELIBERATELY. Every caller here runs either as a cron with + * no session or as a privacy officer sweeping across tenants, so an RBAC- or + * tenant-scoped read would answer "not found" for a schema that plainly + * exists. Since the guard fails closed, that miss would refuse every row in + * the run and quietly stop the sweep. + * + * @param string|int|null $schemaIdentifier The raw identifier from the record. + * + * @return Schema|null The schema, or null when it cannot be resolved. + */ + private function resolveSchema(string|int|null $schemaIdentifier): ?Schema { + if ($schemaIdentifier === null || $schemaIdentifier === '' || $schemaIdentifier === 0) { + return null; + } + + $key = (string)$schemaIdentifier; + if (array_key_exists($key, $this->schemaMemo) === true) { + $memo = $this->schemaMemo[$key]; + if ($memo === false) { + return null; + } + + return $memo; + } + + try { + $schema = $this->schemaMapper->find( + $schemaIdentifier, + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable $e) { + $this->schemaMemo[$key] = false; + return null; + } + + $this->schemaMemo[$key] = $schema; + + return $schema; + }//end resolveSchema() + + /** + * Put the refusal into words a handler can pass on to the requester. + * + * The `message` answers the data subject: why the record is still there. The + * `action` tells the handler what to do about it, because a refusal nobody + * acts on is the same as a silent skip. + * + * @param string $uuid Record UUID. + * @param string $schemaLabel Schema slug or identifier to name in the report. + * @param string|int|null $registerIdentifier Register identifier, when known. + * @param string $ground One of the GROUND_* constants. + * @param string $context One of the CONTEXT_* constants. + * + * @return array + */ + private function report( + string $uuid, + string $schemaLabel, + string|int|null $registerIdentifier, + string $ground, + string $context, + ): array { + $wording = self::WORDING[$context][$ground]; + + $report = [ + 'uuid' => $uuid, + 'schema' => $schemaLabel, + 'ground' => $ground, + 'operation' => $context, + 'message' => $wording['message'], + 'basis' => $wording['basis'], + 'action' => $wording['action'], + ]; + + if ($registerIdentifier !== null && $registerIdentifier !== '') { + $report['register'] = (string)$registerIdentifier; + } + + return $report; + }//end report() + +}//end class diff --git a/lib/Service/Archival/DestructionService.php b/lib/Service/Archival/DestructionService.php index fdd8162ad3..d38ac8510b 100644 --- a/lib/Service/Archival/DestructionService.php +++ b/lib/Service/Archival/DestructionService.php @@ -212,7 +212,7 @@ public function findEligibleObjects(array $existingListObjectIds = []): array { 'register' => $object->getRegister(), 'archiefactiedatum' => $actiedatum, 'classification' => $retention['classification'] ?? null, - 'alreadySoftDeleted' => ($object->getDeleted() !== null), + 'alreadySoftDeleted' => $object->isSoftDeleted(), ]; }//end foreach @@ -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/Authorization/RbacGroupCollector.php b/lib/Service/Authorization/RbacGroupCollector.php index 946f06320d..b68a6b3e06 100644 --- a/lib/Service/Authorization/RbacGroupCollector.php +++ b/lib/Service/Authorization/RbacGroupCollector.php @@ -48,10 +48,24 @@ class RbacGroupCollector { * `public` is a PSEUDO-principal denoting anonymous access; creating a real * Nextcloud group named `public` would be meaningless at best and would * suggest a membership-based grant that RBAC never consults. + * `authenticated` is the same shape of pseudo-principal for "any logged-in + * user". Three independent resolvers short-circuit it before any group test + * — {@see \OCA\OpenRegister\Db\MagicMapper\MagicRbacHandler}, + * {@see \OCA\OpenRegister\Service\Object\PermissionHandler} and + * {@see \OCA\OpenRegister\Service\PropertyRbacHandler} — so it is never + * resolved through IGroupManager and a real group of that name grants + * nothing extra. Leaving it out cost twice: provisioning created an empty + * Nextcloud group called `authenticated`, and `occ openregister:declared-groups` + * then reported the fleet's broadest working grant as "grants nobody + * anything" — a false alarm that sends an operator hunting a bug that is not + * there. Verified 2026-09-05 on a dev instance carrying both symptoms. + * + * The full set of virtual principals is exactly these three; a grep of the + * three resolvers for special-cased principal strings finds no fourth. * * @var string[] */ - public const RESERVED_PRINCIPALS = ['admin', 'public']; + public const RESERVED_PRINCIPALS = ['admin', 'public', 'authenticated']; /** * Authorization keys that hold a role-name => group(s) map rather than an diff --git a/lib/Service/AvgRetentionService.php b/lib/Service/AvgRetentionService.php index 967d144737..01b951533c 100644 --- a/lib/Service/AvgRetentionService.php +++ b/lib/Service/AvgRetentionService.php @@ -44,6 +44,7 @@ use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Verwerkingsactiviteit; use OCA\OpenRegister\Db\VerwerkingsactiviteitMapper; +use OCA\OpenRegister\Service\Archival\ArchivalRetentionGuard; use OCP\AppFramework\Db\DoesNotExistException; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -51,6 +52,10 @@ /** * Retention-period-driven object retention enforcement. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) One over, from the archival + * retention guard: the pass must ask whether a record is legally retained + * before it erases it, and that question has one owner. */ class AvgRetentionService { /** @@ -60,6 +65,7 @@ class AvgRetentionService { * @param VerwerkingsactiviteitMapper $vrwMapper Catalog reader. * @param MagicMapper $objectMapper Object loader. * @param LoggerInterface $logger Logger. + * @param ArchivalRetentionGuard $archivalGuard Refuses erasure of a legally retained record. * * @spec openspec/specs/retention-management/spec.md */ @@ -68,6 +74,7 @@ public function __construct( private readonly VerwerkingsactiviteitMapper $vrwMapper, private readonly MagicMapper $objectMapper, private readonly LoggerInterface $logger, + private readonly ArchivalRetentionGuard $archivalGuard, ) { }//end __construct() @@ -81,10 +88,17 @@ public function __construct( * "evaluatedActivities": , * "skippedActivities": , * "objectsErased": , + * "objectsWithheld": , + * "withheld": [ + * {"uuid": "...", "schema": "...", "ground": "ARCHIVAL_RETENTION_OBLIGATION", + * "message": "...", "basis": "...", "action": "..."}, + * ... + * ], * "dryRun": bool, * "perActivity": [ * {"uuid": "...", "naam": "...", "bewaartermijn": "P10Y", - * "cutoff": "", "matchedObjects": , "erased": }, + * "cutoff": "", "matchedObjects": , "erased": , + * "withheld": [...]}, * ... * ] * } @@ -109,6 +123,11 @@ public function runRetentionPass(bool $dryRun = false): array { 'evaluatedActivities' => 0, 'skippedActivities' => 0, 'objectsErased' => 0, + // RETENTION WINS OVER ERASURE. The archival obligation on a schema + // outlives this activity's bewaartermijn, so those records are left + // live and named here rather than counted as erased. + 'objectsWithheld' => 0, + 'withheld' => [], 'dryRun' => $dryRun, 'perActivity' => [], ]; @@ -127,6 +146,8 @@ public function runRetentionPass(bool $dryRun = false): array { $summary['evaluatedActivities']++; $summary['objectsErased'] += $perActivity['erased']; + $summary['objectsWithheld'] += count($perActivity['withheld']); + $summary['withheld'] = array_merge($summary['withheld'], $perActivity['withheld']); $summary['perActivity'][] = $perActivity; } @@ -169,11 +190,14 @@ private function processActivity(Verwerkingsactiviteit $activity, DateTime $now, ); $erased = 0; + $withheld = []; if ($dryRun === false && $candidates !== []) { - $erased = $this->erasePastRetention( + $outcome = $this->erasePastRetention( candidates: $candidates, activity: $activity ); + $erased = $outcome['erased']; + $withheld = $outcome['withheld']; } return [ @@ -183,6 +207,7 @@ private function processActivity(Verwerkingsactiviteit $activity, DateTime $now, 'cutoff' => $cutoff->format('c'), 'matchedObjects' => count($candidates), 'erased' => $erased, + 'withheld' => $withheld, ]; }//end processActivity() @@ -294,14 +319,24 @@ private function findOverdueObjectsForActivity(string $activityUuid, DateTime $c * erasures; failures are logged and skipped so a single bad * object doesn't abort the whole pass. * + * RETENTION WINS OVER ERASURE. This pass enforces the verwerkingsregister's + * bewaartermijn, which is shorter than the archival obligation a schema + * declares with `x-openregister-archival`. When the two disagree the + * archival obligation wins: the record is left live and named in `withheld`, + * so an operator reading the run sees a record kept rather than one erased. + * Withholding one record does not stop the pass. + * + * USED TO RETURN A BARE INT, which is why a withheld record could not be told + * apart from an erased one: there was no per-object channel at all. + * * @param array> $candidates Overdue objects. * @param Verwerkingsactiviteit $activity Owning activity. * - * @return int + * @return array{erased: int, withheld: array>} * * @spec openspec/specs/retention-management/spec.md */ - private function erasePastRetention(array $candidates, Verwerkingsactiviteit $activity): int { + private function erasePastRetention(array $candidates, Verwerkingsactiviteit $activity): array { // New writes use the renamed `retentionPeriod` key (verwerkingsregister-i18n); already // -persisted historical `deleted` blobs keep their old `bewaartermijn` key unrewritten — // they are point-in-time compliance-evidence snapshots (see design.md). The `reason` tag @@ -316,12 +351,19 @@ private function erasePastRetention(array $candidates, Verwerkingsactiviteit $ac ]; $erased = 0; + $withheld = []; foreach ($candidates as $candidate) { $object = $this->loadCandidate(candidate: $candidate); if ($object === null) { continue; } + $refusal = $this->archivalGuard->erasureRefusal(object: $object); + if ($refusal !== null) { + $withheld[] = $refusal; + continue; + } + $object->setDeleted($deletionData); $object->setProcessingActivityId((string)$activity->getUuid()); @@ -336,7 +378,10 @@ private function erasePastRetention(array $candidates, Verwerkingsactiviteit $ac } } - return $erased; + return [ + 'erased' => $erased, + 'withheld' => $withheld, + ]; }//end erasePastRetention() /** 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/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/lib/Service/Configuration/ExportHandler.php b/lib/Service/Configuration/ExportHandler.php index 74d7a41ebf..dafc69897f 100644 --- a/lib/Service/Configuration/ExportHandler.php +++ b/lib/Service/Configuration/ExportHandler.php @@ -28,7 +28,6 @@ use Exception; use OCA\OpenRegister\Db\Configuration; use OCA\OpenRegister\Db\ConfigurationMapper; -use OCA\OpenRegister\Db\DeployedWorkflowMapper; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\MappingMapper; use OCA\OpenRegister\Db\Register; @@ -36,7 +35,6 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\Authorization\RbacGroupCollector; -use OCA\OpenRegister\Service\WorkflowEngineRegistry; use Psr\Log\LoggerInterface; /** @@ -101,12 +99,6 @@ class ExportHandler { */ private array $schemasMap = []; - /** - * Workflow engine registry for resolving adapters. - * - * @var WorkflowEngineRegistry|null - */ - private ?WorkflowEngineRegistry $workflowEngineRegistry = null; /** * Mapping mapper instance for handling mapping operations. @@ -115,12 +107,6 @@ class ExportHandler { */ private readonly MappingMapper $mappingMapper; - /** - * Mapper for deployed workflow entities. - * - * @var DeployedWorkflowMapper|null - */ - private ?DeployedWorkflowMapper $deployedWorkflowMapper = null; /** * Constructor for ExportHandler. @@ -148,27 +134,7 @@ public function __construct( $this->logger = $logger; }//end __construct() - /** - * Set the workflow engine registry. - * - * @param WorkflowEngineRegistry $registry The registry - * - * @return void - */ - public function setWorkflowEngineRegistry(WorkflowEngineRegistry $registry): void { - $this->workflowEngineRegistry = $registry; - }//end setWorkflowEngineRegistry() - /** - * Set the deployed workflow mapper. - * - * @param DeployedWorkflowMapper $mapper The mapper - * - * @return void - */ - public function setDeployedWorkflowMapper(DeployedWorkflowMapper $mapper): void { - $this->deployedWorkflowMapper = $mapper; - }//end setDeployedWorkflowMapper() /** * Export configuration to OpenAPI format. @@ -327,14 +293,6 @@ public function exportConfig( $openApiSpec['components']['registers'][$register->getSlug()]['schemas'][] = $schema->getSlug(); } - // Export workflows attached to schemas in this register. - foreach ($schemas as $schemaForWorkflows) { - $workflowExports = $this->exportWorkflowsForSchema(schemaSlug: $schemaForWorkflows->getSlug()); - foreach ($workflowExports as $workflowExport) { - $openApiSpec['components']['workflows'][] = $workflowExport; - } - } - // Optionally include objects in the register. if ($includeObjects === true) { $objects = $this->objectEntityMapper->findAll( @@ -721,68 +679,4 @@ private function getLastNumericSegment(string $url): string { return $url; }//end getLastNumericSegment() - /** - * Export deployed workflows for a given schema slug. - * - * Queries DeployedWorkflow records attached to the schema, fetches their - * definitions from the engine, and builds export entries. - * - * @param string $schemaSlug The schema slug to find workflows for - * - * @return array> Workflow export entries - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) Workflow export handles engine lookup and error recovery - */ - private function exportWorkflowsForSchema(string $schemaSlug): array { - if ($this->deployedWorkflowMapper === null || $this->workflowEngineRegistry === null) { - return []; - } - - $deployedWorkflows = $this->deployedWorkflowMapper->findBySchema(schemaSlug: $schemaSlug); - $exports = []; - - foreach ($deployedWorkflows as $deployed) { - $engineType = $deployed->getEngine(); - $engines = $this->workflowEngineRegistry->getEnginesByType(engineType: $engineType); - - if (count($engines) === 0) { - $this->logger->warning( - message: '[ExportHandler] No engine found for type during workflow export', - context: ['engineType' => $engineType, 'workflowName' => $deployed->getName()] - ); - continue; - } - - try { - $adapter = $this->workflowEngineRegistry->resolveAdapter(engine: $engines[0]); - $definition = $adapter->getWorkflow(workflowId: $deployed->getEngineWorkflowId()); - } catch (Exception $e) { - $this->logger->warning( - message: '[ExportHandler] Failed to fetch workflow definition for export', - context: [ - 'workflowName' => $deployed->getName(), - 'error' => $e->getMessage(), - ] - ); - $definition = []; - } - - $export = [ - 'name' => $deployed->getName(), - 'engine' => $engineType, - 'definition' => $definition, - ]; - - if ($deployed->getAttachedSchema() !== null) { - $export['attachTo'] = [ - 'schema' => $deployed->getAttachedSchema(), - 'event' => $deployed->getAttachedEvent() ?? 'created', - ]; - } - - $exports[] = $export; - }//end foreach - - return $exports; - }//end exportWorkflowsForSchema() }//end class diff --git a/lib/Service/Configuration/ImportHandler.php b/lib/Service/Configuration/ImportHandler.php index 4b0f9bc964..2c51d5eb5a 100644 --- a/lib/Service/Configuration/ImportHandler.php +++ b/lib/Service/Configuration/ImportHandler.php @@ -37,8 +37,6 @@ use GuzzleHttp\Exception\GuzzleException; use OCA\OpenRegister\Db\Configuration; use OCA\OpenRegister\Db\ConfigurationMapper; -use OCA\OpenRegister\Db\DeployedWorkflow; -use OCA\OpenRegister\Db\DeployedWorkflowMapper; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\Mapping; use OCA\OpenRegister\Db\MappingMapper; @@ -55,7 +53,6 @@ use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Service\SystemOperationContext; use OCA\OpenRegister\Service\TaskService; -use OCA\OpenRegister\Service\WorkflowEngineRegistry; use OCP\AppFramework\Http\JSONResponse; use OCP\IAppConfig; use OCP\IGroupManager; @@ -219,19 +216,7 @@ class ImportHandler { */ private mixed $connectorConfigSvc = null; - /** - * Workflow engine registry for resolving adapters during import. - * - * @var WorkflowEngineRegistry|null - */ - private ?WorkflowEngineRegistry $workflowRegistry = null; - /** - * Deployed workflow mapper for tracking imported workflows. - * - * @var DeployedWorkflowMapper|null - */ - private ?DeployedWorkflowMapper $deployedWfMapper = null; /** * Optional task service for seeding related VTODO items. @@ -369,27 +354,7 @@ public function setOpenConnectorConfigurationService(mixed $service): void { $this->connectorConfigSvc = $service; }//end setOpenConnectorConfigurationService() - /** - * Set the WorkflowEngineRegistry dependency. - * - * @param WorkflowEngineRegistry $registry The workflow engine registry. - * - * @return void - */ - public function setWorkflowEngineRegistry(WorkflowEngineRegistry $registry): void { - $this->workflowRegistry = $registry; - }//end setWorkflowEngineRegistry() - /** - * Set the DeployedWorkflowMapper dependency. - * - * @param DeployedWorkflowMapper $mapper The deployed workflow mapper. - * - * @return void - */ - public function setDeployedWorkflowMapper(DeployedWorkflowMapper $mapper): void { - $this->deployedWfMapper = $mapper; - }//end setDeployedWorkflowMapper() /** * Inject the TaskService used by seed-related-items to create VTODO tasks. @@ -2232,6 +2197,7 @@ public function importFromJson( 'mappings' => 0, 'seedObjects' => 0, ], + 'unchanged' => ['objects' => 0], 'failed' => ['schemas' => []], ]; }//end if @@ -2273,6 +2239,16 @@ public function importFromJson( // that declare it are created without the link, so the damage // surfaces layers away (empty `*_schema` config keys, a seed step // that cannot resolve a schema) with nothing pointing back here. + // The entities this import LEFT ALONE because they are already + // present at the same or a newer version. Counted because a caller + // otherwise cannot tell "nothing needed doing" from "everything + // failed": both arrive as `objects: []`. dossiq's demo-data step + // read that ambiguity as failure and refused every re-install, + // breaking the promise its own UI makes ("safe to run more than + // once"). + 'unchanged' => [ + 'objects' => 0, + ], 'failed' => [ 'schemas' => [], ], @@ -2627,26 +2603,6 @@ public function importFromJson( }//end foreach }//end if - // Process and import workflows if present (Phase 2: Workflow Deployment). - $deployedWorkflows = []; - if (($data['components']['workflows'] ?? null) !== null - && is_array($data['components']['workflows']) === true - ) { - $result = $this->processWorkflowDeployment( - workflows: $data['components']['workflows'], - result: $result, - deployedWorkflows: $deployedWorkflows, - importSource: $appId ?? 'manual' - ); - - // Phase 3: Hook Wiring — attach deployed workflows to schemas. - $result = $this->processWorkflowHookWiring( - workflows: $data['components']['workflows'], - deployedWorkflows: $deployedWorkflows, - result: $result - ); - }//end if - // Process and import mappings if present. if (($data['components']['mappings'] ?? null) !== null && is_array($data['components']['mappings']) === true @@ -2935,8 +2891,12 @@ public function importFromJson( } if (version_compare($importedVersion, $existingVersion, '>') <= 0) { - // Debug: per object, and skipping an unchanged object - // is the DESIGNED behaviour of a re-import, not news. + // COUNTED, not just logged. Skipping an unchanged + // object is the DESIGNED behaviour of a re-import, + // but a debug line is invisible to the caller, and + // the caller is the one that has to tell an operator + // whether the import worked. + $result['unchanged']['objects']++; $this->logger->debug( message: '[ImportHandler] Skipped object update: imported version not higher', context: [ @@ -3434,240 +3394,7 @@ private function findExistingSeedUuid(Register $register, Schema $schema, string return null; }//end findExistingSeedUuid() - /** - * Process workflow deployment during import (Phase 2). - * - * Deploys workflows to their engines with hash-based idempotency. - * - * @param array> $workflows Workflow entries from import JSON - * @param array $result Current import result array - * @param array $deployedWorkflows Map populated by reference - * @param string $importSource Import source identifier - * - * @param-out array $deployedWorkflows A workflow named - * with a canonical numeric string ("12") gets an INT key from PHP's array-key - * coercion, so the map that comes back out is not key-narrowable to string. - * - * @return array Updated result array - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) - */ - private function processWorkflowDeployment( - array $workflows, - array $result, - array &$deployedWorkflows, - string $importSource, - ): array { - if ($this->workflowRegistry === null || $this->deployedWfMapper === null) { - $this->logger->warning( - message: '[ImportHandler] Workflow import skipped — registry or mapper not configured', - context: ['file' => __FILE__, 'line' => __LINE__] - ); - return $result; - } - - $this->logger->debug( - message: '[ImportHandler] Starting workflow deployment phase', - context: ['file' => __FILE__, 'line' => __LINE__, 'count' => count($workflows)] - ); - - foreach ($workflows as $entry) { - $name = $entry['name'] ?? null; - $engine = $entry['engine'] ?? null; - - if ($name === null || $engine === null || isset($entry['workflow']) === false) { - $result['workflows']['failed'][] = [ - 'name' => $name ?? 'unknown', - 'error' => 'Missing required fields (name, engine, workflow)', - ]; - continue; - } - $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; - $hash = hash('sha256', json_encode($entry['workflow'], $jsonFlags)); - $existing = $this->deployedWfMapper->findByNameAndEngine(name: $name, engine: $engine); - - if ($existing !== null && $existing->getSourceHash() === $hash) { - $result['workflows']['unchanged'][] = $name; - $deployedWorkflows[$name] = $existing; - continue; - } - - $engines = $this->workflowRegistry->getEnginesByType(engineType: $engine); - if (count($engines) === 0) { - $result['workflows']['failed'][] = [ - 'name' => $name, - 'engine' => $engine, - 'error' => "No registered engine of type '{$engine}'", - ]; - continue; - } - - try { - $adapter = $this->workflowRegistry->resolveAdapter(engine: $engines[0]); - - if ($existing !== null) { - $engineId = $adapter->updateWorkflow( - workflowId: $existing->getEngineWorkflowId(), - workflowDefinition: $entry['workflow'] - ); - $existing->setEngineWorkflowId($engineId); - $existing->setSourceHash($hash); - $existing->setVersion($existing->getVersion() + 1); - $existing->setUpdated(new DateTime()); - $this->deployedWfMapper->update($existing); - - $result['workflows']['updated'][] = [ - 'name' => $name, - 'engine' => $engine, - 'version' => $existing->getVersion(), - 'action' => 'updated', - ]; - $deployedWorkflows[$name] = $existing; - } - - if ($existing === null) { - $engineId = $adapter->deployWorkflow(workflowDefinition: $entry['workflow']); - $deployed = $this->deployedWfMapper->createFromArray( - [ - 'name' => $name, - 'engine' => $engine, - 'engineWorkflowId' => $engineId, - 'sourceHash' => $hash, - 'importSource' => $importSource, - 'version' => 1, - ] - ); - - $result['workflows']['deployed'][] = [ - 'name' => $name, - 'engine' => $engine, - 'action' => 'created', - ]; - $deployedWorkflows[$name] = $deployed; - }//end if - } catch (Exception $e) { - $this->logger->error( - message: '[ImportHandler] Workflow deployment failed', - context: ['file' => __FILE__, 'line' => __LINE__, 'name' => $name, 'error' => $e->getMessage()] - ); - $result['workflows']['failed'][] = [ - 'name' => $name, - 'engine' => $engine, - 'error' => $e->getMessage(), - ]; - }//end try - }//end foreach - - return $result; - }//end processWorkflowDeployment() - - /** - * Process workflow hook wiring during import (Phase 3). - * - * Attaches deployed workflows to schema hooks based on attachTo configuration. - * - * @param array> $workflows Workflow entries from import JSON - * @param array $deployedWorkflows Map of deployed workflows - * @param array $result Current import result array - * - * @return array Updated result array - * - * @spec openspec/specs/workflow-in-import/spec.md#requirement-schema-hook-wiring-during-import - */ - private function processWorkflowHookWiring( - array $workflows, - array $deployedWorkflows, - array $result, - ): array { - if ($this->deployedWfMapper === null) { - return $result; - } - - foreach ($workflows as $entry) { - if (isset($entry['attachTo']) === false) { - continue; - } - - $name = $entry['name'] ?? null; - $attachTo = $entry['attachTo']; - $deployed = $deployedWorkflows[$name] ?? null; - - if ($deployed === null) { - continue; - } - - $schemaSlug = $attachTo['schema'] ?? null; - $event = $attachTo['event'] ?? null; - - if ($schemaSlug === null || $event === null) { - $this->logger->warning( - message: "[ImportHandler] Workflow '{$name}' has incomplete attachTo", - context: ['file' => __FILE__, 'line' => __LINE__] - ); - continue; - } - - $schema = $this->schemasMap[$schemaSlug] ?? null; - if ($schema === null) { - try { - $schema = $this->schemaMapper->findBySlug($schemaSlug); - } catch (Exception $e) { - $msg = "Cannot attach '{$name}' — schema '{$schemaSlug}' not found"; - $this->logger->warning( - message: '[ImportHandler] ' . $msg, - context: ['file' => __FILE__, 'line' => __LINE__] - ); - continue; - } - } - - $deployed->setAttachedSchema($schemaSlug); - $deployed->setAttachedEvent($event); - $deployed->setUpdated(new DateTime()); - $this->deployedWfMapper->update($deployed); - - // Build hook entry and add it to the schema's hooks JSON array. - $hookEntry = [ - 'event' => $event, - 'engine' => $deployed->getEngine(), - 'workflowId' => $deployed->getEngineWorkflowId(), - 'mode' => $attachTo['mode'] ?? 'sync', - 'order' => (int)($attachTo['order'] ?? 0), - 'timeout' => (int)($attachTo['timeout'] ?? 30), - 'enabled' => true, - 'onFailure' => $attachTo['onFailure'] ?? 'reject', - 'onTimeout' => $attachTo['onTimeout'] ?? 'reject', - 'onEngineDown' => $attachTo['onEngineDown'] ?? 'allow', - ]; - - $hooks = ($schema->getHooks() ?? []); - - // Avoid duplicate: remove existing hook with same workflowId + event. - $hooks = array_values( - array_filter( - $hooks, - static function (array $hook) use ($hookEntry): bool { - return !(($hook['workflowId'] ?? '') === $hookEntry['workflowId'] - && ($hook['event'] ?? '') === $hookEntry['event']); - } - ) - ); - - $hooks[] = $hookEntry; - $schema->setHooks($hooks); - $this->schemaMapper->update($schema); - - $msg = "Attached workflow '{$name}' to schema '{$schemaSlug}' on event '{$event}'"; - $this->logger->debug( - message: '[ImportHandler] ' . $msg, - context: ['file' => __FILE__, 'line' => __LINE__] - ); - }//end foreach - - return $result; - }//end processWorkflowHookWiring() /** * Import configuration from an app's JSON data. diff --git a/lib/Service/Credential/CredentialBrokerService.php b/lib/Service/Credential/CredentialBrokerService.php index 4cea168099..f1252c80ea 100644 --- a/lib/Service/Credential/CredentialBrokerService.php +++ b/lib/Service/Credential/CredentialBrokerService.php @@ -90,6 +90,13 @@ * @SuppressWarnings(PHPMD.ExcessiveClassLength) 44 lines over, from Guard 1c. The guard * chain is one security policy read top to bottom; moving a guard elsewhere is how a * caller ends up skipping it. + * @SuppressWarnings(PHPMD.TooManyMethods) 29 over a threshold of 25, from the + * three methods this change adds: one dispatch point, one catalogue reader, one host + * resolver. Splitting them off would put part of the guard chain in another class, + * which is the failure mode the ExcessiveClassLength note below already describes: + * a caller ends up skipping a guard because it did not know the other class existed. + * @SuppressWarnings(PHPMD.StaticAccess) Two calls to {@see OAuth2InstanceHost}, + * a stateless security predicate. Injecting it would make the host-lock substitutable. * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The fail-closed guard chain * (scope dispatch + owner/membership + allowedApps + provider rules + host-lock) * is deliberately decomposed into small single-purpose guard methods so each @@ -127,6 +134,20 @@ class CredentialBrokerService { */ private const SCOPE_ORGANISATION = 'organisation'; + /** + * The pre-existing credential kind: one opaque string in one header. + * + * @var string + */ + private const KIND_SECRET = 'secret'; + + /** + * The credential kind whose stored secret is a whole OAuth2 token set. + * + * @var string + */ + private const KIND_OAUTH2_TOKEN_SET = 'oauth2-token-set'; + /** * Constructor. * @@ -144,6 +165,9 @@ class CredentialBrokerService { * @param ObjectGrantResolver|null $objectGrants Per-object grant resolver for Guard 1d; nullable so * adding it is not a fatal at existing construction * sites. + * @param OAuth2RefreshService|null $oauth2Refresh Refreshes and rotates an `oauth2-token-set` credential's + * stored token set; nullable for the same reason, and an + * absent one denies rather than degrading. * * @return void */ @@ -165,6 +189,12 @@ public function __construct( private readonly ?IGroupManager $groupManager = null, private readonly ?IUserManager $userManager = null, private readonly ?ObjectGrantResolver $objectGrants = null, + // Nullable and trailing for the same reason as the three above: the eight + // existing construction sites must keep working, because a drifted + // constructor signature is a FATAL rather than a test failure. Absent, the + // oauth2-token-set injection path denies rather than falling back to + // treating the stored document as a bare secret. + private readonly ?OAuth2RefreshService $oauth2Refresh = null, ) { }//end __construct() @@ -239,13 +269,25 @@ public function request( } $this->assertRuleAllowed(provider: $provider, method: $method, matchPath: $matchPath, credentialId: $credentialId); - $resolvedUrl = $this->resolveAndLockUrl(provider: $provider, path: $path, credentialId: $credentialId); + $resolvedUrl = $this->resolveAndLockUrl( + provider: $provider, + path: $path, + credentialId: $credentialId, + data: $data + ); - // All guards passed — read the secret (from the scope's vault owner) and perform the call. - $secret = $this->credentialStore->get($credentialId, $scope); - if ($secret === null) { - $this->deny(reason: 'no secret stored for credential', credentialId: $credentialId); - } + // All guards passed — resolve the value to inject. For the pre-existing + // `secret` kind that is the stored secret verbatim; for `oauth2-token-set` + // it is the access token in force after any refresh the margin required. + // The dispatch is deliberately BELOW every guard, so a caller who is not + // admitted never causes a token endpoint to be contacted. + $secret = $this->injectionSecret( + data: $data, + provider: $provider, + credentialId: $credentialId, + scope: $scope, + actingUserId: $actingUserId + ); $requestHeaders = $this->injectAuth(provider: $provider, headers: $headers, secret: (string)$secret); @@ -306,8 +348,7 @@ public function request( * @throws CredentialAccessDeniedException When Guard 1 or 2 fails, or an inject-only credential has no stored secret. * * @spec openspec/specs/credential-broker/spec.md - * @spec openspec/specs/credential-broker/spec.md - * @spec openspec/specs/credential-broker/spec.md + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-broker/spec.md#requirement-a-token-set-is-never-resolved-app-side */ public function resolveInjectable( string $credentialId, @@ -330,6 +371,24 @@ public function resolveInjectable( // Only inject-only credentials may be resolved app-side; a proxy credential's secret // stays inside OR — signal that with null so the caller routes to request() instead. $provider = $this->resolveProvider(data: $data, credentialId: $credentialId); + + // An OAuth2 token set is NEVER offered here, and the check is deliberately ABOVE + // the inject-only one rather than resting on it. A token set's whole point is that + // the refresh token — a long-lived credential the owner cannot revoke piecemeal — + // stays in custody, and ADR-064 decision #8 says so; handing the stored document + // to an app would give it exactly that, plus a JSON blob where it expected a + // bearer token. Resting on `inject_only` being false would make the guarantee an + // accident of how a catalogue entry happens to be written, so a future entry that + // set both flags would silently start leaking. Both paths then refuse such an + // entry, which is the right answer for a catalogue that contradicts itself. + if ($this->kindOf(provider: $provider) === self::KIND_OAUTH2_TOKEN_SET) { + $this->logger->warning( + '[CredentialBroker] resolveInjectable refused for the oauth2-token-set kind: ' . $credentialId + ); + + return null; + } + if ($this->isInjectOnly(provider: $provider) === false) { return null; } @@ -380,14 +439,21 @@ public function resolveInjectable( * @param string|null $secret The raw secret, or null/'' to mint metadata only (no vault write). * @param string $scope The ALREADY-RESOLVED scope (`personal`|`organisation`); selects the vault owner. * @param string|null $organisation The ALREADY-GATED owning organisation UUID; required for the organisation scope. + * @param array $metadata Additional NON-SECRET properties for an OAuth2 connection (`kind`, `status`, + * `scopes`, `account`, `expiresAt`, `instanceBaseUrl`, `clientId`, + * `clientCredentialRef`). Every key not on that list is DROPPED, so a caller + * cannot smuggle an arbitrary property, and `instanceBaseUrl` is validated + * here because this is the one moment it may be set. * * @return ObjectEntity The persisted credential entity (its `getUuid()` is the credential id / credentialRef). * - * @throws \InvalidArgumentException When the name is empty, or an organisation-scoped mint carries no organisation. + * @throws \InvalidArgumentException When the name is empty, an organisation-scoped mint carries no organisation, + * or the supplied instance host is not a safe public https origin. * @throws Throwable When the object save or the vault write fails (the orphaned object is removed first). * * @spec openspec/specs/credential-broker/spec.md * @spec openspec/specs/credential-broker/spec.md + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-per-account-host-is-pinned-at-mint-and-immutable-afterwards */ public function mint( string $name, @@ -397,6 +463,7 @@ public function mint( ?string $secret = null, string $scope = self::SCOPE_PERSONAL, ?string $organisation = null, + array $metadata = [], ): ObjectEntity { $name = trim($name); if ($name === '' || trim($provider) === '') { @@ -412,13 +479,16 @@ public function mint( // "metadata only, no vault write". $secret = $this->trimmedSecret(secret: $secret); - $data = [ - 'name' => $name, - 'provider' => $provider, - 'owner' => $owner, - 'allowedApps' => array_values($allowedApps), - 'createdAt' => (new DateTimeImmutable())->format(DATE_ATOM), - ]; + $data = array_merge( + [ + 'name' => $name, + 'provider' => $provider, + 'owner' => $owner, + 'allowedApps' => array_values($allowedApps), + 'createdAt' => (new DateTimeImmutable())->format(DATE_ATOM), + ], + self::connectionMetadata(metadata: $metadata) + ); // Only an organisation-scoped credential carries scope/organisation — a personal // credential's property bag stays byte-for-byte what it has always been (design D1). @@ -467,6 +537,40 @@ public function mint( return $saved; }//end mint() + /** + * Reduce caller-supplied connection metadata to the properties a mint may set. + * + * An ALLOW-LIST rather than a filter of forbidden keys, because the failure mode + * of a deny-list is a property nobody thought of. `instanceBaseUrl` is validated + * here rather than trusted, because a mint is the one moment it may be written: + * afterwards it is the credential's host-lock and the controller refuses to + * change it. + * + * @param array $metadata The caller's proposed metadata. + * + * @return array The accepted subset. + * + * @throws InvalidArgumentException When `instanceBaseUrl` is not a safe public https origin. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-per-account-host-is-pinned-at-mint-and-immutable-afterwards + */ + public static function connectionMetadata(array $metadata): array { + $accepted = []; + $allowed = ['kind', 'status', 'scopes', 'account', 'expiresAt', 'lastRefreshedAt', 'lastError', 'clientId', 'clientCredentialRef']; + foreach ($allowed as $key) { + if (array_key_exists($key, $metadata) === true) { + $accepted[$key] = $metadata[$key]; + } + } + + $host = trim((string)($metadata['instanceBaseUrl'] ?? '')); + if ($host !== '') { + $accepted['instanceBaseUrl'] = OAuth2InstanceHost::normalise(candidate: $host); + } + + return $accepted; + }//end connectionMetadata() + /** * Remove a credential metadata object whose vault write failed (mint rollback). * @@ -1039,6 +1143,123 @@ private function resolveProvider(array $data, string $credentialId): array { return $provider; }//end resolveProvider() + /** + * Resolve the value the provider's auth template is substituted with. + * + * ONE dispatch point for the whole broker, chosen by the CATALOGUE's kind rather + * than by the credential object's mirrored one. The catalogue is read-only at + * runtime and reviewed on release; the object is writable by its owner. If the + * object chose the injection path, an owner could change what their own + * credential does with an ordinary object write, which is the sort of thing an + * allow-list is supposed to make impossible. + * + * @param array $data The credential object's serialised data. + * @param array $provider The catalogue provider entry. + * @param string $credentialId The credential UUID. + * @param string $scope The credential scope (selects the custody-leaf owner). + * @param string|null $actingUserId The asserted user for a sessionless in-process caller. + * + * @return string The value to substitute into the auth header template. + * + * @throws CredentialAccessDeniedException When nothing usable is stored, or the kind cannot be served. + * @throws CredentialRelinkRequiredException When the credential's grant is gone. + * @throws CredentialUpstreamException When a required refresh could not be completed. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-broker/spec.md#requirement-injection-is-selected-by-kind + */ + private function injectionSecret( + array $data, + array $provider, + string $credentialId, + string $scope, + ?string $actingUserId, + ): string { + if ($this->kindOf(provider: $provider) !== self::KIND_OAUTH2_TOKEN_SET) { + $secret = $this->credentialStore->get($credentialId, $scope); + if ($secret === null) { + $this->deny(reason: 'no secret stored for credential', credentialId: $credentialId); + } + + return (string)$secret; + } + + if (is_array(($provider['oauth2'] ?? null)) === false) { + $this->deny(reason: 'provider declares the oauth2-token-set kind but carries no oauth2 block', credentialId: $credentialId); + } + + if ($this->oauth2Refresh === null) { + // Fail CLOSED. Without the refresh service the stored document is a + // token SET, not a bearer token, so falling through to the `secret` + // branch would inject a JSON blob into an Authorization header and read + // the provider's refusal as a credential problem. + $this->deny(reason: 'oauth2 refresh service is unavailable', credentialId: $credentialId); + } + + return $this->oauth2Refresh->accessTokenFor( + credential: $data, + provider: $provider, + credentialId: $credentialId, + scope: $scope, + actingUserId: $actingUserId + ); + }//end injectionSecret() + + /** + * Read a provider entry's credential kind, defaulting to the pre-existing one. + * + * @param array $provider The catalogue provider entry. + * + * @return string Either `secret` or `oauth2-token-set`. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-credential-kind-is-declared-by-the-provider-catalogue-never-by-the-credential-object + */ + private function kindOf(array $provider): string { + $kind = trim((string)($provider['kind'] ?? '')); + + if ($kind === self::KIND_OAUTH2_TOKEN_SET) { + return self::KIND_OAUTH2_TOKEN_SET; + } + + return self::KIND_SECRET; + }//end kindOf() + + /** + * Resolve the base URL a call is host-locked to. + * + * Almost every entry declares a fixed `baseUrl`. The two whose API host belongs + * to the connected ACCOUNT rather than to the provider declare + * `baseUrlFrom: "instanceBaseUrl"` instead, and the host then comes from the + * credential itself. That MOVES the lock rather than removing it: the value was + * validated and pinned at mint and cannot be edited afterwards, so the credential + * is locked to one host for its whole life, and every allow-rule still applies. + * + * @param array $provider The catalogue provider entry. + * @param array $data The credential object's serialised data. + * @param string $credentialId The credential UUID (for logging). + * + * @return string The base URL to resolve the caller's path against. + * + * @throws CredentialAccessDeniedException When a per-credential host is required and is missing or unsafe. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-per-account-host-is-pinned-at-mint-and-immutable-afterwards + */ + private function baseUrlFor(array $provider, array $data, string $credentialId): string { + $from = trim((string)($provider['baseUrlFrom'] ?? '')); + if ($from === '') { + return (string)($provider['baseUrl'] ?? ''); + } + + if ($from !== 'instanceBaseUrl') { + $this->deny(reason: 'unsupported baseUrlFrom "' . $from . '"', credentialId: $credentialId); + } + + try { + return OAuth2InstanceHost::normalise(candidate: (string)($data['instanceBaseUrl'] ?? '')); + } catch (InvalidArgumentException $badHost) { + $this->deny(reason: 'per-credential host is unusable: ' . $badHost->getMessage(), credentialId: $credentialId); + } + }//end baseUrlFor() + /** * Guard 3 — assert method + normalised path match one of the provider's allow-rules. * @@ -1082,6 +1303,7 @@ private function assertRuleAllowed(array $provider, string $method, string $matc * @param array $provider The catalogue provider entry. * @param string $path The caller-supplied path (with any query). * @param string $credentialId The credential UUID (for logging). + * @param array $data The credential object's data, for an entry whose host is per credential. * * @return string The host-locked resolved URL. * @@ -1089,8 +1311,8 @@ private function assertRuleAllowed(array $provider, string $method, string $matc * * @spec openspec/specs/credential-broker/spec.md */ - private function resolveAndLockUrl(array $provider, string $path, string $credentialId): string { - $baseUrl = (string)($provider['baseUrl'] ?? ''); + private function resolveAndLockUrl(array $provider, string $path, string $credentialId, array $data = []): string { + $baseUrl = $this->baseUrlFor(provider: $provider, data: $data, credentialId: $credentialId); $resolvedUrl = $baseUrl . $path; $baseHost = parse_url($baseUrl, PHP_URL_HOST); diff --git a/lib/Service/Credential/CredentialLock.php b/lib/Service/Credential/CredentialLock.php new file mode 100644 index 0000000000..530419cc54 --- /dev/null +++ b/lib/Service/Credential/CredentialLock.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 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use OCP\ICacheFactory; +use OCP\IMemcache; +use Psr\Log\LoggerInterface; + +/** + * Per-credential advisory lock backed by the distributed cache. + */ +class CredentialLock { + /** + * Cache namespace for credential rotation locks. + * + * @var string + */ + private const CACHE_PREFIX = 'openregister_credential_lock'; + + /** + * How long a holder may keep the lock before it expires by itself, in seconds. + * + * Comfortably longer than a token exchange (a few seconds at worst) and short + * enough that a crashed process does not block the credential for a whole + * refresh cycle. + * + * @var integer + */ + public const DEFAULT_TTL_SECONDS = 30; + + /** + * How long a waiter watches for the holder to finish, in milliseconds. + * + * @var integer + */ + private const WAIT_BUDGET_MS = 5000; + + /** + * How long a waiter sleeps between polls, in microseconds. + * + * @var integer + */ + private const WAIT_POLL_US = 100000; + + /** + * Constructor. + * + * @param ICacheFactory $cacheFactory Supplies the distributed cache the lock lives in. + * @param LoggerInterface $logger Reports a degraded, non-excluding lock. + * + * @return void + */ + public function __construct( + private readonly ICacheFactory $cacheFactory, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether this lock can actually exclude a second process. + * + * @return boolean True when a distributed cache offering an atomic add is available. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-refresh-runs-under-a-per-credential-lock-and-rotates-atomically + */ + public function isEffective(): bool { + return ($this->cacheFactory->isAvailable() === true && $this->memcache() !== null); + }//end isEffective() + + /** + * Try to take the lock for one credential. + * + * @param string $credentialId The credential UUID the lock is keyed by. + * @param integer $ttlSeconds How long the lock survives without an explicit release. + * + * @return boolean True when this caller now holds the lock. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-refresh-runs-under-a-per-credential-lock-and-rotates-atomically + */ + public function acquire(string $credentialId, int $ttlSeconds = self::DEFAULT_TTL_SECONDS): bool { + $cache = $this->memcache(); + if ($cache === null) { + $this->logger->warning( + '[CredentialLock] no distributed cache with an atomic add is available, so the refresh lock is' + . ' ADVISORY ONLY and a concurrent refresh of the same credential is possible. Configure' + . ' memcache.distributed to make this lock effective.' + ); + + return true; + } + + return $cache->add($credentialId, 1, $ttlSeconds); + }//end acquire() + + /** + * Release the lock (idempotent). + * + * @param string $credentialId The credential UUID the lock is keyed by. + * + * @return void + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-refresh-runs-under-a-per-credential-lock-and-rotates-atomically + */ + public function release(string $credentialId): void { + $this->memcache()?->remove($credentialId); + }//end release() + + /** + * Wait, briefly, for whoever holds the lock to finish. + * + * The caller re-reads the stored token set afterwards rather than refreshing: + * after a successful rotation by the holder, that re-read finds a token outside + * the margin and no second exchange happens. Returning after the budget expires + * is deliberate — the caller then decides, with the token set in front of it, + * whether there is still work to do. + * + * @param string $credentialId The credential UUID the lock is keyed by. + * + * @return void + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-refresh-runs-under-a-per-credential-lock-and-rotates-atomically + */ + public function waitForRelease(string $credentialId): void { + $cache = $this->memcache(); + if ($cache === null) { + return; + } + + $deadline = (microtime(true) + (self::WAIT_BUDGET_MS / 1000)); + while (microtime(true) < $deadline) { + if ($cache->hasKey($credentialId) === false) { + return; + } + + usleep(self::WAIT_POLL_US); + } + }//end waitForRelease() + + /** + * Resolve the distributed cache when it offers an atomic add, else null. + * + * @return IMemcache|null The cache, or null when no atomic primitive is available. + * + * @spec exclude private cache accessor; its behaviour is asserted through acquire() and isEffective() + */ + private function memcache(): ?IMemcache { + if ($this->cacheFactory->isAvailable() === false) { + return null; + } + + $cache = $this->cacheFactory->createDistributed(self::CACHE_PREFIX); + if ($cache instanceof IMemcache) { + return $cache; + } + + return null; + }//end memcache() +}//end class diff --git a/lib/Service/Credential/CredentialRelinkNotifier.php b/lib/Service/Credential/CredentialRelinkNotifier.php new file mode 100644 index 0000000000..e8f4655853 --- /dev/null +++ b/lib/Service/Credential/CredentialRelinkNotifier.php @@ -0,0 +1,109 @@ + + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use DateTime; +use OCA\OpenRegister\Event\CredentialRelinkRequiredEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Notification\IManager as INotificationManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Announces that a brokered OAuth2 connection must be re-authorised. + */ +class CredentialRelinkNotifier { + /** + * Constructor. + * + * @param IEventDispatcher $eventDispatcher Announces the lost grant to the rest of the instance. + * @param INotificationManager $notifications Tells the owner their connection needs repairing. + * @param LoggerInterface $logger Records an announcement that could not be made. + * + * @return void + */ + public function __construct( + private readonly IEventDispatcher $eventDispatcher, + private readonly INotificationManager $notifications, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Announce a lost grant, once. + * + * @param string $credentialId The credential UUID, which is the credentialRef apps hold. + * @param string $provider The catalogue provider identifier. + * @param string $owner The owning user id, or an empty string when there is none to tell. + * @param string $reason The provider's OAuth2 error code (secret-free by construction). + * + * @return void + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-invalid-grant-moves-the-credential-to-relink-needed-and-fails-closed + */ + public function announce(string $credentialId, string $provider, string $owner, string $reason): void { + try { + $this->eventDispatcher->dispatchTyped( + new CredentialRelinkRequiredEvent( + credentialId: $credentialId, + provider: $provider, + owner: $owner, + reason: $reason + ) + ); + } catch (Throwable $dispatchFailure) { + $this->logger->warning('[CredentialRelinkNotifier] event dispatch failed: ' . $dispatchFailure->getMessage()); + } + + if ($owner === '') { + return; + } + + try { + $notification = $this->notifications->createNotification(); + $notification->setApp('openregister') + ->setUser($owner) + ->setDateTime(new DateTime()) + ->setObject('brokered_credential', $credentialId) + ->setSubject('credential_relink_needed', ['provider' => $provider]); + $this->notifications->notify($notification); + } catch (Throwable $notifyFailure) { + $this->logger->warning('[CredentialRelinkNotifier] notification failed: ' . $notifyFailure->getMessage()); + } + }//end announce() +}//end class diff --git a/lib/Service/Credential/CredentialRelinkRequiredException.php b/lib/Service/Credential/CredentialRelinkRequiredException.php new file mode 100644 index 0000000000..479fcd8397 --- /dev/null +++ b/lib/Service/Credential/CredentialRelinkRequiredException.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 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +/** + * Signals that a connection must be re-authorised before it can be used again. + */ +class CredentialRelinkRequiredException extends CredentialAccessDeniedException { +}//end class diff --git a/lib/Service/Credential/CredentialUpdateRequest.php b/lib/Service/Credential/CredentialUpdateRequest.php new file mode 100644 index 0000000000..00baa23c78 --- /dev/null +++ b/lib/Service/Credential/CredentialUpdateRequest.php @@ -0,0 +1,151 @@ + + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use OCP\IRequest; + +/** + * Reads the three decisions an update request carries. + */ +class CredentialUpdateRequest { + /** + * Constructor. + * + * @param IRequest $request The current request. + * + * @return void + */ + public function __construct(private readonly IRequest $request) { + }//end __construct() + + /** + * Apply the metadata the request offers, leaving the rest of the object alone. + * + * @param array $data The stored credential's property bag. + * + * @return array The property bag with the request's edits applied. + * + * @spec openspec/specs/credential-broker/spec.md + */ + public function applyTo(array $data): array { + $name = $this->request->getParam('name'); + if (is_string($name) === true && trim($name) !== '') { + $data['name'] = trim($name); + } + + if ($this->request->getParam('allowedApps') !== null) { + $data['allowedApps'] = $this->allowedApps(); + } + + return $data; + }//end applyTo() + + /** + * Whether this update would move a credential's pinned host. + * + * @param array $data The stored credential's property bag. + * + * @return boolean True when the request proposes a different host. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-per-account-host-is-pinned-at-mint-and-immutable-afterwards + */ + public function wouldRepointHost(array $data): bool { + $proposed = $this->request->getParam('instanceBaseUrl'); + if (is_string($proposed) === false || trim($proposed) === '') { + return false; + } + + return trim($proposed) !== (string)($data['instanceBaseUrl'] ?? ''); + }//end wouldRepointHost() + + /** + * The rotated secret this request carries, or null when it carries none. + * + * The trim is not cosmetic. This rotation path writes straight to the vault and + * does NOT go through {@see CredentialBrokerService::mint()}, so it needs its own + * (credential-broker-upstream-diagnostics D3): a copy-pasted secret with a + * trailing newline previously reached the vault byte for byte and then failed + * header injection at call time with no usable diagnostic. + * + * @return string|null The trimmed secret, or null. + * + * @spec openspec/specs/credential-broker/spec.md + */ + public function rotatedSecret(): ?string { + $secret = $this->request->getParam('secret'); + if (is_string($secret) === false) { + return null; + } + + $secret = trim($secret); + if ($secret === '') { + return null; + } + + return $secret; + }//end rotatedSecret() + + /** + * Coerce the request's allowedApps value into a string array. + * + * @return array The sanitised app-id list. + * + * @spec exclude private request accessor with no behaviour of its own + */ + private function allowedApps(): array { + $value = $this->request->getParam('allowedApps', []); + if (is_array($value) === false) { + return []; + } + + $apps = []; + foreach ($value as $entry) { + if (is_string($entry) === true && $entry !== '') { + $apps[] = $entry; + } + } + + return $apps; + }//end allowedApps() +}//end class diff --git a/lib/Service/Credential/OAuth2AccountIdentity.php b/lib/Service/Credential/OAuth2AccountIdentity.php new file mode 100644 index 0000000000..291ab8d33b --- /dev/null +++ b/lib/Service/Credential/OAuth2AccountIdentity.php @@ -0,0 +1,157 @@ + + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Reads and records the account a connection speaks for. + */ +class OAuth2AccountIdentity { + /** + * The app id this in-process identity call is made as. + * + * @var string + */ + private const BROKER_APP_ID = 'openregister'; + + /** + * Constructor. + * + * @param CredentialBrokerService $broker Makes the one bounded call. + * @param OAuth2RefreshService $refresh Persists the set carrying the account. + * @param LoggerInterface $logger Secret-free diagnostics. + * + * @return void + */ + public function __construct( + private readonly CredentialBrokerService $broker, + private readonly OAuth2RefreshService $refresh, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Ask who the connection belongs to, and record the answer on the credential. + * + * @param array $provider The catalogue provider entry. + * @param string $credentialId The credential just minted or repaired. + * @param string $scope The credential scope. + * @param OAuth2TokenSet $set The token set just stored. + * @param string $owner The credential's owner, asserted for this sessionless call. + * + * @return void + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-a-connection-records-the-account-it-speaks-for + */ + public function record( + array $provider, + string $credentialId, + string $scope, + OAuth2TokenSet $set, + string $owner, + ): void { + $identity = ($provider['identity'] ?? null); + if (is_array($identity) === false || trim((string)($identity['path'] ?? '')) === '') { + return; + } + + try { + $response = $this->broker->request( + credentialId: $credentialId, + appId: self::BROKER_APP_ID, + method: (string)($identity['method'] ?? 'GET'), + path: (string)$identity['path'], + actingUserId: $owner + ); + + $decoded = json_decode($response['body'], true); + if (is_array($decoded) === false) { + return; + } + + $this->refresh->persist( + credentialId: $credentialId, + scope: $scope, + set: $set->withAccount( + id: $this->fieldAt(payload: $decoded, path: (string)($identity['idField'] ?? '')), + handle: $this->fieldAt(payload: $decoded, path: (string)($identity['handleField'] ?? '')), + displayName: $this->fieldAt(payload: $decoded, path: (string)($identity['displayNameField'] ?? '')) + ) + ); + } catch (Throwable $failure) { + $this->logger->warning( + '[OAuth2AccountIdentity] could not read the connected account for ' . $credentialId . ': ' . $failure->getMessage() + ); + }//end try + }//end record() + + /** + * Read one scalar out of a decoded response by dotted path. + * + * @param array $payload The decoded response. + * @param string $path The dotted field path, or an empty string. + * + * @return string The value, or an empty string when it is absent or not a scalar. + * + * @spec exclude private response accessor with no behaviour of its own + */ + private function fieldAt(array $payload, string $path): string { + if ($path === '') { + return ''; + } + + $cursor = $payload; + foreach (explode('.', $path) as $segment) { + if (is_array($cursor) === false || array_key_exists($segment, $cursor) === false) { + return ''; + } + + $cursor = $cursor[$segment]; + } + + if (is_scalar($cursor) === false) { + return ''; + } + + return (string)$cursor; + }//end fieldAt() +}//end class diff --git a/lib/Service/Credential/OAuth2ClientResolver.php b/lib/Service/Credential/OAuth2ClientResolver.php new file mode 100644 index 0000000000..2832cf0fd6 --- /dev/null +++ b/lib/Service/Credential/OAuth2ClientResolver.php @@ -0,0 +1,156 @@ +` (the credentialRef) and `oauth2_client_id_` + * (the non-secret client id). + * + * THE BROKER IS FETCHED LAZILY FROM THE CONTAINER, and that is not laziness for its + * own sake. `CredentialBrokerService` needs the refresh service, the refresh service + * needs this resolver, and this resolver needs the broker: three constructor + * injections would be a dependency cycle the container cannot build. Resolving the + * broker at call time breaks the cycle at the one edge where the dependency is + * genuinely dynamic — it is needed only when a client secret is actually required, + * which is not on the read path of an unexpired token. + * + * @category Service + * @package OCA\OpenRegister\Service\Credential + * + * @author Conduction Development Team + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Throwable; + +/** + * Resolves the client id and client secret for one OAuth2 connection. + */ +class OAuth2ClientResolver { + /** + * The app id the broker guards see for this in-process resolution. + * + * @var string + */ + private const BROKER_APP_ID = 'openregister'; + + /** + * App-config key prefix holding the instance default client credentialRef per provider. + * + * @var string + */ + private const CONFIG_CLIENT_REF_PREFIX = 'oauth2_client_'; + + /** + * App-config key prefix holding the instance default (non-secret) client id per provider. + * + * @var string + */ + private const CONFIG_CLIENT_ID_PREFIX = 'oauth2_client_id_'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig Holds the per-provider instance default client. + * @param ContainerInterface $container Resolves the broker lazily, breaking the construction cycle. + * + * @return void + */ + public function __construct( + private readonly IAppConfig $appConfig, + private readonly ContainerInterface $container, + ) { + }//end __construct() + + /** + * Resolve the client id and secret for a credential's token exchanges. + * + * @param array $credential The credential object's serialised data. + * @param string $provider The catalogue provider identifier. + * @param string|null $actingUserId The asserted user for a sessionless caller (a background sweep). + * + * @return array{clientId: string, clientSecret: string|null} The client id, and its secret when one exists. + * + * @throws CredentialAccessDeniedException When no client is configured for the provider at all. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-starting-a-connection-returns-an-authorization-url-bound-to-the-caller + */ + public function resolve(array $credential, string $provider, ?string $actingUserId = null): array { + $clientRef = trim((string)($credential['clientCredentialRef'] ?? '')); + $clientId = trim((string)($credential['clientId'] ?? '')); + + if ($clientRef === '') { + $clientRef = $this->appConfig->getValueString(self::BROKER_APP_ID, self::CONFIG_CLIENT_REF_PREFIX . $provider, ''); + if ($clientId === '') { + $clientId = $this->appConfig->getValueString(self::BROKER_APP_ID, self::CONFIG_CLIENT_ID_PREFIX . $provider, ''); + } + } + + if ($clientId === '') { + throw new CredentialAccessDeniedException(message: 'no OAuth2 client id is configured for provider ' . $provider); + } + + $clientSecret = null; + if ($clientRef !== '') { + $clientSecret = $this->secretFor(clientRef: $clientRef, actingUserId: $actingUserId); + } + + return ['clientId' => $clientId, 'clientSecret' => $clientSecret]; + }//end resolve() + + /** + * Read one client secret out of the broker, through its own guard chain. + * + * @param string $clientRef The `generic-oauth2` credential UUID holding the client secret. + * @param string|null $actingUserId The asserted user for a sessionless caller. + * + * @return string|null The client secret, or null when the referenced credential holds none. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-starting-a-connection-returns-an-authorization-url-bound-to-the-caller + */ + private function secretFor(string $clientRef, ?string $actingUserId): ?string { + try { + $broker = $this->container->get(CredentialBrokerService::class); + } catch (Throwable $unresolvable) { + // Fail CLOSED. A client secret that cannot be resolved is not "no + // secret needed"; treating it as one would send an unauthenticated + // token request and read the provider's refusal as the tenant's fault. + throw new CredentialAccessDeniedException( + message: 'credential broker is unavailable to resolve the OAuth2 client secret', + previous: $unresolvable + ); + } + + return $broker->resolveInjectable( + credentialId: $clientRef, + appId: self::BROKER_APP_ID, + actingUserId: $actingUserId + ); + }//end secretFor() +}//end class diff --git a/lib/Service/Credential/OAuth2ConnectService.php b/lib/Service/Credential/OAuth2ConnectService.php new file mode 100644 index 0000000000..e463a091d7 --- /dev/null +++ b/lib/Service/Credential/OAuth2ConnectService.php @@ -0,0 +1,620 @@ + + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use InvalidArgumentException; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\ObjectService; +use OCP\Http\Client\IClientService; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +/** + * Builds authorization URLs and turns authorization codes into credentials. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The connect flow is by nature the + * seam between the catalogue, the broker, the refresh service, an HTTP client and the + * flow's own state; the count is the number of things a connect genuinely touches, + * and four of them are the exception types that distinguish a refused grant from an + * unreachable provider. + * @SuppressWarnings(PHPMD.StaticAccess) Two calls, to a named constructor + * ({@see OAuth2TokenSet}) and to a stateless security predicate + * ({@see OAuth2InstanceHost}); neither has an instance, and injecting the host guard + * would make the host-lock substitutable. + */ +class OAuth2ConnectService { + /** + * The credential kind this service mints. + * + * @var string + */ + public const KIND = 'oauth2-token-set'; + + /** + * Constructor. + * + * @param ProviderCatalogue $catalogue Declares every provider's endpoints and grants. + * @param CredentialBrokerService $broker Mints the credential and its metadata object. + * @param OAuth2ClientResolver $clients Resolves the client id and secret for the exchange. + * @param OAuth2RefreshService $refresh Persists a token set and mirrors its non-secret metadata. + * @param OAuth2InstanceClient $instanceClients Registers or reuses a per-instance OAuth2 application. + * @param OAuth2AccountIdentity $identities Reads and records whose account was connected. + * @param ObjectService $objectService Loads the credential being re-authorised. + * @param IClientService $clientService NC HTTP client factory. + * @param LoggerInterface $logger Secret-free diagnostics. + * + * @return void + */ + public function __construct( + private readonly ProviderCatalogue $catalogue, + private readonly CredentialBrokerService $broker, + private readonly OAuth2ClientResolver $clients, + private readonly OAuth2RefreshService $refresh, + private readonly OAuth2InstanceClient $instanceClients, + private readonly OAuth2AccountIdentity $identities, + private readonly ObjectService $objectService, + private readonly IClientService $clientService, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Make sure a per-instance provider has a client before the person is sent onward. + * + * Delegated to {@see OAuth2InstanceClient}, which owns the register-at-most-once + * rule; it stays on this service's surface so the controller talks to one connect + * seam rather than to every collaborator behind it. + * + * @param array $provider The catalogue provider entry. + * @param array $claims The claims assembled so far. + * @param string $redirectUri The callback to register. + * + * @return array The claims, carrying a client id and its credentialRef. + * + * @throws RuntimeException When the account's server refuses the registration. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-bluesky-is-its-own-client-and-mastodon-registers-per-instance + */ + public function ensureInstanceClient(array $provider, array $claims, string $redirectUri): array { + return $this->instanceClients->ensure(provider: $provider, claims: $claims, redirectUri: $redirectUri); + }//end ensureInstanceClient() + + /** + * Resolve a provider entry, refusing anything that is not an OAuth2 token set. + * + * @param string $providerId The catalogue provider identifier. + * + * @return array The provider entry. + * + * @throws InvalidArgumentException When the provider is unknown or is not an OAuth2 token set. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-starting-a-connection-returns-an-authorization-url-bound-to-the-caller + */ + public function oauth2Provider(string $providerId): array { + $provider = $this->catalogue->get($providerId); + if ($provider === null || (string)($provider['kind'] ?? '') !== self::KIND) { + throw new InvalidArgumentException(message: 'provider "' . $providerId . '" is not an OAuth2 connection'); + } + + if (is_array(($provider['oauth2'] ?? null)) === false) { + throw new InvalidArgumentException(message: 'provider "' . $providerId . '" declares no oauth2 block'); + } + + return $provider; + }//end oauth2Provider() + + /** + * Build the authorization URL a person is sent to. + * + * The OAuth2 client is resolved HERE rather than by the caller, so a controller + * never handles a client secret it has no use for. + * + * @param array $provider The catalogue provider entry. + * @param array $claims The flow's claims (provider, scopes, host, client). + * @param string $redirectUri The callback the provider must redirect to. + * @param string $state The signed state. + * @param string $challenge The PKCE code challenge. + * + * @return string The absolute authorization URL. + * + * @throws InvalidArgumentException When the entry names no usable authorization endpoint. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-starting-a-connection-returns-an-authorization-url-bound-to-the-caller + */ + public function authorizationUrl( + array $provider, + array $claims, + string $redirectUri, + string $state, + string $challenge, + ): string { + $oauth2 = $provider['oauth2']; + $instanceBaseUrl = $this->pinnedHost(claims: $claims); + $scopes = $this->scopesOf(claims: $claims, provider: $provider); + $client = $this->clients->resolve( + credential: [ + 'clientId' => (string)($claims['cl'] ?? ''), + 'clientCredentialRef' => (string)($claims['cr'] ?? ''), + ], + provider: (string)($claims['p'] ?? ''), + actingUserId: (string)($claims['u'] ?? '') + ); + $clientId = $client['clientId']; + $separator = (string)($oauth2['scopeSeparator'] ?? ' '); + + $query = [ + 'response_type' => 'code', + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'state' => $state, + 'scope' => implode($separator, $scopes), + ]; + + if ((string)($oauth2['pkce'] ?? 'none') === 'S256') { + $query['code_challenge'] = $challenge; + $query['code_challenge_method'] = 'S256'; + } + + $extra = ($oauth2['authorizationParameters'] ?? []); + if (is_array($extra) === true) { + $query = array_merge($query, $extra); + } + + return $this->endpoint( + oauth2: $oauth2, + key: 'authorizationEndpoint', + instanceBaseUrl: $instanceBaseUrl + ) . '?' . http_build_query($query); + }//end authorizationUrl() + + /** + * Exchange an authorization code and mint or overwrite the credential. + * + * @param array $claims The verified state claims. + * @param string $code The authorization code the provider returned. + * @param string $verifier The PKCE code verifier held since the start. + * @param string $redirectUri The callback the code was issued against. + * + * @return string The credential UUID, which is the credentialRef apps hold. + * + * @throws InvalidArgumentException When the claims name a provider that cannot be connected. + * @throws RuntimeException When the exchange fails or a re-authorisation would re-point a credential. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-callback-exchanges-the-code-and-mints-a-token-set-credential + */ + public function complete(array $claims, string $code, string $verifier, string $redirectUri): string { + $providerId = (string)($claims['p'] ?? ''); + $provider = $this->oauth2Provider(providerId: $providerId); + $instanceBaseUrl = $this->pinnedHost(claims: $claims); + + $existing = $this->existingCredential(claims: $claims, providerId: $providerId, instanceBaseUrl: $instanceBaseUrl); + + // The claims win only where they SAY something. A re-authorisation whose + // claims carry no client is reusing the credential's own, and letting an + // empty claim overwrite it would strand a per-instance connection: the + // stored client id would be gone, and its secret would still be sitting on + // the account's own server with nothing left pointing at it. + $credentialData = array_merge( + ($existing ?? []), + array_filter( + [ + 'provider' => $providerId, + 'clientId' => trim((string)($claims['cl'] ?? '')), + 'clientCredentialRef' => trim((string)($claims['cr'] ?? '')), + ], + static fn (string $value): bool => $value !== '' + ) + ); + + $client = $this->clients->resolve( + credential: $credentialData, + provider: $providerId, + actingUserId: (string)($claims['u'] ?? '') + ); + + $response = $this->exchangeCode( + provider: $provider, + code: $code, + verifier: $verifier, + redirectUri: $redirectUri, + client: $client, + instanceBaseUrl: $instanceBaseUrl + ); + + $tokenSet = OAuth2TokenSet::fromTokenResponse( + response: $response, + requestedScopes: $this->scopesOf(claims: $claims, provider: $provider) + ); + + $scope = (string)($claims['s'] ?? 'personal'); + + if ($existing !== null) { + $credentialId = (string)$claims['cid']; + $this->refresh->persist(credentialId: $credentialId, scope: $scope, set: $tokenSet, extraMetadata: ['status' => 'active']); + $this->identities->record( + provider: $provider, + credentialId: $credentialId, + scope: $scope, + set: $tokenSet, + owner: (string)($existing['owner'] ?? $claims['u'] ?? '') + ); + + return $credentialId; + } + + $credentialId = $this->mintNew( + claims: $claims, + providerId: $providerId, + scope: $scope, + tokenSet: $tokenSet, + instanceBaseUrl: $instanceBaseUrl + ); + + $this->identities->record( + provider: $provider, + credentialId: $credentialId, + scope: $scope, + set: $tokenSet, + owner: (string)($claims['u'] ?? '') + ); + + return $credentialId; + }//end complete() + + /** + * Revoke a connection upstream where the provider has a revoke endpoint. + * + * Best effort by contract: the local disable must happen whether or not the + * provider is reachable, so a network failure here is reported, not fatal. + * + * @param array $provider The catalogue provider entry. + * @param array $credential The credential object's serialised data. + * @param string $credentialId The credential UUID. + * @param string $scope The credential scope. + * + * @return string An empty string on success, or a secret-free reason the revoke failed. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-disconnecting-revokes-upstream-where-it-can-and-disables-locally + */ + public function revokeUpstream(array $provider, array $credential, string $credentialId, string $scope): string { + $oauth2 = ($provider['oauth2'] ?? []); + if (is_array($oauth2) === false || ($oauth2['revokeEndpoint'] ?? null) === null) { + return ''; + } + + try { + $set = $this->refresh->storedSet(credentialId: $credentialId, scope: $scope); + $client = $this->clients->resolve( + credential: $credential, + provider: (string)($credential['provider'] ?? ''), + actingUserId: (string)($credential['owner'] ?? '') + ); + + $body = ['token' => $set->getAccessToken(), 'client_id' => $client['clientId']]; + if ($client['clientSecret'] !== null) { + $body['client_secret'] = $client['clientSecret']; + } + + $this->clientService->newClient()->post( + $this->endpoint( + oauth2: $oauth2, + key: 'revokeEndpoint', + instanceBaseUrl: (string)($credential['instanceBaseUrl'] ?? '') + ), + ['body' => $body, 'timeout' => 20] + ); + } catch (Throwable $failure) { + $this->logger->warning('[OAuth2ConnectService] upstream revoke failed for ' . $credentialId . ': ' . $failure->getMessage()); + + return 'revoke_failed'; + }//end try + + return ''; + }//end revokeUpstream() + + /** + * Mint a brand-new connection. + * + * @param array $claims The verified state claims. + * @param string $providerId The catalogue provider identifier. + * @param string $scope The credential scope. + * @param OAuth2TokenSet $tokenSet The token set to store. + * @param string|null $instanceBaseUrl The pinned host, when the provider has one. + * + * @return string The new credential UUID. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-callback-exchanges-the-code-and-mints-a-token-set-credential + */ + private function mintNew( + array $claims, + string $providerId, + string $scope, + OAuth2TokenSet $tokenSet, + ?string $instanceBaseUrl, + ): string { + $metadata = [ + 'kind' => self::KIND, + 'status' => 'active', + 'scopes' => $tokenSet->getScopes(), + 'account' => $tokenSet->getAccount(), + 'expiresAt' => $tokenSet->getExpiresAt()?->format(DATE_ATOM), + 'clientId' => (string)($claims['cl'] ?? ''), + 'clientCredentialRef' => (string)($claims['cr'] ?? ''), + ]; + if ($instanceBaseUrl !== null) { + $metadata['instanceBaseUrl'] = $instanceBaseUrl; + } + + $minted = $this->broker->mint( + name: (string)($claims['nm'] ?? $providerId), + provider: $providerId, + owner: (string)($claims['u'] ?? ''), + allowedApps: $this->allowedAppsOf(claims: $claims), + secret: $tokenSet->toStoredJson(), + scope: $scope, + organisation: ($claims['o'] ?? null), + metadata: $metadata + ); + + return (string)$minted->getUuid(); + }//end mintNew() + + /** + * Load the credential a re-authorisation targets, refusing one it would re-point. + * + * @param array $claims The verified state claims. + * @param string $providerId The provider named in the claims. + * @param string|null $instanceBaseUrl The host named in the claims. + * + * @return array|null The existing credential's data, or null for a new connection. + * + * @throws RuntimeException When the credential is gone, or the re-authorisation would re-point it. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-token-set/spec.md#requirement-a-re-authorised-credential-returns-to-active-in-place + */ + private function existingCredential(array $claims, string $providerId, ?string $instanceBaseUrl): ?array { + $credentialId = trim((string)($claims['cid'] ?? '')); + if ($credentialId === '') { + return null; + } + + $entity = $this->objectService->find( + id: $credentialId, + register: CredentialBrokerService::REGISTER, + schema: CredentialBrokerService::SCHEMA, + _rbac: false, + _multitenancy: false, + _render: false + ); + + if ($entity instanceof ObjectEntity === false) { + throw new RuntimeException(message: 'the credential being re-authorised no longer exists'); + } + + $data = $entity->jsonSerialize(); + unset($data['@self']); + + if ((string)($data['provider'] ?? '') !== $providerId) { + throw new RuntimeException(message: 'a re-authorisation may not change a credential\'s provider'); + } + + $pinned = trim((string)($data['instanceBaseUrl'] ?? '')); + if ($pinned !== '' && $pinned !== $instanceBaseUrl) { + throw new RuntimeException(message: 'a re-authorisation may not change a credential\'s pinned host'); + } + + return $data; + }//end existingCredential() + + /** + * Perform the authorization-code exchange. + * + * @param array $provider The catalogue provider entry. + * @param string $code The authorization code. + * @param string $verifier The PKCE code verifier. + * @param string $redirectUri The callback the code was issued against. + * @param array{clientId: string, clientSecret: string|null} $client The resolved client. + * @param string|null $instanceBaseUrl The pinned host, when the provider has one. + * + * @return array The decoded token response. + * + * @throws RuntimeException When the exchange fails or the provider returns an error. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-callback-exchanges-the-code-and-mints-a-token-set-credential + */ + private function exchangeCode( + array $provider, + string $code, + string $verifier, + string $redirectUri, + array $client, + ?string $instanceBaseUrl, + ): array { + $oauth2 = $provider['oauth2']; + $form = [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $redirectUri, + 'client_id' => $client['clientId'], + ]; + + if ((string)($oauth2['pkce'] ?? 'none') === 'S256') { + $form['code_verifier'] = $verifier; + } + + $options = ['body' => $form, 'headers' => ['Accept' => 'application/json'], 'timeout' => 20]; + if ($client['clientSecret'] !== null && (string)($oauth2['clientAuth'] ?? '') === 'client_secret_basic') { + $options['auth'] = [$client['clientId'], $client['clientSecret']]; + } + + if ($client['clientSecret'] !== null && (string)($oauth2['clientAuth'] ?? '') !== 'client_secret_basic') { + $form['client_secret'] = $client['clientSecret']; + $options['body'] = $form; + } + + try { + $response = $this->clientService->newClient()->post( + $this->endpoint(oauth2: $oauth2, key: 'tokenEndpoint', instanceBaseUrl: $instanceBaseUrl), + $options + ); + $decoded = json_decode((string)$response->getBody(), true); + } catch (Throwable $failure) { + throw new RuntimeException(message: 'token exchange failed: ' . $failure::class, previous: $failure); + } + + if (is_array($decoded) === false) { + throw new RuntimeException(message: 'token endpoint returned a body that is not JSON'); + } + + $error = (string)($decoded['error'] ?? ''); + if ($error !== '') { + throw new RuntimeException(message: 'token endpoint returned error ' . $error); + } + + return $decoded; + }//end exchangeCode() + + /** + * Resolve one of the provider's endpoints to an absolute URL. + * + * @param array $oauth2 The provider entry's `oauth2` block. + * @param string $key The endpoint key. + * @param string|null $instanceBaseUrl The pinned host, for a per-instance provider. + * + * @return string The absolute endpoint URL. + * + * @throws InvalidArgumentException When the endpoint is absent or its host cannot be resolved. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-broker/spec.md#requirement-the-catalogue-may-describe-an-oauth2-provider + */ + private function endpoint(array $oauth2, string $key, ?string $instanceBaseUrl): string { + $endpoint = trim((string)($oauth2[$key] ?? '')); + if ($endpoint === '') { + throw new InvalidArgumentException(message: 'the catalogue names no ' . $key . ' for this provider'); + } + + if (($oauth2['endpointsRelativeToInstance'] ?? false) !== true) { + return $endpoint; + } + + return OAuth2InstanceHost::normalise(candidate: (string)$instanceBaseUrl) . '/' . ltrim($endpoint, '/'); + }//end endpoint() + + /** + * The already-validated pinned host a flow carries, or null. + * + * @param array $claims The verified state claims. + * + * @return string|null The normalised origin, or null. + * + * @spec exclude private claims accessor; the host rules live in OAuth2InstanceHost + */ + private function pinnedHost(array $claims): ?string { + $host = trim((string)($claims['h'] ?? '')); + if ($host === '') { + return null; + } + + return OAuth2InstanceHost::normalise(candidate: $host); + }//end pinnedHost() + + /** + * The scopes a flow asked for, falling back to the provider's declared defaults. + * + * @param array $claims The verified state claims. + * @param array $provider The catalogue provider entry. + * + * @return array The scopes. + * + * @spec exclude private claims accessor with no behaviour of its own + */ + private function scopesOf(array $claims, array $provider): array { + $scopes = ($claims['sc'] ?? null); + if (is_array($scopes) === true && $scopes !== []) { + return array_values(array_map('strval', $scopes)); + } + + $defaults = ($provider['oauth2']['defaultScopes'] ?? []); + if (is_array($defaults) === false) { + return []; + } + + return array_values(array_map('strval', $defaults)); + }//end scopesOf() + + /** + * The app ids a new connection is granted to. + * + * @param array $claims The verified state claims. + * + * @return array The app ids. + * + * @spec exclude private claims accessor with no behaviour of its own + */ + private function allowedAppsOf(array $claims): array { + $apps = ($claims['a'] ?? []); + if (is_array($apps) === false) { + $apps = []; + } + + $apps = array_map('strval', $apps); + + // OPENREGISTER IS ALWAYS ON THE LIST, and this is not a courtesy to itself. + // It is the app that reads the connected account's identity right after the + // mint, and that call goes through request(), whose allowedApps guard would + // otherwise deny it. Leaving it off would not produce an error a person sees: + // it would produce a connection that never learns whose account it holds, and + // a panel that says "not connected yet" beside a live token forever. It buys + // the app no reach the entry's own allow-rules do not already permit. The + // daily sweep is unaffected either way — it renews through the custody leaf + // and the token endpoint directly, never through the proxy. + if (in_array('openregister', $apps, true) === false) { + $apps[] = 'openregister'; + } + + return array_values($apps); + }//end allowedAppsOf() +}//end class diff --git a/lib/Service/Credential/OAuth2ConnectionRepository.php b/lib/Service/Credential/OAuth2ConnectionRepository.php new file mode 100644 index 0000000000..209912d210 --- /dev/null +++ b/lib/Service/Credential/OAuth2ConnectionRepository.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 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use InvalidArgumentException; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\OrganisationService; +use Throwable; + +/** + * Loads, gates and disables brokered OAuth2 connections. + */ +class OAuth2ConnectionRepository { + /** + * The organisation credential scope. + * + * @var string + */ + private const SCOPE_ORGANISATION = 'organisation'; + + /** + * Constructor. + * + * @param ObjectService $objectService Loads and writes the credential object. + * @param CredentialStore $credentialStore Deletes the stored token set on disconnect. + * @param OrganisationService $organisationService Resolves and gates organisation membership. + * + * @return void + */ + public function __construct( + private readonly ObjectService $objectService, + private readonly CredentialStore $credentialStore, + private readonly OrganisationService $organisationService, + ) { + }//end __construct() + + /** + * Load a connection the caller may manage, or null. + * + * @param string $credentialId The credential UUID. + * @param string $uid The caller. + * + * @return ObjectEntity|null The credential, or null when absent or not manageable. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-re-authorisation-overrides-the-same-credential + */ + public function findManageable(string $credentialId, string $uid): ?ObjectEntity { + try { + $entity = $this->objectService->find( + id: $credentialId, + register: CredentialBrokerService::REGISTER, + schema: CredentialBrokerService::SCHEMA, + _rbac: false, + _multitenancy: false, + _render: false + ); + } catch (Throwable $failure) { + return null; + } + + if ($entity instanceof ObjectEntity === false) { + return null; + } + + $data = $entity->jsonSerialize(); + if ((string)($data['scope'] ?? 'personal') !== self::SCOPE_ORGANISATION) { + if ((string)$entity->getOwner() !== $uid) { + return null; + } + + return $entity; + } + + $organisation = (string)($data['organisation'] ?? ''); + if ($organisation === '' || $this->organisationService->isOrganisationAdmin($organisation, $uid) === false) { + return null; + } + + return $entity; + }//end findManageable() + + /** + * Resolve and gate the organisation an organisation-scoped connect belongs to. + * + * @param string $uid The initiating user. + * @param string $requestedScope The requested credential scope. + * + * @return string|null The organisation UUID, or null for a personal connect. + * + * @throws InvalidArgumentException When there is no active organisation, or the caller does not administer it. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-starting-a-connection-returns-an-authorization-url-bound-to-the-caller + */ + public function gatedOrganisation(string $uid, string $requestedScope): ?string { + if ($requestedScope !== self::SCOPE_ORGANISATION) { + return null; + } + + $uuid = $this->organisationService->getActiveOrganisation()?->getUuid(); + if ($uuid === null || $uuid === '') { + throw new InvalidArgumentException(message: 'no active organisation to connect for'); + } + + if ($this->organisationService->isOrganisationAdmin($uuid, $uid) === false) { + throw new InvalidArgumentException(message: 'only an organisation administrator may connect a shared account'); + } + + return $uuid; + }//end gatedOrganisation() + + /** + * Delete a connection's stored token set and mark it disabled. + * + * The custody delete happens FIRST. A disconnect whose object write failed leaves + * a credential that looks connected and holds nothing, which fails closed at + * broker time; the other order would leave a credential that looks disconnected + * and still holds a live token. + * + * @param string $credentialId The credential UUID. + * @param array $data The credential's property bag. + * @param string $lastError A secret-free reason the upstream revoke failed, or an empty string. + * + * @return void + * + * @throws Throwable When the custody delete or the object write fails. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-disconnecting-revokes-upstream-where-it-can-and-disables-locally + */ + public function disable(string $credentialId, array $data, string $lastError): void { + $scope = (string)($data['scope'] ?? 'personal'); + $this->credentialStore->delete($credentialId, $scope); + + $this->objectService->saveObject( + object: array_merge($data, ['status' => 'disabled', 'lastError' => $lastError, 'expiresAt' => null]), + register: CredentialBrokerService::REGISTER, + schema: CredentialBrokerService::SCHEMA, + uuid: $credentialId, + _rbac: false, + _multitenancy: false + ); + }//end disable() +}//end class diff --git a/lib/Service/Credential/OAuth2Endpoints.php b/lib/Service/Credential/OAuth2Endpoints.php new file mode 100644 index 0000000000..3ea6031850 --- /dev/null +++ b/lib/Service/Credential/OAuth2Endpoints.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 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use OCP\IURLGenerator; + +/** + * Resolves this instance's own callback, client-metadata and return URLs. + */ +class OAuth2Endpoints { + /** + * Constructor. + * + * @param IURLGenerator $urlGenerator Builds this instance's absolute URLs. + * + * @return void + */ + public function __construct(private readonly IURLGenerator $urlGenerator) { + }//end __construct() + + /** + * This instance's own callback URL. + * + * @return string The absolute callback URL. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-a-relay-forwards-a-code-and-never-exchanges-it + */ + public function callbackUrl(): string { + return $this->urlGenerator->linkToRouteAbsolute('openregister.credentialOauth2.callback'); + }//end callbackUrl() + + /** + * This instance's own AT Protocol client identifier, which is its metadata URL. + * + * @return string The absolute client-metadata URL. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-bluesky-is-its-own-client-and-mastodon-registers-per-instance + */ + public function clientMetadataUrl(): string { + return $this->urlGenerator->linkToRouteAbsolute('openregister.credentialOauth2.clientMetadata'); + }//end clientMetadataUrl() + + /** + * This instance's own root URL. + * + * @return string The absolute root URL. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-bluesky-is-its-own-client-and-mastodon-registers-per-instance + */ + public function instanceUrl(): string { + return $this->urlGenerator->getAbsoluteURL('/'); + }//end instanceUrl() + + /** + * Reduce a proposed return URL to one on this instance. + * + * @param string $candidate The proposed return URL. + * + * @return string An absolute URL on this instance. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-callback-exchanges-the-code-and-mints-a-token-set-credential + */ + public function safeReturnUrl(string $candidate): string { + $fallback = $this->urlGenerator->linkToRouteAbsolute( + 'settings.PersonalSettings.index', + ['section' => 'additional'] + ); + + $candidate = trim($candidate); + if ($candidate === '') { + return $fallback; + } + + $parts = parse_url($candidate); + if (is_array($parts) === false || isset($parts['host']) === true || isset($parts['scheme']) === true) { + return $fallback; + } + + $path = (string)($parts['path'] ?? ''); + if (str_starts_with($path, '/') === false) { + return $fallback; + } + + return $this->urlGenerator->getAbsoluteURL($path); + }//end safeReturnUrl() +}//end class diff --git a/lib/Service/Credential/OAuth2InstanceClient.php b/lib/Service/Credential/OAuth2InstanceClient.php new file mode 100644 index 0000000000..c23d48043c --- /dev/null +++ b/lib/Service/Credential/OAuth2InstanceClient.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 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\ObjectService; +use OCP\Http\Client\IClientService; +use RuntimeException; +use Throwable; + +/** + * Registers and reuses the OAuth2 application of a per-instance provider. + * + * @SuppressWarnings(PHPMD.StaticAccess) One call, to {@see OAuth2InstanceHost}, a + * stateless security predicate; injecting it would make the host-lock substitutable. + */ +class OAuth2InstanceClient { + /** + * Constructor. + * + * @param CredentialBrokerService $broker Mints the client-secret credential. + * @param ObjectService $objectService Reads the client already pinned to a credential. + * @param IClientService $clientService NC HTTP client factory. + * + * @return void + */ + public function __construct( + private readonly CredentialBrokerService $broker, + private readonly ObjectService $objectService, + private readonly IClientService $clientService, + ) { + }//end __construct() + + /** + * Make sure the flow has a client, registering one only when there is none to reuse. + * + * @param array $provider The catalogue provider entry. + * @param array $claims The claims assembled so far. + * @param string $redirectUri The callback to register. + * + * @return array The claims, carrying a client id and its credentialRef. + * + * @throws RuntimeException When the account's server refuses the registration. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-bluesky-is-its-own-client-and-mastodon-registers-per-instance + */ + public function ensure(array $provider, array $claims, string $redirectUri): array { + $oauth2 = ($provider['oauth2'] ?? []); + if (is_array($oauth2) === false || trim((string)($oauth2['registrationEndpoint'] ?? '')) === '') { + return $claims; + } + + if (trim((string)($claims['cl'] ?? '')) !== '' || trim((string)($claims['cr'] ?? '')) !== '') { + return $claims; + } + + $stored = $this->pinnedClient(credentialId: trim((string)($claims['cid'] ?? ''))); + if ($stored !== null) { + return array_merge($claims, $stored); + } + + $registered = $this->register( + provider: $provider, + instanceBaseUrl: OAuth2InstanceHost::normalise(candidate: (string)($claims['h'] ?? '')), + redirectUri: $redirectUri, + scopes: $this->scopesOf(claims: $claims, provider: $provider), + owner: (string)($claims['u'] ?? ''), + scope: (string)($claims['s'] ?? 'personal'), + organisation: ($claims['o'] ?? null) + ); + + return array_merge($claims, ['cl' => $registered['clientId'], 'cr' => $registered['clientCredentialRef']]); + }//end ensure() + + /** + * Register an application at an account's own server. + * + * @param array $provider The catalogue provider entry. + * @param string $instanceBaseUrl The pinned, already-validated host. + * @param string $redirectUri The callback to register. + * @param array $scopes The scopes to register for. + * @param string $owner The user the client-secret credential is minted for. + * @param string $scope The credential scope for the client-secret credential. + * @param string|null $organisation The owning organisation, for an organisation-scoped connection. + * + * @return array{clientId: string, clientCredentialRef: string} The client id and the secret's credentialRef. + * + * @throws RuntimeException When the server refuses the registration. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-bluesky-is-its-own-client-and-mastodon-registers-per-instance + */ + public function register( + array $provider, + string $instanceBaseUrl, + string $redirectUri, + array $scopes, + string $owner, + string $scope, + ?string $organisation = null, + ): array { + $endpoint = $instanceBaseUrl . '/' . ltrim((string)$provider['oauth2']['registrationEndpoint'], '/'); + + try { + $response = $this->clientService->newClient()->post( + $endpoint, + [ + 'body' => [ + 'client_name' => 'Nextcloud OpenRegister', + 'redirect_uris' => $redirectUri, + 'scopes' => implode((string)($provider['oauth2']['scopeSeparator'] ?? ' '), $scopes), + ], + 'headers' => ['Accept' => 'application/json'], + 'timeout' => 20, + ] + ); + $decoded = json_decode((string)$response->getBody(), true); + } catch (Throwable $failure) { + // The class name and nothing else: a registration failure can quote the + // server's own words, and those words can contain the request that was made. + throw new RuntimeException(message: 'application registration failed: ' . $failure::class, previous: $failure); + } + + if (is_array($decoded) === false || is_string(($decoded['client_id'] ?? null)) === false) { + throw new RuntimeException(message: 'application registration returned no client id'); + } + + $minted = $this->broker->mint( + name: 'OAuth2 client for ' . $instanceBaseUrl, + provider: 'generic-oauth2', + owner: $owner, + allowedApps: ['openregister'], + secret: (string)($decoded['client_secret'] ?? ''), + scope: $scope, + organisation: $organisation + ); + + return ['clientId' => (string)$decoded['client_id'], 'clientCredentialRef' => (string)$minted->getUuid()]; + }//end register() + + /** + * The client a credential already carries, when a re-authorisation names one. + * + * @param string $credentialId The credential being re-authorised, or an empty string. + * + * @return array{cl: string, cr: string}|null The stored client, or null when there is none. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-bluesky-is-its-own-client-and-mastodon-registers-per-instance + */ + private function pinnedClient(string $credentialId): ?array { + if ($credentialId === '') { + return null; + } + + try { + $entity = $this->objectService->find( + id: $credentialId, + register: CredentialBrokerService::REGISTER, + schema: CredentialBrokerService::SCHEMA, + _rbac: false, + _multitenancy: false, + _render: false + ); + } catch (Throwable $missing) { + return null; + } + + if ($entity instanceof ObjectEntity === false) { + return null; + } + + $data = $entity->jsonSerialize(); + $clientId = trim((string)($data['clientId'] ?? '')); + if ($clientId === '') { + return null; + } + + return ['cl' => $clientId, 'cr' => trim((string)($data['clientCredentialRef'] ?? ''))]; + }//end pinnedClient() + + /** + * The scopes a flow asked for, falling back to the provider's declared defaults. + * + * @param array $claims The flow's claims. + * @param array $provider The catalogue provider entry. + * + * @return array The scopes. + * + * @spec exclude private claims accessor with no behaviour of its own + */ + private function scopesOf(array $claims, array $provider): array { + $scopes = ($claims['sc'] ?? null); + if (is_array($scopes) === true && $scopes !== []) { + return array_values(array_map('strval', $scopes)); + } + + $defaults = ($provider['oauth2']['defaultScopes'] ?? []); + if (is_array($defaults) === false) { + return []; + } + + return array_values(array_map('strval', $defaults)); + }//end scopesOf() +}//end class diff --git a/lib/Service/Credential/OAuth2InstanceHost.php b/lib/Service/Credential/OAuth2InstanceHost.php new file mode 100644 index 0000000000..e56720f5bf --- /dev/null +++ b/lib/Service/Credential/OAuth2InstanceHost.php @@ -0,0 +1,170 @@ + + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use InvalidArgumentException; + +/** + * Syntactic and network-safety guard for a per-account API host. + * + * @SuppressWarnings(PHPMD.StaticAccess) Deliberately static and stateless. This is a + * security predicate with no collaborators and no configuration; making it injectable + * would let a caller substitute a version that says yes, which is the one thing it + * must never be possible to do to a host-lock. + */ +final class OAuth2InstanceHost { + /** + * Host names that always name the instance itself. + * + * @var array + */ + private const LOOPBACK_NAMES = ['localhost', 'localhost.localdomain', 'ip6-localhost', 'ip6-loopback']; + + /** + * Validate a candidate instance base URL and return its normalised origin. + * + * @param string $candidate The URL supplied at connect time. + * + * @return string The normalised origin (`https://host` with no trailing slash). + * + * @throws InvalidArgumentException When the candidate is not a safe, absolute https origin. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-per-account-host-is-pinned-at-mint-and-immutable-afterwards + */ + public static function normalise(string $candidate): string { + $candidate = trim($candidate); + if ($candidate === '') { + throw new InvalidArgumentException(message: 'instance base URL is empty'); + } + + $parts = parse_url($candidate); + if (is_array($parts) === false) { + throw new InvalidArgumentException(message: 'instance base URL is not a URL'); + } + + self::assertOriginShape(parts: $parts); + + $host = strtolower((string)($parts['host'] ?? '')); + self::assertPublicHost(host: $host); + + return 'https://' . $host; + }//end normalise() + + /** + * Reject anything that is not a bare, absolute https origin. + * + * The scheme and port rules are about transport. The userinfo, path, query and + * fragment rules are about ambiguity: `https://api.example.com@evil.example/` + * reads as one host to a person and another to a parser, and a base URL carrying + * a path would silently prefix every allow-rule the catalogue declares. + * + * @param array $parts The parsed URL. + * + * @return void + * + * @throws InvalidArgumentException When the URL is not a bare https origin. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-per-account-host-is-pinned-at-mint-and-immutable-afterwards + */ + private static function assertOriginShape(array $parts): void { + if (strtolower((string)($parts['scheme'] ?? '')) !== 'https') { + throw new InvalidArgumentException(message: 'instance base URL must use https'); + } + + if (isset($parts['user']) === true || isset($parts['pass']) === true) { + throw new InvalidArgumentException(message: 'instance base URL must carry no userinfo'); + } + + if (isset($parts['query']) === true || isset($parts['fragment']) === true) { + throw new InvalidArgumentException(message: 'instance base URL must carry no query or fragment'); + } + + if (trim((string)($parts['path'] ?? ''), '/') !== '') { + throw new InvalidArgumentException(message: 'instance base URL must be an origin, with no path'); + } + + $port = ($parts['port'] ?? null); + if ($port !== null && (int)$port !== 443) { + throw new InvalidArgumentException(message: 'instance base URL must use the default https port'); + } + }//end assertOriginShape() + + /** + * Reject a host that is not a public, registrable domain name. + * + * An IP literal is refused outright rather than range-checked. A per-account API + * host is somebody's server with a name; accepting a literal would mean accepting + * every address the range checks failed to think of, and there is no legitimate + * connection that needs one. + * + * @param string $host The lower-cased host component. + * + * @return void + * + * @throws InvalidArgumentException When the host is empty, a literal address, or a loopback name. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-per-account-host-is-pinned-at-mint-and-immutable-afterwards + */ + private static function assertPublicHost(string $host): void { + if ($host === '') { + throw new InvalidArgumentException(message: 'instance base URL carries no host'); + } + + if (in_array($host, self::LOOPBACK_NAMES, true) === true) { + throw new InvalidArgumentException(message: 'instance base URL may not name the instance itself'); + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false || str_contains($host, ':') === true) { + throw new InvalidArgumentException(message: 'instance base URL must name a domain, not an address'); + } + + if (str_contains($host, '.') === false) { + throw new InvalidArgumentException(message: 'instance base URL must name a registrable domain'); + } + + if (preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/', $host) !== 1) { + throw new InvalidArgumentException(message: 'instance base URL host is not a valid domain name'); + } + + if (str_ends_with($host, '.local') === true || str_ends_with($host, '.internal') === true) { + throw new InvalidArgumentException(message: 'instance base URL may not name a private-network domain'); + } + }//end assertPublicHost() +}//end class diff --git a/lib/Service/Credential/OAuth2RefreshService.php b/lib/Service/Credential/OAuth2RefreshService.php new file mode 100644 index 0000000000..c1a8584bfd --- /dev/null +++ b/lib/Service/Credential/OAuth2RefreshService.php @@ -0,0 +1,642 @@ + + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use DateTimeImmutable; +use InvalidArgumentException; +use OCA\OpenRegister\Service\ObjectService; +use OCP\Http\Client\IClientService; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Refreshes and rotates OAuth2 token sets on behalf of the broker. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) 16 against a threshold of 13, and the + * composition is the reason rather than an excuse: SEVEN of the sixteen are the failure + * vocabulary this class exists to get right (`InvalidArgumentException`, + * `CredentialAccessDeniedException`, `CredentialRelinkRequiredException`, + * `CredentialUpstreamException`, `Throwable`) plus the two value objects it decodes + * and validates with. Collapsing them into a smaller set would mean one exception type + * for "the grant is gone" and "the provider timed out", which is exactly the + * distinction the whole class is built around: get it wrong and a provider outage + * relinks every connection on the instance. The genuine COLLABORATORS are six, and one + * of them was already split out into {@see CredentialRelinkNotifier} while writing this. + * @SuppressWarnings(PHPMD.StaticAccess) Two static calls, both to named constructors + * or to a stateless security predicate: {@see OAuth2TokenSet} decodes a stored + * document, {@see OAuth2InstanceHost} validates a per-account host. Neither has an + * instance to call, and injecting the host guard would make it substitutable. + */ +class OAuth2RefreshService { + /** + * How far ahead of an access token's expiry a refresh becomes due, in seconds. + * + * A constant rather than a setting: a per-install margin is one more value that + * can be set wrong, and no provider in the catalogue issues an access token with + * a life shorter than five minutes. + * + * @var integer + */ + public const REFRESH_MARGIN_SECONDS = 120; + + /** + * How far ahead of expiry the daily sweep refreshes a token set, in seconds. + * + * Two days, so a token with a sixty-day life is rotated well before it lapses + * even if nothing reads it, and a token with a one-hour life is left to the read + * path, which will have refreshed it long before the sweep next runs. + * + * @var integer + */ + public const SWEEP_WINDOW_SECONDS = 172800; + + /** + * Statuses that are terminal for a brokered call. + * + * @var array + */ + public const BLOCKING_STATUSES = ['relink_needed', 'disabled']; + + /** + * The provider error code that means the grant itself is gone. + * + * @var string + */ + private const TERMINAL_ERROR = 'invalid_grant'; + + /** + * Constructor. + * + * @param CredentialStore $credentialStore Holds the token set; the only place it is ever written. + * @param ObjectService $objectService Updates the credential's non-secret metadata. + * @param CredentialLock $lock Excludes a second concurrent rotation of one credential. + * @param OAuth2ClientResolver $clients Resolves the client id and secret for the token request. + * @param IClientService $clientService NC HTTP client factory for the token request. + * @param CredentialRelinkNotifier $relinkNotifier Announces a lost grant to the instance and its owner. + * @param LoggerInterface $logger Secret-free diagnostics. + * + * @return void + */ + public function __construct( + private readonly CredentialStore $credentialStore, + private readonly ObjectService $objectService, + private readonly CredentialLock $lock, + private readonly OAuth2ClientResolver $clients, + private readonly IClientService $clientService, + private readonly CredentialRelinkNotifier $relinkNotifier, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Return an access token that is valid now, refreshing first when it is not. + * + * @param array $credential The credential object's serialised data (must carry an `id`/`uuid`). + * @param array $provider The catalogue provider entry. + * @param string $credentialId The credential UUID. + * @param string $scope The credential scope (`personal`|`organisation`). + * @param string|null $actingUserId The asserted user for a sessionless caller. + * + * @return string The access token to inject. + * + * @throws CredentialRelinkRequiredException When the credential is blocked or its grant is gone. + * @throws CredentialAccessDeniedException When no usable token set is stored. + * @throws CredentialUpstreamException When the token endpoint could not be reached or refused transiently. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-brokered-call-refreshes-an-expiring-token-set-before-it-is-used + */ + public function accessTokenFor( + array $credential, + array $provider, + string $credentialId, + string $scope, + ?string $actingUserId = null, + ): string { + $this->assertNotBlocked(credential: $credential, credentialId: $credentialId); + + $set = $this->storedSet(credentialId: $credentialId, scope: $scope); + if ($set->needsRefresh(marginSeconds: self::REFRESH_MARGIN_SECONDS) === false) { + return $set->getAccessToken(); + } + + if ($this->lock->acquire($credentialId) === false) { + // Somebody else is rotating this credential. Wait for them, then read + // what they stored rather than starting a second exchange; after a + // successful rotation the re-read is outside the margin and there is + // nothing left to do. + $this->lock->waitForRelease($credentialId); + $set = $this->storedSet(credentialId: $credentialId, scope: $scope); + if ($set->needsRefresh(marginSeconds: self::REFRESH_MARGIN_SECONDS) === false) { + return $set->getAccessToken(); + } + + if ($this->lock->acquire($credentialId) === false) { + throw new CredentialUpstreamException(message: 'refresh lock is held and the token is still expiring'); + } + } + + try { + $rotated = $this->rotate( + credential: $credential, + provider: $provider, + credentialId: $credentialId, + scope: $scope, + current: $set, + actingUserId: $actingUserId + ); + } finally { + $this->lock->release($credentialId); + } + + return $rotated->getAccessToken(); + }//end accessTokenFor() + + /** + * Refresh one credential unconditionally, for the daily sweep. + * + * @param array $credential The credential object's serialised data. + * @param array $provider The catalogue provider entry. + * @param string $credentialId The credential UUID. + * @param string $scope The credential scope. + * + * @return boolean True when a rotation happened, false when the credential was outside the sweep window. + * + * @throws Throwable Whatever the rotation raised; the sweep decides what to do with it. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-daily-job-refreshes-active-token-sets-before-they-expire + */ + public function sweepCredential(array $credential, array $provider, string $credentialId, string $scope): bool { + $this->assertNotBlocked(credential: $credential, credentialId: $credentialId); + + $set = $this->storedSet(credentialId: $credentialId, scope: $scope); + if ($set->needsRefresh(marginSeconds: self::SWEEP_WINDOW_SECONDS) === false) { + return false; + } + + if ($this->lock->acquire($credentialId) === false) { + // The read path is already rotating it. Leaving it to them is the whole + // point of the lock; the next sweep picks up anything they did not finish. + return false; + } + + $owner = trim((string)($credential['owner'] ?? '')); + + try { + $this->rotate( + credential: $credential, + provider: $provider, + credentialId: $credentialId, + scope: $scope, + current: $set, + actingUserId: $this->nullIfEmpty(value: $owner) + ); + } finally { + $this->lock->release($credentialId); + } + + return true; + }//end sweepCredential() + + /** + * Read and decode the stored token set. + * + * @param string $credentialId The credential UUID. + * @param string $scope The credential scope. + * + * @return OAuth2TokenSet The decoded set. + * + * @throws CredentialAccessDeniedException When nothing is stored, or the stored document is not a token set. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-oauth2-token-set-is-stored-as-one-opaque-secret-in-the-custody-leaf + */ + public function storedSet(string $credentialId, string $scope): OAuth2TokenSet { + $stored = $this->credentialStore->get($credentialId, $scope); + if ($stored === null || $stored === '') { + throw new CredentialAccessDeniedException(message: 'no token set stored for credential'); + } + + try { + return OAuth2TokenSet::fromStoredJson(stored: $stored); + } catch (InvalidArgumentException $malformed) { + // The message names the decode failure and never quotes the value, which + // is why it is safe to pass on: OAuth2TokenSet is written to guarantee that. + throw new CredentialAccessDeniedException( + message: 'stored token set is unusable: ' . $malformed->getMessage(), + previous: $malformed + ); + } + }//end storedSet() + + /** + * Persist a token set and mirror its non-secret metadata onto the credential object. + * + * The custody write happens FIRST and the object update only after it returned, + * so a failed custody write never leaves the object claiming a token the leaf + * does not hold. + * + * @param string $credentialId The credential UUID. + * @param string $scope The credential scope. + * @param OAuth2TokenSet $set The set to store. + * @param array $extraMetadata Additional non-secret properties to write on the object. + * + * @return void + * + * @throws Throwable When the custody write fails; the object is then left untouched. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-refresh-runs-under-a-per-credential-lock-and-rotates-atomically + */ + public function persist(string $credentialId, string $scope, OAuth2TokenSet $set, array $extraMetadata = []): void { + $this->credentialStore->put($credentialId, $set->toStoredJson(), $scope); + + $this->writeMetadata( + credentialId: $credentialId, + metadata: array_merge( + [ + 'status' => 'active', + 'scopes' => $set->getScopes(), + 'account' => $set->getAccount(), + 'expiresAt' => $set->getExpiresAt()?->format(DATE_ATOM), + 'lastRefreshedAt' => (new DateTimeImmutable())->format(DATE_ATOM), + 'lastError' => '', + ], + $extraMetadata + ) + ); + }//end persist() + + /** + * Refuse a credential whose status already says it cannot be used. + * + * @param array $credential The credential object's serialised data. + * @param string $credentialId The credential UUID. + * + * @return void + * + * @throws CredentialRelinkRequiredException When the credential is `relink_needed` or `disabled`. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-invalid-grant-moves-the-credential-to-relink-needed-and-fails-closed + */ + private function assertNotBlocked(array $credential, string $credentialId): void { + $status = (string)($credential['status'] ?? 'active'); + if (in_array($status, self::BLOCKING_STATUSES, true) === true) { + throw new CredentialRelinkRequiredException( + message: 'credential ' . $credentialId . ' is ' . $status . ' and must be re-authorised' + ); + } + }//end assertNotBlocked() + + /** + * Exchange the refresh token and store the result. + * + * @param array $credential The credential object's serialised data. + * @param array $provider The catalogue provider entry. + * @param string $credentialId The credential UUID. + * @param string $scope The credential scope. + * @param OAuth2TokenSet $current The set being replaced. + * @param string|null $actingUserId The asserted user for a sessionless caller. + * + * @return OAuth2TokenSet The rotated set. + * + * @throws CredentialRelinkRequiredException When the provider says the grant is gone. + * @throws CredentialUpstreamException When the exchange failed for any other reason. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-refresh-runs-under-a-per-credential-lock-and-rotates-atomically + */ + private function rotate( + array $credential, + array $provider, + string $credentialId, + string $scope, + OAuth2TokenSet $current, + ?string $actingUserId, + ): OAuth2TokenSet { + $refreshToken = $current->getRefreshToken(); + if ($refreshToken === null) { + $this->markRelinkNeeded(credential: $credential, credentialId: $credentialId, reason: 'no_refresh_token'); + throw new CredentialRelinkRequiredException(message: 'credential ' . $credentialId . ' has no refresh token'); + } + + $oauth2 = []; + if (is_array(($provider['oauth2'] ?? null)) === true) { + $oauth2 = $provider['oauth2']; + } + + $client = $this->clients->resolve( + credential: $credential, + provider: (string)($credential['provider'] ?? ''), + actingUserId: $actingUserId + ); + + $response = $this->exchange( + endpoint: $this->tokenEndpoint(oauth2: $oauth2, credential: $credential, credentialId: $credentialId), + form: $this->refreshForm(oauth2: $oauth2, refreshToken: $refreshToken, client: $client), + client: $client, + credential: $credential, + credentialId: $credentialId + ); + + $rotated = OAuth2TokenSet::fromTokenResponse( + response: $response, + requestedScopes: $current->getScopes(), + previous: $current + ); + + $this->persist(credentialId: $credentialId, scope: $scope, set: $rotated); + + return $rotated; + }//end rotate() + + /** + * Build the refresh request's form body. + * + * @param array $oauth2 The provider entry's `oauth2` block. + * @param string $refreshToken The refresh token being spent. + * @param array{clientId: string, clientSecret: string|null} $client The resolved client. + * + * @return array The form fields. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-refresh-runs-under-a-per-credential-lock-and-rotates-atomically + */ + private function refreshForm(array $oauth2, string $refreshToken, array $client): array { + $grant = (string)($oauth2['refreshGrant'] ?? 'refresh_token'); + + // Meta has no refresh_token grant: a short-lived token is traded for a + // long-lived one against the same endpoint, with the CURRENT token in a + // differently named field. Naming the grant in the catalogue is what lets + // one code path serve both shapes. + $form = ['grant_type' => 'refresh_token', 'refresh_token' => $refreshToken]; + if ($grant === 'fb_exchange_token') { + $form = ['grant_type' => 'fb_exchange_token', 'fb_exchange_token' => $refreshToken]; + } + + $form['client_id'] = $client['clientId']; + if ($client['clientSecret'] !== null && (string)($oauth2['clientAuth'] ?? '') !== 'client_secret_basic') { + $form['client_secret'] = $client['clientSecret']; + } + + return $form; + }//end refreshForm() + + /** + * Resolve the absolute token endpoint for a provider. + * + * @param array $oauth2 The provider entry's `oauth2` block. + * @param array $credential The credential object's serialised data. + * @param string $credentialId The credential UUID. + * + * @return string The absolute token endpoint URL. + * + * @throws CredentialAccessDeniedException When the catalogue names no usable token endpoint. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-broker/spec.md#requirement-the-catalogue-may-describe-an-oauth2-provider + */ + private function tokenEndpoint(array $oauth2, array $credential, string $credentialId): string { + $endpoint = trim((string)($oauth2['tokenEndpoint'] ?? '')); + if ($endpoint === '') { + throw new CredentialAccessDeniedException(message: 'no token endpoint in the catalogue for credential ' . $credentialId); + } + + if (($oauth2['endpointsRelativeToInstance'] ?? false) !== true) { + return $endpoint; + } + + try { + $origin = OAuth2InstanceHost::normalise(candidate: (string)($credential['instanceBaseUrl'] ?? '')); + } catch (InvalidArgumentException $badHost) { + throw new CredentialAccessDeniedException( + message: 'credential ' . $credentialId . ' has no usable instance host: ' . $badHost->getMessage(), + previous: $badHost + ); + } + + return $origin . '/' . ltrim($endpoint, '/'); + }//end tokenEndpoint() + + /** + * Perform the token request and decode its response. + * + * @param string $endpoint The absolute token endpoint. + * @param array $form The form body. + * @param array{clientId: string, clientSecret: string|null} $client The resolved client. + * @param array $credential The credential object's serialised data. + * @param string $credentialId The credential UUID. + * + * @return array The decoded token response. + * + * @throws CredentialRelinkRequiredException When the provider answered `invalid_grant`. + * @throws CredentialUpstreamException On any other failure. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-invalid-grant-moves-the-credential-to-relink-needed-and-fails-closed + */ + private function exchange(string $endpoint, array $form, array $client, array $credential, string $credentialId): array { + $options = ['body' => $form, 'headers' => ['Accept' => 'application/json'], 'timeout' => 20]; + if ($client['clientSecret'] !== null && isset($form['client_secret']) === false) { + $options['auth'] = [$client['clientId'], $client['clientSecret']]; + } + + try { + $response = $this->clientService->newClient()->post($endpoint, $options); + $decoded = json_decode((string)$response->getBody(), true); + } catch (Throwable $failure) { + $error = $this->errorCodeOf(throwable: $failure); + if ($error === self::TERMINAL_ERROR) { + $this->markRelinkNeeded(credential: $credential, credentialId: $credentialId, reason: $error); + throw new CredentialRelinkRequiredException(message: 'refresh refused: ' . $error, previous: $failure); + } + + throw new CredentialUpstreamException( + message: 'token endpoint unreachable or failing: ' . $failure::class, + previous: $failure + ); + }//end try + + if (is_array($decoded) === false) { + throw new CredentialUpstreamException(message: 'token endpoint returned a body that is not JSON'); + } + + $error = (string)($decoded['error'] ?? ''); + if ($error === self::TERMINAL_ERROR) { + $this->markRelinkNeeded(credential: $credential, credentialId: $credentialId, reason: $error); + throw new CredentialRelinkRequiredException(message: 'refresh refused: ' . $error); + } + + if ($error !== '') { + throw new CredentialUpstreamException(message: 'token endpoint returned error ' . $error); + } + + return $decoded; + }//end exchange() + + /** + * Read an OAuth2 error code out of a transport failure, without quoting the body. + * + * A `invalid_grant` commonly arrives as an HTTP 400 with a JSON body, which the + * NC client raises rather than returns. Reading the code out of it is what keeps + * a revoked grant from being misfiled as a transient failure and retried forever. + * + * @param Throwable $throwable The transport failure. + * + * @return string The OAuth2 error code, or an empty string when there is none. + * + * @spec exclude private error-code extraction; asserted through exchange()'s terminal and transient branches + */ + private function errorCodeOf(Throwable $throwable): string { + $body = ''; + if (method_exists($throwable, 'getResponse') === true) { + $response = $throwable->getResponse(); + if ($response !== null && method_exists($response, 'getBody') === true) { + $body = (string)$response->getBody(); + } + } + + if ($body === '') { + return ''; + } + + $decoded = json_decode($body, true); + if (is_array($decoded) === false) { + return ''; + } + + return (string)($decoded['error'] ?? ''); + }//end errorCodeOf() + + /** + * Return null for an empty string, so an absent owner is not asserted as one. + * + * @param string $value The candidate. + * + * @return string|null The value, or null when it is empty. + * + * @spec exclude private coercion helper with no behaviour of its own + */ + private function nullIfEmpty(string $value): ?string { + if ($value === '') { + return null; + } + + return $value; + }//end nullIfEmpty() + + /** + * Move a credential to `relink_needed`, announce it, and tell its owner. + * + * Best-effort on the announcement side: the caller is already raising a typed + * exception, and a notification backend that is down must not turn a recoverable + * relink into an unhandled error. The status write is what actually matters, and + * it is what makes this happen once rather than on every attempt. + * + * @param array $credential The credential object's serialised data. + * @param string $credentialId The credential UUID. + * @param string $reason The provider's OAuth2 error code. + * + * @return void + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-invalid-grant-moves-the-credential-to-relink-needed-and-fails-closed + */ + private function markRelinkNeeded(array $credential, string $credentialId, string $reason): void { + $this->writeMetadata( + credentialId: $credentialId, + metadata: ['status' => 'relink_needed', 'lastError' => $reason] + ); + + $this->relinkNotifier->announce( + credentialId: $credentialId, + provider: (string)($credential['provider'] ?? ''), + owner: (string)($credential['owner'] ?? ''), + reason: $reason + ); + }//end markRelinkNeeded() + + /** + * Write non-secret metadata back onto the credential object. + * + * Runs with RBAC and multitenancy off for the same reason `mint()` does: the + * caller may be a background sweep with no session, and the authorization + * decision was already taken by the guard chain that admitted the credential. + * + * @param string $credentialId The credential UUID. + * @param array $metadata The properties to merge onto the object. + * + * @return void + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-non-secret-connection-metadata-lives-on-the-credential-object + */ + private function writeMetadata(string $credentialId, array $metadata): void { + try { + $existing = $this->objectService->find( + id: $credentialId, + register: CredentialBrokerService::REGISTER, + schema: CredentialBrokerService::SCHEMA, + _rbac: false, + _multitenancy: false, + _render: false + ); + + if ($existing === null) { + return; + } + + $data = $existing->jsonSerialize(); + unset($data['@self']); + + $this->objectService->saveObject( + object: array_merge($data, $metadata), + register: CredentialBrokerService::REGISTER, + schema: CredentialBrokerService::SCHEMA, + uuid: $credentialId, + _rbac: false, + _multitenancy: false + ); + } catch (Throwable $writeFailure) { + // The token set is already safe in the custody leaf, which is the store + // that matters; a stale mirror on the object is corrected by the next + // read. Failing the whole call here would turn a cosmetic problem into + // an outage. + $this->logger->warning( + '[OAuth2RefreshService] could not update credential metadata for ' . $credentialId + . ': ' . $writeFailure->getMessage() + ); + }//end try + }//end writeMetadata() +}//end class diff --git a/lib/Service/Credential/OAuth2RelayGuard.php b/lib/Service/Credential/OAuth2RelayGuard.php new file mode 100644 index 0000000000..abd5425037 --- /dev/null +++ b/lib/Service/Credential/OAuth2RelayGuard.php @@ -0,0 +1,166 @@ + + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use OCP\IAppConfig; + +/** + * Administrator-managed allow-list for relayed OAuth2 callbacks. + */ +class OAuth2RelayGuard { + /** + * App-config key holding the newline- or comma-separated list of allowed origins. + * + * @var string + */ + public const CONFIG_KEY = 'oauth2_relay_targets'; + + /** + * The application id the config lives under. + * + * @var string + */ + private const APP_ID = 'openregister'; + + /** + * The callback path suffix a relay may forward to. Anything else is refused. + * + * A SUFFIX rather than a whole path, because Nextcloud's own URL generator + * produces different prefixes for a subfolder install and for one with + * `index.php` in its URLs. Pinning the whole path would silently refuse every + * tenant whose deployment does not look like the relay's own. + * + * @var string + */ + public const CALLBACK_PATH_SUFFIX = '/oauth2/callback'; + + /** + * The path segment identifying this application, required alongside the suffix. + * + * @var string + */ + private const APP_PATH_SEGMENT = '/openregister/'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig Holds the administrator-managed allow-list. + * + * @return void + */ + public function __construct(private readonly IAppConfig $appConfig) { + }//end __construct() + + /** + * Whether a relay may forward a code to this callback URL. + * + * Fails CLOSED on everything: an empty allow-list forwards nowhere, so an + * instance that was never configured as a relay cannot accidentally act as one. + * + * @param string $callbackUrl The receiving instance's callback, taken from the state. + * + * @return boolean True when the URL is an allow-listed origin plus this application's callback path. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-a-relay-forwards-a-code-and-never-exchanges-it + */ + public function permits(string $callbackUrl): bool { + $parts = parse_url(trim($callbackUrl)); + if (is_array($parts) === false) { + return false; + } + + if (strtolower((string)($parts['scheme'] ?? '')) !== 'https') { + return false; + } + + if (isset($parts['user']) === true || isset($parts['pass']) === true) { + return false; + } + + $path = rtrim((string)($parts['path'] ?? ''), '/'); + if (str_ends_with($path, self::CALLBACK_PATH_SUFFIX) === false || str_contains($path, self::APP_PATH_SEGMENT) === false) { + return false; + } + + $origin = 'https://' . strtolower((string)($parts['host'] ?? '')); + if (isset($parts['port']) === true) { + $origin .= ':' . (int)$parts['port']; + } + + return in_array($origin, $this->allowedOrigins(), true); + }//end permits() + + /** + * The origins an administrator has allow-listed. + * + * @return array The normalised origins. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-a-relay-forwards-a-code-and-never-exchanges-it + */ + public function allowedOrigins(): array { + $raw = $this->appConfig->getValueString(self::APP_ID, self::CONFIG_KEY, ''); + $entries = preg_split('/[\s,]+/', $raw, -1, PREG_SPLIT_NO_EMPTY); + if ($entries === false) { + return []; + } + + $origins = []; + foreach ($entries as $entry) { + $parts = parse_url(rtrim(strtolower(trim($entry)), '/')); + if (is_array($parts) === false || strtolower((string)($parts['scheme'] ?? '')) !== 'https') { + continue; + } + + $host = (string)($parts['host'] ?? ''); + if ($host === '') { + continue; + } + + $origin = 'https://' . $host; + if (isset($parts['port']) === true) { + $origin .= ':' . (int)$parts['port']; + } + + $origins[] = $origin; + } + + return $origins; + }//end allowedOrigins() +}//end class diff --git a/lib/Service/Credential/OAuth2StateService.php b/lib/Service/Credential/OAuth2StateService.php new file mode 100644 index 0000000000..0b26820749 --- /dev/null +++ b/lib/Service/Credential/OAuth2StateService.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 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use OCP\Security\ICredentialsManager; +use OCP\Security\ICrypto; +use OCP\Security\ISecureRandom; + +/** + * Signs, stores and redeems the state of an in-flight OAuth2 connect. + */ +class OAuth2StateService { + /** + * Vault key prefix for a pending connect flow. + * + * @var string + */ + private const PENDING_PREFIX = 'openregister/oauth2-pending/'; + + /** + * The reserved Nextcloud system-credential identity (empty-string user). + * + * A pending flow is not owned by the user in the way a credential is: the + * callback that redeems it arrives as a PUBLIC request with no session at all, + * so a per-user vault entry could not be read at the moment it is needed. + * + * @var string + */ + private const SYSTEM_IDENTITY = ''; + + /** + * How long a start stays redeemable, in seconds. + * + * Ten minutes: long enough for a person to read a provider's consent screen and + * log in first, short enough that an abandoned flow is not a standing invitation. + * + * @var integer + */ + public const STATE_TTL_SECONDS = 600; + + /** + * Constructor. + * + * @param ICrypto $crypto Signs and verifies the state with the instance secret. + * @param ICredentialsManager $vault Holds the pending flow, encrypted at rest. + * @param ISecureRandom $random Generates the nonce and the PKCE verifier. + * + * @return void + */ + public function __construct( + private readonly ICrypto $crypto, + private readonly ICredentialsManager $vault, + private readonly ISecureRandom $random, + ) { + }//end __construct() + + /** + * Start a flow: generate a nonce and a verifier, store them, and sign a state. + * + * @param array $claims The flow's claims (user, provider, scope, callback, return URL, credential id). + * + * @return array{state: string, nonce: string, verifier: string, challenge: string} The signed state and its PKCE pair. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-state-value-is-signed-single-use-and-short-lived + */ + public function issue(array $claims): array { + $nonce = $this->random->generate(43, ISecureRandom::CHAR_ALPHANUMERIC); + $verifier = $this->random->generate(64, ISecureRandom::CHAR_ALPHANUMERIC); + + $payload = array_merge($claims, ['v' => 1, 'n' => $nonce, 'exp' => (time() + self::STATE_TTL_SECONDS)]); + $encoded = $this->base64UrlEncode(value: (string)json_encode($payload)); + $signature = $this->base64UrlEncode(value: $this->crypto->calculateHMAC($encoded)); + + $this->vault->store( + self::SYSTEM_IDENTITY, + self::PENDING_PREFIX . $nonce, + (string)json_encode(['verifier' => $verifier, 'exp' => $payload['exp']]) + ); + + return [ + 'state' => $encoded . '.' . $signature, + 'nonce' => $nonce, + 'verifier' => $verifier, + 'challenge' => $this->challengeFor(verifier: $verifier), + ]; + }//end issue() + + /** + * Read a state's claims WITHOUT verifying its signature. + * + * For the RELAY only, which cannot verify a signature it does not hold the key + * for. The relay uses this to learn where to forward, and forwards only to a + * host on its own allow-list; the receiving instance then verifies the signature + * properly, so a forged state cannot mint anything anywhere. Never use this on a + * path that acts on the claims. + * + * @param string $state The state value from the provider's redirect. + * + * @return array|null The claims, or null when the value is not a state at all. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-a-relay-forwards-a-code-and-never-exchanges-it + */ + public function parseUnverified(string $state): ?array { + $parts = explode('.', $state); + if (count($parts) !== 2) { + return null; + } + + $decoded = json_decode($this->base64UrlDecode(value: $parts[0]), true); + if (is_array($decoded) === false) { + return null; + } + + return $decoded; + }//end parseUnverified() + + /** + * Redeem a state: verify its signature and expiry, then consume it. + * + * Consuming DELETES the pending record before anything is exchanged, so a state + * presented twice fails the second time even if the first attempt is still in + * flight. + * + * @param string $state The state value from the provider's redirect. + * + * @return array{claims: array, verifier: string}|null The claims and verifier, or null when invalid. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-state-value-is-signed-single-use-and-short-lived + */ + public function consume(string $state): ?array { + $claims = $this->verifiedClaims(state: $state); + if ($claims === null) { + return null; + } + + $nonce = (string)($claims['n'] ?? ''); + if ($nonce === '') { + return null; + } + + $pending = $this->vault->retrieve(self::SYSTEM_IDENTITY, self::PENDING_PREFIX . $nonce); + if (is_string($pending) === false || $pending === '') { + return null; + } + + // Delete BEFORE returning: this is the single-use guarantee, and doing it + // here rather than after the exchange means a slow or failing exchange still + // cannot be retried with the same state. + $this->vault->delete(self::SYSTEM_IDENTITY, self::PENDING_PREFIX . $nonce); + + $record = json_decode($pending, true); + if (is_array($record) === false || is_string(($record['verifier'] ?? null)) === false) { + return null; + } + + return ['claims' => $claims, 'verifier' => (string)$record['verifier']]; + }//end consume() + + /** + * Verify a state's signature and expiry and return its claims. + * + * The signature is compared with `hash_equals`, so a near-miss takes the same time + * as a wild guess: a timing difference here would leak the signature one byte at a + * time, which is the whole attack against a naive string comparison. + * + * @param string $state The state value from the provider's redirect. + * + * @return array|null The claims, or null when the state is forged or stale. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-state-value-is-signed-single-use-and-short-lived + */ + private function verifiedClaims(string $state): ?array { + $parts = explode('.', $state); + if (count($parts) !== 2) { + return null; + } + + $expectedSignature = $this->base64UrlEncode(value: $this->crypto->calculateHMAC($parts[0])); + if (hash_equals($expectedSignature, $parts[1]) === false) { + return null; + } + + $claims = json_decode($this->base64UrlDecode(value: $parts[0]), true); + if (is_array($claims) === false || (int)($claims['exp'] ?? 0) < time()) { + return null; + } + + return $claims; + }//end verifiedClaims() + + /** + * Derive the S256 code challenge for a verifier. + * + * @param string $verifier The PKCE code verifier. + * + * @return string The base64url-encoded SHA-256 challenge. + * + * @spec openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md#requirement-the-state-value-is-signed-single-use-and-short-lived + */ + public function challengeFor(string $verifier): string { + return $this->base64UrlEncode(value: hash('sha256', $verifier, true)); + }//end challengeFor() + + /** + * Base64url-encode a value (no padding). + * + * @param string $value The raw value. + * + * @return string The encoded value. + * + * @spec exclude private encoding helper with no behaviour of its own + */ + private function base64UrlEncode(string $value): string { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + }//end base64UrlEncode() + + /** + * Base64url-decode a value. + * + * @param string $value The encoded value. + * + * @return string The raw value, or an empty string when it does not decode. + * + * @spec exclude private encoding helper with no behaviour of its own + */ + private function base64UrlDecode(string $value): string { + $decoded = base64_decode(strtr($value, '-_', '+/'), true); + if ($decoded === false) { + return ''; + } + + return $decoded; + }//end base64UrlDecode() +}//end class diff --git a/lib/Service/Credential/OAuth2TokenSet.php b/lib/Service/Credential/OAuth2TokenSet.php new file mode 100644 index 0000000000..e98759c895 --- /dev/null +++ b/lib/Service/Credential/OAuth2TokenSet.php @@ -0,0 +1,416 @@ + + * @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://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Credential; + +use DateTimeImmutable; +use InvalidArgumentException; +use JsonException; + +/** + * Immutable OAuth2 token set: the whole secret of one connected account. + * + * @SuppressWarnings(PHPMD.StaticAccess) The two entry points are NAMED CONSTRUCTORS. + * A value object whose constructor is private has no instance to call them on, and + * turning them into an injected factory would put the one class that must never be + * mocked away behind a seam a test could replace. + */ +final class OAuth2TokenSet { + /** + * Constructor. Private: instances come from the two named constructors only. + * + * @param string $accessToken The bearer token injected on every brokered call. + * @param string|null $refreshToken The refresh token, or null when the provider issued none. + * @param string $tokenType The token type the provider named (practically always `Bearer`). + * @param DateTimeImmutable|null $expiresAt Absolute expiry, or null when the provider declared none. + * @param array $scopes The scopes the provider actually granted. + * @param array $account Non-secret account identity (`id`, `handle`, `displayName`). + * @param array $raw The raw token response, kept for provider-specific fields. + * @param DateTimeImmutable $obtainedAt When this set was issued or rotated. + * + * @return void + */ + private function __construct( + private readonly string $accessToken, + private readonly ?string $refreshToken, + private readonly string $tokenType, + private readonly ?DateTimeImmutable $expiresAt, + private readonly array $scopes, + private readonly array $account, + private readonly array $raw, + private readonly DateTimeImmutable $obtainedAt, + ) { + }//end __construct() + + /** + * Decode a token set previously written to the custody leaf. + * + * @param string $stored The document {@see toStoredJson()} produced. + * + * @return self The decoded token set. + * + * @throws InvalidArgumentException When the document is not decodable into a token set. The message names the + * failure and NEVER quotes any part of the stored value. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-oauth2-token-set-is-stored-as-one-opaque-secret-in-the-custody-leaf + */ + public static function fromStoredJson(string $stored): self { + try { + $decoded = json_decode($stored, true, 32, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new InvalidArgumentException(message: 'stored token set is not valid JSON'); + } + + if (is_array($decoded) === false || is_string(($decoded['accessToken'] ?? null)) === false) { + throw new InvalidArgumentException(message: 'stored token set carries no access token'); + } + + $expiresAt = self::parseAtom(value: ($decoded['expiresAt'] ?? null)); + $obtainedAt = self::parseAtom(value: ($decoded['obtainedAt'] ?? null)); + + $raw = []; + if (is_array(($decoded['raw'] ?? null)) === true) { + $raw = $decoded['raw']; + } + + return new self( + accessToken: (string)$decoded['accessToken'], + refreshToken: self::nullableString(value: ($decoded['refreshToken'] ?? null)), + tokenType: (self::nullableString(value: ($decoded['tokenType'] ?? null)) ?? 'Bearer'), + expiresAt: $expiresAt, + scopes: self::stringList(value: ($decoded['scopes'] ?? [])), + account: self::accountOf(value: ($decoded['account'] ?? [])), + raw: $raw, + obtainedAt: ($obtainedAt ?? new DateTimeImmutable()) + ); + }//end fromStoredJson() + + /** + * Build a token set from a provider's token or refresh response. + * + * `expires_in` is resolved against `$now` rather than against the wall clock at + * use time, so a slow exchange does not inflate the credential's remaining life. + * When the response omits `refresh_token` the previous set's refresh token is + * kept: most providers here rotate the access token only, and dropping the + * refresh token would turn every such rotation into the last one. + * + * @param array $response The decoded token response. + * @param array $requestedScopes Scopes to record when the response names none. + * @param self|null $previous The set being replaced, for the refresh token and account fallbacks. + * @param DateTimeImmutable|null $now The clock, injectable for tests. + * + * @return self The new token set. + * + * @throws InvalidArgumentException When the response carries no access token. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-refresh-runs-under-a-per-credential-lock-and-rotates-atomically + */ + public static function fromTokenResponse( + array $response, + array $requestedScopes = [], + ?self $previous = null, + ?DateTimeImmutable $now = null, + ): self { + $accessToken = self::nullableString(value: ($response['access_token'] ?? null)); + if ($accessToken === null) { + throw new InvalidArgumentException(message: 'token response carries no access token'); + } + + $now = ($now ?? new DateTimeImmutable()); + + $expiresAt = null; + $expiresIn = ($response['expires_in'] ?? null); + if (is_numeric($expiresIn) === true && (int)$expiresIn > 0) { + $expiresAt = $now->modify('+' . (int)$expiresIn . ' seconds'); + } + + $scopes = self::stringList(value: ($response['scope'] ?? null)); + if ($scopes === [] && $requestedScopes !== []) { + $scopes = array_values($requestedScopes); + } + + if ($scopes === [] && $previous !== null) { + $scopes = $previous->scopes; + } + + return new self( + accessToken: $accessToken, + refreshToken: (self::nullableString(value: ($response['refresh_token'] ?? null)) ?? $previous?->refreshToken), + tokenType: (self::nullableString(value: ($response['token_type'] ?? null)) ?? 'Bearer'), + expiresAt: $expiresAt, + scopes: $scopes, + account: self::accountOf(value: $previous?->account), + raw: $response, + obtainedAt: $now + ); + }//end fromTokenResponse() + + /** + * Return a copy carrying the provider account identity. + * + * @param string $id The provider's own identifier for the account. + * @param string $handle The account handle as the provider spells it. + * @param string $displayName The account's display name. + * + * @return self A new token set; this one is unchanged. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-oauth2-token-set-is-stored-as-one-opaque-secret-in-the-custody-leaf + */ + public function withAccount(string $id, string $handle, string $displayName): self { + return new self( + accessToken: $this->accessToken, + refreshToken: $this->refreshToken, + tokenType: $this->tokenType, + expiresAt: $this->expiresAt, + scopes: $this->scopes, + account: ['id' => $id, 'handle' => $handle, 'displayName' => $displayName], + raw: $this->raw, + obtainedAt: $this->obtainedAt + ); + }//end withAccount() + + /** + * Serialise the whole set for the custody leaf. + * + * The ONE legitimate destination for this string is {@see CredentialStore::put()}. + * + * @return string The stored document. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-an-oauth2-token-set-is-stored-as-one-opaque-secret-in-the-custody-leaf + */ + public function toStoredJson(): string { + return (string)json_encode( + [ + 'accessToken' => $this->accessToken, + 'refreshToken' => $this->refreshToken, + 'tokenType' => $this->tokenType, + 'expiresAt' => $this->expiresAt?->format(DATE_ATOM), + 'scopes' => $this->scopes, + 'account' => $this->account, + 'raw' => $this->raw, + 'obtainedAt' => $this->obtainedAt->format(DATE_ATOM), + ] + ); + }//end toStoredJson() + + /** + * The access token to inject as a bearer token. + * + * @return string The access token. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-brokered-call-refreshes-an-expiring-token-set-before-it-is-used + */ + public function getAccessToken(): string { + return $this->accessToken; + }//end getAccessToken() + + /** + * The refresh token, or null when the provider issued none. + * + * @return string|null The refresh token. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-refresh-runs-under-a-per-credential-lock-and-rotates-atomically + */ + public function getRefreshToken(): ?string { + return $this->refreshToken; + }//end getRefreshToken() + + /** + * Absolute expiry, or null when the provider declared none. + * + * @return DateTimeImmutable|null The expiry. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-brokered-call-refreshes-an-expiring-token-set-before-it-is-used + */ + public function getExpiresAt(): ?DateTimeImmutable { + return $this->expiresAt; + }//end getExpiresAt() + + /** + * The scopes the provider granted. + * + * @return array The granted scopes. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-non-secret-connection-metadata-lives-on-the-credential-object + */ + public function getScopes(): array { + return $this->scopes; + }//end getScopes() + + /** + * The non-secret provider account identity. + * + * @return array The `id`, `handle` and `displayName`. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-non-secret-connection-metadata-lives-on-the-credential-object + */ + public function getAccount(): array { + return $this->account; + }//end getAccount() + + /** + * Whether this set must be refreshed before it is used. + * + * A set with no declared expiry never needs refreshing on the clock; a provider + * that issues no expiry is telling us it will fail the call instead, and guessing + * an expiry would refresh perfectly good tokens forever. + * + * @param integer $marginSeconds How far ahead of the expiry a refresh is due. + * @param DateTimeImmutable|null $now The clock, injectable for tests. + * + * @return boolean True when the margin has been crossed. + * + * @spec openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md#requirement-a-brokered-call-refreshes-an-expiring-token-set-before-it-is-used + */ + public function needsRefresh(int $marginSeconds, ?DateTimeImmutable $now = null): bool { + if ($this->expiresAt === null) { + return false; + } + + $now = ($now ?? new DateTimeImmutable()); + + return ($this->expiresAt->getTimestamp() - $marginSeconds) <= $now->getTimestamp(); + }//end needsRefresh() + + /** + * Parse a stored ATOM timestamp, or null when there is none to parse. + * + * A value that does not parse yields null rather than an error. A stored set with + * an unreadable timestamp is still a usable token set: null expiry means "do not + * refresh on the clock", which is the same conservative answer the code gives a + * provider that declared no expiry at all. + * + * @param mixed $value The stored value. + * + * @return DateTimeImmutable|null The parsed timestamp, or null. + * + * @spec exclude private parsing helper with no behaviour of its own + * + * @SuppressWarnings(PHPMD.StaticAccess) DateTimeImmutable::createFromFormat is the + * language's own named constructor; there is no instance to call it on. + */ + private static function parseAtom(mixed $value): ?DateTimeImmutable { + if (is_string($value) === false || trim($value) === '') { + return null; + } + + $parsed = DateTimeImmutable::createFromFormat(DATE_ATOM, $value); + if ($parsed === false) { + return null; + } + + return $parsed; + }//end parseAtom() + + /** + * Coerce a value to a non-empty string, or null. + * + * @param mixed $value The candidate. + * + * @return string|null The trimmed string, or null when absent or empty. + * + * @spec exclude private coercion helper with no behaviour of its own + */ + private static function nullableString(mixed $value): ?string { + if (is_string($value) === false) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '') { + return null; + } + + return $trimmed; + }//end nullableString() + + /** + * Coerce a scope value to a list of strings. + * + * Accepts both shapes providers use: a space-separated string (`scope`) and a + * JSON array. A comma-separated string is split too, because Meta spells its + * granted scopes that way. + * + * @param mixed $value The candidate. + * + * @return array The scope list. + * + * @spec exclude private coercion helper with no behaviour of its own + */ + private static function stringList(mixed $value): array { + if (is_string($value) === true) { + $parts = preg_split('/[\s,]+/', trim($value), -1, PREG_SPLIT_NO_EMPTY); + if ($parts === false) { + return []; + } + + return $parts; + } + + if (is_array($value) === false) { + return []; + } + + $out = []; + foreach ($value as $entry) { + if (is_string($entry) === true && trim($entry) !== '') { + $out[] = trim($entry); + } + } + + return $out; + }//end stringList() + + /** + * Coerce an account value to the three non-secret identity fields. + * + * @param mixed $value The candidate. + * + * @return array The `id`, `handle` and `displayName`. + * + * @spec exclude private coercion helper with no behaviour of its own + */ + private static function accountOf(mixed $value): array { + $source = []; + if (is_array($value) === true) { + $source = $value; + } + + return [ + 'id' => (string)($source['id'] ?? ''), + 'handle' => (string)($source['handle'] ?? ''), + 'displayName' => (string)($source['displayName'] ?? ''), + ]; + }//end accountOf() +}//end class 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/DemoDataService.php b/lib/Service/DemoDataService.php index a4a622ef43..e2b190aa0b 100644 --- a/lib/Service/DemoDataService.php +++ b/lib/Service/DemoDataService.php @@ -84,6 +84,108 @@ public function isAvailable(): bool { return is_file($this->descriptorPath()) === true; }//end isAvailable() + /** + * The answer that means "plant nothing". + * + * 🔴 NOT THE ABSENCE OF AN ANSWER. An operator who declines has FINISHED the + * step; a step that can never be marked done reopens the wizard over every + * page (nextcloud-vue#806). + * + * @var string + */ + public const NONE_DATASET = 'none'; + + /** + * The id of the dataset this app ships. + * + * @var string + */ + public const DEMO_DATASET = 'demo'; + + /** + * Every answer the wizard's choice step may offer, declining included. + * + * 🔴 THE SERVER OWNS THIS LIST, AND THAT IS THE POINT. The step declares + * `optionsSource: datasets` and no options of its own, so the label, the + * description and the object count come from the descriptor that will + * actually be imported. A manifest that restated them could disagree with + * what lands, and nothing would notice. + * + * @return array The answers. + * + * @spec exclude Demo-data choice list; ADR-111 rule 1 has no per-app behavioural spec. + */ + public function listChoices(): array { + $choices = [ + [ + 'id' => self::NONE_DATASET, + 'label' => 'None, I will set this up myself', + 'description' => 'Nothing is imported. You start with an empty app and add your own data.', + 'objectCount' => 0, + 'icon' => 'CloseCircleOutline', + ], + ]; + + $objects = $this->shippedObjectCount(); + if ($objects !== null) { + $choices[] = [ + 'id' => self::DEMO_DATASET, + 'label' => 'Example data', + // 🔴 NO NUMBER IN THIS SENTENCE. The wizard runs a card's + // description through the app's translation function, which is a + // literal lookup, so an interpolated count would make the string + // untranslatable and leave a Dutch operator reading English. The + // count travels as `objectCount` and the card renders it as a + // stat, with a label the library translates. + 'description' => ( + 'Sample values for every schema this app supplies, generated from the schemas ' + . 'themselves. It shows the lists, detail pages and dashboards working rather ' + . 'than telling a story. Safe to run more than once, and you can delete it ' + . 'afterwards.' + ), + 'objectCount' => $objects, + 'icon' => 'DatabaseOutline', + ]; + } + + return $choices; + + }//end listChoices() + + /** + * How many objects the shipped descriptor carries, or null when it ships none. + * + * Counted from the FILE, so the card promises the number that will actually + * be imported. A missing or malformed descriptor returns null and the app + * then offers only "None" — honest, rather than an import that cannot run. + * + * @return integer|null The object count, or null when there is no usable descriptor. + */ + private function shippedObjectCount(): ?int { + $path = $this->descriptorPath(); + if (is_file($path) === false) { + return null; + } + + $raw = file_get_contents($path); + if ($raw === false) { + return null; + } + + $data = json_decode($raw, true); + if (is_array($data) === false) { + return null; + } + + $components = ($data['components'] ?? []); + if (is_array($components) === false || is_array(($components['objects'] ?? null)) === false) { + return 0; + } + + return count($components['objects']); + + }//end shippedObjectCount() + /** * Import the demo dataset. * 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/DsarService.php b/lib/Service/DsarService.php index 79bade1710..c9dade4d91 100644 --- a/lib/Service/DsarService.php +++ b/lib/Service/DsarService.php @@ -42,6 +42,7 @@ use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\VerwerkingsactiviteitMapper; +use OCA\OpenRegister\Service\Archival\ArchivalRetentionGuard; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IAppConfig; use OCP\IDBConnection; @@ -103,6 +104,9 @@ class DsarService { * @param IGroupManager $groupManager Group manager used * by the in-service * admin guard. + * @param ArchivalRetentionGuard $archivalGuard Refuses erasure of a legally + * retained record and puts the + * refusal into words. */ public function __construct( private readonly IDBConnection $db, @@ -112,6 +116,7 @@ public function __construct( private readonly IUserSession $userSession, private readonly LoggerInterface $logger, private readonly IGroupManager $groupManager, + private readonly ArchivalRetentionGuard $archivalGuard, ) { }//end __construct() @@ -253,8 +258,27 @@ public function findObjectsForSubject(string $subject, ?string $type = null, str * ['uuid' => '', 'register' => '<...>', 'schema' => '<...>'], * ... * ], + * 'withheld' => [ + * ['uuid' => '...', 'schema' => '...', 'ground' => 'ARCHIVAL_RETENTION_OBLIGATION', + * 'message' => '...', 'basis' => '...', 'action' => '...'], + * ... + * ], + * 'withheldCount' => , * ] * + * RETENTION WINS OVER ERASURE. A record on a schema declaring + * `x-openregister-archival` is kept under a legal obligation, and art-17(3)(b) + * stands down for it. Those rows are refused, recorded and reported in + * `withheld` with the ground and the wording to pass back to the data subject. + * The refusal is decided by + * {@see \OCA\OpenRegister\Service\Archival\ArchivalRetentionGuard}, which asks + * {@see \OCA\OpenRegister\Db\Schema::hasArchivalAnnotation()}, the same single + * definition the four HTTP delete doors already use. + * + * Withholding one record never blocks the rest: every non-archival match in + * the same request is still erased. A run that withheld anything reports + * `complete: false`, because the data is still there. + * * @param string $subject Subject identifier. * @param string|null $type Optional GdprEntity type filter. * @param bool $dryRun When true, returns matches without erasing. @@ -293,11 +317,17 @@ public function eraseObjectsForSubject(string $subject, ?string $type = null, bo // BUG-SVC-2: objects whose erasure failed are collected here so the // caller can report a partial failure instead of a false "complete". 'failed' => [], + // RETENTION WINS OVER ERASURE: records held under an archival + // obligation are refused, named here, and reported back with the + // rest of the answer. A refusal the requester never sees is a + // silent skip, which is what this bucket exists to prevent. + 'withheld' => [], ]; if ($dryRun === true || $entries === []) { $summary['complete'] = true; $summary['failedCount'] = 0; + $summary['withheldCount'] = 0; return $summary; } @@ -311,53 +341,100 @@ public function eraseObjectsForSubject(string $subject, ?string $type = null, bo ]; foreach ($entries as $entry) { - $object = $this->loadObjectByEntry(entry: $entry); - if ($object === null) { - // BUG-SVC-2: a matched object we cannot load was NOT erased — - // record it as a failure rather than silently skipping it. - $summary['failed'][] = [ - 'object' => $entry, - 'error' => 'Object could not be loaded for erasure', - ]; - $this->logger->warning( - message: '[DSAR] Matched object could not be loaded during vergetelheid', - context: ['object' => $entry] - ); - continue; - } - - $object->setDeleted($deletionData); - if ($dsarActivityUuid !== null) { - $object->setProcessingActivityId($dsarActivityUuid); - } - - try { - $this->objectMapper->update(entity: $object); - $summary['erased'][] = [ - 'uuid' => $object->getUuid(), - 'register' => $object->getRegister(), - 'schema' => $object->getSchema(), - ]; - } catch (\Throwable $e) { - $summary['failed'][] = [ - 'object' => $entry, - 'error' => $e->getMessage(), - ]; - $this->logger->warning( - message: '[DSAR] Soft-delete failed during vergetelheid', - context: ['object' => $entry, 'error' => $e->getMessage()] - ); - } + $this->eraseOneEntry( + entry: $entry, + deletionData: $deletionData, + dsarActivityUuid: $dsarActivityUuid, + summary: $summary + ); }//end foreach // BUG-SVC-2: surface partial completion so callers don't treat a run // with failures as a fully-successful erasure. - $summary['complete'] = ($summary['failed'] === []); + // + // A WITHHELD RECORD MAKES THE ERASURE INCOMPLETE, exactly as a failure + // does. The row is still there, so reporting `complete: true` would tell + // the data subject their data is gone while the law keeps it. The two + // counts stay separate because the answers differ: a failure is retried, + // a withheld record is explained. + $summary['complete'] = ($summary['failed'] === [] && $summary['withheld'] === []); $summary['failedCount'] = count($summary['failed']); + $summary['withheldCount'] = count($summary['withheld']); return $summary; }//end eraseObjectsForSubject() + /** + * Erase one matched object, or record why it was not erased. + * + * EVERY ENTRY LANDS IN EXACTLY ONE BUCKET of `$summary`: `erased`, `failed` + * or `withheld`. An entry that reached none of them would be a match the + * caller is never told about. + * + * @param array $entry The deduped match (object_id / object_uuid). + * @param array $deletionData Soft-delete metadata to stamp. + * @param string|null $dsarActivityUuid Configured DSAR activity, when set. + * @param array $summary Run summary, mutated in place. + * + * @return void + */ + private function eraseOneEntry( + array $entry, + array $deletionData, + ?string $dsarActivityUuid, + array &$summary, + ): void { + $object = $this->loadObjectByEntry(entry: $entry); + if ($object === null) { + // BUG-SVC-2: a matched object we cannot load was NOT erased — + // record it as a failure rather than silently skipping it. + $summary['failed'][] = [ + 'object' => $entry, + 'error' => 'Object could not be loaded for erasure', + ]; + $this->logger->warning( + message: '[DSAR] Matched object could not be loaded during vergetelheid', + context: ['object' => $entry] + ); + return; + } + + // RETENTION WINS OVER ERASURE. Vergetelheid is art-17, and art-17(3)(b) + // stands down for processing the law requires: an archival record is + // kept, the refusal is recorded, and it travels back in `withheld` so + // the officer answering the data subject can name it. The refusal is + // per row, so every non-archival match in the same request is still + // erased. + $refusal = $this->archivalGuard->erasureRefusal(object: $object); + if ($refusal !== null) { + $summary['withheld'][] = $refusal; + return; + } + + $object->setDeleted($deletionData); + if ($dsarActivityUuid !== null) { + $object->setProcessingActivityId($dsarActivityUuid); + } + + try { + $this->objectMapper->update(entity: $object); + $summary['erased'][] = [ + 'uuid' => $object->getUuid(), + 'register' => $object->getRegister(), + 'schema' => $object->getSchema(), + ]; + } catch (\Throwable $e) { + $summary['failed'][] = [ + 'object' => $entry, + 'error' => $e->getMessage(), + ]; + $this->logger->warning( + message: '[DSAR] Soft-delete failed during vergetelheid', + context: ['object' => $entry, 'error' => $e->getMessage()] + ); + } + }//end eraseOneEntry() + /** * Build a dedup key for a relation hit row. * 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/File/DeleteFileHandler.php b/lib/Service/File/DeleteFileHandler.php index b815b42384..d7f1c013ec 100644 --- a/lib/Service/File/DeleteFileHandler.php +++ b/lib/Service/File/DeleteFileHandler.php @@ -134,29 +134,4 @@ public function deleteFile(Node|string|int $file, ?ObjectEntity $object = null): return true; }//end deleteFile() - - /** - * Delete multiple files. - * - * @param array $files Array of file nodes, paths, or IDs. - * @param ObjectEntity|null $object Object entity (optional). - * - * @return (Node|bool|int|mixed|string)[][] Array of deletion results. - * - * @psalm-return list - * - * @spec openspec/specs/file-actions/spec.md - */ - public function deleteFiles(array $files, ?ObjectEntity $object = null): array { - $results = []; - foreach ($files as $file) { - try { - $results[] = ['file' => $file, 'success' => $this->deleteFile(file: $file, object: $object)]; - } catch (Exception $e) { - $results[] = ['file' => $file, 'success' => false, 'error' => $e->getMessage()]; - } - } - - return $results; - }//end deleteFiles() }//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/lib/Service/File/FolderManagementHandler.php b/lib/Service/File/FolderManagementHandler.php index 1a9f94e8d2..9644db97ac 100644 --- a/lib/Service/File/FolderManagementHandler.php +++ b/lib/Service/File/FolderManagementHandler.php @@ -30,6 +30,8 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Exception\FolderAccessDeniedException; use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\SystemOperationContext; +use OCP\Files\Config\IUserMountCache; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\Node; @@ -92,6 +94,8 @@ class FolderManagementHandler { * @param IGroupManager $groupManager Group manager for group operations. * @param LoggerInterface $logger Logger for logging operations. * @param AuditTrailMapper $auditTrailMapper Mapper for writing forensic audit-trail entries on folder-access denials. + * @param IUserMountCache $mountCache Mount cache, used to recognise a folder OpenRegister manages + * without setting up the owning user's mounts. * @param FileService|null $fileService File service facade for cross-handler coordination * (injected lazily to avoid circular dependency). */ @@ -103,6 +107,7 @@ public function __construct( private readonly IGroupManager $groupManager, private readonly LoggerInterface $logger, private readonly AuditTrailMapper $auditTrailMapper, + private readonly IUserMountCache $mountCache, private ?FileService $fileService = null, ) { }//end __construct() @@ -856,6 +861,14 @@ private function getExistingFolderFromProperty(?string $folderProperty): ?Folder * callers from the OpenRegister codebase MUST invoke this method as-is; * if a different access-check policy is needed, introduce a new method * rather than overriding this one. + * + * @spec openspec/specs/self-folder-access-control/spec.md + * + * @SuppressWarnings(PHPMD.StaticAccess) SystemOperationContext::isActive is a + * process-scoped flag with no instance to inject; it is deliberately static so + * the scope cannot be entered from request data. Annotated here with its reason + * rather than added to phpmd.baseline.xml, where the same call in + * MultiTenancyTrait and PermissionHandler is invisible. */ final public function assertFolderIsAccessible( string $folderId, @@ -863,6 +876,24 @@ final public function assertFolderIsAccessible( ObjectEntity|string|null $objectEntity = null, ): Folder { $actingUser = $currentUser ?? $this->getCurrentUser(); + + // A system operation has no user by definition, and until now that was + // indistinguishable here from an anonymous request. Both landed on the + // default-deny below, so a repair step or cron job saving an object + // with a bound folder could not run at all: the step caught the denial, + // logged "could not run", and `occ upgrade` still reported success. + // + // The scope is entered from PHP code only (never from request data) — + // the same signal `MultiTenancyTrait::hasRbacPermission()` and + // `PermissionHandler::hasPermission()` already trust for exactly this + // reason. Recognising it resolves the app's OWN principal and then runs + // the SAME four checks below against that principal's mount. The guard + // is not skipped and not widened: an anonymous request still has no + // principal to resolve and still falls through to the denial. + if ($actingUser === null && SystemOperationContext::isActive() === true) { + $actingUser = $this->resolveSystemPrincipal(); + } + if ($actingUser === null) { $this->logFolderAccessDenied( folderId: $folderId, @@ -923,6 +954,179 @@ final public function assertFolderIsAccessible( return $node; }//end assertFolderIsAccessible() + /** + * Resolve the OpenRegister system principal for a sessionless system operation. + * + * Deliberately NOT `getUser()`: that helper prefers the session user and + * only falls back to the system account, which is the right precedence for + * file operations on behalf of a caller and the wrong one here. This method + * is reached only when there is no session user at all, so it asks for the + * app's own account directly. + * + * Returns null rather than throwing when the account cannot be resolved, so + * the caller falls through to its own default-deny instead of replacing a + * denial with a different exception. + * + * @return IUser|null The OpenRegister system account, or null when it cannot be resolved. + * + * @psalm-return IUser|null + * @phpstan-return IUser|null + */ + private function resolveSystemPrincipal(): ?IUser { + if ($this->fileService === null) { + return null; + } + + try { + return $this->fileService->getUser(); + } catch (Exception $e) { + $this->logger->warning( + message: '[FolderManagementHandler] System principal unavailable: ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return null; + } + }//end resolveSystemPrincipal() + + /** + * Re-validate a binding OpenRegister itself stored, rather than one a caller supplied. + * + * `assertFolderIsAccessible()` answers "can the acting user read this node + * through their own mount?", which is the right question for a + * caller-supplied `@self.folder`: the caller names an arbitrary node id and + * must prove they can already reach it, or a bind becomes a cross-tenant + * escape hatch. + * + * It is the WRONG question for a value the app wrote itself. An object + * folder is created under `Open Registers/` in whichever home the creating + * identity had — `getOpenRegisterUserFolder()` resolves the session user + * when there is one — and the compensating share back to other readers is + * still a TODO. So the stored binding lands in ONE user's mount and nobody + * else's, and re-validating it on every save asks every later editor to + * prove they were the creator. Measured: a second user editing a colleague's + * case is refused `folder_access_denied` / 403 on both PUT and PATCH, and a + * repair step is refused the same way. + * + * This method therefore asks the question that fits a stored binding: is + * this a folder OpenRegister manages? Only when the answer is no does the + * full acting-user check run, and a binding that points anywhere outside + * `Open Registers/` is still refused by it — which is exactly the planted + * cross-tenant binding the re-validation was added (PR #1431) to catch, + * since such a binding names a node in a private home. + * + * Known and accepted: a user may create a folder literally named + * `Open Registers` in their own home and bind their own object inside it, + * and that binding then reads as managed. It grants nothing — this method + * decides whether a SAVE proceeds, not whether any file becomes readable; + * file listing goes through the acting user's own mount and still shows + * nothing. Passing the caller-supplied gate to create such a binding + * already required the caller to be able to read that folder. + * + * @param string $folderId The stored numeric folder ID. + * @param IUser|null $currentUser Explicit acting user; falls back to session resolution. + * @param ObjectEntity|string|null $objectEntity Object the binding belongs to (audit context). + * + * @return void Nothing: this is an assertion, not a lookup. Handing back a + * `Folder` the acting user may not read would be the escape + * hatch the guard exists to close. + * + * @throws FolderAccessDeniedException When the binding is neither an OpenRegister-managed + * folder nor readable by the acting user. + * + * @internal `final` for the same reason `assertFolderIsAccessible()` is: the + * default-deny invariant must not be weakenable by a subclass. + * + * @spec openspec/specs/self-folder-access-control/spec.md + */ + final public function assertManagedFolderIsAccessible( + string $folderId, + ?IUser $currentUser = null, + ObjectEntity|string|null $objectEntity = null, + ): void { + // The managed-tree question is asked FIRST, and not only because it is + // the cheaper one. `assertFolderIsAccessible()` writes a + // `folder_access_denied` audit row before every throw, so asking it + // first would stamp a denial into the forensic record for each save + // this method then goes on to ALLOW — an audit trail that reports + // refusals that never happened is worse than no audit at all. + // + // Nothing is lost by the order: a managed folder is accepted either + // way, and anything else still falls through to the full check below, + // which denies and audits exactly as it always did. + if ($this->isManagedFolder(folderId: $folderId) === true) { + return; + } + + $this->assertFolderIsAccessible( + folderId: $folderId, + currentUser: $currentUser, + objectEntity: $objectEntity + ); + }//end assertManagedFolderIsAccessible() + + /** + * Whether a folder id names a folder OpenRegister manages. + * + * Answered from the mount cache, NOT from `IRootFolder::getById()`. The + * root-folder lookup returns nothing here: it needs the owning user's + * mounts to have been set up, and the whole reason this method exists is + * that the owner is somebody other than the acting user. Measured on the + * rig — `getById()` on a folder that demonstrably exists answered `0`. + * + * `IUserMountCache::getMountsForFileId()` reads the cache directly, so it + * resolves the node's home and internal path without mounting anything and + * without granting the acting user any view of it. Nothing is returned: + * this is a predicate, not a lookup, and handing back a `Folder` the caller + * has no business reading would be exactly the escape hatch the guard + * exists to close. + * + * @param string $folderId The stored numeric folder ID. + * + * @return bool True when the id names a folder inside an `Open Registers` tree. + */ + private function isManagedFolder(string $folderId): bool { + if (ctype_digit($folderId) === false) { + return false; + } + + try { + $mounts = $this->mountCache->getMountsForFileId((int)$folderId); + } catch (Exception $e) { + $this->logger->debug( + message: '[FolderManagementHandler] Managed-folder lookup failed for ' . $folderId . ': ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + return false; + } + + foreach ($mounts as $mount) { + if ($this->isManagedFolderPath(path: $mount->getPath()) === true) { + return true; + } + } + + return false; + }//end isManagedFolder() + + /** + * Whether a node path lies inside an OpenRegister-managed folder tree. + * + * Nextcloud node paths are `//files/`, so a + * managed folder is `//files/Open Registers` or anything beneath it. + * The `` is deliberately unconstrained: object folders are created in + * whichever home the creating identity had, so pinning it to the system + * account would reject every folder created over HTTP. + * + * @param string $path The node path to test. + * + * @return bool True when the path is inside an `Open Registers` tree. + */ + private function isManagedFolderPath(string $path): bool { + $pattern = '#^/[^/]+/files/' . preg_quote(self::ROOT_FOLDER, '#') . '(/|$)#'; + + return preg_match($pattern, $path) === 1; + }//end isManagedFolderPath() + /** * Write a `folder_access_denied` entry to the audit trail. * diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 68db363378..fe662b580a 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -872,23 +872,21 @@ public function getObjectFolder(ObjectEntity|string $objectEntity, int|string|nu * saves that don't touch `@self.folder` because `setSelfMetadata` * only fires when `@self.folder` is present in the write payload. * - * Re-running `assertFolderIsAccessible` on every save through - * `ensureObjectFolder` makes the check apply uniformly. A - * denial throws `FolderAccessDeniedException` (HTTP 403) at the - * controller layer. + * Re-running the check on every save through `ensureObjectFolder` + * makes it apply uniformly. A denial throws + * `FolderAccessDeniedException` (HTTP 403) at the controller layer. * * No-op when the object has no folder bound or the bound value * is non-numeric (legacy path-style folder, handled by the * auto-create branch in ensureObjectFolder). * * **Acting user resolution.** `$currentUser` is forwarded verbatim - * to `assertFolderIsAccessible` and follows that method's - * documented precedence: when non-null it is used as-is, otherwise - * `IUserSession::getUser()` is consulted, otherwise the bind is - * denied. Non-HTTP callers (cron, import pipelines, event listeners) - * MUST pass an explicit `$currentUser` to avoid the - * session-user-is-null → default-deny path; HTTP callers can omit - * the argument and rely on session resolution. + * and follows the documented precedence: when non-null it is used + * as-is, otherwise `IUserSession::getUser()` is consulted, otherwise + * the app's own principal is resolved when a system-operation scope + * is active. Non-HTTP callers SHOULD still pass an explicit + * `$currentUser` where they have one, so the check runs against the + * identity the work is being done for rather than the app account. * * @param ObjectEntity $object The existing object whose folder must be re-validated. * @param IUser|null $currentUser Explicit acting user; falls back to session resolution. @@ -896,6 +894,8 @@ public function getObjectFolder(ObjectEntity|string $objectEntity, int|string|nu * @return void * * @throws \OCA\OpenRegister\Exception\FolderAccessDeniedException When the acting user cannot access the bound folder. + * + * @spec openspec/specs/self-folder-access-control/spec.md */ public function assertObjectFolderAccessible(ObjectEntity $object, ?IUser $currentUser = null): void { $folder = $object->getFolder(); @@ -913,7 +913,16 @@ public function assertObjectFolderAccessible(ObjectEntity $object, ?IUser $curre return; } - $this->folderManagementHandler->assertFolderIsAccessible( + // `assertManagedFolderIsAccessible`, NOT `assertFolderIsAccessible`. The + // value being re-validated was written by OpenRegister, not supplied by + // this caller, so the question is whether it is a folder OpenRegister + // manages — not whether this particular user happens to be the one who + // created it. Asking the caller-supplied question here refused every + // later editor of a colleague's object and every sessionless repair + // step. The acting user's own mount is still tried first, and a binding + // pointing outside `Open Registers/` is still refused; see that method + // for the full rationale and the accepted residual case. + $this->folderManagementHandler->assertManagedFolderIsAccessible( folderId: (string)$folder, currentUser: $currentUser, objectEntity: $object diff --git a/lib/Service/Flow/EventCatalogService.php b/lib/Service/Flow/EventCatalogService.php index c768de62c6..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 @@ -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/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/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 ab8ae72735..3838482c68 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. * @@ -123,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, @@ -131,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() @@ -291,6 +313,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 +340,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 +368,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) { @@ -441,6 +490,9 @@ public function run( 'durationMs' => 0, ]; + // An exit-id tag becomes its place BEFORE routing, so the + // exit choice and the per-place delivery read the same name. + $items = $this->router()->resolveOutputTags(flow: $flow, transition: $transition, items: $items); $taken = $this->router()->takenExits(flow: $flow, transition: $transition, items: $items, context: $context); $placeItems = $this->placement()->advanceItems( transition: $transition, @@ -449,11 +501,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 @@ -486,7 +539,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 +554,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 +616,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, @@ -586,6 +660,12 @@ public function run( $this->runContext?->pop(); }//end try + // A routing step tags items with the exit id its rule named; + // resolve those to the exit's PLACE first, so the choice below and + // the per-place delivery in advanceItems() read the same name — + // unresolved, a routed branch fired with zero items. + $items = $this->router()->resolveOutputTags(flow: $flow, transition: $transition, items: $items); + // Which single exit this firing takes. A token is unique and // exclusive, so a branching node hands it to exactly one successor. $taken = $this->router()->takenExits(flow: $flow, transition: $transition, items: $items, context: $context); @@ -607,16 +687,481 @@ 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 + * 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, + // `settling`: this stream is the one parking, and the walk + // does not know that yet — its token still enables the very + // transition it is waiting ON. Counted, the park derives + // `queued` with no wake time, which a parallel worker picks + // up immediately. + enabled: $streams->workRemains( + transitions: $workflow->getEnabledTransitions(subject: $subject), + settling: $streamId + ) + ); + 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. + // + // `failed`, not `stopped` — the same distinction + // {@see self::outcomeForFailedStep()} explains for the + // single-stream walk, and it has to be made in BOTH places + // or which walk happened to run decides whether a wreck is + // queryable. The stream row already carried the message; + // the RUN row is what a query reads, so the envelope + // carries it too and `persistResult()` puts it on `error`. + $terminal = self::STATUS_FAILED; + 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, + 'error' => $e->getMessage(), + ]; + } + + // `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(); + + // Same tag resolution as the single-stream walk: an exit-id tag + // becomes its place before the exit choice and the item delivery. + $items = $this->router()->resolveOutputTags(flow: $flow, transition: $transition, items: $items); + $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->pruneUntakenExits(workflow: $workflow, subject: $subject, transition: $transition, taken: $taken, context: $context); + + // 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, + // The places this firing TAKES, handed over so the answer describes + // the marking AFTER the commit rather than the one the walk still + // holds: the stream picture is only re-read inside commitFiring(). + // Without them every ordinary mid-flow firing reported "no work + // remains", the commit derived `completed` for a run that was still + // walking, and every terminal listener fired on it. + enabledAfter: $streams->workRemains( + transitions: $workflow->getEnabledTransitions(subject: $subject), + produced: $takenTos + ), + 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". * @@ -648,6 +1193,25 @@ private function withStartNode(array $flow, ?string $startAt): array { * policy so a typo fails safe), or null when the policy is `continue` and * the walk should go on. The failure is logged either way. * + * 🔴 THE POLICY SAYS WHETHER TO END THE RUN; IT DOES NOT SAY THE RUN ENDED + * WELL. `onError: stop` used to return `stopped` with no `error`, which is + * byte-for-byte what a deliberate Stop node returns — so a run killed by a + * broken step and a run an author ended on purpose were the same row. The + * only trace of the difference was the last entry of the JSON `log`, which + * no query reaches: `SELECT ... WHERE status = 'stopped'` cannot tell a + * healthy guard branch from a wreck. Measured on dossiq's shipped `Case + * behandeling` flow, where nine runs died on `status_not_found_on_case_type` + * and every one of them read as a clean end. + * + * So a step failure ends the run as `failed`, with the step's message on the + * `error` column. Both were already in the vocabulary and used elsewhere + * ({@see FlowRun::STATUS_FAILED}, ranked most-severe in + * {@see FlowRunCommit::SEVERITY}); this path simply was not reaching for + * them. `stopped` goes back to meaning what {@see SubFlowNode::itemsFrom()} + * already documents it to mean — a deliberate, successful end — which also + * stops a sub-flow whose step failed from handing its stale items to its + * parent as though it had finished. + * * @param array $step The step configuration. * @param Throwable $error The failure. * @param string $name The transition name. @@ -684,16 +1248,50 @@ private function outcomeForFailedStep( ); if ($policy === self::ON_ERROR_DEAD_LETTER) { - return ['status' => self::STATUS_DEAD_LETTER, 'log' => $log, 'context' => $context, 'items' => $items]; + return [ + 'status' => self::STATUS_DEAD_LETTER, + 'log' => $log, + 'context' => $context, + 'items' => $items, + 'error' => $error->getMessage(), + ]; } if ($policy !== self::ON_ERROR_CONTINUE) { - return ['status' => self::STATUS_STOPPED, 'log' => $log, 'context' => $context, 'items' => $items]; + return [ + 'status' => self::STATUS_FAILED, + 'log' => $log, + 'context' => $context, + 'items' => $items, + 'error' => $error->getMessage(), + ]; } 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/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/FlowItems.php b/lib/Service/Flow/FlowItems.php index fc90bdf903..2c1ccad781 100644 --- a/lib/Service/Flow/FlowItems.php +++ b/lib/Service/Flow/FlowItems.php @@ -209,6 +209,93 @@ public static function fromSubject(object $subject): array { return [self::item(json: $json)]; }//end fromSubject() + /** + * Refresh the subject's own fields on every item that IS the subject. + * + * The seam between two requirements that are compatible but easy to + * conflate. Items survive the pause (flow-runs REQ-FR-003): resuming + * continues from the stored list, never a fresh seed, so everything the + * earlier steps produced is kept. But an item that is the run's SUBJECT + * began as a trigger-time snapshot of it, and a step that branches on a + * subject field after a human answered — "is the description filled in + * now?" — must read the subject as it stands, not as it stood when the + * run started. Without this, the shipped re-ask loop can never succeed: + * the answer lands on the object, the re-check reads the frozen snapshot, + * and the run strands. + * + * So: only items carrying the subject's identity are touched, and on + * those the live subject's serialised fields are merged OVER the stale + * snapshot. A key the live subject does not serialise — a task outcome + * bag, a step's computed field — survives untouched, which is what keeps + * REQ-FR-003 true. An item that is some OTHER object (a fan-out read) + * carries its own identity and is never smeared with the subject's data. + * + * @param array> $items The stored items. + * @param object $subject The live subject. + * @param string $subjectUuid The run's subject uuid, matching items by identity. + * + * @return array> The items, subject fields refreshed. + * + * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md#requirement-resuming-carries-the-runs-own-items-req-fr-003 + */ + public static function refreshSubjectProjection(array $items, object $subject, string $subjectUuid): array { + $subjectUuid = trim($subjectUuid); + if ($subjectUuid === '' || $items === []) { + return $items; + } + + $live = (array)(self::fromSubject(subject: $subject)[0][self::JSON] ?? []); + if ($live === []) { + return $items; + } + + foreach ($items as $index => $item) { + if (is_array($item) === false) { + continue; + } + + $json = (array)($item[self::JSON] ?? []); + if (self::identityOf(json: $json) !== $subjectUuid) { + continue; + } + + // Live wins on the subject's own keys; step-produced keys the + // subject does not serialise are kept as the run carried them. + $items[$index][self::JSON] = array_merge($json, $live); + } + + return $items; + }//end refreshSubjectProjection() + + /** + * The object identity an item's record carries, if any. + * + * A subject-seeded item serialises its uuid as `@self.id` mirrored at the + * top-level `id`; older shapes carried a flat `uuid`. An item with none of + * these has no object identity and can never be "the subject". + * + * @param array $json The item's record. + * + * @return string The identity, or '' when the item carries none. + * + * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md#requirement-resuming-carries-the-runs-own-items-req-fr-003 + */ + private static function identityOf(array $json): string { + $self = ($json['@self'] ?? null); + if (is_array($self) === true && trim((string)($self['id'] ?? '')) !== '') { + return trim((string)$self['id']); + } + + foreach (['id', 'uuid'] as $key) { + $value = ($json[$key] ?? null); + if (is_string($value) === true && trim($value) !== '') { + return trim($value); + } + } + + return ''; + }//end identityOf() + /** * Read an already-set `pairedItem` index, falling back when absent. * diff --git a/lib/Service/Flow/FlowLocator.php b/lib/Service/Flow/FlowLocator.php index b298ba2baf..deeb51760d 100644 --- a/lib/Service/Flow/FlowLocator.php +++ b/lib/Service/Flow/FlowLocator.php @@ -36,6 +36,7 @@ use OCA\OpenRegister\Db\Flow; use OCA\OpenRegister\Db\FlowMapper; use OCA\OpenRegister\Db\FlowTriggerMapper; +use OCA\OpenRegister\Db\FlowVersionMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Service\ObjectService; use Psr\Log\LoggerInterface; @@ -66,12 +67,14 @@ class FlowLocator { * @param FlowTriggerMapper $triggerMapper The node-derived trigger index. * @param ObjectService $objectService Loads the object a run is about. * @param LoggerInterface $logger Records refusals and failures. + * @param FlowVersionMapper $versions Answers whether a fallback-matched flow has a published version. */ public function __construct( private readonly FlowMapper $mapper, private readonly FlowTriggerMapper $triggerMapper, private readonly ObjectService $objectService, private readonly LoggerInterface $logger, + private readonly FlowVersionMapper $versions, ) { }//end __construct() @@ -221,19 +224,10 @@ public function flowsForTrigger(string $event, string $register, string $schema) return []; } - // The column match is kept ONLY for flows the index does not represent. - // A converted flow's columns are stale by construction — they hold one - // trigger where its nodes may hold several — so consulting them for a - // converted flow would resurrect a subscription its author removed. - $matched = []; - foreach ($candidates as $flow) { - $uuid = (string)$flow->getUuid(); - if (isset($converted[$uuid]) === true) { - continue; - } - - $matched[$uuid] = $flow; - } + // The column match is kept ONLY for unconverted flows, and among those + // only for flows a published version can back — see the docblock of + // {@see self::columnFallbackMatches()} for both halves of that rule. + $matched = $this->columnFallbackMatches(candidates: $candidates, converted: $converted, event: $event); // The index answers in UUIDs; the dispatch check needs the row. These // are the flows whose NODES want this event. @@ -259,6 +253,85 @@ public function flowsForTrigger(string $event, string $register, string $schema) return $this->dispatchableUuids(matched: $matched, event: $event); }//end flowsForTrigger() + /** + * The column-fallback matches: unconverted flows a published version can back. + * + * Two rules, one per failure mode this fallback has produced: + * + * - ONLY flows the index does not represent. A converted flow's columns + * are stale by construction — they hold one trigger where its nodes may + * hold several — so consulting them would resurrect a subscription its + * author removed. + * - 🔴 ONLY flows with a published version (flow-definition-versioning: + * a draft's trigger nodes SHALL NOT match anything). The index enforces + * that by derivation from the published graph, but the fallback did not: + * an enabled flow that was never published has zero index rows, so it is + * "unrepresented", its columns match, and `queue()` then refuses it with + * "no published version" — a refusal that used to abort the whole + * fan-out for every healthy flow on the same event. Refused OUT LOUD + * here instead. Legacy flows all hold a backfilled published version 1, + * so the only thing filtered is a flow that could never have run. + * + * @param array $candidates The flows whose columns match the event. + * @param array $converted Uuids the index represents, flipped for lookup. + * @param string $event The trigger being resolved, for the log line. + * + * @return array The matches, by uuid. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md#requirement-trigger-matching-answers-which-flow-the-queue-path-answers-which-version + */ + private function columnFallbackMatches(array $candidates, array $converted, string $event): array { + $matched = []; + foreach ($candidates as $flow) { + $uuid = (string)$flow->getUuid(); + if (isset($converted[$uuid]) === true) { + continue; + } + + if ($this->hasPublishedVersion(uuid: $uuid) === false) { + $this->logger->warning( + message: '[FlowLocator] Flow "' . $uuid . '" matched trigger "' . $event + . '" through its columns but has no published version, so it cannot back a run.' + . ' Publish a version to make it fire.', + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $uuid] + ); + continue; + } + + $matched[$uuid] = $flow; + }//end foreach + + return $matched; + }//end columnFallbackMatches() + + /** + * Whether a flow has a published version to back a run. + * + * FAIL OPEN on an unreadable version table: silencing every fallback flow + * because one lookup failed would stop the engine without a word, and + * `FlowRunVersionPin` re-checks the same fact at queue time anyway — this + * filter only decides whether a flow is worth OFFERING to the queue. + * + * @param string $uuid The flow's uuid. + * + * @return bool True when a published version exists, or when the answer is unknowable. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md#requirement-trigger-matching-answers-which-flow-the-queue-path-answers-which-version + */ + private function hasPublishedVersion(string $uuid): bool { + try { + return $this->versions->findPublished(flowUuid: $uuid) !== null; + } catch (Throwable $e) { + $this->logger->warning( + message: '[FlowLocator] Could not read the published version of flow "' . $uuid . '": ' + . $e->getMessage() . '. Keeping it in the match; the queue path decides.', + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $uuid] + ); + + return true; + } + }//end hasPublishedVersion() + /** * The uuids of the matched flows that can actually be dispatched. * diff --git a/lib/Service/Flow/FlowMessagingService.php b/lib/Service/Flow/FlowMessagingService.php new file mode 100644 index 0000000000..0c2467c3a1 --- /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[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.' + ); + } + + $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/FlowNodeRegistry.php b/lib/Service/Flow/FlowNodeRegistry.php index bc4889cfce..036df05b22 100644 --- a/lib/Service/Flow/FlowNodeRegistry.php +++ b/lib/Service/Flow/FlowNodeRegistry.php @@ -32,6 +32,7 @@ namespace OCA\OpenRegister\Service\Flow; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IURLGenerator; use OCP\WorkflowEngine\IManager; use Psr\Log\LoggerInterface; use UnexpectedValueException; @@ -108,10 +109,17 @@ public static function renamedTypes(): array { * * @param IEventDispatcher $dispatcher Dispatches the contribution event. * @param LoggerInterface $logger The logger. + * @param IURLGenerator|null $urls Resolves the fallback icon for a node + * whose own icon does not resolve. Optional + * so the registry stays constructible + * without a container; absent, such a node + * is served with no icon rather than being + * dropped. */ public function __construct( private readonly IEventDispatcher $dispatcher, private readonly LoggerInterface $logger, + private readonly ?IURLGenerator $urls = null, ) { }//end __construct() @@ -204,7 +212,7 @@ public function palette(int $scope = IManager::SCOPE_ADMIN): array { 'id' => $id, 'displayName' => $node->getDisplayName(), 'description' => $node->getDescription(), - 'icon' => $node->getIcon(), + 'icon' => $this->iconFor(node: $node, type: $id), // ALWAYS present, and always one of trigger/step/end. An // editor that had to infer this fell back to matching the // id against a naming convention, which mis-labels every @@ -250,12 +258,21 @@ public function palette(int $scope = IManager::SCOPE_ADMIN): array { $palette[] = $entry; } catch (\Throwable $e) { - // One node whose metadata throws (a missing icon, a broken - // translation) must not blank the whole palette — the author - // would lose every node, not just the bad one. - $this->logger->warning( - message: '[FlowNodeRegistry] Skipping a node whose palette metadata failed: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__, 'type' => $id] + // One node whose metadata throws must not blank the whole + // palette — the author would lose every node, not just the bad + // one. A DROPPED NODE IS NOT A COSMETIC PROBLEM, though: it + // cannot be added to a flow at all, from anywhere, and the + // author is given no reason. So this is an error naming the + // type, not a warning, and the commonest cause — an icon the + // server does not ship — no longer reaches here at all + // ({@see self::iconFor()}). + $this->logger->error( + message: sprintf( + '[FlowNodeRegistry] DROPPED the node "%s" from the palette: its metadata threw (%s). It cannot be added to a flow until this is fixed.', + $id, + $e->getMessage() + ), + context: ['file' => __FILE__, 'line' => __LINE__, 'type' => $id, 'exception' => $e] ); }//end try }//end foreach @@ -263,6 +280,53 @@ public function palette(int $scope = IManager::SCOPE_ADMIN): array { return $palette; }//end palette() + /** + * A node's icon, or the app's own when the node's does not resolve. + * + * 🔴 A MISSING ICON USED TO DELETE THE NODE. `IURLGenerator::imagePath()` + * throws for an image the server does not ship, `palette()` caught that + * along with everything else, and the node simply was not in the + * catalogue: `openregister.lock-object` and `openregister.unlock-object` + * shipped pointing at `actions/lock.svg` and `actions/unlock.svg`, which + * NEITHER NC 33 NOR NC 34 has, so neither node could be added to a flow + * from the editor at all. Nothing failed; they were absent. Core's icon + * set is not a stable API and the next node to name a retired icon would + * have vanished the same way. + * + * So an icon is now resolved on its own, an unresolvable one is an ERROR + * naming the type and the icon, and the node is served with the app's icon + * instead of being dropped. A node the author can see and place with the + * wrong picture is strictly better than a node that does not exist. + * + * @param IFlowNode $node The node. + * @param string $type Its type id, for the message. + * + * @return string|null The icon path, the app's icon, or null when neither resolves. + * + * @spec openspec/changes/or-flow-nodes/specs/flow-nodes/spec.md + */ + private function iconFor(IFlowNode $node, string $type): ?string { + try { + return $node->getIcon(); + } catch (\Throwable $missing) { + $this->logger->error( + message: sprintf( + '[FlowNodeRegistry] The node "%s" names an icon this server does not have (%s); ' + . 'it is served with the app icon instead. Point it at an image that exists.', + $type, + $missing->getMessage() + ), + context: ['file' => __FILE__, 'line' => __LINE__, 'type' => $type, 'exception' => $missing] + ); + } + + try { + return $this->urls?->imagePath('openregister', 'app-dark.svg'); + } catch (\Throwable $noFallback) { + return null; + } + }//end iconFor() + /** * The links one run-log entry earns, from the node that wrote it. * 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/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/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/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/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/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/FlowRunContext.php b/lib/Service/Flow/FlowRunContext.php index cc34fa3540..39d3a6d4b3 100644 --- a/lib/Service/Flow/FlowRunContext.php +++ b/lib/Service/Flow/FlowRunContext.php @@ -160,6 +160,40 @@ public function current(): ?array { return $this->frames[(count($this->frames) - 1)]; }//end current() + /** + * The uuid of the run currently executing, or null outside any run. + * + * WHY A WRITE GUARD ASKS THIS. A run-scoped lock refuses every caller but + * the holding run — the run's own `runAs` user included, which is the + * point. A guard that cannot name the caller's run therefore refuses the + * run that took the lock: the flow locks a case and is then refused by its + * own lock at the next write. This is the seam that answers "which run is + * writing", and it is ambient for the same reason attribution is: the + * write may be several calls deep inside a leaf app that has never heard + * of flows. + * + * Null outside a run, and null for a hop that is not attributable. Both + * read as "a person is writing", which is the FAIL-CLOSED direction: a run + * lock refuses a caller with no run uuid. + * + * @return string|null The executing run's uuid, or null. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-ownership-is-decided-by-one-predicate + */ + public function currentRunUuid(): ?string { + $frame = $this->current(); + if ($frame === null) { + return null; + } + + $run = trim((string)$frame['run']); + if ($run === '') { + return null; + } + + return $run; + }//end currentRunUuid() + /** * How deep the stack is. Test and diagnostic use only. * 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 78e6ac01f8..43346f0f82 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,14 @@ * 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. + * @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 { /** @@ -77,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. * @@ -103,6 +145,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 +164,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 +192,152 @@ 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); + } + + // 🔴 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; + + // Resume-after-human-answer must see the subject as it stands: the + // answer the task asked for landed on the OBJECT, and the stored items + // still hold the trigger-time snapshot of it. + $this->refreshSubjectItems(run: $run, subject: $subject); + + $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, scope: $this->identityScope()), + 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. * @@ -237,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() @@ -282,6 +479,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}. @@ -293,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() + /** @@ -596,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. * @@ -663,6 +905,12 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed $flow = $pinned; $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 refreshSubjectItems(). + $this->refreshSubjectItems(run: $run, subject: $subject); + } + $run->setStatus(FlowRun::STATUS_RUNNING); $run->setUpdated(new DateTime()); $this->mapper->update($run); @@ -680,18 +928,21 @@ 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( 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 + 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 @@ -713,6 +964,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. @@ -731,6 +983,60 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed return $this->persistResult(run: $run, result: $result); }//end execute() + /** + * Refresh the subject's fields on a resumed run's stored items, in place. + * + * Both stores are refreshed because both are read on resume: the flat + * `items` list feeds a legacy walk, and the per-place buffers are what + * {@see FlowItemPlacement::seedPlaceItems()} prefers — the check node that + * re-enters after a human answered reads ITS input place's buffer, and a + * refresh that missed it would leave the stale snapshot exactly where the + * branch decision is made. + * + * In place and before the walk's first persist, so the commit path — which + * re-reads the run row under its lock — sees the refreshed buffers too. + * + * A subjectless run, or one whose stored items never carried the subject, + * is untouched: {@see FlowItems::refreshSubjectProjection()} matches by + * identity and only ever touches the item that IS the subject. + * + * @param FlowRun $run The resumed run, mutated in place. + * @param object $subject The live subject, as the advancer just resolved it. + * + * @return void + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowItems::refreshSubjectProjection + * is a pure function on value shapes; a factory to call it would add a + * dependency to say the same thing. + * + * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md#requirement-resuming-carries-the-runs-own-items-req-fr-003 + */ + private function refreshSubjectItems(FlowRun $run, object $subject): void { + $subjectUuid = trim((string)$run->getSubjectUuid()); + if ($subjectUuid === '') { + return; + } + + $run->setItems(FlowItems::refreshSubjectProjection( + items: ($run->getItems() ?? []), + subject: $subject, + subjectUuid: $subjectUuid + )); + + $stored = $run->getPlaceItems(); + if (is_array($stored) === true && $stored !== []) { + foreach ($stored as $place => $bucket) { + $stored[$place] = FlowItems::refreshSubjectProjection( + items: (array)$bucket, + subject: $subject, + subjectUuid: $subjectUuid + ); + } + + $run->setPlaceItems($stored); + } + }//end refreshSubjectItems() + /** * Fail a run whose pinned version cannot be resolved. * @@ -785,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); @@ -835,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 @@ -868,6 +1168,11 @@ private function persistResult(FlowRun $run, array $result): FlowRun { $run->setResumeAt($resumeAt); + // The correlation key mirrors `resumeAt`: meaningful only while + // suspended, cleared on every other outcome so a finished run can + // never be the addressee of a business-key signal (design D-7). + $run->setCorrelationKey($this->correlationKeyFrom(context: $context, suspended: ($status === FlowRun::STATUS_SUSPENDED))); + $persisted = $this->mapper->update($run); $this->recordLastRun(run: $persisted, status: $status); @@ -875,6 +1180,90 @@ 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. + * + * Read off the STORED resume-state slots the walk left behind: an + * await-signal step that declared a `correlationKey` resolved it at + * suspension and stamped it into its node slot. Copying it onto the + * run's indexed column here is what makes delivery an index lookup + * instead of a JSON scan (flow-approval-consolidation design D-7). The + * first non-empty key wins; a run holding two suspended await steps with + * keys is addressed by the earlier node's key. + * + * @param array $context The context as it will be persisted. + * @param bool $suspended Whether the run is suspending. + * + * @return string|null The key, or null when not suspended or none is set. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-signal-node-keeps-machine-to-machine-work-and-gains-a-correlation-key + */ + private function correlationKeyFrom(array $context, bool $suspended): ?string { + if ($suspended === false) { + return null; + } + + $byNode = ($context[FlowResumeState::CONTEXT_KEY] ?? null); + if (is_array($byNode) === false) { + return null; + } + + foreach ($byNode as $slot) { + if (is_array($slot) === false) { + continue; + } + + $key = trim((string)($slot['correlationKey'] ?? '')); + if ($key !== '') { + return $key; + } + } + + return null; + }//end correlationKeyFrom() + /** * Copy a finished run's outcome onto its flow. * 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/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/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/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/FlowStreamWalk.php b/lib/Service/Flow/FlowStreamWalk.php new file mode 100644 index 0000000000..5c74c72f98 --- /dev/null +++ b/lib/Service/Flow/FlowStreamWalk.php @@ -0,0 +1,664 @@ + + * 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. + * + * 🔴 THE ANSWER MUST DESCRIBE THE STATE AFTER THE THING BEING COMMITTED, + * not the state the walk still holds in memory. It is called as an + * ARGUMENT to `commitFiring()` and to `park()`, and both of those are what + * move the stream — so at the moment this runs, `$this->streams` still has + * the firing stream on the place the firing just consumed and `$this->parked` + * does not yet know about the stream that is parking. Answering from that + * stale picture said "no work remains" at EVERY ordinary mid-flow firing, + * `FlowRunCommit::applyDerivedStatus()` then derived `completed` for a run + * that was still working, `FlowRunMapper::update()` announced it, and + * `FlowRunLockReleaseListener` released the run's object locks — the + * defect this parameter pair exists to close. `$produced` and `$settling` + * are how the caller says what its commit is about to change. + * + * @param array $transitions The enabled transitions. + * @param array $produced The places the commit is about to put tokens on. + * @param string|null $settling A stream whose current place is about to stop counting: it is parking, or ending. + * + * @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, array $produced = [], ?string $settling = null): bool { + $live = $this->livePlacesAfter(produced: $produced, settling: $settling); + + foreach ($transitions as $transition) { + foreach ($transition->getFroms() as $from) { + if (isset($live[(string)$from]) === true) { + return true; + } + } + } + + return false; + }//end workRemains() + + /** + * The places a token will sit on once the caller's commit lands, excluding + * the parked streams' — see {@see self::workRemains()} for why the answer + * cannot be read off the walk's own picture. + * + * @param array $produced The places the commit is about to mark. + * @param string|null $settling A stream whose place stops counting. + * + * @return array The places, as a set. + * + * @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 livePlacesAfter(array $produced, ?string $settling): array { + $live = []; + foreach ($this->streams as $id => $stream) { + if ($id === $settling || isset($this->parked[$id]) === true) { + continue; + } + + if ($stream['place'] !== null) { + $live[$stream['place']] = true; + } + } + + foreach ($produced as $place) { + $live[(string)$place] = true; + } + + return $live; + }//end livePlacesAfter() + + /** + * 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/lib/Service/Flow/FlowTaskBridge.php b/lib/Service/Flow/FlowTaskBridge.php new file mode 100644 index 0000000000..da5d410280 --- /dev/null +++ b/lib/Service/Flow/FlowTaskBridge.php @@ -0,0 +1,513 @@ + + * 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() + + /** + * 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() + + /** + * 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. + * + * @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). + * + * 🔴 `answers` IS PART OF THE BAG, and was not. A step may declare a form; + * the values are validated against the subject schema and written to + * `task.responses`. Every hop of that had a test — the declaration is + * refused if it names a field the schema lacks, the form is rendered, the + * values are validated on completion, the row is stored. None of them + * asked whether a LATER STEP could read the value, and none could: this + * bag omitted the key while {@see PortalTaskNode} has placed one since it + * was written. So every answer a person typed into a user-task form was + * collected and then discarded. + * + * Placed HERE rather than in each node, so the two callers cannot drift + * apart again — the asymmetry was the whole defect. + * + * ALWAYS PRESENT, sometimes empty. A missing key would make a downstream + * `answers.reason` fail one way when the performer skipped the form and + * another way when the step declared none, which teaches every author to + * write two guards for one question. + * + * @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(), + 'answers' => ($task->getResponses() ?? []), + '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/FlowTokenRouter.php b/lib/Service/Flow/FlowTokenRouter.php index 17a5cee1ab..ed0f27ff7a 100644 --- a/lib/Service/Flow/FlowTokenRouter.php +++ b/lib/Service/Flow/FlowTokenRouter.php @@ -130,6 +130,18 @@ public function takenExits(array $flow, object $transition, array $items, array itemCount: count($items), context: $context ); + + // 🔴 THE STEP'S OWN ROUTING DECISION COUNTS. A routing node (the + // `openregister.route` step) declares UNCONDITIONED exits and tags its + // items with the output it matched — that tag IS the decision. Reading + // only edge conditions here meant every routed firing fell through to + // the first declared exit: the token took a branch by declaration + // order while the node's log said it had routed the other way. + // A conditioned exit that holds still wins (a Switch is unchanged); + // among unconditioned exits, one an item is tagged for — by exit id or + // by target place — beats the plain declaration-order else. + $tags = $this->routedTags(items: $items); + $tagged = null; $fallback = null; foreach ($node['exits'] as $exit) { @@ -137,10 +149,11 @@ public function takenExits(array $flow, object $transition, array $items, array continue; } + $exitId = (string)($exit['id'] ?? ''); $targets = $this->placesForExit( flow: $flow, nodeId: $transition->getName(), - exitId: (string)($exit['id'] ?? ''), + exitId: $exitId, candidates: $all ); if (empty($targets) === true) { @@ -149,6 +162,10 @@ public function takenExits(array $flow, object $transition, array $items, array $condition = ($exit['condition'] ?? null); if (is_array($condition) === false || $condition === []) { + if ($this->exitIsTagged(exitId: $exitId, targets: $targets, tags: $tags) === true) { + $tagged = ($tagged ?? $targets); + } + $fallback = ($fallback ?? $targets); continue; } @@ -158,13 +175,106 @@ public function takenExits(array $flow, object $transition, array $items, array } }//end foreach - // Nothing matched. The else takes it; a branching node is required to - // have one, so reaching null here means the document got past the - // builder's guard and the token would vanish — return nothing rather - // than silently broadcasting to every branch. - return ($fallback ?? []); + // Nothing matched a condition. An exit the items are routed to beats + // the declaration-order else; the else takes it otherwise. A branching + // node is required to have one, so reaching null here means the + // document got past the builder's guard and the token would vanish — + // return nothing rather than silently broadcasting to every branch. + return ($tagged ?? $fallback ?? []); }//end takenExits() + /** + * The set of output tags the produced items carry, keyed for lookup. + * + * @param array $items What the step produced. + * + * @return array The tags. + * + * @spec openspec/changes/or-flow-per-item-routing/specs/flow-per-item-routing/spec.md + */ + private function routedTags(array $items): array { + $tags = []; + foreach ($items as $item) { + $tag = FlowItems::outputOf(member: (array)$item); + if ($tag !== null) { + $tags[$tag] = true; + } + } + + return $tags; + }//end routedTags() + + /** + * Whether any produced item is routed to this exit, by id or by place. + * + * @param string $exitId The exit's id. + * @param array $targets The exit's places. + * @param array $tags The items' output tags. + * + * @return bool True when an item names the exit or one of its places. + * + * @spec openspec/changes/or-flow-per-item-routing/specs/flow-per-item-routing/spec.md + */ + private function exitIsTagged(string $exitId, array $targets, array $tags): bool { + return array_intersect(array_merge([$exitId], $targets), array_keys($tags)) !== []; + }//end exitIsTagged() + + /** + * Resolve item output tags that name an EXIT into the exit's place. + * + * The two halves of per-item routing speak different vocabularies unless + * this runs: a routing node tags items with the exit id its rule named + * (`output: "no"`), while `FlowItemPlacement::itemsForOutput()` matches + * tags against PLACE names (`onRejected`). Left unresolved, every routed + * item was silently discarded when the token landed — the branch fired + * with zero items and the run persisted `items: []`, losing the seed and + * everything the earlier steps produced. + * + * A tag that already names one of the transition's places is left alone. + * A tag naming an exit with exactly one place becomes that place; one + * naming an exit with several places is removed, because the whole exit + * was taken and an untagged item is broadcast to every taken place. A tag + * naming neither is left as-is, keeping the existing drop semantics for a + * genuinely unroutable tag. + * + * @param array $flow The flow document. + * @param object $transition The fired transition (a node). + * @param array $items What the step produced. + * + * @return array The items, exit-id tags resolved to places. + * + * @spec openspec/changes/or-flow-per-item-routing/specs/flow-per-item-routing/spec.md + */ + public function resolveOutputTags(array $flow, object $transition, array $items): array { + $all = array_map(static fn ($t): string => (string)$t, $transition->getTos()); + + foreach ($items as $index => $item) { + if (is_array($item) === false) { + continue; + } + + $tag = FlowItems::outputOf(member: $item); + if ($tag === null || in_array($tag, $all, true) === true) { + continue; + } + + $places = $this->placesForExit( + flow: $flow, + nodeId: $transition->getName(), + exitId: $tag, + candidates: $all + ); + + if (count($places) === 1) { + $items[$index][FlowItems::OUTPUT] = $places[0]; + } elseif (count($places) > 1) { + unset($items[$index][FlowItems::OUTPUT]); + } + } + + return $items; + }//end resolveOutputTags() + /** * The output places reached through one named exit. * 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/FlowTriggerService.php b/lib/Service/Flow/FlowTriggerService.php index 888c015981..ed6bef9e73 100644 --- a/lib/Service/Flow/FlowTriggerService.php +++ b/lib/Service/Flow/FlowTriggerService.php @@ -94,13 +94,30 @@ public function fire(string $event, array $subject = [], ?string $user = null, a $queued = 0; foreach ($flowIds as $flowId) { - $run = $this->runner->queue( - flowId: $flowId, - subject: $subject, - trigger: $event, - context: $context, - user: $user - ); + // 🔴 PER-FLOW ISOLATION. The fan-out queues one run PER FLOW, + // and one flow's refusal is a fact about THAT flow — an + // enabled-but-unpublished flow on the event refuses with "no + // published version". Thrown out of the loop it aborted the + // whole fan-out, so the one broken flow silenced every healthy + // flow wired to the same event. Log it against the flow and + // keep queuing the rest. + try { + $run = $this->runner->queue( + flowId: $flowId, + subject: $subject, + trigger: $event, + context: $context, + user: $user + ); + } catch (Throwable $one) { + $this->logger->error( + message: '[FlowTriggerService] Failed to queue a flow run for an event; ' + . 'the event\'s other flows still queue: ' . $one->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'event' => $event, 'flow' => $flowId] + ); + continue; + } + $queued++; // A `sync` flow runs inside the call that triggered it, so its @@ -109,8 +126,20 @@ public function fire(string $event, array $subject = [], ?string $user = null, a // history, retry and resume treat it exactly like a drained run. // A sync flow that suspends is simply left for the worker; the // inline call never blocks on a wait. - $this->runInline(run: $run, flowId: $flowId, subject: $subject); - } + // + // Its OWN catch, separate from the queue's: by this point the + // run EXISTS and the worker will drain it, so an inline failure + // neither uncounts this flow's run nor skips its siblings. + try { + $this->runInline(run: $run, flowId: $flowId, subject: $subject); + } catch (Throwable $inline) { + $this->logger->error( + message: '[FlowTriggerService] Inline execution of a sync flow failed; ' + . 'the run stays queued and the worker takes over: ' . $inline->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'event' => $event, 'flow' => $flowId, 'run' => $run->getUuid()] + ); + } + }//end foreach $this->logger->debug( message: '[FlowTriggerService] Queued flow runs for an event', 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/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/AwaitSignalNode.php b/lib/Service/Flow/Nodes/AwaitSignalNode.php index 91c76209e4..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 — @@ -57,10 +57,12 @@ namespace OCA\OpenRegister\Service\Flow\Nodes; use DateTime; +use OCA\OpenRegister\Service\Flow\FlowItems; use OCA\OpenRegister\Service\Flow\FlowNodeResumeState; use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowStop; use OCA\OpenRegister\Service\Flow\FlowSuspension; +use OCA\OpenRegister\Service\Flow\FlowValueTemplate; use OCA\OpenRegister\Service\Flow\IFlowNode; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; @@ -177,7 +179,7 @@ public function isAvailableForScope(int $scope): bool { * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable */ public function configKeys(): array { - return ['question', 'assignee', 'signalKey', 'heartbeatMinutes', 'failOnReject']; + return ['question', 'assignee', 'signalKey', 'heartbeatMinutes', 'failOnReject', 'correlationKey']; }//end configKeys() /** @@ -220,6 +222,14 @@ public function configForm(): array { 'type' => 'boolean', 'help' => $this->l10n->t('Off by default: being told "no" is usually the flow working, not breaking. Route on the answer instead.'), ], + [ + 'key' => 'correlationKey', + 'label' => $this->l10n->t('Business key for the answer'), + 'type' => 'text', + 'help' => $this->l10n->t( + 'Lets the caller answer by key instead of run id, like "vote:{{ proposalId }}". Leave empty to keep run-id addressing only.' + ), + ], ]; }//end configForm() @@ -261,7 +271,7 @@ public function execute(array $items, array $config, array $context): array { $signal = $this->decisionFrom(context: $context); if ($signal === null) { - $this->recordRequest(config: $config, context: $context); + $this->recordRequest(config: $config, context: $context, items: $items); throw new FlowSuspension( resumeAt: $this->heartbeatAt(config: $config), @@ -291,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; } @@ -348,12 +362,13 @@ private function decisionFrom(array $context): ?array { * * @param array $config The step configuration. * @param array $context Run-level metadata. + * @param array $items The input items, for correlation-key resolution. * * @return void * * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable */ - private function recordRequest(array $config, array $context): void { + private function recordRequest(array $config, array $context, array $items): void { $resume = ($context[FlowNodeResumeState::CONTEXT_KEY] ?? null); if ($resume instanceof FlowNodeResumeState === false) { return; @@ -368,11 +383,55 @@ private function recordRequest(array $config, array $context): void { 'askedAt' => (new DateTime())->format('c'), 'question' => trim((string)($config['question'] ?? '')), 'assignee' => trim((string)($config['assignee'] ?? '')), + 'correlationKey' => $this->resolvedCorrelationKey(config: $config, items: $items), ] ); }//end recordRequest() + /** + * The business key this suspension can be addressed by, resolved once. + * + * Resolved from the step's config against the first item at SUSPENSION + * time and stamped into the resume slot, from where the run service + * copies it onto the run's indexed `correlation_key` column + * (flow-approval-consolidation design D-7). Empty when the step declares + * none: the run then keeps run-uuid addressing only, and behaves exactly + * as before this capability existed. + * + * @param array $config The step configuration. + * @param array $items The input items. + * + * @return string The resolved key, or the empty string. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-the-signal-node-keeps-machine-to-machine-work-and-gains-a-correlation-key + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate is the flow + * engine's stateless value renderer; a factory would add a dependency to + * say the same thing. + */ + private function resolvedCorrelationKey(array $config, array $items): string { + $declared = trim((string)($config['correlationKey'] ?? '')); + if ($declared === '') { + return ''; + } + + $representative = []; + foreach ($items as $item) { + if (is_array($item) === true) { + // An engine item wraps its payload under the json envelope; + // render against the payload, tolerating a bare array too. + $payload = ($item[FlowItems::JSON] ?? $item); + if (is_array($payload) === true) { + $representative = $payload; + } + break; + } + } + + return trim((string)FlowValueTemplate::render(value: $declared, json: $representative)); + }//end resolvedCorrelationKey() + /** * When to wake up and re-ask, absent an answer. * 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/lib/Service/Flow/Nodes/LockObjectNode.php b/lib/Service/Flow/Nodes/LockObjectNode.php new file mode 100644 index 0000000000..af0a7f8f4a --- /dev/null +++ b/lib/Service/Flow/Nodes/LockObjectNode.php @@ -0,0 +1,604 @@ + + * 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/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use DateTime; +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\FlowValueTemplate; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; +use OCA\OpenRegister\Service\ObjectService; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\WorkflowEngine\IManager; +use RuntimeException; +use Throwable; +use UnexpectedValueException; + +/** + * Take a run-scoped object lock, parking the run while it is contended. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class LockObjectNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm { + + /** + * The step type this node answers to. + * + * @var string + */ + public const TYPE = 'openregister.lock-object'; + + /** + * Default lock duration in seconds. + * + * @var int + */ + public const DEFAULT_DURATION = 3600; + + /** + * Default wait budget in seconds. An hour: long enough to outlast a + * human-scale step upstream, short enough that a wedged run surfaces the + * same working day. + * + * @var int + */ + public const DEFAULT_WAIT_SECONDS = 3600; + + /** + * Retry floor in seconds. + * + * A wake finer than the cron period cannot happen, so asking for one only + * makes the run look busier than it is. + * + * @var int + */ + public const MIN_RETRY_SECONDS = 60; + + /** + * Retry ceiling in seconds. + * + * @var int + */ + public const MAX_RETRY_SECONDS = 900; + + /** + * Resume-slot key holding the absolute wait deadline, stamped ONCE. + * + * @var string + */ + public const SLOT_DEADLINE = 'lockWaitDeadline'; + + /** + * Resume-slot key holding the attempt count, which drives the backoff. + * + * @var string + */ + public const SLOT_ATTEMPTS = 'lockAttempts'; + + /** + * Constructor. + * + * @param ObjectService $objects Object lock operations, RBAC-enforcing. + * @param IUserManager $userManager Resolves the run's acting identity. + * @param IL10N $l10n Translations. + * @param IURLGenerator $urls For the palette icon. + */ + public function __construct( + private readonly ObjectService $objects, + private readonly IUserManager $userManager, + private readonly IL10N $l10n, + private readonly IURLGenerator $urls, + ) { + + }//end __construct() + + /** + * The step type. + * + * @return string The node id. + */ + public function getId(): string { + return self::TYPE; + }//end getId() + + /** + * The palette label. + * + * @return string The display name. + */ + public function getDisplayName(): string { + return $this->l10n->t('Lock an object'); + }//end getDisplayName() + + /** + * The palette description. + * + * @return string The description. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function getDescription(): string { + return $this->l10n->t( + 'Hold an object for this run so nobody else can change it. If another run holds it, this step waits and tries again.' + ); + }//end getDescription() + + /** + * The palette icon. + * + * @return string The icon path. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-node-the-engine-registers-is-offered-in-the-palette + */ + public function getIcon(): string { + // The APP's icon, not core's. `core/img/actions/lock.svg` does not + // exist in NC 33 or 34, `imagePath()` throws for an image the server + // does not ship, and this node was therefore absent from the editor's + // palette entirely. An icon the app ships cannot go missing under it. + return $this->urls->imagePath('openregister', 'lock.svg'); + }//end getIcon() + + /** + * Available in both flow scopes. + * + * @param int $scope The workflow engine scope. + * + * @return bool True when available. + */ + public function isAvailableForScope(int $scope): bool { + return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); + }//end isAvailableForScope() + + /** + * Top-level configuration keys. + * + * @return array The keys. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function configKeys(): array { + return ['uuid', 'duration', 'waitSeconds', 'process']; + }//end configKeys() + + /** + * The editor form. + * + * @return array> The fields. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function configForm(): array { + return [ + [ + 'key' => 'uuid', + 'label' => $this->l10n->t('Object'), + 'type' => 'text', + 'help' => $this->l10n->t('Which object to lock. Leave empty to lock the object the step receives.'), + ], + [ + 'key' => 'duration', + 'label' => $this->l10n->t('Hold for (seconds)'), + 'type' => 'number', + 'help' => $this->l10n->t('How long the lock survives if this run never releases it. Defaults to one hour.'), + ], + [ + 'key' => 'waitSeconds', + 'label' => $this->l10n->t('Wait at most (seconds)'), + 'type' => 'number', + 'help' => $this->l10n->t('How long to keep retrying while another run holds the object. Defaults to one hour.'), + ], + [ + 'key' => 'process', + 'label' => $this->l10n->t('Reason'), + 'type' => 'text', + 'help' => $this->l10n->t('What the lock is for. Shown to anyone whose write is refused.'), + ], + ]; + }//end configForm() + + /** + * Refuse a configuration the run could not act on. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When the configuration cannot be run. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function validateConfig(array $config): void { + $this->requirePositiveSeconds(config: $config, key: 'duration'); + $this->requirePositiveSeconds(config: $config, key: 'waitSeconds'); + + $uuid = ($config['uuid'] ?? null); + if ($uuid !== null && is_string($uuid) === false) { + throw new UnexpectedValueException($this->l10n->t('A lock step\'s object must be text.')); + } + }//end validateConfig() + + /** + * Refuse a duration that is not a whole number of seconds. + * + * @param array $config The step configuration. + * @param string $key The key to check. + * + * @return void + * + * @throws UnexpectedValueException When the value cannot be a duration. + */ + private function requirePositiveSeconds(array $config, string $key): void { + $value = ($config[$key] ?? null); + if ($value === null || $value === '') { + return; + } + + if (is_numeric($value) === false || (int)$value < 1) { + throw new UnexpectedValueException( + $this->l10n->t('A lock step\'s "%s" must be a whole number of seconds, at least 1.', [$key]) + ); + } + }//end requirePositiveSeconds() + + /** + * Take the lock, or park the run and try again later. + * + * @param array $items The incoming items. + * @param array $config The step configuration. + * @param array $context The run context. + * + * @return array The items, unchanged. + * + * @throws FlowSuspension When the lock is contended and the budget remains. + * @throws RuntimeException When the budget is spent, or the step cannot act. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function execute(array $items, array $config, array $context): array { + // An empty firing must not suspend the whole run. After a route, the + // un-taken branch is marked and its steps still fire with zero items. + if ($items === []) { + return $items; + } + + $this->validateConfig(config: $config); + + $resume = ($context[FlowNodeResumeState::CONTEXT_KEY] ?? null); + if ($resume instanceof FlowNodeResumeState === false) { + // Without a slot the node cannot remember its deadline, so every + // heartbeat would restart the budget and the wait would never end. + throw new RuntimeException( + $this->l10n->t('A lock step needs a resume slot; this run did not provide one.') + ); + } + + $runUuid = $this->resolveRunUuid(context: $context); + $owner = $this->resolveOwner(context: $context); + $targets = $this->resolveTargets(items: $items, config: $config); + + $duration = $this->seconds(config: $config, key: 'duration', default: self::DEFAULT_DURATION); + $process = trim((string)($config['process'] ?? '')); + if ($process === '') { + $process = sprintf('flow run %s', $runUuid); + } + + $deadline = $this->deadline(resume: $resume, config: $config); + + foreach ($targets as $uuid) { + try { + $this->objects->runAs( + $owner, + fn (): array => $this->objects->lockObject( + identifier: $uuid, + process: $process, + duration: $duration, + advisory: false, + runUuid: $runUuid, + nodeId: $resume->nodeId() + ) + ); + } catch (Throwable $e) { + $this->parkOrGiveUp( + resume: $resume, + deadline: $deadline, + uuid: $uuid, + cause: $e + ); + }//end try + } + + // Every target is held. Returning normally clears the slot, so a later + // re-entry of this node starts a fresh budget, which is correct: it is + // a different wait. + return $items; + }//end execute() + + /** + * Park the run and retry, or fail because the budget is spent. + * + * @param FlowNodeResumeState $resume This node's resume slot. + * @param DateTime $deadline The absolute wait deadline. + * @param string $uuid The contended object. + * @param Throwable $cause Why the acquire failed. + * + * @return void + * + * @throws FlowSuspension To park the run. + * @throws RuntimeException When the budget is spent. + */ + private function parkOrGiveUp( + FlowNodeResumeState $resume, + DateTime $deadline, + string $uuid, + Throwable $cause, + ): void { + $now = new DateTime(); + if ($now >= $deadline) { + // Fail, naming the holder. Do NOT break the lock and do NOT + // proceed: proceeding is the data race the lock exists to stop. + throw new RuntimeException( + $this->l10n->t( + 'Waited as long as allowed for object %s, and it is still locked: %s', + [$uuid, $cause->getMessage()] + ), + 0, + $cause + ); + } + + $attempts = ((int)$resume->get(self::SLOT_ATTEMPTS, 0) + 1); + $resume->set(self::SLOT_ATTEMPTS, $attempts); + + throw new FlowSuspension( + resumeAt: $this->nextAttemptAt(attempts: $attempts, deadline: $deadline), + reason: sprintf('waiting for a lock on %s', $uuid) + ); + }//end parkOrGiveUp() + + /** + * When to wake for the next attempt. + * + * Backoff is the node's own job: `resume_at` is an absolute wake time and + * the engine keeps no attempt counter. Doubling from the floor spares a + * long wait a wake every minute, and the result is clamped so it never + * overshoots the deadline: waking after the budget has expired would + * report the failure a whole interval late. + * + * @param int $attempts How many attempts have been made. + * @param DateTime $deadline The absolute wait deadline. + * + * @return DateTime The next wake time. + */ + private function nextAttemptAt(int $attempts, DateTime $deadline): DateTime { + $backoff = (self::MIN_RETRY_SECONDS * (2 ** min($attempts - 1, 8))); + $backoff = min($backoff, self::MAX_RETRY_SECONDS); + + $attemptAt = (new DateTime())->modify('+' . $backoff . ' seconds'); + if ($attemptAt > $deadline) { + return $deadline; + } + + return $attemptAt; + }//end nextAttemptAt() + + /** + * The absolute wait deadline, stamped ONCE on the first attempt. + * + * @param FlowNodeResumeState $resume This node's resume slot. + * @param array $config The step configuration. + * + * @return DateTime The deadline. + */ + private function deadline(FlowNodeResumeState $resume, array $config): DateTime { + $stored = $resume->get(self::SLOT_DEADLINE, ''); + if (is_string($stored) === true && trim($stored) !== '') { + try { + return new DateTime($stored); + } catch (Throwable $e) { + // An unreadable deadline is restamped below rather than + // treated as expired: a corrupt slot must not fail a run that + // has done nothing wrong. + $stored = ''; + } + } + + $wait = $this->seconds(config: $config, key: 'waitSeconds', default: self::DEFAULT_WAIT_SECONDS); + $stamp = (new DateTime())->modify('+' . $wait . ' seconds')->format('c'); + $resume->set(self::SLOT_DEADLINE, $stamp); + + // Return the value as PERSISTED, not as computed. `format('c')` drops + // sub-second precision, so the deadline this attempt compares against + // would otherwise be microseconds later than the one every subsequent + // attempt reads back, and the last retry could be scheduled just past + // the bound it is supposed to respect. + return new DateTime($stamp); + }//end deadline() + + /** + * Read a positive integer from the config. + * + * @param array $config The step configuration. + * @param string $key The key to read. + * @param int $default The fallback. + * + * @return int The value. + */ + private function seconds(array $config, string $key, int $default): int { + $raw = ($config[$key] ?? null); + if (is_numeric($raw) === false || (int)$raw < 1) { + return $default; + } + + return (int)$raw; + }//end seconds() + + /** + * Which objects to lock. + * + * Defaults to the uuid each item carries, which is what `object-read` + * injects for exactly this purpose. An explicit `uuid` is templated + * against the item, and a placeholder that renders empty throws rather + * than locking nothing and reporting success. + * + * @param array $items The incoming items. + * @param array $config The step configuration. + * + * @return array The distinct target uuids. + * + * @throws RuntimeException When a target cannot be resolved. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate is the engine's + * stateless template renderer and every node calls it this way. + */ + private function resolveTargets(array $items, array $config): array { + $configured = ($config['uuid'] ?? null); + $targets = []; + + foreach ($items as $item) { + $json = ($item[FlowItems::JSON] ?? []); + if (is_array($json) === false) { + $json = []; + } + + $uuid = trim((string)($json['uuid'] ?? '')); + if (is_string($configured) === true && trim($configured) !== '') { + $uuid = trim((string)FlowValueTemplate::render($configured, $json)); + } + + if ($uuid === '') { + throw new RuntimeException( + $this->l10n->t('A lock step could not work out which object to lock from the item it received.') + ); + } + + $targets[$uuid] = $uuid; + } + + return array_values($targets); + }//end resolveTargets() + + /** + * The run's uuid, which IS the lock holder's identity. + * + * @param array $context The run context. + * + * @return string The run uuid. + * + * @throws RuntimeException When the run cannot identify itself. + */ + private function resolveRunUuid(array $context): string { + $runUuid = trim((string)($context[FlowRunContext::CONTEXT_RUN] ?? ($context['runUuid'] ?? ''))); + if ($runUuid === '') { + // A run-scoped lock with no run is a lock nobody can release and + // the predicate refuses everybody for. Refuse to take it. + throw new RuntimeException( + $this->l10n->t('A lock step needs the run it belongs to; this run did not identify itself.') + ); + } + + return $runUuid; + }//end resolveRunUuid() + + /** + * The acting identity the lock is taken under. + * + * Load-bearing: `LockHandler::callerMayUnlock()` reads `IUserSession` + * directly, and under the cron worker that is nobody, so an unwrapped + * lock or unlock is refused as anonymous. + * + * @param array $context The run context. + * + * @return IUser The acting user. + * + * @throws RuntimeException When there is no usable acting identity. + */ + private function resolveOwner(array $context): IUser { + $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); a lock must be attributable.') + ); + } + + $user = $this->userManager->get(trim($uid)); + if ($user === null) { + throw new RuntimeException( + $this->l10n->t('This flow run acts as "%s", which is not a user account.', [trim($uid)]) + ); + } + + if ($user->isEnabled() === false) { + throw new RuntimeException( + $this->l10n->t('This flow run acts as "%s", whose account is disabled.', [trim($uid)]) + ); + } + + return $user; + }//end resolveOwner() +}//end class 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/Nodes/PortalTaskConfig.php b/lib/Service/Flow/Nodes/PortalTaskConfig.php new file mode 100644 index 0000000000..13e36f523b --- /dev/null +++ b/lib/Service/Flow/Nodes/PortalTaskConfig.php @@ -0,0 +1,559 @@ + + * 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), + 'onTimeout' => $this->behaviourOrNull(value: ($config['onTimeout'] ?? '')), + 'onReject' => $this->behaviourOrNull(value: ($config['onReject'] ?? '')), + '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 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. + * + * @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..aaab1d84a5 --- /dev/null +++ b/lib/Service/Flow/Nodes/PortalTaskNode.php @@ -0,0 +1,675 @@ + + * 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\FlowRunService; +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', + 'onTimeout', + 'onReject', + '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 + * @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 === []) { + // 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) { + // 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')); + + 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() ?? []); + // `answers` now comes from the bridge, which is where it always + // belonged: this node placing it and the user-task node not placing + // it is exactly how a form's values could be stored and never read. + $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' => '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)'), + '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/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/Flow/Nodes/UnlockObjectNode.php b/lib/Service/Flow/Nodes/UnlockObjectNode.php new file mode 100644 index 0000000000..075b531309 --- /dev/null +++ b/lib/Service/Flow/Nodes/UnlockObjectNode.php @@ -0,0 +1,324 @@ + + * 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/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowRunService; +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 OCA\OpenRegister\Service\ObjectService; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\WorkflowEngine\IManager; +use RuntimeException; +use UnexpectedValueException; + +/** + * Release a run-scoped object lock. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class UnlockObjectNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm { + + /** + * The step type this node answers to. + * + * @var string + */ + public const TYPE = 'openregister.unlock-object'; + + /** + * Constructor. + * + * @param ObjectService $objects Object lock operations, RBAC-enforcing. + * @param IUserManager $userManager Resolves the run's acting identity. + * @param IL10N $l10n Translations. + * @param IURLGenerator $urls For the palette icon. + */ + public function __construct( + private readonly ObjectService $objects, + private readonly IUserManager $userManager, + private readonly IL10N $l10n, + private readonly IURLGenerator $urls, + ) { + + }//end __construct() + + /** + * The step type. + * + * @return string The node id. + */ + public function getId(): string { + return self::TYPE; + }//end getId() + + /** + * The palette label. + * + * @return string The display name. + */ + public function getDisplayName(): string { + return $this->l10n->t('Unlock an object'); + }//end getDisplayName() + + /** + * The palette description. + * + * @return string The description. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function getDescription(): string { + return $this->l10n->t( + 'Release an object this run is holding, so others can change it again. The run releases its locks when it ends anyway; this frees them sooner.' + ); + }//end getDescription() + + /** + * The palette icon. + * + * @return string The icon path. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-node-the-engine-registers-is-offered-in-the-palette + */ + public function getIcon(): string { + // The APP's icon: see LockObjectNode::getIcon(). `actions/unlock.svg` + // is not part of NC 33's or 34's core icon set either. + return $this->urls->imagePath('openregister', 'unlock.svg'); + }//end getIcon() + + /** + * Available in both flow scopes. + * + * @param int $scope The workflow engine scope. + * + * @return bool True when available. + */ + public function isAvailableForScope(int $scope): bool { + return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); + }//end isAvailableForScope() + + /** + * Top-level configuration keys. + * + * @return array The keys. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function configKeys(): array { + return ['uuid']; + }//end configKeys() + + /** + * The editor form. + * + * @return array> The fields. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function configForm(): array { + return [ + [ + 'key' => 'uuid', + 'label' => $this->l10n->t('Object'), + 'type' => 'text', + 'help' => $this->l10n->t('Which object to release. Leave empty to release the object the step receives.'), + ], + ]; + }//end configForm() + + /** + * Refuse a configuration the run could not act on. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When the configuration cannot be run. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function validateConfig(array $config): void { + if (array_key_exists('uuid', $config) === true + && is_string($config['uuid']) === false + && $config['uuid'] !== null + ) { + throw new UnexpectedValueException($this->l10n->t('An unlock step\'s object must be text.')); + } + }//end validateConfig() + + /** + * Release the lock. + * + * @param array $items The incoming items. + * @param array $config The step configuration. + * @param array $context The run context. + * + * @return array The items, unchanged. + * + * @throws RuntimeException When the step cannot act. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-a-lock-step-takes-a-lock-or-parks-the-run-and-retries + */ + public function execute(array $items, array $config, array $context): array { + if ($items === []) { + return $items; + } + + $this->validateConfig(config: $config); + + $runUuid = $this->resolveRunUuid(context: $context); + $owner = $this->resolveOwner(context: $context); + + foreach ($this->resolveTargets(items: $items, config: $config) as $uuid) { + $this->objects->runAs( + $owner, + fn (): bool => $this->objects->unlockObject( + identifier: $uuid, + advisory: false, + runUuid: $runUuid + ) + ); + } + + return $items; + }//end execute() + + /** + * Which objects to release. + * + * @param array $items The incoming items. + * @param array $config The step configuration. + * + * @return array The distinct target uuids. + * + * @throws RuntimeException When a target cannot be resolved. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate is the engine's + * stateless template renderer and every node calls it this way. + */ + private function resolveTargets(array $items, array $config): array { + $configured = ($config['uuid'] ?? null); + $targets = []; + + foreach ($items as $item) { + $json = ($item[FlowItems::JSON] ?? []); + if (is_array($json) === false) { + $json = []; + } + + $uuid = trim((string)($json['uuid'] ?? '')); + if (is_string($configured) === true && trim($configured) !== '') { + $uuid = trim((string)FlowValueTemplate::render($configured, $json)); + } + + if ($uuid === '') { + throw new RuntimeException( + $this->l10n->t('An unlock step could not work out which object to release from the item it received.') + ); + } + + $targets[$uuid] = $uuid; + } + + return array_values($targets); + }//end resolveTargets() + + /** + * The run's uuid, which identifies the lock holder. + * + * @param array $context The run context. + * + * @return string The run uuid. + * + * @throws RuntimeException When the run cannot identify itself. + */ + private function resolveRunUuid(array $context): string { + $runUuid = trim((string)($context[FlowRunContext::CONTEXT_RUN] ?? ($context['runUuid'] ?? ''))); + if ($runUuid === '') { + throw new RuntimeException( + $this->l10n->t('An unlock step needs the run it belongs to; this run did not identify itself.') + ); + } + + return $runUuid; + }//end resolveRunUuid() + + /** + * The acting identity the release runs under. + * + * @param array $context The run context. + * + * @return IUser The acting user. + * + * @throws RuntimeException When there is no usable acting identity. + */ + private function resolveOwner(array $context): IUser { + $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 unlock must be attributable.') + ); + } + + $user = $this->userManager->get(trim($uid)); + if ($user === null) { + throw new RuntimeException( + $this->l10n->t('This flow run acts as "%s", which is not a user account.', [trim($uid)]) + ); + } + + if ($user->isEnabled() === false) { + throw new RuntimeException( + $this->l10n->t('This flow run acts as "%s", whose account is disabled.', [trim($uid)]) + ); + } + + return $user; + }//end resolveOwner() +}//end class diff --git a/lib/Service/Flow/Nodes/UserTaskConfig.php b/lib/Service/Flow/Nodes/UserTaskConfig.php new file mode 100644 index 0000000000..22f196e54b --- /dev/null +++ b/lib/Service/Flow/Nodes/UserTaskConfig.php @@ -0,0 +1,473 @@ + + * 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 + * @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\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\Task\TaskFormReader; +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. + * @param TaskFormReader $forms Reads and refuses the step's form declaration. + */ + public function __construct( + private readonly IL10N $l10n, + private readonly TaskFormReader $forms, + ) { + + }//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. + * + * 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 + * + * @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'] ?? '')) === '') { + 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); + + // Throws naming schema, field and reason; a step with no form passes. + $this->forms->validate(form: $this->forms->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), + 'onTimeout' => $this->nullIfEmpty(value: trim((string)($config['onTimeout'] ?? ''))), + 'onReject' => $this->nullIfEmpty(value: trim((string)($config['onReject'] ?? ''))), + '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..7b79f74aed --- /dev/null +++ b/lib/Service/Flow/Nodes/UserTaskNode.php @@ -0,0 +1,763 @@ + + * 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 + * @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\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\FlowRunService; +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 OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use OCA\OpenRegister\Service\Task\TaskFormReader; +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.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. + */ +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. + * @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 + */ + public function __construct( + private readonly FlowTaskBridge $bridge, + private readonly IL10N $l10n, + private readonly IURLGenerator $urls, + TaskFormReader $forms, + private readonly FlowTimerService $timers, + ) { + $this->config = new UserTaskConfig(l10n: $l10n, forms: $forms); + + }//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', + 'onTimeout', + 'onReject', + 'outcomes', + 'outcomeKey', + '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() + + /** + * 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, 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->formFields() + ); + }//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 + * @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 === []) { + // 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); + } + + // 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) { + 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) + ); + + // 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. + $resume->merge( + values: $this->config->slotValues(config: $config, items: $items, taskUuid: (string)$task->getUuid()) + ); + + }//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. + * + * 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' => '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)'), + '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() + + /** + * 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/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..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() @@ -110,8 +119,14 @@ 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); + $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 @@ -127,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. * @@ -159,6 +224,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/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..568a038b5d --- /dev/null +++ b/lib/Service/Flow/Timer/FlowTimerService.php @@ -0,0 +1,1504 @@ + + * 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 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->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: $outcome, + source: 'flow-timer:' . (string)$timer->getUuid(), + 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. + $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..6a89948e52 --- /dev/null +++ b/lib/Service/Flow/Timer/FlowTimerSweep.php @@ -0,0 +1,173 @@ + + * 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 OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Task\TaskService; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The bounded, re-entrant sweep. + */ +class FlowTimerSweep { + + /** + * The actor a swept task timeout records itself as. + * + * @var string + */ + public const TIMEOUT_SOURCE = 'task-expiry-sweep'; + + /** + * Constructor. + * + * @param FlowTimerMapper $timers The two timer 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. + * @param TaskMapper|null $tasks The task-timeout range scan + * (task-expiry-and-outcomes D-3). Nullable so + * pre-existing positional instantiations keep + * working; the container wires both. + * @param TaskService|null $taskService Applies a due task's declared behaviour. + */ + public function __construct( + private readonly FlowTimerMapper $timers, + private readonly FlowTimerService $service, + private readonly WorkingCalendarService $calendars, + private readonly LoggerInterface $logger, + private readonly ?TaskMapper $tasks = null, + private readonly ?TaskService $taskService = null, + ) { + + }//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, taskTimeouts: 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, 'taskTimeouts' => 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] + ); + } + } + + $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/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/Gdpr/DataSubjectRequestService.php b/lib/Service/Gdpr/DataSubjectRequestService.php index dcc681a06c..be6924518c 100644 --- a/lib/Service/Gdpr/DataSubjectRequestService.php +++ b/lib/Service/Gdpr/DataSubjectRequestService.php @@ -55,6 +55,7 @@ use DateTimeInterface; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Archival\ArchivalRetentionGuard; use OCA\OpenRegister\Service\DsarService; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Service\RetentionService; @@ -103,6 +104,7 @@ class DataSubjectRequestService { * @param DataSubjectDeadline $deadline EU art-12 deadline maths. * @param IUserSession $userSession Current user (erasure metadata). * @param LoggerInterface $logger Logger. + * @param ArchivalRetentionGuard $archivalGuard Refuses erasure of a legally retained record. */ public function __construct( private readonly IDBConnection $db, @@ -113,6 +115,7 @@ public function __construct( private readonly DataSubjectDeadline $deadline, private readonly IUserSession $userSession, private readonly LoggerInterface $logger, + private readonly ArchivalRetentionGuard $archivalGuard, ) { }//end __construct() @@ -238,6 +241,13 @@ public function rectify(string $objectIdentifier, array $changes): ?array { * are NEVER erased; they are reported in the `held` bucket so the caller * can surface a partial result rather than a false "complete". * + * RETENTION WINS OVER ERASURE. A record on a schema declaring + * `x-openregister-archival` is held by the Archiefwet, and art-17(3)(b) + * stands down for it. Those rows are refused and reported in `withheld`, + * each carrying its ground, the sentence to pass back to the requester, and + * what the handler does next. Withholding one record never blocks the rest + * of the request. + * * @param string $subjectId Subject identifier value. * @param string|null $type Optional GdprEntity type filter. * @param string $eraseMode One of the ERASE_MODE_* constants. @@ -246,6 +256,8 @@ public function rectify(string $objectIdentifier, array $changes): ?array { * @return array * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * + * @spec openspec/specs/gdpr-data-subject-rights/spec.md#requirement-erasure-honours-legal-hold-and-is-mode-parameterised */ public function erase( string $subjectId, @@ -269,6 +281,10 @@ public function erase( 'erased' => [], 'held' => [], 'failed' => [], + // RETENTION WINS OVER ERASURE: records the Archiefwet keeps are + // refused, named here with their ground, and reported back with the + // rest of the answer to the data subject. + 'withheld' => [], ]; foreach ($grouped as $entry) { @@ -281,6 +297,21 @@ public function erase( continue; } + // RETENTION WINS OVER ERASURE, AND THE SCHEMA IS WHERE THE OBLIGATION + // LIVES. `retentionGuard()` below reads the OBJECT's own + // `retention.archiefstatus` and the legal hold on it; neither says + // anything about `x-openregister-archival`, which is declared on the + // SCHEMA. So a record on an archival schema that had not yet been + // stamped `vernietigd` or `overgebracht` was erased here, past the + // guard whose docblock claims to cover "immutable archival status". + // It is asked of Schema::hasArchivalAnnotation() now, through the same + // definition the four HTTP delete doors use. + $refusal = $this->archivalGuard->erasureRefusal(object: $object); + if ($refusal !== null) { + $summary['withheld'][] = $refusal; + continue; + } + // Retention guard — never erase a held / immutable object. $holdReason = $this->retentionGuard(object: $object); if ($holdReason !== null) { @@ -305,9 +336,18 @@ public function erase( ); }//end foreach - $summary['complete'] = ($summary['failed'] === []); + // A RUN THAT KEPT DATA BACK IS NOT A COMPLETE ERASURE. `complete` used to + // mean "nothing errored", so a request that lawfully held every single + // record still answered `complete: true` and the data subject was told + // their data was gone. Held and withheld rows now count against it too. + // The buckets stay separate because the answers differ: `failed` is + // retried, `held` waits for the hold to lift, `withheld` is explained. + $summary['complete'] = ($summary['failed'] === [] + && $summary['held'] === [] + && $summary['withheld'] === []); $summary['failedCount'] = count($summary['failed']); $summary['heldCount'] = count($summary['held']); + $summary['withheldCount'] = count($summary['withheld']); return $summary; }//end erase() diff --git a/lib/Service/Gdpr/Retention/RetentionSweepService.php b/lib/Service/Gdpr/Retention/RetentionSweepService.php index 899315aec2..b873ca499b 100644 --- a/lib/Service/Gdpr/Retention/RetentionSweepService.php +++ b/lib/Service/Gdpr/Retention/RetentionSweepService.php @@ -41,6 +41,7 @@ namespace OCA\OpenRegister\Service\Gdpr\Retention; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Archival\ArchivalRetentionGuard; use OCA\OpenRegister\Service\Gdpr\Case\CaseObjectAccessor; use OCA\OpenRegister\Service\Gdpr\DataSubjectRequestService; use OCA\OpenRegister\Service\RetentionService; @@ -60,6 +61,7 @@ class RetentionSweepService { * @param DataSubjectRequestService $dsrService Reused evidence-PII scrub (erase pseudonymise). * @param ITimeFactory $time Time source for the `retainUntil` cut-off. * @param LoggerInterface $logger Logger. + * @param ArchivalRetentionGuard $archivalGuard Refuses destruction of a legally retained case. */ public function __construct( private readonly CaseObjectAccessor $accessor, @@ -67,6 +69,7 @@ public function __construct( private readonly DataSubjectRequestService $dsrService, private readonly ITimeFactory $time, private readonly LoggerInterface $logger, + private readonly ArchivalRetentionGuard $archivalGuard, ) { }//end __construct() @@ -78,9 +81,18 @@ public function __construct( * evidence PII via erase pseudonymise and hard-delete the dossier. Held or * immutable expired cases are reported as skipped and left intact. * + * RETENTION WINS OVER ERASURE. A case on a schema declaring + * `x-openregister-archival` is kept whatever its `retainUntil` says, and is + * reported in `withheld` with the ground and the wording to pass on. The + * refusal comes from + * {@see \OCA\OpenRegister\Service\Archival\ArchivalRetentionGuard}, so it + * reads the same definition as every other delete door. + * * @param bool $dryRun When true, report candidates without deleting/scrubbing. * - * @return array{dryRun: bool, evaluated: int, purged: array, skippedHeld: array, withinWindow: int} + * @return array{dryRun: bool, evaluated: int, purged: array, + * skippedHeld: array, withinWindow: int, + * withheld: array>, withheldCount: int} * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Dry-run toggle mirrors AvgRetentionJob's contract. * @@ -94,6 +106,9 @@ public function runSweep(bool $dryRun = false): array { 'evaluated' => 0, 'purged' => [], 'skippedHeld' => [], + // RETENTION WINS OVER ERASURE: cases the Archiefwet keeps are named + // here with their ground, not silently swept. + 'withheld' => [], 'withinWindow' => 0, ]; @@ -119,6 +134,22 @@ public function runSweep(bool $dryRun = false): array { continue; } + // RETENTION WINS OVER ERASURE, AND THIS SWEEP HAD AN EXPLICIT BYPASS. + // `isProtected()` above reads the case's own legal hold and + // archiefstatus; it says nothing about `x-openregister-archival` on + // the case schema. Worse, the delete below hands + // `_retentionSweep: true` to ObjectService, which is exactly the flag + // that waves a row past the archival gate. So an archival case was + // hard-deleted here with no refusal anywhere in the chain. The gate is + // asked BEFORE the bypass is used, of the same + // Schema::hasArchivalAnnotation() the four HTTP doors ask. Refused + // cases are named in `withheld`; the rest of the sweep runs on. + $refusal = $this->archivalGuard->erasureRefusal(object: $case); + if ($refusal !== null) { + $summary['withheld'][] = $refusal; + continue; + } + if ($dryRun === true) { $summary['purged'][] = $uuid; continue; @@ -128,6 +159,8 @@ public function runSweep(bool $dryRun = false): array { $summary['purged'][] = $uuid; }//end foreach + $summary['withheldCount'] = count($summary['withheld']); + return $summary; }//end runSweep() diff --git a/lib/Service/HookExecutor.php b/lib/Service/HookExecutor.php deleted file mode 100644 index c430fe637c..0000000000 --- a/lib/Service/HookExecutor.php +++ /dev/null @@ -1,1009 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://www.OpenRegister.app - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-configuration-on-schema - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-lifecycle-events - * @spec openspec/specs/schema-hooks/spec.md#requirement-sync-hook-response-format - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-execution-order - * @spec openspec/specs/schema-hooks/spec.md#requirement-failure-mode-behavior - * @spec openspec/specs/schema-hooks/spec.md#requirement-filter-condition-for-conditional-hook-execution - * @spec openspec/specs/schema-hooks/spec.md#requirement-async-hook-execution-fire-and-forget - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-retry-via-background-job - * @spec openspec/specs/schema-hooks/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Service; - -use DateTime; -use Exception; -use OCA\OpenRegister\Db\ObjectEntity; -use OCA\OpenRegister\Db\Schema; -use OCA\OpenRegister\Db\SchemaMapper; -use OCA\OpenRegister\Db\WorkflowExecutionMapper; -use OCA\OpenRegister\Event\ObjectCreatedEvent; -use OCA\OpenRegister\Event\ObjectCreatingEvent; -use OCA\OpenRegister\Event\ObjectDeletedEvent; -use OCA\OpenRegister\Event\ObjectDeletingEvent; -use OCA\OpenRegister\Event\ObjectUpdatedEvent; -use OCA\OpenRegister\Event\ObjectUpdatingEvent; -use OCA\OpenRegister\Service\Webhook\CloudEventFormatter; -use OCA\OpenRegister\WorkflowEngine\WorkflowResult; -use OCP\BackgroundJob\IJobList; -use OCP\EventDispatcher\Event; -use Psr\Log\LoggerInterface; - -/** - * HookExecutor orchestrates schema hook execution for object lifecycle events. - * - * Responsibilities: - * 1. Load enabled hooks from a Schema for the current event type - * 2. Sort hooks by order (ascending) - * 3. Build CloudEvents payloads - * 4. Execute sync workflows via engine adapters - * 5. Process responses (approved/rejected/modified) - * 6. Apply failure modes (reject/allow/flag/queue) - * 7. Log all hook executions - * 8. Persist execution history to WorkflowExecution entities - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Needs engine registry, formatter, schema mapper, execution mapper, job list, and logger - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Lifecycle: filter, sync/async dispatch, result processing, 4 failure modes, logging, retry - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) Each method is a cohesive pipeline stage; splitting would scatter execution context - */ -class HookExecutor { - /** - * Constructor for HookExecutor. - * - * @param WorkflowEngineRegistry $engineRegistry Engine registry for resolving adapters - * @param CloudEventFormatter $cloudEventFormatter CloudEvent payload builder - * @param SchemaMapper $schemaMapper Schema mapper for loading schemas - * @param IJobList $jobList Background job list for queue mode - * @param WorkflowExecutionMapper $executionMapper Execution history persistence - * @param LoggerInterface $logger Logger - */ - public function __construct( - private readonly WorkflowEngineRegistry $engineRegistry, - private readonly CloudEventFormatter $cloudEventFormatter, - private readonly SchemaMapper $schemaMapper, - private readonly IJobList $jobList, - private readonly WorkflowExecutionMapper $executionMapper, - private readonly LoggerInterface $logger, - ) { - }//end __construct() - - /** - * Execute hooks for a given event and schema. - * - * Loads hooks matching the event type from the schema, sorts them by order, - * and executes each one. For sync hooks, processes responses and may stop - * propagation or merge modified data into the event. - * - * @param Event $event The lifecycle event (ObjectCreatingEvent, etc.) - * @param Schema $schema The schema containing hook configurations - * - * @return void - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-configuration-on-schema - * @spec openspec/specs/schema-hooks/spec.md - */ - public function executeHooks(Event $event, Schema $schema): void { - $eventType = $this->resolveEventType(event: $event); - if ($eventType === null) { - return; - } - - $hooks = $this->loadHooks(schema: $schema, eventType: $eventType); - if (empty($hooks) === true) { - return; - } - - $object = $this->getObjectFromEvent(event: $event); - if ($object === null) { - return; - } - - foreach ($hooks as $hook) { - if ($this->isEventStopped(event: $event) === true) { - break; - } - - $this->executeSingleHook( - hook: $hook, - event: $event, - object: $object, - schema: $schema, - eventType: $eventType - ); - } - }//end executeHooks() - - /** - * Map an event class to its hook event type string. - * - * @param Event $event The lifecycle event - * - * @return string|null The event type string (e.g. 'creating') or null - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-configuration-on-schema - */ - private function resolveEventType(Event $event): ?string { - return match (true) { - $event instanceof ObjectCreatingEvent => 'creating', - $event instanceof ObjectUpdatingEvent => 'updating', - $event instanceof ObjectDeletingEvent => 'deleting', - $event instanceof ObjectCreatedEvent => 'created', - $event instanceof ObjectUpdatedEvent => 'updated', - $event instanceof ObjectDeletedEvent => 'deleted', - default => null, - }; - }//end resolveEventType() - - /** - * Load enabled hooks from the schema for a specific event type, sorted by order. - * - * @param Schema $schema The schema entity - * @param string $eventType The event type to filter by - * - * @return array> Sorted array of hook configurations - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-configuration-on-schema - */ - private function loadHooks(Schema $schema, string $eventType): array { - $allHooks = ($schema->getHooks() ?? []); - $filtered = []; - - foreach ($allHooks as $hook) { - if (($hook['enabled'] ?? true) === false) { - continue; - } - - if (($hook['event'] ?? '') !== $eventType) { - continue; - } - - $filtered[] = $hook; - } - - usort( - $filtered, - static function (array $hookA, array $hookB): int { - return ($hookA['order'] ?? 0) <=> ($hookB['order'] ?? 0); - } - ); - - return $filtered; - }//end loadHooks() - - /** - * Extract the ObjectEntity from the event. - * - * @param Event $event The lifecycle event - * - * @return ObjectEntity|null The object entity or null - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-configuration-on-schema - */ - private function getObjectFromEvent(Event $event): ?ObjectEntity { - if ($event instanceof ObjectCreatingEvent) { - return $event->getObject(); - } - - if ($event instanceof ObjectUpdatingEvent) { - return $event->getNewObject(); - } - - if ($event instanceof ObjectDeletingEvent) { - return $event->getObject(); - } - - if ($event instanceof ObjectCreatedEvent) { - return $event->getObject(); - } - - if ($event instanceof ObjectUpdatedEvent) { - return $event->getNewObject(); - } - - if ($event instanceof ObjectDeletedEvent) { - return $event->getObject(); - } - - return null; - }//end getObjectFromEvent() - - /** - * Check if the event has had its propagation stopped. - * - * @param Event $event The lifecycle event - * - * @return bool True if propagation is stopped - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-execution-order - */ - private function isEventStopped(Event $event): bool { - if ($event instanceof ObjectCreatingEvent) { - return $event->isPropagationStopped(); - } - - if ($event instanceof ObjectUpdatingEvent) { - return $event->isPropagationStopped(); - } - - if ($event instanceof ObjectDeletingEvent) { - return $event->isPropagationStopped(); - } - - return false; - }//end isEventStopped() - - /** - * Evaluate a hook's filterCondition against the object data. - * - * Supports simple dot-notation equality checks like {"field": "value"}. - * If no filterCondition is set, returns true (hook should execute). - * - * @param array $hook Hook configuration - * @param ObjectEntity $object The object entity - * - * @return bool True if the hook should execute - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-filter-condition-for-conditional-hook-execution - */ - private function evaluateFilterCondition(array $hook, ObjectEntity $object): bool { - $condition = ($hook['filterCondition'] ?? null); - if ($condition === null || (is_array($condition) === true && empty($condition) === true)) { - return true; - } - - if (is_array($condition) === false) { - return true; - } - - $objectData = ($object->getObject() ?? []); - - // Simple key-value matching: all conditions must match. - foreach ($condition as $field => $expected) { - $actual = ($objectData[$field] ?? null); - if ($actual !== $expected) { - return false; - } - } - - return true; - }//end evaluateFilterCondition() - - /** - * Execute a single hook against the event and object. - * - * @param array $hook Hook configuration - * @param Event $event The lifecycle event - * @param ObjectEntity $object The object entity - * @param Schema $schema The schema entity - * @param string $eventType The event type string - * - * @return void - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) Dispatch: filter, engine, async/sync, result, exception - each a required lifecycle branch - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-configuration-on-schema - */ - private function executeSingleHook( - array $hook, - Event $event, - ObjectEntity $object, - Schema $schema, - string $eventType, - ): void { - $hookId = ($hook['id'] ?? 'unknown'); - $engineType = ($hook['engine'] ?? ''); - $workflowId = ($hook['workflowId'] ?? ''); - $mode = ($hook['mode'] ?? 'sync'); - $timeout = ($hook['timeout'] ?? 30); - - // Skip hook if filterCondition is set and does not match the object data. - if ($this->evaluateFilterCondition(hook: $hook, object: $object) === false) { - $this->logger->debug( - message: "[HookExecutor] Hook '$hookId' skipped: filterCondition not met", - context: ['hookId' => $hookId, 'objectId' => $object->getUuid()] - ); - return; - } - - $startTime = hrtime(true); - - $payload = $this->buildCloudEventPayload( - object: $object, - schema: $schema, - eventType: $eventType, - hookId: $hookId, - mode: $mode - ); - - try { - $engines = $this->engineRegistry->getEnginesByType(engineType: $engineType); - if (empty($engines) === true) { - $this->applyFailureMode( - failureMode: ($hook['onEngineDown'] ?? 'allow'), - event: $event, - object: $object, - hook: $hook, - error: "No engine found for type '$engineType'" - ); - $this->logHookExecution( - hook: $hook, - eventType: $eventType, - object: $object, - startTime: $startTime, - success: false, - error: "No engine found for type '$engineType'" - ); - return; - } - - $engine = $engines[0]; - $adapter = $this->engineRegistry->resolveAdapter(engine: $engine); - - if ($mode === 'async') { - $this->executeAsyncHook( - adapter: $adapter, - workflowId: $workflowId, - payload: $payload, - hook: $hook, - eventType: $eventType, - object: $object, - startTime: $startTime - ); - return; - } - - $result = $adapter->executeWorkflow( - workflowId: $workflowId, - data: $payload, - timeout: $timeout - ); - - $this->processWorkflowResult( - result: $result, - event: $event, - object: $object, - hook: $hook, - eventType: $eventType, - startTime: $startTime - ); - } catch (Exception $e) { - $failureMode = $this->determineFailureMode(exception: $e, hook: $hook); - $this->applyFailureMode( - failureMode: $failureMode, - event: $event, - object: $object, - hook: $hook, - error: $e->getMessage() - ); - $this->logHookExecution( - hook: $hook, - eventType: $eventType, - object: $object, - startTime: $startTime, - success: false, - error: $e->getMessage(), - payload: $payload - ); - }//end try - }//end executeSingleHook() - - /** - * Build a CloudEvents payload for a hook execution. - * - * @param ObjectEntity $object The object entity - * @param Schema $schema The schema entity - * @param string $eventType The event type (creating, updating, deleting) - * @param string $hookId The hook configuration ID - * @param string $mode Hook mode (sync or async) - * - * @return array CloudEvent-formatted payload - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-lifecycle-events - */ - private function buildCloudEventPayload( - ObjectEntity $object, - Schema $schema, - string $eventType, - string $hookId, - string $mode, - ): array { - $objectData = $object->getObject(); - $source = '/apps/openregister/registers/' . $object->getRegister() . '/schemas/' . $schema->getId(); - - $payload = $this->cloudEventFormatter->formatAsCloudEvent( - eventType: 'nl.openregister.object.' . $eventType, - payload: [ - 'object' => $objectData, - 'schema' => ($schema->getSlug() ?? $schema->getTitle()), - 'register' => $object->getRegister(), - 'action' => $eventType, - 'hookMode' => $mode, - ], - source: $source, - subject: 'object:' . ($object->getUuid() ?? (string)$object->getId()) - ); - - $payload['openregister']['hookId'] = $hookId; - $payload['openregister']['expectResponse'] = ($mode === 'sync'); - - return $payload; - }//end buildCloudEventPayload() - - /** - * Execute an async hook (fire-and-forget). - * - * @param \OCA\OpenRegister\WorkflowEngine\WorkflowEngineInterface $adapter Engine adapter - * @param string $workflowId Workflow ID - * @param array $payload CloudEvent payload - * @param array $hook Hook configuration - * @param string $eventType Event type - * @param ObjectEntity $object Object entity - * @param int|float $startTime Start time from hrtime - * - * @return void - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-async-hook-execution-fire-and-forget - */ - private function executeAsyncHook( - $adapter, - string $workflowId, - array $payload, - array $hook, - string $eventType, - ObjectEntity $object, - $startTime, - ): void { - try { - $adapter->executeWorkflow( - workflowId: $workflowId, - data: $payload, - timeout: ($hook['timeout'] ?? 30) - ); - $this->logHookExecution( - hook: $hook, - eventType: $eventType, - object: $object, - startTime: $startTime, - success: true, - deliveryStatus: 'delivered' - ); - } catch (Exception $e) { - $this->logHookExecution( - hook: $hook, - eventType: $eventType, - object: $object, - startTime: $startTime, - success: false, - error: $e->getMessage(), - deliveryStatus: 'failed' - ); - }//end try - }//end executeAsyncHook() - - /** - * Process a sync workflow result and apply it to the event. - * - * @param WorkflowResult $result The workflow execution result - * @param Event $event The lifecycle event - * @param ObjectEntity $object The object entity - * @param array $hook Hook configuration - * @param string $eventType Event type - * @param int|float $startTime Start time from hrtime - * - * @return void - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-sync-hook-response-format - */ - private function processWorkflowResult( - WorkflowResult $result, - Event $event, - ObjectEntity $object, - array $hook, - string $eventType, - $startTime, - ): void { - if ($result->isApproved() === true) { - $this->logHookExecution( - hook: $hook, - eventType: $eventType, - object: $object, - startTime: $startTime, - success: true, - responseStatus: 'approved' - ); - return; - } - - if ($result->isModified() === true) { - $modifiedData = $result->getData(); - if ($modifiedData !== null) { - $this->setModifiedDataOnEvent(event: $event, data: $modifiedData); - } - - $this->logHookExecution( - hook: $hook, - eventType: $eventType, - object: $object, - startTime: $startTime, - success: true, - responseStatus: 'modified' - ); - return; - } - - if ($result->isRejected() === true) { - $this->applyFailureMode( - failureMode: ($hook['onFailure'] ?? 'reject'), - event: $event, - object: $object, - hook: $hook, - error: 'Workflow rejected the operation', - errors: $result->getErrors() - ); - $this->logHookExecution( - hook: $hook, - eventType: $eventType, - object: $object, - startTime: $startTime, - success: false, - responseStatus: 'rejected', - error: 'Workflow rejected the operation' - ); - return; - } - - // Error status. - $this->applyFailureMode( - failureMode: ($hook['onFailure'] ?? 'reject'), - event: $event, - object: $object, - hook: $hook, - error: 'Workflow returned error status', - errors: $result->getErrors() - ); - $this->logHookExecution( - hook: $hook, - eventType: $eventType, - object: $object, - startTime: $startTime, - success: false, - responseStatus: 'error', - error: 'Workflow returned error status' - ); - }//end processWorkflowResult() - - /** - * Set modified data on the event. - * - * @param Event $event The lifecycle event - * @param array $data Modified data to merge - * - * @return void - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-sync-hook-response-format - */ - private function setModifiedDataOnEvent(Event $event, array $data): void { - if ($event instanceof ObjectCreatingEvent) { - $event->setModifiedData(data: $data); - } elseif ($event instanceof ObjectUpdatingEvent) { - $event->setModifiedData(data: $data); - } elseif ($event instanceof ObjectDeletingEvent) { - $event->setModifiedData(data: $data); - } - }//end setModifiedDataOnEvent() - - /** - * Determine the appropriate failure mode based on the exception type. - * - * @param Exception $exception The caught exception - * @param array $hook Hook configuration - * - * @return string The failure mode to apply - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-failure-mode-behavior - */ - private function determineFailureMode(Exception $exception, array $hook): string { - $message = strtolower($exception->getMessage()); - - // Connection/timeout errors use engine-down or timeout mode. - if (str_contains($message, 'timeout') === true - || str_contains($message, 'timed out') === true - ) { - return ($hook['onTimeout'] ?? 'reject'); - } - - if (str_contains($message, 'connection') === true - || str_contains($message, 'unreachable') === true - || str_contains($message, 'refused') === true - ) { - return ($hook['onEngineDown'] ?? 'allow'); - } - - return ($hook['onFailure'] ?? 'reject'); - }//end determineFailureMode() - - /** - * Apply a failure mode to the event. - * - * @param string $failureMode Failure mode (reject/allow/flag/queue) - * @param Event $event The lifecycle event - * @param ObjectEntity $object The object entity - * @param array $hook Hook configuration - * @param string $error Error message - * @param array $errors Detailed validation errors - * - * @return void - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) Failure modes reject/allow/flag/queue plus unknown fallback - each case has distinct side effects - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-failure-mode-behavior - */ - private function applyFailureMode( - string $failureMode, - Event $event, - ObjectEntity $object, - array $hook, - string $error, - array $errors = [], - ): void { - $hookId = ($hook['id'] ?? 'unknown'); - - switch ($failureMode) { - case 'reject': - $this->stopEvent(event: $event, errors: $errors, fallbackError: $error); - $this->logger->error( - message: "[HookExecutor] Hook '$hookId' rejected: $error", - context: ['hookId' => $hookId, 'objectId' => $object->getUuid()] - ); - break; - - case 'allow': - $this->logger->warning( - message: "[HookExecutor] Hook '$hookId' failed (allow mode): $error", - context: ['hookId' => $hookId, 'objectId' => $object->getUuid()] - ); - break; - - case 'flag': - $this->setValidationMetadata( - object: $object, - status: 'failed', - errors: $errors, - fallbackError: $error - ); - $this->logger->warning( - message: "[HookExecutor] Hook '$hookId' failed (flag mode): $error", - context: ['hookId' => $hookId, 'objectId' => $object->getUuid()] - ); - break; - - case 'queue': - $this->setValidationMetadata(object: $object, status: 'pending'); - $this->scheduleRetryJob(object: $object, hook: $hook); - $this->logger->warning( - message: "[HookExecutor] Hook '$hookId' queued for retry: $error", - context: ['hookId' => $hookId, 'objectId' => $object->getUuid()] - ); - break; - - default: - $this->stopEvent(event: $event, errors: $errors, fallbackError: $error); - $this->logger->error( - message: "[HookExecutor] Unknown failure mode '$failureMode' for hook '$hookId', defaulting to reject", - context: ['hookId' => $hookId] - ); - }//end switch - }//end applyFailureMode() - - /** - * Stop event propagation and set errors. - * - * @param Event $event The lifecycle event - * @param array $errors Validation errors - * @param string $fallbackError Fallback error message - * - * @return void - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-failure-mode-behavior - */ - private function stopEvent(Event $event, array $errors, string $fallbackError): void { - $eventErrors = $errors; - if (empty($eventErrors) === true) { - $eventErrors = [['message' => $fallbackError]]; - } - - if ($event instanceof ObjectCreatingEvent) { - $event->stopPropagation(); - $event->setErrors(errors: $eventErrors); - } elseif ($event instanceof ObjectUpdatingEvent) { - $event->stopPropagation(); - $event->setErrors(errors: $eventErrors); - } elseif ($event instanceof ObjectDeletingEvent) { - $event->stopPropagation(); - $event->setErrors(errors: $eventErrors); - } - }//end stopEvent() - - /** - * Set validation metadata on the object entity. - * - * @param ObjectEntity $object The object entity - * @param string $status Validation status - * @param array $errors Validation errors - * @param string|null $fallbackError Fallback error message - * - * @return void - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-failure-mode-behavior - */ - private function setValidationMetadata( - ObjectEntity $object, - string $status, - array $errors = [], - ?string $fallbackError = null, - ): void { - $objectData = ($object->getObject() ?? []); - - $objectData['_validationStatus'] = $status; - - if (empty($errors) === false) { - $objectData['_validationErrors'] = $errors; - } elseif ($fallbackError !== null) { - $objectData['_validationErrors'] = [['message' => $fallbackError]]; - } - - $object->setObject($objectData); - }//end setValidationMetadata() - - /** - * Schedule a background retry job for a hook. - * - * @param ObjectEntity $object The object entity - * @param array $hook Hook configuration - * - * @return void - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-retry-via-background-job - */ - private function scheduleRetryJob(ObjectEntity $object, array $hook): void { - $this->jobList->add( - job: \OCA\OpenRegister\BackgroundJob\HookRetryJob::class, - argument: [ - 'objectId' => $object->getId(), - 'schemaId' => $object->getSchema(), - 'hook' => $hook, - ] - ); - }//end scheduleRetryJob() - - /** - * Log a hook execution and persist it to the WorkflowExecution entity. - * - * @param array $hook Hook configuration - * @param string $eventType Event type - * @param ObjectEntity $object Object entity - * @param int|float $startTime Start time from hrtime(true) - * @param bool $success Whether execution succeeded - * @param string|null $error Error message if failed - * @param string|null $responseStatus Response status from workflow - * @param string|null $deliveryStatus Delivery status for async hooks - * @param array|null $payload Request payload (logged on failure) - * - * @return void - * - * @SuppressWarnings(PHPMD.ExcessiveParameterList) hook, timing, event type, object, and 4 optional fields; extracted from logHookExecution - * - * @spec openspec/specs/schema-hooks/spec.md#requirement-hook-execution-order - */ - private function logHookExecution( - array $hook, - string $eventType, - ObjectEntity $object, - $startTime, - bool $success, - ?string $error = null, - ?string $responseStatus = null, - ?string $deliveryStatus = null, - ?array $payload = null, - ): void { - $durationMs = (int)((hrtime(true) - $startTime) / 1_000_000); - $hookId = ($hook['id'] ?? 'unknown'); - $engineName = ($hook['engine'] ?? 'unknown'); - $workflowId = ($hook['workflowId'] ?? 'unknown'); - $mode = ($hook['mode'] ?? 'sync'); - $objectUuid = ($object->getUuid() ?? (string)$object->getId()); - - $context = [ - 'hookId' => $hookId, - 'eventType' => $eventType, - 'objectUuid' => $objectUuid, - 'engine' => $engineName, - 'workflowId' => $workflowId, - 'durationMs' => $durationMs, - ]; - - if ($responseStatus !== null) { - $context['responseStatus'] = $responseStatus; - } - - if ($deliveryStatus !== null) { - $context['deliveryStatus'] = $deliveryStatus; - } - - $defaultStatus = 'error'; - if ($success === true) { - $defaultStatus = 'approved'; - } - - $persistedStatus = $responseStatus ?? $deliveryStatus ?? $defaultStatus; - - $this->persistExecutionHistory( - hook: $hook, - eventType: $eventType, - object: $object, - objectUuid: $objectUuid, - engineName: $engineName, - workflowId: $workflowId, - mode: $mode, - persistedStatus: $persistedStatus, - durationMs: $durationMs, - error: $error, - payload: $payload, - context: $context - ); - - $this->logExecutionOutcome( - success: $success, - hookId: $hookId, - eventType: $eventType, - objectUuid: $objectUuid, - durationMs: $durationMs, - error: $error, - payload: $payload, - context: $context - ); - }//end logHookExecution() - - /** - * Persist a hook execution record to the WorkflowExecution entity. - * - * @param array $hook Hook configuration. - * @param string $eventType Event type. - * @param ObjectEntity $object The object the hook ran against. - * @param string $objectUuid Resolved object UUID string. - * @param string $engineName Engine name. - * @param string $workflowId Workflow id. - * @param string $mode Execution mode (sync/async). - * @param string $persistedStatus Status to persist. - * @param int $durationMs Execution duration in ms. - * @param string|null $error Error message, if any. - * @param array|null $payload Request payload. - * @param array $context Log context array. - * - * @return void - * - * @SuppressWarnings(PHPMD.ExcessiveParameterList) Extracted from logHookExecution() to reduce - * NPath complexity; all params are distinct scalar/array values, no grouping reduces clarity. - */ - private function persistExecutionHistory( - array $hook, - string $eventType, - ObjectEntity $object, - string $objectUuid, - string $engineName, - string $workflowId, - string $mode, - string $persistedStatus, - int $durationMs, - ?string $error, - ?array $payload, - array $context, - ): void { - $hookId = ($hook['id'] ?? 'unknown'); - - $encodedErrors = null; - if ($error !== null) { - $encodedErrors = json_encode([['message' => $error]]); - } - - $encodedPayload = null; - if ($payload !== null) { - $encodedPayload = json_encode($payload); - } - - try { - $this->executionMapper->createFromArray( - [ - 'hookId' => $hookId, - 'eventType' => $eventType, - 'objectUuid' => $objectUuid, - 'schemaId' => $object->getSchema(), - 'registerId' => $object->getRegister(), - 'engine' => $engineName, - 'workflowId' => $workflowId, - 'mode' => $mode, - 'status' => $persistedStatus, - 'durationMs' => $durationMs, - 'errors' => $encodedErrors, - 'metadata' => json_encode($context), - 'payload' => $encodedPayload, - 'executedAt' => new DateTime(), - ] - ); - } catch (Exception $e) { - // Persistence failure MUST NOT fail the original hook execution. - $this->logger->warning( - message: '[HookExecutor] Failed to persist execution history', - context: ['hookId' => $hookId, 'error' => $e->getMessage()] - ); - }//end try - - }//end persistExecutionHistory() - - /** - * Emit an info or error log line for a completed hook execution. - * - * @param bool $success Whether execution succeeded. - * @param string $hookId Hook id. - * @param string $eventType Event type. - * @param string $objectUuid Object UUID string. - * @param int $durationMs Execution duration in ms. - * @param string|null $error Error message, if any. - * @param array|null $payload Request payload (appended to error context). - * @param array $context Base log context. - * - * @return void - */ - private function logExecutionOutcome( - bool $success, - string $hookId, - string $eventType, - string $objectUuid, - int $durationMs, - ?string $error, - ?array $payload, - array $context, - ): void { - if ($success === true) { - $this->logger->info( - message: "[HookExecutor] Hook '$hookId' ok ($eventType on '$objectUuid', {$durationMs}ms)", - context: $context - ); - return; - } - - $context['error'] = $error; - - if ($payload !== null) { - $context['payload'] = $payload; - } - - $this->logger->error( - message: "[HookExecutor] Hook '$hookId' failed for $eventType on object '$objectUuid': $error ({$durationMs}ms)", - context: $context - ); - }//end logExecutionOutcome() -}//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/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php index eccd79124e..a67e8fd1f5 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 @@ -2547,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); @@ -2584,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. * @@ -2601,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. @@ -2610,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 */ @@ -2620,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) { @@ -2851,32 +2710,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 +2770,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 +2801,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/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/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/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/Object/AdvisoryLockStore.php b/lib/Service/Object/AdvisoryLockStore.php new file mode 100644 index 0000000000..dfda333f76 --- /dev/null +++ b/lib/Service/Object/AdvisoryLockStore.php @@ -0,0 +1,168 @@ +` while it decides. It is stored + * in app-config with an expiry. + * + * It conflicts for EVERYONE regardless of identity, which is the right + * behaviour for a name reservation and the reason it needs none of the + * holder machinery that object locks now carry. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Object + * + * @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/object-interactions/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Object; + +use DateTime; +use OCA\OpenRegister\Exception\LockedException; +use OCP\IAppConfig; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * The appConfig-backed advisory lock store. + */ +class AdvisoryLockStore { + + /** + * Advisory-lock app-config key prefix. + * + * @var string + */ + private const PREFIX = 'advisory_lock_'; + + /** + * Default advisory lock duration in seconds when none supplied. + * + * @var int + */ + private const DEFAULT_DURATION = 3600; + + /** + * Constructor. + * + * @param IAppConfig $appConfig Where advisory locks are stored. + * @param IUserSession $userSession Names the holder, for display only. + * @param LoggerInterface $logger Reporting. + */ + public function __construct( + private readonly IAppConfig $appConfig, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Build the app-config key used to store an advisory lock. + * + * @param string $identifier The arbitrary advisory-lock identifier. + * + * @return string The namespaced app-config key. + */ + private function key(string $identifier): string { + return self::PREFIX . md5($identifier); + }//end key() + + /** + * Acquire an advisory lock for an identifier that does not (yet) resolve + * to a stored object. + * + * A still-valid lock raises LockedException; an expired one is silently + * overwritten. + * + * @param string $identifier The arbitrary advisory-lock identifier. + * @param string|null $process Optional process tag (who holds the lock). + * @param int|null $duration Lock duration in seconds. + * + * @return array{uuid: string, locked: array} Advisory lock result. + * + * @throws LockedException If a non-expired advisory lock already exists. + * + * @spec openspec/specs/object-interactions/spec.md + */ + public function acquire(string $identifier, ?string $process = null, ?int $duration = null): array { + $duration = ($duration ?? self::DEFAULT_DURATION); + $key = $this->key(identifier: $identifier); + $now = new DateTime(); + + $existingRaw = $this->appConfig->getValueString('openregister', $key, ''); + if ($existingRaw !== '') { + $existing = json_decode($existingRaw, true); + if (is_array($existing) === true && isset($existing['expiration']) === true) { + $expiration = new DateTime($existing['expiration']); + if ($expiration > $now) { + throw new LockedException(message: "Advisory lock '{$identifier}' is already held"); + } + } + } + + $expiration = (clone $now)->modify("+{$duration} seconds"); + $lock = [ + 'user' => $this->userSession->getUser()?->getUID(), + 'process' => $process, + 'created' => $now->format(DateTime::ATOM), + 'duration' => $duration, + 'expiration' => $expiration->format(DateTime::ATOM), + 'advisory' => true, + ]; + + $this->appConfig->setValueString('openregister', $key, json_encode($lock)); + + $this->logger->info( + message: '[AdvisoryLockStore] Advisory (pre-creation) lock acquired', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'identifier' => $identifier, + 'process' => $process, + ] + ); + + return ['uuid' => $identifier, 'locked' => $lock]; + }//end acquire() + + /** + * Release an advisory lock if one exists for the identifier. + * + * @param string $identifier The arbitrary advisory-lock identifier. + * + * @return bool True if an advisory lock was found and removed. + * + * @spec openspec/specs/object-interactions/spec.md + */ + public function release(string $identifier): bool { + $key = $this->key(identifier: $identifier); + if ($this->appConfig->getValueString('openregister', $key, '') === '') { + return false; + } + + $this->appConfig->deleteKey('openregister', $key); + $this->logger->info( + message: '[AdvisoryLockStore] Advisory (pre-creation) lock released', + context: ['file' => __FILE__, 'line' => __LINE__, 'identifier' => $identifier] + ); + + return true; + }//end release() +}//end class 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/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/lib/Service/Object/DeleteObject.php b/lib/Service/Object/DeleteObject.php index 4858f9b6d6..3abdcaef98 100644 --- a/lib/Service/Object/DeleteObject.php +++ b/lib/Service/Object/DeleteObject.php @@ -80,6 +80,16 @@ class DeleteObject { */ private int $lastCascadeCount = 0; + /** + * Cascade targets the archival obligation kept live during the last root delete. + * + * Reset at the start of each root deleteObject() invocation, exactly like + * $lastCascadeCount, so a reader never picks up a previous delete's answer. + * + * @var array> + */ + private array $lastRetainedTargets = []; + /** * Audit trail mapper * @@ -591,6 +601,7 @@ public function deleteObject( // Reset cascade count for root deletions. if ($originalObjectId === null) { $this->lastCascadeCount = 0; + $this->lastRetainedTargets = []; } // Resolve the lookup: when the caller has guaranteed a (register, schema) @@ -880,7 +891,7 @@ private function executeIntegrityTransaction( $triggerSlug = $contextSchema->getSlug(); } - $this->integrityService->applyDeletionActions( + $integrityResult = $this->integrityService->applyDeletionActions( $analysis, $userId, $uuid, @@ -888,10 +899,18 @@ private function executeIntegrityTransaction( $triggerSlug ); - $cCount = count($analysis->cascadeTargets); + // A RETAINED RECORD STAYS LIVE EVEN WHEN ITS PARENT GOES, so it is not + // a cascade casualty and must not be counted as one. The count used to + // be `count($analysis->cascadeTargets)`, the number of rows the + // analysis WANTED to delete, which is now a different number from the + // rows the cascade actually took. Reporting the analysis figure would + // tell a bulk caller that a record it can still see was deleted. + $this->lastRetainedTargets = $integrityResult['retained']; + + $cCount = (count($analysis->cascadeTargets) - count($this->lastRetainedTargets)); $nCount = count($analysis->nullifyTargets); $dCount = count($analysis->defaultTargets); - $this->lastCascadeCount = ($cCount + $nCount + $dCount); + $this->lastCascadeCount = (max(0, $cCount) + $nCount + $dCount); $this->runLegacyCascade(context: $context, object: $object, uuid: $uuid); @@ -1315,6 +1334,21 @@ public function getLastCascadeCount(): int { return $this->lastCascadeCount; }//end getLastCascadeCount() + /** + * Get the cascade targets the archival obligation kept live in the last root delete. + * + * The parent delete proceeded; these records did not go with it. Each entry + * names the record, the ground it was kept on and what the reader does next, + * so a caller can report the retention rather than leave it to a log line. + * + * @return array> One report per retained record, empty when none. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + public function getLastRetainedCascadeTargets(): array { + return $this->lastRetainedTargets; + }//end getLastRetainedCascadeTargets() + /** * Delegate a delete on an object-source schema to its writable provider. * diff --git a/lib/Service/Object/LockHandler.php b/lib/Service/Object/LockHandler.php index 840e8e6945..1ad5976a3b 100644 --- a/lib/Service/Object/LockHandler.php +++ b/lib/Service/Object/LockHandler.php @@ -36,7 +36,6 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Exception\LockedException; -use OCP\IAppConfig; use OCP\IGroupManager; use OCP\IUserSession; use Psr\Log\LoggerInterface; @@ -68,7 +67,8 @@ class LockHandler { * @param IUserSession $userSession User session for authorization checks * @param IGroupManager $groupManager Group manager for admin checks * @param SchemaMapper $schemaMapper Schema mapper for resolving manage rules - * @param IAppConfig $appConfig App config store for advisory (pre-creation) locks + * @param AdvisoryLockStore $advisory Pre-creation locks, for identifiers that do not resolve to a stored object + * @param RunLockRegistry|null $runLocks Bookkeeping for run-held locks, so the terminal listener and the sweep can find them without reading objects */ public function __construct( private readonly MagicMapper $magicMapper, @@ -77,115 +77,11 @@ public function __construct( private readonly IUserSession $userSession, private readonly IGroupManager $groupManager, private readonly SchemaMapper $schemaMapper, - private readonly IAppConfig $appConfig, + private readonly AdvisoryLockStore $advisory, + private readonly ?RunLockRegistry $runLocks = null, ) { }//end __construct() - /** - * Advisory-lock app-config key prefix. - * - * @var string - */ - private const ADVISORY_LOCK_PREFIX = 'advisory_lock_'; - - /** - * Default advisory lock duration in seconds when none supplied. - * - * @var int - */ - private const ADVISORY_LOCK_DEFAULT_DURATION = 3600; - - /** - * Build the app-config key used to store an advisory (pre-creation) lock. - * - * @param string $identifier The arbitrary advisory-lock identifier. - * - * @return string The namespaced app-config key. - */ - private function advisoryLockKey(string $identifier): string { - return self::ADVISORY_LOCK_PREFIX . md5($identifier); - }//end advisoryLockKey() - - /** - * Acquire an advisory (pre-creation) lock for an arbitrary identifier that - * does not (yet) resolve to a stored object. - * - * Supports create-then-store flows (e.g. the openbuild wizard locking - * `createApp:` before the object exists). The lock is stored in - * app-config with an expiry timestamp. A still-valid lock raises - * LockedException; an expired lock is silently overwritten. - * - * @param string $identifier The arbitrary advisory-lock identifier. - * @param string|null $process Optional process tag (who holds the lock). - * @param int|null $duration Lock duration in seconds. - * - * @return array{uuid: string, locked: array} Advisory lock result. - * - * @throws LockedException If a non-expired advisory lock already exists. - */ - private function acquireAdvisoryLock(string $identifier, ?string $process = null, ?int $duration = null): array { - $duration = ($duration ?? self::ADVISORY_LOCK_DEFAULT_DURATION); - $key = $this->advisoryLockKey(identifier: $identifier); - $now = new DateTime(); - - $existingRaw = $this->appConfig->getValueString('openregister', $key, ''); - if ($existingRaw !== '') { - $existing = json_decode($existingRaw, true); - if (is_array($existing) === true && isset($existing['expiration']) === true) { - $expiration = new DateTime($existing['expiration']); - if ($expiration > $now) { - throw new LockedException(message: "Advisory lock '{$identifier}' is already held"); - } - } - } - - $expiration = (clone $now)->modify("+{$duration} seconds"); - $lock = [ - 'user' => $this->userSession->getUser()?->getUID(), - 'process' => $process, - 'created' => $now->format(DateTime::ATOM), - 'duration' => $duration, - 'expiration' => $expiration->format(DateTime::ATOM), - 'advisory' => true, - ]; - - $this->appConfig->setValueString('openregister', $key, json_encode($lock)); - - $this->logger->info( - message: '[LockHandler] Advisory (pre-creation) lock acquired', - context: [ - 'file' => __FILE__, - 'line' => __LINE__, - 'identifier' => $identifier, - 'process' => $process, - ] - ); - - return ['uuid' => $identifier, 'locked' => $lock]; - }//end acquireAdvisoryLock() - - /** - * Release an advisory (pre-creation) lock if one exists for the identifier. - * - * @param string $identifier The arbitrary advisory-lock identifier. - * - * @return bool True if an advisory lock was found and removed. - */ - private function releaseAdvisoryLock(string $identifier): bool { - $key = $this->advisoryLockKey(identifier: $identifier); - if ($this->appConfig->getValueString('openregister', $key, '') === '') { - return false; - } - - $this->appConfig->deleteKey('openregister', $key); - $this->logger->info( - message: '[LockHandler] Advisory (pre-creation) lock released', - context: ['file' => __FILE__, 'line' => __LINE__, 'identifier' => $identifier] - ); - - return true; - }//end releaseAdvisoryLock() - /** * Find an object and get its register/schema context. * @@ -298,10 +194,13 @@ private function callerHasSchemaManagePermission(ObjectEntity $object): bool { * finding where any authenticated user could unlock any locked object. * * @param ObjectEntity $object The locked object the caller wants to unlock. + * @param string|null $runUuid The flow run asking, when a run is asking. * * @return bool True if the caller may unlock this object. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-an-administrator-may-break-a-lock-and-the-break-is-recorded */ - private function callerMayUnlock(ObjectEntity $object): bool { + private function callerMayUnlock(ObjectEntity $object, ?string $runUuid = null): bool { $user = $this->userSession->getUser(); if ($user === null) { return false; @@ -309,17 +208,29 @@ private function callerMayUnlock(ObjectEntity $object): bool { $userId = $user->getUID(); - // Admins always pass. + // Admins always pass. For a run lock this is the BREAK-LOCK route and + // the only one, so a wedged run cannot hold a case hostage; callers + // that mean to break a run's lock should use breakLock(), which + // records the displacement. if ($this->groupManager->isAdmin($userId) === true) { return true; } - // Lock holder. - $lockInfo = $object->getLockInfo(); - if (is_array($lockInfo) === true && ($lockInfo['user'] ?? null) === $userId) { + // The holder. For a run lock this admits only the holding run, and in + // particular NOT the run's own runAs user: a person must not be able + // to walk in behind the identity the run happens to execute as. + if ($object->isLockedBySomeoneElse(userId: $userId, runUuid: $runUuid) === false) { return true; } + // The object-owner and schema-manage routes are for USER locks only. + // Extending them to run locks would mean any case owner could quietly + // defeat the engine's mutual exclusion, which is the whole point of a + // run lock. They keep an administrator break as their escape hatch. + if ($object->getLockedByRun() !== null) { + return false; + } + // Object owner. if ($object->getOwner() === $userId) { return true; @@ -341,6 +252,10 @@ private function callerMayUnlock(ObjectEntity $object): bool { * @param bool $advisory When true, skip the object lookup and take the * appConfig-backed advisory lock directly (used by * pre-creation guards where no object exists yet) + * @param string|null $runUuid Flow run taking the lock. A run-scoped lock + * refuses every other caller, the run's own + * runAs user included. + * @param string|null $nodeId Flow node that took it, recorded for the sweep * * @return array Lock result with locked details and uuid. * @@ -349,7 +264,14 @@ private function callerMayUnlock(ObjectEntity $object): bool { * * @spec openspec/specs/object-interactions/spec.md */ - public function lock(string $identifier, ?string $process = null, ?int $duration = null, bool $advisory = false): array { + public function lock( + string $identifier, + ?string $process = null, + ?int $duration = null, + bool $advisory = false, + ?string $runUuid = null, + ?string $nodeId = null, + ): array { $this->logger->debug( message: '[LockHandler] Locking object', context: [ @@ -368,7 +290,7 @@ public function lock(string $identifier, ?string $process = null, ?int $duration // lock and skip findObjectWithContext, whose findAcrossAllMagicTables scan // would otherwise query every magic table just to conclude "not found". if ($advisory === true) { - return $this->acquireAdvisoryLock(identifier: $identifier, process: $process, duration: $duration); + return $this->advisory->acquire(identifier: $identifier, process: $process, duration: $duration); } try { @@ -381,42 +303,20 @@ public function lock(string $identifier, ?string $process = null, ?int $duration // `createApp:` before the object exists). Fall back to an // advisory lock keyed by the arbitrary string so create-then-store // flows work instead of failing with a 404/422. - return $this->acquireAdvisoryLock( + return $this->advisory->acquire( identifier: $identifier, process: $process, duration: $duration ); } - $objectBefore = $context['object']; - - // Use MagicMapper for lock operation. - $objectAfter = $this->magicMapper->lockObjectEntity( - entity: $objectBefore, - register: $context['register'], - schema: $context['schema'], - lockDuration: $duration - ); - - $lockResult = [ - 'uuid' => $objectAfter->getUuid(), - 'locked' => $objectAfter->getLocked(), - ]; - - // Record lock action in audit trail. - $this->auditTrailMapper->createAuditTrail(old: $objectBefore, new: $objectAfter, action: 'lock'); - - $this->logger->info( - message: '[LockHandler] Object locked successfully', - context: [ - 'file' => __FILE__, - 'line' => __LINE__, - 'identifier' => $identifier, - 'process' => $process, - ] + return $this->lockStoredObject( + context: $context, + duration: $duration, + process: $process, + runUuid: $runUuid, + nodeId: $nodeId ); - - return $lockResult; } catch (LockedException $e) { $this->logger->warning( message: '[LockHandler] Object is already locked', @@ -442,6 +342,69 @@ public function lock(string $identifier, ?string $process = null, ?int $duration }//end try }//end lock() + /** + * Take the lock on a resolved object and record it. + * + * @param array $context The resolved object with its register and schema. + * @param int|null $duration Lock duration in seconds. + * @param string|null $process What the lock was taken for. + * @param string|null $runUuid Flow run taking the lock, for a run-scoped lock. + * @param string|null $nodeId Flow node that took it, recorded for the sweep. + * + * @return array Lock result with locked details and uuid. + * + * @throws LockedException If a mapper-level lock refuses the acquire. + * @throws \Exception If the object is already held by another holder. + * `ObjectEntity::lock()` throws the global Exception, + * not LockedException, so lock()'s outer handler needs + * both arms. Declaring only the narrow one made PHPStan + * read the broad catch as dead code when it is the arm + * that actually fires on contention. + */ + private function lockStoredObject( + array $context, + ?int $duration, + ?string $process, + ?string $runUuid, + ?string $nodeId, + ): array { + $objectBefore = $context['object']; + + // Use MagicMapper for lock operation. + $objectAfter = $this->magicMapper->lockObjectEntity( + entity: $objectBefore, + register: $context['register'], + schema: $context['schema'], + lockDuration: $duration, + process: $process, + runUuid: $runUuid + ); + + // Record a run's lock so the terminal listener and the sweep can find + // it without reading objects. Bookkeeping only: the payload on the + // object stays the sole authority for the write guard, so a failure + // to record must not undo a lock that was taken. + $this->runLocks?->record(object: $objectAfter, runUuid: $runUuid, nodeId: $nodeId); + + // Record lock action in audit trail. + $this->auditTrailMapper->createAuditTrail(old: $objectBefore, new: $objectAfter, action: 'lock'); + + $this->logger->info( + message: '[LockHandler] Object locked successfully', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'uuid' => $objectAfter->getUuid(), + 'process' => $process, + ] + ); + + return [ + 'uuid' => $objectAfter->getUuid(), + 'locked' => $objectAfter->getLocked(), + ]; + }//end lockStoredObject() + /** * Unlock an object * @@ -450,6 +413,10 @@ public function lock(string $identifier, ?string $process = null, ?int $duration * @param string $identifier Object ID or UUID * @param bool $advisory When true, release the appConfig-backed advisory lock * directly and skip the object lookup / all-tables scan + * @param string|null $runUuid Flow run releasing the lock, for a run-scoped lock + * @param bool $break Release regardless of holder. Reserved for the + * administrator break-lock and the engine's own release + * layers, both of which authorize at their call site. * * @return true True if unlocked successfully * @@ -457,7 +424,12 @@ public function lock(string $identifier, ?string $process = null, ?int $duration * * @spec openspec/specs/object-interactions/spec.md */ - public function unlock(string $identifier, bool $advisory = false): bool { + public function unlock( + string $identifier, + bool $advisory = false, + ?string $runUuid = null, + bool $break = false, + ): bool { $this->logger->debug( message: '[LockHandler] Unlocking object', context: ['file' => __FILE__, 'line' => __LINE__, 'identifier' => $identifier, 'advisory' => $advisory] @@ -466,7 +438,7 @@ public function unlock(string $identifier, bool $advisory = false): bool { // Advisory (pre-creation) fast-path — mirror of lock(): release the // appConfig-backed advisory lock directly and skip the all-tables scan. if ($advisory === true) { - $this->releaseAdvisoryLock(identifier: $identifier); + $this->advisory->release(identifier: $identifier); return true; } @@ -487,7 +459,7 @@ public function unlock(string $identifier, bool $advisory = false): bool { // Pre-creation / advisory lock release: identifier never // resolved to a stored object. Clear any advisory lock held // under this arbitrary key (no-op if none present). - $this->releaseAdvisoryLock(identifier: $identifier); + $this->advisory->release(identifier: $identifier); return true; } @@ -503,7 +475,7 @@ public function unlock(string $identifier, bool $advisory = false): bool { return true; } - if ($this->callerMayUnlock(object: $objectBefore) === false) { + if ($break === false && $this->callerMayUnlock(object: $objectBefore, runUuid: $runUuid) === false) { $this->logger->warning( message: '[LockHandler] Unauthorized unlock attempt', context: [ @@ -515,24 +487,7 @@ public function unlock(string $identifier, bool $advisory = false): bool { throw new Exception('User does not have permission to unlock this object'); } - // Use MagicMapper for unlock operation. - $objectAfter = $this->magicMapper->unlockObjectEntity( - entity: $objectBefore, - register: $context['register'], - schema: $context['schema'] - ); - - // Record unlock action in audit trail. - $this->auditTrailMapper->createAuditTrail(old: $objectBefore, new: $objectAfter, action: 'unlock'); - - $this->logger->info( - message: '[LockHandler] Object unlocked successfully', - context: [ - 'file' => __FILE__, - 'line' => __LINE__, - 'identifier' => $identifier, - ] - ); + $this->releaseStoredLock(context: $context, runUuid: $runUuid, break: $break); return true; } catch (\Exception $e) { @@ -549,6 +504,92 @@ public function unlock(string $identifier, bool $advisory = false): bool { }//end try }//end unlock() + /** + * Release the lock on a resolved object, auditing what happened. + * + * @param array $context The resolved object with its register and schema. + * @param string|null $runUuid Flow run releasing the lock, for a run-scoped lock. + * @param bool $break Release regardless of holder; authorized at the call site. + * + * @return void + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Mirrors unlock()'s own break flag. + */ + private function releaseStoredLock(array $context, ?string $runUuid, bool $break): void { + $objectBefore = $context['object']; + + // Capture the holder BEFORE the release, so a break can name what it + // displaced. After unlockObjectEntity() there is nothing left to name. + $displaced = $objectBefore->describeLockHolder(); + $displacedRun = $objectBefore->getLockedByRun(); + + $objectAfter = $this->magicMapper->unlockObjectEntity( + entity: $objectBefore, + register: $context['register'], + schema: $context['schema'], + runUuid: $runUuid, + break: $break + ); + + if ($displacedRun !== null) { + $this->runLocks?->forget(runUuid: $displacedRun, objectUuid: (string)$objectAfter->getUuid()); + } + + // A BREAK is recorded under its own action, naming the holder it + // displaced. An administrator overriding somebody else's lock is not + // the same event as a holder releasing their own, and an audit trail + // that cannot tell them apart cannot answer who took the case back. + if ($break === true) { + $this->auditTrailMapper->createAuditTrailEntry( + object: $objectAfter, + action: 'lock.broken', + context: [ + 'displacedHolder' => $displaced, + 'displacedRun' => $displacedRun, + ] + ); + } + + $this->auditTrailMapper->createAuditTrail(old: $objectBefore, new: $objectAfter, action: 'unlock'); + + $this->logger->info( + message: '[LockHandler] Object unlocked successfully', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'uuid' => $objectAfter->getUuid(), + ] + ); + }//end releaseStoredLock() + + /** + * Break a lock as an administrator, and record the displacement. + * + * The escape hatch for a wedged run. A run lock refuses everybody by + * design, holder, object owner and schema manager included, so without a + * break a run that died in a way none of the three release layers caught + * would hold a case until its TTL ran out. Restricted to administrators + * and always audited: an override nobody can see afterwards is + * indistinguishable from the lock never having worked. + * + * @param string $identifier Object ID or UUID. + * + * @return bool True when a lock was broken. + * + * @throws Exception If the caller is not an administrator. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-an-administrator-may-break-a-lock-and-the-break-is-recorded + */ + public function breakLock(string $identifier): bool { + $user = $this->userSession->getUser(); + if ($user === null || $this->groupManager->isAdmin($user->getUID()) === false) { + throw new Exception('Only an administrator may break a lock'); + } + + return $this->unlock(identifier: $identifier, break: true); + }//end breakLock() + + /** * Check if an object is locked * diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php index 81b0b3439e..2d5934ba78 100644 --- a/lib/Service/Object/PermissionHandler.php +++ b/lib/Service/Object/PermissionHandler.php @@ -1161,7 +1161,25 @@ public function filterUuidsForPermissions(array $uuids, bool $_rbac, bool $_mult $activeOrganisation = $this->getActiveOrganisationForContext(); // Get objects for permission checking. - $objects = $this->objectEntityMapper->findAll(ids: $uuids, includeDeleted: true); + // + // This must be the CROSS-TABLE lookup. `MagicMapper::findAll()` needs a + // Register AND a Schema entity to know which magic table to read, and + // returns `[]` — after a log warning and nothing else — when it has + // neither. This handler has neither, so asking it here resolved nothing + // for every input: the filter answered `[]` unconditionally, and its one + // caller, ObjectService::deleteObjects(), then ran zero loop iterations. + // `POST /api/bulk/{register}/{schema}/delete` accordingly deleted nothing + // and reported nothing, answering `requested 1, deleted 0, skipped 0`. + // + // A bulk delete is cross-table by nature — its UUIDs may sit in any magic + // table — which is why deleteObjects() already resolves the very same + // UUIDs through findMultipleAcrossAllMagicTables() a few lines later. + // Soft-deleted rows are included: bulk-deleting an already-trashed object + // is how it is destroyed for good. + $objects = $this->objectEntityMapper->findMultipleAcrossAllMagicTables( + uuids: $uuids, + includeDeleted: true + ); foreach ($objects as $object) { $objectUuid = $object->getUuid(); diff --git a/lib/Service/Object/ReferentialIntegrityService.php b/lib/Service/Object/ReferentialIntegrityService.php index 5036a128c4..f01c4d36fc 100644 --- a/lib/Service/Object/ReferentialIntegrityService.php +++ b/lib/Service/Object/ReferentialIntegrityService.php @@ -40,6 +40,7 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Dto\DeletionAnalysis; +use OCA\OpenRegister\Service\Archival\ArchivalRetentionGuard; use OCP\ICacheFactory; use OCP\IDBConnection; use Psr\Log\LoggerInterface; @@ -148,6 +149,7 @@ class ReferentialIntegrityService { * @param LoggerInterface $logger Logger for debugging. * @param IDBConnection $db Database connection for raw SQL queries. * @param ICacheFactory $cacheFactory Distributed cache for the relation index. + * @param ArchivalRetentionGuard $archivalGuard Decides whether a cascade target is legally retained. * * @spec openspec/specs/object-lifecycle/spec.md */ @@ -159,6 +161,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly IDBConnection $db, private readonly ICacheFactory $cacheFactory, + private readonly ArchivalRetentionGuard $archivalGuard, ) { }//end __construct() @@ -201,7 +204,9 @@ public function canDelete(ObjectEntity $object): DeletionAnalysis { * @param string|null $organisationId The active organisation ID. * @param string|null $triggerSchemaSlug Slug of the schema of the deleted object (for audit trail). * - * @return void + * @return array{retained: array>} The cascade targets the + * archival obligation kept live, each naming the record and the ground. + * Empty when nothing was retained. * * @SuppressWarnings(PHPMD.CyclomaticComplexity) Multiple action types require distinct handling paths * @@ -213,7 +218,7 @@ public function applyDeletionActions( string $cascadeSource, ?string $organisationId = null, ?string $triggerSchemaSlug = null, - ): void { + ): array { // 1. Apply SET_NULL targets first (objects survive with cleared reference). foreach ($analysis->nullifyTargets as $target) { $this->applySetNull(target: $target); @@ -255,6 +260,14 @@ public function applyDeletionActions( // 3. Apply CASCADE targets in batch (deepest first = reverse order). $cascadeTargets = array_reverse($analysis->cascadeTargets); + // A RETAINED RECORD STAYS LIVE EVEN WHEN ITS PARENT GOES. The batch below + // soft-deletes with `hardDelete: false`, so an archival child of a + // non-archival parent used to be tombstoned by a delete aimed at somebody + // else. It is kept out of the batch now and named in the return value, + // and the parent delete still proceeds: a retained child refuses the + // cascade, it does not block the delete that reached it. + [$cascadeTargets, $retained] = $this->partitionRetainedTargets(cascadeTargets: $cascadeTargets); + if (empty($cascadeTargets) === false) { $this->applyBatchCascadeDelete( cascadeTargets: $cascadeTargets, @@ -264,8 +277,69 @@ public function applyDeletionActions( organisationId: $organisationId ); } + + return ['retained' => $retained]; }//end applyDeletionActions() + /** + * Split cascade targets into the ones that may be deleted and the ones the law keeps. + * + * Asks {@see ArchivalRetentionGuard}, which asks + * {@see \OCA\OpenRegister\Db\Schema::hasArchivalAnnotation()}. That is the + * single definition of "is archival" and the four HTTP delete doors read the + * same one, so the cascade cannot quietly disagree with them. + * + * A target referenced through two properties appears twice in the analysis + * and is reported once, keyed by uuid, so the caller's count matches the + * number of records it can go and look at. + * + * @param array> $cascadeTargets Targets, already reversed. + * + * @return array{0: array>, 1: array>} + * [0] targets the cascade may delete, [1] one report per retained record. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + private function partitionRetainedTargets(array $cascadeTargets): array { + $proceed = []; + $reported = []; + + foreach ($cascadeTargets as $target) { + $uuid = (string)($target['objectUuid'] ?? ''); + if ($uuid === '') { + $proceed[] = $target; + continue; + } + + if (isset($reported[$uuid]) === true) { + continue; + } + + $refusal = $this->archivalGuard->cascadeRefusal( + uuid: $uuid, + schemaIdentifier: ($target['schema'] ?? null) + ); + if ($refusal === null) { + $proceed[] = $target; + continue; + } + + $reported[$uuid] = $refusal; + $this->logger->info( + message: '[ReferentialIntegrity] Kept a retained record out of a cascade', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'uuid' => $uuid, + 'schema' => ($target['schema'] ?? null), + 'ground' => $refusal['ground'], + ] + ); + } + + return [$proceed, array_values($reported)]; + }//end partitionRetainedTargets() + /** * Log a RESTRICT block event to the audit trail. * diff --git a/lib/Service/Object/RenderObject.php b/lib/Service/Object/RenderObject.php index 60897c2e81..8d6ee1b222 100644 --- a/lib/Service/Object/RenderObject.php +++ b/lib/Service/Object/RenderObject.php @@ -2813,7 +2813,15 @@ private function extendObjectSourceRefs( } $ref = ($definition['$ref'] ?? ($definition['items']['$ref'] ?? null)); - if (is_string($ref) === false || $ref === '') { + + // An INT is as valid a `$ref` as a slug, and is what a descriptor + // ends up holding: `ImportHandler` rewrites `"$ref": "nc-organisation"` + // to the resolved schema id. Requiring a string here meant a + // reference resolved fine when hand-written and silently stopped + // resolving the moment it had been through an import — the property + // rendered as its raw key and nothing reported anything. + // `getSchema()` has always accepted `int|string`. + if (is_int($ref) === false && (is_string($ref) === false || $ref === '')) { continue; } diff --git a/lib/Service/Object/RevertHandler.php b/lib/Service/Object/RevertHandler.php index fb526a3d30..57680d8063 100644 --- a/lib/Service/Object/RevertHandler.php +++ b/lib/Service/Object/RevertHandler.php @@ -37,6 +37,12 @@ /** * Class RevertHandler * Service for handling object reversion + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) One collaborator over the + * threshold, and it is the ambient flow-run stack: a revert is a write, and a + * write guard that cannot name the caller's run refuses the run holding the + * lock. Splitting the class to avoid naming one more type would trade a real + * guard for a metric. */ class RevertHandler { @@ -151,14 +157,13 @@ public function revert( ); } - // Check if the object is locked. - if ($object->isLocked() === true) { - $userId = $this->container->get('userId'); - if ($object->getLockedBy() !== $userId) { - throw new LockedException( - message: sprintf('Object is locked by %s', $object->getLockedBy()) - ); - } + // Check if the object is locked. Ownership is decided by the one + // production predicate, so a run-held lock refuses the run's own + // runAs user here exactly as it does at every other guard. + if ($object->isLockedBySomeoneElse(userId: $this->container->get('userId'), runUuid: $this->callerRunUuid()) === true) { + throw new LockedException( + message: sprintf('Object is locked by %s', (string)$object->describeLockHolder()) + ); } // Get the reverted object using AuditTrailMapper. @@ -180,4 +185,35 @@ public function revert( return $savedObject; }//end revert() + + /** + * The flow run this write is being made for, or null when a person is + * writing. + * + * A run-scoped lock refuses every caller but the holding run, so a guard + * that cannot name the caller's run refuses the run that took the lock. + * Resolved from the container rather than injected because the ambient + * stack is a shared service and this is the only thing here that needs it. + * + * A container that cannot serve it answers "a person", which is the + * FAIL-CLOSED direction: a run lock refuses a caller with no run uuid, so + * the worst outcome is a refusal, never a lock walked through. + * + * @return string|null The executing run's uuid, or null. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-ownership-is-decided-by-one-predicate + */ + private function callerRunUuid(): ?string { + try { + $flowContext = $this->container->get(\OCA\OpenRegister\Service\Flow\FlowRunContext::class); + } catch (\Throwable $unavailable) { + return null; + } + + if ($flowContext instanceof \OCA\OpenRegister\Service\Flow\FlowRunContext === false) { + return null; + } + + return $flowContext->currentRunUuid(); + }//end callerRunUuid() }//end class diff --git a/lib/Service/Object/RunLockRegistry.php b/lib/Service/Object/RunLockRegistry.php new file mode 100644 index 0000000000..3c5e7f07f5 --- /dev/null +++ b/lib/Service/Object/RunLockRegistry.php @@ -0,0 +1,297 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Object + * + * @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/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Object; + +use DateTime; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\RunObjectLock; +use OCA\OpenRegister\Db\RunObjectLockMapper; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Records and releases the object locks a flow run holds. + */ +class RunLockRegistry { + + /** + * Constructor. + * + * @param RunObjectLockMapper $registry The run-lock rows. + * @param MagicMapper $magicMapper Resolves and mutates the objects themselves. + * @param AuditTrailMapper $auditTrailMapper Records the releases. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly RunObjectLockMapper $registry, + private readonly MagicMapper $magicMapper, + private readonly AuditTrailMapper $auditTrailMapper, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Record that a run holds a lock. + * + * A failure here is logged and swallowed: the lock itself is already + * taken and is what the write guard reads, so failing the lock because + * its index entry did not land would trade a working lock for no lock at + * all. The TTL still bounds a lock whose row is missing. + * + * @param ObjectEntity $object The locked object. + * @param string|null $runUuid The holding run, or null for a user lock. + * @param string|null $nodeId The flow node that took it. + * + * @return void + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + public function record(ObjectEntity $object, ?string $runUuid, ?string $nodeId): void { + if ($runUuid === null || trim($runUuid) === '') { + return; + } + + try { + $payload = ($object->getLocked() ?? []); + $expires = null; + if (isset($payload['expiration']) === true) { + $expires = new DateTime((string)$payload['expiration']); + } + + $row = new RunObjectLock(); + $row->setRunUuid(trim($runUuid)); + $row->setObjectUuid((string)$object->getUuid()); + $row->setRegisterId((string)$object->getRegister()); + $row->setSchemaId((string)$object->getSchema()); + $row->setNodeId($nodeId); + $row->setLockedAt(new DateTime()); + $row->setExpiresAt($expires); + + $this->registry->record(lock: $row); + } catch (Throwable $e) { + $this->logger->warning( + message: '[RunLockRegistry] Could not record a run lock; the lock itself stands', + context: ['file' => __FILE__, 'line' => __LINE__, 'runUuid' => $runUuid, 'error' => $e->getMessage()] + ); + }//end try + }//end record() + + /** + * Forget one run's row for one object, after the lock has been released. + * + * @param string $runUuid The run. + * @param string $objectUuid The object. + * + * @return void + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + public function forget(string $runUuid, string $objectUuid): void { + try { + $this->registry->forget(runUuid: $runUuid, objectUuid: $objectUuid); + } catch (Throwable $e) { + // A row left behind is collected by the sweep on its own terms. + $this->logger->debug( + message: '[RunLockRegistry] Could not forget a run lock row', + context: ['file' => __FILE__, 'line' => __LINE__, 'error' => $e->getMessage()] + ); + } + }//end forget() + + /** + * Release every lock one run holds. Release layer 1. + * + * Called from the `FlowRunTerminalEvent` listener, which fires for ALL + * FOUR terminal statuses (completed, stopped, failed, dead_letter) and + * for every reaper path, because the dispatch predicate in + * `FlowRunMapper::update()` is `isTerminal()` rather than a whitelist. + * That is why the release lives here and not in a node: a run that + * crashed never reaches an unlock step, and it is exactly the run whose + * lock most needs releasing. + * + * IDEMPOTENT, because terminality can be observed more than once. + * + * @param string $runUuid The run whose locks are released. + * + * @return int How many locks were released. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + public function releaseRunLocks(string $runUuid): int { + if (trim($runUuid) === '') { + return 0; + } + + $released = 0; + foreach ($this->registry->findByRun(runUuid: trim($runUuid)) as $row) { + if ($this->releaseRecorded(row: $row) === true) { + $released++; + } + } + + $this->registry->forgetRun(runUuid: trim($runUuid)); + + return $released; + }//end releaseRunLocks() + + /** + * Release locks whose holding run is terminal, gone, or expired. Layer 2. + * + * Layer 1 covers a run that reaches terminal, and the reapers do + * eventually terminate a run whose worker was killed. What layer 1 cannot + * cover is a release that itself failed, or a run row deleted out from + * under an outstanding lock. This finds those in one indexed query. + * + * @param DateTime $now The sweep's clock. + * @param int $limit Batch ceiling. + * + * @return int How many locks were released. + * + * @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-every-lock-a-run-holds-is-released-when-the-run-ends + */ + public function sweepOrphaned(DateTime $now, int $limit = 100): int { + $released = 0; + foreach ($this->registry->findOrphaned(now: $now, limit: $limit) as $row) { + if ($this->releaseRecorded(row: $row) === true) { + $released++; + } + + $this->forget( + runUuid: (string)$row->getRunUuid(), + objectUuid: (string)$row->getObjectUuid() + ); + } + + return $released; + }//end sweepOrphaned() + + /** + * Release one recorded lock, if the object still says this run holds it. + * + * The registry is checked AGAINST the object rather than trusted: only a + * lock the object attributes to this run is released. Without that check + * a stale row would let the sweep strip a lock a DIFFERENT run has since + * taken on the same object. + * + * @param RunObjectLock $row The registry row. + * + * @return bool True when a lock was released. + */ + private function releaseRecorded(RunObjectLock $row): bool { + $runUuid = trim((string)$row->getRunUuid()); + $objectUuid = trim((string)$row->getObjectUuid()); + if ($runUuid === '' || $objectUuid === '') { + return false; + } + + try { + // EVERY scoping filter is off, deliberately, and each one is a way + // this sweep could quietly do nothing. + // + // `_rbac` / `_multitenancy`: both release layers run from a + // background job or `occ`, which have NO SESSION, so a scoped read + // resolves as Anonymous and answers "no such object". The sweep + // would then report zero locks to release and the lock would be + // held until its TTL, with no error anywhere. This is the same + // defect that made `prune-retired` count zero rows and delete + // schemas that held data (openregister#3440). + // + // `includeDeleted`: a soft-deleted object can still carry a live + // lock, and it is precisely the object nobody is watching. Leaving + // it out would strand that lock and its registry row forever. + $context = $this->magicMapper->findAcrossAllSources( + identifier: $objectUuid, + includeDeleted: true, + _rbac: false, + _multitenancy: false + ); + $objectBefore = $context['object']; + + if ($objectBefore->getLockedByRun() !== $runUuid) { + // Stale bookkeeping, or another run holds it now. + return false; + } + + $objectAfter = $this->magicMapper->unlockObjectEntity( + entity: $objectBefore, + register: $context['register'], + schema: $context['schema'], + break: true + ); + + $this->auditTrailMapper->createAuditTrail(old: $objectBefore, new: $objectAfter, action: 'unlock'); + + $this->logger->info( + message: '[RunLockRegistry] Released a run lock', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'runUuid' => $runUuid, + 'objectUuid' => $objectUuid, + ] + ); + + return true; + } catch (Throwable $e) { + // Never propagate: layer 1 runs inside the run's own terminal + // write transaction, and a throw would unwind the status change. + // The TTL is the remaining backstop and the sweep sees the row again. + $this->logger->warning( + message: '[RunLockRegistry] Could not release a run lock', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'runUuid' => $runUuid, + 'objectUuid' => $objectUuid, + 'error' => $e->getMessage(), + ] + ); + + return false; + }//end try + }//end releaseRecorded() +}//end class diff --git a/lib/Service/Object/SaveObject.php b/lib/Service/Object/SaveObject.php index 0332a5670b..f576529a03 100644 --- a/lib/Service/Object/SaveObject.php +++ b/lib/Service/Object/SaveObject.php @@ -43,6 +43,7 @@ use OCA\OpenRegister\Event\ReferenceValidatedEvent; use OCA\OpenRegister\Event\ReferenceValidationFailedEvent; use OCA\OpenRegister\Exception\CircularReferenceException; +use OCA\OpenRegister\Exception\LockedException; use OCA\OpenRegister\Exception\ObjectExistsException; use OCA\OpenRegister\Exception\ReferenceValidationException; use OCA\OpenRegister\Exception\ValidationException; @@ -307,6 +308,7 @@ class SaveObject { * @param IEventDispatcher|null $eventDispatcher Event dispatcher (reference events) * @param \OCA\OpenRegister\Service\ObjectSource\ObjectSourceRegistry|null $objectSourceRegistry Writable object-source provider registry * @param FieldEncryptionHandler|null $fieldEncryptionHandler Field-level encryption handler + * @param \OCA\OpenRegister\Service\Flow\FlowRunContext|null $runContext The ambient flow-run stack, so the lock guard can tell which run is writing * * @SuppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection * @@ -341,6 +343,7 @@ public function __construct( private readonly ?IEventDispatcher $eventDispatcher = null, private readonly ?\OCA\OpenRegister\Service\ObjectSource\ObjectSourceRegistry $objectSourceRegistry = null, private readonly ?FieldEncryptionHandler $fieldEncryptionHandler = null, + private readonly ?\OCA\OpenRegister\Service\Flow\FlowRunContext $runContext = null, ) { $this->twig = new Environment($arrayLoader); }//end __construct() @@ -516,6 +519,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. @@ -3174,21 +3185,38 @@ private function findAndValidateExistingObject( ); // Check if object is locked - prevent updates on locked objects. - $lockData = $existingObject->getLocked(); - if ($lockData !== null && is_array($lockData) === true) { - $currentUser = $this->userSession->getUser(); - $currentUserId = null; - if ($currentUser !== null) { - $currentUserId = $currentUser->getUID(); - } - - $lockOwner = $lockData['userId'] ?? null; + // + // This guard NEVER FIRED before the run-scoped-locking change. It + // read the holder from `$lockData['userId']`, and + // `ObjectEntity::lock()` has always written it under `user`, so + // `$lockOwner` was invariably null and the `!== null` test + // short-circuited every time. Every lock taken since the endpoint + // shipped was decorative on this path. It is fixed by delegating + // to the one production predicate instead of re-spelling the + // comparison here, which is what went wrong in the first place. + $currentUser = $this->userSession->getUser(); + $currentUserId = null; + if ($currentUser !== null) { + $currentUserId = $currentUser->getUID(); + } - // If object is locked by someone other than the current user, prevent update. - if ($lockOwner !== null && $lockOwner !== $currentUserId) { - $unlockAdvice = 'Please unlock the object before attempting to update it.'; - throw new Exception("Cannot update object: Object is locked by user '{$lockOwner}'. " . $unlockAdvice); - } + // WHICH RUN IS WRITING, not just which user. A run-scoped lock + // refuses every caller but the holding run, so a guard that omits + // the run identity refuses THE RUN THAT TOOK THE LOCK: a flow + // locks a case at one step and is turned away by its own lock at + // the next. Ambient rather than a parameter because the write is + // routinely several calls deep in code that has never heard of + // flows; absent, it reads as a person, which is the fail-closed + // answer a run lock already gives. + $callerRun = $this->runContext?->currentRunUuid(); + + // A TYPED refusal, not a plain `Exception`. The message is unchanged, + // but the type is what lets a caller answer 423 instead of falling + // into a generic 500 — which is what PATCH and POST-patch did for a + // payload that was otherwise valid. The holder travels on the + // exception rather than being re-derived at each catch site. + if ($existingObject->isLockedBySomeoneElse(userId: $currentUserId, runUuid: $callerRun) === true) { + throw LockedException::forObject($existingObject); } return $existingObject; diff --git a/lib/Service/Object/SaveObject/MetadataHydrationHandler.php b/lib/Service/Object/SaveObject/MetadataHydrationHandler.php index 28b476dd24..ad133ab146 100644 --- a/lib/Service/Object/SaveObject/MetadataHydrationHandler.php +++ b/lib/Service/Object/SaveObject/MetadataHydrationHandler.php @@ -55,6 +55,8 @@ * Reason: Metadata hydration handles multiple complex scenarios for template processing * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) + * + * @spec openspec/specs/object-lifecycle/spec.md */ class MetadataHydrationHandler { /** @@ -278,6 +280,16 @@ public function extractMetadataValue(array $data, string $fieldPath, array $sche // Simple field path - use existing method. $value = $this->getValueFromPath(data: $data, path: $fieldPath); + // A translatable property holds a LANGUAGE MAP, not a value. Resolve it + // to one string before the json_encode below turns it into markup a + // person then reads on the page. + if (is_array($value) === true) { + $fromLanguageMap = $this->resolveLanguageMap(value: $value); + if ($fromLanguageMap !== null) { + return $fromLanguageMap; + } + } + // Convert arrays/objects to JSON string to satisfy the ?string return type. if (is_array($value) === true) { return json_encode($value); @@ -286,6 +298,99 @@ public function extractMetadataValue(array $data, string $fieldPath, array $sche return $value; }//end extractMetadataValue() + /** + * Resolve a translatable property's language map to a single string. + * + * 🔴 WITHOUT THIS, A TRANSLATABLE NAME FIELD IS SHOWN TO USERS AS RAW JSON. + * + * A property marked `translatable: true` stores `{"nl": "…", "en": "…"}`. + * `extractMetadataValue()` must return a string, and it used to satisfy + * that by `json_encode`-ing whatever array it was handed. So a schema whose + * `objectNameField` names a translatable property stored the literal + * `{"nl":"Rijkswaterstaat - Onderhoudscontract software 2026"}` in + * `@self.name` — and every detail page renders `@self.name` as its + * headline. Observed on pipelinq's lead page; `lead.title` is + * `translatable: true` and is the `objectNameField`, and dossiq's + * `case.title` is not translatable, which is exactly why its header reads + * clean on the same component. + * + * WHICH LANGUAGE THIS PICKS, and why it is not a locale decision. + * `@self.name` is a DENORMALISED display and search string written once at + * save time, when there is no reader and so no locale to honour — under + * `occ` there is not even a request. The authoritative value stays in the + * property, and per-request resolution belongs to + * {@see \OCA\OpenRegister\Service\Object\TranslationHandler::resolveTranslationsForRender()}, + * which reads the register's language list and the caller's Accept-Language. + * So this deliberately bakes in NO language: it takes the FIRST key the + * author declared, which is exact for the single-language maps that are + * the common case and a defensible source-language convention otherwise. + * + * Returns null for an array that is not a language map, so a genuinely + * structured value still falls through to `json_encode` as before. + * + * @param array $value The candidate value. + * + * @return string|null The resolved string, or null when this is not a language map. + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + private function resolveLanguageMap(array $value): ?string { + if ($this->isLanguageKeyedObject(value: $value) === false) { + return null; + } + + foreach ($value as $entry) { + // A nested map is not a language value; leave the whole thing to + // json_encode rather than reaching further in and guessing. + if (is_scalar($entry) === false) { + return null; + } + + if (trim((string)$entry) !== '') { + return (string)$entry; + } + } + + // Every language is present but empty. That is not a name, and falling + // through to json_encode would put `{"nl":""}` on the page, so let the + // caller's common-field fallback have its turn. + return null; + }//end resolveLanguageMap() + + /** + * Whether every key of an array is a BCP 47 language tag. + * + * Kept identical, deliberately, to TranslationHandler's own predicate: the + * two must agree on what a language map IS, or a value one of them resolves + * the other would encode. TranslationHandler cannot simply be injected + * here — it is request-scoped through LanguageService, and this runs on the + * save path, including from `occ` where there is no request at all. + * + * @param array $value The candidate value. + * + * @return bool True when the array is keyed by language tags. + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + private function isLanguageKeyedObject(array $value): bool { + if (empty($value) === true) { + return false; + } + + foreach (array_keys($value) as $key) { + if (is_string($key) === false) { + return false; + } + + // BCP 47 language tag: 2-3 lowercase letters, optional subtags. + if (preg_match('/^[a-z]{2,3}(-[a-zA-Z0-9]{2,8})*$/', $key) !== 1) { + return false; + } + } + + return true; + }//end isLanguageKeyedObject() + /** * Processes a pipe-separated fallback chain and returns the first non-empty value. * diff --git a/lib/Service/Object/SearchQueryHandler.php b/lib/Service/Object/SearchQueryHandler.php index 5aa4bc7fd0..7b9ad22461 100644 --- a/lib/Service/Object/SearchQueryHandler.php +++ b/lib/Service/Object/SearchQueryHandler.php @@ -485,75 +485,6 @@ public function applyViewsToQuery(array $query, array $viewIds): array { return $query; }//end applyViewsToQuery() - /** - * Clean and normalize query parameters - * - * Converts legacy query parameter formats to the standard format used by MagicMapper. - * Handles ordering, operator suffixes (_in, _gt, _lt, etc.), and normalizes parameter names. - * - * @param array $parameters Query parameters to clean. - * - * @return array Cleaned query parameters - * - * @SuppressWarnings(PHPMD.CyclomaticComplexity) Multiple conditional paths for parameter normalization - * - * @spec openspec/specs/zoeken-filteren/spec.md - */ - public function cleanQuery(array $parameters): array { - $newParameters = []; - - // 1. Handle ordering. - if (isset($parameters['ordering']) === true) { - $ordering = $parameters['ordering']; - $direction = 'ASC'; - if (str_starts_with($ordering, '-') === true) { - $direction = 'DESC'; - } - - $field = ltrim($ordering, '-'); - $newParameters['_order'] = [$field => $direction]; - unset($parameters['ordering']); - } - - // 2. Normalize keys: replace '__' with '_'. - $normalized = []; - foreach ($parameters as $key => $value) { - $normalized[str_replace('__', '_', $key)] = $value; - } - - // 3. Process parameters (no nested loops). - foreach ($normalized as $key => $value) { - if (preg_match('/^(.*)_(in|gt|lt|gte|lte|isnull)$/', $key, $matches) === 1) { - // Suppress unused variable warning for $matches[0] (full match). - unset($matches[0]); - [$base, $suffix] = array_values($matches); - - switch ($suffix) { - case 'in': - case 'gt': - case 'lt': - case 'gte': - case 'lte': - $newParameters[$base][$suffix] = $value; - break; - - case 'isnull': - $newParameters[$base] = 'IS NOT NULL'; - if ($value === true) { - $newParameters[$base] = 'IS NULL'; - } - break; - }//end switch - - continue; - }//end if - - $newParameters[$key] = $value; - }//end foreach - - return $newParameters; - }//end cleanQuery() - /** * Add pagination URLs to search results * @@ -664,9 +595,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/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 84756796f0..156728c541 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -765,6 +765,61 @@ private function discardPendingSchemaRef(): void $this->currentSchemaRef = null; }//end discardPendingSchemaRef() + /** + * Put back the scope an entry point found, after it resolved its own. + * + * THE CONTRACT THIS ESTABLISHES. + * + * `setRegister()` / `setSchema()` are the CALLER'S way of anchoring this + * shared service, and they are meant to persist: the fluent pattern + * `setRegister($r)->setSchema($s)` followed by `findAll()` is how a dozen + * controllers scope a read, and nothing here changes that. + * + * An entry point that takes `register:` / `schema:` ARGUMENTS is a different + * thing. It is scoping ITSELF, for the duration of one operation, and the + * scope belongs to that operation — not to whoever calls next. Leaving it + * behind turns a write into an invisible `setRegister()`/`setSchema()` on a + * service instance that is reused for the whole request (and, under `occ`, + * for a whole `upgrade` run). + * + * WHY THIS IS NOT HYPOTHETICAL. `find()` has restored its context in a + * `finally` since BUG-OBJ-13 (openregister#1520). `saveObject()` never did, + * and openregister#3408 is what that costs: `ImportCredentialBrokerRegister` + * saved two example objects through + * `saveObject(register: credential-broker, schema: brokeredcredential)`, and + * four repair steps later `MigrateRegisterFlowsToTable` — whose own read was + * unscoped — inherited that pair and copied two `brokeredcredential` + * examples into `openregister_flows` as flows. Deterministically, on every + * install and every `occ upgrade`, with no error anywhere. + * + * That defect was fixed at its call site. This is the same fix at the source, + * so the next unscoped reader after a write inherits NOTHING rather than + * inheriting the write's scope. + * + * WHERE THIS IS CALLED FROM. Every entry point that resolves a scope from + * its own arguments, in a `finally` so a throw restores too: `saveObject()`, + * `saveObjects()`, `saveObjectsStreaming()`, `patchObject()`, + * `deleteObject()` and `findSilent()`. `find()` predates this helper and + * keeps its own inline restore, documented at BUG-OBJ-13. + * + * @param Register|null $register The register context to put back. + * @param Schema|null $schema The schema context to put back. + * + * @return void + * + * @spec exclude Context hygiene shared by every scoping entry point; no business rule of its own. + */ + private function restoreScopeContext(?Register $register, ?Schema $schema): void + { + $this->currentRegister = $register; + $this->currentSchema = $schema; + // The pending ref is CLEARED rather than restored, exactly as find() + // does: the restored schema is an already-resolved ENTITY, so a + // surviving raw ref would only give the next setRegister() something + // unrelated to re-resolve. + $this->currentSchemaRef = null; + }//end restoreScopeContext() + /** * Get the register entity resolved by the last setRegister() call. * @@ -1193,27 +1248,38 @@ public function findSilent( bool $_rbac=true, bool $_multitenancy=true ): ObjectEntity { - $this->discardPendingSchemaRef(); - // Check if a register is provided and set the current register context. - if ($register !== null) { - $this->setRegister(register: $register); - } + // Same contract as find(), which has restored its context since + // BUG-OBJ-13. findSilent() differs from find() only in skipping the + // audit row; there is no reason for it to differ in what it leaves + // behind. See restoreScopeContext(). + $previousRegister = $this->currentRegister; + $previousSchema = $this->currentSchema; - // Check if a schema is provided and set the current schema context. - if ($schema !== null) { - $this->setSchema(schema: $schema); - } + try { + $this->discardPendingSchemaRef(); + // Check if a register is provided and set the current register context. + if ($register !== null) { + $this->setRegister(register: $register); + } - // Use the silent find method from the GetObject handler. - return $this->getHandler->findSilent( - id: $id, - register: $this->currentRegister, - schema: $this->currentSchema, - _extend: $_extend, - files: $files, - _rbac: $_rbac, - _multitenancy: $_multitenancy - ); + // Check if a schema is provided and set the current schema context. + if ($schema !== null) { + $this->setSchema(schema: $schema); + } + + // Use the silent find method from the GetObject handler. + return $this->getHandler->findSilent( + id: $id, + register: $this->currentRegister, + schema: $this->currentSchema, + _extend: $_extend, + files: $files, + _rbac: $_rbac, + _multitenancy: $_multitenancy + ); + } finally { + $this->restoreScopeContext(register: $previousRegister, schema: $previousSchema); + } }//end findSilent() /** @@ -1501,295 +1567,308 @@ public function saveObject( bool $failIfExists=false, bool $_unowned=false ): ObjectEntity { - $this->discardPendingSchemaRef(); - // Bound the folder-access revalidation cache to this single save call - // (not the whole FileService/request lifetime), so a cascade save that - // moves or trashes a folder mid-request can't be waved through on a - // stale "accessible" verdict from an earlier write. - $this->fileService->resetFolderAccessRevalidationCache(); + // A SAVE SCOPES ITSELF; IT DOES NOT SCOPE THE NEXT CALLER. + // + // See restoreScopeContext() for the contract and for openregister#3408, + // the defect this leak produced. A caller that anchored the service with + // setRegister()/setSchema() and then saves WITHOUT naming a scope is + // unaffected: the snapshot and the restore are the same context. + $previousRegister = $this->currentRegister; + $previousSchema = $this->currentSchema; - \OCA\OpenRegister\Service\WritePhaseProbe::mark('start'); + try { + $this->discardPendingSchemaRef(); + // Bound the folder-access revalidation cache to this single save call + // (not the whole FileService/request lifetime), so a cascade save that + // moves or trashes a folder mid-request can't be waved through on a + // stale "accessible" verdict from an earlier write. + $this->fileService->resetFolderAccessRevalidationCache(); + + \OCA\OpenRegister\Service\WritePhaseProbe::mark('start'); + + // Set register/schema context. + $this->setContextFromParameters( + register: $register, + schema: $schema + ); - // Set register/schema context. - $this->setContextFromParameters( - register: $register, - schema: $schema - ); + // Extract UUID and convert ObjectEntity to array if needed. + [$object, $uuid] = $this->extractUuidAndNormalizeObject( + object: $object, + uuid: $uuid + ); - // Extract UUID and convert ObjectEntity to array if needed. - [$object, $uuid] = $this->extractUuidAndNormalizeObject( - object: $object, - uuid: $uuid - ); + // Check permissions for CREATE or UPDATE operation. + \OCA\OpenRegister\Service\WritePhaseProbe::mark('pc:context+uuid'); - // Check permissions for CREATE or UPDATE operation. - \OCA\OpenRegister\Service\WritePhaseProbe::mark('pc:context+uuid'); + $this->checkSavePermissions( + uuid: $uuid, + _rbac: $_rbac + ); - $this->checkSavePermissions( - uuid: $uuid, - _rbac: $_rbac - ); + \OCA\OpenRegister\Service\WritePhaseProbe::mark('pc:permissions.check'); - \OCA\OpenRegister\Service\WritePhaseProbe::mark('pc:permissions.check'); + // Reject updates to transferred objects (archiefstatus = overgebracht). + if ($uuid !== null) { + $this->rejectIfTransferred(uuid: $uuid); + } - // Reject updates to transferred objects (archiefstatus = overgebracht). - if ($uuid !== null) { - $this->rejectIfTransferred(uuid: $uuid); - } + // Reject UPDATE operations on append-only schemas (INSERT is still allowed). + if ($uuid !== null && $this->currentSchema !== null && $this->currentSchema->isAppendOnly() === true) { + $schemaSlug = $this->currentSchema->getSlug() ?? (string) $this->currentSchema->getId(); + throw new AppendOnlyException( + schemaIdentifier: $schemaSlug, + operation: 'update' + ); + } - // Reject UPDATE operations on append-only schemas (INSERT is still allowed). - if ($uuid !== null && $this->currentSchema !== null && $this->currentSchema->isAppendOnly() === true) { - $schemaSlug = $this->currentSchema->getSlug() ?? (string) $this->currentSchema->getId(); - throw new AppendOnlyException( - schemaIdentifier: $schemaSlug, - operation: 'update' - ); - } + // Track if UUID was originally null (to distinguish user-provided vs auto-generated UUIDs). + $uuidWasNull = ($uuid === null); - // Track if UUID was originally null (to distinguish user-provided vs auto-generated UUIDs). - $uuidWasNull = ($uuid === null); + // Handle cascading relations while preserving context. + \OCA\OpenRegister\Service\WritePhaseProbe::mark('pc:permissions'); - // Handle cascading relations while preserving context. - \OCA\OpenRegister\Service\WritePhaseProbe::mark('pc:permissions'); + [$object, $uuid] = $this->handleCascadingWithContextPreservation( + object: $object, + uuid: $uuid + ); - [$object, $uuid] = $this->handleCascadingWithContextPreservation( - object: $object, - uuid: $uuid - ); + // If UUID was null and is now set, mark it as auto-generated in object data. + // This allows SaveObject to distinguish between user-provided UUIDs (UPDATE) + // and auto-generated UUIDs (CREATE). + if ($uuidWasNull === true && $uuid !== null) { + // Store flag in @self to indicate this is a CREATE operation. + if (isset($object['@self']) === false || is_array($object['@self']) === false) { + $object['@self'] = []; + } - // If UUID was null and is now set, mark it as auto-generated in object data. - // This allows SaveObject to distinguish between user-provided UUIDs (UPDATE) - // and auto-generated UUIDs (CREATE). - if ($uuidWasNull === true && $uuid !== null) { - // Store flag in @self to indicate this is a CREATE operation. - if (isset($object['@self']) === false || is_array($object['@self']) === false) { - $object['@self'] = []; + $object['@self']['_autoGeneratedUuid'] = true; + $this->logger->debug( + message: '[ObjectService] UUID auto-generated by CascadingHandler, marking as CREATE operation', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'uuid' => $uuid, + 'register' => $this->currentRegister?->getId(), + 'schema' => $this->currentSchema?->getId(), + ] + ); } - $object['@self']['_autoGeneratedUuid'] = true; - $this->logger->debug( - message: '[ObjectService] UUID auto-generated by CascadingHandler, marking as CREATE operation', - context: [ - 'file' => __FILE__, - 'line' => __LINE__, - 'uuid' => $uuid, - 'register' => $this->currentRegister?->getId(), - 'schema' => $this->currentSchema?->getId(), - ] - ); - } - - // BUG-OBJ-4: applyAlwaysDefaults() and validateObjectIfRequired() - // both dereference $this->currentSchema (non-nullable param / - // ->getHardValidation()). If the schema could not be resolved from - // the request we would otherwise emit a raw TypeError 500 here. - // Throw a structured ValidationException instead, which the - // controllers translate into a clean 400 via handleValidationException(). - if ($this->currentSchema === null) { - throw new ValidationException( - message: 'Schema could not be resolved for this object; provide a valid register/schema.' - ); - } - - // Apply "always" defaults BEFORE validation. - // This ensures computed/derived properties (e.g., dienstType from type) are set - // before validation runs, allowing them to override invalid incoming values. - $object = $this->saveHandler->applyAlwaysDefaults( - schema: $this->currentSchema, - data: $object - ); + // BUG-OBJ-4: applyAlwaysDefaults() and validateObjectIfRequired() + // both dereference $this->currentSchema (non-nullable param / + // ->getHardValidation()). If the schema could not be resolved from + // the request we would otherwise emit a raw TypeError 500 here. + // Throw a structured ValidationException instead, which the + // controllers translate into a clean 400 via handleValidationException(). + if ($this->currentSchema === null) { + throw new ValidationException( + message: 'Schema could not be resolved for this object; provide a valid register/schema.' + ); + } - // Normalize date values BEFORE validation. - // Accepts datetime input (e.g. "2024-01-15T10:30:00+02:00") for date fields - // and casts it to date-only (e.g. "2024-01-15") so Opis validation passes. - $object = $this->normalizeDateValues(object: $object); - - // Auto-seed a graph-lifecycle field from the parent on CREATE only, - // BEFORE validation, so a required `$ref` lifecycle field passes on a - // seeded create. $uuidWasNull is the create signal (updates always - // carry a UUID); the seed itself is empty-field-only and fail-soft, so - // it never overwrites a client-supplied value. See the object-lifecycle - // spec: fk-graph-lifecycle-transitions. - if ($uuidWasNull === true) { - $object = $this->saveHandler->seedLifecycleFieldOnCreate( + // Apply "always" defaults BEFORE validation. + // This ensures computed/derived properties (e.g., dienstType from type) are set + // before validation runs, allowing them to override invalid incoming values. + $object = $this->saveHandler->applyAlwaysDefaults( schema: $this->currentSchema, data: $object ); - } - \OCA\OpenRegister\Service\WritePhaseProbe::mark('prepare+cascade'); + // Normalize date values BEFORE validation. + // Accepts datetime input (e.g. "2024-01-15T10:30:00+02:00") for date fields + // and casts it to date-only (e.g. "2024-01-15") so Opis validation passes. + $object = $this->normalizeDateValues(object: $object); + + // Auto-seed a graph-lifecycle field from the parent on CREATE only, + // BEFORE validation, so a required `$ref` lifecycle field passes on a + // seeded create. $uuidWasNull is the create signal (updates always + // carry a UUID); the seed itself is empty-field-only and fail-soft, so + // it never overwrites a client-supplied value. See the object-lifecycle + // spec: fk-graph-lifecycle-transitions. + if ($uuidWasNull === true) { + $object = $this->saveHandler->seedLifecycleFieldOnCreate( + schema: $this->currentSchema, + data: $object + ); + } - // Validate if hard validation is enabled. - $this->validateObjectIfRequired(object: $object); - \OCA\OpenRegister\Service\WritePhaseProbe::mark('validate'); + \OCA\OpenRegister\Service\WritePhaseProbe::mark('prepare+cascade'); - // Wave-12 Fix 1: enforce JSON-Schema `readOnly: true` on UPDATE. - // Skipped on CREATE (no prior value to violate). Loads the existing - // object exactly once so the check is data-driven, not metadata-only. - $this->enforceReadOnlyOnUpdate(object: $object, uuid: $uuid); + // Validate if hard validation is enabled. + $this->validateObjectIfRequired(object: $object); + \OCA\OpenRegister\Service\WritePhaseProbe::mark('validate'); - \OCA\OpenRegister\Service\WritePhaseProbe::mark('folder.readonly'); + // Wave-12 Fix 1: enforce JSON-Schema `readOnly: true` on UPDATE. + // Skipped on CREATE (no prior value to violate). Loads the existing + // object exactly once so the check is data-driven, not metadata-only. + $this->enforceReadOnlyOnUpdate(object: $object, uuid: $uuid); - // Ensure folder exists for the object. - $folderId = $this->ensureObjectFolder(uuid: $uuid, currentUser: $currentUser); + \OCA\OpenRegister\Service\WritePhaseProbe::mark('folder.readonly'); - \OCA\OpenRegister\Service\WritePhaseProbe::mark('folder.ensure'); + // Ensure folder exists for the object. + $folderId = $this->ensureObjectFolder(uuid: $uuid, currentUser: $currentUser); - // Clear request-scoped caches before starting a new top-level save operation. - // This ensures cascade operations benefit from caching while avoiding stale data. - $this->saveHandler->clearAllCaches(); + \OCA\OpenRegister\Service\WritePhaseProbe::mark('folder.ensure'); - \OCA\OpenRegister\Service\WritePhaseProbe::mark('folder'); + // Clear request-scoped caches before starting a new top-level save operation. + // This ensures cascade operations benefit from caching while avoiding stale data. + $this->saveHandler->clearAllCaches(); - // Delegate to SaveObject handler for actual save operation. - $savedObject = $this->saveHandler->saveObject( - register: $this->currentRegister, - schema: $this->currentSchema, - data: $object, - uuid: $uuid, - folderId: $folderId, - _rbac: $_rbac, - _multitenancy: $_multitenancy, - persist: true, - silent: $silent, - _validation: $_validation, - uploadedFiles: $uploadedFiles, - currentUser: $currentUser, - failIfExists: $failIfExists, - _unowned: $_unowned - ); + \OCA\OpenRegister\Service\WritePhaseProbe::mark('folder'); - // Invalidate contact matching cache for objects with email properties. - // BUG-OBJ-9: invalidate against the SAVED object's data (final UUID + - // applied defaults), not the pre-save input array, so an email value - // injected by a default/computed property is also invalidated. - try { - $container = \OC::$server; - if ($container !== null) { - $contactMatchingService = $container->get( - \OCA\OpenRegister\Service\ContactMatchingService::class - ); - $contactMatchingService->invalidateCacheForObject($savedObject->getObject()); - } - } catch (\Throwable $e) { - // BUG-OBJ-9 / BUG-OBJ-14: contact-match cache invalidation is a - // non-essential post-save side-effect and must NEVER fail the save. - // Catch \Throwable (the invalidation path can raise a runtime Error, - // e.g. an unavailable SystemTag subsystem, not just a container - // exception) but log it with object context so the miss stays - // visible instead of being silently swallowed. - $this->logger->warning( - message: '[ObjectService] Skipped contact-match cache invalidation: invalidation failed', - context: [ - 'file' => __FILE__, - 'line' => __LINE__, - 'exception' => $e->getMessage(), - 'uuid' => $savedObject->getUuid(), - 'register' => $this->currentRegister?->getId(), - 'schema' => $this->currentSchema?->getId(), - ] + // Delegate to SaveObject handler for actual save operation. + $savedObject = $this->saveHandler->saveObject( + register: $this->currentRegister, + schema: $this->currentSchema, + data: $object, + uuid: $uuid, + folderId: $folderId, + _rbac: $_rbac, + _multitenancy: $_multitenancy, + persist: true, + silent: $silent, + _validation: $_validation, + uploadedFiles: $uploadedFiles, + currentUser: $currentUser, + failIfExists: $failIfExists, + _unowned: $_unowned ); - }//end try - // Invalidate Smart Picker / reference-provider preview caches for the - // saved object, so an edit is reflected the next time the object is - // resolved as a reference (mail-smart-picker spec: "Cache invalidation - // on object update"). Every canonical URL shape OpenRegister itself - // recognises comes from ObjectPreviewFormatter::buildCanonicalUrls() — - // the SAME pattern list matchReference()/getCachePrefix() use — so - // this can never invalidate a different set of shapes than the ones - // the providers actually match against (design.md D4). Best-effort: - // never fails the save. - try { - $container = \OC::$server; - if ($container !== null) { - $referenceManager = $container->get(\OCP\Collaboration\Reference\IReferenceManager::class); - $formatter = $container->get(\OCA\OpenRegister\Service\Reference\ObjectPreviewFormatter::class); - $deepLinkRegistry = $container->get(\OCA\OpenRegister\Service\DeepLinkRegistryService::class); - - $invalidationRegisterId = (int)$this->currentRegister?->getId(); - $invalidationSchemaId = (int)$this->currentSchema?->getId(); - $invalidationUuid = (string)$savedObject->getUuid(); - - $invalidatedPrefixes = []; - foreach ( - $formatter->buildCanonicalUrls( + // Invalidate contact matching cache for objects with email properties. + // BUG-OBJ-9: invalidate against the SAVED object's data (final UUID + + // applied defaults), not the pre-save input array, so an email value + // injected by a default/computed property is also invalidated. + try { + $container = \OC::$server; + if ($container !== null) { + $contactMatchingService = $container->get( + \OCA\OpenRegister\Service\ContactMatchingService::class + ); + $contactMatchingService->invalidateCacheForObject($savedObject->getObject()); + } + } catch (\Throwable $e) { + // BUG-OBJ-9 / BUG-OBJ-14: contact-match cache invalidation is a + // non-essential post-save side-effect and must NEVER fail the save. + // Catch \Throwable (the invalidation path can raise a runtime Error, + // e.g. an unavailable SystemTag subsystem, not just a container + // exception) but log it with object context so the miss stays + // visible instead of being silently swallowed. + $this->logger->warning( + message: '[ObjectService] Skipped contact-match cache invalidation: invalidation failed', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'exception' => $e->getMessage(), + 'uuid' => $savedObject->getUuid(), + 'register' => $this->currentRegister?->getId(), + 'schema' => $this->currentSchema?->getId(), + ] + ); + }//end try + + // Invalidate Smart Picker / reference-provider preview caches for the + // saved object, so an edit is reflected the next time the object is + // resolved as a reference (mail-smart-picker spec: "Cache invalidation + // on object update"). Every canonical URL shape OpenRegister itself + // recognises comes from ObjectPreviewFormatter::buildCanonicalUrls() — + // the SAME pattern list matchReference()/getCachePrefix() use — so + // this can never invalidate a different set of shapes than the ones + // the providers actually match against (design.md D4). Best-effort: + // never fails the save. + try { + $container = \OC::$server; + if ($container !== null) { + $referenceManager = $container->get(\OCP\Collaboration\Reference\IReferenceManager::class); + $formatter = $container->get(\OCA\OpenRegister\Service\Reference\ObjectPreviewFormatter::class); + $deepLinkRegistry = $container->get(\OCA\OpenRegister\Service\DeepLinkRegistryService::class); + + $invalidationRegisterId = (int)$this->currentRegister?->getId(); + $invalidationSchemaId = (int)$this->currentSchema?->getId(); + $invalidationUuid = (string)$savedObject->getUuid(); + + $invalidatedPrefixes = []; + foreach ( + $formatter->buildCanonicalUrls( + registerId: $invalidationRegisterId, + schemaId: $invalidationSchemaId, + uuid: $invalidationUuid + ) as $canonicalUrl + ) { + $prefix = $formatter->resolveCachePrefix(referenceText: $canonicalUrl); + if (isset($invalidatedPrefixes[$prefix]) === false) { + $referenceManager->invalidateCache(cachePrefix: $prefix); + $invalidatedPrefixes[$prefix] = true; + } + } + + // Flat data shape deep link URL templates resolve placeholders + // against — same shape ObjectPreviewFormatter::buildReference() + // and ObjectSearchResultFormatter::format() build for the same + // purpose: the object's own fields plus the identity triple. + $deepLinkObjectData = array_merge( + $savedObject->getObject(), + [ + 'uuid' => $invalidationUuid, + 'register' => $invalidationRegisterId, + 'schema' => $invalidationSchemaId, + ] + ); + + $deepLinkUrl = $deepLinkRegistry->resolveUrl( registerId: $invalidationRegisterId, schemaId: $invalidationSchemaId, - uuid: $invalidationUuid - ) as $canonicalUrl - ) { - $prefix = $formatter->resolveCachePrefix(referenceText: $canonicalUrl); - if (isset($invalidatedPrefixes[$prefix]) === false) { - $referenceManager->invalidateCache(cachePrefix: $prefix); - $invalidatedPrefixes[$prefix] = true; + objectData: $deepLinkObjectData + ); + if ($deepLinkUrl !== null) { + $referenceManager->invalidateCache(cachePrefix: $deepLinkUrl); } - } - - // Flat data shape deep link URL templates resolve placeholders - // against — same shape ObjectPreviewFormatter::buildReference() - // and ObjectSearchResultFormatter::format() build for the same - // purpose: the object's own fields plus the identity triple. - $deepLinkObjectData = array_merge( - $savedObject->getObject(), - [ - 'uuid' => $invalidationUuid, - 'register' => $invalidationRegisterId, - 'schema' => $invalidationSchemaId, + }//end if + } catch (\Throwable $e) { + // Smart Picker cache invalidation is a non-essential post-save + // side-effect and must NEVER fail the save. Catch \Throwable (the + // reference manager or an unavailable formatter service can raise + // a runtime Error, not just a container exception) but log it with + // object context so the miss stays visible instead of being + // silently swallowed. + $this->logger->warning( + message: '[ObjectService] Skipped Smart Picker cache invalidation: invalidation failed', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'exception' => $e->getMessage(), + 'uuid' => $savedObject->getUuid(), + 'register' => $this->currentRegister?->getId(), + 'schema' => $this->currentSchema?->getId(), ] ); + }//end try - $deepLinkUrl = $deepLinkRegistry->resolveUrl( - registerId: $invalidationRegisterId, - schemaId: $invalidationSchemaId, - objectData: $deepLinkObjectData - ); - if ($deepLinkUrl !== null) { - $referenceManager->invalidateCache(cachePrefix: $deepLinkUrl); - } - }//end if - } catch (\Throwable $e) { - // Smart Picker cache invalidation is a non-essential post-save - // side-effect and must NEVER fail the save. Catch \Throwable (the - // reference manager or an unavailable formatter service can raise - // a runtime Error, not just a container exception) but log it with - // object context so the miss stays visible instead of being - // silently swallowed. - $this->logger->warning( - message: '[ObjectService] Skipped Smart Picker cache invalidation: invalidation failed', - context: [ - 'file' => __FILE__, - 'line' => __LINE__, - 'exception' => $e->getMessage(), - 'uuid' => $savedObject->getUuid(), - 'register' => $this->currentRegister?->getId(), - 'schema' => $this->currentSchema?->getId(), - ] + // Lazy folder creation: intentionally do NOT create a file-storage + // folder here. An object only needs a folder once a file is attached; + // the folder is created on demand on the first upload + // (CreateFileHandler → getObjectFolder, which creates-if-missing). This + // avoids cluttering the Files tree with an empty folder per object and + // avoids binding system/seed-created objects to a folder a later editor + // can't access (the folder_access_denied case). + \OCA\OpenRegister\Service\WritePhaseProbe::mark('persist+events'); + + // Render and return the saved object. + $renderedObject = $this->renderHandler->renderEntity( + entity: $savedObject, + _extend: $extend ?? [], + registers: null, + schemas: null, + _rbac: $_rbac, + _multitenancy: $_multitenancy ); - }//end try + \OCA\OpenRegister\Service\WritePhaseProbe::mark('render'); + \OCA\OpenRegister\Service\WritePhaseProbe::flush(); - // Lazy folder creation: intentionally do NOT create a file-storage - // folder here. An object only needs a folder once a file is attached; - // the folder is created on demand on the first upload - // (CreateFileHandler → getObjectFolder, which creates-if-missing). This - // avoids cluttering the Files tree with an empty folder per object and - // avoids binding system/seed-created objects to a folder a later editor - // can't access (the folder_access_denied case). - \OCA\OpenRegister\Service\WritePhaseProbe::mark('persist+events'); - - // Render and return the saved object. - $renderedObject = $this->renderHandler->renderEntity( - entity: $savedObject, - _extend: $extend ?? [], - registers: null, - schemas: null, - _rbac: $_rbac, - _multitenancy: $_multitenancy - ); - \OCA\OpenRegister\Service\WritePhaseProbe::mark('render'); - \OCA\OpenRegister\Service\WritePhaseProbe::flush(); - - return $renderedObject; + return $renderedObject; + } finally { + $this->restoreScopeContext(register: $previousRegister, schema: $previousSchema); + } }//end saveObject() /** @@ -2370,132 +2449,138 @@ public function deleteObject( ?IUser $currentUser=null, bool $permanent=false ): bool { - $this->discardPendingSchemaRef(); - // Explicit acting user for the permission check; null keeps today's - // session-resolved behaviour for every existing caller. - $actingUserId = $currentUser?->getUID(); - - // Resolve the explicit scope (if any) onto the service's currentRegister - // / currentSchema so downstream context (permission checks, audit-trail - // recording) sees the API-supplied scope, not a stale leftover from a - // previous call on this service instance. - $hasScope = ($register !== null && $schema !== null); - if ($register !== null) { - $this->setRegister(register: $register); - } - - if ($schema !== null) { - $this->setSchema(schema: $schema); - } + // A DELETE SCOPES ITSELF; IT DOES NOT SCOPE THE NEXT CALLER. + // Same contract as find() and saveObject(); see restoreScopeContext(). + $previousRegister = $this->currentRegister; + $previousSchema = $this->currentSchema; - \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.scope'); + try { + $this->discardPendingSchemaRef(); + // Explicit acting user for the permission check; null keeps today's + // session-resolved behaviour for every existing caller. + $actingUserId = $currentUser?->getUID(); + + // Resolve the explicit scope (if any) onto the service's currentRegister + // / currentSchema so downstream context (permission checks, audit-trail + // recording) sees the API-supplied scope, not a stale leftover from a + // previous call on this service instance. + $hasScope = ($register !== null && $schema !== null); + if ($register !== null) { + $this->setRegister(register: $register); + } - // Reject deletion of transferred objects (archiefstatus = overgebracht). - $this->rejectIfTransferred(uuid: $uuid); + if ($schema !== null) { + $this->setSchema(schema: $schema); + } - \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.transferred'); + \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.scope'); - // Reject DELETE operations on append-only schemas. - if ($this->currentSchema !== null && $this->currentSchema->isAppendOnly() === true) { - $schemaSlug = $this->currentSchema->getSlug() ?? (string) $this->currentSchema->getId(); - throw new AppendOnlyException( - schemaIdentifier: $schemaSlug, - operation: 'delete' - ); - } + // Reject deletion of transferred objects (archiefstatus = overgebracht). + $this->rejectIfTransferred(uuid: $uuid); - // Reject DELETE operations on archival-annotated schemas unless this - // call originates from the retention sweep cron (which alone sets - // $_retentionSweep true). User-driven deletes get a structured 403. - if ($_retentionSweep === false - && $this->currentSchema !== null - && $this->schemaHasArchivalAnnotation(schema: $this->currentSchema) === true - ) { - $schemaSlug = $this->currentSchema->getSlug() ?? (string) $this->currentSchema->getId(); - throw new ArchivalImmutableException( - schemaIdentifier: $schemaSlug, - operation: 'delete' - ); - } + \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.transferred'); - // Find the object to get its owner for permission check (include soft-deleted objects). - // When the caller supplied both register + schema, the lookup is scoped - // to a single magic table — a UUID in a different scope raises - // DoesNotExistException and never reaches the delete handler. - $scopedRegister = null; - $scopedSchema = null; - if ($hasScope === true) { - $scopedRegister = $this->currentRegister; - $scopedSchema = $this->currentSchema; - } + // Reject DELETE operations on append-only schemas. + if ($this->currentSchema !== null && $this->currentSchema->isAppendOnly() === true) { + $schemaSlug = $this->currentSchema->getSlug() ?? (string) $this->currentSchema->getId(); + throw new AppendOnlyException( + schemaIdentifier: $schemaSlug, + operation: 'delete' + ); + } - try { - $objectToDelete = $this->objectMapper->find( - identifier: $uuid, - register: $scopedRegister, - schema: $scopedSchema, - includeDeleted: true - ); + // Reject DELETE operations on archival-annotated schemas unless this + // call originates from the retention sweep cron (which alone sets + // $_retentionSweep true). User-driven deletes get a structured 403. + if ($this->currentSchema !== null) { + $this->rejectIfArchivalImmutable( + schema: $this->currentSchema, + retentionSweep: $_retentionSweep + ); + } - // If no schema was provided but we have an object, derive the schema from the object. - if ($this->currentSchema === null) { - $this->setSchema(schema: $objectToDelete->getSchema()); + // Find the object to get its owner for permission check (include soft-deleted objects). + // When the caller supplied both register + schema, the lookup is scoped + // to a single magic table — a UUID in a different scope raises + // DoesNotExistException and never reaches the delete handler. + $scopedRegister = null; + $scopedSchema = null; + if ($hasScope === true) { + $scopedRegister = $this->currentRegister; + $scopedSchema = $this->currentSchema; } - \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.found'); + try { + $objectToDelete = $this->objectMapper->find( + identifier: $uuid, + register: $scopedRegister, + schema: $scopedSchema, + includeDeleted: true + ); - // Check user has permission to delete this specific object. - $this->checkPermission( - schema: $this->currentSchema, - action: 'delete', - userId: $actingUserId, - objectOwner: $objectToDelete->getOwner(), - _rbac: $_rbac, - object: $objectToDelete - ); + // If no schema was provided but we have an object, derive the schema from the object. + if ($this->currentSchema === null) { + $this->setSchema(schema: $objectToDelete->getSchema()); + } - \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.permitted'); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - // Scoped lookup is authoritative: if the caller asked for a - // specific (register, schema) and the UUID is not in that scope, - // re-throw so the failure mode is "404 not in scope" instead of - // "silently look at another magic table" (the #1638 bug). - if ($hasScope === true) { - throw $e; - } + \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.found'); - // Unscoped path: object doesn't exist anywhere, no permission check - // needed but let deleteHandler raise its own consistent error path. - if ($this->currentSchema !== null) { + // Check user has permission to delete this specific object. $this->checkPermission( schema: $this->currentSchema, action: 'delete', userId: $actingUserId, - objectOwner: null, - _rbac: $_rbac + objectOwner: $objectToDelete->getOwner(), + _rbac: $_rbac, + object: $objectToDelete ); - } - }//end try - return $this->deleteHandler->deleteObject( - register: $this->currentRegister, - schema: $this->currentSchema, - uuid: $uuid, - originalObjectId: null, - _rbac: $_rbac, - _multitenancy: $_multitenancy, - scoped: $hasScope, - permanent: $permanent - ); + \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.permitted'); + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + // Scoped lookup is authoritative: if the caller asked for a + // specific (register, schema) and the UUID is not in that scope, + // re-throw so the failure mode is "404 not in scope" instead of + // "silently look at another magic table" (the #1638 bug). + if ($hasScope === true) { + throw $e; + } + + // Unscoped path: object doesn't exist anywhere, no permission check + // needed but let deleteHandler raise its own consistent error path. + if ($this->currentSchema !== null) { + $this->checkPermission( + schema: $this->currentSchema, + action: 'delete', + userId: $actingUserId, + objectOwner: null, + _rbac: $_rbac + ); + } + }//end try + + return $this->deleteHandler->deleteObject( + register: $this->currentRegister, + schema: $this->currentSchema, + uuid: $uuid, + originalObjectId: null, + _rbac: $_rbac, + _multitenancy: $_multitenancy, + scoped: $hasScope, + permanent: $permanent + ); + } finally { + $this->restoreScopeContext(register: $previousRegister, schema: $previousSchema); + } }//end deleteObject() /** * Check whether a schema declares an `x-openregister-archival` annotation. * * Used by the deleteObject() immutability gate to short-circuit - * user-driven deletes before any DB work. Reads from the schema's - * `configuration` array; absence of the key (or a non-array value) - * means archival enforcement does NOT apply. + * user-driven deletes before any DB work. Delegates to + * {@see Schema::hasArchivalAnnotation()}, which is the single definition of + * the rule: DeletedController's purge gate asks the same question of the + * same method, so the two delete routes cannot drift apart. * * @param Schema $schema Schema to inspect. * @@ -2505,10 +2590,60 @@ public function deleteObject( */ private function schemaHasArchivalAnnotation(Schema $schema): bool { - $configuration = ($schema->getConfiguration() ?? []); - return is_array($configuration['x-openregister-archival'] ?? null); + return $schema->hasArchivalAnnotation(); }//end schemaHasArchivalAnnotation() + /** + * Refuse a delete on an archival-annotated schema. + * + * THE ONE PLACE THIS SERVICE DECIDES A DELETE IS NOT ALLOWED. + * + * `deleteObject()` and `deleteObjects()` destroy rows through different + * routes — the single-object path resolves its own scope and calls the + * delete handler once; the bulk loop resolves each row's scope itself and + * calls the handler per row. Only the first one used to ask this question, + * so `POST /api/bulk/{register}/{schema}/delete` was a third door onto the + * destruction that `DELETE /api/objects/...` answers with a 403 and + * `DELETE /api/deleted/{uuid}` has refused since openregister#3428. Both + * callers now go through here, so the rule has one definition per door and + * the doors read the same one. + * + * WHAT TO DO ABOUT AN UNRESOLVED SCHEMA IS THE CALLER'S DECISION, and the two + * callers genuinely differ, so this method takes a resolved Schema and each + * call site states its own policy. `deleteObject()` keeps skipping the gate + * when it has no schema in scope — a bare `deleteObject($uuid)` never had + * one and refusing those now would break every scopeless caller. The bulk + * loop refuses instead, because it resolves each ROW's own schema: failing + * to resolve one is not evidence the row is deletable, and falling back to + * the route's schema would gate a cross-table row on the wrong annotation. + * + * @param Schema $schema Schema the row belongs to. + * @param bool $retentionSweep True only for ArchivalRetentionTask, the sanctioned expiry path. + * + * @return void + * + * @throws ArchivalImmutableException When the delete is not permitted. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Mirrors deleteObject()'s $_retentionSweep parameter. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + private function rejectIfArchivalImmutable(Schema $schema, bool $retentionSweep): void + { + if ($retentionSweep === true) { + return; + } + + if ($this->schemaHasArchivalAnnotation(schema: $schema) === false) { + return; + } + + throw new ArchivalImmutableException( + schemaIdentifier: ($schema->getSlug() ?? (string) $schema->getId()), + operation: 'delete' + ); + }//end rejectIfArchivalImmutable() + /** * Reject an operation if the object has been transferred to e-Depot. * @@ -2519,7 +2654,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 */ @@ -3928,6 +4064,10 @@ public function handleValidationException( * @param bool $advisory When true, treat the identifier as a synthetic * pre-creation key and take the appConfig-backed * advisory lock without scanning object tables + * @param string|null $runUuid Flow run taking the lock, for a run-scoped lock + * that refuses every other caller including the + * run's own runAs user + * @param string|null $nodeId Flow node that took it, recorded for the sweep * * @return array Lock information * @@ -3935,9 +4075,22 @@ public function handleValidationException( * * @spec exclude One-line delegation to lock handler; lock behavior owned by object-lifecycle. */ - public function lockObject(string $identifier, ?string $process=null, ?int $duration=null, bool $advisory=false): array - { - return $this->lockHandler->lock(identifier: $identifier, process: $process, duration: $duration, advisory: $advisory); + public function lockObject( + string $identifier, + ?string $process=null, + ?int $duration=null, + bool $advisory=false, + ?string $runUuid=null, + ?string $nodeId=null + ): array { + return $this->lockHandler->lock( + identifier: $identifier, + process: $process, + duration: $duration, + advisory: $advisory, + runUuid: $runUuid, + nodeId: $nodeId + ); }//end lockObject() /** @@ -3948,6 +4101,7 @@ public function lockObject(string $identifier, ?string $process=null, ?int $dura * @param string|int $identifier The object to unlock * @param bool $advisory When true, release the appConfig-backed advisory * lock for this synthetic key without scanning tables + * @param string|null $runUuid Flow run releasing the lock, for a run-scoped lock * * @return true True if unlocked successfully * @@ -3955,11 +4109,31 @@ public function lockObject(string $identifier, ?string $process=null, ?int $dura * * @spec exclude One-line delegation to lock handler; unlock behavior owned by object-lifecycle. */ - public function unlockObject(string|int $identifier, bool $advisory=false): bool + public function unlockObject(string|int $identifier, bool $advisory=false, ?string $runUuid=null): bool { - return $this->lockHandler->unlock(identifier: (string) $identifier, advisory: $advisory); + return $this->lockHandler->unlock( + identifier: (string) $identifier, + advisory: $advisory, + runUuid: $runUuid + ); }//end unlockObject() + /** + * Break a lock as an administrator, recording the displacement. + * + * @param string $identifier The object whose lock is to be broken + * + * @return bool True when a lock was broken + * + * @throws \Exception If the caller is not an administrator + * + * @spec exclude One-line delegation to lock handler; break behavior owned by the lock handler. + */ + public function breakObjectLock(string $identifier): bool + { + return $this->lockHandler->breakLock(identifier: $identifier); + }//end breakObjectLock() + /** * Bulk Save Operations Orchestrator (HIGH-PERFORMANCE BULK PROCESSING) * @@ -4040,63 +4214,72 @@ public function saveObjects( bool $enrich=true, bool $_audit=true ): array { - $this->discardPendingSchemaRef(); + // A BULK SAVE SCOPES ITSELF; IT DOES NOT SCOPE THE NEXT CALLER. + // Same contract as saveObject(); see restoreScopeContext(). + $previousRegister = $this->currentRegister; + $previousSchema = $this->currentSchema; - // Bound the folder-access revalidation cache to this bulk-save call - // (see saveObject) so mid-request folder mutations are re-validated. - $this->fileService->resetFolderAccessRevalidationCache(); + try { + $this->discardPendingSchemaRef(); - // Set register and schema context if provided. - if ($register !== null) { - $this->setRegister(register: $register); - } + // Bound the folder-access revalidation cache to this bulk-save call + // (see saveObject) so mid-request folder mutations are re-validated. + $this->fileService->resetFolderAccessRevalidationCache(); - if ($schema !== null) { - $this->setSchema(schema: $schema); - } + // Set register and schema context if provided. + if ($register !== null) { + $this->setRegister(register: $register); + } - // Delegate to SaveObjects handler for bulk save operations. - $bulkResult = $this->saveObjectsHandler->saveObjects( - objects: $objects, - register: $this->currentRegister, - schema: $this->currentSchema, - _rbac: $_rbac, - _multitenancy: $_multitenancy, - _validation: $validation, - _events: $events, - deduplicateIds: $deduplicateIds, - enrich: $enrich, - _audit: $_audit - ); + if ($schema !== null) { + $this->setSchema(schema: $schema); + } - // Invalidate collection caches after successful bulk operations. - $createdCount = (int) ($bulkResult['statistics']['objectsCreated'] ?? 0); - $updatedCount = (int) ($bulkResult['statistics']['objectsUpdated'] ?? 0); - $totalAffected = $createdCount + $updatedCount; + // Delegate to SaveObjects handler for bulk save operations. + $bulkResult = $this->saveObjectsHandler->saveObjects( + objects: $objects, + register: $this->currentRegister, + schema: $this->currentSchema, + _rbac: $_rbac, + _multitenancy: $_multitenancy, + _validation: $validation, + _events: $events, + deduplicateIds: $deduplicateIds, + enrich: $enrich, + _audit: $_audit + ); - if ($totalAffected > 0) { - try { - $this->cacheHandler->invalidateForObjectChange( - object: null, - operation: 'bulk_save', - registerId: $this->currentRegister?->getId(), - schemaId: $this->currentSchema?->getId() - ); - } catch (\Exception $e) { - // BUG-OBJ-14: include register/schema context in the warning. - $this->logger->warning( - message: '[ObjectService] Bulk save cache invalidation failed', - context: [ - 'error' => $e->getMessage(), - 'totalAffected' => $totalAffected, - 'registerId' => $this->currentRegister?->getId(), - 'schemaId' => $this->currentSchema?->getId(), - ] - ); - } - }//end if + // Invalidate collection caches after successful bulk operations. + $createdCount = (int) ($bulkResult['statistics']['objectsCreated'] ?? 0); + $updatedCount = (int) ($bulkResult['statistics']['objectsUpdated'] ?? 0); + $totalAffected = $createdCount + $updatedCount; + + if ($totalAffected > 0) { + try { + $this->cacheHandler->invalidateForObjectChange( + object: null, + operation: 'bulk_save', + registerId: $this->currentRegister?->getId(), + schemaId: $this->currentSchema?->getId() + ); + } catch (\Exception $e) { + // BUG-OBJ-14: include register/schema context in the warning. + $this->logger->warning( + message: '[ObjectService] Bulk save cache invalidation failed', + context: [ + 'error' => $e->getMessage(), + 'totalAffected' => $totalAffected, + 'registerId' => $this->currentRegister?->getId(), + 'schemaId' => $this->currentSchema?->getId(), + ] + ); + } + }//end if - return $bulkResult; + return $bulkResult; + } finally { + $this->restoreScopeContext(register: $previousRegister, schema: $previousSchema); + } }//end saveObjects() /** @@ -4137,25 +4320,220 @@ public function saveObjectsStreaming( Register | string | int | null $register=null, Schema | string | int | null $schema=null ): BatchOperationStatus { - if ($register !== null) { - $this->setRegister(register: $register); + // A STREAMING SAVE SCOPES ITSELF; IT DOES NOT SCOPE THE NEXT CALLER. + // Same contract as saveObject(); see restoreScopeContext(). + $previousRegister = $this->currentRegister; + $previousSchema = $this->currentSchema; + + try { + if ($register !== null) { + $this->setRegister(register: $register); + } + + if ($schema !== null) { + $this->setSchema(schema: $schema); + } + + // The reference cache is request-scoped and this call is a logical + // boundary: verdicts from an earlier batch must not decide this one. + $this->saveHandler->clearReferenceValidationCache(); + + return $this->saveHandler->saveObjectsStreaming( + register: $this->currentRegister, + schema: $this->currentSchema, + rows: $objects + ); + + } finally { + $this->restoreScopeContext(register: $previousRegister, schema: $previousSchema); } + }//end saveObjectsStreaming() - if ($schema !== null) { - $this->setSchema(schema: $schema); + + /** + * Append rows to a register+schema table with every safeguard switched OFF. + * + * THIS IS THE FAST PATH FOR HIGH-VOLUME, LOW-VALUE WRITERS such as traffic + * events and telemetry: chunked multi-row INSERT ... ON DUPLICATE KEY UPDATE + * statements straight into the magic table. It exists because the normal + * save path costs several queries and side effects PER ROW, and a collector + * receiving thousands of events a minute can afford neither. + * + * WHAT IS SKIPPED, on purpose and without exception: + * - RBAC and multitenancy: nobody's permissions are checked. Rows carry + * whatever `owner` / `organisation` the caller passes, or NULL, so they + * may be invisible to tenant-scoped reads. + * - Schema validation: property values are written as given. A value that + * does not fit its column fails at the database; a property the table + * has no column for is dropped. + * - Audit trail: no create/update entry is written, so these rows have no + * history, no revert and no hash chain. + * - Lifecycle events: no ObjectCreated/ObjectUpdated events, so webhooks, + * flows and the search index are not notified. + * - Relations, files, computed fields, slugs, cascading and enrichment. + * + * THE CALLER TAKES THE AUTHORISATION RESPONSIBILITY. Calling this method is + * the same declaration as `_rbac: false` on the normal path (ADR-022): the + * caller has decided who may write here. Use saveObjects() for anything a + * person will edit, audit or revert. + * + * Each `$objects` entry is a plain associative array of property values, + * plus the optional metadata keys `uuid` (a v4 is generated when absent), + * `expires` (ISO 8601 string or DateTimeInterface; lands in the indexed + * `_expires` column that purgeExpiredObjectsRaw() sweeps), `owner` and + * `organisation`. `_created` and `_updated` are set to now. The service's + * current register/schema context is left untouched. + * + * @param array $objects Rows to append, as plain arrays. + * @param Register|string|int $register The register entity, id, uuid or slug. + * @param Schema|string|int $schema The schema entity, id, uuid or slug, resolved WITHIN the register. + * + * @return int The number of rows written. + * + * @throws OcpDoesNotExistException When the register does not exist. + * @throws SchemaNotInRegisterException When the register does not carry the schema. + * @throws \OCP\DB\Exception When the insert fails. + * + * @psalm-param array> $objects + * @phpstan-param array> $objects + * + * @spec openspec/specs/data-import-export/spec.md#requirement-raw-append-for-high-volume-writers + */ + public function appendObjectsRaw(array $objects, Register|string|int $register, Schema|string|int $schema): int + { + if ($objects === []) { + return 0; } - // The reference cache is request-scoped and this call is a logical - // boundary: verdicts from an earlier batch must not decide this one. - $this->saveHandler->clearReferenceValidationCache(); + [$registerEntity, $schemaEntity] = $this->resolveRawTarget(register: $register, schema: $schema); - return $this->saveHandler->saveObjectsStreaming( - register: $this->currentRegister, - schema: $this->currentSchema, - rows: $objects + $rows = array_map( + fn (array $object): array => $this->prepareRawRow(object: $object), + array_values($objects) ); - }//end saveObjectsStreaming() + // The bulk upsert creates a missing table only after a failed statement; + // creating it up front keeps the first append on the same path as + // every later one. + $this->objectMapper->ensureTableForRegisterSchema(register: $registerEntity, schema: $schemaEntity); + $tableName = $this->objectMapper->getTableNameForRegisterSchema(register: $registerEntity, schema: $schemaEntity); + + $written = $this->objectMapper->bulkUpsert( + objects: $rows, + register: $registerEntity, + schema: $schemaEntity, + tableName: $tableName, + needsPreUpdateState: false + ); + + return count($written); + }//end appendObjectsRaw() + + + /** + * Hard-delete the rows of a register+schema table whose expiry has passed. + * + * The sweep for appendObjectsRaw(). Rows with a NULL `_expires` are never + * touched. This BYPASSES soft-delete and the audit trail on purpose: raw + * rows never had an audit trail, so there is nothing to tombstone, and + * keeping expired telemetry as soft-deleted rows would defeat the + * retention the expiry expresses. Same responsibility model as the append: + * the caller has decided that this register+schema holds expiring raw rows. + * + * @param Register|string|int $register The register entity, id, uuid or slug. + * @param Schema|string|int $schema The schema entity, id, uuid or slug, resolved WITHIN the register. + * + * @return int The number of rows removed; 0 when the table does not exist yet. + * + * @throws OcpDoesNotExistException When the register does not exist. + * @throws SchemaNotInRegisterException When the register does not carry the schema. + * @throws \OCP\DB\Exception When the delete fails. + * + * @orphaned-write-capability exclude Declared on ObjectServiceInterface, so + * its callers are OTHER APPS and no in-repo call site can exist. + * gate-57 counts `->purgeExpiredObjectsRaw(` across lib/ only. Do + * NOT satisfy it by adding a route: this bypasses soft-delete and + * the audit trail by design, and exposing it over HTTP would hand + * any caller an unauditable hard delete of a whole register+schema. + * The sweep belongs to the app that owns the expiring rows. + * + * @spec openspec/specs/data-import-export/spec.md#requirement-raw-append-for-high-volume-writers + */ + public function purgeExpiredObjectsRaw(Register|string|int $register, Schema|string|int $schema): int + { + [$registerEntity, $schemaEntity] = $this->resolveRawTarget(register: $register, schema: $schema); + + return $this->objectMapper->purgeExpired(register: $registerEntity, schema: $schemaEntity); + }//end purgeExpiredObjectsRaw() + + + /** + * Resolve a register and a schema for a raw operation without touching the service context. + * + * The register is the boundary: the schema is resolved among the schemas the + * register carries, never instance-wide, exactly as setRegister()/setSchema() + * do. Unlike those setters nothing is stored on `$this`, so a raw write from + * a background job cannot leak a register or a pending schema ref into the + * next caller's chain. + * + * @param Register|string|int $register The register entity, id, uuid or slug. + * @param Schema|string|int $schema The schema entity, id, uuid or slug. + * + * @return array{0: Register, 1: Schema} The resolved pair. + * + * @throws OcpDoesNotExistException When the register does not exist. + * @throws SchemaNotInRegisterException When the register does not carry the schema. + * + * @spec openspec/specs/data-import-export/spec.md#requirement-raw-append-for-high-volume-writers + */ + private function resolveRawTarget(Register|string|int $register, Schema|string|int $schema): array + { + if (($register instanceof Register) === false) { + $register = $this->registerMapper->find(id: $register, _rbac: false, _multitenancy: false); + } + + if (($schema instanceof Schema) === false) { + $schema = $this->scopedSchemaResolver->resolveSchemaWithin(register: $register, schemaRef: $schema); + } + + return [$register, $schema]; + }//end resolveRawTarget() + + + /** + * Shape one caller-supplied row for the bulk handler. + * + * Metadata moves into `@self`, which is where the handler reads it from; + * everything else stays a property. Keys the caller did not pass are left + * to the handler's defaults: NULL owner and organisation, no expiry. + * + * @param array $object A plain row as passed to appendObjectsRaw(). + * + * @return array The row in the handler's `@self` + properties shape. + * + * @spec openspec/specs/data-import-export/spec.md#requirement-raw-append-for-high-volume-writers + */ + private function prepareRawRow(array $object): array + { + $self = ['uuid' => (string) ($object['uuid'] ?? Uuid::v4()->toRfc4122())]; + + foreach (['expires', 'owner', 'organisation'] as $metadataKey) { + if (array_key_exists($metadataKey, $object) === true) { + $self[$metadataKey] = $object[$metadataKey]; + } + } + + unset( + $object['uuid'], + $object['expires'], + $object['owner'], + $object['organisation'], + $object['@self'], + $object['object'] + ); + + return ['@self' => $self] + $object; + }//end prepareRawRow() /** * Transform objects from serialized format to database format @@ -4216,24 +4594,49 @@ public function migrateObjects( /** * Perform bulk delete operations on objects by UUID * - * This method handles both soft delete and hard delete based on the current state - * of the objects. If an object has no deleted value set, it performs a soft delete - * by setting the deleted timestamp. If an object already has a deleted value set, - * it performs a hard delete by removing the object from the database. + * EVERY DELETE HERE IS A SOFT DELETE. This docblock used to claim the loop + * escalated to a hard delete for a row already carrying a deleted timestamp; + * it does not, and never did — it calls `DeleteObject::deleteObject()` without + * `permanent: true`, which is the only thing that destroys a row. The claim + * was unfalsifiable while the loop ran zero iterations. Deleting a trashed row + * again re-tombstones it and reports success. Destroying a row for good is + * `DELETE /api/deleted/{uuid}` or `occ openregister:objects:purge`, both of + * which refuse an archival record (openregister#3428). + * + * EVERY REQUESTED UUID LANDS IN EXACTLY ONE OUTCOME BUCKET: the union of + * 'deleted_uuids' and 'skipped_uuids' is the submitted set. It used not to be + * — a UUID the permission filter removed, and a UUID whose handler answered + * false rather than throwing, left the loop in neither — so a caller could be + * told `requested 1, deleted 0, skipped 0` about a row that is still there. * * @param array $uuids Array of object UUIDs to delete * @param bool $_rbac Whether to apply RBAC filtering * @param bool $_multitenancy Whether to apply multi-organization filtering * - * @return array Associative array with 'deleted_uuids', 'skipped_uuids', and 'cascade_count' keys. + * @return array Associative array with 'deleted_uuids', 'skipped_uuids', + * 'skipped_reasons' and 'cascade_count' keys. * 'deleted_uuids' contains UUIDs of successfully deleted objects. - * 'skipped_uuids' contains UUIDs skipped due to RESTRICT or errors. + * 'skipped_uuids' contains UUIDs skipped by a gate, a RESTRICT + * constraint or an error; it is the complement of 'deleted_uuids'. + * 'skipped_reasons' maps each skipped UUID to a structured + * {error, message} pair, so an archival refusal is legible as + * something other than a RESTRICT block. * 'cascade_count' contains total count of objects affected by cascade operations. * * @phpstan-param array $uuids * @psalm-param array $uuids - * @phpstan-return array{deleted_uuids: array, skipped_uuids: array, cascade_count: int} - * @psalm-return array{deleted_uuids: array, skipped_uuids: array, cascade_count: int} + * @phpstan-return array{ + * deleted_uuids: array, + * skipped_uuids: array, + * skipped_reasons: array>, + * cascade_count: int + * } + * @psalm-return array{ + * deleted_uuids: array, + * skipped_uuids: array, + * skipped_reasons: array>, + * cascade_count: int + * } * * @spec exclude Bulk-delete loop over deleteHandler->deleteObject(); per-object RESTRICT/CASCADE behavior owned by referential-integrity. */ @@ -4241,7 +4644,12 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten { $this->discardPendingSchemaRef(); if (empty($uuids) === true) { - return ['deleted_uuids' => [], 'skipped_uuids' => [], 'cascade_count' => 0]; + return [ + 'deleted_uuids' => [], + 'skipped_uuids' => [], + 'skipped_reasons' => [], + 'cascade_count' => 0, + ]; } // Apply RBAC and multi-organization filtering if enabled. @@ -4254,6 +4662,20 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten ); } + // EVERY REQUESTED UUID MUST LAND IN EXACTLY ONE OUTCOME BUCKET. + // A UUID the filter removes is a row the caller asked to delete and + // still believes it deleted. These used to vanish here: the endpoint + // reported `requested_count: 1, deleted_count: 0, skipped_count: 0`, + // which names no failure for a caller to act on. Report them as + // skipped, with the reason, so `requested = deleted + skipped` holds. + $skippedReasons = []; + foreach (array_diff($uuids, $filteredUuids) as $refusedUuid) { + $skippedReasons[$refusedUuid] = [ + 'error' => 'OBJECT_NOT_PERMITTED', + 'message' => 'Object is not visible to this user, or does not exist.', + ]; + } + // PERF: resolve the scope AND the entity of every UUID with ONE batched // cross-table lookup instead of a full magic-table scan per UUID. The // batch matches on the uuid column only; identifiers in other shapes @@ -4269,7 +4691,7 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten // referential integrity rules (CASCADE, SET_NULL, SET_DEFAULT, RESTRICT) // are enforced per object. Skips objects that fail (e.g., RESTRICT blocks). $deletedObjectIds = []; - $skippedUuids = []; + $skippedUuids = array_keys($skippedReasons); $totalCascadeCount = 0; // BUG-OBJ-5: collect the distinct (registerId, schemaId) pairs of the // objects we actually delete so we can invalidate the per-schema query @@ -4338,6 +4760,42 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten } } + // ARCHIVAL IMMUTABILITY, ON THE THIRD DOOR. + // This loop calls the delete HANDLER directly, so it never went + // through deleteObject()'s gate: a schema declaring + // `x-openregister-archival` — refused with 403 by + // `DELETE /api/objects/...`, and by the purge route since + // openregister#3428 — was destroyed here without a word. + // + // The question is asked of the ROW's OWN schema, not of the + // route's. A bulk delete spans magic tables, so gating on the + // scope the controller happened to set would check somebody + // else's annotation. A row whose schema cannot be resolved is + // refused rather than deleted: an unresolvable schema is not + // evidence that a row is deletable. The refusal is per row, so + // the rest of the batch still runs. + $gateSchema = $this->loadRowSchema( + schemaId: $deletedSchemaId, + schemaCache: $schemaEntityCache + ); + if ($gateSchema === null) { + $skippedUuids[] = $uuid; + $skippedReasons[$uuid] = [ + 'error' => 'SCHEMA_UNRESOLVED', + 'message' => 'The schema of this object could not be resolved, ' + .'so its archival status could not be established.', + ]; + continue; + } + + try { + $this->rejectIfArchivalImmutable(schema: $gateSchema, retentionSweep: false); + } catch (ArchivalImmutableException $archivalRefusal) { + $skippedUuids[] = $uuid; + $skippedReasons[$uuid] = $archivalRefusal->toResponseBody(); + continue; + } + $result = $this->deleteHandler->deleteObject( register: $handlerRegister, schema: $handlerSchema, @@ -4372,6 +4830,23 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten ]; } }//end if + + if ($result !== true) { + // The handler reports some refusals by RETURN VALUE rather + // than by throwing — a read-only object source declines this + // way, and so does its own catch when the inner delete + // fails. Without this branch the UUID left the loop in + // neither bucket and the caller was told nothing at all. + $this->logger->warning( + message: '[ObjectService] Bulk delete handler refused object', + context: ['uuid' => $uuid] + ); + $skippedUuids[] = $uuid; + $skippedReasons[$uuid] = [ + 'error' => 'DELETE_REFUSED', + 'message' => 'The delete handler did not delete this object.', + ]; + } } catch (\OCA\OpenRegister\Exception\ReferentialIntegrityException $e) { // RESTRICT blocks should not abort the entire bulk operation. // Log and skip this object, continue with the rest. @@ -4382,7 +4857,11 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten 'blockers' => count($e->getAnalysis()->blockers), ] ); - $skippedUuids[] = $uuid; + $skippedUuids[] = $uuid; + $skippedReasons[$uuid] = [ + 'error' => 'REFERENTIAL_INTEGRITY_RESTRICT', + 'message' => $e->getMessage(), + ]; } catch (\Exception $e) { // Other failures (transaction rollback, etc.) are logged and skipped. $this->logger->warning( @@ -4392,7 +4871,11 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten 'error' => $e->getMessage(), ] ); - $skippedUuids[] = $uuid; + $skippedUuids[] = $uuid; + $skippedReasons[$uuid] = [ + 'error' => 'DELETE_FAILED', + 'message' => $e->getMessage(), + ]; }//end try }//end foreach @@ -4433,9 +4916,10 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten }//end if return [ - 'deleted_uuids' => $deletedObjectIds, - 'skipped_uuids' => $skippedUuids, - 'cascade_count' => $totalCascadeCount, + 'deleted_uuids' => $deletedObjectIds, + 'skipped_uuids' => $skippedUuids, + 'skipped_reasons' => $skippedReasons, + 'cascade_count' => $totalCascadeCount, ]; }//end deleteObjects() @@ -4540,6 +5024,57 @@ private function loadDeleteScopeEntities( return [$registerCache[$registerKey], $schemaCache[$schemaKey]]; }//end loadDeleteScopeEntities() + /** + * Materialise the Schema a bulk-delete candidate actually belongs to. + * + * Separate from {@see loadDeleteScopeEntities()} because the archival gate + * needs the row's schema on BOTH delete paths, including the legacy one + * that hands the handler no pre-resolved entity — there the register may be + * unknown while the schema is not, and refusing the delete because the + * register did not materialise would be a refusal for the wrong reason. + * + * Shares the caller's per-operation schema cache, so a batch of rows on one + * schema costs one lookup. Returns null when the id is absent or does not + * resolve; the caller decides what that means. + * + * @param string|int|null $schemaId The schema id carried by the resolved row. + * @param array $schemaCache Cache of Schema entities keyed by int id. + * + * @return Schema|null The schema, or null when it cannot be established. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + private function loadRowSchema(string | int | null $schemaId, array &$schemaCache): ?Schema + { + if (is_numeric($schemaId) === false) { + return null; + } + + $schemaKey = (int) $schemaId; + if (isset($schemaCache[$schemaKey]) === true) { + return $schemaCache[$schemaKey]; + } + + try { + $schemaCache[$schemaKey] = $this->schemaMapper->find( + $schemaKey, + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable $schemaError) { + $this->logger->warning( + message: '[ObjectService] Bulk delete could not resolve the schema of a candidate row', + context: [ + 'schemaId' => $schemaKey, + 'error' => $schemaError->getMessage(), + ] + ); + return null; + } + + return $schemaCache[$schemaKey]; + }//end loadRowSchema() + /** * Delete all objects belonging to a specific schema * @@ -4895,47 +5430,58 @@ public function patchObject( bool $_multitenancy=true, ?IUser $currentUser=null ): ObjectEntity { - // Resolve the caller's scope onto the service context first, so the - // lookup below and the save that follows both see the same one. - $this->setContextFromParameters( - register: $register, - schema: $schema - ); + // A PATCH SCOPES ITSELF; IT DOES NOT SCOPE THE NEXT CALLER. + // The saveObject() it delegates to now restores too, so without this the + // only context left behind would be the one patchObject() set itself. + // See restoreScopeContext(). + $previousRegister = $this->currentRegister; + $previousSchema = $this->currentSchema; - // Scoped lookup when both halves of the scope are known — that targets - // exactly one magic table. Otherwise the legacy cross-table resolve, - // which every existing caller relies on. Note the identifier is passed - // through unchanged: casting a uuid to int resolves to an unrelated row. - $scopedRegister = null; - $scopedSchema = null; - if ($register !== null && $schema !== null) { - $scopedRegister = $this->currentRegister; - $scopedSchema = $this->currentSchema; - } + try { + // Resolve the caller's scope onto the service context first, so the + // lookup below and the save that follows both see the same one. + $this->setContextFromParameters( + register: $register, + schema: $schema + ); - $existing = $this->objectMapper->find( - identifier: $objectId, - register: $scopedRegister, - schema: $scopedSchema - ); + // Scoped lookup when both halves of the scope are known — that targets + // exactly one magic table. Otherwise the legacy cross-table resolve, + // which every existing caller relies on. Note the identifier is passed + // through unchanged: casting a uuid to int resolves to an unrelated row. + $scopedRegister = null; + $scopedSchema = null; + if ($register !== null && $schema !== null) { + $scopedRegister = $this->currentRegister; + $scopedSchema = $this->currentSchema; + } - $merged = $this->mergePatchData( - stored: (array) $existing->getObject(), - patch: $data - ); + $existing = $this->objectMapper->find( + identifier: $objectId, + register: $scopedRegister, + schema: $scopedSchema + ); - // Address the save at the object we actually resolved, not at whatever - // form of the identifier the caller happened to hold. - $merged['id'] = ($existing->getUuid() ?? $objectId); + $merged = $this->mergePatchData( + stored: (array) $existing->getObject(), + patch: $data + ); - return $this->saveObject( - object: $merged, - register: $register, - schema: $schema, - _rbac: $_rbac, - _multitenancy: $_multitenancy, - currentUser: $currentUser - ); + // Address the save at the object we actually resolved, not at whatever + // form of the identifier the caller happened to hold. + $merged['id'] = ($existing->getUuid() ?? $objectId); + + return $this->saveObject( + object: $merged, + register: $register, + schema: $schema, + _rbac: $_rbac, + _multitenancy: $_multitenancy, + currentUser: $currentUser + ); + } finally { + $this->restoreScopeContext(register: $previousRegister, schema: $previousSchema); + } }//end patchObject() /** diff --git a/lib/Service/ObjectSource/NcEntitySemanticMap.php b/lib/Service/ObjectSource/NcEntitySemanticMap.php index ce707d31f5..d20c28a660 100644 --- a/lib/Service/ObjectSource/NcEntitySemanticMap.php +++ b/lib/Service/ObjectSource/NcEntitySemanticMap.php @@ -63,9 +63,15 @@ final class NcEntitySemanticMap { * - `provider` — the ObjectSourceProvider id that serves its objects; * - `requiredApp` — the NC app that must be installed for the provider to be * usable (`null` = Nextcloud core, always available); - * - `application` — the register's `application` (drives the ADR-048 gate). + * - `application` — the register's `application` (drives the ADR-048 gate); + * - `writable` — OPTIONAL, absent means read-only. Sets the schema's + * `x-openregister-object-source.readOnly`, which is the + * annotation the save/delete dispatch reads before it will + * delegate a write. Only `nc-organisation` carries it. * - * @var array + * @var array */ public const ENTITIES = [ 'user' => [ @@ -84,6 +90,34 @@ 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. + // + // WRITABLE, and the only row that is. A read-only projection cannot + // replace the leaf copies, because the apps that declared them CREATE + // organisations: stackiq's setup walkthrough says "Click New and save an + // organisation" and advances on `object-created`. The write goes through + // `OrganisationService::createOrganisation()`, so the slug, the owner and + // the admin-group RBAC grant are assigned as they are anywhere else, and + // `remove()` refuses outright. + // + // The other rows project someone else's system (a Nextcloud user, a Deck + // card) and stay read-only. This one projects OpenRegister's own entity. + 'organisation' => [ + 'register' => self::DIRECTORY_REGISTER, + 'schema' => 'nc-organisation', + 'schemaOrg' => 'schema:Organization', + 'provider' => 'organisation-source', + 'requiredApp' => null, + 'application' => 'openregister', + 'writable' => true, + ], // 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..9d64abcf9b --- /dev/null +++ b/lib/Service/ObjectSource/OrganisationObjectSourceProvider.php @@ -0,0 +1,617 @@ + + * @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 OCA\OpenRegister\Service\OrganisationService; +use OCP\IGroupManager; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +/** + * Projects each organisation as a virtual object, readable by its members and + * writable by its owner. + * + * @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 WritableObjectSourceProvider { + + /** + * 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. + * @param OrganisationService $organisationService The organisation lifecycle, + * which owns slug generation, + * owner assignment and the + * admin-group RBAC grant. + * + * @return void + */ + public function __construct( + private readonly OrganisationMapper $organisationMapper, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + private readonly OrganisationService $organisationService, + ) { + }//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() + + /** + * {@inheritDoc} + * + * Creates the organisation through {@see OrganisationService::createOrganisation()} + * rather than through the mapper, so the slug, the owner, the admin users and + * the admin-group RBAC grant are all assigned the way they are for an + * organisation created anywhere else. A row written straight through the + * mapper would be a tenant nobody administers. + * + * The remaining identity fields are applied afterwards, because + * `createOrganisation()` takes only a name and a description. + * + * @param Register $register The register the schema belongs to. + * @param Schema $schema The sourced schema. + * @param array $data The validated object data. + * @param array $config The object-source config block (unused). + * + * @return ObjectEntity The created organisation, projected. + * + * @throws RuntimeException When there is no acting user, or `name` is missing. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $config reserved for future scoping options. + * + * @spec openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md#requirement-an-organisation-can-be-created-through-the-projection-req-orp-104 + */ + public function insert(Register $register, Schema $schema, array $data, array $config = []): ObjectEntity { + if ($this->userSession->getUser() === null) { + throw new RuntimeException('An organisation cannot be created without an acting user.'); + } + + $name = trim((string)($data['name'] ?? '')); + if ($name === '') { + // Refused rather than defaulted. `createOrganisation()` derives the + // slug from the name, so an empty one would produce a tenant with an + // empty slug that the next create then collides with. + throw new RuntimeException('An organisation needs a name.'); + } + + $organisation = $this->organisationService->createOrganisation( + name: $name, + description: (string)($data['description'] ?? '') + ); + + $organisation = $this->applyProjectedFields(organisation: $organisation, data: $data, skip: ['name', 'description']); + + return $this->toObjectEntity(register: $register, schema: $schema, organisation: $organisation); + }//end insert() + + /** + * {@inheritDoc} + * + * Only the projected identity fields are written. Tenancy administration + * (quota, users, groups, authorization) is not projected and so cannot be + * reached here, which is the same boundary the read side draws. + * + * @param Register $register The register the schema belongs to. + * @param Schema $schema The sourced schema. + * @param string $id The organisation uuid. + * @param array $data The validated object data. + * @param array $config The object-source config block (unused). + * + * @return ObjectEntity The updated organisation, projected. + * + * @throws RuntimeException When the organisation is absent, merged away, or the + * acting user does not administer it. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $config reserved for future scoping options. + * + * @spec openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md#requirement-only-an-organisations-administrator-may-write-it-req-orp-105 + */ + public function update(Register $register, Schema $schema, string $id, array $data, array $config = []): ObjectEntity { + try { + $organisation = $this->organisationMapper->findByUuid(uuid: $id); + } catch (Throwable $e) { + throw new RuntimeException(sprintf('Organisation "%s" does not exist.', $id), 0, $e); + } + + // NOT `findByUuidFollowingMerge()`, which the read side uses. Following a + // merge on a WRITE would silently edit the survivor while the caller + // believes it is editing the record it addressed. + if ($organisation->isMerged() === true) { + throw new RuntimeException( + sprintf('Organisation "%s" has been merged away and cannot be written.', $id) + ); + } + + if ($this->mayWrite(organisation: $organisation) === false) { + // Deliberately the same message shape as the absent case above would + // give an outsider nothing to distinguish them by — but an + // administrator reading the log needs the difference, so it is logged. + $this->logger->info( + '[ObjectSource:organisation-source] write on organisation ' . $id . ' refused: acting user is not its administrator' + ); + throw new RuntimeException(sprintf('Organisation "%s" does not exist.', $id)); + } + + $organisation = $this->applyProjectedFields(organisation: $organisation, data: $data, skip: []); + + return $this->toObjectEntity(register: $register, schema: $schema, organisation: $organisation); + }//end update() + + /** + * {@inheritDoc} + * + * REFUSES, always. An organisation is the tenant boundary: every object, + * register and schema on the instance is scoped to one. Deleting it through + * the object API would orphan all of that, from a caller that believes it is + * removing a reference record. + * + * Merging is the operation that exists for retiring an organisation, and it + * keeps every stored reference pointing at a record that still owns something. + * + * @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 bool Never returns. + * + * @throws RuntimeException Always. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The signature is the interface's. + * + * @spec openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md#requirement-an-organisation-cannot-be-deleted-through-the-projection-req-orp-106 + */ + public function remove(Register $register, Schema $schema, string $id, array $config = []): bool { + throw new RuntimeException( + sprintf( + 'Organisation "%s" cannot be deleted through the object API: it is a tenant boundary, ' + .'and every object scoped to it would be orphaned. Merge it instead.', + $id + ) + ); + }//end remove() + + /** + * Write the projected identity fields onto an organisation and persist it. + * + * Only keys in {@see PROJECTED} are written. A key outside it is ignored + * rather than rejected: the store already discards unprojected properties on + * the way in, so rejecting here would fail a request over a field the caller + * cannot see anyway. + * + * Organisation's accessors are magic (`Entity::__call`), so the setter is + * called dynamically. `method_exists()` would answer false for every one of + * them, which is exactly how {@see project()} once produced an object + * carrying nothing but an id. + * + * @param Organisation $organisation The organisation to write. + * @param array $data The object data. + * @param array $skip Properties already applied. + * + * @return Organisation The saved organisation. + * + * @spec openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md#requirement-only-the-identity-facet-is-writable-req-orp-107 + */ + private function applyProjectedFields(Organisation $organisation, array $data, array $skip): Organisation { + $changed = false; + + foreach (self::PROJECTED as $property) { + if (in_array($property, $skip, true) === true || array_key_exists($property, $data) === false) { + continue; + } + + $value = $data[$property]; + if ($value !== null && is_scalar($value) === false) { + continue; + } + + if ($value !== null) { + $value = (string)$value; + } + + $organisation->{'set'.ucfirst($property)}($value); + $changed = true; + } + + if ($changed === false) { + return $organisation; + } + + return $this->organisationMapper->save($organisation); + }//end applyProjectedFields() + + /** + * Whether the acting user may write this organisation. + * + * Membership is enough to READ the projection; writing needs ownership. + * `isOrganisationAdmin()` is OpenRegister's own answer to that question + * (instance admin, or the organisation's owner), so the projection and the + * rest of the app agree on who administers an organisation. + * + * @param Organisation $organisation The organisation being written. + * + * @return bool True when the acting user administers it. + * + * @spec openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md#requirement-only-an-organisations-administrator-may-write-it-req-orp-105 + */ + private function mayWrite(Organisation $organisation): bool { + if ($this->userSession->getUser() === null) { + return false; + } + + try { + return $this->organisationService->isOrganisationAdmin( + organisationUuid: (string)$organisation->getUuid() + ); + } catch (Throwable $e) { + $this->logger->warning( + '[ObjectSource:organisation-source] could not check write authority: ' . $e->getMessage() + ); + return false; + } + }//end mayWrite() + + /** + * 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/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/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/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/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/Service/RetentionService.php b/lib/Service/RetentionService.php index 26cf8ddd22..3b8111adec 100644 --- a/lib/Service/RetentionService.php +++ b/lib/Service/RetentionService.php @@ -771,7 +771,7 @@ public function createDestructionList(array $objects): ?array { 'register' => $object->getRegister(), 'archiefactiedatum' => $retention['archiefactiedatum'] ?? null, 'classification' => $retention['classification'] ?? null, - 'softDeleted' => $object->getDeleted() !== null, + 'softDeleted' => $object->isSoftDeleted(), 'wooGepubliceerd' => $isWooPublished, ]; } diff --git a/lib/Service/SchemaDeletionService.php b/lib/Service/SchemaDeletionService.php index e1b3b1b56f..800294fbb3 100644 --- a/lib/Service/SchemaDeletionService.php +++ b/lib/Service/SchemaDeletionService.php @@ -34,6 +34,7 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\ArchivalImmutableException; use OCP\IDBConnection; use Psr\Log\LoggerInterface; use Throwable; @@ -57,6 +58,13 @@ * therefore leaves an EMPTY orphan table, which is reported honestly as * `tableDropped: false` — it never fails a request whose data work succeeded. * + * ARCHIVAL IMMUTABILITY: a schema declaring `x-openregister-archival` holds records + * its operator is legally required to retain, so neither entry point will destroy its + * objects. `deleteObjectsBySchema()` refuses outright; `cascadeDeleteSchema()` refuses + * unless an operator authorises the destruction explicitly from the CLI. See + * {@see self::rejectIfArchivalImmutable()} for why the audit trail is not a substitute + * for the record. + * * KNOWN LIMIT: the cascade is synchronous, and its audit writes are inherently * sequential (hash-chained, ADR-003). A schema with far more than ~10k objects will * make the request slow and may hit the PHP time limit. Because phase 1 is @@ -128,17 +136,25 @@ public function __construct( * its row is touched, because `MagicMapper::deleteObjectsBySchema()` returns * only a count and cannot tell us afterwards what it removed. * + * ARCHIVAL SCHEMAS ARE REFUSED OUTRIGHT. Both callers are HTTP routes, and the + * spec's answer for an HTTP caller is the same at every door: an archival record + * leaves only through the retention cron or the `occ` purge. There is no override + * parameter here on purpose — an override this method accepted would be reachable + * over HTTP the moment a controller passed it through. + * * @param int $registerId The register id. * @param int $schemaId The schema id. * @param bool $hardDelete True to remove the rows, false to soft-delete them. * @param string $triggeredBy Audit trigger context. * + * @throws ArchivalImmutableException If the schema declares `x-openregister-archival`. * @throws \Exception If the register or schema cannot be resolved, or deletion fails. * * @return array{deleted_count: int, deleted_uuids: array, schema_id: int} The deletion result. * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The hard/soft toggle mirrors the mapper primitive it wraps. * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md * @spec openspec/changes/schema-delete-cascade/specs/runtime-schema-api/spec.md */ public function deleteObjectsBySchema( @@ -150,6 +166,8 @@ public function deleteObjectsBySchema( $register = $this->registerMapper->find(id: $registerId); $schema = $this->schemaMapper->find(id: $schemaId); + $this->rejectIfArchivalImmutable(schema: $schema, operation: 'delete'); + $result = $this->deleteObjectsOfPair( register: $register, schema: $schema, @@ -177,15 +195,36 @@ public function deleteObjectsBySchema( * still succeeds, because the caller's intent is already satisfied and DDL is not * rollbackable on MySQL/MariaDB. * + * ARCHIVAL SCHEMAS ARE REFUSED UNLESS THE CALLER OVERRIDES EXPLICITLY, and the + * override is an argument rather than a default because the two callers do not + * carry the same authority. `DELETE /api/schemas/{id}?deleteObjects=true` never + * passes it: an HTTP caller cannot destroy a legally retained record, exactly as + * at the other three doors. `occ openregister:schemas:prune-retired` passes it + * only when the operator typed `--force-archival`, which is the same bargain + * `occ openregister:objects:purge --force` already strikes — shell access is an + * authorization boundary an HTTP request cannot cross, and the operator has to + * name the archival records out loud rather than sweep them up inside a flag + * they passed for another reason. + * * @param Schema $schema The schema to tear down. + * @param bool $archivalOverride True only when an operator has explicitly authorised + * destroying a legally retained record from the CLI. * + * @throws ArchivalImmutableException If the schema is archival and no override was given. * @throws \Exception If phase 1 fails (nothing is deleted). * * @return array{deletedCount: int, deletedUuids: array, tableDropped: bool} The cascade result. * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The override is the caller's authority, and it has to be stated per call site. + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md * @spec openspec/changes/schema-delete-cascade/specs/runtime-schema-api/spec.md */ - public function cascadeDeleteSchema(Schema $schema): array { + public function cascadeDeleteSchema(Schema $schema, bool $archivalOverride = false): array { + if ($archivalOverride === false) { + $this->rejectIfArchivalImmutable(schema: $schema, operation: 'delete'); + } + $schemaId = (int)$schema->getId(); $tables = $this->resolveMagicTablesForSchema(schema: $schema); @@ -260,6 +299,60 @@ public function cascadeDeleteSchema(Schema $schema): array { }//end cascadeDeleteSchema() + /** + * How many objects `cascadeDeleteSchema()` would destroy. + * + * THE GUARD AND THE DELETE MUST LOOK AT THE SAME ROWS. They did not: the + * prune command counted through the registers that REFERENCE a schema and + * through `MagicSearchHandler`, which applies RBAC. Both narrow the set. + * + * Under `occ` there is no session, so the RBAC filter reads as Anonymous and + * rows nobody anonymous may see counted as zero. And a schema referenced by + * no register counted zero by construction — which is exactly the state a + * half-pruned schema is left in. Either way the operator read "0 objects, + * safe to delete" and the delete dropped the table. + * + * This counts what the delete enumerates: every magic table resolved for the + * schema, including ones whose register no longer references it. + * + * @param Schema $schema The schema to count for. + * + * @return int The number of objects a cascade delete would remove. + * + * @spec openspec/specs/schema-import/spec.md#requirement-a-schema-retired-from-a-descriptor-must-be-removable-from-the-instance + */ + public function countObjectsCascadeWouldDelete(Schema $schema): int { + $total = 0; + + foreach ($this->resolveMagicTablesForSchema(schema: $schema) as $entry) { + if ($entry['register'] === null) { + // A table outliving its register: the delete drops it without + // reading it, so it removes an unknown number of rows. Counting + // it as at least one keeps the guard on the side of the data. + $total++; + continue; + } + + try { + $total += $this->magicMapper->countObjectsInRegisterSchemaTable( + query: [ + '_rbac' => false, + '_multitenancy' => false, + '_includeDeleted' => true, + ], + register: $entry['register'], + schema: $schema + ); + } catch (Throwable $e) { + // An unreadable table cannot be counted. Treat it as non-empty so + // the guard errs towards keeping data. + $total++; + } + } + + return $total; + }//end countObjectsCascadeWouldDelete() + /** * Drop the schema's magic tables, but only when they hold no rows at all. * @@ -324,6 +417,60 @@ public function dropEmptyTablesForSchema(Schema $schema): bool { }//end dropEmptyTablesForSchema() + /** + * Refuse to destroy the objects of a schema that holds legally retained records. + * + * THE ONE PLACE THIS SERVICE DECIDES A SCHEMA-WIDE DELETE IS NOT ALLOWED. + * + * The condition is not restated here: it is read from + * {@see Schema::hasArchivalAnnotation()}, the single definition of "is archival" + * that `ObjectService::deleteObject()`, `ObjectService::rejectIfArchivalImmutable()`, + * `DeletedController::destroy()` and `occ openregister:objects:purge` all ask. A + * fifth reading of the annotation is a fifth chance to disagree with the other four. + * + * The gate sits in this service rather than in its callers because this service is + * the single implementation of "delete every object of a schema": both bulk routes, + * the HTTP cascade and the prune CLI reach the destruction through here, so a future + * caller inherits the refusal instead of having to remember it. + * + * WHY REFUSE RATHER THAN PERMIT-WITH-AUDIT. This service audits every object into the + * hash-chained trail before dropping it, which is genuinely a design for deliberate + * destruction — but an audit entry records that a record was destroyed, it is not the + * record. A retention obligation is discharged by still holding the row, so the audit + * trail cannot stand in for it, and the fact that this path destroys carefully is not + * a reason to let it destroy here. + * + * @param Schema $schema The schema whose objects are about to be destroyed. + * @param string $operation The operation name reported in the structured error body. + * + * @throws ArchivalImmutableException When the schema declares `x-openregister-archival`. + * + * @return void + * + * @spec openspec/specs/archival-annotation-vocabulary/spec.md + */ + private function rejectIfArchivalImmutable(Schema $schema, string $operation): void { + if ($schema->hasArchivalAnnotation() === false) { + return; + } + + $this->logger->warning( + message: '[SchemaDeletionService] Refused a schema-wide delete on an archival schema', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'schemaId' => (int)$schema->getId(), + 'schemaSlug' => $schema->getSlug(), + 'operation' => $operation, + ] + ); + + throw new ArchivalImmutableException( + schemaIdentifier: ($schema->getSlug() ?? (string)$schema->getId()), + operation: $operation + ); + }//end rejectIfArchivalImmutable() + /** * Audit and delete every object of one register/schema magic table. * diff --git a/lib/Service/Settings/ConfigurationSettingsHandler.php b/lib/Service/Settings/ConfigurationSettingsHandler.php index e2db6c0967..0e3138c3b1 100644 --- a/lib/Service/Settings/ConfigurationSettingsHandler.php +++ b/lib/Service/Settings/ConfigurationSettingsHandler.php @@ -1279,69 +1279,7 @@ public function updateFileSettingsOnly(array $fileData): array { }//end try }//end updateFileSettingsOnly() - /** - * Get n8n workflow configuration settings only. - * - * Retrieves the n8n workflow automation integration settings. - * - * @return array n8n configuration. - * - * @throws \RuntimeException If n8n settings retrieval fails. - * - * @spec openspec/changes/retrofit-2026-05-25-bw-svc-mid1/tasks.md#task-1 - * @spec openspec/changes/retrofit-2026-05-24-b-svc-settings-mgmt/tasks.md#task-2 - */ - public function getN8nSettingsOnly(): array { - try { - $n8nConfig = $this->appConfig->getValueString($this->appName, 'n8n', ''); - if (empty($n8nConfig) === true) { - // Return default configuration. - return [ - 'enabled' => false, - 'url' => '', - 'apiKey' => '', - 'project' => 'openregister', - ]; - } - - return json_decode($n8nConfig, true); - } catch (Exception $e) { - throw new RuntimeException('Failed to retrieve n8n settings: ' . $e->getMessage()); - } - }//end getN8nSettingsOnly() - - /** - * Update n8n workflow configuration settings only. - * - * Updates the n8n workflow automation integration settings. - * - * @param array $n8nData n8n configuration data. - * - * @return (false|mixed|string)[] Updated n8n configuration. - * - * @throws \RuntimeException If n8n settings update fails. - * - * @psalm-return array{enabled: false|mixed, url: ''|mixed, apiKey: ''|mixed, project: 'openregister'|mixed} - * - * @spec openspec/changes/retrofit-2026-05-25-bw-svc-mid1/tasks.md#task-1 - * @spec openspec/changes/retrofit-2026-05-24-b-svc-settings-mgmt/tasks.md#task-2 - */ - public function updateN8nSettingsOnly(array $n8nData): array { - try { - $n8nConfig = [ - 'enabled' => $n8nData['enabled'] ?? false, - 'url' => $n8nData['url'] ?? '', - 'apiKey' => $n8nData['apiKey'] ?? '', - 'project' => $n8nData['project'] ?? 'openregister', - ]; - - $this->appConfig->setValueString($this->appName, 'n8n', json_encode($n8nConfig)); - return $n8nConfig; - } catch (Exception $e) { - throw new RuntimeException('Failed to update n8n settings: ' . $e->getMessage()); - } - }//end updateN8nSettingsOnly() /** * Get only version information. 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/lib/Service/Task/TaskAuthorizationService.php b/lib/Service/Task/TaskAuthorizationService.php new file mode 100644 index 0000000000..324cec6dc5 --- /dev/null +++ b/lib/Service/Task/TaskAuthorizationService.php @@ -0,0 +1,529 @@ + + * 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. + * + * @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 { + + /** + * 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', + ]; + + /** + * 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. + * + * @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 { + $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) { + 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 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. + * + * `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..2ff124e771 --- /dev/null +++ b/lib/Service/Task/TaskBuilder.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-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); + + // 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')); + + // 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)); + + // Sequence membership (flow-approval-consolidation): the ordinal is + // written at provisioning and never re-derived from timestamps. + $task->setSequenceUuid($this->stringOrNull(value: $data['sequenceUuid'] ?? null)); + $task->setSequencePosition($this->intOrNull(value: ($data['sequencePosition'] ?? null))); + $task->setLegacyStepId($this->intOrNull(value: ($data['legacyStepId'] ?? 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() + + /** + * 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. + * + * @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/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/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..88aca91584 --- /dev/null +++ b/lib/Service/Task/TaskFormResolver.php @@ -0,0 +1,381 @@ + + * 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, + 'behaviours' => $this->behavioursOf(task: $task), + ]; + } + + $form = null; + if ($declaration->isNative() === true) { + $form = $this->describeNative(declaration: $declaration); + } 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. + * + * @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/TaskInboxService.php b/lib/Service/Task/TaskInboxService.php new file mode 100644 index 0000000000..7063f2514c --- /dev/null +++ b/lib/Service/Task/TaskInboxService.php @@ -0,0 +1,375 @@ +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\PortalTaskDelivery; +use OCA\OpenRegister\Db\PortalTaskDeliveryMapper; +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 + * + * @SuppressWarnings(PHPMD.StaticAccess) PortalTaskDelivery::summarise is a + * stateless fold over rows; an instance to call it would be a second copy. + */ +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. + * @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() + + /** + * 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']; + + 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. + * + * 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. + * + * 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(); + } + + $context = $this->contextRow(serialised: (array)$serialised); + if ($context !== null) { + $contexts[(string)$context['uuid']] = $context; + } + } + + return $contexts; + }//end subjectContexts() + + /** + * One subject's context row, read from its REAL serialised shape. + * + * 🔴 THE REAL SHAPE. ObjectEntity::jsonSerialize() puts the object's own + * data at top level and its identity under `@self` (uuid keyed `id` there, + * mirrored as top-level `id`); there is no top-level `uuid`, `register` or + * `schema` key. Reading those flat keys made every row's subject null — + * the portal contract (flow-portal-task: "with their case context") + * shipped null case context while the unit test's hand-rolled stub agreed + * with the wrong shape. The flat keys stay as fallbacks for stores that + * serialise flat. + * + * @param array $serialised The object's serialised form. + * + * @return array|null The context row, or null without an identity. + * + * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else + */ + private function contextRow(array $serialised): ?array { + $self = ($serialised['@self'] ?? null); + if (is_array($self) === false) { + $self = []; + } + + $uuid = trim((string)($self['id'] ?? ($serialised['id'] ?? ($serialised['uuid'] ?? '')))); + if ($uuid === '') { + return null; + } + + return [ + 'uuid' => $uuid, + 'register' => ($self['register'] ?? ($serialised['register'] ?? null)), + 'schema' => ($self['schema'] ?? ($serialised['schema'] ?? null)), + 'title' => ($self['name'] ?? ($serialised['name'] ?? ($serialised['title'] ?? null))), + ]; + }//end contextRow() +}//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/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/TaskSequenceDecisionGuard.php b/lib/Service/Task/TaskSequenceDecisionGuard.php new file mode 100644 index 0000000000..1f12d0e502 --- /dev/null +++ b/lib/Service/Task/TaskSequenceDecisionGuard.php @@ -0,0 +1,143 @@ + + * @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-approval-consolidation/specs/approval-workflow/spec.md#req-009 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskSequence; +use OCA\OpenRegister\Db\TaskSequenceMapper; +use OCA\OpenRegister\Exception\TaskSeparationOfDutiesException; +use OCP\AppFramework\Db\DoesNotExistException; + +/** + * Refuses a self-decision on a sequence position, delegated or direct. + * + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-009 + */ +class TaskSequenceDecisionGuard { + + /** + * Constructor. + * + * @param TaskSequenceMapper $sequences Resolves the task's sequence. + */ + public function __construct( + private readonly TaskSequenceMapper $sequences, + ) { + + }//end __construct() + + /** + * Refuse a decision the sequence's policy forbids. + * + * A task outside any sequence, a sequence that cannot be resolved, and a + * sequence with no recorded requester all pass: there is nothing to + * enforce. The refusal fires only on the one fact the policy names — the + * deciding identity, direct or acted-for, is the recorded requester. + * + * @param Task $task The task being decided. + * @param string|null $actor The deciding identity. + * + * @return void + * + * @throws TaskSeparationOfDutiesException On a refused self-decision. + * + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-009 + */ + public function assertDecidable(Task $task, ?string $actor): void { + $sequenceUuid = trim((string)$task->getSequenceUuid()); + if ($sequenceUuid === '') { + return; + } + + try { + $sequence = $this->sequences->findByUuid(uuid: $sequenceUuid); + } catch (DoesNotExistException) { + // A dangling sequence reference has no policy to read. The + // performer check still applies in full. + return; + } + + if ($this->separationOfDutiesApplies(sequence: $sequence) === false) { + return; + } + + $requester = trim((string)$sequence->getRequesterId()); + if ($requester === '') { + return; + } + + $deciding = trim((string)$actor); + $actedFor = trim((string)$task->getOnBehalfOf()); + + if ($deciding === $requester) { + throw new TaskSeparationOfDutiesException( + message: sprintf( + "Separation of duties: '%s' requested this approval and may not decide it. This is not an authorization failure.", + $requester + ) + ); + } + + if ($actedFor !== '' && $actedFor === $requester) { + throw new TaskSeparationOfDutiesException( + message: sprintf( + "Separation of duties: this decision acts on behalf of '%s', who requested the approval." + . ' A delegated self-decision is refused on the same grounds.', + $requester + ) + ); + } + }//end assertDecidable() + + /** + * Whether the frozen declaration requires separation of duties. + * + * ON unless the frozen snapshot explicitly says `false`. The snapshot is + * read, never the live schema, so an opt-out edited onto the schema + * mid-cycle does not loosen an approval that is already running. + * + * @param TaskSequence $sequence The sequence being decided. + * + * @return bool True when the policy applies. + * + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-009 + */ + private function separationOfDutiesApplies(TaskSequence $sequence): bool { + $snapshot = ($sequence->getTemplateSnapshot() ?? []); + + return (($snapshot['separationOfDuties'] ?? true) !== false); + }//end separationOfDutiesApplies() +}//end class diff --git a/lib/Service/Task/TaskSequenceService.php b/lib/Service/Task/TaskSequenceService.php new file mode 100644 index 0000000000..c606becf20 --- /dev/null +++ b/lib/Service/Task/TaskSequenceService.php @@ -0,0 +1,415 @@ + + * @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-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-an-approval-is-an-ordered-task-sequence-with-one-position-enabled-at-a-time + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use DateTime; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskSequence; +use OCA\OpenRegister\Db\TaskSequenceMapper; +use OCA\OpenRegister\Event\TaskSequenceCompletedEvent; +use OCP\EventDispatcher\IEventDispatcher; +use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; +use Throwable; + +/** + * Provisions and progresses ordered task sequences. + * + * @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 + * + * @SuppressWarnings(PHPMD.StaticAccess) Uuid::v4() is the codebase's uuid + * idiom and TaskState is the published, stateless state vocabulary. + */ +class TaskSequenceService { + + /** + * The system actor prefix a sequence writes into task audits. + * + * @var string + */ + public const ACTOR_PREFIX = 'task-sequence:'; + + /** + * Constructor. + * + * @param TaskSequenceMapper $sequences The sequence rows. + * @param TaskMapper $taskReader Reads a sequence's positions. Read-only + * here; every write goes through the verbs. + * @param TaskService $tasks The authorized task lifecycle. + * @param IEventDispatcher $dispatcher Announces sequence completion. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly TaskSequenceMapper $sequences, + private readonly TaskMapper $taskReader, + private readonly TaskService $tasks, + private readonly IEventDispatcher $dispatcher, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Provision a sequence from a compiled template. + * + * Creates every position and enables ONLY the first. The template + * snapshot and the resolved tier are FROZEN onto the sequence row, so a + * schema edit or an amount edit cannot re-shape or re-route an approval + * that is already running (design D-3). + * + * @param array $template The compiled template + * ({@see \OCA\OpenRegister\Service\ApprovalChainAnnotationInstaller::compile()}). + * @param string $anchorObjectUuid The object the approval is about. + * @param string|null $requesterId Who attempted the gated transition. + * @param array>|null $tierPositions The resolved + * amount tier, or + * null for every + * declared position. + * @param int|null $registerId The anchor's register. + * @param string|null $runUuid Optional flow-run provenance. + * @param string|null $nodeId Optional node provenance. + * + * @return TaskSequence The provisioned, running sequence. + * + * @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 provision( + array $template, + string $anchorObjectUuid, + ?string $requesterId, + ?array $tierPositions = null, + ?int $registerId = null, + ?string $runUuid = null, + ?string $nodeId = null, + ): TaskSequence { + $positions = ($tierPositions ?? (array)($template['positions'] ?? [])); + + $sequence = new TaskSequence(); + $sequence->setUuid(Uuid::v4()->toRfc4122()); + $sequence->setTemplateId((string)($template['templateId'] ?? '')); + $sequence->setTemplateVersion((int)($template['templateVersion'] ?? 1)); + $sequence->setTemplateSnapshot($template); + $sequence->setAnchorObjectUuid($anchorObjectUuid); + $sequence->setRegisterId($registerId); + $schemaId = ($template['schemaId'] ?? null); + if ($schemaId !== null) { + $schemaId = (int)$schemaId; + } + + $sequence->setSchemaId($schemaId); + $sequence->setChainKey((string)($template['name'] ?? '')); + $sequence->setRequesterId($requesterId); + if ($tierPositions !== null) { + $sequence->setResolvedTier(['positions' => $tierPositions]); + } + + $sequence->setPositionCursor((int)(($positions[0]['order'] ?? 1))); + $sequence->setStatus(TaskSequence::STATUS_RUNNING); + $sequence->setRunUuid($runUuid); + $sequence->setNodeId($nodeId); + $sequence->setOpenedAt(new DateTime()); + $sequence = $this->sequences->insert($sequence); + + $creator = ($requesterId ?? self::ACTOR_PREFIX . (string)$sequence->getUuid()); + $count = count($positions); + $index = 0; + foreach ($positions as $position) { + $index++; + $order = (int)($position['order'] ?? $index); + $role = trim((string)($position['role'] ?? '')); + $state = Task::STATE_AVAILABLE; + if ($index === 1) { + $state = Task::STATE_ENABLED; + } + + $this->tasks->import( + data: [ + 'title' => sprintf('Approve %s (step %d of %d)', (string)$sequence->getChainKey(), $order, $count), + 'description' => sprintf( + 'Approval step %d of %d for %s on object %s.', + $order, + $count, + (string)$sequence->getChainKey(), + $anchorObjectUuid + ), + 'state' => $state, + 'performerType' => Task::PERFORMER_GROUP, + 'candidateGroups' => [$role], + 'routingStrategy' => 'single-role', + 'requester' => $requesterId, + 'objectUuid' => $anchorObjectUuid, + 'registerId' => $registerId, + 'schemaId' => $sequence->getSchemaId(), + 'templateId' => $sequence->getTemplateId(), + 'templateVersion' => $sequence->getTemplateVersion(), + 'templateSnapshot' => $position, + 'sequenceUuid' => $sequence->getUuid(), + 'sequencePosition' => $order, + 'runUuid' => $runUuid, + 'nodeId' => $nodeId, + ], + actor: $creator + ); + }//end foreach + + return $sequence; + }//end provision() + + /** + * Progress a sequence after one of its tasks reached a terminal state. + * + * Called in the SAME request as the completing decision, after the + * task's own transaction committed. Three paths: + * + * - an approving completion enables the next position, or completes the + * sequence and dispatches {@see TaskSequenceCompletedEvent} + * - a rejecting completion closes the sequence as rejected (KEPT, never + * deleted; design D-5) and terminates every remaining task with a + * reason naming the rejecting position + * - any other terminal end (cancelled, terminated, disabled) closes the + * sequence as terminated and terminates the remainder + * + * Idempotent: a terminal sequence absorbs every further report, which is + * exactly what the remainder-termination fan-out produces. + * + * @param Task $task The sequence task as persisted in a terminal state. + * + * @return void + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-a-rejection-terminates-the-sequence-and-every-task-it-still-owns + */ + public function onTaskTerminal(Task $task): void { + $sequenceUuid = trim((string)$task->getSequenceUuid()); + if ($sequenceUuid === '') { + return; + } + + try { + $sequence = $this->sequences->findByUuid(uuid: $sequenceUuid); + } catch (Throwable $missing) { + $this->logger->warning( + '[TaskSequenceService] Task ' . (string)$task->getUuid() . ' names sequence ' + . $sequenceUuid . ', which does not resolve: ' . $missing->getMessage() + ); + + return; + } + + if ($sequence->isTerminal() === true) { + return; + } + + $outcome = (string)$task->getOutcome(); + $isDecision = ((string)$task->getState() === Task::STATE_COMPLETED); + + if ($isDecision === true && TaskState::isRejectingOutcome(outcome: $outcome) === false) { + $this->advance(sequence: $sequence, completed: $task); + + return; + } + + if ($isDecision === true) { + $this->close( + sequence: $sequence, + status: TaskSequence::STATUS_REJECTED, + outcome: $this->statusOnReject(sequence: $sequence, position: (int)$task->getSequencePosition()), + reason: sprintf( + 'Rejected at position %d by %s.', + (int)$task->getSequencePosition(), + (string)($task->getCompletedBy() ?? 'an unnamed decider') + ) + ); + + return; + } + + $this->close( + sequence: $sequence, + status: TaskSequence::STATUS_TERMINATED, + outcome: 'terminated', + reason: sprintf( + "Position %d ended as '%s' without a decision.", + (int)$task->getSequencePosition(), + (string)$task->getState() + ) + ); + }//end onTaskTerminal() + + /** + * Enable the next position, or complete the sequence. + * + * @param TaskSequence $sequence The running sequence. + * @param Task $completed The position that just completed approvingly. + * + * @return void + * + * @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 + */ + private function advance(TaskSequence $sequence, Task $completed): void { + $source = self::ACTOR_PREFIX . (string)$sequence->getUuid(); + $next = null; + foreach ($this->taskReader->findBySequence(sequenceUuid: (string)$sequence->getUuid()) as $candidate) { + $later = ((int)$candidate->getSequencePosition() > (int)$completed->getSequencePosition()); + if ($later === true && $candidate->isInTerminalState() === false) { + $next = $candidate; + break; + } + } + + if ($next !== null) { + $this->tasks->enable( + uuid: (string)$next->getUuid(), + source: $source, + reason: sprintf('Position %d approved; position %d is now enabled.', (int)$completed->getSequencePosition(), (int)$next->getSequencePosition()) + ); + $sequence->setPositionCursor((int)$next->getSequencePosition()); + $this->sequences->update($sequence); + + return; + } + + $statusOnApprove = $this->statusOnApprove(sequence: $sequence, position: (int)$completed->getSequencePosition()); + $sequence->setStatus(TaskSequence::STATUS_COMPLETED); + $sequence->setOutcome($statusOnApprove); + $sequence->setClosedAt(new DateTime()); + $persisted = $this->sequences->update($sequence); + + $this->dispatcher->dispatchTyped( + new TaskSequenceCompletedEvent( + sequence: $persisted, + finalTask: $completed, + decider: $completed->getCompletedBy(), + statusOnApprove: $statusOnApprove + ) + ); + }//end advance() + + /** + * Close a sequence and terminate every task it still owns. + * + * The sequence row is closed FIRST, so the terminality events the + * remainder-termination raises find a terminal sequence and no-op. The + * closed sequence, its tasks, its decisions and its audit remain + * readable indefinitely: nothing here deletes (design D-5). + * + * @param TaskSequence $sequence The sequence to close. + * @param string $status The terminal status. + * @param string $outcome The recorded outcome. + * @param string $reason Why, written onto every terminated task's audit. + * + * @return void + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-a-rejection-terminates-the-sequence-and-every-task-it-still-owns + */ + private function close(TaskSequence $sequence, string $status, string $outcome, string $reason): void { + $sequence->setStatus($status); + $sequence->setOutcome($outcome); + $sequence->setClosedAt(new DateTime()); + $this->sequences->update($sequence); + + $source = self::ACTOR_PREFIX . (string)$sequence->getUuid(); + foreach ($this->taskReader->findBySequence(sequenceUuid: (string)$sequence->getUuid()) as $sibling) { + if ($sibling->isInTerminalState() === true) { + continue; + } + + try { + $this->tasks->terminateAsMoot(uuid: (string)$sibling->getUuid(), reason: $reason, source: $source); + } catch (Throwable $failure) { + // Terminating the remainder must not fail the decision that + // closed the sequence; the missed task is terminal-by-sweep. + $this->logger->error( + '[TaskSequenceService] Could not terminate task ' . (string)$sibling->getUuid() + . ' of closed sequence ' . (string)$sequence->getUuid() . ': ' . $failure->getMessage(), + ['exception' => $failure] + ); + } + }//end foreach + }//end close() + + /** + * The `statusOnApprove` the frozen declaration resolves for a position. + * + * @param TaskSequence $sequence The sequence. + * @param int $position The final position's ordinal. + * + * @return string The resolved status; `approved` when undeclared. + * + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-010 + */ + private function statusOnApprove(TaskSequence $sequence, int $position): string { + return $this->declaredStatus(sequence: $sequence, position: $position, key: 'statusOnApprove', default: 'approved'); + }//end statusOnApprove() + + /** + * The `statusOnReject` the frozen declaration resolves for a position. + * + * @param TaskSequence $sequence The sequence. + * @param int $position The rejecting position's ordinal. + * + * @return string The resolved status; `rejected` when undeclared. + * + * @spec openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md#req-007 + */ + private function statusOnReject(TaskSequence $sequence, int $position): string { + return $this->declaredStatus(sequence: $sequence, position: $position, key: 'statusOnReject', default: 'rejected'); + }//end statusOnReject() + + /** + * Read a per-position declared status off the FROZEN snapshot. + * + * @param TaskSequence $sequence The sequence whose snapshot to read. + * @param int $position The position's ordinal. + * @param string $key `statusOnApprove` or `statusOnReject`. + * @param string $default The vocabulary default. + * + * @return string The declared value, or the default. + */ + private function declaredStatus(TaskSequence $sequence, int $position, string $key, string $default): string { + $snapshot = ($sequence->getTemplateSnapshot() ?? []); + $declared = ($sequence->getResolvedTier()['positions'] ?? ($snapshot['positions'] ?? [])); + foreach ((array)$declared as $entry) { + if (is_array($entry) === true && (int)($entry['order'] ?? 0) === $position) { + $value = trim((string)($entry[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + } + + return $default; + }//end declaredStatus() +}//end class diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php new file mode 100644 index 0000000000..b2d86f71b7 --- /dev/null +++ b/lib/Service/Task/TaskService.php @@ -0,0 +1,1530 @@ + + * 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\Event\TaskTerminalEvent; +use OCA\OpenRegister\Event\TaskTransitionedEvent; +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; +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 + * 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. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) The tenth constructor + * 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 + */ +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). + * @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. + * @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. + * @param TaskSequenceDecisionGuard|null $sequenceGuard Refuses a + * self-decision on a + * sequence position + * BEFORE the performer + * check, so the reason + * is honest. Nullable + * for hand-built test + * services; absent, no + * sequence policy is + * enforced here. + */ + 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, + private readonly ?IEventDispatcher $dispatcher = null, + private readonly ?TaskFormReader $forms = null, + private readonly ?TaskSequenceDecisionGuard $sequenceGuard = null, + ) { + + }//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. + * + * 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->refuseUnrenderableForm(task: $task); + $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 { + $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. + * @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, + ?array $responses = null, + ?array $evidence = null, + ): Task { + return $this->completeInternal( + verb: 'complete', + uuid: $uuid, + outcome: $outcome, + resultText: $resultText, + comment: $comment, + actor: $actor, + responses: $responses, + evidence: $evidence + ); + }//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() + + /** + * Consume an approving decision: record that the work it authorized ran. + * + * The home of openconnector's `consumedAt` semantic + * (flow-approval-consolidation, HITL retirement inventory): an approved + * but unconsumed request must not silently re-authorize a later run. A + * TRUSTED in-process verb: the actor is the consuming system + * (`sync-run:`, `flow-run:`), recorded on the audit. + * + * Refused on a task that is not completed with an approving outcome, and + * refused AGAIN on one already consumed — the second refusal is the + * whole point. + * + * @param string $uuid The decided task's uuid. + * @param string $source The consuming identity recorded as actor. + * @param string $reason What relied on the approval. + * + * @return Task The task with its consumption recorded. + * + * @throws TaskConflictException When not an unconsumed approving decision. + * + * @spec openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md#requirement-a-human-in-the-loop-semantic-is-not-retired-until-it-has-a-named-home + */ + public function consume(string $uuid, string $source, string $reason): Task { + $task = $this->tasks->findByUuid(uuid: $uuid); + + $approved = ((string)$task->getState() === Task::STATE_COMPLETED + && TaskState::isRejectingOutcome(outcome: $task->getOutcome()) === false); + if ($approved === false) { + throw new TaskConflictException( + message: sprintf( + "Verb 'consume' refused: task '%s' is not an approving decision (state '%s', outcome '%s').", + $uuid, + (string)$task->getState(), + (string)$task->getOutcome() + ) + ); + } + + $metadata = ($task->getMetadata() ?? []); + if (trim((string)($metadata['consumedAt'] ?? '')) !== '') { + throw new TaskConflictException( + message: sprintf( + "Verb 'consume' refused: the approval on task '%s' was already consumed at %s by %s, and an approval authorizes exactly once.", + $uuid, + (string)$metadata['consumedAt'], + (string)($metadata['consumedBy'] ?? 'an unrecorded consumer') + ) + ); + } + + $metadata['consumedAt'] = (new DateTime())->format('c'); + $metadata['consumedBy'] = $source; + $task->setMetadata($metadata); + + // A hand-rolled transaction instead of transactional(): the task was + // terminal long before this call, and re-announcing its terminality + // here would replay run continuation and sequence progression for a + // decision that already happened. + $this->db->beginTransaction(); + try { + $persisted = $this->tasks->update($task); + $this->appendAudit(task: $persisted, action: 'consume', actor: $source, reason: $reason); + $this->db->commit(); + } catch (Throwable $failure) { + $this->db->rollBack(); + throw $failure; + } + + return $persisted; + }//end consume() + + /** + * Enable a position: the sequence's own promotion verb. + * + * The task-sequence equivalent of the twenty lines at the retired + * `ApprovalService.php:193-204` — a waiting position becomes the one + * position a person can act on. A TRUSTED in-process verb like + * {@see terminateAsMoot()}: the actor is a system source + * (`task-sequence:`), recorded on the audit, and the caller is + * {@see TaskSequenceService}, which owns WHEN a position may be enabled. + * Not reachable over HTTP. + * + * Refused on a terminal task (the reason names its terminal state) and a + * no-op on a task that is already enabled or active. + * + * @param string $uuid The task uuid. + * @param string $source The enabling identity recorded as actor. + * @param string $reason Why, recorded on the audit. + * + * @return Task The enabled task. + * + * @throws TaskConflictException When the task is terminal, or closed concurrently. + * + * @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 enable(string $uuid, string $source, string $reason): Task { + $task = $this->tasks->findByUuid(uuid: $uuid); + if ($task->isInTerminalState() === true) { + throw new TaskConflictException( + message: sprintf("Verb 'enable' refused: task '%s' is already in terminal state '%s'.", $uuid, (string)$task->getState()) + ); + } + + if ($task->getState() === Task::STATE_ENABLED || $task->getState() === Task::STATE_ACTIVE) { + // Idempotent: enabling the enabled position changes nothing. + return $task; + } + + return $this->transactional( + mutation: function () use ($task, $source, $reason): Task { + $this->applyState(task: $task, state: Task::STATE_ENABLED, action: 'enable'); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit(task: $persisted, action: 'enable', actor: $source, reason: $reason); + + return $persisted; + } + ); + }//end enable() + + /** + * 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. + * + * @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() + + /** + * 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. + * + * @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. + * @param array|null $responses Submitted answer fields, when any. + * @param array>|null $evidence Stored file references, when any. + * + * @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 + * @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, + string $uuid, + string $outcome, + ?string $resultText, + ?string $comment, + ?string $actor, + ?array $responses = null, + ?array $evidence = null, + ): Task { + // Separation of duties FIRST (flow-approval-consolidation D-8): a + // requester who claimed their own approval would PASS the assignee + // check, and refusing after it would report the wrong reason. + if ($this->sequenceGuard !== null) { + $this->sequenceGuard->assertDecidable(task: $this->tasks->findByUuid(uuid: $uuid), actor: $actor); + } + + $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) + ); + } + + // 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, + $recordedOutcome, + $targetState, + $auditReason, + $resultText, + $comment, + $actor, + $verb, + $responses, + $evidence, + ): Task { + $task->setOutcome($recordedOutcome); + $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: $targetState, action: $verb); + $persisted = $this->persistOpen(task: $task); + $this->appendAudit(task: $persisted, action: $verb, actor: $actor, reason: $auditReason); + + return $persisted; + } + ); + + }//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. + * + * @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()) + ); + } + + // 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() + + /** + * 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, 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. + * + * @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(); + } 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 + // 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() + + /** + * 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. + * + * 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/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/lib/Service/VocabularyImportService.php b/lib/Service/VocabularyImportService.php index cce6b6a7af..87317c85b3 100644 --- a/lib/Service/VocabularyImportService.php +++ b/lib/Service/VocabularyImportService.php @@ -546,11 +546,21 @@ private function deprecateMissing(string $schemeUuid, array $seenUris): int { $offset = 0; do { + // The pair goes UNDER `filters`. `prepareFindAllConfig()` reads + // `$config['filters']['register']` / `['schema']` and no other key, so + // a top-level pair scopes NOTHING — the read ran on whatever the last + // write left on the shared ObjectService. In this loop that was + // self-concealing: the `saveObject()` below anchors the service on + // exactly this pair, so page 1 read an arbitrary scope and every later + // page read the right one, by accident. openregister#3408 is what the + // same shape costs when no write happens to precede the read. $results = $this->objectService->findAll( config: [ - 'register' => self::REGISTER, - 'schema' => self::SCHEMA_CONCEPT, - 'filters' => ['inScheme' => $schemeUuid], + 'filters' => [ + 'register' => self::REGISTER, + 'schema' => self::SCHEMA_CONCEPT, + 'inScheme' => $schemeUuid, + ], 'limit' => $limit, 'offset' => $offset, ] @@ -596,11 +606,17 @@ private function deprecateMissing(string $schemeUuid, array $seenUris): int { * @return ObjectEntity|null The matching object, or null when absent. */ private function findByUri(string $schema, string $uri): ?ObjectEntity { + // The pair goes UNDER `filters` — see deprecateMissing() for why a + // top-level pair is inert. This one is the import's identity lookup: an + // unscoped read here answers "does this uri already exist?" from the + // wrong table, and the import creates a duplicate rather than updating. $results = $this->objectService->findAll( config: [ - 'register' => self::REGISTER, - 'schema' => $schema, - 'filters' => ['uri' => $uri], + 'filters' => [ + 'register' => self::REGISTER, + 'schema' => $schema, + 'uri' => $uri, + ], 'limit' => 1, ] ); diff --git a/lib/Service/WorkflowEngineRegistry.php b/lib/Service/WorkflowEngineRegistry.php deleted file mode 100644 index fb18f22177..0000000000 --- a/lib/Service/WorkflowEngineRegistry.php +++ /dev/null @@ -1,282 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-configuration-entity - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-health-monitoring - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\Service; - -use DateTime; -use InvalidArgumentException; -use OCA\OpenRegister\Db\WorkflowEngine; -use OCA\OpenRegister\Db\WorkflowEngineMapper; -use OCA\OpenRegister\WorkflowEngine\N8nAdapter; -use OCA\OpenRegister\WorkflowEngine\WindmillAdapter; -use OCA\OpenRegister\WorkflowEngine\WorkflowEngineInterface; -use OCP\App\IAppManager; -use OCP\Security\ICrypto; -use Psr\Log\LoggerInterface; - -/** - * Registry for managing workflow engines and resolving adapters. - */ -class WorkflowEngineRegistry { - /** - * Constructor for WorkflowEngineRegistry. - * - * @param WorkflowEngineMapper $mapper Engine mapper - * @param N8nAdapter $n8nAdapter n8n adapter - * @param WindmillAdapter $windmillAdapter Windmill adapter - * @param ICrypto $crypto Crypto service for credential encryption - * @param IAppManager $appManager App manager for auto-discovery - * @param LoggerInterface $logger Logger - */ - public function __construct( - private readonly WorkflowEngineMapper $mapper, - private readonly N8nAdapter $n8nAdapter, - private readonly WindmillAdapter $windmillAdapter, - private readonly ICrypto $crypto, - private readonly IAppManager $appManager, - private readonly LoggerInterface $logger, - ) { - }//end __construct() - - /** - * Resolve the correct adapter for an engine configuration. - * - * @param WorkflowEngine $engine Engine configuration - * - * @return WorkflowEngineInterface Configured adapter - * - * @throws InvalidArgumentException If engine type is unsupported - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-configuration-entity - */ - public function resolveAdapter(WorkflowEngine $engine): WorkflowEngineInterface { - $authConfig = $this->decryptAuthConfig(engine: $engine); - - $adapter = match ($engine->getEngineType()) { - 'n8n' => $this->n8nAdapter, - 'windmill' => $this->windmillAdapter, - default => throw new InvalidArgumentException( - "Unsupported engine type: '{$engine->getEngineType()}'" - ), - }; - - $adapter->configure(baseUrl: $engine->getBaseUrl(), authConfig: $authConfig); - - return $adapter; - }//end resolveAdapter() - - /** - * Resolve an adapter by engine ID. - * - * @param int $engineId Engine ID - * - * @return WorkflowEngineInterface - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-registration-and-discovery - */ - public function resolveAdapterById(int $engineId): WorkflowEngineInterface { - $engine = $this->mapper->find($engineId); - - return $this->resolveAdapter(engine: $engine); - }//end resolveAdapterById() - - /** - * Get all registered engines. - * - * @return array - */ - public function getEngines(): array { - return $this->mapper->findAll(); - }//end getEngines() - - /** - * Get engines by type. - * - * @param string $engineType Engine type - * - * @return array - */ - public function getEnginesByType(string $engineType): array { - return $this->mapper->findByType($engineType); - }//end getEnginesByType() - - /** - * Get a single engine by ID. - * - * @param int $id Engine ID - * - * @return WorkflowEngine - */ - public function getEngine(int $id): WorkflowEngine { - return $this->mapper->find($id); - }//end getEngine() - - /** - * Create a new engine with encrypted credentials. - * - * @param array $data Engine configuration data - * - * @return WorkflowEngine - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-specific-credential-management - */ - public function createEngine(array $data): WorkflowEngine { - if (isset($data['authConfig']) === true && is_array($data['authConfig']) === true) { - $data['authConfig'] = $this->crypto->encrypt(json_encode($data['authConfig'])); - } - - return $this->mapper->createFromArray($data); - }//end createEngine() - - /** - * Update an engine with encrypted credentials. - * - * @param int $id Engine ID - * @param array $data Updated data - * - * @return WorkflowEngine - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-specific-credential-management - */ - public function updateEngine(int $id, array $data): WorkflowEngine { - if (isset($data['authConfig']) === true && is_array($data['authConfig']) === true) { - $data['authConfig'] = $this->crypto->encrypt(json_encode($data['authConfig'])); - } - - return $this->mapper->updateFromArray($id, $data); - }//end updateEngine() - - /** - * Delete an engine. - * - * @param int $id Engine ID - * - * @return WorkflowEngine The deleted engine - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-registration-and-discovery - */ - public function deleteEngine(int $id): WorkflowEngine { - $engine = $this->mapper->find($id); - $this->mapper->delete($engine); - - return $engine; - }//end deleteEngine() - - /** - * Run a health check on an engine and update its status. - * - * @param int $id Engine ID - * - * @return array{healthy: bool, responseTime: int} - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-health-monitoring - */ - public function healthCheck(int $id): array { - $engine = $this->mapper->find($id); - $adapter = $this->resolveAdapter(engine: $engine); - - $start = hrtime(true); - $healthy = $adapter->healthCheck(); - $elapsed = (int)((hrtime(true) - $start) / 1_000_000); - - $engine->setHealthStatus($healthy); - $engine->setLastHealthCheck(new DateTime()); - $engine->setUpdated(new DateTime()); - $this->mapper->update($engine); - - return [ - 'healthy' => $healthy, - 'responseTime' => $elapsed, - ]; - }//end healthCheck() - - /** - * Discover available workflow engine ExApps. - * - * @return array - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-registration-and-discovery - */ - public function discoverEngines(): array { - $available = []; - - // Requires app_api to be installed for ExApp discovery. - if ($this->appManager->isEnabledForUser('app_api') === false) { - return []; - } - - $knownEngines = [ - 'n8n' => ['appId' => 'n8n', 'defaultPort' => 5678], - 'windmill' => ['appId' => 'windmill', 'defaultPort' => 8000], - ]; - - foreach ($knownEngines as $type => $config) { - $isInstalled = $this->appManager->isEnabledForUser($config['appId']); - - if ($isInstalled === true) { - $available[] = [ - 'engineType' => $type, - 'suggestedBaseUrl' => 'http://localhost:' . $config['defaultPort'], - 'installed' => true, - ]; - } - } - - return $available; - }//end discoverEngines() - - /** - * Decrypt the auth config from an engine entity. - * - * @param WorkflowEngine $engine Engine entity - * - * @return array Decrypted auth config - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-configuration-entity - */ - private function decryptAuthConfig(WorkflowEngine $engine): array { - $encrypted = $engine->getAuthConfig(); - - if ($encrypted === null || $encrypted === '') { - return ['authType' => $engine->getAuthType() ?? 'none']; - } - - try { - $decrypted = $this->crypto->decrypt($encrypted); - - $config = json_decode($decrypted, true) ?? []; - $config['authType'] = $engine->getAuthType() ?? 'none'; - - return $config; - } catch (\Exception $e) { - $this->logger->warning( - message: '[WorkflowEngineRegistry] Failed to decrypt auth config', - context: ['engineId' => $engine->getId(), 'error' => $e->getMessage()] - ); - - return ['authType' => $engine->getAuthType() ?? 'none']; - } - }//end decryptAuthConfig() -}//end class diff --git a/lib/Settings/credential-providers.json b/lib/Settings/credential-providers.json index cc9a9b5f65..fed623faef 100644 --- a/lib/Settings/credential-providers.json +++ b/lib/Settings/credential-providers.json @@ -2,7 +2,9 @@ "$comment": "Credential-broker provider catalogue — a RUNTIME-IMMUTABLE, read-only file shipped with OpenRegister. It defines the external providers the broker may call, host-locked, and the constrained-proxy allow-rules (permitted method + path-prefix patterns) that bound what any credential can ever do. These allow-rules are a security control: they MUST NOT be widenable at runtime via any API — new providers/rules ship in a reviewed release only (design.md D2, ADR-031 exception). The broker reads this file server-side; it is NOT an OpenRegister schema, NOT register-seeded objects, and has no create/update/delete endpoint. '{secret}' in an authScheme.template is substituted by the broker with the vault-held token at call time; the token itself never appears here. Doffin caveat: the baseUrl is Doffin's Azure-APIM-hosted Public API on its BETA host, and the GET /notices rule is carried from the spectr Norway connector contract (not live-verifiable without a real subscription key, 2026-07-06); if the path or host moves, the broker fails closed (403) and the fix is another reviewed release of this file.", "$fleetComment": "2026-07-12 — the catalogue held only github/gitlab/doffin, and CredentialController::create() rejects an unknown provider. So Mollie, Stripe, Adyen, CCV, KVK, Twilio, MessageBird, CM.com and the LLM providers could not be brokered AT ALL — you could not even create a credential for them. Every app therefore kept custody of its own secrets, not out of carelessness but because compliance was impossible. This release adds the providers whose auth the broker can actually express: ONE request header carrying ONE secret (injectAuth() substitutes {secret} into a single header template — it cannot do a token exchange, mTLS, or a signing operation). Rules are scoped to the endpoints the fleet actually calls, taken from the adapters themselves — no root wildcards, no DELETE. STILL NOT BROKERABLE, each needing broker capability work rather than a catalogue entry: (1) BRP/HaalCentraal — OAuth2 client_credentials needs a token EXCHANGE (POST secret -> access_token -> call), which a static header template cannot do; (2) procest StUF — an mTLS client certificate rides the TLS handshake, not a header; (3) scholiq's tenant RSA key — needs a SIGN operation, it is never sent as a request header; (4) softwarecatalog's SMTP password and its SendGrid/Mailgun/Postmark/Mailjet keys — Symfony Mailer takes them in a transport DSN, and SMTP is not HTTP at all, so a constrained HTTP proxy cannot carry them (these move to Doriath custody with the app holding only a credentialRef). Self-hosted targets (xWiki, and OpenConnector's arbitrary Sources) cannot be host-locked from an immutable file at all — they need a per-install, admin-approved provider registration, which is a broker design change.", "$injectOnlyComment": "2026-07-14 (v1.4.0) — the last paragraph above named the standing gap: OpenConnector's arbitrary/self-hosted Sources cannot be host-locked from an immutable file, so their secrets never had a compliant home and stayed embedded in the source schema (ocon#147). The `inject_only` providers below close that gap WITHOUT weakening the host-locked proxy. They carry NO baseUrl and NO allowRules, so the broker's request() proxy path REFUSES them (CredentialBrokerService::request() denies an inject_only provider — an unbounded host is exactly what must never be proxied). They are reachable ONLY through CredentialBrokerService::resolveInjectable(), which runs guards 1+2 (owner + allowedApps) and hands the raw secret to the SAME-INSTANCE calling app, which injects it itself against whatever host its own config names. The trade-off is deliberate and narrow: the secret leaves OR into the trusted in-process app (NOT zero-knowledge, unlike the SaaS proxy above), but it lives in Doriath and the app's schema holds only a credentialRef — which is the whole point (never store a secret in a schema; store a reference). The five entries mirror OpenConnector's Source `auth` schemes; the app decides how to inject per its own Source.auth, so the authScheme here is descriptive only. 2026-07-16 (v1.6.0) — `inject_only` is NO LONGER only the five generic-* entries: `anthropic-cli` (grouped with the other anthropic providers above, not here) is inject_only for a different reason. It is not an unbounded host — it is a CLI. A vendor CLI needs its token in the process ENVIRONMENT, so there is no outbound request to proxy and no header to substitute, and the constrained-proxy path cannot express it at any host. Read `inject_only` as 'the broker cannot bound this call, so it refuses to make it and the guarded resolveInjectable() path applies instead' — which covers BOTH an unbounded host and a non-HTTP consumer. When adding a provider, prefer a host-locked proxy entry; reach for inject_only only when the broker genuinely cannot make the call itself.", - "version": "1.8.0", + "$oauth2Comment": "2026-09-04 (v1.9.0) — the $fleetComment above says the broker \"cannot do a token exchange\", and named that as the reason BRP/HaalCentraal and every OAuth2 provider were unbrokerable. FOR THE `oauth2-token-set` KIND, THAT IS NO LONGER TRUE. An entry declaring `kind: \"oauth2-token-set\"` carries an `oauth2` block naming its authorization, token and revoke endpoints, its refresh grant, its client authentication method and whether it takes PKCE; the broker stores a whole token set as ONE opaque secret in the custody leaf, refreshes it on read past a margin and on a daily sweep, rotates it atomically under a per-credential lock, and injects only the access token. The rest of the sentence still stands verbatim: mTLS, a signing operation, and an SMTP transport DSN remain unexpressible, and client_credentials is NOT covered here either — this kind is the AUTHORIZATION CODE grant with a refresh token, minted by a person consenting, which is a different shape from a machine-to-machine client_credentials exchange. An entry with NO `kind` is `secret` and behaves exactly as it always has; nothing existing moved. Two entries (mastodon, bluesky) declare `baseUrlFrom: \"instanceBaseUrl\"` instead of a fixed `baseUrl`, because their API host belongs to the connected ACCOUNT rather than to the provider. That MOVES the host-lock rather than removing it: the host is validated at mint (absolute https, registrable domain, never a loopback or private-range address), pinned onto the credential, and immutable afterwards, so each credential is locked to one host for its whole life while the allow-rules below still bound every call. Adding an OAuth2 provider is still a reviewed release of this file, and an endpoint supplied in a request body is ignored.", + "$identityComment": "2026-09-04 (v1.10.0) — an `identity` block names the ONE call the connect flow makes on its own behalf, immediately after a token set is minted, to fill the account handle and display name on the credential. Without it the connections panel can only ever say \"not connected yet\", which is exactly the shape of connection nobody can audit: an object that holds a live token and does not say whose. Every path here is ALREADY an allowRule on the same entry, so the call goes through the ordinary constrained proxy and is bounded by the same rules as any other call on that credential; the block adds no reach, it only says which of the already-permitted calls answers \"who is this\". The three field names are read out of the response by dotted path and are the only values taken from it. A provider with NO identity block simply shows the name the person gave the connection: meta-graph answers with a list of Pages rather than one identity, Google Search Console and Analytics have no such call at all, and bluesky's getProfile needs a DPoP-bound token this release cannot yet mint. An identity fetch that fails is logged and dropped — a connection that works is never undone because a cosmetic label could not be read.", + "version": "1.10.0", "providers": { "mollie": { "identifier": "mollie", @@ -280,6 +282,225 @@ { "method": "GET", "pathPattern": "/notices" } ] }, + "linkedin": { + "identifier": "linkedin", + "title": "LinkedIn (member and organisation posting)", + "$comment": "kind oauth2-token-set: the stored secret is a whole token set, not a bare string, and the broker refreshes it. Host-locked to the REST API. Scopes are requested at connect time; the Community Management API grants (w_organization_social, r_organization_social) require LinkedIn's Community Management application to be approved for the client, which is a filing, not a code change. LinkedIn does NOT support PKCE on the authorization code grant, so `pkce` is `none` — the connect flow still generates and holds a verifier, it is simply not sent.", + "kind": "oauth2-token-set", + "baseUrl": "https://api.linkedin.com", + "oauth2": { + "authorizationEndpoint": "https://www.linkedin.com/oauth/v2/authorization", + "tokenEndpoint": "https://www.linkedin.com/oauth/v2/accessToken", + "revokeEndpoint": "https://www.linkedin.com/oauth/v2/revoke", + "refreshGrant": "refresh_token", + "clientAuth": "client_secret_post", + "scopeSeparator": " ", + "pkce": "none", + "defaultScopes": ["openid", "profile", "w_member_social"] + }, + "identity": { + "$why": "GET /v2/userinfo is already an allow-rule below, and OpenID Connect guarantees `sub` and `name` on it. LinkedIn publishes no handle, so the display name is the only thing a person will recognise and it fills both fields.", + "method": "GET", + "path": "/v2/userinfo", + "idField": "sub", + "handleField": "name", + "displayNameField": "name" + }, + "authScheme": { + "header": "Authorization", + "template": "Bearer {secret}" + }, + "allowRules": [ + { "$why": "Publishing a post as the connected member or organisation. This is the one write the marketing programme needs from LinkedIn.", "method": "POST", "pathPattern": "/rest/posts" }, + { "$why": "Reading back a post the credential itself published, to record its permanent id and URL.", "method": "GET", "pathPattern": "/rest/posts/*" }, + { "$why": "Reading likes and comments on the connected account's own posts.", "method": "GET", "pathPattern": "/rest/socialActions/*" }, + { "$why": "Impression and engagement statistics for the connected organisation's own shares.", "method": "GET", "pathPattern": "/rest/organizationalEntityShareStatistics" }, + { "$why": "Follower counts for the connected organisation.", "method": "GET", "pathPattern": "/rest/organizationalEntityFollowerStatistics" }, + { "$why": "The connected member's own identity, used to fill the account handle and display name on the credential.", "method": "GET", "pathPattern": "/v2/userinfo" } + ] + }, + "meta-graph": { + "identifier": "meta-graph", + "title": "Meta Graph (Facebook Pages and Instagram Business)", + "$comment": "kind oauth2-token-set. Meta has no refresh_token: a short-lived token is traded for a long-lived one with grant_type=fb_exchange_token against the same token endpoint, which is why `refreshGrant` names it. There is no revoke endpoint on the token host — revoking is DELETE /{user-id}/permissions, which is a Graph call rather than an OAuth2 one, so `revokeEndpoint` is null and disconnecting deletes locally. Personal Facebook and Instagram profiles cannot be posted to through the API at all; only Pages and Instagram Business accounts.", + "kind": "oauth2-token-set", + "baseUrl": "https://graph.facebook.com", + "oauth2": { + "authorizationEndpoint": "https://www.facebook.com/v21.0/dialog/oauth", + "tokenEndpoint": "https://graph.facebook.com/v21.0/oauth/access_token", + "revokeEndpoint": null, + "refreshGrant": "fb_exchange_token", + "clientAuth": "client_secret_post", + "scopeSeparator": ",", + "pkce": "none", + "defaultScopes": ["pages_manage_posts", "pages_read_engagement", "pages_show_list"] + }, + "authScheme": { + "header": "Authorization", + "template": "Bearer {secret}" + }, + "allowRules": [ + { "$why": "Publishing to a connected Page's feed.", "method": "POST", "pathPattern": "/v21.0/*/feed" }, + { "$why": "Uploading an image that a Page post references.", "method": "POST", "pathPattern": "/v21.0/*/photos" }, + { "$why": "Creating an Instagram Business media container, the first half of an Instagram publish.", "method": "POST", "pathPattern": "/v21.0/*/media" }, + { "$why": "Publishing a prepared Instagram media container, the second half.", "method": "POST", "pathPattern": "/v21.0/*/media_publish" }, + { "$why": "Reading views, likes, comments and shares on the connected account's own posts. Reach and impressions were withdrawn by Meta in June 2026; the adapter normalises what remains.", "method": "GET", "pathPattern": "/v21.0/*/insights" }, + { "$why": "Listing the Pages the connected user administers, so the connect flow can name the account the credential belongs to.", "method": "GET", "pathPattern": "/v21.0/me/accounts" } + ] + }, + "x": { + "identifier": "x", + "title": "X (formerly Twitter)", + "$comment": "kind oauth2-token-set. X requires PKCE with S256 and issues a refresh token only when offline.access is granted, so that scope is in the defaults; without it a connection lapses at the first expiry and needs a relink rather than a refresh. X is pay-per-use, so the consuming app is expected to hold a spend budget with a hard stop; the broker does not meter.", + "kind": "oauth2-token-set", + "baseUrl": "https://api.x.com", + "oauth2": { + "authorizationEndpoint": "https://x.com/i/oauth2/authorize", + "tokenEndpoint": "https://api.x.com/2/oauth2/token", + "revokeEndpoint": "https://api.x.com/2/oauth2/revoke", + "refreshGrant": "refresh_token", + "clientAuth": "client_secret_basic", + "scopeSeparator": " ", + "pkce": "S256", + "defaultScopes": ["tweet.read", "tweet.write", "users.read", "offline.access"] + }, + "identity": { + "$why": "GET /2/users/me is already an allow-rule below and answers with the connected account nested under `data`.", + "method": "GET", + "path": "/2/users/me", + "idField": "data.id", + "handleField": "data.username", + "displayNameField": "data.name" + }, + "authScheme": { + "header": "Authorization", + "template": "Bearer {secret}" + }, + "allowRules": [ + { "$why": "Publishing a post.", "method": "POST", "pathPattern": "/2/tweets" }, + { "$why": "Reading back the connected account's own post, including its public metrics.", "method": "GET", "pathPattern": "/2/tweets/*" }, + { "$why": "The connected account's own identity, used to fill the handle and display name on the credential.", "method": "GET", "pathPattern": "/2/users/me" }, + { "$why": "The connected account's own timeline, used for the metrics pull.", "method": "GET", "pathPattern": "/2/users/*/tweets" } + ] + }, + "mastodon": { + "identifier": "mastodon", + "title": "Mastodon (per-instance)", + "$comment": "kind oauth2-token-set with baseUrlFrom: Mastodon has no single API host, so the host comes from the credential's own instanceBaseUrl rather than from this file. That is a MOVE of the host-lock, not a removal: the host is validated once at mint (absolute https, registrable domain, never a loopback or private-range address), pinned onto the credential, and immutable afterwards, so a credential is locked to one server for its whole life. The allow-rules below still bound what any Mastodon credential can ever do, exactly as for a fixed-host entry. The oauth2 endpoints are paths, resolved against the same pinned host. Mastodon has no central application registry: the connect flow registers an application at the account's own server (POST /api/v1/apps), stores the client id as non-secret metadata and the client secret as a separate brokered credential. No relay is needed, because the tenant's own callback is registered at registration time.", + "kind": "oauth2-token-set", + "baseUrlFrom": "instanceBaseUrl", + "oauth2": { + "authorizationEndpoint": "/oauth/authorize", + "tokenEndpoint": "/oauth/token", + "revokeEndpoint": "/oauth/revoke", + "registrationEndpoint": "/api/v1/apps", + "endpointsRelativeToInstance": true, + "refreshGrant": "refresh_token", + "clientAuth": "client_secret_post", + "scopeSeparator": " ", + "pkce": "S256", + "defaultScopes": ["read:accounts", "read:statuses", "write:statuses", "write:media"] + }, + "identity": { + "$why": "GET /api/v1/accounts/verify_credentials is already an allow-rule below. `acct` is the handle as the account's own server spells it, which is the only spelling that stays unambiguous once a second server is connected.", + "method": "GET", + "path": "/api/v1/accounts/verify_credentials", + "idField": "id", + "handleField": "acct", + "displayNameField": "display_name" + }, + "authScheme": { + "header": "Authorization", + "template": "Bearer {secret}" + }, + "allowRules": [ + { "$why": "Publishing a status.", "method": "POST", "pathPattern": "/api/v1/statuses" }, + { "$why": "Reading back the connected account's own status, including its reblog and favourite counts.", "method": "GET", "pathPattern": "/api/v1/statuses/*" }, + { "$why": "Uploading media a status references.", "method": "POST", "pathPattern": "/api/v2/media" }, + { "$why": "The connected account's own identity, used to fill the handle and display name on the credential.", "method": "GET", "pathPattern": "/api/v1/accounts/verify_credentials" }, + { "$why": "Follower counts for the connected account.", "method": "GET", "pathPattern": "/api/v1/accounts/*/followers" } + ] + }, + "bluesky": { + "identifier": "bluesky", + "title": "Bluesky (AT Protocol) — preview", + "$comment": "kind oauth2-token-set with baseUrlFrom, for the same reason as mastodon: the API host is the account's own PDS. AT Protocol OAuth needs no relay at all, because the client publishes its own metadata JSON and the instance serves that itself, so the tenant's Nextcloud is its own OAuth client. ⚠️ PREVIEW: AT Protocol requires DPoP-bound access tokens, and the DPoP proof layer is NOT implemented by the change that added this entry. A Bluesky connection can therefore be minted and refreshed but its access token will be refused by a PDS until DPoP lands. The entry ships anyway so the gap is visible in the catalogue rather than absent from it; the follow-up is credential-oauth2-bluesky-dpop. The authorization server is discovered per host rather than declared here.", + "kind": "oauth2-token-set", + "baseUrlFrom": "instanceBaseUrl", + "preview": true, + "oauth2": { + "authorizationServerDiscovery": "/.well-known/oauth-authorization-server", + "endpointsRelativeToInstance": true, + "clientMetadataServedByInstance": true, + "refreshGrant": "refresh_token", + "clientAuth": "none", + "scopeSeparator": " ", + "pkce": "S256", + "defaultScopes": ["atproto", "transition:generic"], + "requiresDpop": true + }, + "authScheme": { + "header": "Authorization", + "template": "Bearer {secret}" + }, + "allowRules": [ + { "$why": "Publishing a post as a repository record.", "method": "POST", "pathPattern": "/xrpc/com.atproto.repo.createRecord" }, + { "$why": "Reading back the connected account's own feed, which is where a post's like and repost counts are read.", "method": "GET", "pathPattern": "/xrpc/app.bsky.feed.getAuthorFeed" }, + { "$why": "The connected account's own profile, used to fill the handle and display name on the credential.", "method": "GET", "pathPattern": "/xrpc/app.bsky.actor.getProfile" }, + { "$why": "Follower counts for the connected account.", "method": "GET", "pathPattern": "/xrpc/app.bsky.graph.getFollowers" } + ] + }, + "google-search-console": { + "identifier": "google-search-console", + "title": "Google Search Console (read-only)", + "$comment": "kind oauth2-token-set, host-locked to the Search Console API. A SEPARATE entry from google-analytics rather than one 'google' entry with two hosts, for exactly the reason the adyen live and test entries are separate: the baseUrl IS the host-lock, so two hosts cannot share one entry. Read-only scope only. ⚠️ A Google OAuth client left in testing status has its refresh tokens revoked after seven days; for a public-sector tenant the first-class path is a service account added as a property user, which skips the consent screen and this entry entirely.", + "kind": "oauth2-token-set", + "baseUrl": "https://searchconsole.googleapis.com", + "oauth2": { + "authorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "tokenEndpoint": "https://oauth2.googleapis.com/token", + "revokeEndpoint": "https://oauth2.googleapis.com/revoke", + "refreshGrant": "refresh_token", + "clientAuth": "client_secret_post", + "scopeSeparator": " ", + "pkce": "S256", + "authorizationParameters": { "access_type": "offline", "prompt": "consent" }, + "defaultScopes": ["https://www.googleapis.com/auth/webmasters.readonly"] + }, + "authScheme": { + "header": "Authorization", + "template": "Bearer {secret}" + }, + "allowRules": [ + { "$why": "The one query the keyword-intelligence work needs: clicks, impressions, CTR and position per query and page.", "method": "POST", "pathPattern": "/webmasters/v3/sites/*/searchAnalytics/query" }, + { "$why": "Listing the properties the connected account may read, so a tenant can pick one at connect time.", "method": "GET", "pathPattern": "/webmasters/v3/sites" } + ] + }, + "google-analytics": { + "identifier": "google-analytics", + "title": "Google Analytics Data API (read-only)", + "$comment": "kind oauth2-token-set, host-locked to the Analytics Data API. Separate from google-search-console because the host differs; the OAuth endpoints are Google's own and are shared. Read-only scope only. Matomo is the first-party alternative and is preferred for public-sector tenants, who commonly forbid third-party trackers outright.", + "kind": "oauth2-token-set", + "baseUrl": "https://analyticsdata.googleapis.com", + "oauth2": { + "authorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "tokenEndpoint": "https://oauth2.googleapis.com/token", + "revokeEndpoint": "https://oauth2.googleapis.com/revoke", + "refreshGrant": "refresh_token", + "clientAuth": "client_secret_post", + "scopeSeparator": " ", + "pkce": "S256", + "authorizationParameters": { "access_type": "offline", "prompt": "consent" }, + "defaultScopes": ["https://www.googleapis.com/auth/analytics.readonly"] + }, + "authScheme": { + "header": "Authorization", + "template": "Bearer {secret}" + }, + "allowRules": [ + { "$why": "Running a report against a property the connected account may read. Read-only by construction; the Data API has no write surface.", "method": "POST", "pathPattern": "/v1beta/properties/*:runReport" } + ] + }, "generic-apikey": { "identifier": "generic-apikey", "title": "Generic API key (app-injected)", diff --git a/lib/Settings/credential_broker_register.json b/lib/Settings/credential_broker_register.json index 829b722741..b16434c83b 100644 --- a/lib/Settings/credential_broker_register.json +++ b/lib/Settings/credential_broker_register.json @@ -3,7 +3,7 @@ "info": { "title": "Credential Broker", "description": "Owner- and organisation-scoped credential metadata for the OpenRegister credential broker. Each object carries only a handle to a stored secret (name, provider, owner, scope, organisation, allowedApps) — never the secret value itself, which lives in the Nextcloud credential vault (ICredentialsManager) keyed by the object UUID (under the owning user for personal credentials, under the reserved system identity for organisation credentials). Materialised into OpenRegister by the credential-broker-service Repair step (OR does not self-import its own register JSON at boot — ADR-037).", - "version": "1.3.0" + "version": "1.4.0" }, "x-openregister": { "type": "core", @@ -17,7 +17,7 @@ "credential-broker": { "slug": "credential-broker", "title": "Credential Broker", - "version": "1.3.0", + "version": "1.4.0", "description": "Owner- and organisation-scoped credential metadata. One object per stored credential; the secret is NOT here — it lives in the Nextcloud vault keyed by the object UUID (owning user for personal, reserved system identity for organisation).", "published": "2026-01-01T00:00:00+00:00", "schemas": [ @@ -30,7 +30,7 @@ "brokeredcredential": { "slug": "brokeredcredential", "title": "Credential", - "version": "1.3.0", + "version": "1.4.0", "published": "2026-01-01T00:00:00+00:00", "summary": "Owner- and organisation-scoped metadata for a brokered credential — a handle, never the secret.", "description": "Metadata for a secret the credential broker holds on a user's or organisation's behalf. Carries a name, the catalogue provider it targets, the owning user, the scope (personal|organisation), the owning organisation UUID for organisation credentials, and the app ids permitted to use it. MUST NOT contain the secret value: the token is stored in the Nextcloud credential vault (ICredentialsManager), encrypted at rest, keyed by this object's UUID — under the owning user for personal credentials and under a reserved system identity for organisation credentials. No property here is secret-bearing, so the metadata is safe to list, export, audit, and query.", @@ -143,6 +143,103 @@ }, "title": "Shared Groups" }, + "kind": { + "type": "string", + "enum": [ + "secret", + "oauth2-token-set" + ], + "default": "secret", + "description": "The shape of the stored secret, MIRRORED from the provider catalogue for display and filtering. `secret` (the default when absent): one opaque string substituted into the provider's single auth header. `oauth2-token-set`: a whole OAuth2 token set the broker refreshes and rotates. The catalogue entry is authoritative: when this value and the entry disagree, the broker follows the entry and treats this as a stale mirror. It is never an input to which injection path runs, because this property is owner-writable and the catalogue is not.", + "facetable": true, + "title": "Kind" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "active", + "expired", + "relink_needed", + "disabled" + ], + "default": "active", + "description": "Connection lifecycle for an `oauth2-token-set` credential. `pending`: a connection was started but no token set has been minted yet. `active`: usable. `expired`: past its expiry with no usable refresh token. `relink_needed`: the provider rejected the refresh with `invalid_grant`, so the grant is gone and a person must re-authorise; the object and its id are kept so every reference to it survives the repair. `disabled`: switched off locally, after a disconnect. A `relink_needed` or `disabled` credential fails every brokered call closed before any outbound request is made.", + "facetable": true, + "title": "Status" + }, + "scopes": { + "type": "array", + "description": "The scopes the provider actually granted, as returned by the token response. Non-secret: it says what this connection may do, never how to do it.", + "items": { + "type": "string", + "maxLength": 255 + }, + "title": "Granted Scopes" + }, + "account": { + "type": "object", + "description": "The provider account this connection belongs to. Non-secret identity only, used to label the connection in the UI and to stop a re-authorisation landing on a different account by mistake.", + "properties": { + "id": { + "type": "string", + "maxLength": 255, + "description": "The provider's own identifier for the account.", + "title": "Account id" + }, + "handle": { + "type": "string", + "maxLength": 255, + "description": "The account handle as the provider spells it (for example `@example@mastodon.example`).", + "title": "Handle" + }, + "displayName": { + "type": "string", + "maxLength": 255, + "description": "The account's display name at the time of connecting.", + "title": "Display name" + } + }, + "title": "Account" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "When the stored access token expires. Mirrored from the token set so a listing can show a connection's health without touching the custody leaf.", + "title": "Expires At" + }, + "lastRefreshedAt": { + "type": "string", + "format": "date-time", + "description": "When the token set was last rotated. Written only after the custody write succeeded, so it never claims a token the leaf does not hold.", + "title": "Last Refreshed At" + }, + "lastError": { + "type": "string", + "maxLength": 1024, + "description": "The last refresh or revoke failure, secret-free by construction: an error code and a redacted reason, never a token, a client secret, or a provider response body.", + "title": "Last Error" + }, + "instanceBaseUrl": { + "type": "string", + "format": "uri", + "maxLength": 512, + "description": "The API host for a provider whose catalogue entry declares `baseUrlFrom: \"instanceBaseUrl\"` (Mastodon, Bluesky), where the host belongs to the connected account rather than to the provider. Validated once at mint (absolute https, no userinfo, no query, no fragment, a registrable domain rather than an IP literal, and never a loopback or private-range address) and IMMUTABLE afterwards: an update that changes it is rejected. This is the host-lock for such a credential, so a change here would be a change of security posture, not of configuration.", + "facetable": true, + "title": "Instance Base URL" + }, + "clientId": { + "type": "string", + "maxLength": 255, + "description": "The OAuth2 client identifier this connection was authorised with. Non-secret by definition, and recorded so a re-authorisation uses the same client. The matching client SECRET is never here; it is a separate brokered credential named by `clientCredentialRef`.", + "title": "Client Id" + }, + "clientCredentialRef": { + "type": "string", + "maxLength": 255, + "description": "UUID of the brokered `generic-oauth2` credential holding the client secret used for this connection's token exchanges. Present when a tenant brought its own application, or when one was registered at the account's own server. Absent when the instance's administrator-set default client is used.", + "title": "Client Credential Ref" + }, "createdAt": { "type": "string", "format": "date-time", @@ -167,6 +264,35 @@ ], "createdAt": "2026-01-01T00:00:00+00:00" }, + { + "$comment": "A worked example of the oauth2-token-set kind. It is deliberately `pending`: a seeded object cannot carry a token set, because the token set lives in the custody leaf and this file has no place to put one and should not. A real connection reaches `active` only through the connect flow.", + "@self": { + "register": "credential-broker", + "schema": "brokeredcredential", + "slug": "reisbureau-example-mastodon-company-account" + }, + "name": "Reisbureau Example \u2014 Mastodon company account", + "provider": "mastodon", + "kind": "oauth2-token-set", + "status": "pending", + "scope": "organisation", + "organisation": "00000000-0000-0000-0000-000000000000", + "owner": "00000000-0000-0000-0000-000000000000", + "instanceBaseUrl": "https://mastodon.example", + "scopes": [ + "read:accounts", + "write:statuses" + ], + "account": { + "id": "", + "handle": "", + "displayName": "" + }, + "allowedApps": [ + "pipelinq" + ], + "createdAt": "2026-01-01T00:00:00+00:00" + }, { "@self": { "register": "credential-broker", 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/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/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/lib/WorkflowEngine/N8nAdapter.php b/lib/WorkflowEngine/N8nAdapter.php deleted file mode 100644 index 1c2cbb17cf..0000000000 --- a/lib/WorkflowEngine/N8nAdapter.php +++ /dev/null @@ -1,410 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-health-monitoring - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\WorkflowEngine; - -use OCP\Http\Client\IClientService; -use Psr\Log\LoggerInterface; - -/** - * Adapter for n8n workflow engine. - * - * Translates WorkflowEngineInterface calls to n8n's REST API. - * Supports routing through the ExApp proxy when n8n runs as a Nextcloud ExApp. - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - * @SuppressWarnings(PHPMD.StaticAccess) - */ -class N8nAdapter implements WorkflowEngineInterface { - - /** - * Base URL for the n8n API. - * - * @var string - */ - private string $baseUrl; - - /** - * Authentication configuration for engine connection. - * - * @var array - */ - private array $authConfig; - - /** - * Constructor for N8nAdapter. - * - * @param IClientService $clientService HTTP client - * @param LoggerInterface $logger Logger - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function __construct( - private readonly IClientService $clientService, - private readonly LoggerInterface $logger, - ) { - $this->baseUrl = ''; - $this->authConfig = []; - }//end __construct() - - /** - * Configure the adapter with engine settings. - * - * When n8n runs as a Nextcloud ExApp, set baseUrl to the ExApp proxy path - * (e.g. `/index.php/apps/app_api/proxy/n8n`) to route through Nextcloud's proxy. - * - * @param string $baseUrl Engine base URL (or ExApp proxy path) - * @param array $authConfig Authentication configuration - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function configure(string $baseUrl, array $authConfig = []): void { - $this->baseUrl = rtrim($baseUrl, '/'); - $this->authConfig = $authConfig; - }//end configure() - - /** - * Deploy a workflow to n8n. - * - * @param array $workflowDefinition Workflow definition - * - * @return string The workflow ID - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function deployWorkflow(array $workflowDefinition): string { - $client = $this->clientService->newClient(); - $response = $client->post( - $this->baseUrl . '/rest/workflows', - $this->buildRequestOptions(extra: ['json' => $workflowDefinition]) - ); - - $data = json_decode($response->getBody(), true); - - return (string)($data['id'] ?? ''); - }//end deployWorkflow() - - /** - * Update an existing workflow in n8n. - * - * @param string $workflowId The workflow ID to update - * @param array $workflowDefinition Updated workflow definition - * - * @return string The workflow ID - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function updateWorkflow(string $workflowId, array $workflowDefinition): string { - $client = $this->clientService->newClient(); - $response = $client->patch( - $this->baseUrl . '/rest/workflows/' . $workflowId, - $this->buildRequestOptions(extra: ['json' => $workflowDefinition]) - ); - - $data = json_decode($response->getBody(), true); - - return (string)($data['id'] ?? $workflowId); - }//end updateWorkflow() - - /** - * Get a workflow definition from n8n. - * - * @param string $workflowId The workflow ID to retrieve - * - * @return array The workflow definition - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function getWorkflow(string $workflowId): array { - $client = $this->clientService->newClient(); - $response = $client->get( - $this->baseUrl . '/rest/workflows/' . $workflowId, - $this->buildRequestOptions() - ); - - return json_decode($response->getBody(), true) ?? []; - }//end getWorkflow() - - /** - * Delete a workflow from n8n. - * - * @param string $workflowId The workflow ID to delete - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function deleteWorkflow(string $workflowId): void { - $client = $this->clientService->newClient(); - $client->delete( - $this->baseUrl . '/rest/workflows/' . $workflowId, - $this->buildRequestOptions() - ); - }//end deleteWorkflow() - - /** - * Activate a workflow in n8n. - * - * @param string $workflowId The workflow ID to activate - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function activateWorkflow(string $workflowId): void { - $client = $this->clientService->newClient(); - $client->patch( - $this->baseUrl . '/rest/workflows/' . $workflowId, - $this->buildRequestOptions(extra: ['json' => ['active' => true]]) - ); - }//end activateWorkflow() - - /** - * Deactivate a workflow in n8n. - * - * @param string $workflowId The workflow ID to deactivate - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function deactivateWorkflow(string $workflowId): void { - $client = $this->clientService->newClient(); - $client->patch( - $this->baseUrl . '/rest/workflows/' . $workflowId, - $this->buildRequestOptions(extra: ['json' => ['active' => false]]) - ); - }//end deactivateWorkflow() - - /** - * Execute a workflow in n8n via webhook. - * - * @param string $workflowId The workflow ID to execute - * @param array $data Input data for the workflow - * @param int $timeout Timeout in seconds - * - * @return WorkflowResult The execution result - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function executeWorkflow(string $workflowId, array $data, int $timeout = 30): WorkflowResult { - try { - $webhookUrl = $this->getWebhookUrl(workflowId: $workflowId); - $client = $this->clientService->newClient(); - - $response = $client->post( - $webhookUrl, - $this->buildRequestOptions( - extra: [ - 'json' => $data, - 'timeout' => $timeout, - ] - ) - ); - - $responseData = json_decode($response->getBody(), true); - - return $this->parseWorkflowResponse(responseData: $responseData); - } catch (\Exception $e) { - $this->logger->error( - message: '[N8nAdapter] Workflow execution failed', - context: [ - 'workflowId' => $workflowId, - 'error' => $e->getMessage(), - ] - ); - - if (str_contains($e->getMessage(), 'timed out') === true - || str_contains($e->getMessage(), 'timeout') === true - ) { - return WorkflowResult::error( - message: 'Workflow execution timed out after ' . $timeout . ' seconds', - metadata: ['engine' => 'n8n', 'workflowId' => $workflowId] - ); - } - - return WorkflowResult::error( - message: $e->getMessage(), - metadata: ['engine' => 'n8n', 'workflowId' => $workflowId] - ); - }//end try - }//end executeWorkflow() - - /** - * Get the webhook URL for a workflow. - * - * @param string $workflowId The workflow ID - * - * @return string The webhook URL - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function getWebhookUrl(string $workflowId): string { - return $this->baseUrl . '/webhook/' . $workflowId; - }//end getWebhookUrl() - - /** - * List all workflows from n8n. - * - * @return array List of workflows - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function listWorkflows(): array { - try { - $client = $this->clientService->newClient(); - $response = $client->get( - $this->baseUrl . '/rest/workflows', - $this->buildRequestOptions() - ); - - $data = json_decode($response->getBody(), true); - $workflows = []; - - foreach (($data['data'] ?? []) as $workflow) { - $workflows[] = [ - 'id' => (string)$workflow['id'], - 'name' => $workflow['name'] ?? '', - 'active' => $workflow['active'] ?? false, - ]; - } - - return $workflows; - } catch (\Exception $e) { - $this->logger->warning( - message: '[N8nAdapter] Failed to list workflows', - context: ['error' => $e->getMessage()] - ); - - return []; - }//end try - }//end listWorkflows() - - /** - * Check the health of the n8n engine. - * - * @return bool True if healthy - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-health-monitoring - */ - public function healthCheck(): bool { - try { - $client = $this->clientService->newClient(); - $response = $client->get( - $this->baseUrl . '/rest/settings', - $this->buildRequestOptions(extra: ['timeout' => 5]) - ); - - return $response->getStatusCode() === 200; - } catch (\Exception $e) { - $this->logger->debug( - message: '[N8nAdapter] Health check failed', - context: ['error' => $e->getMessage()] - ); - - return false; - } - }//end healthCheck() - - /** - * Build HTTP request options with authentication headers. - * - * @param array $extra Additional options - * - * @return array - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - private function buildRequestOptions(array $extra = []): array { - $options = array_merge( - [ - 'headers' => $this->buildAuthHeaders(), - ], - $extra - ); - - return $options; - }//end buildRequestOptions() - - /** - * Build authentication headers based on auth config. - * - * @return array - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - private function buildAuthHeaders(): array { - $headers = ['Accept' => 'application/json']; - - $authType = $this->authConfig['authType'] ?? 'none'; - - if ($authType === 'bearer' && isset($this->authConfig['token']) === true) { - $headers['Authorization'] = 'Bearer ' . $this->authConfig['token']; - } elseif ($authType === 'basic' && isset($this->authConfig['username'], $this->authConfig['password']) === true) { - $headers['Authorization'] = 'Basic ' . base64_encode( - $this->authConfig['username'] . ':' . $this->authConfig['password'] - ); - } - - return $headers; - }//end buildAuthHeaders() - - /** - * Parse n8n workflow response into a WorkflowResult. - * - * @param array|null $responseData Response from n8n - * - * @return WorkflowResult - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - private function parseWorkflowResponse(?array $responseData): WorkflowResult { - if ($responseData === null) { - return WorkflowResult::approved(metadata: ['engine' => 'n8n']); - } - - $status = $responseData['status'] ?? 'approved'; - - return match ($status) { - 'rejected' => WorkflowResult::rejected( - errors: $responseData['errors'] ?? [], - metadata: array_merge(['engine' => 'n8n'], $responseData['metadata'] ?? []) - ), - 'modified' => WorkflowResult::modified( - data: $responseData['data'] ?? [], - metadata: array_merge(['engine' => 'n8n'], $responseData['metadata'] ?? []) - ), - 'error' => WorkflowResult::error( - message: $responseData['errors'][0]['message'] ?? 'Unknown error', - metadata: array_merge(['engine' => 'n8n'], $responseData['metadata'] ?? []) - ), - default => WorkflowResult::approved( - metadata: array_merge(['engine' => 'n8n'], $responseData['metadata'] ?? []) - ), - }; - }//end parseWorkflowResponse() -}//end class diff --git a/lib/WorkflowEngine/WindmillAdapter.php b/lib/WorkflowEngine/WindmillAdapter.php deleted file mode 100644 index ea7c28954c..0000000000 --- a/lib/WorkflowEngine/WindmillAdapter.php +++ /dev/null @@ -1,395 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\WorkflowEngine; - -use OCP\Http\Client\IClientService; -use Psr\Log\LoggerInterface; - -/** - * Adapter for Windmill workflow engine. - * - * Translates WorkflowEngineInterface calls to Windmill's REST API. - * - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - * @SuppressWarnings(PHPMD.StaticAccess) - */ -class WindmillAdapter implements WorkflowEngineInterface { - - /** - * Base URL for the Windmill API. - * - * @var string - */ - private string $baseUrl; - - /** - * Windmill workspace identifier. - * - * @var string - */ - private string $workspace; - - /** - * Authentication configuration for engine connection. - * - * @var array - */ - private array $authConfig; - - /** - * Constructor for WindmillAdapter. - * - * @param IClientService $clientService HTTP client - * @param LoggerInterface $logger Logger - */ - public function __construct( - private readonly IClientService $clientService, - private readonly LoggerInterface $logger, - ) { - $this->baseUrl = ''; - $this->workspace = 'main'; - $this->authConfig = []; - }//end __construct() - - /** - * Configure the adapter with engine settings. - * - * @param string $baseUrl Engine base URL - * @param array $authConfig Authentication configuration - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function configure(string $baseUrl, array $authConfig = []): void { - $this->baseUrl = rtrim($baseUrl, '/'); - $this->workspace = $authConfig['workspace'] ?? 'main'; - $this->authConfig = $authConfig; - }//end configure() - - /** - * Deploy a workflow to Windmill. - * - * @param array $workflowDefinition Workflow definition - * - * @return string The workflow ID - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function deployWorkflow(array $workflowDefinition): string { - $client = $this->clientService->newClient(); - $response = $client->post( - $this->baseUrl . '/api/w/' . $this->workspace . '/flows/create', - $this->buildRequestOptions(extra: ['json' => $workflowDefinition]) - ); - - $data = json_decode($response->getBody(), true); - - return (string)($data['path'] ?? $data['id'] ?? ''); - }//end deployWorkflow() - - /** - * Update an existing workflow in Windmill. - * - * @param string $workflowId The workflow ID to update - * @param array $workflowDefinition Updated workflow definition - * - * @return string The workflow ID - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function updateWorkflow(string $workflowId, array $workflowDefinition): string { - $client = $this->clientService->newClient(); - $response = $client->post( - $this->baseUrl . '/api/w/' . $this->workspace . '/flows/update/' . $workflowId, - $this->buildRequestOptions(extra: ['json' => $workflowDefinition]) - ); - - $data = json_decode($response->getBody(), true); - - return (string)($data['path'] ?? $workflowId); - }//end updateWorkflow() - - /** - * Get a workflow definition from Windmill. - * - * @param string $workflowId The workflow ID to retrieve - * - * @return array The workflow definition - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function getWorkflow(string $workflowId): array { - $client = $this->clientService->newClient(); - $response = $client->get( - $this->baseUrl . '/api/w/' . $this->workspace . '/flows/get/' . $workflowId, - $this->buildRequestOptions() - ); - - return json_decode($response->getBody(), true) ?? []; - }//end getWorkflow() - - /** - * Delete a workflow from Windmill. - * - * @param string $workflowId The workflow ID to delete - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function deleteWorkflow(string $workflowId): void { - $client = $this->clientService->newClient(); - $client->delete( - $this->baseUrl . '/api/w/' . $this->workspace . '/flows/delete/' . $workflowId, - $this->buildRequestOptions() - ); - }//end deleteWorkflow() - - /** - * Activate a workflow in Windmill (no-op). - * - * @param string $workflowId The workflow ID to activate - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function activateWorkflow(string $workflowId): void { - // Windmill flows are always active once created. No-op. - }//end activateWorkflow() - - /** - * Deactivate a workflow in Windmill (no-op). - * - * @param string $workflowId The workflow ID to deactivate - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function deactivateWorkflow(string $workflowId): void { - // Windmill doesn't have an activate/deactivate toggle for flows. - // Archiving could be used but is destructive. No-op. - }//end deactivateWorkflow() - - /** - * Execute a workflow in Windmill. - * - * @param string $workflowId The workflow ID to execute - * @param array $data Input data for the workflow - * @param int $timeout Timeout in seconds - * - * @return WorkflowResult The execution result - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function executeWorkflow(string $workflowId, array $data, int $timeout = 30): WorkflowResult { - try { - $client = $this->clientService->newClient(); - $response = $client->post( - $this->baseUrl . '/api/w/' . $this->workspace . '/jobs/run_wait_result/f/' . $workflowId, - $this->buildRequestOptions( - extra: [ - 'json' => $data, - 'timeout' => $timeout, - ] - ) - ); - - $responseData = json_decode($response->getBody(), true); - - return $this->parseWorkflowResponse(responseData: $responseData); - } catch (\Exception $e) { - $this->logger->error( - message: '[WindmillAdapter] Workflow execution failed', - context: [ - 'workflowId' => $workflowId, - 'error' => $e->getMessage(), - ] - ); - - if (str_contains($e->getMessage(), 'timed out') === true - || str_contains($e->getMessage(), 'timeout') === true - ) { - return WorkflowResult::error( - message: 'Workflow execution timed out after ' . $timeout . ' seconds', - metadata: ['engine' => 'windmill', 'workflowId' => $workflowId] - ); - } - - return WorkflowResult::error( - message: $e->getMessage(), - metadata: ['engine' => 'windmill', 'workflowId' => $workflowId] - ); - }//end try - }//end executeWorkflow() - - /** - * Get the webhook URL for a Windmill workflow. - * - * @param string $workflowId The workflow ID - * - * @return string The webhook URL - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function getWebhookUrl(string $workflowId): string { - return $this->baseUrl . '/api/w/' . $this->workspace . '/jobs/run/f/' . $workflowId; - }//end getWebhookUrl() - - /** - * List all workflows from Windmill. - * - * @return array List of workflows - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function listWorkflows(): array { - try { - $client = $this->clientService->newClient(); - $response = $client->get( - $this->baseUrl . '/api/w/' . $this->workspace . '/flows/list', - $this->buildRequestOptions() - ); - - $data = json_decode($response->getBody(), true); - $workflows = []; - - foreach (($data ?? []) as $flow) { - $workflows[] = [ - 'id' => $flow['path'] ?? '', - 'name' => $flow['summary'] ?? $flow['path'] ?? '', - ]; - } - - return $workflows; - } catch (\Exception $e) { - $this->logger->warning( - message: '[WindmillAdapter] Failed to list workflows', - context: ['error' => $e->getMessage()] - ); - - return []; - }//end try - }//end listWorkflows() - - /** - * Check the health of the Windmill engine. - * - * @return bool True if healthy - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - public function healthCheck(): bool { - try { - $client = $this->clientService->newClient(); - $response = $client->get( - $this->baseUrl . '/api/version', - $this->buildRequestOptions(extra: ['timeout' => 5]) - ); - - return $response->getStatusCode() === 200; - } catch (\Exception $e) { - $this->logger->debug( - message: '[WindmillAdapter] Health check failed', - context: ['error' => $e->getMessage()] - ); - - return false; - } - }//end healthCheck() - - /** - * Build HTTP request options with authentication headers. - * - * @param array $extra Additional options - * - * @return array - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - private function buildRequestOptions(array $extra = []): array { - return array_merge( - [ - 'headers' => $this->buildAuthHeaders(), - ], - $extra - ); - }//end buildRequestOptions() - - /** - * Build authentication headers. - * - * @return array - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - private function buildAuthHeaders(): array { - $headers = ['Accept' => 'application/json']; - - if (isset($this->authConfig['token']) === true) { - $headers['Authorization'] = 'Bearer ' . $this->authConfig['token']; - } - - return $headers; - }//end buildAuthHeaders() - - /** - * Parse Windmill response into a WorkflowResult. - * - * @param array|null $responseData Response from Windmill - * - * @return WorkflowResult - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-windmill-adapter-implementation - */ - private function parseWorkflowResponse(?array $responseData): WorkflowResult { - if ($responseData === null) { - return WorkflowResult::approved(metadata: ['engine' => 'windmill']); - } - - $status = $responseData['status'] ?? 'approved'; - - return match ($status) { - 'rejected' => WorkflowResult::rejected( - errors: $responseData['errors'] ?? [], - metadata: array_merge(['engine' => 'windmill'], $responseData['metadata'] ?? []) - ), - 'modified' => WorkflowResult::modified( - data: $responseData['data'] ?? [], - metadata: array_merge(['engine' => 'windmill'], $responseData['metadata'] ?? []) - ), - 'error' => WorkflowResult::error( - message: $responseData['errors'][0]['message'] ?? 'Unknown error', - metadata: array_merge(['engine' => 'windmill'], $responseData['metadata'] ?? []) - ), - default => WorkflowResult::approved( - metadata: array_merge(['engine' => 'windmill'], $responseData['metadata'] ?? []) - ), - }; - }//end parseWorkflowResponse() -}//end class diff --git a/lib/WorkflowEngine/WorkflowEngineInterface.php b/lib/WorkflowEngine/WorkflowEngineInterface.php deleted file mode 100644 index ec653ef113..0000000000 --- a/lib/WorkflowEngine/WorkflowEngineInterface.php +++ /dev/null @@ -1,146 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://OpenRegister.app - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - * @spec openspec/specs/workflow-engine-abstraction/spec.md - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - -declare(strict_types=1); - -namespace OCA\OpenRegister\WorkflowEngine; - -/** - * Interface for workflow engine adapters. - * - * Each supported workflow engine (n8n, Windmill, etc.) implements this interface - * to provide a unified way to deploy, execute, and manage workflows. - */ -interface WorkflowEngineInterface { - /** - * Deploy a workflow definition to the engine. - * - * @param array $workflowDefinition Engine-specific workflow JSON - * - * @return string Engine-specific workflow ID - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function deployWorkflow(array $workflowDefinition): string; - - /** - * Update an existing workflow definition in the engine. - * - * @param string $workflowId Engine-specific workflow ID - * @param array $workflowDefinition Updated workflow definition - * - * @return string Engine-specific workflow ID (may change on some engines) - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - */ - public function updateWorkflow(string $workflowId, array $workflowDefinition): string; - - /** - * Get the full workflow definition from the engine. - * - * @param string $workflowId Engine-specific workflow ID - * - * @return array Engine-specific workflow definition - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - */ - public function getWorkflow(string $workflowId): array; - - /** - * Remove a workflow from the engine. - * - * @param string $workflowId Engine-specific workflow ID - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - */ - public function deleteWorkflow(string $workflowId): void; - - /** - * Activate a workflow so it can receive triggers. - * - * @param string $workflowId Engine-specific workflow ID - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - */ - public function activateWorkflow(string $workflowId): void; - - /** - * Deactivate a workflow. - * - * @param string $workflowId Engine-specific workflow ID - * - * @return void - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - */ - public function deactivateWorkflow(string $workflowId): void; - - /** - * Execute a workflow synchronously and return the response. - * - * @param string $workflowId Engine-specific workflow ID - * @param array $data Data to pass to the workflow - * @param int $timeout Timeout in seconds (default 30) - * - * @return WorkflowResult Structured execution result - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function executeWorkflow(string $workflowId, array $data, int $timeout = 30): WorkflowResult; - - /** - * Get the webhook URL that triggers a specific workflow. - * - * @param string $workflowId Engine-specific workflow ID - * - * @return string Webhook URL - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - */ - public function getWebhookUrl(string $workflowId): string; - - /** - * List all workflows in the engine. - * - * @return array Array of workflow summaries - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - */ - public function listWorkflows(): array; - - /** - * Check engine health/connectivity. - * - * @return bool True if the engine is reachable and responsive - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md#requirement-engine-interface-definition - */ - public function healthCheck(): bool; -}//end interface diff --git a/lib/WorkflowEngine/WorkflowResult.php b/lib/WorkflowEngine/WorkflowResult.php deleted file mode 100644 index 2cfcd88e43..0000000000 --- a/lib/WorkflowEngine/WorkflowResult.php +++ /dev/null @@ -1,278 +0,0 @@ - - * @copyright 2024 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\WorkflowEngine; - -use InvalidArgumentException; -use JsonSerializable; - -/** - * Value object representing the result of a synchronous workflow execution. - */ -class WorkflowResult implements JsonSerializable { - - public const STATUS_APPROVED = 'approved'; - public const STATUS_REJECTED = 'rejected'; - public const STATUS_MODIFIED = 'modified'; - public const STATUS_ERROR = 'error'; - - private const VALID_STATUSES = [ - self::STATUS_APPROVED, - self::STATUS_REJECTED, - self::STATUS_MODIFIED, - self::STATUS_ERROR, - ]; - - /** - * Outcome status. - * - * @var string - */ - private string $status; - - /** - * Modified object data (when status is 'modified'). - * - * @var array|null - */ - private ?array $data; - - /** - * Validation errors from workflow execution. - * - * @var array - */ - private array $errors; - - /** - * Engine-specific metadata. - * - * @var array - */ - private array $metadata; - - /** - * Constructor for WorkflowResult. - * - * @param string $status One of: approved, rejected, - * modified, error - * @param array|null $data Modified object data - * @param array $errors Validation errors - * @param array $metadata Engine-specific metadata - * - * @throws InvalidArgumentException If status is not valid - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function __construct( - string $status, - ?array $data = null, - array $errors = [], - array $metadata = [], - ) { - if (in_array(needle: $status, haystack: self::VALID_STATUSES, strict: true) === false) { - $validList = implode(separator: ', ', array: self::VALID_STATUSES); - throw new InvalidArgumentException( - message: "Invalid workflow result status '$status'. Must be one of: $validList" - ); - } - - $this->status = $status; - $this->data = $data; - $this->errors = $errors; - $this->metadata = $metadata; - }//end __construct() - - /** - * Create an approved result. - * - * @param array $metadata Optional metadata - * - * @return self - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public static function approved(array $metadata = []): self { - return new self(status: self::STATUS_APPROVED, metadata: $metadata); - }//end approved() - - /** - * Create a rejected result with validation errors. - * - * @param array $errors Validation errors - * @param array $metadata Optional metadata - * - * @return self - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public static function rejected(array $errors, array $metadata = []): self { - return new self(status: self::STATUS_REJECTED, errors: $errors, metadata: $metadata); - }//end rejected() - - /** - * Create a modified result with updated data. - * - * @param array $data Modified object data - * @param array $metadata Optional metadata - * - * @return self - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public static function modified(array $data, array $metadata = []): self { - return new self(status: self::STATUS_MODIFIED, data: $data, metadata: $metadata); - }//end modified() - - /** - * Create an error result. - * - * @param string $message Error message - * @param array $metadata Optional metadata - * - * @return self - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public static function error(string $message, array $metadata = []): self { - return new self( - status: self::STATUS_ERROR, - errors: [['message' => $message]], - metadata: $metadata - ); - }//end error() - - /** - * Get the outcome status of the workflow result. - * - * @return string The status value - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function getStatus(): string { - return $this->status; - }//end getStatus() - - /** - * Get the modified object data. - * - * @return array|null - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function getData(): ?array { - return $this->data; - }//end getData() - - /** - * Get the validation errors. - * - * @return array - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function getErrors(): array { - return $this->errors; - }//end getErrors() - - /** - * Get the engine-specific metadata. - * - * @return array - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function getMetadata(): array { - return $this->metadata; - }//end getMetadata() - - /** - * Check whether the result status is approved. - * - * @return bool True if approved - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function isApproved(): bool { - return $this->status === self::STATUS_APPROVED; - }//end isApproved() - - /** - * Check whether the result status is rejected. - * - * @return bool True if rejected - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function isRejected(): bool { - return $this->status === self::STATUS_REJECTED; - }//end isRejected() - - /** - * Check whether the result status is modified. - * - * @return bool True if modified - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function isModified(): bool { - return $this->status === self::STATUS_MODIFIED; - }//end isModified() - - /** - * Check whether the result status is error. - * - * @return bool True if error - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function isError(): bool { - return $this->status === self::STATUS_ERROR; - }//end isError() - - /** - * Convert the result to an array representation. - * - * @return array - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function toArray(): array { - return [ - 'status' => $this->status, - 'data' => $this->data, - 'errors' => $this->errors, - 'metadata' => $this->metadata, - ]; - }//end toArray() - - /** - * Serialize the result to a JSON-compatible array. - * - * @return array - * - * @spec openspec/specs/workflow-engine-abstraction/spec.md - */ - public function jsonSerialize(): array { - return $this->toArray(); - }//end jsonSerialize() -}//end class diff --git a/openapi.json b/openapi.json index cd3fa7f1a6..f57e54ddf0 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "openregister", - "version": "2.0.12", + "version": "2.0.15-unstable.20260905143606", "description": "Open Register", "license": { "name": "EUPL-1.2" diff --git a/openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/proposal.md b/openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/proposal.md new file mode 100644 index 0000000000..98c28ce15a --- /dev/null +++ b/openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/proposal.md @@ -0,0 +1,50 @@ +# A configuration import 500s on a descriptor with objects + +## What was wrong + +`POST /api/configurations/import` answered **HTTP 500 with Nextcloud's HTML +error page** for any register descriptor that carries seed objects. + +`ConfigurationsController::import()` linked the result to the configuration with + +```php +$objectIds = array_map(static fn ($obj) => $obj->getId(), $result['objects']); +``` + +`$result['objects']` is not uniformly entities. `ImportHandler` appends an +`ObjectEntity` in two places and a **bare id** in two others +(`$result['objects'][] = $existingObject->getId()`). On the id path +`$obj->getId()` is a call on an int, which is a `TypeError`. + +A `TypeError` is an `Error`, not an `Exception`, so the method's +`catch (Exception $e)` did not see it either. The endpoint documents a JSON 400 +for a failed import; what a caller actually got was a 500 and an HTML page, with +nothing to distinguish a malformed descriptor from a crash. + +## How it was found + +Not by reading the code. The fleet's schema-slug collision inventory had been +measured three times from descriptors and been wrong three times, so it was +re-measured by importing every app's register through this endpoint and reading +the schema rows back. + +Seventeen of eighteen apps imported. stackiq's returned 500, and its register is +the one that ships seed objects. + +## The change + +`idsOf()` accepts an entity or a bare id and drops anything that is neither, so +the configuration's id list never holds a hole. The catch becomes `Throwable`, +so a bug in this method answers in JSON like every other failure rather than +falling through to the error page. + +Verified against the live instance: the same descriptor that returned 500 +returns `200 Import successful` and links its register. + +## Not fixed here + +`ImportHandler`'s return shape is still inconsistent — two of its four append +sites push entities and two push ids. Normalising it there is the better fix and +a wider one: other callers read the same array and would have to move together. +The controller no longer depends on which of the two it gets, which is what +unblocks the import. diff --git a/openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/specs/configuration-import/spec.md b/openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/specs/configuration-import/spec.md new file mode 100644 index 0000000000..2417d5b644 --- /dev/null +++ b/openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/specs/configuration-import/spec.md @@ -0,0 +1,37 @@ +# Configuration import + +## ADDED Requirements + +### Requirement: The import endpoint answers in JSON (REQ-CIM-001) + +`POST /api/configurations/import` SHALL answer with a JSON body for every +outcome, including a failure caused by a defect in the endpoint itself. + +It SHALL catch `Throwable` rather than `Exception`. A `TypeError` raised inside +the handler is a bug rather than bad input, and letting it escape produces +Nextcloud's HTML error page with an HTTP 500 — from which a caller cannot tell a +malformed descriptor from a crash. + +#### Scenario: A defect in the handler answers in JSON + +- **WHEN** the import handler raises an `Error` +- **THEN** the response is JSON carrying the message, not an HTML error page. + +### Requirement: The import result's ids are read from either shape (REQ-CIM-002) + +Linking the imported registers, schemas and objects to the configuration SHALL +accept a list holding entities, bare ids, or a mix. + +`ImportHandler` appends an `ObjectEntity` at two sites and a bare id at two +others, so a descriptor carrying seed objects yields a mixed list. Mapping it +with `$obj->getId()` reaches that call on an int. + +Anything that is neither an entity with `getId()` nor a scalar id SHALL be +dropped, so the stored id list never holds a hole. + +#### Scenario: A descriptor carrying seed objects imports + +- **GIVEN** a register descriptor whose import returns object ids rather than + object entities +- **WHEN** it is imported +- **THEN** the import succeeds and the configuration lists the imported ids. diff --git a/openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/tasks.md b/openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/tasks.md new file mode 100644 index 0000000000..f1c3b1f3d9 --- /dev/null +++ b/openspec/changes/a-configuration-import-500s-on-a-descriptor-with-objects/tasks.md @@ -0,0 +1,24 @@ +# Tasks + +## 1. The fix + +- [x] 1.1 `idsOf()` maps an entity through `getId()`, passes a bare id through, + and drops anything else. +- [x] 1.2 `import()` catches `Throwable`, so a bug answers in JSON rather than + as an HTML 500. + +## 2. Verification + +- [x] 2.1 Unit tests for entities, bare ids, a MIXED list (the shape that + distinguishes the fix — entities-only and ids-only both passed before it), + unusable entries, and a non-array. +- [x] 2.2 A test that the catch is `Throwable`, read off the source, because the + difference is invisible to a test that only exercises the happy path. +- [x] 2.3 Live: the descriptor that returned 500 returns 200 and links its + register. + +## 3. Not in this change + +- [ ] 3.1 `ImportHandler`'s inconsistent return shape (two append sites push + entities, two push ids). Normalising it there is the better fix; other + callers read the same array and would have to move with it. 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 01fd6bf504..83d6006196 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 @@ -34,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/openspec/changes/credential-oauth2-connect-flow/.openspec.yaml b/openspec/changes/credential-oauth2-connect-flow/.openspec.yaml new file mode 100644 index 0000000000..1d9aeef992 --- /dev/null +++ b/openspec/changes/credential-oauth2-connect-flow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/credential-oauth2-connect-flow/design.md b/openspec/changes/credential-oauth2-connect-flow/design.md new file mode 100644 index 0000000000..6d36992d68 --- /dev/null +++ b/openspec/changes/credential-oauth2-connect-flow/design.md @@ -0,0 +1,89 @@ +## Context + +See proposal.md for motivation, and `credential-oauth2-token-set` for the credential kind this change mints. The constraint that shapes everything here is in the marketing architecture's connection table: Meta, LinkedIn, X and Google all match callback URLs exactly, and their per-application caps are small enough that "every tenant registers its own callback" is not a plan. Bluesky publishes its own client metadata and needs nothing. Mastodon registers an application per server at connect time. + +OpenRegister already has the pieces this builds on. `CredentialBrokerService::mint()` is sessionless and takes an already-resolved scope and organisation, which is exactly the shape a callback needs. `CredentialController` owns the authorisation gates. `CaseTokenController` is the worked example of an ADR-082 throttled public endpoint: `#[PublicPage]`, `#[AnonRateLimit]`, `#[BruteForceProtection]`, and a `registerAttempt()` call on the failure branch, because the attribute alone does nothing. + +## Goals / Non-Goals + +**Goals:** + +- One controller that is both the relay and the receiving instance, so the Conduction-hosted relay is not a second implementation with a second set of bugs. +- A `state` that a receiving instance can verify on its own, without trusting the relay. +- Re-authorisation that lands on the same credential id, because every `socialAccount` and `searchProperty` in a consuming app points at that id. + +**Non-Goals:** + +- DPoP proofs. Bluesky's AT Protocol OAuth requires them for a working token, and this change ships the client metadata endpoint and the code exchange only. The DPoP layer is a named follow-up, not a silent omission. +- Provider-specific business calls. Publishing a post and reading metrics belong to the consuming app through OpenConnector, per ADR-091. +- Automatic discovery of a Mastodon or Bluesky host from a handle. The user supplies the server at start. + +## Decisions + +### D1: the relay validates shape and destination, the receiving instance validates the signature + +A relay cannot verify a `state` it did not sign, and giving every tenant the relay's signing key would make the relay's key the only key. So the two roles check different things, and the split is deliberate rather than a gap: + +- The **relay** parses the `state`, reads its declared callback URL, and refuses unless that URL's origin is on an administrator-managed allow-list and its path is this application's callback path. It then 302s to that URL with `code` and `state` intact. It performs no exchange, resolves no client, and mints nothing. A forged `state` therefore buys an attacker one redirect to a host the relay operator already trusts. +- The **receiving instance** verifies the HMAC with its own key, checks the expiry, consumes the nonce, and looks up the PKCE verifier it stored at start. A `state` it did not issue fails at the first of those. + +The security property that matters survives: a code can only be exchanged by the instance that started the flow, holds the verifier, and signed the state. That is true whether the relay is honest, compromised, or absent. + +The rejected alternative was a shared fleet-wide signing key so the relay could verify. It fails the sovereignty test: a tenant's connections would then be forgeable by whoever holds the fleet key, which is the opposite of what a bring-your-own-instance product should offer. + +### D2: PKCE always, and the verifier never travels + +Every start generates an S256 challenge. The verifier is stored server-side in the distributed cache keyed by the state's nonce, with the same TTL as the state, and is deleted when consumed. It is absent from the authorization URL, from the `state`, and from the start response. This is what makes the relay safe to be dumb: forwarding a code to a host that has no verifier gets that host nothing. + +Consuming the nonce is also what makes the state single-use. The cache entry is the record of "this start has not been completed"; deleting it on first use is both the replay defence and the verifier cleanup, in one operation. + +### D3: the client secret is itself a brokered credential + +There are three ways a client secret could reach the token exchange, and two of them break ADR-064: + +1. An app config value. Rejected: a secret on the instance's config table is exactly the shape the ADR closes. +2. A file. Rejected for the same reason, plus it cannot be per organisation. +3. A brokered credential of the existing `generic-oauth2` kind, resolved through `resolveInjectable()` at exchange time. Chosen. + +`OAuth2ClientResolver` therefore returns a client id and a secret from one of two places: the caller's `credentialRef` when the tenant brought its own application, or the administrator-set default credentialRef for that provider otherwise. The `generic-oauth2` entry already exists and already says the app performs the token exchange itself; the broker is now that app, resolving in-process. The resolution runs the ordinary guard chain, so a caller cannot borrow a client secret they have no claim on. + +### D4: one endpoint, two roles, decided by the state's own callback field + +`callback()` reads the state's declared receiving callback and compares it to this instance's own. Same means receive, different means relay. There is no mode flag, no separate route, and no deployment-time switch, so a Conduction-hosted relay is an ordinary OpenRegister install whose administrator populated the allow-list. That also means the relay path is exercised by the same tests every tenant's code runs. + +### D5: throttling follows ADR-082's two-halves rule + +`#[PublicPage]`, `#[NoCSRFRequired]`, `#[AnonRateLimit(limit: 30, period: 60)]` and `#[BruteForceProtection(action: 'openregisterOauth2Callback')]`, plus an explicit `IThrottler::registerAttempt()` on every rejection branch. The ADR's own finding is that openregister had 23 public endpoints and zero brute-force machinery; adding an endpoint whose only credential is a signed opaque string without registering its failures would repeat exactly that. + +The callback's failure response is uniform: a redirect carrying a generic failure marker where a return URL is known, and a static 400 otherwise. It never reports which check failed and never forwards the provider's own error text, which would be an oracle for state forgery. + +### D6: the return URL is declared at start and validated as same-origin + +An attacker-chosen redirect target on a callback is an open redirect. The return URL is taken at start, from an authenticated caller, and is accepted only when it is a path on this instance. It is then carried inside the signed state, so the callback redirects to a value the instance itself approved rather than to anything present in the request. + +### D7: the frontend is a new OpenRegister-owned section, not a change to `CnCredentials` + +The credential wallet on the personal settings page is `CnCredentials`, which lives in `@conduction/nextcloud-vue`, a different repository. Connect is broker behaviour and its status vocabulary is this change's, so the panel ships here as `OAuth2ConnectionsSection.vue` and `PersonalRoot.vue` renders it beside the existing wallet. When the shape settles it can move into the library; putting it there first would mean shipping a library release before the server that backs it exists. + +### D8: Bluesky ships honestly incomplete + +The client metadata endpoint and the code exchange are real. A Bluesky access token without a DPoP proof will be refused by a PDS, so a Bluesky connection minted by this change is not yet usable for API calls. The alternative was to omit Bluesky from the catalogue entirely, which would have made the gap invisible to the marketing programme's phase 3 planning rather than visible in a follow-up change. It is stated in the catalogue entry's own comment and in the connections panel, which marks the provider as preview. + +## Declarative-vs-imperative decision (ADR-031) + +Every behaviour here is an HTTP protocol exchange with an external party under a signature and a lock: the authorisation redirect, the code exchange, the relay forward, the upstream revoke, and the per-instance application registration. All fall under ADR-031's external-integration exception and are implemented imperatively in `lib/Service/Credential/`. The one declarative part, the credential's status vocabulary and metadata shape, is declared in `credential_broker_register.json` by the predecessor change and is not re-declared here. + +## Risks / Trade-offs + +- A compromised relay can redirect a user to any allow-listed tenant → the receiving instance still refuses a state it did not sign, so the worst outcome is a failed connect, and the allow-list is administrator-managed rather than derived from the request. +- The relay operator learns which tenant connected which provider and when → it is metadata the relay must see to route at all; it never sees a token, and a tenant that objects brings its own client and skips the relay entirely. +- A distributed cache is required for the nonce and verifier → without one there is no single-use guarantee, so the start endpoint refuses to issue a state when no distributed cache is available, rather than issuing one that cannot be made single-use. +- Six providers, one code path, and no live developer applications yet → the provider-specific parts are confined to catalogue data, so a wrong endpoint is a data fix in a reviewed release rather than a code change. + +## Migration Plan + +Additive. Four new routes, one new controller, four new services, one new settings section. Nothing existing changes shape. Rollback is removing the routes; a credential already minted keeps working through the predecessor change's refresh path, because a token set does not depend on how it was created. + +## Open Questions + +- Whether the relay allow-list should eventually be derived from the federated instance registry rather than typed by an administrator. Deferrable: it changes where a list of origins comes from, not what the relay does with one. diff --git a/openspec/changes/credential-oauth2-connect-flow/proposal.md b/openspec/changes/credential-oauth2-connect-flow/proposal.md new file mode 100644 index 0000000000..484db9fa87 --- /dev/null +++ b/openspec/changes/credential-oauth2-connect-flow/proposal.md @@ -0,0 +1,39 @@ +--- +kind: code +depends_on: + - credential-oauth2-token-set +--- + +## Why + +`credential-oauth2-token-set` gives the broker a credential kind it can refresh, but nothing can create one. An OAuth2 token set is minted by a user consenting at the provider, and every network except Bluesky keeps an exact-match allow-list of callback URLs. A tenant's own Nextcloud domain can never be the callback of a Conduction-owned developer app, and the caps are small: X allows ten callbacks per app, Google a hundred. Without a relay, connecting an account means every tenant filing its own developer application. + +This change adds the authorise, callback and mint dance to the broker, and puts the relay and the receiving instance in one controller so a Conduction-hosted relay runs the same code every tenant runs. + +## What Changes + +- `POST /api/credentials/oauth2/start`, authenticated. Takes a provider, the scopes to ask for, the desired credential scope, an optional tenant client override, an optional instance host for Mastodon and Bluesky, and a return URL. Returns the provider's authorization URL with PKCE and a signed, short-lived `state`. +- `GET /oauth2/callback`, a throttled `PublicPage`. Verifies `state`, exchanges the code, mints or overwrites the token-set credential, and redirects to the return URL declared at start. +- Relay mode in the same handler. When `state` names a callback on another instance, the relay validates the target against an admin-set allow-list and forwards the code there without ever exchanging it. +- `GET /oauth2/client-metadata.json`, a `PublicPage` serving the instance's own AT Protocol client metadata, so Bluesky needs no relay. +- Mastodon app registration at connect time, storing the client id on the credential and the client secret as a separate brokered credential. +- `DELETE /api/credentials/oauth2/{id}`, which revokes upstream where the provider has a revoke endpoint and then disables the credential. +- A "Connect account" panel in OpenRegister's personal settings, showing a status chip per connection and a Reconnect action that re-runs the flow onto the same credential id. + +## Capabilities + +### New Capabilities + +- `credential-oauth2-connect`: the start, callback, relay, revoke and reconnect surface that mints and re-mints an `oauth2-token-set` credential. + +### Modified Capabilities + +- `credential-oauth2-token-set`: re-authorisation of an existing credential overwrites the same credential id and returns it to `active`, so every reference to it keeps working. + +## Impact + +- `lib/Controller/CredentialOAuth2Controller.php` and four `appinfo/routes.php` entries. +- `lib/Service/Credential/`: `OAuth2StateService`, `OAuth2ConnectService`, `OAuth2RelayGuard`, `OAuth2ClientResolver`. +- `src/components/userSettings/OAuth2ConnectionsSection.vue`, mounted from the existing `PersonalRoot.vue`. +- Admin configuration: the relay target allow-list and the per-provider default client credentialRef. +- Consumers: pipelinq marketing phase 3 and phase 5 connect their accounts through this surface. No existing endpoint changes. diff --git a/openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md b/openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md new file mode 100644 index 0000000000..20b84f4595 --- /dev/null +++ b/openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-connect/spec.md @@ -0,0 +1,195 @@ +## Purpose + +Defines how a tenant connects a provider account to the credential broker: the authorisation start, the callback that exchanges the code and mints an `oauth2-token-set` credential, the relay that lets a Conduction-hosted callback serve tenants whose own domain a provider will not accept, and the reconnect and revoke actions on an existing connection. + +## ADDED Requirements + +### Requirement: Starting a connection returns an authorization URL bound to the caller + +`POST /api/credentials/oauth2/start` SHALL be available to an authenticated user only. It SHALL accept a catalogue provider identifier whose entry declares `kind: "oauth2-token-set"`, the scopes to request, a desired credential scope of `personal` or `organisation`, an optional `credentialRef` naming a tenant-supplied `generic-oauth2` client secret, an optional instance host for a provider whose catalogue entry declares `baseUrlFrom`, an optional existing credential id to re-authorise, and a return URL. It SHALL return the provider's authorization URL carrying a `state` value, and a PKCE code challenge for every provider whose catalogue entry declares PKCE support. A code verifier SHALL be generated and held for every start, whether or not the provider consumes it. An organisation-scoped start SHALL be refused unless the caller administers the organisation the credential would belong to. + +`@e2e tests/e2e/credential-oauth2-connect.spec.ts` + +#### Scenario: A start returns a provider URL and never a secret + +- **WHEN** an authenticated user starts a connection for a supported provider +- **THEN** the response carries the provider's authorization endpoint with the requested scopes and a `state`, and a code challenge when the provider supports PKCE +- **AND** it carries no client secret, no code verifier and no token + +#### Scenario: An unsupported provider is refused + +- **WHEN** a start names a provider that is absent from the catalogue or whose entry is not an OAuth2 token set +- **THEN** the request is refused and no state is issued + +#### Scenario: An organisation start needs organisation administration + +- **WHEN** a member who does not administer the organisation starts an organisation-scoped connection +- **THEN** the request is refused + +### Requirement: The state value is signed, single-use and short-lived + +The `state` issued at start SHALL be signed with an instance-held key and SHALL bind the initiating user, the instance, the provider, the desired credential scope, a nonce, the callback URL of the instance that must receive the code, the return URL, any credential id being re-authorised, and an expiry. The callback SHALL reject a `state` whose signature does not verify, whose expiry has passed, or whose nonce has already been consumed. The PKCE code verifier SHALL be held server-side against the nonce and SHALL NOT travel inside the `state`. + +`@e2e tests/e2e/credential-oauth2-connect.spec.ts` + +#### Scenario: A tampered state is refused + +- **WHEN** any field of a `state` value is altered before the callback is reached +- **THEN** the callback refuses it and mints nothing + +#### Scenario: An expired state is refused + +- **WHEN** a `state` is presented after its expiry +- **THEN** the callback refuses it and mints nothing + +#### Scenario: A replayed state is refused + +- **WHEN** a valid `state` is presented a second time +- **THEN** the first presentation is honoured and the second is refused + +#### Scenario: The verifier never leaves the instance + +- **WHEN** a start is issued +- **THEN** the code verifier is not present in the authorization URL, the `state`, or the response body + +### Requirement: The callback exchanges the code and mints a token-set credential + +`GET /oauth2/callback` SHALL be a public, throttled endpoint. On a `state` that verifies and names this instance as the receiving instance, it SHALL exchange the authorization code at the catalogue's token endpoint using the held code verifier and the resolved client, SHALL mint an `oauth2-token-set` credential holding the resulting token set in the custody leaf, SHALL record the granted scopes, the account identity and the expiry as non-secret metadata with `status` `active`, and SHALL then redirect the user to the return URL declared at start. A failed exchange SHALL mint nothing and SHALL redirect to the same return URL carrying a failure marker. + +`@e2e tests/e2e/credential-oauth2-connect.spec.ts` + +#### Scenario: A successful callback produces one active credential + +- **WHEN** the callback receives a code with a valid state +- **THEN** exactly one credential exists for that provider and account, with status `active` +- **AND** the browser is redirected to the return URL declared at start + +#### Scenario: A failed exchange leaves nothing behind + +- **WHEN** the token endpoint rejects the exchange +- **THEN** no credential object and no stored secret is created +- **AND** the redirect carries a failure marker rather than a provider error message + +#### Scenario: A failed callback is throttled + +- **WHEN** callbacks with an unverifiable state arrive repeatedly from one address +- **THEN** each failure is registered with the brute-force throttler + +### Requirement: A relay forwards a code and never exchanges it + +When a verified-shape `state` names a callback URL that is not this instance's own, the handler SHALL act as a relay: it SHALL forward the authorization code and the `state` unchanged to that URL and SHALL NOT contact the token endpoint, SHALL NOT resolve a client, and SHALL NOT mint anything. It SHALL forward only to a target whose origin appears on the instance's administrator-managed relay allow-list, and whose path is the callback path of this application. Any other target SHALL be refused. + +`@e2e exclude relay forwarding needs two instances; asserted by PHPUnit on the controller, including the allow-list refusal` + +#### Scenario: A relay forwards to an allow-listed tenant + +- **WHEN** the relay receives a code whose state names an allow-listed instance callback +- **THEN** the browser is redirected to that callback with the code and state intact +- **AND** the relay makes no token request + +#### Scenario: A relay refuses an unknown target + +- **WHEN** the state names a callback on an origin that is not on the allow-list +- **THEN** the relay refuses the request and forwards nothing + +#### Scenario: A forged state cannot mint anywhere + +- **WHEN** a state the relay forwarded was not signed by the receiving instance +- **THEN** the receiving instance refuses it on signature verification and mints nothing + +### Requirement: Re-authorisation overrides the same credential + +A start that names an existing credential the caller may manage SHALL, on a successful callback, replace that credential's stored token set and metadata rather than creating a second credential. The credential's id SHALL not change, its `allowedApps` and shares SHALL be preserved, and its status SHALL return to `active`. This is how a `relink_needed` connection is repaired, so every object referencing that credentialRef keeps working. + +`@e2e tests/e2e/credential-oauth2-connect.spec.ts` + +#### Scenario: Reconnecting a broken connection keeps its id + +- **WHEN** a credential in `relink_needed` is re-authorised +- **THEN** the same credential id is now `active` with a fresh token set +- **AND** no second credential was created for the same account + +#### Scenario: A caller cannot re-authorise a credential they do not manage + +- **WHEN** a start names a credential the caller may not manage +- **THEN** the start is refused + +### Requirement: Disconnecting revokes upstream where it can and disables locally + +`DELETE /api/credentials/oauth2/{id}` SHALL be available to a caller who may manage the credential. When the provider's catalogue entry declares a revoke endpoint, the broker SHALL call it with the stored token before deleting anything locally. It SHALL then delete the stored secret and set the credential's status to `disabled`. A failed upstream revoke SHALL NOT prevent the local disable, and SHALL be recorded as a secret-free `lastError`. + +`@e2e tests/e2e/credential-oauth2-connect.spec.ts` + +#### Scenario: A revoke is attempted before the local delete + +- **WHEN** a connection for a provider with a revoke endpoint is disconnected +- **THEN** the revoke request is made and the stored secret is then removed + +#### Scenario: An unreachable provider still disables the connection locally + +- **WHEN** the revoke endpoint cannot be reached +- **THEN** the stored secret is still removed and the status becomes `disabled` + +### Requirement: Bluesky is its own client and Mastodon registers per instance + +For a provider whose catalogue entry declares client metadata served by the instance, the application SHALL expose that metadata at a public JSON endpoint whose URL is the client identifier, and SHALL use its own callback as the redirect target with no relay. For a provider that requires an application registered at the account's own server, the start SHALL register one at connect time, store the resulting client identifier as non-secret metadata on the credential, and store the issued client secret as a separate brokered credential referenced by `clientCredentialRef`. + +`@e2e exclude both paths need a live third-party server; asserted by PHPUnit against mocked registration and metadata responses` + +#### Scenario: Client metadata is served publicly and self-consistently + +- **WHEN** the client metadata endpoint is fetched +- **THEN** it returns JSON whose client identifier is that endpoint's own URL and whose redirect target is this instance's callback + +#### Scenario: A per-instance registration stores no secret on the object + +- **WHEN** an application is registered at an account's own server +- **THEN** the credential carries the client identifier and a `clientCredentialRef` +- **AND** the client secret appears only in the custody leaf + +#### Scenario: A reconnect does not register a second application + +- **WHEN** a connection whose credential already carries a client identifier is re-authorised +- **THEN** no new application is registered at the account's server +- **AND** the existing client identifier and `clientCredentialRef` are reused + +### Requirement: A connection records the account it speaks for + +Where a provider's catalogue entry declares an identity call, the connect flow SHALL make that one call immediately after the token set is stored and SHALL record the returned account identifier, handle and display name as non-secret metadata on the credential. The call SHALL go through the broker's own constrained proxy, so it is bounded by the same allow-rules and host-lock as any other call on that credential, and the catalogue SHALL only declare an identity call that its own allow-rules already permit. + +A failure of this call SHALL NOT undo or alter the connection: the credential stands, the stored token set is untouched, and the failure is logged. A connection is a working connection whether or not its label could be read. + +`@e2e exclude the identity answer comes from a live third-party account; asserted by PHPUnit against a mocked broker` + +#### Scenario: The handle is read once and shown on the connection + +- **WHEN** a connection is minted for a provider that declares an identity call +- **THEN** the credential's `account` carries the handle the provider returned +- **AND** the connections panel shows that handle rather than a placeholder + +#### Scenario: A provider that declares no identity call is not asked + +- **WHEN** a connection is minted for a provider with no identity declaration +- **THEN** no additional call is made, and the connection carries the name the person gave it + +#### Scenario: A failed identity call leaves a working connection alone + +- **WHEN** the identity call is refused, unreachable, or answers with something that is not JSON +- **THEN** the stored token set is unchanged and the connection remains usable + +### Requirement: A person can connect, see and repair a connection from personal settings + +OpenRegister's personal settings SHALL offer a "Connect account" action for every catalogue provider that is an OAuth2 token set, SHALL list the caller's existing connections with a status chip reading active, expired or relink needed, and SHALL offer a Reconnect action on a connection that needs one. The panel SHALL never display a token, a client secret, or an authorization code. + +`@e2e tests/e2e/credential-oauth2-connect.spec.ts` + +#### Scenario: A connection that needs repair says so and offers the repair + +- **WHEN** a connection's status is `relink_needed` +- **THEN** the panel shows the relink needed chip on that connection +- **AND** a Reconnect action starts the flow onto the same credential + +#### Scenario: The panel shows no secret material + +- **WHEN** the connections panel is rendered +- **THEN** it shows the account handle, the granted scopes and the expiry, and no token diff --git a/openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-token-set/spec.md b/openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-token-set/spec.md new file mode 100644 index 0000000000..97e7028453 --- /dev/null +++ b/openspec/changes/credential-oauth2-connect-flow/specs/credential-oauth2-token-set/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: A re-authorised credential returns to active in place + +A credential in any status MAY be returned to `active` by a successful re-authorisation that writes a new token set under the same credential id. The re-authorisation SHALL clear `lastError`, SHALL set `expiresAt`, `scopes` and `account` from the new token response, and SHALL leave `allowedApps`, shares, `kind`, `scope` and `organisation` unchanged. A credential's `provider` and, where one is pinned, its `instanceBaseUrl` SHALL NOT change on re-authorisation; a re-authorisation naming a different provider or host SHALL be refused. + +`@e2e exclude the in-place override is asserted end to end by the connect spec; the invariants here are asserted by PHPUnit` + +#### Scenario: Re-authorisation preserves grants and identity + +- **WHEN** a credential with two entries in `allowedApps` is re-authorised +- **THEN** both entries survive and the credential is `active` + +#### Scenario: Re-authorisation cannot re-point a credential + +- **WHEN** a re-authorisation would change the credential's provider or its pinned instance host +- **THEN** it is refused and the stored token set is unchanged diff --git a/openspec/changes/credential-oauth2-connect-flow/tasks.md b/openspec/changes/credential-oauth2-connect-flow/tasks.md new file mode 100644 index 0000000000..3a1bda6afe --- /dev/null +++ b/openspec/changes/credential-oauth2-connect-flow/tasks.md @@ -0,0 +1,39 @@ +## 1. State and client resolution + +- [x] 1.1 Add `OAuth2StateService` issuing and verifying a signed, expiring, single-use state and holding the PKCE verifier server-side against its nonce; verify tampering, expiry and replay rejections in `OAuth2StateServiceTest` +- [x] 1.2 Add `OAuth2ClientResolver` returning a client id and secret from a caller-supplied `credentialRef` or the administrator-set default, resolved through the existing broker guard chain; verify with `OAuth2ClientResolverTest` + +## 2. Connect, callback and relay + +- [x] 2.1 Add `OAuth2ConnectService` building the authorization URL with PKCE, exchanging a code at the catalogue token endpoint, and minting or overwriting an `oauth2-token-set` credential; verify with `OAuth2ReauthorisationTest` against a mocked token endpoint +- [x] 2.2 Add `OAuth2RelayGuard` accepting a forward target only when its origin is on the administrator-managed allow-list and its path is this application's callback path; verify the refusal cases in `OAuth2RelayGuardTest` +- [x] 2.3 Add `CredentialOauth2Controller` with `start`, `callback`, `disconnect` and `clientMetadata`, the callback carrying `#[PublicPage]`, `#[NoCSRFRequired]`, `#[AnonRateLimit]`, `#[BruteForceProtection]` and an explicit `registerAttempt()` on every rejection branch; verify with `CredentialOauth2ControllerTest` +- [x] 2.4 Register the four routes in `appinfo/routes.php` and verify each resolves to an existing method with a declared auth posture +- [x] 2.5 Implement relay forwarding in the same `callback` method, deciding by the state's own declared callback and making no token request in that branch; verify the relay branches in `CredentialOauth2ControllerTest` + +## 3. Provider specifics + +- [x] 3.1 Serve AT Protocol client metadata whose client identifier is the endpoint's own URL and whose redirect target is this instance's callback; verify with `CredentialOauth2ControllerTest` +- [x] 3.2 Register a Mastodon application at the account's server at start through `OAuth2InstanceClient`, at most once per connection so a reconnect reuses the client already pinned to the credential, storing the client id on the credential and the client secret as a separate brokered credential; verify with `OAuth2InstanceClientTest` against a mocked registration response +- [x] 3.3 Revoke upstream where the catalogue declares a revoke endpoint, then delete the stored secret and set `disabled` even when the revoke failed; verify both branches in `CredentialOauth2ControllerTest` + +## 4. Re-authorisation + +- [x] 4.1 Overwrite an existing credential in place on re-authorisation, preserving id, `allowedApps`, shares, scope and organisation, clearing `lastError` and returning to `active`; verify with `OAuth2ReauthorisationTest` +- [x] 4.2 Refuse a re-authorisation that would change the credential's provider or pinned instance host, and refuse one the caller may not manage; verify both in `OAuth2ReauthorisationTest` + +## 5. Frontend and e2e + +- [x] 5.1 Add `OAuth2AccountIdentity` reading the connected account through the broker so the panel can name it, and `src/components/userSettings/OAuth2ConnectionsSection.vue` listing connections with status chips and Connect, Reconnect and Disconnect actions, mounted from `PersonalRoot.vue`; verify with its jest spec +- [x] 5.2 Add `tests/e2e/credential-oauth2-connect.spec.ts` covering the connect entry point, the status chips and the reconnect action; verify it lints + +## 6. Quality + +- [x] 6.1 Run `composer check:strict`, `npm run format`, `npm run lint`, `npm test` and the hydra gates with `HYDRA_GATE_BASE_REF=origin/development`, and fix every finding in a touched file; verify by exit code + +### Acceptance criteria + +- A tampered, expired or replayed state mints nothing, and each rejection registers a throttler attempt. +- The relay never contacts a token endpoint and never resolves a client, asserted by a test that fails if it does. +- Re-authorising a `relink_needed` connection produces the same credential id in `active`, with no second credential for the same account. +- The connections panel renders an account handle, its scopes and its expiry, and no token. diff --git a/openspec/changes/credential-oauth2-token-set/.openspec.yaml b/openspec/changes/credential-oauth2-token-set/.openspec.yaml new file mode 100644 index 0000000000..1d9aeef992 --- /dev/null +++ b/openspec/changes/credential-oauth2-token-set/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/credential-oauth2-token-set/design.md b/openspec/changes/credential-oauth2-token-set/design.md new file mode 100644 index 0000000000..d9b6436138 --- /dev/null +++ b/openspec/changes/credential-oauth2-token-set/design.md @@ -0,0 +1,107 @@ +## Context + +See proposal.md for motivation. The constraints that shape the approach are already in the tree. + +`CredentialBrokerService::request()` is a fixed guard chain: owner or organisation membership, allowed apps, provider allow-rules, host-lock, then read the secret and substitute it into one header template. `ProviderCatalogue` reads `lib/Settings/credential-providers.json` from disk and nothing writes it. `CredentialStore` is a three-method leaf, `put` / `get` / `delete`, keyed by the credential UUID and its scope, with a categorical docblock forbidding logging, persistence to an object, export, or return in an API response. The catalogue's own `$fleetComment` states that the broker "cannot do a token exchange", and lists BRP as the first casualty. + +ADR-064 makes three of the boundaries non-negotiable: no secret on an object, the proxy is preferred over injection, and infrastructure credentials use organisation scope. The value of the proxy for OAuth2 is unusually high, because a refresh token is a long-lived credential that an app that never sees it cannot lose. + +## Goals / Non-Goals + +**Goals:** + +- Add one credential kind without changing what any existing credential does at runtime. +- Keep the host-lock meaningful for providers whose host is per account. +- Make refresh a property of the read path, so a consumer never has to think about expiry. +- Make a dead grant loud once and silent afterwards, rather than a repeated failing call. + +**Non-Goals:** + +- The authorise and callback dance. That is `credential-oauth2-connect-flow`. +- DPoP-bound tokens. Bluesky's AT Protocol OAuth requires them, and this change stops at the catalogue entry and the token-set plumbing; the DPoP proof layer is a follow-up. +- Client-credentials grants, mTLS, and signing operations. They remain unbrokerable and stay named in the catalogue comment. +- Provider-specific request shaping. That belongs to the consuming app's OpenConnector adapters, per ADR-067 and ADR-091. + +## Decisions + +### D1: kind lives on the catalogue entry, not on the credential object + +The alternative is a `kind` property the minting caller sets. It was rejected because the catalogue is the security control: it is read-only at runtime and reviewed on release, and the object is writable by its owner. If the object chose the injection path, an owner could flip a proxy credential's behaviour with an ordinary object write. The object still mirrors `kind` so the settings UI and a listing can show it without loading the catalogue, and the broker treats a disagreement as a stale mirror rather than as input. + +### D2: the token set is one opaque secret, not several + +Storing the access token and the refresh token as two `CredentialStore` entries would make rotation a two-write operation with a window in which the pair does not agree. One serialised document gives an atomic swap for free: a `put` either lands the whole new set or leaves the whole old one. `OAuth2TokenSet` is an immutable value object with a private constructor, named constructors for decoding stored JSON and for parsing a provider token response, and no `__toString`. It carries a `withRefreshed()` that returns a new instance and keeps the previous refresh token when the response omits one, which is the majority behaviour among the six providers here. + +### D3: a per-account host is pinned to the credential, so the lock moves rather than weakens + +Mastodon and Bluesky have no single API host. A catalogue entry for them cannot carry a `baseUrl`, and the obvious workaround, making them `inject_only`, would hand the refresh token to the calling app and lose the whole point. + +The chosen shape is `baseUrlFrom: "instanceBaseUrl"`. It changes where the lock's value comes from, not whether there is one: + +- The catalogue still declares the provider's allow-rules, so the permitted method and path set is fixed in a reviewed release exactly as for every other entry. +- The host is validated once, at mint, against a syntactic and network safety rule: absolute `https`, no userinfo, no query, no fragment, a registrable domain rather than an IP literal, and not a loopback or private-range name. This is the same class of check the webhook target guard makes, and it exists to stop the broker being turned into an SSRF tool against the instance's own network. +- The value is then immutable. `CredentialController::update()` rejects a write that changes it. A credential is therefore locked to one host for its whole life, which is a stronger statement than the catalogue can make for a shared provider, because it is per credential rather than per entry. + +The rejected alternative was an admin-approved per-install provider registration, which the catalogue's comment already names as "a broker design change". It is the right answer for OpenConnector's arbitrary sources and the wrong size for this: here the host is not arbitrary, it is the account's own server, discovered during the connect flow and never chosen by hand. + +### D4: refresh happens inside `request()`, behind one new hook + +`request()` gains exactly one new call, replacing the direct `credentialStore->get()`: + +```php +$injectable = $this->injectionSecret(credential: $credential, provider: $provider, scope: $scope); +``` + +`injectionSecret()` dispatches on the catalogue kind. For `secret` it is the old two lines verbatim. For `oauth2-token-set` it delegates to `OAuth2RefreshService::accessTokenFor()`, which decodes, checks the margin, refreshes under the lock when needed, and returns the access token. The guard chain above it is untouched, and the guard-ordering test asserts that a denial happens before any token endpoint is contacted. + +The margin defaults to 120 seconds. It is a constant rather than a config value, because a per-install margin is one more thing that can be set wrong and no provider here issues a token shorter than five minutes. + +### D5: the lock is a distributed-cache compare-and-set, and its absence is not silent + +`CredentialLock` wraps `ICacheFactory::createDistributed()`. When the cache is an `IMemcache`, `add()` gives an atomic acquire, and the lock carries a TTL so a crashed holder does not wedge the credential. A waiter polls briefly and then re-reads the stored token set rather than refreshing itself: after a successful refresh by the holder, the re-read finds a token outside the margin and no second exchange happens. + +On an install with no distributed cache, `ICacheFactory::isAvailable()` is false and there is no atomic primitive available. The lock then degrades to advisory and says so in a warning log line, once per acquire. That is a real weakening, and it is the reason the log line exists: "we take a lock" must be a claim someone can substantiate, exactly as ADR-064 requires of "secrets are in Doriath". The worst case is a duplicate refresh, which every provider here tolerates, not a lost token, because the custody write is a single atomic `put` either way. + +### D6: `relink_needed` is a distinct, typed failure that existing catch blocks still fail closed on + +`CredentialRelinkRequiredException extends CredentialAccessDeniedException`. That gives callers a type they can catch specifically to offer a reconnect, while every existing `catch (CredentialAccessDeniedException)` in the tree keeps failing closed rather than falling through to an untyped error. The controller maps it to HTTP 409 with a static message, so the client learns "reconnect this credential" without learning which guard or which provider error produced it. + +`invalid_grant` is the only refresh error treated as terminal. Everything else, including a 500 or a timeout from the token endpoint, is an upstream failure that leaves the status `active`. Treating a provider outage as a revoked grant would relink every connection on the instance during someone else's incident. + +### D7: the notification is a Nextcloud notification to the owner, not an email + +The owner is a Nextcloud user, the action needed is inside Nextcloud, and `IManager::notify()` already deduplicates by object id. Rate limiting is inherent: the status flips to `relink_needed` on the first failure, and a `relink_needed` credential never reaches the refresh path again, so the notification is produced once per break rather than once per attempt. + +### D8: the job is a `TimedJob` in `lib/BackgroundJob/`, registered in `info.xml` + +Per ADR-069 rules 1, 2 and 3. Interval 86400 seconds, refresh window 48 hours, so a token with a one-hour life is still refreshed on read and a token with a 60-day life is refreshed by the job well before it lapses. The job holds the same lock as the read path, so a sweep and a live call never both exchange. + +## Declarative-vs-imperative decision (ADR-031) + +| Behaviour | Path | Rationale | +| --- | --- | --- | +| `status` vocabulary and metadata shape | Declarative, `credential_broker_register.json` | It is a schema statement about a stored object, and the enumeration belongs where every other property constraint lives. | +| Refresh, rotation, lock, relink | Imperative, `lib/Service/Credential/` | ADR-031's external-integration exception. This is an HTTP exchange with an external provider under a lock, with an error taxonomy that decides a lifecycle transition. No schema grammar expresses it. | +| Daily sweep | Imperative `TimedJob` | ADR-031's scheduled-bulk-work exception, and ADR-069 rule 3. | +| Owner notification | Imperative | It is emitted from the refresh failure path, which is imperative already; a declarative notification cannot observe an OAuth2 error code. | + +## Seed data (ADR-001) + +The `credential-broker` register already ships two example objects with the nil UUID as owner. This change adds one more in the same shape, so the new metadata properties have a worked example: + +- `Reisbureau Example, Mastodon company account`: provider `mastodon`, kind `oauth2-token-set`, scope `organisation`, `instanceBaseUrl` `https://mastodon.example`, `status` `pending`, `scopes` `["read:accounts", "write:statuses"]`, `account` with an empty handle, `owner` the nil UUID, no secret. A `pending` seed carries no token set by construction, which keeps the seed honest: the register JSON has no place to put one and should not. + +## Risks / Trade-offs + +- A per-credential host is a smaller blast radius than a shared host but a larger surface to review → the validation runs at mint, the value is immutable afterwards, and the rejection cases are unit tested rather than assumed. +- No distributed cache means no true lock → the degradation is logged on every acquire, and the atomic single-`put` rotation means the failure mode is a wasted exchange rather than a corrupted token set. +- A provider that rotates refresh tokens on every use and then fails mid-write would strand the credential → the custody write happens before the object update, so the leaf always holds the newest set the provider issued, and the next read decodes it. +- Six catalogue entries written against published documentation cannot all be verified without six live developer accounts → each entry's allow-rules are scoped to the calls the marketing programme names, and a wrong path fails closed with a 403 rather than succeeding wrongly. The fix is another reviewed release, which is the same posture the Doffin entry already documents. + +## Migration Plan + +The change is additive. No existing catalogue entry gains a `kind`, so every existing credential takes the `secret` path byte for byte. The register version moves to 1.4.0 and the new properties are optional, so existing objects validate unchanged. Rollback is removing the entries and the new services; no stored data changes shape. + +## Open Questions + +- Whether the refresh window should become a per-provider value once a provider with a very short refresh-token life appears. Deferrable: the window is a job constant and changing it later changes no spec, no API and no stored data. diff --git a/openspec/changes/credential-oauth2-token-set/proposal.md b/openspec/changes/credential-oauth2-token-set/proposal.md new file mode 100644 index 0000000000..b651199564 --- /dev/null +++ b/openspec/changes/credential-oauth2-token-set/proposal.md @@ -0,0 +1,40 @@ +--- +kind: mixed +--- + +## Why + +The credential broker can only express one shape of secret: a single opaque string substituted into one request header. The catalogue file says so itself, in the `$fleetComment` that lists what is "STILL NOT BROKERABLE" and names a token exchange first. Every OAuth2 provider therefore has no compliant home in the broker, so an app that talks to LinkedIn, Meta, X, Bluesky, Mastodon or Google Search Console either keeps custody of the tokens itself, which ADR-064 forbids, or does not integrate at all. + +The pipelinq marketing programme needs exactly that capability in its phase 0: a tenant connects a social account or a Search Console property once, and every later publish or metrics pull runs on a token the broker holds, refreshes and rotates without the app ever seeing it. This change adds the missing credential kind. The connect dance that mints one is the sibling change `credential-oauth2-connect-flow`. + +## What Changes + +- A second credential kind, `oauth2-token-set`, declared by the provider catalogue entry rather than by the credential object. Entries without a `kind` keep today's behaviour verbatim, so nothing existing moves. +- The stored secret for that kind is one opaque JSON document holding the access token, refresh token, expiry, token type, granted scopes, the provider account identity and the raw token response. It goes to the custody leaf whole and is never split across the object. +- New non-secret metadata on `brokeredcredential`: `kind`, `status`, `scopes`, `account`, `expiresAt`, `lastRefreshedAt`, `lastError`, `instanceBaseUrl`, `clientId` and `clientCredentialRef`. No property carries a token. +- Catalogue entries for LinkedIn, Meta Graph, X, Bluesky, Mastodon and Google, each with its authorization, token and revoke endpoints, its refresh grant shape, and allow-rules covering publishing a post, reading own post metrics, reading followers and querying Search Console. Google is two entries, Search Console and Analytics, because the host-lock is per host and the two APIs answer on different hosts, exactly as the live and test Adyen entries are already split. +- Per-credential host binding for the two providers whose host is per account, Mastodon and Bluesky. The catalogue declares that the host comes from connection metadata; the broker pins it onto the credential at mint and refuses to serve any other host afterwards. +- `request()` learns to refresh. Past `expiresAt` minus a margin it takes a per-credential lock, refreshes, rotates the stored set atomically, then injects `Authorization: Bearer `. +- A refresh rejected with `invalid_grant` moves the credential to `relink_needed`, emits `CredentialRelinkRequiredEvent`, notifies the owner, and makes every later `request()` fail closed with a typed exception. +- A daily `TimedJob` refreshes every `active` token set whose expiry falls inside the refresh window. +- Personal scope for a person's own account, organisation scope for a company page, per ADR-064 rule 4. + +## Capabilities + +### New Capabilities + +- `credential-oauth2-token-set`: the OAuth2 token-set credential kind, its stored payload, refresh and rotation under a lock, the relink lifecycle, and the daily refresh sweep. + +### Modified Capabilities + +- `credential-broker`: `request()` gains a kind-aware injection step, the provider catalogue gains `kind`, `oauth2` and `baseUrlFrom` fields, and the host-lock is allowed to resolve from per-credential metadata for entries that declare it. + +## Impact + +- `lib/Service/Credential/`: new `OAuth2TokenSet`, `OAuth2TokenSetCodec`, `OAuth2RefreshService`, `CredentialLock`, `CredentialRelinkRequiredException`; `CredentialBrokerService::request()` gains one injection hook. +- `lib/Settings/credential-providers.json`: six new entries and a version bump. +- `lib/Settings/credential_broker_register.json`: ten new non-secret properties. +- `lib/BackgroundJob/OAuth2TokenRefreshJob.php` plus its `appinfo/info.xml` registration. +- `lib/Event/CredentialRelinkRequiredEvent.php`. +- Consumers: pipelinq marketing (phase 3 and 5), and any later app that needs a refreshing OAuth2 token. No existing consumer changes, because no existing credential carries a `kind`. diff --git a/openspec/changes/credential-oauth2-token-set/specs/credential-broker/spec.md b/openspec/changes/credential-oauth2-token-set/specs/credential-broker/spec.md new file mode 100644 index 0000000000..8be09b33da --- /dev/null +++ b/openspec/changes/credential-oauth2-token-set/specs/credential-broker/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: The catalogue may describe an OAuth2 provider + +A provider catalogue entry MAY carry a `kind` field and, when that kind is `oauth2-token-set`, an `oauth2` block declaring the authorization endpoint, the token endpoint, the revoke endpoint where the provider has one, the refresh grant shape, the scope separator, and whether the token request authenticates with a client secret or with PKCE alone. These fields SHALL be read only from the shipped, runtime-immutable file, so an endpoint the broker will exchange a code or a refresh token at can only ever change in a reviewed release. + +`@e2e exclude catalogue file contents; asserted by PHPUnit on ProviderCatalogue and by the JSON strictness check` + +#### Scenario: An OAuth2 endpoint cannot be supplied at runtime + +- **WHEN** a caller supplies a token endpoint in a request body +- **THEN** the broker ignores it and uses the catalogue's value + +#### Scenario: An entry missing its oauth2 block cannot act as a token set + +- **WHEN** an entry declares `kind: "oauth2-token-set"` but carries no `oauth2` block +- **THEN** the broker denies every call on credentials for that provider + +### Requirement: Injection is selected by kind + +`CredentialBrokerService::request()` SHALL resolve the value substituted into the provider's `authScheme` template through a kind-aware step: for a `secret` kind the stored secret verbatim, for an `oauth2-token-set` kind the access token in force after any refresh required by the margin. All four existing guards SHALL run before that step, unchanged and in the same order. + +`@e2e exclude broker injection path; asserted by PHPUnit including a guard-ordering test` + +#### Scenario: Guards still run before any refresh + +- **WHEN** a caller is not admitted by the owner or allowed-app guard +- **THEN** the call is denied before any token endpoint is contacted + +#### Scenario: An inject-only provider is still refused by the proxy + +- **WHEN** `request()` is called for an `inject_only` provider +- **THEN** it is denied exactly as before, whatever kind the entry declares + +### Requirement: A token set is never resolved app-side + +`CredentialBrokerService::resolveInjectable()` SHALL refuse a credential whose catalogue entry declares the `oauth2-token-set` kind, and SHALL decide that on the kind rather than on the entry's `inject_only` flag. A refusal SHALL return the same "call `request()` instead" signal every other proxied credential returns, so a caller learns nothing about why. The stored document for this kind is a whole token set including a long-lived refresh token, and handing it to an app is the case ADR-064 decision #8 closes. + +`@e2e exclude in-process resolution path with no HTTP route; asserted by PHPUnit` + +#### Scenario: A token set marked inject-only is still refused + +- **WHEN** an `oauth2-token-set` entry also carries `inject_only: true` +- **THEN** `resolveInjectable()` resolves nothing and `request()` denies as well, so the credential fails closed on both paths + +#### Scenario: An ordinary inject-only secret is unaffected + +- **WHEN** `resolveInjectable()` is called for an entry with no `kind` +- **THEN** it behaves exactly as before, so an OpenConnector source still receives its client secret diff --git a/openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md b/openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md new file mode 100644 index 0000000000..42ea3f5f46 --- /dev/null +++ b/openspec/changes/credential-oauth2-token-set/specs/credential-oauth2-token-set/spec.md @@ -0,0 +1,179 @@ +## Purpose + +Defines the `oauth2-token-set` credential kind: a brokered credential whose stored secret is a complete OAuth2 token set rather than a single opaque string, which the broker refreshes and rotates on the caller's behalf so that no consuming app ever holds an access or refresh token. + +## ADDED Requirements + +### Requirement: A credential kind is declared by the provider catalogue, never by the credential object + +The provider catalogue entry SHALL declare its credential kind in a `kind` field with the value `secret` or `oauth2-token-set`. An entry with no `kind` SHALL be treated as `secret`, which is the behaviour every entry has today. The credential object SHALL mirror the resolved kind in a non-secret `kind` property for display and filtering, and the broker SHALL take the catalogue's value as authoritative whenever the two disagree. + +`@e2e exclude catalogue parsing has no UI surface; asserted by PHPUnit on ProviderCatalogue and CredentialBrokerService` + +#### Scenario: An entry with no kind keeps the single-secret behaviour + +- **WHEN** a brokered call runs against a catalogue entry that declares no `kind` +- **THEN** the broker injects the stored secret into the entry's `authScheme` template exactly as before +- **AND** no refresh, lock, or token-set decoding is attempted + +#### Scenario: The catalogue wins over a stale object property + +- **WHEN** a credential object records `kind: "secret"` but its provider entry declares `kind: "oauth2-token-set"` +- **THEN** the broker treats the credential as an OAuth2 token set +- **AND** the object's stale value is never used to select an injection path + +### Requirement: An OAuth2 token set is stored as one opaque secret in the custody leaf + +The secret for an `oauth2-token-set` credential SHALL be a single serialised document holding the access token, the refresh token when one was issued, the absolute expiry, the token type, the granted scopes, the provider account identity (`id`, `handle`, `displayName`) and the raw token response as returned by the provider. It SHALL be written to and read from `CredentialStore` as one value under the credential's UUID and its scope. No part of it SHALL be persisted to the credential object, written to a log line, returned by any API response, or included in an exception message. + +`@e2e exclude custody-leaf storage has no UI surface; asserted by PHPUnit including a no-token-leaks assertion over object payloads and log records` + +#### Scenario: Minting a token set writes nothing token-bearing to the object + +- **WHEN** an `oauth2-token-set` credential is minted +- **THEN** the credential object carries only `kind`, `status`, `scopes`, `account`, `expiresAt` and the other declared non-secret metadata +- **AND** neither the access token nor the refresh token appears anywhere in the object's serialised form + +#### Scenario: A malformed stored payload fails closed + +- **WHEN** the stored secret for an `oauth2-token-set` credential cannot be decoded into a token set +- **THEN** the broker denies the call +- **AND** the denial message names the decode failure without quoting any part of the stored value + +### Requirement: Non-secret connection metadata lives on the credential object + +The `brokeredcredential` schema SHALL carry `kind`, `status`, `scopes`, `account`, `expiresAt`, `lastRefreshedAt`, `lastError`, `instanceBaseUrl`, `clientId` and `clientCredentialRef`. `status` SHALL be one of `pending`, `active`, `expired`, `relink_needed` or `disabled`. None of these properties SHALL ever hold a token, a client secret, or an authorization code. + +`@e2e exclude schema declaration; asserted by the register JSON validator and PHPUnit` + +#### Scenario: Status vocabulary is closed + +- **WHEN** a credential is written with a `status` outside the declared enumeration +- **THEN** the schema rejects the value + +#### Scenario: A client secret is referenced, never stored + +- **WHEN** a tenant supplies its own OAuth2 client for a provider +- **THEN** the client identifier is stored on the object as `clientId` +- **AND** the client secret is stored as a separate brokered credential whose UUID is recorded in `clientCredentialRef` + +### Requirement: A per-account host is pinned at mint and immutable afterwards + +A catalogue entry MAY declare `baseUrlFrom: "instanceBaseUrl"` instead of a fixed `baseUrl`, for providers whose API host belongs to the connected account rather than to the provider. For such an entry the broker SHALL resolve the host from the credential's `instanceBaseUrl`, and SHALL accept it only when it is an absolute `https` URL with no userinfo, no query and no fragment, whose host is a registrable domain name rather than an IP literal, a loopback name, or a private-network address. The value SHALL be recorded on the credential at mint time and SHALL NOT be changeable afterwards; a later write that changes it SHALL be rejected. Every allow-rule still applies, so the host varies per credential while the permitted method and path set does not. + +`@e2e exclude host binding is a broker guard with no UI surface; asserted by PHPUnit including rejection cases` + +#### Scenario: A credential is locked to the host it was minted with + +- **WHEN** a Mastodon credential minted against `https://mastodon.example` is used +- **THEN** the broker resolves the outbound URL against that host only +- **AND** a request that would resolve to any other host is denied + +#### Scenario: An unsafe instance host is refused at mint + +- **WHEN** a mint supplies `instanceBaseUrl` of `http://127.0.0.1:8080` or `https://192.168.1.10` +- **THEN** the mint is refused +- **AND** no credential object is created + +#### Scenario: The pinned host cannot be edited later + +- **WHEN** an update changes an existing credential's `instanceBaseUrl` +- **THEN** the update is rejected and the stored value is unchanged + +### Requirement: A brokered call refreshes an expiring token set before it is used + +For an `oauth2-token-set` credential the broker SHALL compare the stored `expiresAt` against the current time plus a refresh margin. When the margin has been crossed and a refresh token is present, the broker SHALL refresh before injecting. The header injected on every call SHALL be `Authorization: Bearer `, taking the access token from the token set in force after any refresh. + +`@e2e exclude refresh-on-read is broker-internal; asserted by PHPUnit against a mock token endpoint` + +#### Scenario: A token still inside its margin is used unchanged + +- **WHEN** a brokered call runs against a token set whose expiry is further away than the margin +- **THEN** no token request is made +- **AND** the stored access token is injected as a bearer token + +#### Scenario: A token past its margin is refreshed first + +- **WHEN** a brokered call runs against a token set whose expiry is inside the margin +- **THEN** the broker exchanges the refresh token at the provider's token endpoint before the outbound call +- **AND** the outbound call carries the newly issued access token + +### Requirement: A refresh runs under a per-credential lock and rotates atomically + +A refresh SHALL be performed while holding a lock keyed by the credential UUID. A caller that cannot take the lock SHALL wait for the holder to finish and then re-read the stored token set rather than starting a second refresh. The rotated token set SHALL be written to the custody leaf in one `put`, carrying the new access token, the new expiry, and the new refresh token when the provider issued one or the previous refresh token when it did not. The object's `expiresAt`, `lastRefreshedAt`, `scopes` and `status` SHALL be updated only after the custody write succeeded, so a failed write never leaves the object claiming a token the leaf does not hold. + +`@e2e exclude lock and rotation are broker-internal; asserted by PHPUnit including a contention case` + +#### Scenario: Two concurrent calls perform one refresh + +- **WHEN** two brokered calls on the same credential both find the token past its margin +- **THEN** exactly one token request is made to the provider +- **AND** the second call uses the token set the first one stored + +#### Scenario: A rotation that omits a new refresh token keeps the old one + +- **WHEN** a provider's refresh response carries an access token but no `refresh_token` +- **THEN** the stored token set keeps the refresh token it already held +- **AND** the credential stays refreshable on the next cycle + +#### Scenario: A failed custody write leaves the object untouched + +- **WHEN** the custody leaf rejects the rotated token set +- **THEN** the credential object's `expiresAt` and `lastRefreshedAt` are unchanged +- **AND** the call fails rather than reporting success + +### Requirement: An invalid grant moves the credential to relink needed and fails closed + +When a refresh is rejected by the provider with the OAuth2 error `invalid_grant`, the broker SHALL set the credential's `status` to `relink_needed`, record a secret-free `lastError`, emit `CredentialRelinkRequiredEvent`, and send a notification to the credential's owner. The refresh token SHALL NOT be deleted, and the credential object SHALL NOT be deleted, so re-authorisation can restore the same credential id. Every subsequent brokered call on a `relink_needed` or `disabled` credential SHALL fail closed with a typed relink exception before any outbound request is made. + +`@e2e exclude relink lifecycle is broker-internal; asserted by PHPUnit on the event, the notification and the fail-closed path` + +#### Scenario: A revoked grant is reported once and fails closed thereafter + +- **WHEN** a refresh returns `invalid_grant` +- **THEN** the credential's status becomes `relink_needed` and its owner is notified +- **AND** a later brokered call on the same credential is refused without contacting the provider + +#### Scenario: A transient refresh failure does not relink + +- **WHEN** a refresh fails with a network timeout or an HTTP 503 +- **THEN** the credential keeps its `active` status +- **AND** the failure is reported to the caller as an upstream failure + +#### Scenario: No token value reaches the notification or the event + +- **WHEN** the relink notification and event are produced +- **THEN** neither carries an access token, a refresh token, or a client secret + +### Requirement: A daily job refreshes active token sets before they expire + +A `TimedJob` SHALL run once per day and refresh every credential whose kind is `oauth2-token-set`, whose status is `active`, and whose `expiresAt` falls inside the job's refresh window. It SHALL take the same per-credential lock as the read path, SHALL continue past a failure on one credential, and SHALL apply the same `invalid_grant` handling as the read path. + +`@e2e exclude background job; asserted by PHPUnit on the job and observable with occ in dev` + +#### Scenario: An expiring token set is refreshed ahead of use + +- **WHEN** the daily job runs and a credential expires inside the window +- **THEN** the credential's token set is refreshed and its `lastRefreshedAt` moves forward + +#### Scenario: One bad credential does not stop the sweep + +- **WHEN** one credential's refresh throws +- **THEN** the job records the failure and continues with the remaining credentials + +#### Scenario: Credentials outside the window are left alone + +- **WHEN** the job runs and a credential expires beyond the window +- **THEN** no token request is made for it + +### Requirement: Token-set credentials are scoped by who the account belongs to + +A token set for a person's own account SHALL be minted with `personal` scope. A token set for an organisation's account, such as a company page or a shared Search Console property, SHALL be minted with `organisation` scope so that no individual owns a shared connection, per ADR-064 rule 4. The scope selects the custody-leaf owner for both the initial write and every rotation. + +`@e2e exclude scope selection follows the existing broker guard chain; asserted by PHPUnit` + +#### Scenario: A rotation stays in the scope the credential was minted with + +- **WHEN** an organisation-scoped token set is refreshed +- **THEN** the rotated set is written under the same reserved system identity the original used +- **AND** it is never rewritten under the refreshing user diff --git a/openspec/changes/credential-oauth2-token-set/tasks.md b/openspec/changes/credential-oauth2-token-set/tasks.md new file mode 100644 index 0000000000..9e249e8528 --- /dev/null +++ b/openspec/changes/credential-oauth2-token-set/tasks.md @@ -0,0 +1,33 @@ +## 1. Catalogue and schema + +- [x] 1.1 Add `kind`, `oauth2` and `baseUrlFrom` support to `ProviderCatalogue` consumers and add the six provider entries (linkedin, meta-graph, x, bluesky, mastodon, google) to `lib/Settings/credential-providers.json`, bumping `version` and recording in a new `$oauth2Comment`, beside the standing `$fleetComment` rather than rewriting its history, that a token exchange is now possible for the `oauth2-token-set` kind; verify with `npm run check:json-strict` and `ProviderCatalogueTest` +- [x] 1.2 Add the ten non-secret properties and the `status` enumeration to `brokeredcredential` in `lib/Settings/credential_broker_register.json`, bump its version, and add the pending Mastodon seed object from design.md; verify with `npm run check:register` + +## 2. Token set and custody + +- [x] 2.1 Add `OAuth2TokenSet` as an immutable value object with named constructors for a stored document and for a provider token response, and `withRefreshed()` keeping the previous refresh token when the response omits one; verify with `OAuth2TokenSetTest` +- [x] 2.2 Add `CredentialLock` over `ICacheFactory` with an atomic acquire, a TTL, and a warning log line when no distributed cache is available; verify with `CredentialLockTest` including the contention case + +## 3. Refresh on the read path + +- [x] 3.1 Add `OAuth2RefreshService` performing the refresh grant against the catalogue token endpoint, rotating through one `CredentialStore::put()` and updating the object only after the custody write succeeded; verify with `OAuth2RefreshServiceTest` against a mocked client +- [x] 3.2 Add `CredentialRelinkRequiredException`, `CredentialRelinkRequiredEvent`, the owner notification and its arm in `Notification\Notifier::prepare()` without which Nextcloud drops the notification unseen, fired only on `invalid_grant`; verify a transient failure leaves the status `active` in `OAuth2RefreshServiceTest` +- [x] 3.3 Replace the direct secret read in `CredentialBrokerService::request()` with a kind-aware `injectionSecret()` hook and add per-credential host resolution for `baseUrlFrom` entries; verify guard ordering and the `secret`-kind regression in `CredentialBrokerOAuth2Test` +- [x] 3.4 Reject a `relink_needed` or `disabled` credential before any outbound request, map the typed exception to HTTP 409 in `CredentialController`, and refuse the kind in `resolveInjectable()` on the kind rather than on `inject_only`; verify with `CredentialControllerTest` and `CredentialBrokerOAuth2Test` +- [x] 3.5 Validate and pin `instanceBaseUrl` at mint and reject a later change in `CredentialController::update()`; verify the loopback, private-range and mutation rejections in `CredentialOAuth2MintTest` + +## 4. Sweep and wiring + +- [x] 4.1 Add `lib/BackgroundJob/OAuth2TokenRefreshJob.php` as a `TimedJob` on a daily interval refreshing active token sets inside the window, continuing past a per-credential failure, and register it in `appinfo/info.xml`; verify with `OAuth2TokenRefreshJobTest` +- [x] 4.2 Assert no token value reaches an object payload, a log record, an event or a notification; verify with `OAuth2NoTokenLeakTest` + +## 5. Quality + +- [x] 5.1 Run `composer check:strict`, `npm run format`, `npm run lint` and the hydra gates with `HYDRA_GATE_BASE_REF=origin/development`, and fix every finding in a touched file; verify by exit code + +### Acceptance criteria + +- An existing credential with no catalogue `kind` takes an unchanged code path, asserted by a regression test rather than by inspection. +- No access token, refresh token or client secret appears in a credential object, a log line, an event payload or an API response. +- Two concurrent brokered calls on one expiring credential perform exactly one token exchange. +- A refresh answered with `invalid_grant` flips the status once, notifies the owner once, and fails every later call closed. 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/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-approval-consolidation/tasks.md b/openspec/changes/flow-approval-consolidation/tasks.md index 8c06a79255..d2491a1ab6 100644 --- a/openspec/changes/flow-approval-consolidation/tasks.md +++ b/openspec/changes/flow-approval-consolidation/tasks.md @@ -2,7 +2,7 @@ ## 1. Storage -- [ ] 1.1 Migration: create `openregister_task_sequences` (`uuid`, +- [x] 1.1 Migration: create `openregister_task_sequences` (`uuid`, `template_id`, `template_version`, `template_snapshot`, `anchor_object_uuid`, `register_id`, `schema_id`, `chain_key`, `requester_id`, `resolved_tier`, `position_cursor`, `status`, `outcome`, @@ -13,7 +13,7 @@ `openregister_approval_steps`; add `correlation_key` to `openregister_flow_runs` with an index on `(status, correlation_key)`. No table is dropped. -- [ ] 1.2 `lib/Db/TaskSequence.php` + `TaskSequenceMapper` (find by uuid, find +- [x] 1.2 `lib/Db/TaskSequence.php` + `TaskSequenceMapper` (find by uuid, find the running sequence for an anchor+template, list an anchor's sequences newest-first, list positions in ordinal order). Ordinals are stable and unique within a sequence — enforced by a unique index, not by the @@ -22,7 +22,7 @@ ## 2. The sequence -- [ ] 2.1 `lib/Service/Task/TaskSequenceService.php`: provision (create every +- [x] 2.1 `lib/Service/Task/TaskSequenceService.php`: provision (create every position, enable only the first, freeze `template_snapshot` and `resolved_tier`), advance (enable the next position in the SAME request as the completing decision — the behaviour at @@ -31,25 +31,25 @@ rejecting position), and terminate. Each verb goes through `TaskService`'s authorized lifecycle verbs; none writes a task row directly. -- [ ] 2.2 Sequence authorization: separation of duties resolved from the +- [x] 2.2 Sequence authorization: separation of duties resolved from the chain's declarative entry, defaulting to ON when the entry exists (`ApprovalService.php:371-397`), evaluated against the acting identity AND the `on_behalf_of` identity, refused BEFORE the performer check so the reason is honest (`ApprovalService.php:165-168`); rejecting outcomes require a comment. -- [ ] 2.3 `lib/Event/TaskSequenceCompletedEvent.php`, dispatched at the moment +- [x] 2.3 `lib/Event/TaskSequenceCompletedEvent.php`, dispatched at the moment the retired `ApprovalStepCompletedEvent` was — the final position completing with an approving outcome — carrying sequence, final task, decider and resolved approving status. ## 3. The declarative surface, re-pointed -- [ ] 3.1 `ApprovalChainAnnotationInstaller` compiles +- [x] 3.1 `ApprovalChainAnnotationInstaller` compiles `x-openregister-approval-chains` into a task TEMPLATE instead of an `ApprovalChain` row (`:134-175`), idempotent so a re-save produces no second template and no new template version. The annotation's declared shape and its vocabulary registration are unchanged. -- [ ] 3.2 `ApprovalChainGateListener` asks the SEQUENCE whether the approval is +- [x] 3.2 `ApprovalChainGateListener` asks the SEQUENCE whether the approval is complete instead of scanning step rows (`:225-244`); keeps `approval-chain-pending` and `approval-chain-misconfigured` verbatim; keeps failing closed on an unprovisionable chain (`:200-206`); CLOSES a @@ -57,14 +57,14 @@ (`:232`); freezes the amount tier at provisioning (`resolveStepsOverride()`, `:277-300`) so a mid-cycle amount edit cannot re-route a running approval. -- [ ] 3.3 `ApprovalChainAdvanceListener` subscribes to +- [x] 3.3 `ApprovalChainAdvanceListener` subscribes to `TaskSequenceCompletedEvent`; the `onApprove: advanceTransition` lookup and the fail-soft `TransitionEngine::transition()` call (`:110-121`) are otherwise unchanged. ## 4. Retirement -- [ ] 4.1 Delete `lib/Db/ApprovalChain.php`, `ApprovalChainMapper.php`, +- [x] 4.1 Delete `lib/Db/ApprovalChain.php`, `ApprovalChainMapper.php`, `ApprovalStep.php`, `ApprovalStepMapper.php`, `lib/Service/ApprovalService.php`, `lib/Controller/ApprovalController.php`, the four @@ -72,18 +72,18 @@ `appinfo/routes.php:1231-1240`. No facade, alias or deprecation shim. Assert the removal left no orphan route entry, service registration or listener registration. -- [ ] 4.2 Delete `src/components/workflow/ApprovalChainPanel.vue` and +- [x] 4.2 Delete `src/components/workflow/ApprovalChainPanel.vue` and `ApprovalStepList.vue`; `src/views/schemas/SchemaWorkflowTab.vue:42` mounts the task-sequence panel instead. Deciding happens in the task inbox, not in a schema tab. -- [ ] 4.3 Publish the event replacement mapping as migration documentation: +- [x] 4.3 Publish the event replacement mapping as migration documentation: per retired event, the replacement, the replacement for EVERY field it carried, the ordering guarantee, and any field with no replacement named as such. Referenced by `filinq: migrate-signing-to-or-tasks`. ## 5. Correlation -- [ ] 5.1 `correlationKey` added to `AwaitSignalNode::configKeys()` +- [x] 5.1 `correlationKey` added to `AwaitSignalNode::configKeys()` (`lib/Service/Flow/Nodes/AwaitSignalNode.php:180`) and its config form, resolved from items/context and written to the run's indexed `correlation_key` at suspension; one new route beside @@ -94,7 +94,7 @@ ## 6. Data migration -- [ ] 6.1 Repair step: chains → templates; each `(chain_id, object_uuid)` → +- [x] 6.1 Repair step: chains → templates; each `(chain_id, object_uuid)` → one sequence; each step → a task at its `stepOrder` with `role` as candidate group and performer type `group`, `requesterId` as sequence requester, `created` preserved, `pending` → enabled, `waiting` → @@ -102,14 +102,14 @@ migrated task-audit entry attributed to the ORIGINAL decider with the original comment and time. Reconciliation columns written on both sides; every stage guarded on them so a second run changes nothing. -- [ ] 6.2 In-migration verification that FAILS LOUDLY: every non-terminal step +- [x] 6.2 In-migration verification that FAILS LOUDLY: every non-terminal step has exactly one non-terminal task; every chain has exactly one template; no (object, template) has two running sequences; every migrated task's ordinal equals its step's `stepOrder`; enabled-task count equals the count of steps that were `pending`. Any mismatch names chain, object and step and stops the migration. The cutover timestamp is recorded where an operator will find it. -- [ ] 6.3 Reverse repair step for the post-cutover rollback path: write +- [x] 6.3 Reverse repair step for the post-cutover rollback path: write migrated tasks' decisions back onto their originating step rows for the fields the legacy schema can express, and REPORT the fields it cannot (performer type, on-behalf-of, mandate, per-entry audit). Dropping the @@ -118,7 +118,7 @@ ## 7. Contracts -- [ ] 7.1 openconnector HITL retirement inventory as a checked-in fixture with +- [x] 7.1 openconnector HITL retirement inventory as a checked-in fixture with a test asserting it covers every property of `../openconnector/lib/Settings/register.d/hitl-approval-rule-action.json`: approver group, requester, comment, `expiresAt` + `onTimeout` (to @@ -126,7 +126,7 @@ outcome, with `skip` given the behaviour its enum always promised and `../openconnector/lib/Service/ApprovalService.php:662` never gave it), and `consumedAt`. A property with no named home fails the test. -- [ ] 7.2 The leaf migration contract as the spec's enforceable rules, handed +- [x] 7.2 The leaf migration contract as the spec's enforceable rules, handed to the hydra-gates anti-pattern gate: no home-grown step engine, no own approver-group resolution, no stored `overdue`, no schema mirroring flow-definition or task fields, and a hard finding for any call to a @@ -137,14 +137,14 @@ ## 8. Tests -- [ ] 8.1 Sequence, gate and correlation tests: one position enabled at a +- [x] 8.1 Sequence, gate and correlation tests: one position enabled at a time; advance inside the completing request; rejection terminating every remaining task; rejection without a comment refused; a delegated self-approval refused; a frozen tier surviving a mid-cycle amount edit; a rejected cycle still readable after resubmission; and the four correlation cases (hit, ambiguous, unmatched-and-not-buffered, cannot decide a user task). -- [ ] 8.2 Migration and regression: the repair over a seeded database with +- [x] 8.2 Migration and regression: the repair over a seeded database with in-flight, rejected and fully decided chains, run twice with identical results; verification failing loudly on a corrupted fixture; the reverse repair reporting what it cannot carry; and a full pass with opencatalogi 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/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/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/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/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/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-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/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/openspec/changes/flow-node-taxonomy/.openspec.yaml b/openspec/changes/flow-node-taxonomy/.openspec.yaml new file mode 100644 index 0000000000..1a62d62b5b --- /dev/null +++ b/openspec/changes/flow-node-taxonomy/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-06 diff --git a/openspec/changes/flow-node-taxonomy/design.md b/openspec/changes/flow-node-taxonomy/design.md new file mode 100644 index 0000000000..e2a2b3ef99 --- /dev/null +++ b/openspec/changes/flow-node-taxonomy/design.md @@ -0,0 +1,70 @@ +# Design + +## D-1 · Two axes, because one cannot do both jobs + +A single field would have to be either semantic or navigational, and each +choice loses the other. + +Semantic only: the palette groups "Ask a person" with "Approve payroll" and +"Request a decision" — correct, they are all user tasks — and separates +"Ask a person" from "Wait for an answer", which are the two halves of one +authoring decision and belong side by side. + +Navigational only: the BPMN interchange has nothing to emit, and would end up +inferring an element type from a category, which is the mapping this design +exists to avoid. + +n8n reached the same conclusion from the other direction: an architectural +kind that decides how a node may be wired, and a separate palette category +that includes "Human in the loop". The categories here are ours because the +domain is ours; the kinds are BPMN's because an interchange has to speak it. + +## D-2 · Why the defaults are on the interface, not enforced + +43 of 64 nodes live in repositories this change cannot touch, on their own +release cycles. Three options existed: + +1. Require the methods. Every contributed node fatals on the next release of + openregister. Not viable. +2. Guess on their behalf from the node id or class name. Cheap, and produces + a *wrong* BPMN element type that nobody will ever revisit, because it + looks answered. +3. Default them, visibly, and let each owner declare. + +Option 3 is the only one where being undeclared is distinguishable from being +declared. `other` in the palette is a prompt; a guessed `serviceTask` is a +lie with a long half-life. + +The gate then keeps this repository honest without reaching into anyone +else's. + +## D-3 · `receiveTask` is in the `human` category on purpose + +The most tempting mistake in the category axis is to derive it from the kind. +`openregister.await-signal` is a `receiveTask` — it waits for a system to +call back — and by kind it belongs with the integration steps. + +But its docblock already says what it is for: it is the machine-to-machine +half of a pair whose other half is "Ask a person", and the node's own +description tells authors which to pick. An author deciding between them is +looking in one place. Splitting the pair across two palette groups would undo +the only thing that currently helps them choose. + +This is the concrete case that proves the axes must be independent. + +## Traps + +**Do not let the palette order come from the registry.** Registration order +depends on which apps are installed and in what order their listeners fire, +so the same palette reorders itself when an unrelated app is enabled. Fixed +order, defined once. + +**The gate must be scoped by path, not by interface.** A gate that flags any +`IFlowNode` implementation missing the methods will fire on every contributed +node in the workspace, which is exactly the behaviour the defaults exist to +prevent. Scope it to this repository's own node directory. + +**`getKind()` describes the step, not its subject.** An object-write step is +a `serviceTask` because it calls something, not a `businessRuleTask` because +the object has rules. The first wrong classification here will be copied by +the next twenty. diff --git a/openspec/changes/flow-node-taxonomy/proposal.md b/openspec/changes/flow-node-taxonomy/proposal.md new file mode 100644 index 0000000000..40ce2924e3 --- /dev/null +++ b/openspec/changes/flow-node-taxonomy/proposal.md @@ -0,0 +1,47 @@ +--- +kind: code +depends_on: [] +--- + +## Why + +The node catalog on a live instance serves **64 step types as one flat list**: 21 from openregister, 18 from dossiq, 10 from openconnector, 4 from humaniq, 3 from hermiq, 2 from pipelinq. An author opening the palette to add a step is shown all of them, in whatever order the registry happened to collect them, with no grouping and nothing to say that "Wait for an answer" and "Ask a person" are two halves of one decision while "Apply a mapping" is a different kind of thing entirely. + +`IFlowNode` declares `getId`, `getDisplayName`, `getDescription`, `getIcon`, `isAvailableForScope`, `validateConfig` and `execute`. There is no notion of a kind or a category anywhere, so there is nothing for a palette to group by and nothing for an exporter to read. + +Two established taxonomies apply, and they answer different questions: + +- **n8n** types every node architecturally — trigger, action, core, cluster — *and* groups the palette separately, with categories including "Human in the loop". The two axes are independent on purpose: the architecture decides how a node may be wired, the category decides where an author finds it. +- **BPMN** already defines the semantic taxonomy this domain uses: user task, service task, script task, business rule task, send task, receive task, manual task, gateway, event. `openspec/changes/flow-bpmn-interchange` is already in this repo, and an interchange cannot emit a BPMN element type it never asked the node for. + +So the kind is not a new invention to maintain; it is a fact the BPMN work needs anyway, currently inferred nowhere. + +## What Changes + +- **`IFlowNode` gains `getKind()`**, returning a BPMN element kind: `userTask`, `serviceTask`, `scriptTask`, `businessRuleTask`, `sendTask`, `receiveTask`, `manualTask`, `gateway`, `event`, `subProcess`. +- **`IFlowNode` gains `getCategory()`**, returning a palette grouping: `triggers`, `human`, `objects`, `logic`, `messaging`, `ai`, `integrations`, `other`. +- **Both are defaulted, not required.** A node that implements neither is served as `serviceTask` / `other`, so no contributed node in any app breaks and no app is forced to release in step with this. **This is the whole reason the two are separate methods on the interface rather than a required constructor argument**: 43 of the 64 nodes are in repos this change cannot touch. +- **The catalog serves both**, and the palette groups by category with the categories in a fixed order rather than registration order. +- **openregister's own 21 nodes declare both.** The other apps' nodes are left to their owners, arriving as `other` until then, which is visibly worse than a wrong guess made on their behalf. +- **A gate refuses a new openregister node that declares neither**, so the flat list cannot quietly grow back. + +## Capabilities + +### New Capabilities +- `flow-node-taxonomy`: what a node's kind and category mean, how they default, how the catalog serves them, and which axis answers which question. + +## Impact + +| Area | Change | +|---|---| +| `lib/Service/Flow/IFlowNode.php` | two methods, both defaulted | +| `lib/Service/Flow/Nodes/*.php` | 21 nodes declare kind and category | +| `lib/Service/Flow/FlowNodeRegistry.php` | serves both, applies the defaults | +| `lib/Controller/FlowController.php` | node catalog response | +| nextcloud-vue palette | groups by category, fixed order | +| `.github/hydra-gates` | a gate for an openregister node declaring neither | + +## Out of scope, deliberately + +- **Assigning kinds to the 43 nodes owned by other apps.** Each app knows whether its step is a service task or a send task; guessing on their behalf would put a wrong answer into a BPMN export, which is worse than an honest `other`. +- **Emitting BPMN.** That is `flow-bpmn-interchange`. This change gives it the fact it needs; it does not do its job. diff --git a/openspec/changes/flow-node-taxonomy/specs/flow-node-taxonomy/spec.md b/openspec/changes/flow-node-taxonomy/specs/flow-node-taxonomy/spec.md new file mode 100644 index 0000000000..b33b044e31 --- /dev/null +++ b/openspec/changes/flow-node-taxonomy/specs/flow-node-taxonomy/spec.md @@ -0,0 +1,108 @@ +## Purpose + +Two independent facts about a step type: what kind of thing it is, and where +an author should find it. The first is semantic and is what a BPMN export +needs; the second is a grouping and is what a palette of 64 entries needs. + +## ADDED Requirements + +### Requirement: A node declares a semantic kind, drawn from BPMN + +A step type SHALL be able to declare a kind, and the kind SHALL be one of the +BPMN element types: `userTask`, `serviceTask`, `scriptTask`, +`businessRuleTask`, `sendTask`, `receiveTask`, `manualTask`, `gateway`, +`event`, `subProcess`. + +The vocabulary SHALL NOT be extended with terms of our own. Its value is that +it is the vocabulary an interchange already has to emit; a local synonym +would have to be mapped back, and the mapping would be the thing that rots. + +The kind SHALL describe what the step IS, not what it is about. A step that +calls an external system is a `serviceTask` whether it calls a payment +provider or a case register. + +#### Scenario: The catalog reports a kind for a node that declares one + +- **WHEN** the node catalog is read +- **THEN** the entry for `openregister.user-task` MUST report the kind + `userTask` +- **AND** the entry for `openregister.switch` MUST report `gateway` + +--- + +### Requirement: A node declares a palette category, independent of its kind + +A step type SHALL be able to declare a category, and the category SHALL be +one of `triggers`, `human`, `objects`, `logic`, `messaging`, `ai`, +`integrations`, `other`. + +The category SHALL be independent of the kind. The two answer different +questions and a node may pair them freely: "Ask a person" is `userTask` and +`human`; "Send an email" is `sendTask` and `messaging`; "Wait for an answer" +is `receiveTask` and also `human`, because an author looking for the other +half of an approval looks where approvals are. + +The palette SHALL present categories in a fixed order and SHALL NOT order +them by registration. Registration order is an accident of which apps are +installed, so the same instance can present the palette differently after an +app is enabled. + +#### Scenario: Two nodes of one kind sit in different categories + +- **GIVEN** `openregister.send-email` and `openregister.send-notification`, + both `sendTask` +- **WHEN** the palette is grouped +- **THEN** both MUST appear under `messaging` + +#### Scenario: Two nodes of one category have different kinds + +- **GIVEN** `openregister.user-task` (`userTask`) and + `openregister.await-signal` (`receiveTask`) +- **WHEN** the palette is grouped +- **THEN** both MUST appear under `human` +- **AND** each MUST keep its own kind + +--- + +### Requirement: Both are optional, and a node that declares neither still works + +A step type that declares no kind SHALL be served as `serviceTask`. A step +type that declares no category SHALL be served as `other`. + +A node SHALL NOT be refused registration, hidden from the palette, or +excluded from the catalog for declaring neither. + +This is load-bearing rather than lenient. Of the 64 step types on a measured +instance, 43 are contributed by apps in other repositories, on their own +release cycles. A required declaration would mean either breaking those apps +or guessing their semantics for them, and a guessed `serviceTask` written +into a BPMN export is a wrong answer wearing the appearance of a right one. +An honest `other` in the palette is visible and gets fixed. + +#### Scenario: A contributed node with no declaration is still offered + +- **GIVEN** an app contributing a node that implements neither method +- **WHEN** the catalog is read +- **THEN** the node MUST appear +- **AND** it MUST report `serviceTask` and `other` + +--- + +### Requirement: A new node in this repository must declare both + +A gate SHALL refuse a step type defined in this repository that declares +neither a kind nor a category. + +The gate SHALL apply only to this repository's own nodes. It SHALL NOT +inspect nodes contributed by other apps, whose defaults are deliberate. + +Without the gate the flat list grows back one node at a time, and each +addition is individually too small to argue with. + +#### Scenario: The gate refuses an undeclared node here and ignores one elsewhere + +- **GIVEN** a new step type added under this repository's node directory with + no kind and no category +- **WHEN** the gate runs +- **THEN** it MUST fail, naming the node +- **AND** a node in another app's repository MUST NOT be reported diff --git a/openspec/changes/flow-node-taxonomy/tasks.md b/openspec/changes/flow-node-taxonomy/tasks.md new file mode 100644 index 0000000000..1060b884bd --- /dev/null +++ b/openspec/changes/flow-node-taxonomy/tasks.md @@ -0,0 +1,33 @@ +# Tasks + +## 1. The interface + +- [ ] 1.1 `getKind()` and `getCategory()` on `IFlowNode`, both defaulted — `serviceTask` and `other`. +- [ ] 1.2 Enumerate both vocabularies as constants so a typo is a fatal, not a silent `other`. +- [ ] 1.3 A test that a node implementing neither is still registered, still in the catalog, and reports the defaults. + +## 2. Declare this repository's 21 nodes + +- [ ] 2.1 Triggers: `trigger-object`, `trigger-manual`, `trigger-schedule` → `event` / `triggers`. +- [ ] 2.2 Human: `user-task` → `userTask` / `human`; `portal-task` → `userTask` / `human`; `await-signal` → `receiveTask` / `human` (D-3). +- [ ] 2.3 Objects: `object-read`, `object-write`, `lock-object`, `unlock-object`, `batch` → `serviceTask` / `objects`. +- [ ] 2.4 Logic: `switch`, `route`, `filter`, `iterate`, `explode`, `merge`, `map`, `set-fields`, `flow-state`, `wait`, `end`, `sub-flow` → `gateway` or `scriptTask` or `subProcess` as each actually is; `decision-table` → `businessRuleTask` / `logic`. +- [ ] 2.5 Messaging: `send-email`, `send-notification`, `send-talk-message` → `sendTask` / `messaging`. +- [ ] 2.6 Review every assignment against the trap: the kind describes the step, not its subject. + +## 3. Catalog and palette + +- [ ] 3.1 The registry applies the defaults; the catalog serves both fields. +- [ ] 3.2 The palette groups by category in a fixed order, never registration order. +- [ ] 3.3 nc-vue: grouped palette, jest spec seen RED first, en/nl for the category labels with the `writing` skill loaded first. + +## 4. The gate + +- [ ] 4.1 A hydra gate refusing a node in THIS repository's node directory that declares neither. Scoped by path (D-3 trap), never by interface. +- [ ] 4.2 An acceptance fixture with a planted undeclared node, so the gate is proven to refuse rather than assumed to. + +## 5. Proof + +- [ ] 5.1 Playwright: the catalog reports a kind and a category for every openregister node, and the palette renders grouped. +- [ ] 5.2 Assert a contributed node with no declaration still appears, under `other`. +- [ ] 5.3 `composer check:strict`, both l10n gates, full unit suite. Exit code, not summary line. 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/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..bb6ea52ae0 --- /dev/null +++ b/openspec/changes/flow-portal-task/tasks.md @@ -0,0 +1,144 @@ +# Tasks: flow-portal-task + +## 1. The node + +- [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`. +- [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`. +- [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 + +- [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. +- [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 + +- [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. +- [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 + +- [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). +- [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 + +- [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. +- [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. +- [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 + +- [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) + +- [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. +- [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 + +- [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 + 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 + +- [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`. +- [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. +- [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. + +## 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). diff --git a/openspec/changes/flow-run-subjects-and-answers/.openspec.yaml b/openspec/changes/flow-run-subjects-and-answers/.openspec.yaml new file mode 100644 index 0000000000..1a62d62b5b --- /dev/null +++ b/openspec/changes/flow-run-subjects-and-answers/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-06 diff --git a/openspec/changes/flow-run-subjects-and-answers/design.md b/openspec/changes/flow-run-subjects-and-answers/design.md new file mode 100644 index 0000000000..5171b07f5b --- /dev/null +++ b/openspec/changes/flow-run-subjects-and-answers/design.md @@ -0,0 +1,45 @@ +# Design + +## D-1 · Why a declared set rather than deriving one + +The audit trail already answers "what did this run write", and `flow-object-attribution` built the endpoint for it. It is tempting to reuse that as the run's object set and add nothing. + +It cannot serve this purpose, for a reason that only shows up in the flows that need it. A case flow reads a case it never writes; it writes a task, a document and an audit row it does not consider a subject; and it may write the same object at three steps for three different reasons. The derived list therefore contains too much and too little at once, and — decisively — it carries **no role**. `attachTo: case` needs a name, and a list of things that happened has none to give. + +So the two coexist. Derived answers the auditor. Declared answers the author. + +## D-2 · Why a role and not an index + +`attachTo: 2` would work and would be unreadable, and would break the moment a step is inserted. A role is the flow author's own word for what the object is *to this flow*, which is the thing later steps actually mean. + +Roles are per-run and unconstrained on purpose. There is no vocabulary to register and no schema to keep in step: `case`, `besluit`, `aanvraag` are all fine, and a typo is caught by the addressing rule failing loudly rather than by a registry the author has to maintain. + +## D-3 · Replacement is recorded, not refused + +Re-pointing a role is legitimate: a flow that supersedes a draft decision with a final one genuinely means `decision` to be the new object. Refusing would force authors into `decision2`. + +But it is also exactly how a later step silently attaches to the wrong record. So it is allowed and logged. The log entry is what makes the eventual "why is this task on the old decision" answerable in under a minute. + +## D-4 · Attaching through the task's existing object fields + +`Task` already has `objectUuid`, `registerId` and `schemaId`, and three things already read them: the subject-anchored inbox query (which also relaxes the external-performer exclusion on that anchor), the case sidebar, and the portal visibility rule. + +Filling those from the subject entry means the attachment works everywhere immediately. A new `attachedSubjectRole` column would have needed all three taught about it, and the two that were not taught would have been found by a user. + +## D-5 · The answers fix is one line, and that is the point + +`outcomeBagFor()` gains `answers`. `PortalTaskNode` line 450 has done exactly this since it was written. + +The interesting question is why nobody noticed. Because the feature reads as complete from every angle except the last: the form is declared, refused at save if it names a field the schema lacks, rendered to the performer, validated on completion, and stored. Every one of those has a test. None of them asserts that a *following step* can see the value, so the one hop that was missing was the one hop nothing looked at. + +The test that closes it must therefore be a downstream assertion — a Switch routing on `answers.x` — not another assertion that the responses were stored. + +## Traps + +**An absent `answers` key and an empty one are different failures.** Omitting the key when a step declares no form would make every downstream expression need two guards. Always present, sometimes empty. + +**The subject set must survive suspension.** It lives on the run row, not in the resume slot: slots are per node, and a run holds one per node. A three-week approval must wake up still knowing what its case is. + +**Idempotency is not optional here.** A user-task node is re-entered on a heartbeat, by design, with the task still open. Any node that records a subject will be re-entered too, and a set that grew on each wake would report a run as working on the same case forty times. + +**Do not seed declared subjects from the audit in the migration.** The repair seeds the `trigger` entry from `subjectUuid` and nothing else. Back-filling roles from what old runs wrote would invent declarations the authors never made, and those declarations would then be addressable by `attachTo`. diff --git a/openspec/changes/flow-run-subjects-and-answers/proposal.md b/openspec/changes/flow-run-subjects-and-answers/proposal.md new file mode 100644 index 0000000000..dd29cfeb99 --- /dev/null +++ b/openspec/changes/flow-run-subjects-and-answers/proposal.md @@ -0,0 +1,49 @@ +--- +kind: code +depends_on: [] +--- + +## Why + +Two gaps, found by asking one question of a working flow: *what did the person answer, and what is this run working on?* + +**A user-task form's answers never reach the run.** `PortalTaskNode` line 450 puts them there: `$bag['answers'] = $task->getResponses()`. `FlowTaskBridge::outcomeBagFor()` — the bag `UserTaskNode` uses — has no `answers` key at all. It carries the outcome, the comment, the result text and the performer fields, and nothing the performer filled in. + +So a step may declare a form (`formKind: fields`, `formFields`, `formAction`), the performer may fill it in, the values are validated against the subject schema and written to `task.responses`, and then they stop. No later step can route on them, read them, or write them anywhere. The feature exists end to end except for the last hop, and the portal node proves the hop is one line. + +**A run records one object and touches many.** `FlowRun` carries `subjectUuid`, `subjectRegister`, `subjectSchema` — the thing that triggered it. `Task` carries one `objectUuid`. `GET /api/flow-runs/{uuid}/objects` exists but is derived from the audit trail: it answers *what did this run write*, which is a different question from *what is this run working on*. + +The difference matters as soon as a step wants to attach something. A case flow creates a case, locks it, asks somebody for a go, and records the decision. The decision belongs to the person, to the run, **and to the case** — and there is no declared set for a node to point at. A node cannot say "attach this task to the case" because nothing names the case except the trigger, and by step nine the trigger may not be the object anyone means. + +## What Changes + +- **The outcome bag carries the answers.** `outcomeBagFor()` gains `answers` from `task.responses`, the way the portal node already does, so a Switch can route on what somebody filled in. **This is a bug fix, not a feature**: the values were being collected and discarded. +- **A run keeps a declared set of subjects.** `FlowRun` gains `subjects`: an ordered set of `{register, schema, uuid, role}` entries, where `role` is a short author-chosen name (`case`, `decision`, `document`). The trigger's subject is the first entry, with the role `trigger`, so a run with no declared subjects behaves exactly as it does now. +- **Nodes add to it, by declaration.** `openregister.object-write` and `openregister.lock-object` accept `subjectRole`; naming one records the object they acted on under that role. Nothing is added implicitly — an implicit set would be the audit-derived list again, and that already exists. +- **A task can attach to a subject.** `UserTaskNode` accepts `attachTo: `. The task's existing `objectUuid`/`registerId`/`schemaId` are filled from that entry, so the attachment rides fields the task row and the inbox already have. Naming a role the run has not recorded fails the step, rather than creating a task attached to nothing. +- **`GET /api/flow-runs/{uuid}` serves the declared subjects**, distinct from `/objects`, which keeps its audit-derived meaning. Two questions, two answers, neither pretending to be the other. + +## Capabilities + +### New Capabilities +- `flow-run-subjects`: what a run's declared subject set is, how a node adds to it, how a role is addressed, and how it differs from the audit-derived objects a run touched. + +### Modified Capabilities +- `flow-user-task-node`: the outcome bag carries the performer's answers; a step may attach its task to a declared subject. + +## Impact + +| Area | Change | +|---|---| +| `lib/Service/Flow/FlowTaskBridge.php` | `outcomeBagFor()` gains `answers` | +| `lib/Db/FlowRun.php` | `subjects` json column | +| `lib/Migration/` | the column, plus a repair seeding the trigger entry on open runs | +| `lib/Service/Flow/FlowRunSubjects.php` | new — reading, adding, addressing by role | +| `lib/Service/Flow/Nodes/ObjectWriteNode.php`, `LockObjectNode.php` | optional `subjectRole` | +| `lib/Service/Flow/Nodes/UserTaskNode.php` | optional `attachTo` | +| `lib/Controller/FlowRunController.php` | subjects on the run read | + +## Out of scope, deliberately + +- **Typed performers and the assignee picker** — `flow-typed-principals`, a different surface. +- **Changing what `/objects` means.** It is audit-derived and should stay so. A run's declared subjects and the objects it wrote are different facts, and collapsing them would lose the one an auditor needs. diff --git a/openspec/changes/flow-run-subjects-and-answers/specs/flow-run-subjects/spec.md b/openspec/changes/flow-run-subjects-and-answers/specs/flow-run-subjects/spec.md new file mode 100644 index 0000000000..4de083edb6 --- /dev/null +++ b/openspec/changes/flow-run-subjects-and-answers/specs/flow-run-subjects/spec.md @@ -0,0 +1,108 @@ +## Purpose + +The objects a run is deliberately working with, named by role, so a later step +can point at one. Distinct from the objects a run has written, which the audit +trail already answers. + +## ADDED Requirements + +### Requirement: A run keeps an ordered set of declared subjects + +A flow run SHALL carry a set of subject entries. Each entry SHALL name a +`register`, a `schema`, a `uuid`, and a `role`: a short name chosen by the +flow's author, such as `case` or `decision`. + +A role SHALL be unique within a run. Recording a second object under a role +already held SHALL replace the entry, and the replacement SHALL be recorded in +the run's log — a flow that quietly re-points `case` halfway through is a flow +whose later steps attach to something the author did not mean. + +The run's triggering object, where it has one, SHALL be the first entry, with +the role `trigger`. A run that declares nothing else therefore holds exactly +the fact `subjectUuid` already holds, and behaves as it does today. + +The set SHALL be persisted with the run and SHALL survive suspension and +resumption. A run parked on a human task for three weeks must still know what +it is working on. + +#### Scenario: A run with no declarations holds only its trigger + +- **GIVEN** a flow with no node declaring a subject role +- **WHEN** it runs from an object trigger +- **THEN** the run's subjects MUST hold exactly one entry, with role `trigger` +- **AND** it MUST name the triggering object + +#### Scenario: Re-pointing a role replaces and is recorded + +- **GIVEN** a run that has recorded object A under the role `case` +- **WHEN** a later step records object B under `case` +- **THEN** the entry MUST name B +- **AND** the run's log MUST record the replacement + +--- + +### Requirement: A node adds a subject only by declaring it + +A node SHALL record an object into the subject set only when its +configuration names a role for it. A node that names no role SHALL record +nothing. + +The system SHALL NOT populate the set implicitly from what nodes write. +Implicit population is the audit-derived list, which already exists and +answers a different question; a set that filled itself would answer neither +question well. + +Recording SHALL be idempotent for a node that fires more than once with the +same object and role, so a heartbeat wake, a retry or a resumed run does not +grow the set. + +#### Scenario: A write with no role changes nothing + +- **GIVEN** an object-write step with no `subjectRole` +- **WHEN** it writes an object +- **THEN** the run's subject set MUST be unchanged +- **AND** the audit-derived objects list MUST still show the write + +#### Scenario: A re-fired node does not duplicate an entry + +- **GIVEN** a step that recorded object A under `case` +- **WHEN** the same step fires again with the same object +- **THEN** the set MUST still hold one entry for `case` + +--- + +### Requirement: Addressing a role the run has not recorded fails the step + +When a node names a subject role in order to READ it, and the run holds no +entry under that role, the node SHALL fail the step, naming the role and +listing the roles the run does hold. + +The system SHALL NOT fall back to the trigger. A step that asked for `case` +and silently got the triggering object would attach a decision to the wrong +record, and would do so most often in exactly the flows complex enough for +the two to differ. + +#### Scenario: An unknown role fails loudly and says what is available + +- **GIVEN** a run holding subjects under `trigger` and `case` +- **WHEN** a step addresses the role `decision` +- **THEN** the step MUST fail +- **AND** the failure MUST name `decision` and list `trigger` and `case` + +--- + +### Requirement: The declared subjects and the touched objects are read separately + +The run read SHALL serve the declared subject set. The existing objects +endpoint SHALL keep its audit-derived meaning and SHALL NOT be changed to +serve declarations. + +The two answer different questions — *what is this run working on* and *what +has this run written* — and an object may appear in either, both, or neither. + +#### Scenario: An object read but never written appears only in the declarations + +- **GIVEN** a run that recorded a case under `case` and never wrote to it +- **WHEN** both reads are made +- **THEN** the declared subjects MUST include the case +- **AND** the audit-derived objects MUST NOT diff --git a/openspec/changes/flow-run-subjects-and-answers/specs/flow-user-task-node/spec.md b/openspec/changes/flow-run-subjects-and-answers/specs/flow-user-task-node/spec.md new file mode 100644 index 0000000000..93c7b044ef --- /dev/null +++ b/openspec/changes/flow-run-subjects-and-answers/specs/flow-user-task-node/spec.md @@ -0,0 +1,68 @@ +## MODIFIED Requirements + +### Requirement: The outcome is written onto every item, not only onto the run + +The outcome bag a user-task step places on its items SHALL include the +performer's form answers, under the key `answers`, taken from the task's +recorded responses. + +An empty or absent set of responses SHALL yield an empty `answers`, never a +missing key. A downstream expression reading `answers.reason` must fail the +same way whether the form was skipped or the step declared none, rather than +failing differently and teaching authors to guard for both. + +This closes a gap rather than adding a feature: a step could already declare +a form, the values were already validated against the subject schema and +already stored on the task, and the portal-task node already places them on +its bag. Only this node's bag omitted them, so every answer a person typed +was collected and then discarded. + +#### Scenario: A form answer reaches the following step + +- **GIVEN** a user-task step declaring the fields `reason` and `amount` +- **WHEN** the performer completes it, filling both +- **THEN** the items leaving the step MUST carry `answers.reason` and + `answers.amount` +- **AND** a Switch on `answers.reason` MUST be able to route on it + +#### Scenario: A step with no form yields an empty answers set + +- **GIVEN** a user-task step declaring no form +- **WHEN** it is completed +- **THEN** the bag MUST carry `answers` as an empty set, not omit it + +--- + +### Requirement: A step may attach its task to a declared subject + +A user-task step SHALL accept an `attachTo` naming a subject role of its run. +When it is given, the created task SHALL be anchored to that entry through the +task's existing object fields. + +Naming a role the run does not hold SHALL fail the step, per the subject +capability. It SHALL NOT create an unattached task: a task attached to nothing +is exactly the task an author believed was attached to the case. + +A step naming no `attachTo` SHALL create the task with no object anchor, as it +does today. + +The anchor SHALL use the task row's existing object fields rather than a new +one, so the subject-anchored inbox read, the case sidebar and the portal +visibility rule all keep working with no change. + +#### Scenario: A go is recorded against the case, the run and the person + +- **GIVEN** a run holding a case under the role `case` +- **AND** a user-task step with `attachTo: case` +- **WHEN** the step creates its task +- **THEN** the task MUST carry the run uuid, the node id, and the case's + register, schema and uuid +- **AND** the task MUST appear in the case's subject-anchored task list + +#### Scenario: Attaching to a role the run never recorded fails the step + +- **GIVEN** a run holding no `case` entry +- **AND** a step with `attachTo: case` +- **WHEN** the run reaches it +- **THEN** the step MUST fail naming the role +- **AND** no task MUST be created diff --git a/openspec/changes/flow-run-subjects-and-answers/tasks.md b/openspec/changes/flow-run-subjects-and-answers/tasks.md new file mode 100644 index 0000000000..696923a5af --- /dev/null +++ b/openspec/changes/flow-run-subjects-and-answers/tasks.md @@ -0,0 +1,38 @@ +# Tasks + +## 1. The answers fix, first and on its own + +- [ ] 1.1 A test asserting a **downstream** step can route on `answers.x`, seen RED. Not another assertion that responses were stored — D-5. +- [ ] 1.2 `FlowTaskBridge::outcomeBagFor()` gains `answers` from `task.responses`, empty set when absent, never a missing key. +- [ ] 1.3 A test for the empty case, so the two failure shapes stay one shape. +- [ ] 1.4 Check the portal node still places its own `answers` and that the two paths now agree. + +## 2. The subject set + +- [ ] 2.1 `subjects` json column on `FlowRun`, plus the entity accessors. +- [ ] 2.2 Migration for the column, and a repair seeding ONLY the `trigger` entry from `subjectUuid` on runs that have one. Nothing back-filled from the audit — see the trap. +- [ ] 2.3 Bump `` in `appinfo/info.xml` in the same commit or the repair never runs (gate-110). +- [ ] 2.4 `FlowRunSubjects`: read, record (replace-and-log on a held role), address-by-role, idempotent re-record. +- [ ] 2.5 Unit tests seen RED: trigger-only default; replacement logged; re-fire does not duplicate; unknown role fails naming the roles held. + +## 3. Nodes that record + +- [ ] 3.1 Optional `subjectRole` on `openregister.object-write` and `openregister.lock-object`. +- [ ] 3.2 No role, no recording — assert the audit-derived list still shows the write. + +## 4. Attaching a task + +- [ ] 4.1 Optional `attachTo` on `openregister.user-task`, filling the task's existing `objectUuid`/`registerId`/`schemaId` — D-4, not a new column. +- [ ] 4.2 An unheld role fails the step and creates no task. +- [ ] 4.3 Assert the attached task appears in the subject-anchored inbox read and in the case sidebar, since those are the readers the choice of fields was made for. + +## 5. Reading it back + +- [ ] 5.1 `GET /api/flow-runs/{uuid}` serves the declared subjects. +- [ ] 5.2 `/objects` is untouched. A test asserting an object read but never written appears in one and not the other. + +## 6. Proof + +- [ ] 6.1 Playwright, over the live API, the whole scene from the brief: a case is created and recorded as `case`, locked, a person is asked with `attachTo: case`, they answer with a form value, and the answer routes a Switch. Assert the task is on the case, the run and the person. +- [ ] 6.2 Playwright: `attachTo` naming an unheld role fails the step and leaves no task. +- [ ] 6.3 `composer check:strict`, both l10n gates, full unit suite. Exit code, not summary line. 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..dbdcbb71bb --- /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 + +- [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. +- [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 + before any query. + +## 2. The completed-runs read + +- [x] 2.1 `FlowRunMapper::findCompletedForSubject()` + + `countCompletedForSubject()`: `FlowRun::TERMINAL` statuses + (`lib/Db/FlowRun.php:142`), required subject uuid, organisation + predicate, newest first, capped limit. +- [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 + +- [x] 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 + +- [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. +- [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 + 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. 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/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/openspec/changes/flow-task-inbox-projections/tasks.md b/openspec/changes/flow-task-inbox-projections/tasks.md index 9d030ba17d..d1382fce61 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 @@ -106,11 +110,26 @@ ## 5. Inbox surfaces -- [ ] 5.1 `CnTasksWidget` in nextcloud-vue modelled on +- [x] 5.1 `CnTasksWidget` in nextcloud-vue modelled on `src/components/CnFlowRunsWidget/CnFlowRunsWidget.vue` (559L), reading 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. + > **Unblocked and wired (2026-09-01).** nextcloud-vue#910 merged the + > `tasks` entity source and `CnTasksWidget` (change + > cn-tasks-entity-source). OpenRegister side: the manifest declares + > `flow-task-inbox` (`/flow-tasks`, `type: "index"`, + > `entitySource: "tasks"`) plus a Tasks menu entry, the dashboard + > carries the `tasks` widget, and the per-uuid deep link + > `/flow-tasks/{uuid}` renders `FlowTaskDetail` + > (`TaskController::open()` now serves the SPA shell instead of a + > hash redirect the history-mode router never resolved). + > ⚠️ Release gate: #910 is on nextcloud-vue `development` and NOT yet + > in a published npm version (latest is 2.29.0, cut before the + > merge). The pin is `^2.29.0`; when the next promotion publishes, + > refresh the lockfile so the built bundle actually carries the + > source and the widget — until then the inbox index is empty with a + > console warning and the widget cell reads unavailable. - [ ] 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 +140,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 +158,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/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/openspec/changes/flow-typed-principals/.openspec.yaml b/openspec/changes/flow-typed-principals/.openspec.yaml new file mode 100644 index 0000000000..1a62d62b5b --- /dev/null +++ b/openspec/changes/flow-typed-principals/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-06 diff --git a/openspec/changes/flow-typed-principals/design.md b/openspec/changes/flow-typed-principals/design.md new file mode 100644 index 0000000000..fc67327f66 --- /dev/null +++ b/openspec/changes/flow-typed-principals/design.md @@ -0,0 +1,132 @@ +# Design + +## The decision behind every other decision + +A performer reference is **resolved late and re-resolved every time**, never +frozen at task creation. + +The alternative — resolve once, store the uids, authorise against those — is +simpler, faster, and wrong for the domain. A municipal approval outlives the +roster it was raised against. A task assigned to the bezwaarcommissie in +March must be answerable by whoever sits on it in June, and a task assigned +to the *afdelingshoofd* must follow the post, not the person who held it. The +frozen design gets that backwards in the direction that hurts: it keeps +authorising someone who has left and stops authorising the person now +responsible. + +The cost is a resolver call on every answer. That is one membership lookup on +a verb a human is performing by hand, which is not a budget worth optimising +against correctness. + +The resolution IS recorded, as `flow-tasks` requires — but as evidence, +consulted by no guard. Confusing those two is how a "who was asked" audit +field quietly becomes an authorisation bypass. + +## D-1 · Why an event and not an interface the apps implement directly + +Flow nodes are already contributed through `RegisterFlowNodesEvent`, and +principals take the same path for the same three reasons: OpenRegister must +not name a consuming app (gate-27, ADR-022); a type whose app is not +installed must simply be absent rather than fatal; and the registration point +is already a place the fleet's developers know to look. + +The resolver interface lives in OpenRegister and is implemented in the app +that owns the concept. decidiq knows what a position on a body is; hermiq +knows what a function is; dossiq knows what a case role is. None of that +knowledge can move into the engine without the engine growing opinions about +municipal organisation charts. + +## D-2 · Why the type is refused at save and the resolution at run + +These are two different kinds of wrongness and they are discovered by two +different people. + +An **unknown type** is a defect in the document. The author is at the +keyboard, the field is on screen, and the fix is to pick a different type. +Refusing at save puts the error in front of the only person who can act on +it. + +An **empty resolution** is a fact about the instance, and it changes. A +committee with no members today has members next week. Refusing to *save* a +flow because a group is momentarily empty would make the flow unauthorable +for a reason that has nothing to do with the flow. Refusing to *create the +task* is the right moment: that is when somebody actually needs to be found. + +The failure mode this replaces is the one that was measured — a task created +for nobody, a run suspended, a heartbeat re-reading it every few minutes, and +no signal anywhere. Silence was the defect. A loud step failure, subject to +the flow's own error policy, is strictly better even when the author chooses +to continue past it. + +## D-3 · Collapsing three candidate fields into one + +`candidateUsers`, `candidateGroups` and `candidateRole` are three fields that +differ only in the kind of thing you type into them. That is a type system +implemented as field names, and it exists only because the field was a text +box. + +With typed references, one `candidates` field expresses all three and more, +including a mix. The three keep working as input — they are read as +candidates of type `user`, `group` and `role` — so no stored flow changes +meaning and no migration is required for them. + +The one thing lost is the ability to say "these users AND these groups" as +separate, separately-meaningful lists. Nothing in the routing strategies +reads them separately, so nothing loses a capability. + +## D-4 · What the repair may and may not guess + +The repair rewrites a stored string to a typed reference by resolving it +once. Three outcomes, and only one of them may be acted on: + +| Resolves under | Action | Why | +|---|---|---| +| exactly one type | rewrite | unambiguous; the string meant that | +| more than one type | leave, report | guessing moves who may answer, silently | +| no type | leave, report | already broken; the repair says so, it does not become the breakage | + +The ambiguous case is not hypothetical. A Nextcloud instance may hold a user +and a group of the same name, and the existing `mayAnswer` accepts BOTH — uid +equality first, then group membership — so today's behaviour is genuinely the +union. Any rewrite narrows it. That is a decision for a person. + +🔴 The repair MUST NOT fail the upgrade over unresolvable strings. On the +measured instance that would have meant 27 failures on a routine `occ +upgrade`, for flows that were already broken before the repair existed. +A repair step that turns pre-existing breakage into an upgrade failure is +worse than the breakage. + +## D-5 · Why `performerType` goes away + +`Task.performerType` accepts `user | group | agent | worker` and the node +exposed it as a free-typed word. With every reference carrying its own type, +the field is a second, weaker copy of the same fact, and two sources of one +truth is how they drift. + +The **column** stays, because it is on the task row and other things read it. +It becomes derived: written from the resolved reference's type, never +authored. + +## Traps + +**A bare string must keep meaning `user`.** Reading it as "try every type" +would be friendlier and would silently widen who may answer on every existing +flow. The current guard tries uid first, so `user` is the reading that +preserves behaviour. + +**`mayAnswer` is called on a hot path and on a cold one.** It guards the +completion verb, and it is also consulted by inbox projections. A resolver +that is cheap for one group is expensive for a hundred rows. Inbox scoping +must keep predicating in the datastore rather than resolving row by row in +PHP; the resolver decides *authorisation*, not *listing*. + +**An agent is a performer, and agents are not users.** The `agent` resolver +must return something the answer guard can compare against, which means an +agent turn completes as a real identity. Whatever that identity is, it must +not be a member of the groups a human performer belongs to, or an agent step +would be answerable by the humans and vice versa. + +**Do not let the picker become the contract.** The `principal` field type +tells the editor how to draw a field. What is *valid* is decided by the +resolver registry on the server. An editor that offers only what it can search +must still accept a reference typed by an app whose search it does not know. diff --git a/openspec/changes/flow-typed-principals/proposal.md b/openspec/changes/flow-typed-principals/proposal.md new file mode 100644 index 0000000000..5f34b949b8 --- /dev/null +++ b/openspec/changes/flow-typed-principals/proposal.md @@ -0,0 +1,59 @@ +--- +kind: code +depends_on: [] +--- + +## Why + +A flow step that asks a person names that person as **a string typed into a text box**, and nothing ever checks that the string names anybody. + +This is not a theoretical gap. Measured on the demo instance, 2026-09-06: + +- 24 flows carry **30 `dossiq.askPerson` nodes**. Their assignees are role names: `Beleidsadviseur`, `juridische-dienst`, `Afdelingshoofd`, `Gemeentesecretaris`, `Griffier`, `financieel-adviseur`, `teamleider`, `burgemeester`, `bezwaarcommissie`. +- `FlowRunAssignee::mayAnswer()` accepts an answer only when the answerer's uid **equals** the assignee string, or the answerer is in a **group named exactly** that string. +- Of those 30 nodes, **3 resolved. 27 could be answered by nobody on the instance** — not even by an administrator. The run suspends, the heartbeat re-reads forever, and nothing anywhere says why. +- The same role appears under two spellings in one instance (`Afdelingshoofd` / `afdelingshoofd`, `Gemeentesecretaris` / `gemeentesecretaris`). That is what a free-text box produces. + +`validateConfig()` already refuses an *empty* assignee, with a good comment about why an unassigned task is a task anyone may complete. It cannot refuse a *non-resolving* one, because a bare string carries no type to resolve against. + +The vocabulary for fixing this is half-built already and unreachable. `Task.performerType` accepts `user`, `group`, `agent` and `worker`; `UserTaskNode` exposes `assignee`, `candidateUsers`, `candidateGroups`, `candidateRole`, `routingStrategy` and `performerType`. Every one of them is declared `'type' => 'text'` in the node's config form, so the editor draws seven text boxes and the author is asked to remember uids. Meanwhile the kinds of performer a Dutch municipality actually routes to — a **position** in a body (decidiq), a **function** (hermiq) — cannot be named at all. + +## What Changes + +- **A performer is a typed reference, not a string.** `assignee` and the candidate fields accept `{type, id}` where `type` is one of `user`, `group`, `role`, `position`, `function` or `agent`. A bare string keeps working and is read as `{type: 'user', id: }`, so no existing flow changes meaning. +- **`IPrincipalResolver`, contributed the way flow nodes already are.** OpenRegister owns `user` and `group`; a consuming app registers a resolver for its own type through a `RegisterPrincipalResolversEvent`, the same recipe as `RegisterFlowNodesEvent`. decidiq contributes `position`, hermiq contributes `function`, dossiq contributes its case `role`. OpenRegister never calls those apps directly (gate-27, ADR-022). +- **A reference that cannot resolve fails when it is authored, not when a person is needed.** `validateConfig()` refuses a reference whose *type* has no registered resolver. Task creation refuses a reference that resolves to **nobody**, loudly, instead of parking a task in the void — which is the defect above, converted from silence into a refusal the author can act on. +- **`mayAnswer()` asks the resolver.** It stops string-matching a uid and a group id, so a position or a function authorises an answer on the same terms a group does. **BREAKING for one edge case**: a task whose assignee string happens to equal a *group* name is authorised today by accident of spelling; after this it is authorised because the reference says `group`. A repair step migrates stored assignee strings to typed references by resolving them once, and reports every one it cannot. +- **A searchable picker replaces the seven text boxes.** A new `principal` field type in the node catalog, rendered by nc-vue as a multi-select that searches users and groups as you type (`shareTypes[]=0,1`) plus the types apps contribute. The existing `user` widget stays for `runAs`, which takes one uid and means something different. +- **Agent becomes a performer of this node.** `performerType: agent` gains a `prompt`, dispatched through the `AgentRunRequestedEvent` already designed in `flow-agent-action`, so an agent's answer is a task row with the same audit and the same handover to a person. `hermiq.agent-step` is untouched and stays the way to run a turn that is not a question. +- The node is renamed **"Ask a person or group"**, because it always could ask a group and never said so. + +## Capabilities + +### New Capabilities +- `flow-typed-principals`: what a typed performer reference is, how a resolver is contributed and consulted, when an unresolvable reference is refused, and how an answer is authorised through it. + +### Modified Capabilities +- `flow-user-task-node`: the assignee vocabulary becomes typed; the node declares a `principal` field type; agent gains a prompt; the display name changes. +- `flow-node-config-forms`: the catalog gains the `principal` field type and its contributed option sources. +- `flow-tasks`: `mayAnswer` is resolver-backed rather than string-matched; task creation refuses an empty resolution. + +## Impact + +| Area | Change | +|---|---| +| `lib/Service/Flow/FlowRunAssignee.php` | `mayAnswer` consults the resolver registry instead of comparing strings | +| `lib/Service/Flow/PrincipalResolver/` | new — the registry, the `user` and `group` resolvers, the reference value object | +| `lib/Service/Flow/RegisterPrincipalResolversEvent.php` | new — the contribution seam | +| `lib/Service/Flow/Nodes/UserTaskConfig.php` | parses and validates typed references; refuses an unresolvable type | +| `lib/Service/Flow/Nodes/UserTaskNode.php` | `principal` field type, agent prompt, new display name | +| `lib/Service/Task/TaskBuilder.php` | stores the resolved performers alongside the reference | +| `lib/Migration/` | repair step migrating stored assignee strings to typed references, reporting what it cannot resolve | +| nextcloud-vue `CnFlowNodeEditModal.vue` | the `principal` widget | +| decidiq / hermiq / dossiq | one resolver each, in their own repos, after this lands | + +## Out of scope, deliberately + +- **Returning a task form's answers into the run.** `FlowTaskBridge::outcomeBagFor()` has no `answers` key while `PortalTaskNode` line 450 does, so a user-task form's values die on the task row. That is a real defect and it is a different surface; it belongs with the run's declared object set in its own change. +- **Categorising the node palette.** The catalog serves 64 node types as one flat list. Also real, also separate: it touches every node in the fleet and none of this. +- **Retiring `dossiq.askPerson`.** It depends on this landing first, it lives in another repo, and per ADR-032 a change that mixes an engine refactor with a cross-app migration is the shape that burns a cycle without shipping. diff --git a/openspec/changes/flow-typed-principals/specs/flow-tasks/spec.md b/openspec/changes/flow-typed-principals/specs/flow-tasks/spec.md new file mode 100644 index 0000000000..29caf41421 --- /dev/null +++ b/openspec/changes/flow-typed-principals/specs/flow-tasks/spec.md @@ -0,0 +1,59 @@ +## MODIFIED Requirements + +### Requirement: Who may answer a task is decided by resolving its performers + +The task service SHALL decide whether a caller may answer a task by resolving +the task's performer references through the principal resolvers and asking +whether the caller is among the resulting user ids. + +It SHALL NOT decide this by string comparison against the stored assignee, +and SHALL NOT decide it by group membership inferred from the assignee's +spelling. Those two rules are the whole of the current decision, and together +they mean a performer is authorised only when somebody happened to name a +group exactly right. + +The five sanctioned visibility relationships are unchanged. Only the +resolution of *performer* changes: `requester`, `watcher`, administrator and +subject-anchored visibility all keep their present meaning. + +A task naming no performer SHALL keep whatever behaviour it has today. This +change narrows nothing and widens nothing about unassigned tasks. + +#### Scenario: A resolved performer may answer + +- **GIVEN** a task assigned to `{type: 'group', id: 'behandelaars'}` +- **AND** alice is a member of that group +- **WHEN** alice completes the task +- **THEN** the completion MUST be accepted + +#### Scenario: A caller outside every resolution may not answer + +- **GIVEN** the same task +- **AND** bob is in no group and is not the requester or a watcher +- **WHEN** bob attempts to complete it +- **THEN** the attempt MUST be refused + +--- + +### Requirement: A task records both the reference and the resolution that created it + +A task SHALL store the performer reference as authored, and SHALL +additionally record the user ids that reference resolved to at the moment the +task was created. + +The stored reference is what authorises: it is re-resolved on every answer, +so a task follows the role rather than the roster. + +The recorded resolution is evidence, not authority. It answers "who was this +in front of when it was raised", which is the question an audit asks after a +committee has been re-composed and the reference alone can no longer say. It +SHALL NOT be consulted by the answer guard. + +#### Scenario: The recorded resolution does not authorise a former member + +- **GIVEN** a task created while alice was in `juristen` +- **AND** alice has since left that group +- **WHEN** alice attempts to answer +- **THEN** the attempt MUST be refused +- **AND** the recorded resolution MUST still show alice, as evidence of who + was asked diff --git a/openspec/changes/flow-typed-principals/specs/flow-typed-principals/spec.md b/openspec/changes/flow-typed-principals/specs/flow-typed-principals/spec.md new file mode 100644 index 0000000000..23d46386d1 --- /dev/null +++ b/openspec/changes/flow-typed-principals/specs/flow-typed-principals/spec.md @@ -0,0 +1,216 @@ +## Purpose + +What it means to name a performer in a flow: a reference that carries its own +type, a registry of resolvers that turn a type and an id into real people, a +refusal at authoring time when the type is unknown and at creation time when +the reference names nobody, and one predicate that decides who may answer. + +The property this capability exists to guarantee is narrow and was measurably +absent: **a step that asks somebody must be answerable by somebody.** + +## ADDED Requirements + +### Requirement: A performer reference carries its own type + +The system SHALL represent a performer as a reference of the shape +`{type, id}`, where `type` names a kind of principal and `id` identifies one +within that kind. + +The system SHALL accept the following types in the engine itself: `user` and +`group`. It SHALL accept any further type for which a resolver has been +registered, and the engine SHALL NOT enumerate those types in its own code. + +A **bare string** SHALL be read as `{type: 'user', id: }`. Every +performer stored before this capability existed is a bare string, and reading +it as a user reference preserves exactly the behaviour those flows have +today: `mayAnswer` matched a uid first. + +A reference SHALL be accepted anywhere a performer is named: the direct +assignee, the candidate list and the routing fallback alike. A field that +takes several performers SHALL accept a list of references, mixing types +freely — a step may offer a task to one named person and two groups. + +#### Scenario: A typed reference names a group + +- **GIVEN** a step whose assignee is `{type: 'group', id: 'behandelaars'}` +- **WHEN** the step creates its task +- **THEN** the task MUST record the reference as given +- **AND** every member of `behandelaars` MUST be able to answer it + +#### Scenario: A bare string still means a user + +- **GIVEN** a step, authored before this capability, whose assignee is the + bare string `alice` +- **WHEN** the step creates its task +- **THEN** the reference MUST be read as `{type: 'user', id: 'alice'}` +- **AND** the behaviour MUST be indistinguishable from before + +#### Scenario: One field carries several references of different types + +- **GIVEN** a step whose candidates are `{type: 'user', id: 'alice'}` and + `{type: 'group', id: 'juristen'}` +- **WHEN** the step creates its task +- **THEN** both MUST be recorded +- **AND** alice MUST be able to claim it, and so MUST any member of + `juristen` + +--- + +### Requirement: A resolver is contributed, never hard-coded + +The system SHALL define an `IPrincipalResolver` interface. An implementation +SHALL declare the one type it resolves, and SHALL answer, for a given id, the +set of user ids that are the principal for that id at the moment it is asked. + +The system SHALL collect resolvers through a dispatched registration event, +the same recipe by which flow nodes are already contributed. OpenRegister +SHALL register the `user` and `group` resolvers itself. Any other type SHALL +come from a consuming app registering a listener. + +OpenRegister SHALL NOT call a consuming app directly to resolve a principal, +and SHALL NOT name decidiq, hermiq or dossiq anywhere in this mechanism. A +type whose owning app is absent from an instance SHALL simply have no +resolver there, which is a refusal (see below), not an error at boot. + +Resolution SHALL be evaluated when it is needed and SHALL NOT be cached +across requests. Group membership, committee composition and job function all +change while a task is open, and a task that outlives a reorganisation must be +answerable by whoever holds the role now, not by whoever held it when the run +started. + +#### Scenario: An app contributes a type the engine has never heard of + +- **GIVEN** an app that registers a resolver for the type `position` +- **WHEN** a step names `{type: 'position', id: 'voorzitter'}` +- **THEN** the reference MUST be accepted +- **AND** the engine MUST resolve it by calling that resolver + +#### Scenario: Membership is read when it is asked, not when the task was made + +- **GIVEN** an open task assigned to `{type: 'group', id: 'juristen'}` +- **AND** the task was created while alice was not a member +- **WHEN** alice is added to `juristen` and then answers +- **THEN** the answer MUST be accepted + +--- + +### Requirement: An unknown type is refused when the step is saved + +The system SHALL refuse to save a step configuration naming a performer whose +type has no registered resolver, and the refusal SHALL name the type and the +field. + +This is refused at SAVE and not at run time on purpose. A misconfigured +performer discovered at run time is discovered by a suspended run that nobody +is watching, and the author who could fix it has long since moved on. + +#### Scenario: A type no app provides is refused at authoring time + +- **GIVEN** an instance with no resolver for the type `gremium` +- **WHEN** an author saves a step naming `{type: 'gremium', id: 'raad'}` +- **THEN** the save MUST be refused +- **AND** the refusal MUST name both the unknown type and the field it was in + +--- + +### Requirement: A reference that resolves to nobody refuses the task + +When a step creates a task, the system SHALL resolve every performer +reference on it. If the direct assignee resolves to no user, task creation +SHALL fail, and the failure SHALL name the reference that resolved to nobody. + +If the step names candidates rather than a direct assignee, creation SHALL +fail when the union of every candidate reference is empty. A task that no +candidate can see is a task in nobody's inbox. + +The failure SHALL be a step failure, handled by the flow's own error policy, +so an author may deliberately choose to continue past it. It SHALL NOT be +swallowed, and it SHALL NOT produce a suspended run waiting on an +unanswerable task. + +**This is the defect this capability exists for.** Measured on a live +instance, 27 of 30 asking steps named a performer that resolved to nobody; +every one of them created a task, suspended its run, and reported nothing +wrong. + +#### Scenario: An empty resolution fails the step instead of parking the run + +- **GIVEN** a step assigned to `{type: 'group', id: 'afdelingshoofd'}` +- **AND** no such group exists on the instance +- **WHEN** the run reaches that step +- **THEN** the step MUST fail, naming that reference +- **AND** the run MUST NOT be left suspended on an unanswerable task + +#### Scenario: A group that exists but is empty is also nobody + +- **GIVEN** a step assigned to a group that exists and has no members +- **WHEN** the run reaches that step +- **THEN** the step MUST fail +- **AND** the failure MUST distinguish "resolved to no users" from "no such + principal", because the two are fixed in different places + +--- + +### Requirement: Authorisation to answer is resolver-backed + +The system SHALL decide whether a caller may answer a task by resolving the +task's performer references and asking whether the caller is among the +resulting users. + +The system SHALL NOT decide this by comparing the caller's uid to the stored +assignee string, and SHALL NOT decide it by asking whether the caller belongs +to a group whose name happens to equal that string. + +A task naming NO performer SHALL remain answerable by anyone the task +service already authorises, unchanged: whether an unassigned task is open to +all is a `flow-tasks` question and not this capability's to redefine. + +#### Scenario: A position authorises an answer the way a group does + +- **GIVEN** a task assigned to `{type: 'position', id: 'voorzitter'}` +- **AND** a resolver that answers `[bob]` for that position +- **WHEN** bob completes the task +- **THEN** the completion MUST be accepted + +#### Scenario: A coincidental name no longer authorises + +- **GIVEN** a task assigned to `{type: 'user', id: 'juristen'}` +- **AND** a group also named `juristen` containing alice +- **WHEN** alice attempts to answer +- **THEN** the attempt MUST be refused, because the reference names a user + +--- + +### Requirement: Stored assignee strings are migrated once, and what cannot be migrated is reported + +The system SHALL provide a repair step that reads every stored performer +string on a task and on a flow definition, resolves it once against the +registered resolvers, and rewrites it as a typed reference. + +A string that resolves to exactly one type SHALL be rewritten to that type. A +string that resolves under MORE than one type SHALL be left alone and +reported, because the repair cannot know which was meant, and guessing would +silently move who may answer. + +A string that resolves under no type SHALL be left alone and reported. The +repair SHALL NOT delete it, refuse the upgrade, or fail the instance: a flow +naming a performer nobody can be is already broken, and the repair's job is +to say so, not to become the thing that breaks it. + +The report SHALL name each unmigrated string, the flow or task carrying it, +and which of the two reasons applies. + +#### Scenario: An ambiguous string is reported rather than guessed + +- **GIVEN** a stored assignee string `support` that names both a user and a + group +- **WHEN** the repair runs +- **THEN** the string MUST be left as it is +- **AND** the report MUST name it as ambiguous, naming both types + +#### Scenario: The repair does not fail an upgrade over a broken flow + +- **GIVEN** 27 stored assignee strings that resolve to nothing +- **WHEN** the repair runs +- **THEN** it MUST complete +- **AND** it MUST report all 27, with the flow each belongs to diff --git a/openspec/changes/flow-typed-principals/specs/flow-user-task-node/spec.md b/openspec/changes/flow-typed-principals/specs/flow-user-task-node/spec.md new file mode 100644 index 0000000000..33fc467b37 --- /dev/null +++ b/openspec/changes/flow-typed-principals/specs/flow-user-task-node/spec.md @@ -0,0 +1,109 @@ +## MODIFIED Requirements + +### Requirement: The node describes its own form, served from the node catalog + +The step type `openregister.user-task` SHALL describe itself to the node +catalog, and the editor SHALL draw its configuration from that description +rather than from anything the editor knows about this node. + +The node's display name SHALL be **"Ask a person or group"**. The node has +accepted group performers since it existed and its name said only "person", +which is why authors reached for a free-text box and typed a role name into +a field they believed took one user. + +Every field that names a performer — `assignee`, `candidateUsers`, +`candidateGroups`, `candidateRole` and `routingFallback` — SHALL be declared +with the field type `principal`, not `text`. + +`candidateUsers`, `candidateGroups` and `candidateRole` SHALL be superseded +by a single `candidates` field of type `principal` accepting several +references. The three separate fields exist only because a text box cannot +express a type; a typed reference can, and three fields that differ only in +the kind of thing typed into them is the API asking the author to do the +type system's job. The three SHALL keep working as input, read as candidates +of type `user`, `group` and `role` respectively. + +`performerType` SHALL be removed from the config form. It duplicated, as a +free-typed word, the type that each reference now carries. + +The node SHALL continue to serve its full field list from the catalog, and +the editor SHALL continue to need no change to draw a field the node adds. + +#### Scenario: The catalog serves the node with typed performer fields + +- **WHEN** the node catalog is read +- **THEN** the entry for `openregister.user-task` MUST be named "Ask a person + or group" +- **AND** its `assignee` and `candidates` fields MUST declare the type + `principal` +- **AND** no performer field MUST declare the type `text` + +#### Scenario: The three old candidate fields still configure a step + +- **GIVEN** a step saved before this change with `candidateGroups: "juristen"` +- **WHEN** the step is loaded +- **THEN** its candidates MUST include `{type: 'group', id: 'juristen'}` + +--- + +### Requirement: An agent is a performer of this node, asked with a prompt + +The node SHALL accept a performer reference of type `agent`, and a step whose +performer is an agent SHALL declare a `prompt` instead of a form. + +An agent step SHALL produce a task row exactly as a human step does: the same +provenance, the same audit, the same lifecycle verbs, the same outcome bag. +The consequence that matters is that an agent's answer is reviewable and +re-assignable — a task an agent has not yet answered can be handed to a +person, and a person can see what was asked. + +The engine SHALL NOT invoke an agent runtime. It SHALL dispatch the request +as an event, and a consuming app performs the turn and completes the task +through the ordinary verbs. This is the same boundary the node already keeps +with every other performer, and the one gate-27 and ADR-022 require. + +The separate step type for running an agent turn SHALL remain, unchanged. A +turn that is not a question to be answered is not a task, and forcing it +through an inbox would put a row in front of a person for something nobody +was ever asked. + +#### Scenario: An agent answers through the same verbs a person uses + +- **GIVEN** a step whose performer is `{type: 'agent', id: }` with a + prompt +- **WHEN** the run reaches it +- **THEN** a task MUST exist carrying the run and node provenance +- **AND** the run MUST suspend until that task is terminal +- **AND** the outcome MUST reach the following steps in the ordinary outcome + bag + +#### Scenario: An unanswered agent task can be given to a person + +- **GIVEN** an open task whose performer is an agent +- **WHEN** it is reassigned to `{type: 'user', id: 'alice'}` +- **THEN** alice MUST be able to answer it +- **AND** the reassignment MUST be recorded on the task's audit + +--- + +### Requirement: A step that names nobody is refused before it can run + +`validateConfig` SHALL refuse a performer reference whose type has no +registered resolver, naming the type and the field. + +The node SHALL continue to refuse a step that names no performer at all. The +existing reason is unchanged and still correct: a task nobody is assigned can +be completed by anyone. + +The node SHALL NOT resolve references during validation. Whether a group has +members is a fact about the instance at run time, not about the document +being saved, and refusing a save because a committee is temporarily empty +would make a flow unauthorable for a reason that has nothing to do with it. + +#### Scenario: Validation refuses an unknown type but not an empty group + +- **GIVEN** an instance with no resolver for `gremium` +- **WHEN** a step naming `{type: 'gremium', id: 'raad'}` is saved +- **THEN** the save MUST be refused +- **AND** a step naming an existing but empty group MUST save without + complaint diff --git a/openspec/changes/flow-typed-principals/tasks.md b/openspec/changes/flow-typed-principals/tasks.md new file mode 100644 index 0000000000..76e14a3c2a --- /dev/null +++ b/openspec/changes/flow-typed-principals/tasks.md @@ -0,0 +1,61 @@ +# Tasks + +## 1. The reference and the registry + +- [ ] 1.1 `lib/Service/Flow/Principal/PrincipalReference.php` — the `{type, id}` value object, with a `from()` that reads a bare string as `{type: 'user', id: …}` and a list reader that accepts mixed types. +- [ ] 1.2 `lib/Service/Flow/Principal/IPrincipalResolver.php` — `type(): string` and `resolve(string $id): array` returning user ids. +- [ ] 1.3 `lib/Service/Flow/Principal/RegisterPrincipalResolversEvent.php` — modelled on `RegisterFlowNodesEvent`. +- [ ] 1.4 `lib/Service/Flow/Principal/PrincipalResolverRegistry.php` — collects resolvers on first use, answers `has(type)` and `resolve(reference)`. No cross-request cache: D-1, and the late-resolution decision above it. +- [ ] 1.5 `UserPrincipalResolver` and `GroupPrincipalResolver`, registered by openregister itself. +- [ ] 1.6 Unit tests, each seen RED first: a bare string reads as a user; a list mixes types; an unregistered type answers `has() === false`; a group resolves to its current members and re-resolves after a membership change. + +## 2. Authorisation + +- [ ] 2.1 `FlowRunAssignee::mayAnswer()` consults the registry instead of comparing strings. Keep the null-assignee branch exactly as it is. +- [ ] 2.2 The task service's answer guard takes the same path, so the two cannot disagree. +- [ ] 2.3 Record the resolution at creation as evidence only. Assert in a test that the guard does **not** read it — a former member must be refused while still appearing in the record. +- [ ] 2.4 Check every caller of `mayAnswer` for the hot-path trap: inbox listing must still predicate in the datastore, never resolve row by row. +- [ ] 2.5 Tests: a position authorises; a coincidental group name no longer authorises a `user` reference; an unassigned task is unchanged. + +## 3. The node + +- [ ] 3.1 `UserTaskConfig` parses typed references on `assignee`, `candidates`, `routingFallback`; reads the three legacy candidate fields as typed candidates. +- [ ] 3.2 `validateConfig` refuses an unknown type, naming type and field. It does NOT resolve — D-2. +- [ ] 3.3 Task creation resolves; an empty resolution fails the step, distinguishing "no such principal" from "resolved to no users". +- [ ] 3.4 Declare `principal` as the field type on every performer field; drop `performerType` from the form and derive the column from the reference. +- [ ] 3.5 Display name to "Ask a person or group"; description reviewed against the `writing` skill before it is written. +- [ ] 3.6 Tests: legacy `candidateGroups` still configures a step; an unknown type is refused at save; an empty group fails at creation and not at save. + +## 4. Agent as a performer + +- [ ] 4.1 Accept `{type: 'agent', id}` with a `prompt` in place of a form. +- [ ] 4.2 Dispatch `AgentRunRequestedEvent` rather than invoking a runtime; the completing app uses the ordinary verbs. +- [ ] 4.3 An `agent` resolver whose identity is **not** a member of human groups — see the trap; assert it. +- [ ] 4.4 Test: an unanswered agent task reassigned to a person is answerable by that person, and the reassignment is on the audit. + +## 5. The picker (nextcloud-vue) + +- [ ] 5.1 A `principal` widget in `CnFlowNodeEditModal.vue`: multi-select, searches as you type against `/ocs/v2.php/core/autocomplete/get` with `shareTypes[]=0,1`. +- [ ] 5.2 A stored reference the current user cannot see must still SHOW, the way the existing `user` widget synthesises an unseen uid — otherwise a save silently clears a delegation. +- [ ] 5.3 App-contributed types are offered from a server-declared option source, not from a list the editor hard-codes. +- [ ] 5.4 `runAs` keeps the existing single-user widget. +- [ ] 5.5 jest specs seen RED first; en/nl catalogues for every new string, `writing` skill loaded first. + +## 6. The repair + +- [ ] 6.1 A repair step rewriting unambiguous strings on tasks and on flow definitions. +- [ ] 6.2 Ambiguous and unresolvable strings are left alone and reported, naming the carrier and the reason. **Must not fail the upgrade** — D-4. +- [ ] 6.3 Bump `` in `appinfo/info.xml` in the same commit, or the step never runs (gate-110). +- [ ] 6.4 Test with a fixture holding one of each: unambiguous, ambiguous, unresolvable. + +## 7. Proof + +- [ ] 7.1 Playwright, over the live API: author a step naming a group, run it, confirm a member can answer and a non-member cannot. +- [ ] 7.2 Playwright: a step naming a group that does not exist fails the step and leaves no suspended run — the measured defect, inverted into a test. +- [ ] 7.3 Playwright: an unknown type is refused when the flow is saved. +- [ ] 7.4 `composer check:strict`, both l10n gates, and the full unit suite. Read the exit code, not the summary line. + +## 8. Afterwards, not here + +- [ ] 8.1 decidiq contributes `position`; hermiq contributes `function`; dossiq contributes `role`. Separate PRs in their own repos. +- [ ] 8.2 Retiring `dossiq.askPerson` onto this node — its own change, once the resolvers exist. 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/openspec/changes/isnull-filter-operator/proposal.md b/openspec/changes/isnull-filter-operator/proposal.md new file mode 100644 index 0000000000..53fa0b68fa --- /dev/null +++ b/openspec/changes/isnull-filter-operator/proposal.md @@ -0,0 +1,77 @@ +# Proposal: isnull-filter-operator + +## Problem + +`openspec/specs/zoeken-filteren` has advertised `?afgehandeld_op_isnull=true` since +2026-03. **It has never worked.** Not "worked incorrectly" — the filter contributed no +condition at all, so the caller got an unrelated result set with no error and no log. + +The mistake is two layers deep, and the top layer is a decoy. + +**The decoy.** `SearchQueryHandler::cleanQuery()` contains a `_isnull` branch that reads + +```php +$newParameters[$base] = 'IS NOT NULL'; +if ($value === true) { $newParameters[$base] = 'IS NULL'; } +``` + +`=== true` cannot match a query string, which only ever delivers `"true"`. That looks +like the bug. It is not, because **`cleanQuery()` has zero production callers.** Nothing +in `lib/` invokes it; only tests and the spec name it. Fixing that comparison changes +nothing observable, which is how this was nearly shipped as a one-line fix. + +**The real layer.** What actually normalises a suffixed parameter is +`SearchQueryHandler::buildSearchQuery()`, whose underscore-to-nested reconstruction turns +`?status_in[]=new` into `status => ['in' => ['new']]`. That is why `_in`, `_notIn`, `_ne`, +`_gte` and friends work: `MagicSearchHandler::COMPARISON_OPERATORS` happens to contain +those names. `isnull` was absent from that list, so `?assignee_isnull=true` became a +nested `['isnull' => 'true']` bag that no condition builder inspected, and the filter was +dropped. + +Measured on a live instance over 13 tickets, 11 of them unassigned: + +| query | rows | +|---|---| +| `?assignee_isnull=true` | 0 | +| `?assignee_isnull=false` | 0 | +| `?assignee=IS%20NULL` (the sentinel) | 11 | +| `?assignee=IS%20NOT%20NULL` | 2 | + +Both spellings returning 0 is the tell: a working-but-inverted operator would have +returned 2 for one of them. + +**The same shape hides a second dead parameter.** `?ordering=-title` is documented in the +same spec and was also implemented only in `cleanQuery()`. Over HTTP `ordering` is read as +a filter on a property no schema declares, which adds `1 = 0`: `?ordering=title` returns +**0 of 13 rows**. The documented legacy sort does not sort, it empties the result. + +## Solution + +Implement the operator where operators actually live, and delete the decoy. + +1. **`isnull` joins `COMPARISON_OPERATORS`**, so a bag containing only `isnull` is + recognised as an operator bag. Without this it would fall through to the historical + bare-list branch and become `IN ('true')` — matching nothing, and looking like a + correct empty page. +2. **All four condition builders handle it**: the QueryBuilder and raw-SQL UNION paths, + for object fields and for `@self` metadata. Two implementations of one filter language + is exactly where a fix lands on one side only and behaviour starts depending on which + query shape the caller hit. +3. **The value is coerced with `filter_var(..., FILTER_VALIDATE_BOOLEAN)`.** Raw truthiness + would make the string `"false"` mean true, which is worse than the bug being fixed. +4. **`cleanQuery()` is deleted** along with its tests. It is a second, dead filter language + that made the spec look implemented. +5. **The spec is corrected** to describe the real mechanism, name the exhaustive operator + list, and record that `?ordering=` is unsupported. + +The literal `?field=IS NULL` sentinel is untouched and remains the spelling the fleet's +queue pages use, since it works on instances that do not yet carry this change. + +## Impact + +- **`?field_isnull=true` starts filtering.** Any caller that had adapted to the broken + behaviour would change — none can have, because the parameter returned an unfiltered + result rather than a usable one. +- **`cleanQuery()` is gone from the public surface of `SearchQueryHandler`.** A fleet-wide + grep finds no caller outside this repo's own copies. +- **No schema, migration or API-shape change.** diff --git a/openspec/changes/isnull-filter-operator/specs/zoeken-filteren/spec.md b/openspec/changes/isnull-filter-operator/specs/zoeken-filteren/spec.md new file mode 100644 index 0000000000..bac3bb92be --- /dev/null +++ b/openspec/changes/isnull-filter-operator/specs/zoeken-filteren/spec.md @@ -0,0 +1,52 @@ +# Spec: zoeken-filteren (delta) + +## MODIFIED Requirements + +### Requirement: Field-level filtering with comparison operators + +`isnull` becomes a real operator, and the requirement now names the mechanism that +actually implements operators rather than a method nothing calls. + +The operator vocabulary is `MagicSearchHandler::COMPARISON_OPERATORS`, and it is +exhaustive: `gte`, `lte`, `gt`, `lt`, `in`, `notIn`, `ne`, `isnull`. A suffix outside that +list contributes no condition and is silently ignored, so an operator MUST be added there +AND in each of the four condition builders — the QueryBuilder and raw-SQL paths, for +object fields and for `@self` metadata. + +`isnull` MUST be read with boolean coercion, because a query string delivers only strings. +A strict `=== true` comparison MUST NOT be used. + +#### Scenario: The null-check operator filters +@e2e exclude query-layer operator with no browser surface — both halves and every spelling are pinned by tests/Unit/Db/MagicSearchHandlerIsNullOperatorTest, and the HTTP behaviour was measured on a live instance (11 of 13 rows for true, 2 for false) + +- **WHEN** the user filters with `?assignee_isnull=true` +- **THEN** only objects with no `assignee` value MUST be returned +- **AND** `?assignee_isnull=false` MUST return exactly the complement +- **AND** the two result sets together MUST be the unfiltered set + +#### Scenario: An operator bag carrying only isnull is not a bare IN list +@e2e exclude query-layer operator with no browser surface — pinned by the same unit test + +- **WHEN** a filter resolves to `assignee => ['isnull' => 'true']` +- **THEN** it MUST emit a null check +- **AND** it MUST NOT be treated as the historical bare-list `IN ('true')`, which matches + nothing and reads as a correct empty result + +#### Scenario: Both filter paths agree +@e2e exclude query-layer operator with no browser surface — pinned by the same unit test + +- **WHEN** the same `isnull` filter runs through the QueryBuilder path and the raw-SQL + UNION path +- **THEN** both MUST emit the same predicate + +## REMOVED Requirements + +### Requirement: Legacy ordering parameter + +**Reason**: `?ordering=-aanmaakdatum` was only ever implemented in +`SearchQueryHandler::cleanQuery()`, which no production code called. Over HTTP `ordering` +is read as a filter on a property no schema declares, so it adds `1 = 0` and returns zero +rows. Measured on a live instance: `?ordering=title` returned 0 of 13. + +**Migration**: Use `?_order[]=ASC|DESC`, which is the mechanism the search actually +implements and which is verified working on the same instance. diff --git a/openspec/changes/isnull-filter-operator/tasks.md b/openspec/changes/isnull-filter-operator/tasks.md new file mode 100644 index 0000000000..ad5e479647 --- /dev/null +++ b/openspec/changes/isnull-filter-operator/tasks.md @@ -0,0 +1,14 @@ +# Tasks: isnull-filter-operator + +- [x] 1.1 Add `isnull` to `MagicSearchHandler::COMPARISON_OPERATORS`. +- [x] 1.2 Add a shared `isNullOperatorAsksForNull()` helper using + `filter_var(..., FILTER_VALIDATE_BOOLEAN)`. +- [x] 1.3 Handle `isnull` in all four condition builders: + `buildObjectFilterConditionsSql`, `buildMetadataOperatorConditionsSql`, + `applyMetadataOperators` and `applyObjectFilters`. +- [x] 1.4 Delete the dead `SearchQueryHandler::cleanQuery()` and its tests. +- [x] 1.5 Add `MagicSearchHandlerIsNullOperatorTest` covering both paths, both halves of + the operator and every query-string spelling. Verified red without the fix: 13 of 13. +- [x] 1.6 Correct `openspec/specs/zoeken-filteren`: the real normalisation mechanism, the + exhaustive operator list, the sentinel, and `?ordering=` being unsupported. +- [x] 1.7 Verify over HTTP on a throwaway instance, both spellings and their complement. 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/openspec/changes/retire-external-workflow-engines/proposal.md b/openspec/changes/retire-external-workflow-engines/proposal.md new file mode 100644 index 0000000000..51f2c49f28 --- /dev/null +++ b/openspec/changes/retire-external-workflow-engines/proposal.md @@ -0,0 +1,62 @@ +# One flow engine, and it is ours + +## Why + +ADR-065 already says OpenRegister is the only home for a flow engine in this +fleet. The external-engine layer predates that decision and was never retired +with it: adapters for n8n and windmill, a registry, an engines table, a settings +screen, actions, hooks, deployed workflows, scheduled workflows, five background +jobs and two event listeners. + +Measured before removing any of it: + +| table | rows | +| --- | --- | +| `workflow_engines` | **0** | +| `actions` | 0 | +| `action_logs` | 0 | +| `deployed_workflows` | 0 | +| `scheduled_workflows` | 4 | +| `workflow_executions` | 4 | + +Nobody has configured an engine, so every path through the layer is unreachable: +each one resolves an engine id first and throws when it finds none. + +The four scheduled workflows are the interesting part, and they say more than +the zeroes do. Every one names `engine=openconnector` — **an engine type the +registry never supported**, since `resolveAdapter()` matched only `n8n` and +`windmill` and threw on anything else. All four carried `last_status=error`, +unchanged since 2026-08-30. They had been failing every run for days. + +So this is not the removal of a working capability. It is the removal of a layer +that could not work, in favour of the engine that does. + +## What goes + +The adapters, the registry, the engines table and its settings screen; actions +and hooks and their executors, listeners, retry and schedule jobs; deployed and +scheduled workflows and their controllers; the config export/import paths that +deployed workflows into an engine; and the frontend that drove all of it. + +## What stays, and is easy to confuse + +`lib/WorkflowEngine/` held **two different things** behind one directory name. +`RunFlowOperation` and `RegisterObjectEntity` integrate OUR flow engine into +Nextcloud's NATIVE workflow engine — that is the opposite of what this change +removes, and deleting them would have removed the very thing "rely purely on our +own engine" asks for. They stay. + +`SchemaWorkflowTab` also survives, stripped rather than deleted: its hook and +engine sections go, its `TaskSequencePanel` stays, because task sequences are +our approval flow. + +## A defect this change introduced and then found + +The migration reports each scheduled workflow before dropping it, so the intent +somebody wrote down is not destroyed in silence. The first version queried with +backticks around the table name — MySQL syntax. On Postgres it threw, the catch +swallowed it, and the report never fired. Rewritten through the query builder +and verified on Postgres. + +That failure mode is worth naming: a report that exists to prevent silent data +loss, which silently does not run, is worse than no report at all. diff --git a/openspec/changes/retire-external-workflow-engines/specs/single-flow-engine/spec.md b/openspec/changes/retire-external-workflow-engines/specs/single-flow-engine/spec.md new file mode 100644 index 0000000000..b2a046d402 --- /dev/null +++ b/openspec/changes/retire-external-workflow-engines/specs/single-flow-engine/spec.md @@ -0,0 +1,47 @@ +# Single flow engine + +## ADDED Requirements + +### Requirement: OpenRegister drives no external workflow engine (REQ-SFE-101) + +OpenRegister MUST NOT contain an adapter, registry, settings screen or API for +driving an external workflow engine. ADR-065 makes OpenRegister the only home +for a flow engine in this fleet, and a second dispatch path is a second engine. + +The Nextcloud NATIVE workflow engine integration is explicitly NOT covered by +this requirement. Registering an OpenRegister flow as a Nextcloud workflow +operation is how our engine reaches the Files UI, and it MUST remain. + +#### Scenario: No engine adapter is reachable + +- **WHEN** the application boots +- **THEN** no external workflow engine adapter, registry or route exists. + +#### Scenario: The native integration survives + +- **WHEN** a Nextcloud workflow operation list is built +- **THEN** the OpenRegister flow operation is still offered. + +### Requirement: The external engine tables are removed (REQ-SFE-102) + +The tables the external engine layer owned MUST be dropped, idempotently. + +Before dropping them, every scheduled workflow MUST be reported. A schedule +records intent that a person wrote down, and destroying it with no trace in the +upgrade output leaves nothing to re-create it from. + +The report MUST work on every supported database. A report that exists to +prevent silent loss, and silently does not run, is worse than no report. + +#### Scenario: Scheduled workflows are named before removal + +- **GIVEN** an instance with scheduled workflows +- **WHEN** the migration runs +- **THEN** each one's name, engine and interval appears in the output before the + tables are dropped. + +#### Scenario: Re-running the migration changes nothing + +- **GIVEN** an instance where the tables are already gone +- **WHEN** the migration runs again +- **THEN** it drops nothing and reports nothing. diff --git a/openspec/changes/retire-external-workflow-engines/tasks.md b/openspec/changes/retire-external-workflow-engines/tasks.md new file mode 100644 index 0000000000..687292476e --- /dev/null +++ b/openspec/changes/retire-external-workflow-engines/tasks.md @@ -0,0 +1,44 @@ +# Tasks + +## 1. Remove the layer + +- [x] 1.1 Adapters (`N8nAdapter`, `WindmillAdapter`), `WorkflowEngineInterface`, + `WorkflowResult`, `WorkflowEngineRegistry`. +- [x] 1.2 Engines table, entity, mapper, controller and the n8n settings screen. +- [x] 1.3 Actions and hooks: executors, listeners, retry and schedule jobs. +- [x] 1.4 Deployed and scheduled workflows, entities, mappers, controllers. +- [x] 1.5 The workflow deploy/export paths in the configuration handlers. +- [x] 1.6 Routes (20), background-job registrations (3), DI and listener wiring. +- [x] 1.7 Frontend: the n8n settings section, the hook UI, the execution and + schedule panels. + +## 2. Keep what is ours + +- [x] 2.1 `RunFlowOperation` and `RegisterObjectEntity` stay. They live in + `lib/WorkflowEngine/` but integrate OUR engine into NEXTCLOUD's native + workflow engine. The directory name is the trap. +- [x] 2.2 `SchemaWorkflowTab` is stripped, not deleted: `TaskSequencePanel` is + our approval flow's UI and would have gone with the tab. + +## 3. Storage + +- [x] 3.1 A migration drops the six tables, idempotently. +- [x] 3.2 It REPORTS each scheduled workflow first. A schedule is a statement of + intent, and dropping it without printing it loses that with no trace. +- [x] 3.3 Built through the query builder. The first version used backticks, + which is MySQL syntax: on Postgres it threw, the catch swallowed it, and + the report never ran. Verified on Postgres both ways. + +## 4. Verified + +- [x] 4.1 Full suite: 18,797 tests, 0 errors. +- [x] 4.2 Full-tree phpstan clean; 11 now-dead baseline entries removed. +- [x] 4.3 The migration drops all six tables on a live instance, and reports the + schedules it destroys. +- [x] 4.4 No reference to any removed symbol remains in `lib/`, `src/`, + `tests/` or `appinfo/`. + +## 5. Not done here + +- [ ] 5.1 The `n8n-nextcloud` ExApp is a separate repo and keeps working as a + way to RUN n8n. OpenRegister simply no longer drives it. diff --git a/openspec/changes/run-scoped-object-locking/design.md b/openspec/changes/run-scoped-object-locking/design.md new file mode 100644 index 0000000000..6c9d10ea5f --- /dev/null +++ b/openspec/changes/run-scoped-object-locking/design.md @@ -0,0 +1,224 @@ +# Design: run-scoped-object-locking + +## D-1 · The lock record shape + +This is the decision that is awkward to reverse, because the record is +already written into live `_locked` columns across every magic table on every +instance. It is settled here before any code is written. + +### The record + +```jsonc +{ + "kind": "run", // NEW. "run" | "user". ABSENT means "user". + "runUuid": "9f2c…", // NEW. Present only when kind === "run". + "user": "alice", // unchanged meaning: WHO. For a run lock, its runAs. + "process": "review-step", // unchanged. Now actually persisted (see D-5). + "created": "2026-09-05T…", // unchanged + "duration": 3600, // unchanged + "expiration": "2026-09-05T…" // unchanged +} +``` + +### Why this shape and not another + +**Why a `kind` discriminator rather than "a `runUuid` key means a run lock".** +A present-key discriminator makes an absent key ambiguous between "user lock" +and "run lock whose writer forgot the key", and the second must be refused +loudly rather than silently downgraded to a user lock that the runAs user can +then walk straight through. With an explicit `kind`, a record that says `run` +and carries no `runUuid` is detectably malformed, and +`isLockedBySomeoneElse()` refuses every caller for it rather than admitting +one by accident. Failing closed on a malformed lock is the only safe +direction: the alternative silently converts a bug into an open door. + +**Why `user` keeps its meaning in both cases instead of a nested holder +object.** A nested `{holder: {kind, id}}` would be tidier on a blank sheet +and is the wrong call here. `user` is read today by +`ObjectEntity::getLockedBy()`, by `LockHandler::callerMayUnlock()`, by +`LockHandler::getLockInfo()`'s `locked_by` projection, by +`ObjectsController`'s 423 body, by `RevertHandler`, and by two frontend +stores in opencatalogi and nextcloud-vue that render "locked by X". Moving it +breaks all of them for no gain. Keeping `user` populated on a run lock with +the run's `runAs` means every one of those readers keeps working and shows a +true statement: that identity really is who the run acts as. What changes is +only that `user` stops being the *authority* on ownership. + +**Why no migration and no back-fill.** `_locked` is a `Types::JSON` column +(`MagicMapper::getMetadataColumns()`), so adding keys is a write-shape change +with no schema change. Every lock written before this change has no `kind`; +`kind ?? 'user'` reads it as a user lock, which is exactly what it is. There +is no record in existence that this default misclassifies, because no run has +ever been able to take a lock. A back-fill would have nothing to fill. + +**Why not a lock table instead of the JSON column.** The lock has to be +readable in the same row read that the write guard already performs. Moving +it to a side table adds a query to every single object write to answer a +question that is `null` in the overwhelming majority of cases. The registry +in D-4 exists for a different question, asked by a cron job rather than by +every write. + +### How ownership is compared + +One production predicate, on `ObjectEntity`, and every guard calls it: + +```php +public function isLockedBySomeoneElse(?string $userId, ?string $runUuid = null): bool +``` + +| Lock | `kind` | Held against | Admitted | +|---|---|---|---| +| user lock (legacy or new) | absent / `user` | everyone whose uid differs from `user` | that uid only | +| run lock | `run` | **everyone**, including the run's own `runAs` user | that `runUuid` only | +| malformed run lock | `run`, no `runUuid` | everyone | nobody | +| expired lock | any | nobody (`isLocked()` is false) | everyone | + +A run lock refusing the run's own `runAs` user is decision 2 stated in code. +If the runAs user were admitted, a lock taken by a run under `alice` would be +no obstacle to alice, and on the many instances where flows run as a single +service account it would be no obstacle to anybody. The lock would be +decorative again, in a new way. + +The predicate is what tests assert against. No test restates the table above; +`openregister#3428` is the standing reminder of what a test that reimplements +its subject is worth. + +## D-2 · Suspend and retry reuses the heartbeat, and adds nothing + +`openregister.lock-object` needs no new waiting mechanism. The engine already +has one, and the user-task node is the worked example. + +- **Park**: throw `FlowSuspension(resumeAt: , reason: …)`. + `resumeAt` MUST be non-null. A null `resumeAt` means "waiting on an + external signal", and `FlowRunMapper::findAbandonedSignals()` fails such a + run after 14 days. A lock is not waiting on a signal, it is waiting on a + clock. +- **Remember**: the node's own resume slot, + `$context[FlowNodeResumeState::CONTEXT_KEY]`. It stores `deadline` (the + wait budget's absolute expiry, stamped once on the first attempt and never + restamped) and `attempts`. The slot is per-node, so two lock steps in one + flow do not read each other's state. +- **Wake**: `FlowRunWorker` calls `FlowRunMapper::findDue()` every cron tick, + which selects `suspended AND resume_at <= now`. The node runs again, + retries the acquire, and either proceeds or parks again. +- **Forget**: the dispatcher clears the slot when `execute()` returns + normally. A node that suspends skips that line. Since openregister#3358, + `FlowRunService::keepResumeSlots()` preserves slots for every non-terminal + end, so a pass that legitimately ends `queued` no longer loses the + deadline and restart the budget from zero. + +**Backoff is the node's job.** There is no attempt counter or backoff column +on `FlowRun`; `resume_at` is an absolute wake time. The node computes its own +next `resumeAt` from `attempts` in its slot, clamped to a floor of 60 seconds +because a wake finer than the cron period cannot happen anyway. + +**When the budget runs out the node fails, naming the holder.** It does not +proceed, and it does not break the lock. Proceeding would be the data race +the lock exists to prevent; breaking it would let any flow author defeat +every other flow author's lock by waiting. + +## D-3 · Three release layers, and what each one is for + +| Layer | Covers | Latency | +|---|---|---| +| 1 · `FlowRunTerminalEvent` listener | every terminal outcome, including the reapers' | immediate | +| 2 · sweep in `FlowRunWorker` | a lock whose run row is gone, or terminal without a release | one cron tick | +| 3 · TTL (`expiration`, already exists) | everything else, including a database that lost layers 1 and 2 | the lock's duration | + +**Layer 1 hooks one place.** `FlowRunMapper::update()` dispatches +`FlowRunTerminalEvent` whenever the persisted row `isTerminal()`. That is a +predicate, not a status whitelist, so all four terminal statuses fire it, and +so does every reaper (`reapStale`, `expireStaleQueued`, +`expireAbandonedSignals`) because they all terminate through the same +`update()`. The listener is idempotent by construction, which it has to be: +the event can fire more than once for one run. + +> **Correction to the brief.** The four terminal statuses are `completed`, +> `stopped`, `failed` and **`dead_letter`**. There is no `cancelled` and no +> `expired` status: a cancellation and a queue-TTL expiry both land on +> `failed` with an explanatory `error`. The tests cover the four that exist. + +**The listener must not rethrow.** For the stream-walk path the dispatch +happens inside `FlowRunCommit`'s open transaction, so a throw would unwind +the run's own terminal write. `TaskRunTerminalListener` is the established +shape and this listener copies it: log, never rethrow. + +**Layer 2 is why the registry in D-4 exists.** A killed worker does reach +terminal eventually, when `reapStale()` observes `updated` going cold, so +layer 1 covers it after the stale window. What layer 1 cannot cover is a +release that itself failed, or a run row deleted out from under an +outstanding lock. The sweep finds those by asking the registry, not by +looking at objects. + +## D-4 · The run-lock registry, and the 3.4-second alternative + +`openregister_run_object_locks`, one row per (run, object): `run_uuid`, +`object_uuid`, `register_id`, `schema_id`, `node_id`, `locked_at`, +`expires_at`, unique on `(run_uuid, object_uuid)`. + +**Why a table rather than reading the objects.** Locks live in the `_locked` +column of magic tables, one table per register-schema pair. +`MagicMapper::findAcrossAllMagicTables()` carries a measured note that an +instance-wide scan over 2,728 magic tables builds 690 KB of SQL and costs +about 3.4 seconds to *plan*, before a row is read. That is not a thing to put +on a cron tick, and it is not a thing to put in a terminal-event listener +that runs inside the run's own transaction. With the registry, "which locks +does this run hold" is one indexed read, and "which locks are orphaned" is +one `NOT IN` against the runs table, exactly the shape +`FlowClaimMapper::deleteOrphans()` already uses for place claims. + +**Why not store the held locks in the run's context instead.** The context +dies with the run row, which is precisely the case layer 2 exists to catch. + +**The registry is bookkeeping, never the authority.** The `_locked` column +remains the only thing a write guard consults. A registry row without a +matching `_locked` payload is stale bookkeeping and the sweep deletes it; a +`_locked` payload without a registry row still blocks writes and still +expires on its TTL. Making the registry authoritative would introduce a +second source of truth for the question the guard asks on every write, and +the two would drift. + +## D-5 · Two defects fixed on the way past + +**The write guard.** `SaveObject::findAndValidateExistingObject()` reads +`$lockData['userId']`; `lock()` writes `user`. `$lockOwner` is always null +and the guard's `!== null` short-circuits. Fixed by calling the D-1 +predicate. `ObjectsController`'s update path already compared the right key +via `getLockedBy()` and so was the one live guard; it now delegates to the +predicate too, so there is one comparison rather than two spellings of it. + +**The dropped process tag.** `LockHandler::lock()` accepts `$process` and +never passes it to `MagicMapper::lockObjectEntity()`, which hardcodes +`'MagicMapper lock'`. Both signatures now carry it. This is what lets a run +lock say which node took it. + +**The post-save auto-unlock.** `ObjectsController` releases any lock after a +successful update, patch or post-patch. That is fine for the user lock it was +written for and wrong for a run lock, so it now releases only a lock the +writer actually holds. Without this a single admin write would silently strip +a run's lock as a side effect of a guard it had just passed. + +## D-6 · The `lock()` caller sweep + +Every caller of `lockObject()` in the fleet, checked for the shape that +breaks when locks become real: lock under one identity, write under another. + +| Caller | Shape | Verdict | +|---|---|---| +| `ObjectsController::lock()` / `unlock()` (openregister) | HTTP, user session | unaffected | +| integriq `EndpointService::processLockingRule()` | locks or unlocks as the request's user; does not write in the same rule | unaffected, and its `process` UUID now survives | +| dossiq `DrcController::lock()` | locks, then `storeLockIdInData()` writes the same object **as the same user** | passes: the holder's own write is admitted. The write is already wrapped in a warn-only `try` | +| dossiq `DrcController::unlock()` | unlocks, then writes | unaffected, the lock is gone before the write | +| buildiq `ApplicationVersionsController::update()` | locks 15s, writes as the same user, releases in `finally` | passes, and **starts working**: the 409 branch it already has was unreachable | +| buildiq `AbstractToolHandler::saveVersionManifest()` | locks 30s, writes as the same user, releases in `finally` | same | +| buildiq `ApplicationCreationService` | advisory (pre-creation) lock, no object | unaffected | + +**No caller locks and then writes under a different identity, so nothing +starts refusing.** Two callers start being *protected*: buildiq took its +locks explicitly "to prevent last-writer-wins data loss when two concurrent +MCP agents mutate the same version", and two agents under one account have +never been able to conflict. Both already handle the 409 that they will now +sometimes get. + +Nothing in `lib/Repair/`, `lib/Command/`, `lib/Migration/` or any seed takes +an object lock, so no import or repair path is affected. diff --git a/openspec/changes/run-scoped-object-locking/proposal.md b/openspec/changes/run-scoped-object-locking/proposal.md new file mode 100644 index 0000000000..cc4353fb89 --- /dev/null +++ b/openspec/changes/run-scoped-object-locking/proposal.md @@ -0,0 +1,87 @@ +--- +kind: code +--- + +# Proposal: run-scoped-object-locking + +## Summary + +Make an object lock mean something, and let a flow run hold one. Two new +nodes, `openregister.lock-object` and `openregister.unlock-object`, take and +release a lock in the name of the **run** rather than the name of the user +the run happens to execute as. A second run that wants the same object parks +and retries on the heartbeat until the lock frees or its wait budget runs +out. A person who tries to write to a locked object is refused with a message +naming the run that holds it. The engine releases every lock a run holds on +any terminal outcome, a sweep collects what a killed worker left behind, and +the existing TTL stays as the last backstop. + +## Why + +**No lock has ever blocked a write.** `ObjectEntity::lock()` writes the +holder under the key `user`. `SaveObject::findAndValidateExistingObject()` +reads it under the key `userId`, so `$lockOwner` is always `null`, and the +guard's `if ($lockOwner !== null && ...)` never fires. Every lock taken +through `POST /api/objects/{register}/{schema}/{id}/lock` since the endpoint +shipped has been decorative on that path. Measured on +`origin/development@c18e5c286`. + +**The unit test agreed with the bug.** `SaveObjectTest:: +testFindAndValidateExistingObjectThrowsOnLockedObject` hand-writes +`setLocked(['userId' => 'other-user'])`, a payload shape `lock()` has never +produced, and passes. It is why the defect survived: the test asserted the +guard's logic against the guard's own misreading rather than against a lock +the production writer actually made. + +**Two runs under one identity cannot conflict today.** Ownership is compared +on the user alone: `if ($lock['user'] !== $userId) throw`. Two flow runs +executing under the same `runAs` therefore never collide. The second silently +takes the *extend* branch and pushes the first run's expiry out, and either +one can release the other's lock through `unlock()`, which repeats the same +comparison. A run-scoped lock is exactly the case the current shape cannot +express. + +**The process tag is dropped on the floor.** `LockHandler::lock()` accepts a +`$process` argument and never forwards it; `MagicMapper::lockObjectEntity()` +hardcodes `'MagicMapper lock'`. integriq's `processLockingRule()` mints a +fresh UUID per lock and passes it in, and it is discarded before it reaches +the payload, so no caller has ever been able to say what it locked *for*. + +## What Changes + +- **The lock record grows a holder kind.** `kind: 'run'|'user'` and + `runUuid`, added beside the existing keys. `_locked` is a JSON column, so + this is additive with no migration and no back-fill: a record written + before this change has no `kind` and reads as a user lock, which is what it + is. See design.md D-1. +- **One production predicate owns ownership.** + `ObjectEntity::isLockedBySomeoneElse(?string $userId, ?string $runUuid)`. + Every guard calls it. No caller restates the comparison, and no test + restates it either. +- **The write guard is fixed** in `SaveObject`, in the `ObjectsController` + update path, and in `RevertHandler`, and the refusal names the holder. +- **Two nodes** in `lib/Service/Flow/Nodes/`, registered through + `FlowNodeRegistrationListener` like every other built-in. `lock-object` + parks the run with a non-null `resumeAt` and retries, keeping its attempt + count and deadline in its own resume slot. +- **A run-lock registry**, `openregister_run_object_locks`, one row per + (run, object). It is what lets the terminal listener release a run's locks + without scanning, and what makes the sweep a single indexed query. See + design.md D-4 for why the alternative costs 3.4 seconds. +- **Three release layers**: a `FlowRunTerminalEvent` listener, a sweep in + `FlowRunWorker`, and the TTL that already exists. +- **An admin break-lock**, recorded in the audit trail as `lock.broken`. + +## Impact + +- **Affected specs**: `object-interactions`, `flow-engine`. +- **Affected code**: `lib/Db/ObjectEntity.php`, `lib/Db/MagicMapper.php`, + `lib/Service/Object/LockHandler.php`, `lib/Service/Object/SaveObject.php`, + `lib/Service/Object/RevertHandler.php`, + `lib/Controller/ObjectsController.php`, `lib/Service/ObjectService.php`, + two new nodes, one new entity, one new mapper, one new migration, one new + listener, `lib/BackgroundJob/FlowRunWorker.php`. +- **Behaviour that changes for existing callers**: a lock now refuses a + write. The caller sweep in design.md D-6 found no caller that locks and + then writes under a different identity, so nothing in the fleet starts + failing. integriq's `processLockingRule()` gains a working `process` tag. diff --git a/openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md b/openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md new file mode 100644 index 0000000000..fbd103e7ce --- /dev/null +++ b/openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md @@ -0,0 +1,298 @@ +# Spec: run-scoped object locking + +## Purpose + +A lock that a flow run can hold, that actually refuses a write, and that is +always released. Locks were writable and readable before this change and +blocked nothing on the service write path; ownership was keyed on the user +alone, so two runs under one identity could not conflict with each other. + +## ADDED Requirements + +### Requirement: A lock records whether a run or a person holds it + +The lock payload SHALL carry a `kind` of either `run` or `user`, and a +`runUuid` when and only when `kind` is `run`. A payload with no `kind` SHALL +be read as a user lock, so records written before this capability keep their +meaning with no migration and no back-fill. + +The payload SHALL keep populating `user` in both cases: the person for a user +lock, and the run's `runAs` identity for a run lock. `user` SHALL NOT be the +authority on ownership. + +A payload whose `kind` is `run` but which carries no `runUuid` SHALL be +treated as held against every caller, including the caller that would +otherwise match. A malformed lock fails closed. + +#### Scenario: A record written before this change is a user lock +- **GIVEN** a stored lock payload carrying `user` and `expiration` and no `kind` +- **WHEN** ownership is evaluated for that same user +- **THEN** the lock MUST be held by that user, and no migration MUST be required +- @e2e exclude entity-level payload semantics, covered by ObjectEntityTest + +#### Scenario: A malformed run lock admits nobody +- **GIVEN** a lock payload with `kind` of `run` and no `runUuid` +- **WHEN** any caller, run or person, is evaluated against it +- **THEN** the lock MUST be held against that caller +- @e2e exclude entity-level payload semantics, covered by ObjectEntityTest + +### Requirement: Ownership is decided by one predicate + +The system SHALL expose a single predicate that answers whether a live lock +is held against a given caller, identified by a user id and an optional run +uuid. Every write guard, every unlock authorization and every node SHALL call +it. No caller SHALL restate the comparison. + +A user lock SHALL be held against every caller whose user id differs from the +recorded one. A run lock SHALL be held against every caller whose run uuid +differs from the recorded one, **including a caller presenting the run's own +`runAs` user id and no run uuid**. An expired lock SHALL be held against +nobody. + +A run and the person it executes as are DIFFERENT HOLDERS, in both +directions: a user lock SHALL be held against a caller presenting a run uuid, +**including a run executing as the person who took it**. Without that, a run +walking over a person's locked object did not merely pass the guard — it took +the extend branch, rewrote the payload as its own run lock, and released it +when the run ended, destroying a lock a person was relying on with no error +and no audited displacement. + +#### Scenario: Two runs under one user conflict +- **GIVEN** an object locked by run A executing as `alice` +- **WHEN** run B, also executing as `alice`, evaluates the lock +- **THEN** the lock MUST be held against run B, and run B MUST NOT extend it +- @e2e exclude engine-internal, covered by the two-run real-engine test + +#### Scenario: A run's own runAs user is still refused +- **GIVEN** an object locked by run A executing as `alice` +- **WHEN** `alice` evaluates the lock as a person, with no run uuid +- **THEN** the lock MUST be held against her +- @e2e tests/e2e/api-direct/flow-object-locking.spec.ts — a person's write is refused with 423 while a run holds the lock, the run's own runAs user included + +#### Scenario: A run may extend its own lock +- **GIVEN** an object locked by run A +- **WHEN** run A locks it again +- **THEN** the lock MUST be extended rather than refused + +#### Scenario: A person's lock survives a run passing over the object +- **GIVEN** an object locked by `alice` as a person +- **WHEN** a run executing as `alice` locks the same object +- **THEN** the run MUST be refused, the payload MUST be unchanged, and the + lock MUST still be a user lock when the run ends +- @e2e tests/e2e/api-direct/flow-object-locking.spec.ts — a person's own lock survives a run passing over the object, and the run takes it once she releases + +### Requirement: A lock refuses a write and names its holder + +While a live lock is held against the caller, the system SHALL refuse to +update, patch or revert that object, and the refusal SHALL name the holder. +When a run holds the lock the message SHALL name the run. + +The refusal SHALL apply on the service write path, not only at the HTTP +controller: a lock that only the controller enforces is not a lock. + +Every refusal guard SHALL identify the caller by BOTH its user id and, when a +flow run is executing, that run's uuid. The run identity is ambient — a write +made for a run is routinely several calls deep inside code that has never +heard of flows — and a guard that omits it refuses the run that took the +lock, which makes the lock step unusable by the flow that used it. A guard +that decides a RELEASE rather than a refusal SHALL NOT ask as the run: a run's +lock is meant to outlive every write the run makes. + +#### Scenario: A person is refused while a run holds the lock +- **GIVEN** an object locked by a flow run +- **WHEN** a person updates it over the API +- **THEN** the write MUST be refused, and the message MUST name the holding run +- @e2e tests/e2e/api-direct/flow-object-locking.spec.ts — a person's write is refused with 423 while a run holds the lock, the run's own runAs user included + +#### Scenario: The holder writes freely +- **GIVEN** an object locked by a person +- **WHEN** that same person updates it +- **THEN** the write MUST succeed +- @e2e tests/e2e/api-direct/flow-object-locking.spec.ts — a person's own lock survives a run passing over the object, and the run takes it once she releases + +#### Scenario: The holding run writes to the object it locked +- **GIVEN** an object locked by run A +- **WHEN** a step of run A updates it +- **THEN** the write MUST succeed, while the same write made for run B or by a + person MUST be refused +- @e2e exclude engine-internal, covered by SaveObjectTest and the rig walk + +#### Scenario: A successful write releases only the writer's own lock +- **GIVEN** an object carrying a lock the writer does not hold +- **WHEN** a write to that object completes +- **THEN** the lock MUST survive the write +- @e2e exclude controller-internal, covered by ObjectsControllerTest + +### Requirement: An administrator may break a lock, and the break is recorded + +An administrator SHALL be able to release a lock held by anyone, so a wedged +run cannot hold a case hostage. The break SHALL be written to the audit trail +with an action distinguishing it from an ordinary unlock, naming the holder +that was displaced. + +No other caller SHALL release a run's lock. The existing holder, object-owner +and schema-manage routes to unlocking SHALL continue to apply to **user** +locks only. + +#### Scenario: An administrator breaks a run's lock +- **GIVEN** an object locked by a flow run +- **WHEN** an administrator breaks the lock +- **THEN** the lock MUST be released and an audit entry MUST record the break and the displaced run + +#### Scenario: The object owner cannot break a run's lock +- **GIVEN** an object locked by a flow run +- **WHEN** its owner, who is not an administrator, tries to unlock it +- **THEN** the unlock MUST be refused + +### Requirement: A lock step takes a lock, or parks the run and retries + +The system SHALL provide a step node type `openregister.lock-object` that +takes a run-scoped lock on a target object, and +`openregister.unlock-object` that releases one. + +The target SHALL default to the object the item carries and SHALL be +overridable by configuration. Both nodes SHALL accept a duration; the lock +step SHALL accept a wait budget. + +When the lock is held by another holder, the lock step SHALL suspend the run +with a non-null resume time and retry on re-entry, rather than failing +immediately or proceeding without the lock. It SHALL keep its wait deadline +and attempt count in its own resume slot, stamping the deadline once, and it +SHALL NOT restart the budget across re-entries. + +When the wait budget expires the step SHALL fail, naming the holding run. It +SHALL NOT break the lock and SHALL NOT proceed without it. + +An empty firing SHALL take no lock and SHALL NOT suspend the run. + +#### Scenario: A contended lock parks the run +- **GIVEN** an object already locked by another run +- **WHEN** a lock step runs against it +- **THEN** the run MUST suspend with a non-null resume time +- @e2e exclude node-internal, covered by LockObjectNodeTest + +#### Scenario: The wait budget is not restarted by a retry +- **GIVEN** a lock step that has already parked once and recorded its deadline +- **WHEN** it re-enters and the object is still locked +- **THEN** it MUST keep the original deadline +- @e2e exclude node-internal, covered by LockObjectNodeTest + +#### Scenario: An exhausted budget fails and names the holder +- **GIVEN** a lock step whose recorded deadline has passed and whose target is still locked +- **WHEN** it re-enters +- **THEN** it MUST fail with a message naming the holding run, and MUST NOT take or break the lock +- @e2e tests/e2e/api-direct/flow-object-locking.spec.ts — a second run bounces off the lock: it parks, then fails naming the holding run + +#### Scenario: An empty firing does not suspend +- **GIVEN** a lock step reached with no items +- **WHEN** it runs +- **THEN** it MUST return no items and MUST NOT suspend the run + +#### Scenario: Configuration is validated before the flow is published +- **GIVEN** a lock step whose wait budget is not a positive number +- **WHEN** its configuration is validated +- **THEN** validation MUST be refused with a message saying what to correct + +### Requirement: Every lock a run holds is released when the run ends + +The engine SHALL release every lock a run holds on **any** terminal outcome: +`completed`, `stopped`, `failed` and `dead_letter`. The release SHALL NOT +depend on a node running, so a run that crashed or failed still releases. + +A run that has NOT ended SHALL keep every lock it holds. The status a run's +commit path derives mid-pass is part of that guarantee: a run whose stored +status is `suspended`, `queued` or `running` MUST NOT have been announced as +terminal on the way there, because the announcement is what releases the +locks — and a case that cannot stay locked while its flow works has no lock +at all. + +The release SHALL be idempotent, since terminality can be observed more than +once, and SHALL NOT propagate a failure into the run's own terminal write. + +The system SHALL additionally sweep locks whose holding run is terminal or no +longer exists, for the case where a release did not happen. The sweep SHALL +NOT require reading every object table. + +The existing lock expiry SHALL remain in force as the final backstop. + +#### Scenario: A parked run keeps its locks +- **GIVEN** a run that has taken a lock and reached a step that waits +- **WHEN** the run is stored as `suspended` +- **THEN** no terminal event MUST have been announced for it, and the lock MUST + still be held by that run +- @e2e tests/e2e/api-direct/flow-object-locking.spec.ts — an object is locked by its run, stays locked while the run is PARKED, and is released when the run ends + +#### Scenario: A completed run releases its locks +- **GIVEN** a run holding a lock +- **WHEN** the run completes +- **THEN** the lock MUST be released +- @e2e tests/e2e/api-direct/flow-object-locking.spec.ts — an object is locked by its run, stays locked while the run is PARKED, and is released when the run ends + +#### Scenario: A failed run releases its locks +- **GIVEN** a run holding a lock +- **WHEN** the run fails +- **THEN** the lock MUST be released + +#### Scenario: A stopped run releases its locks +- **GIVEN** a run holding a lock +- **WHEN** the run stops +- **THEN** the lock MUST be released + +#### Scenario: A dead-lettered run releases its locks +- **GIVEN** a run holding a lock +- **WHEN** the run is dead-lettered +- **THEN** the lock MUST be released + +#### Scenario: The sweep releases a lock whose run is gone +- **GIVEN** a lock held by a run whose row no longer exists +- **WHEN** the sweep runs +- **THEN** the lock MUST be released + +#### Scenario: The sweep leaves a live run's lock alone +- **GIVEN** a lock held by a run that is still suspended +- **WHEN** the sweep runs +- **THEN** the lock MUST survive + +#### Scenario: An expired lock blocks nobody +- **GIVEN** a run lock whose expiry has passed and whose run never released it +- **WHEN** any caller evaluates it +- **THEN** the lock MUST be held against nobody + +### Requirement: A node the engine registers is offered in the palette + +Every node type the engine can execute SHALL appear in the node catalogue the +editor reads. A node SHALL NOT be dropped from the catalogue because its icon +does not resolve: `IURLGenerator::imagePath()` throws for an image the server +does not ship, Nextcloud's core icon set is not a stable API, and a node that +is missing from the catalogue cannot be added to a flow at all — a total +failure produced by a cosmetic cause. A node whose icon cannot be resolved +SHALL be served with the app's own icon, and the failure SHALL be logged as an +error naming the node. + +#### Scenario: The lock and unlock nodes can be added to a flow +- **GIVEN** an instance running any supported Nextcloud +- **WHEN** the editor reads the node catalogue +- **THEN** `openregister.lock-object` and `openregister.unlock-object` MUST both + be present, each with an icon that resolves +- @e2e tests/e2e/ci/flow-lock-nodes.spec.ts — the node catalogue serves both lock steps, each with an icon that resolves +- @e2e tests/e2e/ci/flow-lock-nodes.spec.ts — both lock steps are offered in the editor palette and reach the canvas + +#### Scenario: A node with an unresolvable icon is reported, not deleted +- **GIVEN** a registered node whose `getIcon()` names an image the server does not ship +- **WHEN** the catalogue is built +- **THEN** the node MUST still be listed, with the app icon, and an error naming + the node MUST be logged +- @e2e exclude covered by FlowNodePaletteIconsTest + +## MODIFIED Requirements + +### Requirement: A lock records what it was taken for + +`LockHandler::lock()` has accepted a `process` argument since it shipped and +never forwarded it; `MagicMapper` wrote the literal `MagicMapper lock` in its +place. The caller's `process` SHALL reach the stored payload. + +#### Scenario: A caller's process tag survives +- **GIVEN** a caller locking an object with a process tag +- **WHEN** the lock payload is read back +- **THEN** it MUST carry that tag diff --git a/openspec/changes/run-scoped-object-locking/tasks.md b/openspec/changes/run-scoped-object-locking/tasks.md new file mode 100644 index 0000000000..d0d745788c --- /dev/null +++ b/openspec/changes/run-scoped-object-locking/tasks.md @@ -0,0 +1,76 @@ +# Tasks: run-scoped-object-locking + +## 1 · The lock record and its predicate + +- [ ] 1.1 Add `LOCK_KIND_RUN` / `LOCK_KIND_USER`, `isLockedBySomeoneElse()`, + `describeLockHolder()` and `getLockedByRun()` to `ObjectEntity`. + **files**: `lib/Db/ObjectEntity.php` +- [ ] 1.2 Thread `?string $runUuid` through `ObjectEntity::lock()` and + `unlock()`, and route both through the predicate. + **files**: `lib/Db/ObjectEntity.php` +- [ ] 1.3 Replace `SaveObjectTest`'s two `['userId' => …]` fixtures, which + pin a payload shape `lock()` never wrote. + **files**: `tests/Unit/Service/Object/SaveObjectTest.php` + +## 2 · The write guard + +- [ ] 2.1 Fix `findAndValidateExistingObject()` to call the predicate and + name the holder. + **files**: `lib/Service/Object/SaveObject.php` +- [ ] 2.2 Route the controller's 423 guard and `RevertHandler` through the + same predicate. + **files**: `lib/Controller/ObjectsController.php`, `lib/Service/Object/RevertHandler.php` +- [ ] 2.3 Make the post-save auto-unlock release only the writer's own lock. + **files**: `lib/Controller/ObjectsController.php` + +## 3 · Plumbing the run identity and the process tag + +- [ ] 3.1 Forward `$process` and `$runUuid` through + `MagicMapper::lockObjectEntity()` / `unlockObjectEntity()`. + **files**: `lib/Db/MagicMapper.php` +- [ ] 3.2 Thread them through `LockHandler`, `ObjectService` and the contract. + **files**: `lib/Service/Object/LockHandler.php`, `lib/Service/ObjectService.php`, `lib/Contract/ObjectServiceInterface.php` +- [ ] 3.3 Make `callerMayUnlock()` run-aware and add an audited `breakLock()`. + **files**: `lib/Service/Object/LockHandler.php` + +## 4 · The run-lock registry + +- [ ] 4.1 `RunObjectLock` entity and `RunObjectLockMapper` with + `findByRun()`, `releaseByRun()`, `deleteOrphans()`. + **files**: `lib/Db/RunObjectLock.php`, `lib/Db/RunObjectLockMapper.php` +- [ ] 4.2 Migration creating `openregister_run_object_locks`. + **files**: `lib/Migration/Version1Date20260905120000.php` + +## 5 · The nodes + +- [ ] 5.1 `LockObjectNode` with suspend-and-retry over the resume slot. + **files**: `lib/Service/Flow/Nodes/LockObjectNode.php` +- [ ] 5.2 `UnlockObjectNode`. + **files**: `lib/Service/Flow/Nodes/UnlockObjectNode.php` +- [ ] 5.3 Register both; bump the registration test's node count. + **files**: `lib/Listener/FlowNodeRegistrationListener.php`, `tests/Unit/Listener/FlowNodeRegistrationListenerTest.php` + +## 6 · Release + +- [ ] 6.1 `FlowRunLockReleaseListener` on `FlowRunTerminalEvent`, idempotent, + never rethrowing. + **files**: `lib/Listener/FlowRunLockReleaseListener.php`, `lib/AppInfo/Application.php` +- [ ] 6.2 Sweep orphaned run locks from `FlowRunWorker`. + **files**: `lib/BackgroundJob/FlowRunWorker.php` + +## 7 · Tests that can fail + +- [ ] 7.1 Two runs under one user conflict, proven red against the old + predicate first. + **files**: `tests/Unit/Db/ObjectEntityRunLockTest.php` +- [ ] 7.2 Release proven separately for each of the four terminal statuses. + **files**: `tests/Unit/Listener/FlowRunLockReleaseListenerTest.php` +- [ ] 7.3 Holder gone, sweep releases; live run's lock survives. + **files**: `tests/Unit/Db/RunObjectLockMapperTest.php`, `tests/Unit/Listener/FlowRunLockReleaseListenerTest.php` +- [ ] 7.4 TTL backstop. + **files**: `tests/Unit/Db/ObjectEntityRunLockTest.php` +- [ ] 7.5 A human write refused while a run holds the lock; the admin break + works and is audited. + **files**: `tests/Unit/Service/Object/SaveObjectTest.php`, `tests/Unit/Service/Object/LockHandlerBreakLockTest.php` +- [ ] 7.6 Node config validation and the park/retry/budget behaviour. + **files**: `tests/Unit/Service/Flow/LockObjectNodeTest.php`, `tests/Unit/Service/Flow/UnlockObjectNodeTest.php` diff --git a/openspec/changes/store-over-federated-config/proposal.md b/openspec/changes/store-over-federated-config/proposal.md new file mode 100644 index 0000000000..5a4fe6ceb3 --- /dev/null +++ b/openspec/changes/store-over-federated-config/proposal.md @@ -0,0 +1,92 @@ +# The store exchanges configuration, not rows + +## Problem + +OpenRegister has two stores. They were built for the same purpose, neither +references the other, and the weaker one is the one users can see. + +**`federated-config-sharing`** is the fleet standard. A schema opts itself in +with one marker, `x-openregister-shareable`, and `SchemaShareableConfigScanner` +turns it into a shareable type without any per-app code. Three types ship +built in: flows, registers and schemas, and a whole configuration set, which +`ConfigSetShareableConfigType` documents as "an app's worth of configuration at +once: registers, schemas, objects, views, flows, sources and mappings". Bundles +are signed with the instance's Ed25519 key, published to a repository, found by +topic, and gated on a per-org trusted-key list. The spec calls this the store: +"A schema SHALL be able to opt its objects into the store with a single marker." + +Twenty one of its twenty five tasks are done. The backend works. It has no user +interface at all, so nobody can reach it. + +**`apphost-store-plane`** is what users actually see. `CnStorePage` calls +`/api/store/items`, the engine fetches items from one remote OpenRegister +instance over HTTP, and `GenericStoreInstaller` writes each component of an item +as a plain object into one allowlisted schema. It cannot carry a schema. It +cannot carry a flow. It reads its allowlist from the consuming app's manifest, +so the schema never gets to say whether it may travel. + +The result is a Store menu entry that exchanges rows of a single schema. A +municipality cannot publish the way it runs its council. A neighbouring +municipality cannot install it. + +## What a store item should be + +A store item is a collection of schemas and flows that work together to provide +a functionality. Decidiq publishes a default gemeente: the organisational +structure, the decision types, and the flows that move a decision through them. +Dossiq publishes case types. Portaliq publishes forms. Buildiq publishes whole +apps. Humaniq publishes tax schemas. Shillinq publishes administration setups. + +Each of those is one shape, not six. `ConfigSetShareableConfigType` already is +that shape. + +**The fleet has already written these, and calls them seed datasets.** Decidiq +ships four in `lib/Settings/profiles/`: a municipality with committees and +factions, an association with a members' meeting and a board, a company board +with a supervisory and an executive layer, and a works council. Every one is a +named organisational structure with the schemas and vocabulary to run it, which +is a configuration set with a different file extension. `SeedProfileService` +already imports one on request. + +So the first store catalogue is not something anyone has to invent. It is the +seed data each app already ships, published instead of bundled. + +## Solution + +Point the store surface at the engine that already does this, and delete the +duplicate exchange rather than grow it. + +- **`GenericStoreController::search()` sources its cards from + `FederatedConfigService::discover()`**, across the topics of the shareable + types the calling app declares, instead of from one remote objects API. +- **`install()` resolves the card and calls `FederatedConfigService::install()`**, + which routes to the owning type's `deserialise()`. A config set arrives as + registers, schemas, objects, views, flows, sources and mappings. A flow + arrives as a flow. +- **The schema decides whether it travels.** `x-openregister-shareable` is + already read by the scanner, so an app that marks a schema gets it in the + store with no manifest change. The manifest `installable` allowlist stays as + the second boundary, because a remote publisher naming a schema is not the + same claim as that schema being shareable. +- **Trust moves with it.** A federated install already passes + `isSourceAllowed()` and the trusted-key check, which the object install path + has no equivalent for. + +The manifest `store` block gains `types`, the shareable type ids an app +surfaces. `schema`, `register` and `cardFields` stay, and an app that declares +only those keeps the object store it has today, so nothing that ships now +breaks while the fleet moves. + +## Affected Projects + +- [x] Project: `openregister` — the engine and the store controller. +- [x] Project: `nextcloud-vue` — `CnStorePage` renders a type and a publisher. +- [x] Project: `decidiq` — first consumer, publishes a default gemeente. + +## Out of scope + +Retiring the object store path. It stays until every app that uses it has +moved, and this change does not move them. + +Dossiq's hand-written `StoreController`, which predates both engines and wins +the route alias by design. It moves in its own change. diff --git a/openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md b/openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md new file mode 100644 index 0000000000..2cf172296f --- /dev/null +++ b/openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md @@ -0,0 +1,100 @@ +## ADDED Requirements + +### Requirement: A store MUST be able to offer configuration, not only objects + +A leaf app SHALL be able to declare `store.types`, a list of shareable +configuration type ids. When it does, the store surface SHALL list what +`FederatedConfigService::discover()` returns for each declared type's topic, +and each card SHALL carry the type id that produced it. + +A store item found this way MAY contain registers, schemas, objects, views, +flows, sources and mappings together, because that is what a configuration set +is. The plane SHALL NOT flatten such an item into objects of one schema. + +#### Scenario: A configuration set reaches the store surface + +- **GIVEN** an app declaring `store.types: ["openregister.config-set"]` +- **AND** a published configuration set tagged with that type's topic +- **WHEN** an administrator opens the app's store +- **THEN** the set is listed as one card naming its type and publisher + +#### Scenario: An app that declares no types keeps the object store + +- **GIVEN** an app declaring only `store.schema` and `store.register` +- **WHEN** the store is searched +- **THEN** the remote objects API is used exactly as before +- **AND** no discovery call is made + +### Requirement: A schema MUST decide whether it may be shared + +A schema carrying `x-openregister-shareable` in its configuration SHALL be +offered as a shareable type by `SchemaShareableConfigScanner`, and the store +surface SHALL list it for any app that declares its type id. + +A schema without that marker SHALL NOT be offered, even when the app's +`installable` allowlist names it. The marker governs whether the schema may +travel at all, which is a different question from what an install may write. + +The `installable` allowlist SHALL NOT gate a configuration install, and this is +deliberate. A configuration set exists to introduce registers, schemas and +flows the instance does not have yet, so a list of schemas the app already owns +cannot express whether that set may be applied: it would refuse exactly the +sets worth installing. The trust boundary for a bundle is its PUBLISHER, and it +is enforced by `isSourceAllowed()` and the trusted-key check. The allowlist +keeps its meaning for the objects path, where an item names a schema the app +does own. + +#### Scenario: A set may introduce a schema the app does not own + +- **GIVEN** a configuration set carrying a schema absent from `installable` +- **AND** a publisher this organisation trusts +- **WHEN** an administrator installs it +- **THEN** the schema is created + +#### Scenario: An unmarked schema is not offered + +- **GIVEN** a schema absent `x-openregister-shareable` +- **AND** an app whose `installable` list names that schema +- **WHEN** the store is searched +- **THEN** the schema is not listed as a shareable type + +### Requirement: A configuration install MUST run through its owning type + +Installing a card that carries a type id SHALL fetch the bundle and call +`FederatedConfigService::install()`, which routes to that type's +`deserialise()`. The plane SHALL NOT write the bundle's contents directly. + +The install SHALL refuse a source that `isSourceAllowed()` rejects, and SHALL +report the refusal rather than installing part of the bundle. + +#### Scenario: An untrusted publisher is refused + +- **GIVEN** a card published by a source outside the org allowlist +- **WHEN** an administrator installs it +- **THEN** the install is refused and names the source +- **AND** nothing is written + +#### Scenario: A flow arrives as a flow + +- **GIVEN** a published bundle of type `openregister.flows` +- **WHEN** an administrator installs it +- **THEN** the flow exists on this instance +- **AND** it is not written as an object of some other schema + +## MODIFIED Requirements + +### Requirement: A store descriptor MUST carry every per-app parameter + +The descriptor SHALL carry the app id, the remote schema slug, the default +register and the card-field map, so that everything differing between apps is +data and everything shared lives once in `GenericStoreService`. + +It SHALL additionally carry the declared shareable type ids. A descriptor with +type ids selects federated discovery; a descriptor with none keeps the remote +objects API, so an app that has not moved is unaffected. + +#### Scenario: A descriptor names its types + +- **GIVEN** a manifest declaring two shareable type ids +- **WHEN** the descriptor is built +- **THEN** it carries both ids in declaration order diff --git a/openspec/changes/store-over-federated-config/tasks.md b/openspec/changes/store-over-federated-config/tasks.md new file mode 100644 index 0000000000..32ecb2e796 --- /dev/null +++ b/openspec/changes/store-over-federated-config/tasks.md @@ -0,0 +1,55 @@ +# Tasks + +## 1. Engine + +- [ ] 1.1 `StoreManifest` parses `types` and exposes `declaredTypes()` +- [ ] 1.2 `StoreDescriptor` carries the declared type ids +- [ ] 1.3 `GenericStoreService::searchFederated()` calls + `FederatedConfigService::discover()` per declared topic and normalises the + results to the same card shape the objects path returns +- [ ] 1.4 A card carries `type`, `publisher` and `source` alongside the existing + fields, so the surface can show where a set came from +- [ ] 1.5 `GenericStoreService::resolveFederated()` maps a card slug back to its + repo, path and type id + +## 2. Install + +- [ ] 2.1 `GenericStoreController::install()` routes a card with a type id + through `FederatedConfigService::install()` +- [ ] 2.2 A source outside the org allowlist is refused before any fetch, and + the refusal names the source +- [ ] 2.3 The object install path is unchanged for a card with no type id + +## 3. Selection + +- [ ] 3.1 A descriptor with no declared types keeps the objects API, verified by + a test that asserts no discovery call is made + +## 4. Surface + +- [ ] 4.1 `CnStorePage` renders the card's type and publisher +- [ ] 4.2 Kind filters fall back to type display names when an app declares no + kinds + +## 5. First consumer + +- [ ] 5.1 Decidiq declares `store.types` and a Store menu entry at footer 92 +- [ ] 5.2 Decidiq marks the schemas that may travel with + `x-openregister-shareable` +- [ ] 5.3 A default gemeente configuration set is published and installs onto a + clean instance +- [ ] 5.4 The four example sets in `lib/Settings/profiles/` are offered as + built-in store items, so the store names what decidiq already ships + rather than rendering blank without a registry +- [ ] 5.5 An example set serialises to a configuration set, so the seed data an + app bundles and the configuration it publishes are one artefact rather + than two formats holding the same thing + +## 6. Verification + +- [ ] 6.1 Unit tests for descriptor selection, discovery normalisation and + install routing +- [ ] 6.2 An e2e test opens a store declaring types and asserts a config-set + card renders +- [ ] 6.3 Live check on a throwaway instance: publish a set from one instance, + install it on another diff --git a/openspec/changes/store-plane-action-auth/proposal.md b/openspec/changes/store-plane-action-auth/proposal.md new file mode 100644 index 0000000000..28f7891563 --- /dev/null +++ b/openspec/changes/store-plane-action-auth/proposal.md @@ -0,0 +1,45 @@ +# Store plane: resolve an action posture against the declaring app + +## Why + +The previous increment defined `installAuth: "action:"` and then +**refused** it, because resolving an ADR-023 action needs the matrix that lives +in the leaf app. Refusing was the right holding position — downgrading to +`authenticated` would have installed on a weaker gate than the app asked for — +but it leaves integriq unable to migrate, since `catalog.instantiate` is +exactly its posture. + +## What changes + +`StoreActionAuthorizer` resolves `OCA\\Service\ActionAuthService` from +the server container and calls its `can(IUser, string): bool`. The controller +uses it for the `action:` arm and nothing else. + +## The one thing that matters here + +🔴 **An unresolvable authorizer REFUSES. It never permits.** + +This is a duck-typed lookup by convention, and the fleet has been bitten by +that shape repeatedly: `isInstalled('docudesk')`, +`class_exists('OCA\DocuDesk\…')` — a runtime lookup pointed at a name nothing +answers to becomes a **silent no-op** rather than an error. A no-op here is an +install that skipped its authorization check and reported success. + +So every way of failing to decide is a refusal, and each is logged at ERROR +with the name that could not be resolved: + +- the class is not in the container +- it resolves but has no `can()` method — the shape a rename produces, where a + lookup that only checked existence would sail past +- `can()` throws — ADR-023's own `requireAction()` throws to DENY, so anything + propagating out must not be read as consent + +**An administrator does not bypass the matrix.** The point of the posture is +that the leaf app decides; letting admins through anyway would make the +declaration decorative and would stop an app gating an install MORE tightly +than instance admin. + +## What does NOT change + +`installable` is untouched. This decides who may ask; it does not widen what an +install may write. diff --git a/openspec/changes/store-plane-action-auth/specs/apphost-store-plane/spec.md b/openspec/changes/store-plane-action-auth/specs/apphost-store-plane/spec.md new file mode 100644 index 0000000000..8f5f75ca63 --- /dev/null +++ b/openspec/changes/store-plane-action-auth/specs/apphost-store-plane/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: An action posture MUST resolve against the declaring app + +When a store declares `installAuth: "action:"`, the engine MUST ask the +declaring app's own ADR-023 authorization matrix whether the signed-in user may +perform ``, and MUST install only when it answers yes. + +The engine MUST NOT decide an action posture itself. Assuming `admin` would +turn "the operators who hold this action" into "instance administrators", which +is the capability loss the key exists to prevent. + +An administrator MUST NOT bypass the matrix. Otherwise the declaration is +decorative, and an app could never gate an install more tightly than instance +admin. + +#### Scenario: The leaf matrix permits + +- **WHEN** a store declares `"installAuth": "action:catalog.instantiate"` +- **AND** the declaring app's matrix permits the signed-in user +- **THEN** the install proceeds + +#### Scenario: The leaf matrix declines + +- **WHEN** the declaring app's matrix declines +- **THEN** the response is 403 +- **AND** no component is written + +#### Scenario: An administrator is still asked + +- **WHEN** the caller is an instance administrator +- **AND** the declaring app's matrix declines +- **THEN** the response is 403 + +#### Scenario: Anonymous never reaches the matrix + +- **WHEN** an anonymous caller posts an install against an action posture +- **THEN** the response is 403 +- **AND** the matrix is not consulted + +### Requirement: An unresolvable authorizer MUST refuse, never permit + +Every failure to decide MUST be a refusal, and MUST be logged at ERROR with the +name that could not be resolved. + +The lookup is duck-typed by convention, which is the shape that has repeatedly +produced silent no-ops in this fleet. A no-op here is an install that skipped +its authorization check and then reported success, so the absence of an answer +is treated as "no", never as "no objection". + +#### Scenario: The service is not in the container + +- **WHEN** `OCA\\Service\ActionAuthService` cannot be resolved +- **THEN** the install is refused +- **AND** an ERROR is logged naming the action + +#### Scenario: The service has no can() method + +- **WHEN** the class resolves but exposes no `can()` +- **THEN** the install is refused + +This is the shape a RENAME produces: the class still resolves and the method is +gone, so a check that only tested existence would sail past it. + +#### Scenario: The matrix throws + +- **WHEN** `can()` throws +- **THEN** the install is refused + +ADR-023's own `requireAction()` throws to DENY, so anything propagating out of +the matrix must not be read as consent. diff --git a/openspec/changes/store-plane-declarative-sources/proposal.md b/openspec/changes/store-plane-declarative-sources/proposal.md new file mode 100644 index 0000000000..5d33e0f191 --- /dev/null +++ b/openspec/changes/store-plane-declarative-sources/proposal.md @@ -0,0 +1,62 @@ +# Store plane: a declarative discovery source + +## Why + +ADR-114 Decision 4 put a Store in every app's footer and ADR-080 Decision 4 +governs what may carry the word: registry-backed, discovery in OpenRegister, +and an install that writes only what the app allowed. The generic plane already +does that for a store whose registry is **another OpenRegister**. + +Five apps ship a bespoke store today, and the fleet decision is that they +migrate onto the generic plane **without regression**. Two of them cannot, +because they do not discover from an OpenRegister at all: + +- **buildiq** searches the GitHub REST API for `topic:openbuild-app`. +- **hermiq** searches it for `topic:hermiq-agent-template` and + `topic:hermiq-skill`, one call per kind, merged client-side. + +Both cache aggressively, both degrade to a 200 with an empty card list rather +than a 5xx, and both distinguish *rate limited* from *unreachable* because the +remedy differs: one is "wait or add a credential", the other is "the network is +broken". + +`GenericStoreService` can only ever GET +`{registry}/index.php/apps/openregister/api/objects/{register}/{schema}`. An app +whose registry is GitHub is reported as `not_configured`, which is not what is +wrong with it. + +## What changes + +The `store` block gains a **`source`** discriminator. Absent, it is +`openregister` and nothing about today's behaviour moves — dossiq and pipelinq +migrate on the existing shape. + +```jsonc +"store": { + "source": "github", // "openregister" (default) | "github" + "topics": ["openbuild-app"], // github only: what to search for + "kinds": [ … ], // unchanged + "installable": [ … ] // unchanged, still the security boundary +} +``` + +Discovery moves behind a `StoreSourceInterface` with two implementations. The +outcome vocabulary gains `rate_limited`, which the page already needs in order +to say the useful thing rather than the generic one. + +## What does NOT change + +- `installable` stays the security boundary, and an empty list still refuses + everything. +- The SSRF guard, the redirect refusal and the Bearer-header rule stay exactly + as they are for the `openregister` source. A `github` source has a + **compile-time host** and therefore no URL for an app to influence at all, + which is a stronger position, not a weaker one. +- An app still writes no controller. + +## Out of scope, deliberately + +Install shapes beyond writing objects (buildiq clones an app, hermiq +instantiates an agent), `installAuth`, and the approve/export/publish actions. +Those are the next increments; this one is discovery only, so it can be proven +against the two apps that already have a GitHub store to compare against. diff --git a/openspec/changes/store-plane-declarative-sources/specs/apphost-store-plane/spec.md b/openspec/changes/store-plane-declarative-sources/specs/apphost-store-plane/spec.md new file mode 100644 index 0000000000..68abf7c1ad --- /dev/null +++ b/openspec/changes/store-plane-declarative-sources/specs/apphost-store-plane/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: A store MUST declare its discovery source, defaulting to OpenRegister + +The `store` block MAY carry a `source` key. Its value MUST be one of +`openregister` or `github`. When the key is absent the source MUST be +`openregister`, so every store declared before this change keeps its behaviour +exactly. + +An unknown `source` MUST be treated as a malformed block: the manifest is +DISABLED and the store does not appear. Falling back to `openregister` would +silently point an app at a registry it never asked for. + +#### Scenario: An absent source defaults to OpenRegister + +- **WHEN** a `store` block declares no `source` +- **THEN** the manifest reports source `openregister` +- **AND** discovery uses the registry URL configured for the app + +#### Scenario: An unknown source disables the store + +- **WHEN** a `store` block declares `"source": "npm"` +- **THEN** the manifest reports `enabled: false` +- **AND** no store entry may be rendered for that app + +### Requirement: A GitHub source MUST discover by topic against a compile-time host + +When `source` is `github` the block MUST carry a non-empty `topics` list. Each +topic is searched as `topic:` against the GitHub repository search API. + +The host MUST be a compile-time constant. A `github` source MUST NOT read the +app's `registry_url`, and an app MUST NOT be able to influence the host it +reaches. This is deliberately stricter than the `openregister` source, where the +URL is admin-configured and therefore SSRF-guarded at request time: here there +is no URL to guard. + +A `github` source MUST NOT be reported as `not_configured` for want of a +registry URL. It is configured by its topics. + +#### Scenario: Topics are searched and results merged + +- **WHEN** a store declares `"source": "github"` with two topics +- **THEN** each topic is searched +- **AND** the results are merged into one card list, de-duplicated by + `owner/repo` + +#### Scenario: A GitHub source with no topics is malformed + +- **WHEN** a store declares `"source": "github"` and an empty `topics` list +- **THEN** the manifest reports `enabled: false` + +### Requirement: Search MUST distinguish rate limiting from unreachability + +The outcome vocabulary gains `rate_limited`. A source that answers with an +explicit rate-limit signal MUST map to `rate_limited`, never to +`store_unreachable`. + +The two are not interchangeable to a reader: `rate_limited` means wait, or add +a credential to raise the limit; `store_unreachable` means the network or the +registry is broken. Reporting the first as the second sends someone to debug a +network that is fine. + +A rate-limited or unreachable search MUST still answer HTTP 200 with an empty +card list, as today. A store that cannot reach its registry is not a server +error in the app that hosts the page. + +#### Scenario: GitHub rate limiting is reported as itself + +- **WHEN** the GitHub API answers 403 with a rate-limit signal +- **THEN** the outcome is `rate_limited` +- **AND** the HTTP status is 200 +- **AND** the card list is empty + +#### Scenario: A network failure stays unreachable + +- **WHEN** the request to the source throws a connection error +- **THEN** the outcome is `store_unreachable` + +### Requirement: Discovery MUST be cached per source + +A source MUST declare a cache lifetime for search results, and the engine MUST +NOT issue an upstream request while a cached answer for the same query is live. + +Without this, every keystroke past the debounce is an upstream call, and the +GitHub search API's unauthenticated limit is low enough that a single user +typing exhausts it — turning a working store into a `rate_limited` one for +everybody on the instance. + +#### Scenario: A repeated query inside the cache window issues no request + +- **WHEN** the same query is searched twice inside the cache lifetime +- **THEN** exactly one upstream request is made +- **AND** both answers carry the same cards diff --git a/openspec/changes/store-plane-install-auth/proposal.md b/openspec/changes/store-plane-install-auth/proposal.md new file mode 100644 index 0000000000..ada9481bd9 --- /dev/null +++ b/openspec/changes/store-plane-install-auth/proposal.md @@ -0,0 +1,59 @@ +# Store plane: a declared install authorization + +## Why + +The engine hardcodes `IGroupManager::isAdmin` on install. That is the right +posture for dossiq and pipelinq, whose installs write case and commercial +CONFIGURATION — the shape every later handler operates against. + +It is the wrong posture for the other three, and each is wrong differently: + +- **integriq** gates instantiate on ADR-023 `catalog.instantiate`, and the + `source` schema carries its own data-layer admin lock. An admin-only route + would take a capability away from every operator who has the action and is + not an instance administrator. +- **hermiq** lets any authenticated user install, because the install lands + `state: quarantined` and an ADR-023 action (`agenttemplate.approve-quarantined`) + gates the thing that actually matters — approval. +- **buildiq** lets any authenticated user install, and the installer becomes + the owner of the app they cloned. + +Migrating those three onto an admin-only install is a regression, and the fleet +decision is explicitly that they migrate without one. + +## What changes + +The `store` block gains `installAuth`. Absent, it is `admin`, so dossiq, +pipelinq and every block written before this key keep exactly the posture they +have. + +```jsonc +"store": { + "installAuth": "admin" // default + // "installAuth": "authenticated" + // "installAuth": "action:catalog.instantiate" +} +``` + +An unknown value is malformed and DISABLES the store, for the same reason an +unknown `source` does: the alternative is guessing at an authorization posture +on an app's behalf, and the safe-looking guess (fall back to `admin`) silently +removes a capability rather than granting one, which is just as wrong and +harder to notice. + +## What does NOT change + +- `installable` remains the security boundary and is unaffected by this key. + Loosening WHO may install does not widen WHAT may be written; an + `authenticated` install still refuses every schema the allowlist omits. +- The write still runs as the calling user and never through `runAsSystem()`. + A store payload comes off the network. +- Discovery is untouched. + +## Out of scope + +The `action:` arm resolves through ADR-023's `ActionAuthService`, which lives +in the leaf apps rather than in OpenRegister. This change defines the +vocabulary and enforces `admin` and `authenticated`; wiring `action:` to a +resolver is the increment that follows, and until then a store declaring it is +rejected as malformed rather than silently treated as `authenticated`. diff --git a/openspec/changes/store-plane-install-auth/specs/apphost-store-plane/spec.md b/openspec/changes/store-plane-install-auth/specs/apphost-store-plane/spec.md new file mode 100644 index 0000000000..adc818a4ca --- /dev/null +++ b/openspec/changes/store-plane-install-auth/specs/apphost-store-plane/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: A store MUST declare who may install, defaulting to admin + +The `store` block MAY carry an `installAuth` key. Its value MUST be one of +`admin`, `authenticated`, or `action:`. When the key is absent the +posture MUST be `admin`, so every store declared before this key existed keeps +the posture it has. + +An unrecognised value MUST disable the block. Falling back to `admin` would +silently REMOVE a capability from an app that asked for a weaker gate, which is +as wrong as granting one it did not ask for and considerably harder to notice: +the store still works, for fewer people, for no stated reason. + +#### Scenario: An absent installAuth stays admin-only + +- **WHEN** a `store` block declares no `installAuth` +- **AND** a signed-in non-administrator posts an install +- **THEN** the response is 403 + +#### Scenario: An authenticated store admits a non-administrator + +- **WHEN** a `store` block declares `"installAuth": "authenticated"` +- **AND** a signed-in non-administrator posts an install +- **THEN** the install proceeds + +#### Scenario: Anonymous is refused whatever the posture + +- **WHEN** a `store` block declares `"installAuth": "authenticated"` +- **AND** an anonymous caller posts an install +- **THEN** the response is 403, on the same guard as a permitted-but-unauthorised + user + +`authenticated` is the weakest posture the vocabulary offers and it still means +signed in. Anonymous and not-permitted share one guard deliberately, which is +the contract the endpoint already had. + +#### Scenario: An unknown posture disables the store + +- **WHEN** a `store` block declares `"installAuth": "everyone"` +- **THEN** the manifest reports `enabled: false` + +### Requirement: A loosened install posture MUST NOT widen the allowlist + +`installAuth` decides WHO may install. `installable` decides WHAT an install +may write. The two MUST remain independent: an `authenticated` install MUST +refuse every schema the allowlist omits, exactly as an `admin` install does, +and an empty allowlist MUST still refuse everything. + +This is stated as its own requirement because the two keys sit side by side in +the same block and read like a pair. They are not one: relaxing the gate on the +door does not enlarge the room. + +#### Scenario: A non-administrator cannot write a schema the allowlist omits + +- **WHEN** a store declares `"installAuth": "authenticated"` and an + `installable` list that omits `case` +- **AND** a signed-in non-administrator installs an item whose component names + `case` +- **THEN** that component is refused +- **AND** the refusal is reported per component, as for an administrator diff --git a/openspec/changes/store-plane-install-ops/proposal.md b/openspec/changes/store-plane-install-ops/proposal.md new file mode 100644 index 0000000000..de6e1d895c --- /dev/null +++ b/openspec/changes/store-plane-install-ops/proposal.md @@ -0,0 +1,59 @@ +# Store plane: install operations other than writing an object + +## Why + +`GenericStoreInstaller` can do exactly one thing: write a component's object +into an allowed schema. That is the whole of what dossiq and pipelinq install, +and it is why they migrate cleanly. + +It is not the whole of what the other three do. integriq's catalogue has two +kinds of item and only one of them is an object: a **flag-gated** item installs +by flipping an `IAppConfig` boolean, and a **source-backed** one writes a +`source` object. An engine that can only write objects can express half of +integriq's store. + +## What changes + +A component MAY declare an `op`. Absent, it is `writeObject`, which is exactly +today's behaviour, so every existing item installs unchanged. + +```jsonc +{ + "op": "setAppConfig", // "writeObject" (default) | "setAppConfig" + "key": "enableSoapAdapter", + "value": true +} +``` + +`setAppConfig` writes a boolean into the **declaring app's own** config +namespace. It cannot address another app's config, and the key must be +allowlisted by the store block's new `configurable` list — the same shape, and +the same reasoning, as `installable` for schemas. + +## 🔴 `configurable` is a second security boundary, not a convenience + +`installable` stops a remote registry naming any schema the app owns. +`setAppConfig` needs the identical defence for config keys, and it needs it +more: an app's config namespace holds registry URLs, tokens and feature flags, +so an unallowlisted write is a remote actor toggling whatever it names. + +An empty `configurable` list therefore refuses every key, exactly as an empty +`installable` refuses every schema. + +## What does NOT change + +- Objects still install through the existing path, still identity-stripped, + still allowlisted. +- A refusal still does not abort the install, and is still reported per + component. +- Nothing here touches WHO may install. + +## Out of scope, and honestly so + +buildiq's install clones an Application, creates a per-app register, namespaces +companion schemas and rewrites a manifest. hermiq's imports a package through a +content scanner and lands it quarantined, then instantiates an Agent with +model-policy coercion. Neither is expressible as a declarative op without +inventing a language to describe them, and a vocabulary that covers them badly +would be worse than one that admits it does not. They are named here so the gap +is visible rather than discovered later. diff --git a/openspec/changes/store-plane-install-ops/specs/apphost-store-plane/spec.md b/openspec/changes/store-plane-install-ops/specs/apphost-store-plane/spec.md new file mode 100644 index 0000000000..338be44fe5 --- /dev/null +++ b/openspec/changes/store-plane-install-ops/specs/apphost-store-plane/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: A component MUST declare its install operation, defaulting to writing an object + +A component MAY carry an `op` key. Its value MUST be one of `writeObject` or +`setAppConfig`. Absent, it MUST be `writeObject`, so every item that installs +today installs unchanged. + +An unrecognised `op` MUST be refused for that component and reported, and MUST +NOT abort the remaining components. A component whose operation this server +does not understand is the registry's mistake, not a reason to deny an +administrator the components it does understand. + +#### Scenario: A component with no op writes an object + +- **WHEN** a component declares no `op` +- **THEN** it is installed through the object write path +- **AND** the `installable` allowlist applies to it + +#### Scenario: An unknown op is refused, and the rest still install + +- **WHEN** an item carries one component with `"op": "summonDaemon"` and one + ordinary object component +- **THEN** the unknown component is reported as refused +- **AND** the object component is still installed + +### Requirement: A config write MUST be allowlisted by key and scoped to the declaring app + +`setAppConfig` MUST write only into the declaring app's own config namespace, +and only for a key named in the store block's `configurable` list. + +An empty or absent `configurable` list MUST refuse every key. + +🔴 This is a second security boundary, not a convenience. `installable` stops a +remote registry naming any schema the app owns; an app's config namespace holds +registry URLs, tokens and feature flags, so an unallowlisted config write is a +remote actor toggling whatever it names. The default must therefore be "refuse +everything", exactly as it is for schemas. + +#### Scenario: An allowlisted key is written + +- **WHEN** a store declares `"configurable": ["enableSoapAdapter"]` +- **AND** a component sets that key +- **THEN** the value is written to the declaring app's config + +#### Scenario: A key outside the allowlist is refused + +- **WHEN** a component sets a key the `configurable` list omits +- **THEN** the component is refused and reported + +#### Scenario: An absent configurable list refuses everything + +- **WHEN** a store declares no `configurable` list +- **AND** a component declares `setAppConfig` +- **THEN** the component is refused + +#### Scenario: A config write cannot address another app + +- **WHEN** a component's key names another app's namespace +- **THEN** the key is treated as a plain key of the declaring app, never as a + cross-app write + +### Requirement: A config value MUST be a scalar + +`setAppConfig` MUST accept only a boolean, string or number. An array or object +MUST be refused. + +Config is a flat key-value store, and silently serialising a structure into it +produces a value the app will later read back as a string it never wrote. + +#### Scenario: A structured value is refused + +- **WHEN** a component sets a key to an object +- **THEN** the component is refused and reported 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/openspec/changes/the-organisation-projection-is-writable/proposal.md b/openspec/changes/the-organisation-projection-is-writable/proposal.md new file mode 100644 index 0000000000..09b16a572f --- /dev/null +++ b/openspec/changes/the-organisation-projection-is-writable/proposal.md @@ -0,0 +1,103 @@ +# The organisation projection is writable + +## Why + +`nc-organisation` exists to retire the leaf-app `organization` schemas. A schema +slug is global per organisation, so opencatalogi, stackiq and others each +declaring their own meant `SchemaMapper::find()` returned whichever row it +reached first. + +opencatalogi migrated (opencatalogi#1411). stackiq could not, and measuring why +is what produced this change: its setup walkthrough says "Click New and save an +organisation" and advances on `object-created`. Migrating that onto a read-only +schema would retire a working flow rather than move it. Every other app with an +`organization` schema has the same shape, because an app that only ever READ +organisations would not have declared a schema for them. + +So the read-only projection cannot do the job it was built for. + +## What changes + +`OrganisationObjectSourceProvider` implements `WritableObjectSourceProvider`. +The dispatch it plugs into already exists and already gates correctly: +`SaveObject::delegateObjectSourceWrite()` delegates only when the schema +annotation carries `readOnly: false` AND the provider implements the interface. + +**Create goes through `OrganisationService::createOrganisation()`**, not through +the mapper. That is what makes the write safe rather than a second, thinner path +to a tenant: slug generation, owner assignment, admin-user membership, the +admin-group RBAC grant and slug-collision recovery all still happen. The +provider applies the remaining identity fields afterwards, because +`createOrganisation()` takes only a name and a description. + +**Update requires ownership.** Membership is enough to read the projection; +writing needs `OrganisationService::isOrganisationAdmin()`, which is the instance +admin or the organisation's owner. The projection and the rest of the app then +cannot disagree about who administers an organisation. + +**Update does not follow a merge chain, although read does.** Following one on a +write would silently edit the survivor while the caller believes it is editing +the record it addressed. + +**Delete refuses.** An organisation is the tenant boundary; deleting one through +the object API would orphan every object scoped to it, from a caller that thinks +it is removing a reference record. Merging is the operation that retires an +organisation, and it keeps stored references pointing at a record that still +owns something. + +## The part that would have shipped inert + +`SeedDirectoryVirtualSchemas::ensureSchema()` returns the moment the schema is +found. Setting `readOnly: false` only on the create branch would have reached +fresh installs and no existing instance, and `delegateObjectSourceWrite()` reads +exactly that annotation. The provider would implement the interface, the seed +would report success, and every write would still be refused with a generic +"read-only projection" message that names the provider rather than the +annotation. + +So the seed now reconciles the flag on an existing schema too, touching only the +`readOnly` key and leaving the rest of the configuration as the instance has it. +A reconcile that fails logs at ERROR with the consequence spelled out, rather +than degrading into `run()`'s generic warning: the schema exists and works, it +just refuses writes, which is the kind of failure that reads as a different bug +entirely. + +## What this unblocks, and what it does not + +It unblocks stackiq's migration (stackiq#944 landed the resolution; the schema +retirement waits on this). It does not perform any migration itself. Each app +still needs its own change: a schema for the properties the projection has no +column for, the reading sites repointed, then +`openregister:organisations:adopt` and `openregister:schemas:prune-retired`. + +## Verified against a live instance, not only in unit tests + +Task 4.3 exists because a passing seed test proves the code and not the +migration. On a running instance whose `nc-organisation` was already seeded +`readOnly: true`: + +- `occ maintenance:repair` flipped it to `readOnly: false`, and left `nc-user`, + `nc-group`, `nc-contact`, `nc-event`, `nc-file`, `nc-card`, + `nc-conversation` and `nc-task` read-only. +- `POST /api/objects/2/38` created a real organisation: uuid preserved as the + object id, slug `e2e-probe-organisatie` generated, owner `admin` assigned, OIN + applied. That is the lifecycle having run, not a row inserted. +- `PUT` on it wrote `summary` through and read back changed. + +## Two things this leaves standing + +**A delete over HTTP is refused by the wrong thing.** `ObjectsController::destroy()` +resolves the uuid through `MagicMapper` before the object-source dispatch, and +answers 404 when the register/schema table does not exist. So `remove()` is +never reached and its message never surfaces. The organisation survives, which +is the outcome this change needs, but the refusal is an accident of routing +rather than the guard: it is a pre-existing gap shared with every read-only +projection, and it would stop refusing the moment that lookup learns about +virtual objects. The provider's refusal is what makes it durable, and it is +tested at that level. + +**`remove()` refusing means an organisation created through the projection by +mistake cannot be removed through it.** That is deliberate, and the merge +command is the answer. If a "create and immediately undo" flow turns out to be +needed, it belongs behind the organisation lifecycle, not behind the object +API. diff --git a/openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md b/openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md new file mode 100644 index 0000000000..5188d62a2b --- /dev/null +++ b/openspec/changes/the-organisation-projection-is-writable/specs/organisation-projection/spec.md @@ -0,0 +1,150 @@ +# Organisation projection + +## MODIFIED 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 support create and update, and MUST refuse delete. It was +read-only, on the reasoning that a write path would be a second way to mutate a +tenant that bypasses the organisation lifecycle. That reasoning holds; the +conclusion did not. A read-only projection cannot replace the leaf-app +`organization` schemas it exists to retire, because the apps that declared them +create organisations. So the write path exists and goes THROUGH the lifecycle +rather than around it (REQ-ORP-104). + +#### 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. + +## ADDED Requirements + +### Requirement: An organisation can be created through the projection (REQ-ORP-104) + +Creating an object on the `nc-organisation` schema MUST create an organisation +through the organisation lifecycle, not through the mapper, so that slug +generation, owner assignment, the admin-user membership and the admin-group RBAC +grant all happen as they do for an organisation created anywhere else. + +A create without an acting user MUST be refused. A create without a name MUST be +refused rather than defaulted: the slug is derived from the name, so an empty +name would produce a tenant whose slug the next create collides with. + +The `nc-organisation` schema MUST carry `x-openregister-object-source.readOnly: +false`, and no other virtual schema may. The save dispatch reads that annotation +before it will delegate a write, so a provider that implements the write +interface while its schema still says `readOnly: true` refuses every write with +nothing naming the annotation as the reason. + +That annotation MUST be reconciled on an EXISTING schema, not only set on a new +one. The seed returns early when the schema is found, so an instance that +already seeded `nc-organisation` would otherwise keep the `readOnly: true` it was +created with, and the capability would reach fresh installs only. + +#### Scenario: A create goes through the lifecycle + +- **WHEN** an object is created on `nc-organisation` with a name +- **THEN** an organisation exists carrying that name, a generated slug, the + acting user as owner, and the admin-group RBAC grant. + +#### Scenario: A create without a name is refused + +- **WHEN** an object is created on `nc-organisation` with no name, or an empty one +- **THEN** the create is refused. + +#### Scenario: An already-seeded schema is flipped, not skipped + +- **GIVEN** an instance whose `nc-organisation` schema carries `readOnly: true` +- **WHEN** the directory-schema seed runs +- **THEN** the schema carries `readOnly: false`. + +### Requirement: Only an organisation's administrator may write it (REQ-ORP-105) + +Membership is enough to READ the projection. Writing MUST require that the +acting user administers the organisation, which is the instance admin or the +organisation's owner, decided by the same check the rest of the app uses so the +projection and the app cannot disagree about who administers an organisation. + +A write on an organisation the acting user does not administer MUST be refused +with the same response as a write on one that does not exist, so the projection +stays unusable as an enumeration oracle. The distinction MUST be logged, because +an administrator reading the log needs it. + +A write MUST NOT follow a merge chain, even though a read does. Following one +would silently edit the surviving organisation while the caller believes it is +editing the record it addressed. A write on a merged-away organisation MUST be +refused. + +#### Scenario: A member who is not the owner cannot write + +- **GIVEN** an organisation the acting user belongs to but does not own +- **WHEN** they update it through the projection +- **THEN** the write is refused. + +#### Scenario: A write on a merged-away organisation is refused + +- **GIVEN** an organisation that was merged into another +- **WHEN** it is updated by its own uuid +- **THEN** the write is refused rather than applied to the survivor. + +### Requirement: An organisation cannot be deleted through the projection (REQ-ORP-106) + +Deleting an object on `nc-organisation` MUST be refused, always. + +An organisation is the tenant boundary: every object, register and schema on the +instance is scoped to one. Deleting it through the object API would orphan all +of that, from a caller that believes it is removing a reference record. Merging +is the operation that exists for retiring an organisation, and it keeps every +stored reference pointing at a record that still owns something. + +The refusal MUST come from the provider, not from an accident of routing. Over +HTTP today a delete on any virtual schema is refused EARLIER, by +`ObjectsController::destroy()` resolving the uuid through `MagicMapper` and +answering 404 when the register/schema table does not exist, so `remove()` is +never reached and the message never surfaces. That is a pre-existing gap in the +virtual-schema delete path, shared with every read-only projection, and it is +not what this requirement rests on: it would stop refusing the moment that +lookup learns about virtual objects. + +#### Scenario: A delete is refused + +- **WHEN** the provider is asked to remove an organisation +- **THEN** the delete is refused, and the refusal names merging as the operation + that retires an organisation. + +#### Scenario: A delete over HTTP does not remove the organisation + +- **WHEN** an object is deleted on `nc-organisation` through the object API +- **THEN** the organisation still exists afterwards. + +### Requirement: Only the identity facet is writable (REQ-ORP-107) + +A write MUST apply only the projected identity properties. Quota, users, groups +and authorization are not projected and MUST NOT be reachable through a write, +which is the same boundary the read side draws. + +A property outside the projection MUST be ignored rather than rejected. The +store already discards unprojected properties on the way in, so rejecting here +would fail a request over a field the caller cannot see in the first place. + +#### Scenario: An unprojected property is ignored + +- **WHEN** an organisation is updated with a `quota` property +- **THEN** the update succeeds and the organisation's quota is unchanged. diff --git a/openspec/changes/the-organisation-projection-is-writable/tasks.md b/openspec/changes/the-organisation-projection-is-writable/tasks.md new file mode 100644 index 0000000000..acc332f33d --- /dev/null +++ b/openspec/changes/the-organisation-projection-is-writable/tasks.md @@ -0,0 +1,54 @@ +# Tasks + +## 1. The provider + +- [x] 1.1 `OrganisationObjectSourceProvider` implements + `WritableObjectSourceProvider`. +- [x] 1.2 `insert()` delegates to `OrganisationService::createOrganisation()`, + then applies the remaining projected identity fields. +- [x] 1.3 `insert()` refuses without an acting user, and refuses an empty name + rather than defaulting one. +- [x] 1.4 `update()` writes only the projected properties; anything else is + ignored, matching what the store does with it anyway. +- [x] 1.5 `update()` requires `isOrganisationAdmin()`, and answers a denied write + the way it answers an absent one so the projection is not an enumeration + oracle. The difference is logged. +- [x] 1.6 `update()` does NOT follow a merge chain, and refuses a merged-away + organisation. +- [x] 1.7 `remove()` refuses, naming merging as the operation that retires an + organisation. + +## 2. The annotation + +- [x] 2.1 `NcEntitySemanticMap` carries an optional `writable` flag; only + `nc-organisation` sets it. +- [x] 2.2 The seed writes `readOnly: false` for a writable row on create. +- [x] 2.3 The seed RECONCILES an existing schema whose annotation disagrees. + Without this the flag reaches fresh installs only, and the whole change is + inert on every instance that already seeded the schema. +- [x] 2.4 A failed reconcile logs at ERROR with the consequence, rather than + degrading into `run()`'s generic warning. + +## 3. Wiring + +- [x] 3.1 `Application.php` injects `OrganisationService` into the provider. + +## 4. Verification + +- [x] 4.1 Unit tests for each of 1.2 through 1.7, including the negative cases. +- [x] 4.2 A test that the seed flips an ALREADY-SEEDED schema, which is the + failure mode 2.3 exists for and the one a create-path test cannot see. +- [x] 4.3 Run the repair step against a live instance and read the schema's + annotation back, because a passing seed test proves the code and not the + migration. Then create and update an organisation over HTTP, and confirm a + delete leaves it standing. + +## 5. Not in this change + +- [ ] 5.1 Each app's own migration off its `organization` schema. This unblocks + them; it performs none of them. +- [ ] 5.2 `ObjectsController::destroy()` resolves the uuid through `MagicMapper` + before the object-source dispatch, so a delete on ANY virtual schema + answers 404 rather than the read-only rejection, and `remove()` is never + reached. Pre-existing, shared with every read-only projection, and the + reason the delete refusal is tested at the provider rather than over HTTP. diff --git a/openspec/specs/apphost-store-plane/spec.md b/openspec/specs/apphost-store-plane/spec.md index 7dd6e76854..bab5fd5b4a 100644 --- a/openspec/specs/apphost-store-plane/spec.md +++ b/openspec/specs/apphost-store-plane/spec.md @@ -200,3 +200,73 @@ app's install action needs the payload. `manifest` property - **WHEN** `resolve()` returns it - **THEN** the returned array MUST still contain `manifest` + +### Requirement: A leaf app MUST declare its store rather than implement one + +An adopting app SHALL declare a `store` block in `src/manifest.json` and SHALL +NOT ship a store controller. The engine hosts `/api/store/items` and +`/api/store/items/{slug}/install`, which the app aliases at +`GenericStoreController` the same way it already aliases `/api/health` and +`/api/metrics`. + +This amends ADR-080 Decision 3, which kept `install` per app. That decision +rejected a cross-app controller BASE CLASS, whose three documented failures are +all consequences of `extends` being resolved by the autoloader rather than the +container. Route aliasing uses no inheritance, so none of them apply. The other +half of Decision 3, that install semantics differ per app, is answered by making +the difference data: the only thing that varied was which schemas an install may +write. + +An app that aliases the routes but declares no block MUST report +`not_configured`, not `404`. The page then renders its own items rather than +reading as a broken endpoint. + +#### Scenario: An app with no store block reports not_configured + +- **GIVEN** an app whose manifest has no `store` key +- **WHEN** `GET /api/store/items` is called by a signed-in user +- **THEN** the response MUST be `200` with outcome `not_configured` +- **AND** no network call MUST be made + +### Requirement: An install MUST refuse every schema the manifest does not allow + +The `installable` list is an allowlist and a security boundary. A registry is a +third-party server, so an install MUST write only into the schema slugs the +calling app declares. An absent or empty list MUST refuse **every** component +rather than permit every component: an app that declares a store and omits the +allowlist gets refusals, not an open door. + +A refusal MUST NOT abort the install. The remaining components still arrive and +the per-component report names what did not, because an item that is half +configuration and half records is the registry's mistake rather than a reason to +deny an administrator the half they may have. + +#### Scenario: A component naming an undeclared schema is refused + +- **GIVEN** a manifest whose `installable` is `["caseType"]` +- **WHEN** an item declares a component for schema `case` +- **THEN** nothing MUST be written for it +- **AND** the report MUST mark it `refused` + +#### Scenario: An empty allowlist refuses everything + +- **GIVEN** a manifest whose `store` block declares no `installable` +- **WHEN** any component is installed +- **THEN** every component MUST be refused + +### Requirement: An install MUST create a new object, never replace one + +Every identity key the remote payload carries (`id`, `uuid`, `@self`) MUST be +stripped before the write. `ObjectService::saveObject()` resolves its target +FROM the payload and the write is PUT-semantic, so a component carrying the uuid +of a live local object would replace it and null every key the payload omits. + +The schema allowlist does not cover this: it governs which schema a component +may write, never whether the write creates or replaces, so an entirely +legitimate component is the attack. + +#### Scenario: A payload carrying a local uuid still creates + +- **GIVEN** a component whose object carries `id`, `uuid` and `@self` +- **WHEN** it is installed +- **THEN** the object handed to `saveObject()` MUST carry none of them diff --git a/openspec/specs/archival-annotation-vocabulary/spec.md b/openspec/specs/archival-annotation-vocabulary/spec.md index 18581fa5cb..4e66997302 100644 --- a/openspec/specs/archival-annotation-vocabulary/spec.md +++ b/openspec/specs/archival-annotation-vocabulary/spec.md @@ -76,6 +76,112 @@ When a schema declares `x-openregister-archival`, `ObjectService::deleteObject() - **THEN** the immutability gate SHALL be bypassed - **AND** the row SHALL be deleted via the standard `DeleteObject` handler with an audit-trail entry +### Requirement: Purging an archival record is refused on the same terms as deleting one + +`DELETE /api/deleted/{uuid}` and `DELETE /api/deleted` permanently destroy rows rather than tombstoning them, so they SHALL apply the archival gate that `ObjectService::deleteObject()` applies. When the object's schema declares `x-openregister-archival`, the single-object route SHALL respond `403` with body `{ error: "SCHEMA_ARCHIVAL_IMMUTABLE", message, schema, operation: "purge", hint }`, and the bulk route SHALL count the object as `failed` rather than destroying it. Both routes SHALL fail closed: an object whose schema cannot be resolved SHALL be refused, because an unreadable annotation is indistinguishable from a retained record. + +The gate SHALL read `Schema::hasArchivalAnnotation()`, which is the single definition of the rule for both delete routes. + +#### Scenario: Purging a live archival record is refused +- **GIVEN** the `dossiq/case` schema declares `x-openregister-archival` +- **AND** an admin authenticates and a live `case` row exists with UUID `` +- **WHEN** `DELETE /api/deleted/` is called +- **THEN** the response SHALL be HTTP 403 with `error: "SCHEMA_ARCHIVAL_IMMUTABLE"` +- **AND** the underlying row SHALL still exist in the magic table + +#### Scenario: Purging a soft-deleted archival record is refused +- **GIVEN** a soft-deleted `case` row on the same archival schema +- **WHEN** `DELETE /api/deleted/` is called +- **THEN** the response SHALL be HTTP 403 with `operation: "purge"` +- **AND** the row SHALL still exist + +#### Scenario: Emptying the trash still works for an ordinary record +- **GIVEN** a soft-deleted row on a schema that does NOT declare `x-openregister-archival` +- **WHEN** `DELETE /api/deleted/` is called +- **THEN** the response SHALL be HTTP 200 and the row SHALL be destroyed + +### Requirement: A live object cannot be purged through the trash endpoints + +`DELETE /api/deleted/{uuid}` empties the TRASH. It SHALL refuse any object that is not soft-deleted, with HTTP 400 and `{ error: "Object is not deleted" }`. The soft-delete test SHALL be `ObjectEntity::isSoftDeleted()`; comparing `getDeleted()` with `null` SHALL NOT be used, because `ObjectEntity::$deleted` defaults to `[]` and the row hydrator skips NULL columns, so a live object answers `[]` and such a comparison never refuses anything. + +#### Scenario: Purging a live object is refused +- **GIVEN** a live row on any schema +- **WHEN** `DELETE /api/deleted/` is called +- **THEN** the response SHALL be HTTP 400 with `error: "Object is not deleted"` +- **AND** the row SHALL still exist + +#### Scenario: Bulk purge skips live rows +- **GIVEN** a request naming one live row and one soft-deleted non-archival row +- **WHEN** `DELETE /api/deleted` is called +- **THEN** exactly one row SHALL be destroyed and the live row SHALL be counted as `failed` + +### Requirement: Schema-wide object deletion is refused on an archival schema + +`SchemaDeletionService` is the single implementation of "delete every object of a schema", reached by `POST /api/bulk/{register}/{schema}/delete-objects`, its legacy twin `/delete-schema`, `DELETE /api/schemas/{id}?deleteObjects=true` and `occ openregister:schemas:prune-retired`. It SHALL apply the archival gate that `ObjectService::deleteObject()` applies, reading `Schema::hasArchivalAnnotation()` rather than the annotation itself. + +`deleteObjectsBySchema()` SHALL refuse an archival schema unconditionally and SHALL expose no override parameter: both of its callers are HTTP routes, and an override they could pass through would put archival destruction back on the network. `cascadeDeleteSchema()` SHALL refuse unless the caller passes an explicit `archivalOverride`, which only `occ openregister:schemas:prune-retired --force-archival` passes — the same bargain `occ openregister:objects:purge --force` strikes, on the same reasoning that shell access is an authorization boundary an HTTP caller cannot cross. + +`--force-archival` SHALL be a distinct flag from `--force`. `--force` means "this schema still owns objects"; an operator pruning a retired test schema passes it without having said anything about retained records, so it MUST NOT authorise their destruction. + +The refusal SHALL surface as HTTP 403 with the `{ error: "SCHEMA_ARCHIVAL_IMMUTABLE", message, schema, operation, hint }` body, on both the bulk routes and the schema cascade — not as a 500, which would read as an endpoint bug rather than as the platform protecting a retained record. + +The audit trail SHALL NOT be treated as a substitute for the record. `SchemaDeletionService` writes a hash-chained audit entry per object before removing it, but an entry recording that a record was destroyed does not discharge an obligation to retain it. + +#### Scenario: Bulk delete-objects on an archival schema destroys nothing +- **GIVEN** the `dossiq/retained-case` schema declares `x-openregister-archival` and its magic table holds 3 rows +- **WHEN** `POST /api/bulk/{register}/retained-case/delete-objects` is called with `{"hardDelete": true}` +- **THEN** the response SHALL be HTTP 403 with `error: "SCHEMA_ARCHIVAL_IMMUTABLE"` and `operation: "delete"` +- **AND** all 3 rows SHALL still exist in the magic table +- **AND** no `schema.cascade_delete` audit entry SHALL be written + +#### Scenario: A soft schema-wide delete is refused too +- **WHEN** the same call is made with `{"hardDelete": false}` +- **THEN** the response SHALL be HTTP 403 and no row SHALL be tombstoned + +#### Scenario: The HTTP schema cascade on an archival schema is refused +- **WHEN** `DELETE /api/schemas/{id}?deleteObjects=true` names the archival schema +- **THEN** the response SHALL be HTTP 403 with `error: "SCHEMA_ARCHIVAL_IMMUTABLE"` +- **AND** no transaction SHALL be opened, no row removed, no magic table dropped, and the schema entity SHALL still exist + +#### Scenario: The prune CLI refuses without the archival flag +- **WHEN** `occ openregister:schemas:prune-retired --app dossiq --slug retained-case --apply --force` names the archival schema +- **THEN** the command SHALL skip it, SHALL name `x-openregister-archival` and `--force-archival`, and SHALL destroy nothing + +#### Scenario: The prune CLI proceeds with the archival flag and says so +- **WHEN** the same command is re-run with `--force-archival` +- **THEN** the cascade SHALL run and the output SHALL mark the schema as holding ARCHIVAL RECORDS + +#### Scenario: An ordinary schema is unaffected +- **GIVEN** a schema that does NOT declare `x-openregister-archival` +- **WHEN** any of these paths deletes its objects +- **THEN** the deletion SHALL succeed exactly as before, reporting the count and the UUIDs + +### Requirement: A failed bulk upsert never resolves itself by dropping the table + +`MagicMapper::bulkUpsert()` recovers from a missing magic table by creating it and retrying. The recovery SHALL create only a table that is actually absent, verified against the live connection. It MUST NOT reach `ensureTableForRegisterSchema(force: true)` while the table exists, because that drops the table before recreating it and destroys every row with no audit entry. + +The message test that selects this recovery is wider than a missing table: PostgreSQL phrases a missing COLUMN (42703) as `column "x" of relation "y" does not exist`, which matches the same substring as a missing relation (42P01). A failure that is not a missing table SHALL be re-thrown. + +#### Scenario: An error naming an existing table is surfaced, not resolved destructively +- **GIVEN** a populated magic table for a register/schema pair +- **WHEN** `bulkUpsert()` fails with an error whose message contains `does not exist` while that table exists +- **THEN** the original exception SHALL propagate +- **AND** the table SHALL NOT be dropped and its rows SHALL remain + +### Requirement: An administrative CLI purge is the only path that can destroy an archival record + +The platform SHALL ship `occ openregister:objects:purge ...`. It SHALL be dry-run by default and write only with `--apply`. Without `--force` it SHALL refuse an archival record and SHALL refuse a live (not soft-deleted) object. With `--force` it SHALL destroy either, and SHALL name the record as archival in its output. + +This is deliberately a CLI surface: `occ` requires shell access to the server, which is an authorization boundary an HTTP caller cannot cross, and it is the sanctioned path for removing a record created in error or a test fixture on an archival schema. + +#### Scenario: Archival record refused without force +- **WHEN** `occ openregister:objects:purge --apply` names a row on an archival schema +- **THEN** the command SHALL exit non-zero, SHALL name `x-openregister-archival`, and SHALL destroy nothing + +#### Scenario: Force purges an archival record +- **WHEN** `occ openregister:objects:purge --apply --force` names the same row +- **THEN** the command SHALL exit zero, SHALL report the row as an ARCHIVAL RECORD, and the row SHALL be destroyed + ### Requirement: RetentionConditionEvaluator parses the minimal condition DSL The platform SHALL ship `OCA\OpenRegister\Service\Archival\RetentionConditionEvaluator` exposing `evaluate(string $condition, array $row): bool`. The grammar SHALL be exactly ` ` with operators `<`, `<=`, `==`, `!=`, `>=`, `>` and literals: integer, float, single- or double-quoted string, `true`, `false`, `null`. A field absent from `$row` SHALL evaluate to `false` (no rule match). A malformed condition SHALL throw `\InvalidArgumentException` and SHALL NOT crash the cron — callers are responsible for catching and logging. diff --git a/openspec/specs/data-import-export/spec.md b/openspec/specs/data-import-export/spec.md index 151aa95df0..ac574b10c7 100644 --- a/openspec/specs/data-import-export/spec.md +++ b/openspec/specs/data-import-export/spec.md @@ -796,6 +796,45 @@ size-guard its input, and any cap applied SHALL be logged, never silent. - **THEN** its content is not loaded into memory - **AND** it is skipped with a logged, non-fatal outcome +### Requirement: Raw append for high-volume writers + +The object service SHALL offer a raw append entry point, `appendObjectsRaw()`, +for high-volume, low-value rows such as traffic events and telemetry, and a +matching sweep, `purgeExpiredObjectsRaw()`. The raw append SHALL write straight +into the register+schema table in chunked multi-row statements and SHALL skip +RBAC, multitenancy, schema validation, the audit trail, lifecycle events, the +search index, relations and files. The caller takes the authorisation +responsibility, as with `_rbac: false` (ADR-022). Both entry points SHALL be +part of `ObjectServiceInterface` so a leaf app can reach them without +OpenRegister internals, and neither SHALL alter the service's current +register/schema context. + +#### Scenario: Rows are appended without side effects @e2e exclude server-side PHP API with no UI, covered by the PHPUnit DB test tests/Db/RawAppendIntegrationTest.php +- **GIVEN** a register and a schema the register carries +- **WHEN** a caller passes three plain property maps to `appendObjectsRaw()` +- **THEN** the register+schema table holds three rows, each with a uuid (generated when the caller passed none) +- **AND** no audit trail entry, lifecycle event or search-index update is produced for them +- **AND** the return value is the number of rows written + +#### Scenario: An expiry lands in the indexed expires column @e2e exclude server-side PHP API with no UI, covered by the PHPUnit DB test tests/Db/RawAppendIntegrationTest.php +- **GIVEN** a row passed with an `expires` value +- **WHEN** it is appended +- **THEN** the row's `_expires` column holds that instant +- **AND** a row passed without `expires` keeps `_expires` NULL + +#### Scenario: Expired rows are hard-deleted by the purge @e2e exclude server-side PHP API with no UI, covered by the PHPUnit DB test tests/Db/RawAppendIntegrationTest.php +- **GIVEN** three appended rows of which one has an `expires` in the past +- **WHEN** `purgeExpiredObjectsRaw()` runs for that register and schema +- **THEN** exactly that row is removed, with no soft-delete marker and no audit entry +- **AND** the return value is 1 +- **AND** a register+schema whose table does not exist yet answers 0 + +#### Scenario: Identifiers resolve within the register @e2e exclude server-side PHP API with no UI, covered by the PHPUnit unit test tests/Unit/Service/ObjectServiceRawAppendTest.php +- **GIVEN** a register slug and a schema slug +- **WHEN** either raw entry point is called with them +- **THEN** the schema is resolved among the schemas that register carries, never instance-wide +- **AND** the service's current register and schema context is unchanged afterwards + ### Requirement: Auto-create a missing register from a bundle (REQ-IMP-AC-01) The register-bundle import MUST create a missing register and its referenced diff --git a/openspec/specs/features-roadmap-menu/spec.md b/openspec/specs/features-roadmap-menu/spec.md index 01a4da2ac6..201e524fe9 100644 --- a/openspec/specs/features-roadmap-menu/spec.md +++ b/openspec/specs/features-roadmap-menu/spec.md @@ -5,7 +5,7 @@ status: done # features-roadmap-menu Specification ## Purpose -Adds a Features & Roadmap surface to an app — a dedicated route with a Features tab listing capabilities from a generated `docs/features.json` manifest and a Roadmap tab showing live GitHub issues sorted by reaction count, plus a "Suggest feature" modal that submits new requests. Markdown is rendered with strict DOMPurify sanitization, pipeline labels are filtered out, widgets and pages can declare a `specRef` to pre-fill and filter by capability, and admins can disable the whole feature via an app-config flag. The same manifest powers a public Docusaurus `/features` page, with a CI check keeping it in sync with the specs. +Adds a Features & Roadmap surface to an app — a dedicated route with a Features tab listing capabilities from a generated `docs/features.json` manifest and a Roadmap tab showing live GitHub issues sorted by reaction count, plus a "Suggest feature" link to the forge's issue form. Markdown is rendered with strict DOMPurify sanitization, pipeline labels are filtered out, widgets and pages can declare a `specRef` to pre-fill and filter by capability, and admins can disable the whole feature via an app-config flag. The same manifest powers a public Docusaurus `/features` page, with a CI check keeping it in sync with the specs. ## Requirements ### Requirement: Full route, not side panel @@ -35,8 +35,8 @@ The `CnFeaturesAndRoadmapLink` component SHALL be positioned as the first child ### Requirement: Two tabs with i18n labels The route view SHALL render two tabs labelled "Features" and "Roadmap" in English, or -"Functies" and "Planning" in Dutch. The tab labels, empty-state messages, modal fields, -error messages, and toasts SHALL be translatable via Nextcloud's `t()` / `n()` helpers. +"Functies" and "Planning" in Dutch. The tab labels, empty-state messages, the +"Suggest feature" link, error messages, and toasts SHALL be translatable via Nextcloud's `t()` / `n()` helpers. English (en) and Dutch (nl) MUST be shipped at minimum. #### Scenario: Dutch locale shows Dutch tab labels @@ -77,8 +77,7 @@ each item. Ties SHALL fall back to `created_at` descending. Each roadmap item's body SHALL be rendered as full GitHub-flavored markdown using the `marked` library. Rendered output MUST be sanitized with `DOMPurify` using a strict allowlist config that MUST exclude ` + + diff --git a/src/components/userSettings/PersonalRoot.vue b/src/components/userSettings/PersonalRoot.vue index c139fdc256..3c9ae4da67 100644 --- a/src/components/userSettings/PersonalRoot.vue +++ b/src/components/userSettings/PersonalRoot.vue @@ -15,24 +15,34 @@ - The component is deliberately given the whole broker surface (no `appId` filter): - this is the user's own credential wallet, and a credential is granted to apps via - its `allowedApps` list, not by which page created it. + - + - `OAuth2ConnectionsSection` sits BESIDE it rather than inside it. `CnCredentials` + - lives in @conduction/nextcloud-vue, a different repository, and connecting an + - account is broker behaviour whose status vocabulary belongs to this server; adding + - it to the library first would mean shipping a library release before the server + - that backs it exists. --> diff --git a/src/components/workflow/ApprovalChainPanel.vue b/src/components/workflow/ApprovalChainPanel.vue deleted file mode 100644 index 7b4bc55889..0000000000 --- a/src/components/workflow/ApprovalChainPanel.vue +++ /dev/null @@ -1,133 +0,0 @@ - - - - - diff --git a/src/components/workflow/ApprovalStepList.vue b/src/components/workflow/ApprovalStepList.vue deleted file mode 100644 index 4a5f31e2da..0000000000 --- a/src/components/workflow/ApprovalStepList.vue +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - - diff --git a/src/components/workflow/HookForm.vue b/src/components/workflow/HookForm.vue deleted file mode 100644 index 216786b0a5..0000000000 --- a/src/components/workflow/HookForm.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - - - diff --git a/src/components/workflow/HookList.vue b/src/components/workflow/HookList.vue deleted file mode 100644 index 06b8a6d549..0000000000 --- a/src/components/workflow/HookList.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - - - diff --git a/src/components/workflow/ScheduledWorkflowPanel.vue b/src/components/workflow/ScheduledWorkflowPanel.vue deleted file mode 100644 index 8aae1fd045..0000000000 --- a/src/components/workflow/ScheduledWorkflowPanel.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - - - diff --git a/src/components/workflow/TaskSequencePanel.vue b/src/components/workflow/TaskSequencePanel.vue new file mode 100644 index 0000000000..2966d67528 --- /dev/null +++ b/src/components/workflow/TaskSequencePanel.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/src/components/workflow/WorkflowExecutionDetail.vue b/src/components/workflow/WorkflowExecutionDetail.vue deleted file mode 100644 index 0d1287e2a5..0000000000 --- a/src/components/workflow/WorkflowExecutionDetail.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - - diff --git a/src/components/workflow/WorkflowExecutionPanel.vue b/src/components/workflow/WorkflowExecutionPanel.vue deleted file mode 100644 index 00c049e26d..0000000000 --- a/src/components/workflow/WorkflowExecutionPanel.vue +++ /dev/null @@ -1,181 +0,0 @@ - - - - - diff --git a/src/dialogs/TestHookDialog.vue b/src/dialogs/TestHookDialog.vue deleted file mode 100644 index 9095e335bf..0000000000 --- a/src/dialogs/TestHookDialog.vue +++ /dev/null @@ -1,157 +0,0 @@ -