From 6a661a145f36c6ca5102271758307e8ec37bc4e3 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 9 Oct 2026 14:54:57 +0200 Subject: [PATCH 001/183] fix(flow): a task completion wakes its run with the outcome bag, not an empty signal dossiq signals the run with its own payload; the bridge then overwrote it with []. The wake now carries FlowTaskBridge::outcomeBagFor(). Sibling ask from dossiq remove-casetask 7.2. --- lib/Service/Flow/FlowTaskBridge.php | 7 +++++-- openspec/specs/flow-user-task-node/spec.md | 12 +++++++++++- tests/Unit/Service/Flow/FlowTaskBridgeTest.php | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/lib/Service/Flow/FlowTaskBridge.php b/lib/Service/Flow/FlowTaskBridge.php index b5592d9179..4cc43d2dba 100644 --- a/lib/Service/Flow/FlowTaskBridge.php +++ b/lib/Service/Flow/FlowTaskBridge.php @@ -323,7 +323,10 @@ public static function outcomeBagFor(Task $task): array { * * Three steps, each of which may legitimately do nothing: * - * 1. `signal()` with an empty payload parks the run as due. Null means the + * 1. `signal()` with the task's outcome bag parks the run as due. The bag, + * not an empty payload: an empty one overwrote what a leaf app had + * signalled about the same completion. The node still reads the TASK, + * never this slot. 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, @@ -361,7 +364,7 @@ public function continueRun(Task $task): ?FlowRun { } $runService = $this->container->get(FlowRunService::class); - $woken = $runService->signal(run: $run, payload: []); + $woken = $runService->signal(run: $run, payload: self::outcomeBagFor(task: $task)); if ($woken === null) { return null; } diff --git a/openspec/specs/flow-user-task-node/spec.md b/openspec/specs/flow-user-task-node/spec.md index bfabdf421d..05b5400162 100644 --- a/openspec/specs/flow-user-task-node/spec.md +++ b/openspec/specs/flow-user-task-node/spec.md @@ -69,7 +69,11 @@ context's signal slot as an answer: a signal is one slot per run, consumed by the walk it wakes, and a flow with two user-task nodes would otherwise have the second read an answer given to the first. -Completing the task SHALL make the run due. A completion that is delivered +Completing the task SHALL make the run due. The wake SHALL carry the task's +outcome bag (the same bag the node writes onto the items) in the run's signal +slot, never an empty payload: the slot is still not an answer the node reads, +but an empty wake overwrote whatever a leaf app had signalled about the same +completion and left the run's context saying nothing about it. A completion that is delivered while the run is still mid-walk, or whose delivery fails, SHALL NOT strand the run: the node SHALL suspend with a heartbeat `resumeAt` so a lost wake costs one heartbeat interval rather than the flow. The node SHALL NOT @@ -112,6 +116,12 @@ task and the run's suspension unchanged. - **THEN** it MUST NOT be null - @e2e exclude covered by a unit test asserting the thrown suspension +#### Scenario: The wake carries the outcome bag +- @e2e exclude bridge payload, covered by PHPUnit (FlowTaskBridgeTest) +- **GIVEN** a run suspended on a user-task node +- **WHEN** its task is completed with outcome `approved` by `alice` +- **THEN** the run's signal slot SHALL hold the task's outcome bag, with `outcome: approved` and `completedBy: alice` + ### Requirement: The outcome is written onto every item, not only onto the run When the node continues, it SHALL write the task's completion result onto diff --git a/tests/Unit/Service/Flow/FlowTaskBridgeTest.php b/tests/Unit/Service/Flow/FlowTaskBridgeTest.php index ace75fa33c..719808cce1 100644 --- a/tests/Unit/Service/Flow/FlowTaskBridgeTest.php +++ b/tests/Unit/Service/Flow/FlowTaskBridgeTest.php @@ -201,6 +201,24 @@ public function testTheDefaultBudgetParksForTheWorker(): void { $this->assertNotNull($after->getResumeAt(), 'the run must be due for the worker'); }//end testTheDefaultBudgetParksForTheWorker() + /** + * The wake carries the task's outcome bag, never an empty payload: an + * empty signal overwrote what a leaf app had signalled (decision, node, + * taskId, completedBy) and left the run's signal slot saying nothing. + */ + public function testTheWakeCarriesTheOutcomeBag(): void { + $run = $this->suspendedRun(advance: null); + $this->runs->method('findByUuid')->willReturn($run); + $task = $this->terminalTask(); + $task->setCompletedBy('alice'); + $this->runService->expects($this->once()) + ->method('signal') + ->with($run, FlowTaskBridge::outcomeBagFor(task: $task)) + ->willReturn($run); + + $this->bridge->continueRun(task: $task); + }//end testTheWakeCarriesTheOutcomeBag() + /** * A budget of N continues THE STREAM parked on the node, for N + 1 * firings (the node's own re-entry is the completion landing), through From 42a46ab1456daa46c599d6e81faf02acfadcf5ff Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 9 Oct 2026 14:54:58 +0200 Subject: [PATCH 002/183] fix(archival): category, categoryProperty and action are known archival keys ArchivalDeclarationReader and ClassificationOverride read them, yet every schema import that declared them logged them as ignored. Sibling ask from dossiq the-case-archives-through-openregister 1.1. --- .../Archival/ArchivalAnnotationValidator.php | 21 +++++++++++---- .../archival-annotation-vocabulary/spec.md | 8 +++++- .../ArchivalAnnotationValidatorTest.php | 26 ++++++++++++++++++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/lib/Service/Archival/ArchivalAnnotationValidator.php b/lib/Service/Archival/ArchivalAnnotationValidator.php index e4b9adc84c..c75f399c33 100644 --- a/lib/Service/Archival/ArchivalAnnotationValidator.php +++ b/lib/Service/Archival/ArchivalAnnotationValidator.php @@ -52,14 +52,25 @@ final class ArchivalAnnotationValidator { /** * Allowed keys directly under `x-openregister-archival`. * - * `retention` is the disposal decision. The other three are the archival - * facts MDTO asks for that nothing in openregister used to write, so an - * export could only ever omit them; see the archival-conformance A3 - * finding. + * `retention` is the disposal decision. `aggregationLevel`, + * `useRestriction` and `temporalCoverage` are the archival facts MDTO asks + * for that nothing in openregister used to write, so an export could only + * ever omit them; see the archival-conformance A3 finding. `category`, + * `categoryProperty` and `action` are read by ArchivalDeclarationReader and + * ClassificationOverride, so calling them unknown was a false warning on + * every import of a schema that declares them. * * @var array */ - private const ALLOWED_ANNOTATION_KEYS = ['retention', 'aggregationLevel', 'useRestriction', 'temporalCoverage']; + private const ALLOWED_ANNOTATION_KEYS = [ + 'retention', + 'aggregationLevel', + 'useRestriction', + 'temporalCoverage', + 'category', + 'categoryProperty', + 'action', + ]; /** * Allowed top-level keys under `retention`. diff --git a/openspec/specs/archival-annotation-vocabulary/spec.md b/openspec/specs/archival-annotation-vocabulary/spec.md index e80e3d3ffb..c055bf3273 100644 --- a/openspec/specs/archival-annotation-vocabulary/spec.md +++ b/openspec/specs/archival-annotation-vocabulary/spec.md @@ -56,12 +56,18 @@ The platform SHALL ship an `OCA\OpenRegister\Service\Archival\ArchivalAnnotation #### Scenario: Unknown key at the top level is reported, not rejected - @e2e exclude schema-save validation — covered by PHPUnit -- **GIVEN** a schema declares `x-openregister-archival = { retention: { default: "P7Y" }, category: "Archiefwet 1995 selectielijst", action: "destroy" }` +- **GIVEN** a schema declares `x-openregister-archival = { retention: { default: "P7Y" }, aggregatieniveau: "Dossier" }` - **WHEN** the schema is saved - **THEN** the save SHALL succeed - **AND** `SchemaMapper` SHALL log one warning naming the schema and every ignored key - **AND** the import of a register declaring that schema SHALL link it, not drop it +#### Scenario: A key the archive reader reads is not unknown +- @e2e exclude schema-save validation — covered by PHPUnit +- **GIVEN** a schema declares `x-openregister-archival = { retention: { default: "P7Y" }, category: "1.1", categoryProperty: "selectielijstCategorie", action: "bewaren" }` +- **WHEN** the schema is saved +- **THEN** no `archival-unknown-key` finding SHALL be made, because `ArchivalDeclarationReader` and `ClassificationOverride` read all three keys + #### Scenario: Well-formed annotation passes - **GIVEN** a schema declares `x-openregister-archival.retention = { default: "P30D", rules: [{ condition: "statusCode < 400", retention: "PT1H", reason: "successful integrations" }] }` - **WHEN** the schema is saved diff --git a/tests/Unit/Service/Archival/ArchivalAnnotationValidatorTest.php b/tests/Unit/Service/Archival/ArchivalAnnotationValidatorTest.php index ef78a3f98c..9fb256bac4 100644 --- a/tests/Unit/Service/Archival/ArchivalAnnotationValidatorTest.php +++ b/tests/Unit/Service/Archival/ArchivalAnnotationValidatorTest.php @@ -74,6 +74,28 @@ public function testDeclaredArchivalFactsPass(): void { self::assertSame([], $errors); }//end testDeclaredArchivalFactsPass() + /** + * `category`, `categoryProperty` and `action` are read by + * ArchivalDeclarationReader and ClassificationOverride. Every dossiq case + * schema import logged them as ignored while they were being used. + * + * @return void + */ + public function testKeysTheArchiveReaderReadsAreNotUnknown(): void { + $findings = $this->validator->validate( + [ + 'x-openregister-archival' => [ + 'retention' => ['default' => 'P7Y'], + 'category' => '1.1', + 'categoryProperty' => 'selectielijstCategorie', + 'action' => 'bewaren', + ], + ] + ); + + self::assertSame([], $findings); + }//end testKeysTheArchiveReaderReadsAreNotUnknown() + public function testUnknownTopLevelKeyIsReportedAsAWarningNotAnError(): void { $findings = $this->validator->validate( [ @@ -129,7 +151,9 @@ public function testExtraDescriptiveKeysBesideAValidRetentionBlockDoNotRefuseThe ArchivalAnnotationValidator::partition(findings: $filinq)['errors'], "filinq's correspondence schema must still import." ); - self::assertCount(3, ArchivalAnnotationValidator::partition(findings: $filinq)['warnings']); + // `category` and `action` are read by the archive reader now, so only + // `responsibleParty` is still an unknown key. + self::assertCount(1, ArchivalAnnotationValidator::partition(findings: $filinq)['warnings']); $pipelinq = $this->validator->validate( [ From 1957771e5c3bd42be8689db7644d62d8e6c10b0c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 9 Oct 2026 14:55:00 +0200 Subject: [PATCH 003/183] feat(search): a deep-link template may name an object property The formatter hands the object's top-level scalar properties (URL-encoded, metadata winning) to DeepLinkRegistration::resolveUrl(), so /apps/dossiq/cases/{case} is filled. Sibling ask from dossiq unified-search-opens-every-hit-in-dossiq 2.2/2.3. --- .../Search/ObjectSearchResultFormatter.php | 41 ++++++++++++++++- .../specs/unified-search-provider/spec.md | 23 ++++++++++ .../ObjectSearchResultFormatterTest.php | 46 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/lib/Service/Search/ObjectSearchResultFormatter.php b/lib/Service/Search/ObjectSearchResultFormatter.php index 560af39ea3..ad2769962a 100644 --- a/lib/Service/Search/ObjectSearchResultFormatter.php +++ b/lib/Service/Search/ObjectSearchResultFormatter.php @@ -115,13 +115,16 @@ public function format(array|ObjectEntity $result, ?string $term): SearchResultE $uuid = $selfData['id'] ?? $result['id'] ?? ''; // Build a flat data array for deep link URL resolution. The - // resolveUrl method needs {uuid} and other top-level keys. + // resolveUrl method needs {uuid} and other top-level keys. The + // object's own scalar properties come first so a template can name + // one (`/apps/dossiq/cases/{case}`); the metadata wins on a clash. $selfArray = []; if (is_array($selfData) === true) { $selfArray = $selfData; } $flatData = array_merge( + $this->linkableProperties(result: $result), $selfArray, ['uuid' => $uuid, 'register' => $registerId, 'schema' => $schemaId] ); @@ -177,6 +180,42 @@ public function format(array|ObjectEntity $result, ?string $term): SearchResultE ); }//end format() + /** + * The object's top-level scalar properties, URL-encoded, for a deep-link template. + * + * A property value is record data, so it is encoded: a `/` or `?` in it + * must not change which page the link opens. + * + * @param array $result The rendered object. + * + * @return array Property name to encoded value. + * + * @spec openspec/specs/unified-search-provider/spec.md#requirement-a-deep-link-template-may-name-an-object-property + */ + private function linkableProperties(array $result): array { + $properties = []; + foreach ($result as $key => $value) { + if (is_string($key) === false || str_starts_with($key, '@') === true) { + continue; + } + + if (is_scalar($value) === false) { + continue; + } + + if (is_bool($value) === true) { + $value = match ($value) { + true => 'true', + false => 'false', + }; + } + + $properties[$key] = rawurlencode((string)$value); + } + + return $properties; + }//end linkableProperties() + /** * Icon precedence: * 1. the schema's own MDI icon (an explicit, per-schema choice by the diff --git a/openspec/specs/unified-search-provider/spec.md b/openspec/specs/unified-search-provider/spec.md index 55bf4e66d4..521b485fc2 100644 --- a/openspec/specs/unified-search-provider/spec.md +++ b/openspec/specs/unified-search-provider/spec.md @@ -191,6 +191,29 @@ second declaration mechanism. - WHEN the entry is built - THEN its URL is the `openregister.objects.show` route for that register, schema, and UUID +### Requirement: A deep-link template may name an object property + +The data handed to `DeepLinkRegistryService::resolveUrl()` SHALL carry the +object's own top-level scalar properties beside the `@self` metadata, so a +template such as `/apps/dossiq/cases/{case}` is filled. Each property value +SHALL be URL-encoded, because it is record data and must not change which page +the link opens. On a name clash the metadata (`uuid`, `register`, `schema` and +the `@self` keys) SHALL win. A property whose value is not scalar SHALL NOT be +offered as a link value. + +#### Scenario: A template names a property of the object +- @e2e exclude formatter data assembly, covered by PHPUnit (ObjectSearchResultFormatterTest) +- GIVEN dossiq registered `urlTemplate: '/apps/dossiq/cases/{case}'` +- AND a matched object carries `case: "c-42"` +- WHEN the entry is built +- THEN its URL is `/apps/dossiq/cases/c-42` + +#### Scenario: A property cannot override the metadata +- @e2e exclude formatter data assembly, covered by PHPUnit (ObjectSearchResultFormatterTest) +- GIVEN a matched object carries a property named `uuid` with another value +- WHEN the entry is built +- THEN `{uuid}` is filled from the object's `@self.id` + ### Requirement: Result sublines MUST contain an excerpt of the matched content When the search was term-driven, the subline MUST end with an excerpt: diff --git a/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php b/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php index 51ab6ef4f4..4ed6fa18a5 100644 --- a/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php +++ b/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php @@ -360,6 +360,52 @@ public function testFormatPrefersDeepLinkUrl(): void { $this->assertSame('/apps/pipelinq/#/clients/u1', $entry->jsonSerialize()['resourceUrl']); }//end testFormatPrefersDeepLinkUrl() + /** + * A deep-link template may name an object property (`{case}`): the + * formatter hands the object's top-level scalar properties to the + * registry, URL-encoded, with the metadata winning on a clash. + * + * @return void + */ + public function testDeepLinkDataCarriesTheObjectsOwnScalarProperties(): void { + $schema = new Schema(); + $this->schemaMapper->method('find')->willReturn($schema); + $this->deepLinkRegistry->method('resolveIcon')->willReturn(null); + $this->deepLinkRegistry->method('resolveDisplayName')->willReturn(null); + $this->deepLinkRegistry->expects($this->once()) + ->method('resolveUrl') + ->with( + 1, + 2, + $this->callback( + function (array $data): bool { + $this->assertSame('c-42', $data['case'] ?? null); + $this->assertSame('a%2Fb%3Fx%3D1', $data['ref'] ?? null, 'a property value is URL-encoded'); + $this->assertSame('u1', $data['uuid'], 'metadata wins over a property of the same name'); + $this->assertSame(2, $data['schema']); + $this->assertArrayNotHasKey('tags', $data, 'a non-scalar property is not a link value'); + + return true; + } + ) + ) + ->willReturn('/apps/dossiq/cases/c-42'); + + $entry = $this->formatter->format( + [ + 'title' => 'Zaak 42', + 'case' => 'c-42', + 'ref' => 'a/b?x=1', + 'uuid' => 'spoofed', + 'schema' => 'spoofed', + 'tags' => ['a'], + '@self' => ['id' => 'u1', 'register' => 1, 'schema' => 2], + ], + null + ); + $this->assertSame('/apps/dossiq/cases/c-42', $entry->jsonSerialize()['resourceUrl']); + }//end testDeepLinkDataCarriesTheObjectsOwnScalarProperties() + /** * Test format() title fallback chain: title -> @self.name -> uuid. * From d98664ebd9b45e9ee991744966ce583acaa1e7de Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 9 Oct 2026 15:08:24 +0200 Subject: [PATCH 004/183] feat(notifications): a rule can mail an address it reads through a reference {kind: email, field: supplierRef.contactEmail} reads the reference as the system, keeps it inside the object's tenant (ReferenceTenantGuard), asks integriq before sending, and records each outcome. Decision 128; sibling ask from dossiq portal-contribution T3. --- .../AnnotationNotificationDispatcher.php | 85 +++++ .../NotificationAnnotationValidator.php | 22 +- .../ReferencedAddressNotifier.php | 307 ++++++++++++++++++ .../specs/external-recipient-opt-out/spec.md | 25 ++ .../AnnotationNotificationDispatcherTest.php | 56 ++++ .../NotificationAnnotationValidatorTest.php | 24 ++ .../ReferencedAddressNotifierTest.php | 232 +++++++++++++ 7 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 lib/Service/Notification/ReferencedAddressNotifier.php create mode 100644 tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php diff --git a/lib/Service/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php index ebcb7f2d72..0b9bc9b6cc 100644 --- a/lib/Service/Notification/AnnotationNotificationDispatcher.php +++ b/lib/Service/Notification/AnnotationNotificationDispatcher.php @@ -565,6 +565,19 @@ public function dispatchWithSchema(ObjectEntity $object, string $trigger, array category: $partiesCategory ); + // `{kind: email, field: ...}` mails an address read off the object + // or through a reference on it (decision 128). Like parties, it + // resolves to no account, so it counts towards the same reach. + $partiesReached += $this->dispatchToAddresses( + recipientsSpec: (array)($spec['recipients'] ?? []), + object: $object, + channels: $channels, + ruleId: (string)$name, + subject: $broadcastSubject, + body: $partiesBody, + category: $partiesCategory + ); + if (count($recipients) === 0) { // 🔴 IT USED TO `continue` IN SILENCE. No log, no counter, no // complaint — and declared groups ship EMPTY across this fleet, @@ -3272,6 +3285,78 @@ private function dispatchToParties( return $reached; }//end dispatchToParties() + /** + * Mail the `email` recipients of a rule: addresses read off the object or through a reference. + * + * Resolved from the container for the same reason as the party service: + * it reads objects, and the object layer dispatches notifications. + * + * @param array $recipientsSpec The rule's `recipients` declaration. + * @param ObjectEntity $object The triggering object. + * @param array $channels The rule's channels. + * @param string $ruleId The rule, for the history row. + * @param string $subject The rule's subject, in the default locale. + * @param string $body The rule's message, in the default locale; the subject when it has none. + * @param string $category The rule's message category, for integriq's opt-out question. + * + * @return int How many addresses were put to the mail path (refusals included, missing addresses not). + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-a-rule-may-mail-an-address-it-reads-through-a-reference-req-ero-007 + */ + private function dispatchToAddresses( + array $recipientsSpec, + ObjectEntity $object, + array $channels, + string $ruleId, + string $subject, + string $body, + string $category, + ): int { + if (in_array('email', $channels, true) === false) { + return 0; + } + + $hasEmailKind = false; + foreach ($recipientsSpec as $recipient) { + if (is_array($recipient) === true && (string)($recipient['kind'] ?? '') === 'email') { + $hasEmailKind = true; + break; + } + } + + if ($hasEmailKind === false) { + return 0; + } + + $outcomes = $this->serverContainer->get(ReferencedAddressNotifier::class)->notify( + recipientsSpec: $recipientsSpec, + object: $object, + subject: $subject, + body: $body, + category: $category + ); + + $reached = 0; + foreach ($outcomes as $outcome) { + $unaddressed = [ReferencedAddressNotifier::OUTCOME_NO_ADDRESS, ReferencedAddressNotifier::OUTCOME_REFERENCE_UNRESOLVED]; + if (in_array($outcome['outcome'], $unaddressed, true) === false) { + $reached++; + } + + $this->recordHistory( + ruleId: $ruleId, + channel: 'email', + recipient: 'email-field:' . $outcome['field'], + status: $outcome['outcome'], + object: $object, + subject: $subject, + locale: null + ); + } + + return $reached; + }//end dispatchToAddresses() + /** * Send a transactional email to a Nextcloud user. * diff --git a/lib/Service/Notification/NotificationAnnotationValidator.php b/lib/Service/Notification/NotificationAnnotationValidator.php index 44f5c0c118..53c0687cbd 100644 --- a/lib/Service/Notification/NotificationAnnotationValidator.php +++ b/lib/Service/Notification/NotificationAnnotationValidator.php @@ -49,7 +49,7 @@ final class NotificationAnnotationValidator { private const VALID_TRIGGERS = ['created', 'updated', 'transition', 'scheduled', 'threshold', 'calculatedChange']; - private const VALID_RECIPIENT_KINDS = ['users', 'field', 'groups', 'role', 'relation', 'object-acl', 'expression', 'watchers', 'parties']; + private const VALID_RECIPIENT_KINDS = ['users', 'field', 'groups', 'role', 'relation', 'object-acl', 'expression', 'watchers', 'parties', 'email']; private const VALID_CHANNELS = ['nc-notification', 'email', 'activity', 'webhook', 'talk', 'web-push']; @@ -833,6 +833,26 @@ public function validate(array $schema): array { } } + if ($kind === 'email') { + // `supplierRef.contactEmail`: the first segment is a property + // of this schema (the address itself, or a reference to the + // object holding it). The rest lives on the other schema and + // is checked at dispatch, where a missing value is recorded. + $field = trim((string)($recipient['field'] ?? '')); + $head = explode('.', $field)[0]; + if ($field === '' || in_array($head, $propKeys, true) === false) { + $errors[] = [ + 'code' => 'notification-recipient-field-unknown', + 'message' => sprintf( + 'Notification "%s" recipient[%d] kind=email field "%s" does not start with a property declared on the schema.', + $name, + $i, + $field + ), + ]; + } + } + if ($kind === 'object-acl') { $perm = (string)($recipient['permission'] ?? ''); if (in_array($perm, ['read', 'manage'], true) === false) { diff --git a/lib/Service/Notification/ReferencedAddressNotifier.php b/lib/Service/Notification/ReferencedAddressNotifier.php new file mode 100644 index 0000000000..e067c40582 --- /dev/null +++ b/lib/Service/Notification/ReferencedAddressNotifier.php @@ -0,0 +1,307 @@ + + * @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/specs/external-recipient-opt-out/spec.md#requirement-a-rule-may-mail-an-address-it-reads-through-a-reference-req-ero-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Calculation\ReferenceTenantGuard; +use OCA\OpenRegister\Service\ObjectService; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Resolves and mails the `email` recipient kind. + * + * The address is record data, so it is never trusted as an account: it is + * mailed as a bare address, after integriq's opt-out check, exactly like a + * party's address. A reference is read as the system (the dispatcher runs in + * whatever session saved the object) and bounded to the object's tenant by + * the same guard calculations use, so a reference cannot reach an address in + * another organisation's register. + */ +class ReferencedAddressNotifier { + + /** + * Outcome when the field, or the reference it goes through, holds no usable address. + * + * @var string + */ + public const OUTCOME_NO_ADDRESS = 'no-address'; + + /** + * Outcome when the reference names an object the tenant may not read, or none at all. + * + * @var string + */ + public const OUTCOME_REFERENCE_UNRESOLVED = 'reference-unresolved'; + + /** + * Outcome when integriq refused the address. + * + * @var string + */ + public const OUTCOME_REFUSED_OPTED_OUT = 'refused-opted-out'; + + /** + * Outcome when integriq did not answer. + * + * @var string + */ + public const OUTCOME_AUTHORITY_UNAVAILABLE = 'authority-unavailable'; + + /** + * Wire the collaborators. + * + * @param ObjectService $objectService Reads the referenced object. + * @param ReferenceTenantGuard $tenantGuard Keeps the reference inside the object's tenant. + * @param OptOutAuthority $optOut Integriq's opt-out answer. + * @param EmailSender $email The shared mail send unit. + * @param LoggerInterface $logger For a reference read that failed. + * + * @return void + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-a-rule-may-mail-an-address-it-reads-through-a-reference-req-ero-007 + */ + public function __construct( + private readonly ObjectService $objectService, + private readonly ReferenceTenantGuard $tenantGuard, + private readonly OptOutAuthority $optOut, + private readonly EmailSender $email, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Mail every `email` recipient of a rule. + * + * @param array $recipientsSpec The rule's `recipients`. + * @param ObjectEntity $object The object the rule fired on. + * @param string $subject The rendered subject. + * @param string $body The rendered body. + * @param string $category The rule's message category. + * + * @return array What happened per recipient entry. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-a-rule-may-mail-an-address-it-reads-through-a-reference-req-ero-007 + */ + public function notify(array $recipientsSpec, ObjectEntity $object, string $subject, string $body, string $category): array { + $resolved = []; + foreach ($recipientsSpec as $recipient) { + if (is_array($recipient) === false || (string)($recipient['kind'] ?? '') !== 'email') { + continue; + } + + $field = trim((string)($recipient['field'] ?? '')); + if ($field === '') { + continue; + } + + $resolved[] = ['field' => $field] + $this->addressFor(object: $object, field: $field); + } + + if ($resolved === []) { + return []; + } + + $askable = []; + foreach ($resolved as $entry) { + if ($entry['address'] !== null) { + $askable[] = $entry['address']; + } + } + + $decisions = $this->optOut->ask( + channel: 'email', + category: $category, + addresses: $askable, + correlationId: 'openregister-email-field:' . (string)$object->getUuid() + ); + + $outcomes = []; + foreach ($resolved as $entry) { + $outcomes[] = [ + 'field' => $entry['field'], + 'outcome' => $this->send(entry: $entry, decisions: $decisions, subject: $subject, body: $body), + ]; + } + + return $outcomes; + }//end notify() + + /** + * The address a field path names, or why there is none. + * + * One segment reads the address off the object itself. More segments read + * the first as a reference, load that object, and read the rest of the + * path inside it. + * + * @param ObjectEntity $object The object the rule fired on. + * @param string $field The dotted field path. + * + * @return array{address: string|null, outcome: string} The address, or null with the reason. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-a-rule-may-mail-an-address-it-reads-through-a-reference-req-ero-007 + */ + public function addressFor(ObjectEntity $object, string $field): array { + $data = ($object->getObject() ?? []); + $segments = explode('.', $field); + $head = array_shift($segments); + + if ($segments === []) { + return $this->asAddress(value: ($data[$head] ?? null)); + } + + $referenced = $this->referenced(object: $object, reference: ($data[$head] ?? null)); + if ($referenced === null) { + return ['address' => null, 'outcome' => self::OUTCOME_REFERENCE_UNRESOLVED]; + } + + $value = ($referenced->getObject() ?? []); + foreach ($segments as $segment) { + if (is_array($value) === false || array_key_exists($segment, $value) === false) { + return ['address' => null, 'outcome' => self::OUTCOME_NO_ADDRESS]; + } + + $value = $value[$segment]; + } + + return $this->asAddress(value: $value); + }//end addressFor() + + /** + * Send one resolved entry, or say why it was not sent. + * + * @param array{field: string, address: string|null, outcome: string} $entry The resolved entry. + * @param array> $decisions Integriq's answers. + * @param string $subject The subject. + * @param string $body The body. + * + * @return string The outcome. + */ + private function send(array $entry, array $decisions, string $subject, string $body): string { + if ($entry['address'] === null) { + return $entry['outcome']; + } + + $decision = $this->optOut->decisionFor(decisions: $decisions, address: $entry['address']); + if ($decision['send'] !== true) { + if ($decision['code'] === OptOutAuthority::CODE_AUTHORITY_UNAVAILABLE) { + return self::OUTCOME_AUTHORITY_UNAVAILABLE; + } + + return self::OUTCOME_REFUSED_OPTED_OUT; + } + + return $this->email->sendToAddress( + address: $entry['address'], + displayName: '', + subject: $subject, + body: $this->optOut->withLink(body: $body, unsubscribe: $decision['unsubscribe']), + unsubscribe: $decision['unsubscribe'] + ); + }//end send() + + /** + * The object a reference value names, read as the system and bounded to the tenant. + * + * @param ObjectEntity $object The object holding the reference. + * @param mixed $reference A uuid, a URL or path ending in one, or an object carrying `id`/`uuid`. + * + * @return ObjectEntity|null The referenced object, or null when it is missing or outside the tenant. + */ + private function referenced(ObjectEntity $object, mixed $reference): ?ObjectEntity { + $id = $this->referenceId(reference: $reference); + if ($id === null) { + return null; + } + + try { + $found = $this->objectService->find( + id: $id, + _rbac: false, + _multitenancy: false, + _render: false, + _audit: false + ); + } catch (Throwable $error) { + $this->logger->info( + '[ReferencedAddressNotifier] reference ' . $id . ' on ' . (string)$object->getUuid() + . ' did not resolve: ' . $error->getMessage() + ); + + return null; + } + + return $this->tenantGuard->firstAdmitted(savingOrganisation: $object->getOrganisation(), candidates: [$found]); + }//end referenced() + + /** + * The object id a reference value carries. + * + * @param mixed $reference The stored reference. + * + * @return string|null The id, or null when the value names nothing. + */ + private function referenceId(mixed $reference): ?string { + if (is_array($reference) === true) { + $reference = ($reference['id'] ?? $reference['uuid'] ?? $reference['value'] ?? null); + } + + if (is_string($reference) === false) { + return null; + } + + $reference = trim($reference); + if ($reference === '') { + return null; + } + + // A URL or path names the object in its last segment. + $parts = explode('/', rtrim($reference, '/')); + $last = (string)end($parts); + if ($last === '') { + return null; + } + + return $last; + }//end referenceId() + + /** + * A value as a mailable address. + * + * @param mixed $value The value the path reached. + * + * @return array{address: string|null, outcome: string} The address, or null with the reason. + */ + private function asAddress(mixed $value): array { + if (is_string($value) === true && filter_var(trim($value), FILTER_VALIDATE_EMAIL) !== false) { + return ['address' => trim($value), 'outcome' => '']; + } + + return ['address' => null, 'outcome' => self::OUTCOME_NO_ADDRESS]; + }//end asAddress() +}//end class diff --git a/openspec/specs/external-recipient-opt-out/spec.md b/openspec/specs/external-recipient-opt-out/spec.md index a0139da851..014963cd04 100644 --- a/openspec/specs/external-recipient-opt-out/spec.md +++ b/openspec/specs/external-recipient-opt-out/spec.md @@ -122,3 +122,28 @@ A `parties` notification MUST send the rule's resolved `message` as the mail bod - **WHEN** the rule fires for a party - **THEN** the mail's body starts with the subject - @e2e exclude notification dispatcher path, covered by PHPUnit + +### Requirement: A rule may mail an address it reads through a reference (REQ-ERO-007) + +An `x-openregister-notifications` rule MUST accept the recipient `{kind: email, field: }`. A path of one segment names a property of the object that holds the address. A longer path reads its first segment as a reference (a uuid, a URL or path ending in one, or an object carrying `id` or `uuid`), loads that object as the system, and reads the rest of the path inside it: `supplierRef.contactEmail` mails the `contactEmail` of the supplier the object points at. The referenced object MUST be inside the firing object's tenant (`ReferenceTenantGuard`, the boundary calculations use); a reference outside it MUST NOT be followed and gets the outcome `reference-unresolved`. A value that is not a valid address gets `no-address`. Every address MUST be put to integriq before it is mailed, exactly as a party's address is (REQ-ERO-003), and the mail carries the rule's message as its body (REQ-ERO-006). The rule mails only when its channels include `email`. The validator MUST refuse a path whose first segment is not a property of the schema, with `notification-recipient-field-unknown`. + +#### Scenario: A supplier's contact address is mailed through the reference + +- **GIVEN** a schema with a property `supplierRef` and a rule with recipient `{kind: email, field: supplierRef.contactEmail}` on channel `email` +- **AND** an object whose `supplierRef` names a supplier in the same organisation with `contactEmail: inkoop@leverancier.nl` +- **WHEN** the rule fires +- **THEN** integriq is asked about `inkoop@leverancier.nl`, and on `send: true` that address is mailed +- @e2e exclude notification dispatcher path, covered by PHPUnit (ReferencedAddressNotifierTest) + +#### Scenario: A reference into another organisation is not followed + +- **GIVEN** the same rule, and a `supplierRef` that names an object of another organisation +- **WHEN** the rule fires +- **THEN** nothing is mailed and the history row reads `reference-unresolved` +- @e2e exclude notification dispatcher path, covered by PHPUnit (ReferencedAddressNotifierTest) + +#### Scenario: A path that starts outside the schema is refused at save + +- **WHEN** a schema without an `ownerRef` property is saved with recipient `{kind: email, field: ownerRef.contactEmail}` +- **THEN** the save is refused with code `notification-recipient-field-unknown` +- @e2e exclude schema-save validation, covered by PHPUnit diff --git a/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php b/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php index e97b946547..ebf719b698 100644 --- a/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php +++ b/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php @@ -11,6 +11,7 @@ use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; use OCA\OpenRegister\Service\Notification\NotificationPreferenceService; use OCA\OpenRegister\Service\Notification\RecipientResolverInterface; +use OCA\OpenRegister\Service\Notification\ReferencedAddressNotifier; use OCA\OpenRegister\Service\Party\PartyNotificationService; use OCP\Activity\IManager as IActivityManager; use OCP\App\IAppManager; @@ -1096,6 +1097,61 @@ public function testAPartyMailWithoutAMessageKeepsTheSubjectAsItsBody(): void { $this->assertSame('service', $calls[0]['category']); }//end testAPartyMailWithoutAMessageKeepsTheSubjectAsItsBody() + /** + * An `email` recipient is handed to the address notifier with the rule's + * message and category, and each outcome is recorded. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-a-rule-may-mail-an-address-it-reads-through-a-reference-req-ero-007 + */ + public function testAnEmailRecipientIsMailedThroughTheAddressNotifier(): void { + $recipients = [['kind' => 'email', 'field' => 'supplierRef.contactEmail']]; + $notifier = $this->createMock(ReferencedAddressNotifier::class); + $notifier->expects($this->once()) + ->method('notify') + ->with($recipients, $this->isInstanceOf(ObjectEntity::class), 'Nieuw bericht', 'Er staat een bericht voor u klaar.', 'case-update') + ->willReturn([['field' => 'supplierRef.contactEmail', 'outcome' => 'dispatched']]); + $this->serverServices[ReferencedAddressNotifier::class] = $notifier; + + $schema = $this->schemaWithNotification( + [ + 'supplier-message' => [ + 'trigger' => ['type' => 'updated'], + 'channels' => ['email'], + 'recipients' => $recipients, + 'subject' => 'Nieuw bericht', + 'message' => 'Er staat een bericht voor u klaar.', + 'messageCategory' => 'case-update', + ], + ] + ); + $this->schemaMapper->method('find')->willReturn($schema); + + $this->makeDispatcher()->dispatch($this->object($schema), 'updated'); + }//end testAnEmailRecipientIsMailedThroughTheAddressNotifier() + + /** + * A rule without the email channel never reaches the address notifier. + */ + public function testAnEmailRecipientWithoutTheEmailChannelIsNotMailed(): void { + $notifier = $this->createMock(ReferencedAddressNotifier::class); + $notifier->expects($this->never())->method('notify'); + $this->serverServices[ReferencedAddressNotifier::class] = $notifier; + + $schema = $this->schemaWithNotification( + [ + 'x' => [ + 'trigger' => ['type' => 'updated'], + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'email', 'field' => 'email']], + 'subject' => 's', + ], + ] + ); + $this->schemaMapper->method('find')->willReturn($schema); + + $this->makeDispatcher()->dispatch($this->object($schema), 'updated'); + }//end testAnEmailRecipientWithoutTheEmailChannelIsNotMailed() + /** * @param array $notifications */ diff --git a/tests/Unit/Service/Notification/NotificationAnnotationValidatorTest.php b/tests/Unit/Service/Notification/NotificationAnnotationValidatorTest.php index 91cfa58b83..c3887e9d81 100644 --- a/tests/Unit/Service/Notification/NotificationAnnotationValidatorTest.php +++ b/tests/Unit/Service/Notification/NotificationAnnotationValidatorTest.php @@ -100,6 +100,30 @@ public function testRecipientFieldMustExist(): void { $this->assertContains('notification-recipient-field-unknown', $codes); } + /** + * `kind: email` reads its address through the first segment of `field`, + * which must be a property of the schema. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-a-rule-may-mail-an-address-it-reads-through-a-reference-req-ero-007 + */ + public function testAnEmailRecipientFieldStartsWithADeclaredProperty(): void { + $rule = static fn (string $field): array => [ + 'x-openregister-notifications' => [ + 'x' => [ + 'trigger' => ['type' => 'created'], + 'recipients' => [['kind' => 'email', 'field' => $field]], + 'channels' => ['email'], + 'subject' => 'Nieuw bericht', + ], + ], + 'properties' => ['supplierRef' => ['type' => 'string', 'format' => 'uuid']], + ]; + + $this->assertSame([], $this->v->validate($rule('supplierRef.contactEmail'))); + $this->assertContains('notification-recipient-field-unknown', array_column($this->v->validate($rule('ownerRef.contactEmail')), 'code')); + $this->assertContains('notification-recipient-field-unknown', array_column($this->v->validate($rule('')), 'code')); + } + public function testValidNoErrors(): void { $errors = $this->v->validate([ 'x-openregister-notifications' => [ diff --git a/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php b/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php new file mode 100644 index 0000000000..4394f83b4e --- /dev/null +++ b/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php @@ -0,0 +1,232 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Notification; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Calculation\ReferenceTenantGuard; +use OCA\OpenRegister\Service\Notification\EmailSender; +use OCA\OpenRegister\Service\Notification\OptOutAuthority; +use OCA\OpenRegister\Service\Notification\ReferencedAddressNotifier; +use OCA\OpenRegister\Service\ObjectService; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * The `email` recipient kind: an address read off the object, directly or through a reference. + * + * @covers \OCA\OpenRegister\Service\Notification\ReferencedAddressNotifier + */ +class ReferencedAddressNotifierTest extends TestCase { + + /** + * @var ObjectService&MockObject + */ + private ObjectService $objects; + + /** + * @var ReferenceTenantGuard&MockObject + */ + private ReferenceTenantGuard $guard; + + /** + * @var OptOutAuthority&MockObject + */ + private OptOutAuthority $optOut; + + /** + * @var EmailSender&MockObject + */ + private EmailSender $email; + + private ReferencedAddressNotifier $notifier; + + protected function setUp(): void { + $this->objects = $this->createMock(ObjectService::class); + $this->guard = $this->createMock(ReferenceTenantGuard::class); + $this->optOut = $this->createMock(OptOutAuthority::class); + $this->email = $this->createMock(EmailSender::class); + $this->optOut->method('withLink')->willReturnArgument(0); + $this->optOut->method('decisionFor')->willReturnCallback( + static fn (array $decisions, string $address): array => ($decisions[$address] ?? ['send' => false, 'code' => OptOutAuthority::CODE_AUTHORITY_UNAVAILABLE, 'unsubscribe' => null]) + ); + + $this->notifier = new ReferencedAddressNotifier( + objectService: $this->objects, + tenantGuard: $this->guard, + optOut: $this->optOut, + email: $this->email, + logger: $this->createMock(LoggerInterface::class) + ); + }//end setUp() + + private function object(array $data, ?string $organisation = 'org-a'): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid('case-1'); + $object->setOrganisation($organisation); + $object->setObject($data); + + return $object; + }//end object() + + private function allowAll(): void { + $this->optOut->method('ask')->willReturnCallback( + static function (string $channel, string $category, array $addresses): array { + $out = []; + foreach ($addresses as $address) { + $out[$address] = ['send' => true, 'code' => 'allowed', 'unsubscribe' => null]; + } + + return $out; + } + ); + }//end allowAll() + + /** + * `supplierRef.contactEmail`: the reference is read as the system, the + * tenant guard admits it, and its `contactEmail` is mailed. + */ + public function testAnAddressIsReadThroughAReference(): void { + $supplier = $this->object(['contactEmail' => 'inkoop@leverancier.nl']); + $supplier->setUuid('sup-1'); + $this->objects->expects($this->once()) + ->method('find') + ->with('sup-1', $this->anything(), $this->anything(), null, null, false, false, false, false) + ->willReturn($supplier); + $this->guard->expects($this->once()) + ->method('firstAdmitted') + ->with('org-a', [$supplier]) + ->willReturn($supplier); + $this->allowAll(); + $this->email->expects($this->once()) + ->method('sendToAddress') + ->with('inkoop@leverancier.nl', '', 'Nieuw bericht', 'Er is een bericht voor u.', null) + ->willReturn(EmailSender::OUTCOME_DISPATCHED); + + $outcomes = $this->notifier->notify( + recipientsSpec: [['kind' => 'users', 'users' => ['admin']], ['kind' => 'email', 'field' => 'supplierRef.contactEmail']], + object: $this->object(['supplierRef' => 'sup-1']), + subject: 'Nieuw bericht', + body: 'Er is een bericht voor u.', + category: 'service' + ); + + $this->assertSame([['field' => 'supplierRef.contactEmail', 'outcome' => EmailSender::OUTCOME_DISPATCHED]], $outcomes); + }//end testAnAddressIsReadThroughAReference() + + /** + * A reference may be stored as an object or a URL; both name the uuid. + */ + public function testAReferenceStoredAsAnObjectOrAUrlNamesTheSameObject(): void { + $supplier = $this->object(['contact' => ['email' => 'a@b.nl']]); + $this->objects->method('find')->willReturnCallback( + function (string $id) use ($supplier): ObjectEntity { + $this->assertSame('sup-1', $id); + + return $supplier; + } + ); + $this->guard->method('firstAdmitted')->willReturn($supplier); + + $this->assertSame('a@b.nl', $this->notifier->addressFor(object: $this->object(['ref' => ['id' => 'sup-1']]), field: 'ref.contact.email')['address']); + $this->assertSame('a@b.nl', $this->notifier->addressFor(object: $this->object(['ref' => 'https://x.nl/api/objects/r/s/sup-1/']), field: 'ref.contact.email')['address']); + }//end testAReferenceStoredAsAnObjectOrAUrlNamesTheSameObject() + + /** + * A reference into another tenant is not followed: no find result passes + * the guard, so nothing is mailed and the outcome says why. + */ + public function testAReferenceOutsideTheTenantIsNotMailed(): void { + $this->objects->method('find')->willReturn($this->object(['contactEmail' => 'x@other.nl'], 'org-b')); + $this->guard->method('firstAdmitted')->willReturn(null); + $this->allowAll(); + $this->email->expects($this->never())->method('sendToAddress'); + + $outcomes = $this->notifier->notify( + recipientsSpec: [['kind' => 'email', 'field' => 'supplierRef.contactEmail']], + object: $this->object(['supplierRef' => 'sup-9']), + subject: 's', + body: 'b', + category: 'service' + ); + + $this->assertSame(ReferencedAddressNotifier::OUTCOME_REFERENCE_UNRESOLVED, $outcomes[0]['outcome']); + }//end testAReferenceOutsideTheTenantIsNotMailed() + + /** + * A failed read is an unresolved reference, never a thrown dispatch. + */ + public function testAFailedReadIsUnresolvedNotThrown(): void { + $this->objects->method('find')->willThrowException(new RuntimeException('gone')); + + $this->assertSame( + ReferencedAddressNotifier::OUTCOME_REFERENCE_UNRESOLVED, + $this->notifier->addressFor(object: $this->object(['supplierRef' => 'sup-1']), field: 'supplierRef.contactEmail')['outcome'] + ); + }//end testAFailedReadIsUnresolvedNotThrown() + + /** + * One segment reads the address off the object; a value that is not an + * address is not mailed. + */ + public function testADirectFieldMustHoldAnAddress(): void { + $this->objects->expects($this->never())->method('find'); + + $this->assertSame('melder@example.nl', $this->notifier->addressFor(object: $this->object(['email' => ' melder@example.nl ']), field: 'email')['address']); + $this->assertSame( + ReferencedAddressNotifier::OUTCOME_NO_ADDRESS, + $this->notifier->addressFor(object: $this->object(['email' => 'not an address']), field: 'email')['outcome'] + ); + }//end testADirectFieldMustHoldAnAddress() + + /** + * Integriq's refusal is honoured, and its silence is a refusal too. + */ + public function testAnOptedOutAddressIsNotMailed(): void { + $this->optOut->expects($this->once()) + ->method('ask') + ->with('email', 'case-update', ['melder@example.nl'], 'openregister-email-field:case-1') + ->willReturn(['melder@example.nl' => ['send' => false, 'code' => 'opted-out', 'unsubscribe' => null]]); + $this->email->expects($this->never())->method('sendToAddress'); + + $outcomes = $this->notifier->notify( + recipientsSpec: [['kind' => 'email', 'field' => 'email']], + object: $this->object(['email' => 'melder@example.nl']), + subject: 's', + body: 'b', + category: 'case-update' + ); + + $this->assertSame(ReferencedAddressNotifier::OUTCOME_REFUSED_OPTED_OUT, $outcomes[0]['outcome']); + }//end testAnOptedOutAddressIsNotMailed() + + public function testARuleWithoutAnEmailRecipientAsksNobody(): void { + $this->optOut->expects($this->never())->method('ask'); + + $this->assertSame( + [], + $this->notifier->notify(recipientsSpec: [['kind' => 'parties']], object: $this->object([]), subject: 's', body: 'b', category: 'service') + ); + }//end testARuleWithoutAnEmailRecipientAsksNobody() +}//end class From 88feb5585a9b29e5eaa1aac95f493e4a54b4a34d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 9 Oct 2026 22:20:37 +0200 Subject: [PATCH 005/183] wip: saved by the coordinator after the 9 Oct afternoon session stop (unverified) --- lib/Controller/ExportRunsController.php | 41 +++++++++++++--- lib/Db/ExportProfile.php | 26 ++++++++++ lib/Db/ExportRunMapper.php | 21 +++++++- lib/Service/Export/ExportProfileService.php | 10 ++++ lib/Service/Export/ExportProfileValidator.php | 17 +++++++ lib/Service/Export/ExportRightService.php | 16 ++++++ lib/Service/ScheduledReportService.php | 31 +++++++++++- .../Export/ExportProfileServiceTest.php | 41 ++++++++++++++++ .../Service/Export/ExportRightServiceTest.php | 9 ++++ .../ScheduledReportServiceProfileRunTest.php | 49 ++++++++++++++++++- 10 files changed, 248 insertions(+), 13 deletions(-) diff --git a/lib/Controller/ExportRunsController.php b/lib/Controller/ExportRunsController.php index 5135d0e14e..2965b58b0e 100644 --- a/lib/Controller/ExportRunsController.php +++ b/lib/Controller/ExportRunsController.php @@ -31,11 +31,13 @@ namespace OCA\OpenRegister\Controller; +use DateTime; +use Exception; +use OCA\OpenRegister\Service\Export\ExportRightService; use OCA\OpenRegister\Service\Export\ExportRunRecorder; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -51,14 +53,14 @@ class ExportRunsController extends Controller { * @param IRequest $request The request. * @param ExportRunRecorder $runs The export runs. * @param IUserSession $userSession Resolves the caller. - * @param IGroupManager $groupManager Decides whether the caller is an administrator. + * @param ExportRightService $rights Decides whether the caller sees every run. */ public function __construct( string $appName, IRequest $request, private readonly ExportRunRecorder $runs, private readonly IUserSession $userSession, - private readonly IGroupManager $groupManager, + private readonly ExportRightService $rights, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() @@ -66,8 +68,12 @@ public function __construct( /** * GET /api/exports - the runs this caller made. * - * Filters on register, schema, profile, source and status. An unknown - * filter key is dropped rather than widening the result. + * Filters on register, schema, profile, source, status and actor, and on + * the period `from` / `until` (ISO 8601, inclusive) over when the export + * was produced. An unknown filter key is dropped rather than widening the + * result; a date that does not parse is refused with 400, because + * dropping it would widen the result too. Whether the caller sees every + * run or only their own is answered by ExportRightService. * * @return JSONResponse The runs, or 401 when anonymous. * @@ -88,17 +94,36 @@ public function index(): JSONResponse { $offset = (int)($this->request->getParam(key: 'offset') ?? 0); $filters = []; - foreach (['register', 'schema', 'profile', 'source', 'status'] as $key) { + foreach (['register', 'schema', 'profile', 'source', 'status', 'actor'] as $key) { $value = $this->request->getParam(key: $key); if ($value !== null && $value !== '') { $filters[$key] = (string)$value; } } + $period = []; + foreach (['from', 'until'] as $key) { + $value = $this->request->getParam(key: $key); + if ($value === null || $value === '') { + continue; + } + + try { + $period[$key] = new DateTime((string)$value); + } catch (Exception) { + return new JSONResponse( + data: ['error' => sprintf('The %s date "%s" is not a date.', $key, (string)$value)], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $filters[$key] = (string)$value; + } + $results = $this->runs->listFor( actor: $user->getUID(), - isAdmin: $this->groupManager->isAdmin($user->getUID()), - filters: $filters, + isAdmin: $this->rights->seesEveryExportRun(userId: $user->getUID()), + filters: array_merge($filters, $period), limit: max(1, min($limit, 200)), offset: max(0, $offset) ); diff --git a/lib/Db/ExportProfile.php b/lib/Db/ExportProfile.php index 0b447f552c..f0ed20e224 100644 --- a/lib/Db/ExportProfile.php +++ b/lib/Db/ExportProfile.php @@ -58,6 +58,8 @@ * @method void setWholeSet(?bool $wholeSet) * @method DateTime|null getCreatedAt() * @method void setCreatedAt(?DateTime $createdAt) + * @method int|null getRetentionDays() + * @method void setRetentionDays(?int $retentionDays) * @method DateTime|null getUpdatedAt() * @method void setUpdatedAt(?DateTime $updatedAt) * @@ -173,6 +175,13 @@ class ExportProfile extends Entity implements JsonSerializable { */ protected ?bool $wholeSet = null; + /** + * How many days the files this profile produces are kept, or null to keep them. + * + * @var integer|null + */ + protected ?int $retentionDays = null; + /** * When the profile was created. * @@ -202,6 +211,7 @@ public function __construct() { $this->addType(fieldName: 'format', type: 'string'); $this->addType(fieldName: 'filters', type: 'string'); $this->addType(fieldName: 'wholeSet', type: 'boolean'); + $this->addType(fieldName: 'retentionDays', type: 'integer'); $this->addType(fieldName: 'createdAt', type: 'datetime'); $this->addType(fieldName: 'updatedAt', type: 'datetime'); }//end __construct() @@ -265,6 +275,21 @@ public function isWholeSet(): bool { return ($this->wholeSet === true && $this->getFiltersArray() === []); }//end isWholeSet() + /** + * The retention a run produced under this profile copies, in seconds. + * + * @return int|null Seconds, or null when the profile keeps its files. + * + * @spec openspec/changes/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md#requirement-an-export-expires-and-the-row-outlives-the-file + */ + public function getRetentionSeconds(): ?int { + if ($this->retentionDays === null) { + return null; + } + + return ($this->retentionDays * 86400); + }//end getRetentionSeconds() + /** * JSON serialization. * @@ -286,6 +311,7 @@ public function jsonSerialize(): array { 'format' => ($this->format ?? 'csv'), 'filters' => $this->getFiltersArray(), 'wholeSet' => ($this->wholeSet ?? false), + 'retentionDays' => $this->retentionDays, 'createdAt' => $this->createdAt?->format(DateTime::ATOM), 'updatedAt' => $this->updatedAt?->format(DateTime::ATOM), ]; diff --git a/lib/Db/ExportRunMapper.php b/lib/Db/ExportRunMapper.php index b49957025d..3a5ac5c32b 100644 --- a/lib/Db/ExportRunMapper.php +++ b/lib/Db/ExportRunMapper.php @@ -91,7 +91,8 @@ public function findByUuid(string $uuid): ExportRun { * ones that are gone as much as the ones that are not. * * @param string|null $actor The actor, or null for every actor (admin). - * @param array $filters Optional equality filters on register, schema, profile, source or status. + * @param array $filters Optional equality filters on register, schema, profile, source, status + * or actor, and `from` / `until` (DateTime) bounding produced_at. * @param int $limit Page size. * @param int $offset Page offset. * @@ -108,6 +109,7 @@ public function findForActor(?string $actor, array $filters = [], int $limit = 5 'profile' => 'profile', 'source' => 'source', 'status' => 'status', + 'actor' => 'actor', ]; $qb = $this->db->getQueryBuilder(); @@ -121,11 +123,26 @@ public function findForActor(?string $actor, array $filters = [], int $limit = 5 $qb->andWhere($qb->expr()->eq('actor', $qb->createNamedParameter($actor))); } + // The period bounds `produced_at`, inclusive on both ends. They arrive + // as DateTime values; the controller parsed and refused a bad + // date already, so nothing here guesses at a string. + $from = ($filters['from'] ?? null); + if ($from instanceof DateTime === true) { + $qb->andWhere($qb->expr()->gte('produced_at', $qb->createNamedParameter($from, IQueryBuilder::PARAM_DATETIME_MUTABLE))); + } + + $until = ($filters['until'] ?? null); + if ($until instanceof DateTime === true) { + $qb->andWhere($qb->expr()->lte('produced_at', $qb->createNamedParameter($until, IQueryBuilder::PARAM_DATETIME_MUTABLE))); + } + + unset($filters['from'], $filters['until']); + foreach ($filters as $key => $value) { // An unknown filter key is DROPPED rather than widening the result: // a query that silently ignores a narrowing term returns more than // the caller asked for, which is the wrong direction to fail in. - if (isset($columns[$key]) === false || $value === null || $value === '') { + if (isset($columns[$key]) === false || is_scalar($value) === false || $value === '') { continue; } diff --git a/lib/Service/Export/ExportProfileService.php b/lib/Service/Export/ExportProfileService.php index fc3196e364..e86ffcf0fe 100644 --- a/lib/Service/Export/ExportProfileService.php +++ b/lib/Service/Export/ExportProfileService.php @@ -412,5 +412,15 @@ private function applyShape(ExportProfile $profile, array $data): void { $profile->setValueMode((string)($data['valueMode'] ?? $profile->getValueMode() ?? ExportProfile::MODE_STORED)); $profile->setFormat((string)($data['format'] ?? $profile->getFormat() ?? 'csv')); + + // Present and null means "keep the files"; absent leaves it alone. + if (array_key_exists('retentionDays', $data) === true) { + $retention = null; + if ($data['retentionDays'] !== null) { + $retention = (int)$data['retentionDays']; + } + + $profile->setRetentionDays($retention); + } }//end applyShape() }//end class diff --git a/lib/Service/Export/ExportProfileValidator.php b/lib/Service/Export/ExportProfileValidator.php index 5becddb095..e6d3403793 100644 --- a/lib/Service/Export/ExportProfileValidator.php +++ b/lib/Service/Export/ExportProfileValidator.php @@ -37,6 +37,13 @@ */ class ExportProfileValidator { + /** + * The longest retention a profile may declare, matching ExportRunRecorder's cap. + * + * @var int + */ + public const MAX_RETENTION_DAYS = 90; + /** * Validate a submitted profile. * @@ -132,5 +139,15 @@ private function validateChoices(array $data): void { if (isset($data['filters']) === true && is_array($data['filters']) === false) { throw new InvalidArgumentException('The filter of an export profile is a map.'); } + + $retention = ($data['retentionDays'] ?? null); + if ($retention !== null + && (is_int($retention) === false || $retention < 1 || $retention > self::MAX_RETENTION_DAYS) + ) { + throw new InvalidArgumentException( + 'An export profile keeps its files for 1 to ' . self::MAX_RETENTION_DAYS + . ' days, or keeps them when no retention is given.' + ); + } }//end validateChoices() }//end class diff --git a/lib/Service/Export/ExportRightService.php b/lib/Service/Export/ExportRightService.php index 7c4de063ae..dc408fabc4 100644 --- a/lib/Service/Export/ExportRightService.php +++ b/lib/Service/Export/ExportRightService.php @@ -104,6 +104,22 @@ public function refusalFor(?Schema $schema): ?ExportRefusedException { return $this->refusalForUid(schema: $schema, userId: $user->getUID()); }//end refusalFor() + /** + * Whether a principal sees every export run, or only their own. + * + * @param string $userId The principal reading the exports area. + * + * @return bool True when every run is in scope. + * + * @spec openspec/changes/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md#requirement-an-exports-area-lists-the-runs + */ + public function seesEveryExportRun(string $userId): bool { + // The same bypass refusalForUid() grants: an administrator may export + // any schema, so they may see every export that was made. Answered + // HERE so the exports area has no scope rule of its own to drift. + return ($this->groupManager->isAdmin($userId) === true); + }//end seesEveryExportRun() + /** * The refusal for a named principal, or null when they may export. * diff --git a/lib/Service/ScheduledReportService.php b/lib/Service/ScheduledReportService.php index 8565421606..fee9c0f8e9 100644 --- a/lib/Service/ScheduledReportService.php +++ b/lib/Service/ScheduledReportService.php @@ -1013,7 +1013,8 @@ private function recordRun(ScheduledReport $report, \OCP\IUser $owner, string $f registerName: $this->nullableString(value: $report->getRegisterId()), schemaName: $this->nullableString(value: $report->getSchemaId()), fileId: $fileId, - filePath: $filePath + filePath: $filePath, + retentionSeconds: $this->retentionFor(report: $report) ); } catch (\Throwable $e) { $this->logger->warning( @@ -1023,6 +1024,34 @@ private function recordRun(ScheduledReport $report, \OCP\IUser $owner, string $f } }//end recordRun() + /** + * The retention a run of this report is produced under. + * + * A report that runs a profile copies the profile's retention, null + * meaning the profile keeps its files. A report without a profile keeps + * the recorder's default. Read once, at production: the run stores it, so + * a later profile edit does not move this file's deadline. + * + * @param ScheduledReport $report The report. + * + * @return int|null Seconds, or null to keep the file. + * + * @spec openspec/changes/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md#requirement-an-export-expires-and-the-row-outlives-the-file + */ + private function retentionFor(ScheduledReport $report): ?int { + if ($report->getProfileId() === null || $this->profileService === null) { + return ExportRunRecorder::DEFAULT_RETENTION_SECONDS; + } + + try { + return $this->profileService->find(id: (int)$report->getProfileId())->getRetentionSeconds(); + } catch (\Throwable $e) { + // The profile ran a moment ago; if it cannot be read now, the safe + // direction is the default expiry, never keeping a copy forever. + return ExportRunRecorder::DEFAULT_RETENTION_SECONDS; + } + }//end retentionFor() + /** * An identifier as a string, or null when it is absent. * diff --git a/tests/Unit/Service/Export/ExportProfileServiceTest.php b/tests/Unit/Service/Export/ExportProfileServiceTest.php index 78401510cf..744dd4cc2b 100644 --- a/tests/Unit/Service/Export/ExportProfileServiceTest.php +++ b/tests/Unit/Service/Export/ExportProfileServiceTest.php @@ -197,6 +197,47 @@ public function testACreatedProfileGetsAUuidAnOwnerAndItsFieldOrder(): void { self::assertSame(ExportProfile::MODE_STORED, $profile->getValueMode()); }//end testACreatedProfileGetsAUuidAnOwnerAndItsFieldOrder() + /** + * A profile declares its file retention in days; a run copies it as + * seconds. Absent keeps the files, and the value is bounded like the run's. + * + * @spec openspec/changes/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md#requirement-an-export-expires-and-the-row-outlives-the-file + */ + public function testAProfileDeclaresHowLongItsFilesAreKept(): void { + $this->mapper->method('insert')->willReturnCallback(static fn (...$args) => $args[0]); + $this->mapper->method('update')->willReturnCallback(static fn (...$args) => $args[0]); + + $kept = $this->service()->create(['name' => 'x', 'registerId' => 7, 'fields' => ['a']], 'eigenaar-1'); + self::assertNull($kept->getRetentionSeconds(), 'no retention keeps the files'); + + $monthly = $this->service()->create(['name' => 'x', 'registerId' => 7, 'fields' => ['a'], 'retentionDays' => 30], 'eigenaar-1'); + self::assertSame(30 * 86400, $monthly->getRetentionSeconds()); + self::assertSame(30, $monthly->jsonSerialize()['retentionDays']); + + $this->service()->update($monthly, ['name' => 'renamed']); + self::assertSame(30, $monthly->getRetentionDays(), 'an edit that does not name the retention leaves it'); + + $this->service()->update($monthly, ['retentionDays' => null]); + self::assertNull($monthly->getRetentionDays(), 'an explicit null goes back to keeping the files'); + }//end testAProfileDeclaresHowLongItsFilesAreKept() + + /** + * @dataProvider badRetentions + */ + public function testARetentionOutsideOneToNinetyDaysIsRefused(mixed $days): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('1 to 90 days'); + + $this->service()->create(['name' => 'x', 'registerId' => 7, 'fields' => ['a'], 'retentionDays' => $days], 'eigenaar-1'); + }//end testARetentionOutsideOneToNinetyDaysIsRefused() + + /** + * @return array + */ + public static function badRetentions(): array { + return ['zero' => [0], 'beyond the cap' => [91], 'a string' => ['30']]; + }//end badRetentions() + public function testSomebodyElsesProfileIsRefused(): void { $this->expectException(ExportRefusedException::class); diff --git a/tests/Unit/Service/Export/ExportRightServiceTest.php b/tests/Unit/Service/Export/ExportRightServiceTest.php index 5e4ae64f49..d274e0f7d1 100644 --- a/tests/Unit/Service/Export/ExportRightServiceTest.php +++ b/tests/Unit/Service/Export/ExportRightServiceTest.php @@ -93,6 +93,15 @@ private function schema(): Schema { return $schema; }//end schema() + /** + * The exports area's scope is answered here: an administrator sees every + * run, anybody else only their own. + */ + public function testOnlyAnAdministratorSeesEveryExportRun(): void { + $this->assertTrue($this->service(uid: 'admin', isAdmin: true, authorization: null, holds: [])->seesEveryExportRun(userId: 'admin')); + $this->assertFalse($this->service(uid: 'alice', isAdmin: false, authorization: null, holds: [])->seesEveryExportRun(userId: 'alice')); + }//end testOnlyAnAdministratorSeesEveryExportRun() + public function testAReaderWhoMayNotTakeTheDataIsRefused(): void { $refusal = $this->service( 'behandelaar-1', diff --git a/tests/Unit/Service/ScheduledReportServiceProfileRunTest.php b/tests/Unit/Service/ScheduledReportServiceProfileRunTest.php index 86533d1fd8..20a632c993 100644 --- a/tests/Unit/Service/ScheduledReportServiceProfileRunTest.php +++ b/tests/Unit/Service/ScheduledReportServiceProfileRunTest.php @@ -41,6 +41,7 @@ use OCA\OpenRegister\Db\ScheduledReportMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\Export\ExportProfileService; +use OCA\OpenRegister\Service\Export\ExportRunRecorder; use OCA\OpenRegister\Service\ExportService; use OCA\OpenRegister\Service\ScheduledReportService; use OCP\Files\Folder; @@ -89,7 +90,7 @@ protected function setUp(): void { $this->delivered = []; }//end setUp() - private function service(?ExportProfileService $profiles): ScheduledReportService { + private function service(?ExportProfileService $profiles, ?ExportRunRecorder $runs = null): ScheduledReportService { $config = $this->createMock(IConfig::class); $config->method('getSystemValue')->willReturnCallback( static fn ($key, $default = null) => $default @@ -107,7 +108,8 @@ private function service(?ExportProfileService $profiles): ScheduledReportServic new NullLogger(), $this->createMock(IMailer::class), $config, - $profiles + $profiles, + $runs ); }//end service() @@ -168,6 +170,49 @@ private function profileService(): ExportProfileService&MockObject { return $profiles; }//end profileService() + /** + * A report that runs a profile records its run under the profile's + * retention: 30 days as seconds, or null (kept) when the profile declares none. + * + * @dataProvider profileRetentions + * + * @spec openspec/changes/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md#requirement-an-export-expires-and-the-row-outlives-the-file + */ + public function testTheRunCopiesTheProfilesRetention(?int $days, ?int $expected): void { + $this->mockOwnerFolder(); + + $profile = new ExportProfile(); + $profile->setName('Maandelijkse aanlevering'); + $profile->setFormat('json'); + $profile->setRetentionDays($days); + $profiles = $this->createMock(ExportProfileService::class); + $profiles->method('find')->willReturn($profile); + $profiles->method('run')->willReturn(['bytes' => '{}', 'rowCount' => 3, 'metadata' => [], 'filename' => 'x.json']); + + $seen = 'not called'; + $runs = $this->createMock(ExportRunRecorder::class); + $runs->expects(self::once())->method('record')->willReturnCallback( + function (...$args) use (&$seen) { + // PHPUnit hands the declared parameters positionally; + // retentionSeconds is the eleventh. + $seen = (array_key_exists(10, $args) === true) ? $args[10] : 'absent'; + + return new \OCA\OpenRegister\Db\ExportRun(); + } + ); + + $this->service($profiles, $runs)->runOne(report: $this->report()); + + self::assertSame($expected, $seen); + }//end testTheRunCopiesTheProfilesRetention() + + /** + * @return array + */ + public static function profileRetentions(): array { + return ['thirty days' => [30, 30 * 86400], 'no retention keeps the file' => [null, null]]; + }//end profileRetentions() + public function testAScheduledExportRunsAsItsOwner(): void { $this->mockOwnerFolder(); From 3e1fa01655bd7693ddc8a0d9d8391c22356c4c74 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 9 Oct 2026 22:23:01 +0200 Subject: [PATCH 006/183] feat(export): a profile says how long its files are kept, and the exports list filters by period retentionDays on export profiles (migration, version bump); a scheduled report running a profile records its run under that retention. GET /api/exports takes from/until and actor, and its scope comes from ExportRightService. Verifies and completes the coordinator's wip commit 88feb5585a. an-export-is-a-file-with-a-life 2.1, 4.1. --- appinfo/info.xml | 2 +- lib/Migration/Version1Date20261009150000.php | 77 ++++++++++ .../an-export-is-a-file-with-a-life/tasks.md | 10 +- .../Controller/ExportRunsControllerTest.php | 143 ++++++++++++++++++ 4 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 lib/Migration/Version1Date20261009150000.php create mode 100644 tests/Unit/Controller/ExportRunsControllerTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 9fce9291a3..ee484d332d 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.1.35-unstable.20261007150104 + 2.1.35-unstable.20261009150000 EUPL-1.2 Conduction OpenRegister diff --git a/lib/Migration/Version1Date20261009150000.php b/lib/Migration/Version1Date20261009150000.php new file mode 100644 index 0000000000..8cc3376499 --- /dev/null +++ b/lib/Migration/Version1Date20261009150000.php @@ -0,0 +1,77 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * An export profile declares how long the files it produces are kept. + * + * Adds `retention_days` to `openregister_export_profiles`. Null means the + * profile keeps its files; a run produced under it says so (`kept`). A run + * copies the retention it was produced under, so editing this column later + * never moves an existing file's deadline. + * + * @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/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md#requirement-an-export-expires-and-the-row-outlives-the-file + */ + +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 file retention column to export profiles. + * + * @spec openspec/changes/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md#requirement-an-export-expires-and-the-row-outlives-the-file + */ +class Version1Date20261009150000 extends SimpleMigrationStep { + + /** + * The export profiles table. + */ + private const TABLE = 'openregister_export_profiles'; + + /** + * Add the column unless it is already there. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the schema wrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The schema, changed or not. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The signature is Nextcloud's. + * + * @spec openspec/changes/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md#requirement-an-export-expires-and-the-row-outlives-the-file + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable(self::TABLE) === false) { + return $schema; + } + + $table = $schema->getTable(self::TABLE); + if ($table->hasColumn('retention_days') === true) { + return $schema; + } + + $table->addColumn('retention_days', Types::INTEGER, ['notnull' => false, 'unsigned' => true]); + $output->info('Added retention_days to '.self::TABLE); + + return $schema; + }//end changeSchema() +}//end class diff --git a/openspec/changes/an-export-is-a-file-with-a-life/tasks.md b/openspec/changes/an-export-is-a-file-with-a-life/tasks.md index b062b52013..2b33320242 100644 --- a/openspec/changes/an-export-is-a-file-with-a-life/tasks.md +++ b/openspec/changes/an-export-is-a-file-with-a-life/tasks.md @@ -28,13 +28,17 @@ profile and the export verb. ## 2. The expiry -- [ ] 2.1 A profile declares a file retention; a run carries the expiry it +- [x] 2.1 A profile declares a file retention; a run carries the expiry it was produced under, so changing the profile later does not silently move an existing file's deadline. > HALF DONE 2026-09-22. The run carries the expiry it was produced > under, which is the half that protects an existing deadline. The > profile does not declare a retention yet, so the recorder's default > of seven days applies, capped at ninety. + > 2026-10-09 (B2): DONE. `retentionDays` on the profile (1-90, null keeps the + > files; migration Version1Date20261009150000), `ExportProfile::getRetentionSeconds()`, + > and a scheduled report that runs a profile records its run under that retention + > (`ScheduledReportServiceProfileRunTest::testTheRunCopiesTheProfilesRetention`). - [x] 2.2 A background job deletes the files of expired runs and keeps the rows (2026-09-22, `SweepExpiredExportRunsJob`). - Unit tests: a run with no expiry is never swept; a run whose file @@ -60,6 +64,10 @@ profile and the export verb. > schema, profile, source and status, scoped to the caller's own runs > with administrators seeing all. Period is not a filter yet, and the > scope is not resolved through `ExportRightService`. + > 2026-10-09 (B2): period (`from`/`until`, inclusive, a bad date is 400) and + > `actor` are filters now, and the scope comes from + > `ExportRightService::seesEveryExportRun()` (`ExportRunsControllerTest`). + > Open: the live two-account probe below (not run: needs a live instance). - 🔴 Probe with the least privileged principal that should be refused, across a tenant boundary. A scope that is accidentally a no-op returns exactly what an administrator sees, which is diff --git a/tests/Unit/Controller/ExportRunsControllerTest.php b/tests/Unit/Controller/ExportRunsControllerTest.php new file mode 100644 index 0000000000..4a0c93e10e --- /dev/null +++ b/tests/Unit/Controller/ExportRunsControllerTest.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 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use DateTime; +use OCA\OpenRegister\Controller\ExportRunsController; +use OCA\OpenRegister\Service\Export\ExportRightService; +use OCA\OpenRegister\Service\Export\ExportRunRecorder; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Controller\ExportRunsController + */ +class ExportRunsControllerTest extends TestCase { + + /** + * @var ExportRunRecorder&MockObject + */ + private ExportRunRecorder $runs; + + /** + * @var ExportRightService&MockObject + */ + private ExportRightService $rights; + + protected function setUp(): void { + $this->runs = $this->createMock(ExportRunRecorder::class); + $this->rights = $this->createMock(ExportRightService::class); + }//end setUp() + + /** + * @param array $params The query parameters. + */ + private function controller(array $params, ?string $uid = 'alice'): ExportRunsController { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturnCallback(static fn (string $key) => ($params[$key] ?? null)); + + $session = $this->createMock(IUserSession::class); + $user = null; + if ($uid !== null) { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + } + + $session->method('getUser')->willReturn($user); + + return new ExportRunsController('openregister', $request, $this->runs, $session, $this->rights); + }//end controller() + + /** + * The scope comes from the authorization layer, not from a rule written + * for this page: what ExportRightService answers is what listFor gets. + * + * @dataProvider scopes + */ + public function testTheScopeIsAnsweredByTheExportRight(bool $seesAll): void { + $this->rights->expects($this->once())->method('seesEveryExportRun')->with('alice')->willReturn($seesAll); + $this->runs->expects($this->once()) + ->method('listFor') + ->with('alice', $seesAll, $this->anything(), 50, 0) + ->willReturn([]); + + $this->assertSame(200, $this->controller([])->index()->getStatus()); + }//end testTheScopeIsAnsweredByTheExportRight() + + /** + * @return array + */ + public static function scopes(): array { + return ['a handler sees their own' => [false], 'an administrator sees every run' => [true]]; + }//end scopes() + + /** + * The period bounds when the export was produced; actor narrows too. + */ + public function testThePeriodAndTheActorAreFilters(): void { + $this->rights->method('seesEveryExportRun')->willReturn(true); + $this->runs->expects($this->once()) + ->method('listFor') + ->with( + 'alice', + true, + $this->callback( + function (array $filters): bool { + $this->assertSame('bob', $filters['actor']); + $this->assertSame('5', $filters['register']); + $this->assertInstanceOf(DateTime::class, $filters['from']); + $this->assertSame('2026-10-01', $filters['from']->format('Y-m-d')); + $this->assertSame('2026-10-09', $filters['until']->format('Y-m-d')); + + return true; + } + ) + ) + ->willReturn([]); + + $response = $this->controller(['actor' => 'bob', 'register' => '5', 'from' => '2026-10-01', 'until' => '2026-10-09T23:59:59Z'])->index(); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame('2026-10-01', $response->getData()['filters']['from']); + }//end testThePeriodAndTheActorAreFilters() + + /** + * A date that does not parse is refused: dropping it would widen the list. + */ + public function testADateThatDoesNotParseIsRefused(): void { + $this->runs->expects($this->never())->method('listFor'); + + $response = $this->controller(['from' => 'last tuesday-ish!!'])->index(); + + $this->assertSame(400, $response->getStatus()); + }//end testADateThatDoesNotParseIsRefused() + + public function testAnAnonymousCallerIsRefused(): void { + $this->runs->expects($this->never())->method('listFor'); + + $this->assertSame(401, $this->controller([], null)->index()->getStatus()); + }//end testAnAnonymousCallerIsRefused() +}//end class From 75ae8c2f4cd84502d092f8aa763c50bf825841a5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 9 Oct 2026 23:12:48 +0200 Subject: [PATCH 007/183] refactor(export): the retention check is its own method Keeps validateChoices() under the phpmd complexity threshold. --- lib/Service/Export/ExportProfileValidator.php | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/Service/Export/ExportProfileValidator.php b/lib/Service/Export/ExportProfileValidator.php index e6d3403793..866a471100 100644 --- a/lib/Service/Export/ExportProfileValidator.php +++ b/lib/Service/Export/ExportProfileValidator.php @@ -66,6 +66,7 @@ public function validate(array $data, bool $partial): void { $this->validateFields(data: $data); $this->validateChoices(data: $data); + $this->validateRetention(data: $data); }//end validate() /** @@ -139,15 +140,30 @@ private function validateChoices(array $data): void { if (isset($data['filters']) === true && is_array($data['filters']) === false) { throw new InvalidArgumentException('The filter of an export profile is a map.'); } + }//end validateChoices() + /** + * The file retention: whole days from 1 to the cap, or absent to keep the files. + * + * @param array $data The submission. + * + * @return void + * + * @throws InvalidArgumentException When the retention is not such a number. + * + * @spec openspec/changes/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md#requirement-an-export-expires-and-the-row-outlives-the-file + */ + private function validateRetention(array $data): void { $retention = ($data['retentionDays'] ?? null); - if ($retention !== null - && (is_int($retention) === false || $retention < 1 || $retention > self::MAX_RETENTION_DAYS) - ) { + if ($retention === null) { + return; + } + + if (is_int($retention) === false || $retention < 1 || $retention > self::MAX_RETENTION_DAYS) { throw new InvalidArgumentException( 'An export profile keeps its files for 1 to ' . self::MAX_RETENTION_DAYS . ' days, or keeps them when no retention is given.' ); } - }//end validateChoices() + }//end validateRetention() }//end class From 10ad0e6fa204b9032dc0ad97cf78b2b8d2609ebe Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 09:45:23 +0200 Subject: [PATCH 008/183] wip: saved by the coordinator after the 10 Oct overnight session stop (unverified) --- appinfo/info.xml | 2 +- lib/AppInfo/Application.php | 9 + lib/Controller/TaskController.php | 6 + lib/Db/Task.php | 18 ++ lib/Db/TaskBlockerMapper.php | 107 +++++++++ lib/Db/TaskInboxCriteria.php | 6 + lib/Db/TaskMapper.php | 38 +++ lib/Listener/TaskBlockerReleaseListener.php | 108 +++++++++ lib/Migration/Version1Date20261010090000.php | 81 +++++++ lib/Service/Task/TaskBlockerGuard.php | 124 ++++++++++ lib/Service/Task/TaskBuilder.php | 2 + lib/Service/Task/TaskInboxService.php | 54 ++++- lib/Service/Task/TaskService.php | 7 + .../.openspec.yaml | 2 + .../a-task-may-wait-on-another-task/design.md | 59 +++++ .../proposal.md | 54 +++++ .../specs/flow-tasks/spec.md | 62 +++++ .../a-task-may-wait-on-another-task/tasks.md | 25 ++ tests/Unit/Controller/TaskControllerTest.php | 23 ++ tests/Unit/Db/FluentQueryBuilderTrait.php | 5 +- tests/Unit/Db/TaskBlockedByTest.php | 224 ++++++++++++++++++ tests/Unit/Db/TaskMapperQueriesTest.php | 7 +- .../TaskBlockerReleaseListenerTest.php | 145 ++++++++++++ .../Service/Task/TaskBlockedByCreateTest.php | 208 ++++++++++++++++ 24 files changed, 1370 insertions(+), 6 deletions(-) create mode 100644 lib/Db/TaskBlockerMapper.php create mode 100644 lib/Listener/TaskBlockerReleaseListener.php create mode 100644 lib/Migration/Version1Date20261010090000.php create mode 100644 lib/Service/Task/TaskBlockerGuard.php create mode 100644 openspec/changes/a-task-may-wait-on-another-task/.openspec.yaml create mode 100644 openspec/changes/a-task-may-wait-on-another-task/design.md create mode 100644 openspec/changes/a-task-may-wait-on-another-task/proposal.md create mode 100644 openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md create mode 100644 openspec/changes/a-task-may-wait-on-another-task/tasks.md create mode 100644 tests/Unit/Db/TaskBlockedByTest.php create mode 100644 tests/Unit/Listener/TaskBlockerReleaseListenerTest.php create mode 100644 tests/Unit/Service/Task/TaskBlockedByCreateTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index ee484d332d..61d2d5a474 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.1.35-unstable.20261009150000 + 2.1.35-unstable.20261010090000 EUPL-1.2 Conduction OpenRegister diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 13a5834c2c..330b1a567f 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -3239,6 +3239,15 @@ private function registerEventListeners(IRegistrationContext $context): void { \OCA\OpenRegister\Event\TaskTerminalEvent::class, \OCA\OpenRegister\Listener\CaseTaskTerminalListener::class ); + + // A closed task releases the tasks that waited on it + // (a-task-may-wait-on-another-task D-4): a `released` audit entry and a + // fresh announcement per open dependant. Post-event work on the + // committed dispatch; the blocked flag itself is derived on read. + $context->registerEventListener( + \OCA\OpenRegister\Event\TaskTerminalEvent::class, + \OCA\OpenRegister\Listener\TaskBlockerReleaseListener::class + ); $context->registerEventListener( \OCA\OpenRegister\Event\FlowRunTerminalEvent::class, \OCA\OpenRegister\Listener\CaseRunTerminalListener::class diff --git a/lib/Controller/TaskController.php b/lib/Controller/TaskController.php index 5a6ef39bed..3dbd1ea751 100644 --- a/lib/Controller/TaskController.php +++ b/lib/Controller/TaskController.php @@ -211,10 +211,14 @@ public function open(string $uuid): TemplateResponse { * as one parameter. * @param int $limit Page size. * @param int $offset Page offset. + * @param string|null $includeBlocked 'true' to list tasks whose blocker is + * still open. An object or run anchor + * lists them regardless. * * @return JSONResponse The page: results, total, limit, offset. * * @spec openspec/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ #[NoAdminRequired] #[NoCSRFRequired] @@ -232,6 +236,7 @@ public function index( string $sort = TaskInboxCriteria::SORT_DUE, int $limit = 25, int $offset = 0, + ?string $includeBlocked = null, ): JSONResponse { $uid = $this->uid(); if ($uid === null) { @@ -283,6 +288,7 @@ public function index( dueBefore: $dueBeforeAt, sort: $sortKey, sortDescending: $descending, + includeBlocked: ($includeBlocked !== null && filter_var($includeBlocked, FILTER_VALIDATE_BOOLEAN) === true), ); return new JSONResponse($this->inbox->inbox(criteria: $criteria, limit: $limit, offset: $offset)); diff --git a/lib/Db/Task.php b/lib/Db/Task.php index a0b18ea55b..8fd924ffd3 100644 --- a/lib/Db/Task.php +++ b/lib/Db/Task.php @@ -73,6 +73,8 @@ * @method void setOutcome(?string $outcome) * @method string|null getBlockedReason() * @method void setBlockedReason(?string $blockedReason) + * @method string|null getBlockedBy() + * @method void setBlockedBy(?string $blockedBy) * @method string|null getPerformerType() * @method void setPerformerType(?string $performerType) * @method string|null getAssignee() @@ -439,6 +441,18 @@ class Task extends Entity implements JsonSerializable { */ protected ?string $blockedReason = null; + /** + * The uuid of the task this one waits on, when it waits on one. + * + * Only the declaration is stored. Whether the task is blocked NOW is + * derived on read from the blocker's terminality (see + * {@see \OCA\OpenRegister\Service\Task\TaskInboxService::row()}), so a + * blocker closed by any path releases it without a second write. + * + * @var string|null + */ + protected ?string $blockedBy = null; + /** * user|group|agent|worker (extensible — see PERFORMER_TYPES). * @@ -771,6 +785,7 @@ class Task extends Entity implements JsonSerializable { /** * Constructor: declare field types so the mapper hydrates them correctly. + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ public function __construct() { $this->addType(fieldName: 'uuid', type: 'string'); @@ -790,6 +805,7 @@ public function __construct() { $this->addType(fieldName: 'lastAction', type: 'string'); $this->addType(fieldName: 'outcome', type: 'string'); $this->addType(fieldName: 'blockedReason', type: 'string'); + $this->addType(fieldName: 'blockedBy', type: 'string'); $this->addType(fieldName: 'performerType', type: 'string'); $this->addType(fieldName: 'assignee', type: 'string'); $this->addType(fieldName: 'candidateUsers', type: 'json'); @@ -887,6 +903,7 @@ public function hydrate(array $object): self { * @return array The task as plain data. * * @spec openspec/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ public function jsonSerialize(): array { return [ @@ -908,6 +925,7 @@ public function jsonSerialize(): array { 'lastAction' => $this->lastAction, 'outcome' => $this->outcome, 'blockedReason' => $this->blockedReason, + 'blockedBy' => $this->blockedBy, 'performerType' => $this->performerType, 'assignee' => $this->assignee, 'candidateUsers' => $this->candidateUsers, diff --git a/lib/Db/TaskBlockerMapper.php b/lib/Db/TaskBlockerMapper.php new file mode 100644 index 0000000000..b9dd8a99f5 --- /dev/null +++ b/lib/Db/TaskBlockerMapper.php @@ -0,0 +1,107 @@ + + * 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/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Blocker lookups on the tasks table. + * + * @template-extends QBMapper + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ +class TaskBlockerMapper extends QBMapper { + + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_tasks', entityClass: Task::class); + + }//end __construct() + + /** + * Which of these task uuids name a task that is still open. + * + * The one lookup behind the derived `blocked` flag on a page of rows: a + * row is blocked while its `blockedBy` is in this answer. A uuid no task + * carries is not open, so a removed blocker blocks nothing. + * + * @param array $uuids The blocker uuids of a page. + * + * @return array The uuids among them whose task is not terminal. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + public function openBlockerUuids(array $uuids): array { + $uuids = array_values(array_unique(array_filter($uuids, static fn (string $uuid): bool => $uuid !== ''))); + if ($uuids === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('uuid') + ->from($this->getTableName()) + ->where($qb->expr()->in('uuid', $qb->createNamedParameter($uuids, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))); + + $result = $qb->executeQuery(); + $open = []; + while (($row = $result->fetch()) !== false) { + $open[] = (string)$row['uuid']; + } + + $result->closeCursor(); + + return $open; + }//end openBlockerUuids() + + /** + * The open tasks that wait on this one. + * + * @param string $blockerUuid The blocker's uuid. + * + * @return array The open dependants. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + public function findOpenBlockedBy(string $blockerUuid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('blocked_by', $qb->createNamedParameter($blockerUuid))) + ->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))); + + return $this->findEntities(query: $qb); + }//end findOpenBlockedBy() +}//end class diff --git a/lib/Db/TaskInboxCriteria.php b/lib/Db/TaskInboxCriteria.php index f77a45a4b1..8df52c0785 100644 --- a/lib/Db/TaskInboxCriteria.php +++ b/lib/Db/TaskInboxCriteria.php @@ -112,8 +112,13 @@ final class TaskInboxCriteria { * @param string|null $kind When set, only tasks carrying this kind. Last * in the list on purpose: every caller names its * arguments, and appending cannot shift one. + * @param bool $includeBlocked When true, tasks whose blocker is still open + * are listed too. An object or run anchor + * lists them regardless: the case shows what + * waits and on what. * * @spec openspec/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ public function __construct( public readonly string $uid, @@ -131,6 +136,7 @@ public function __construct( public readonly string $sort = self::SORT_DUE, public readonly bool $sortDescending = false, public readonly ?string $kind = null, + public readonly bool $includeBlocked = false, ) { }//end __construct() diff --git a/lib/Db/TaskMapper.php b/lib/Db/TaskMapper.php index 6847caf925..29350484a9 100644 --- a/lib/Db/TaskMapper.php +++ b/lib/Db/TaskMapper.php @@ -567,8 +567,46 @@ private function applyInboxPredicates(IQueryBuilder $qb, TaskInboxCriteria $crit $this->applyVisibility(qb: $qb, criteria: $criteria); $this->applyFilters(qb: $qb, criteria: $criteria); $this->applyExternalExclusion(qb: $qb, criteria: $criteria); + $this->applyBlockedExclusion(qb: $qb, criteria: $criteria); }//end applyInboxPredicates() + /** + * A task whose blocker is still open is in nobody's inbox and in nobody's total. + * + * Derived in the datastore from the blocker's terminality, never from a + * stored flag, so a blocker closed by any path (a verb, run termination, + * the timer sweep) releases its dependants on the next read. The subquery + * is uncorrelated; `uuid IS NOT NULL` keeps `NOT IN` from turning every + * row unknown. A read anchored to an object or a run skips it, for the + * reason {@see applyExternalExclusion()} gives: that is the case looking at + * its own work, and it must show what waits and on what. + * + * @param IQueryBuilder $qb The query under construction. + * @param TaskInboxCriteria $criteria Carries the anchors and `includeBlocked`. + * + * @return void + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + private function applyBlockedExclusion(IQueryBuilder $qb, TaskInboxCriteria $criteria): void { + if ($criteria->includeBlocked === true || $criteria->objectUuid !== null || $criteria->runUuid !== null) { + return; + } + + $open = $this->db->getQueryBuilder(); + $open->select('blocker.uuid') + ->from($this->getTableName(), 'blocker') + ->where($open->expr()->eq('blocker.is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($open->expr()->isNotNull('blocker.uuid')); + + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('blocked_by'), + $qb->expr()->notIn('blocked_by', $qb->createFunction($open->getSQL())) + ) + ); + }//end applyBlockedExclusion() + /** * An EXTERNAL task is in nobody's Nextcloud inbox and in nobody's total. * diff --git a/lib/Listener/TaskBlockerReleaseListener.php b/lib/Listener/TaskBlockerReleaseListener.php new file mode 100644 index 0000000000..8d6ba4f28c --- /dev/null +++ b/lib/Listener/TaskBlockerReleaseListener.php @@ -0,0 +1,108 @@ + + * 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/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Db\TaskBlockerMapper; +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Releases a closed task's dependants. + * + * @template-implements IEventListener + */ +class TaskBlockerReleaseListener implements IEventListener { + + /** + * Constructor. + * + * @param TaskBlockerMapper $tasks Finds the dependants. + * @param TaskService $service Records the release and announces it. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly TaskBlockerMapper $tasks, + private readonly TaskService $service, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle the event. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + public function handle(Event $event): void { + if ($event instanceof TaskTerminalEvent === false || $event->isCommitted() === false) { + return; + } + + $blocker = $event->getTaskUuid(); + try { + $dependants = $this->tasks->findOpenBlockedBy(blockerUuid: $blocker); + } catch (Throwable $failure) { + $this->logger->warning( + '[TaskBlockerReleaseListener] Could not find the tasks waiting on a closed task: ' . $failure->getMessage(), + ['blocker' => $blocker] + ); + + return; + } + + foreach ($dependants as $dependant) { + try { + $this->service->record( + uuid: (string)$dependant->getUuid(), + action: 'released', + actor: null, + reason: sprintf("Task '%s' it waited on reached state '%s'.", $blocker, $event->getState()) + ); + } catch (Throwable $failure) { + $this->logger->warning( + '[TaskBlockerReleaseListener] Could not record a release: ' . $failure->getMessage(), + ['blocker' => $blocker, 'task' => $dependant->getUuid()] + ); + } + } + }//end handle() +}//end class diff --git a/lib/Migration/Version1Date20261010090000.php b/lib/Migration/Version1Date20261010090000.php new file mode 100644 index 0000000000..444c52b0eb --- /dev/null +++ b/lib/Migration/Version1Date20261010090000.php @@ -0,0 +1,81 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * A task may name the task it waits on. + * + * Adds `blocked_by` to `openregister_tasks`: the uuid of the blocking task, + * null for a task that waits on nothing. Only the declaration is stored; the + * blocked state is derived on read from the blocker's `is_terminal`. Indexed, + * because the release on a blocker's close looks dependants up by it. + * + * Idempotent: the column and its index are added only when absent. + * + * @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/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + +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 blocker column to tasks. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ +class Version1Date20261010090000 extends SimpleMigrationStep { + + /** + * The tasks table. + */ + private const TABLE = 'openregister_tasks'; + + /** + * Add the column and its index unless they are already there. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the schema wrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper The schema, changed or not. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The signature is Nextcloud's. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable(self::TABLE) === false) { + return $schema; + } + + $table = $schema->getTable(self::TABLE); + if ($table->hasColumn('blocked_by') === false) { + $table->addColumn('blocked_by', Types::STRING, ['notnull' => false, 'length' => 36]); + $output->info('Added blocked_by to '.self::TABLE); + } + + if ($table->hasIndex('or_tasks_blocked_by') === false) { + $table->addIndex(['blocked_by', 'is_terminal'], 'or_tasks_blocked_by'); + } + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Service/Task/TaskBlockerGuard.php b/lib/Service/Task/TaskBlockerGuard.php new file mode 100644 index 0000000000..d3339e53be --- /dev/null +++ b/lib/Service/Task/TaskBlockerGuard.php @@ -0,0 +1,124 @@ + + * 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/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCP\AppFramework\Db\DoesNotExistException; + +/** + * Refuses a hand-written blocked state, a self-block, an unknown blocker and a loop. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ +class TaskBlockerGuard { + + /** + * How far the chain of blockers is walked before it counts as a loop. + */ + private const MAX_CHAIN = 50; + + /** + * Constructor. + * + * @param TaskMapper $tasks Reads the blockers. + */ + public function __construct( + private readonly TaskMapper $tasks, + ) { + + }//end __construct() + + /** + * Refuse a payload that writes the blocked state by hand. + * + * Only the HTTP create path calls this: the trusted import path keeps the + * legacy `blocked` state mapping for migrated approval chains. + * + * @param array $data The create payload. + * + * @return void + * + * @throws TaskValidationException When the payload carries `blocked` or the state `blocked`. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + public function refuseHandWrittenState(array $data): void { + $state = strtolower(trim((string)($data['state'] ?? ''))); + if (array_key_exists('blocked', $data) === true || $state === 'blocked') { + throw new TaskValidationException( + message: 'A task is blocked while the task it names in blockedBy is open. Name that task in blockedBy; the blocked state cannot be written.' + ); + } + }//end refuseHandWrittenState() + + /** + * Refuse a blocker that is the task itself, does not exist, or closes a loop. + * + * @param Task $task The task about to be created, uuid already set. + * + * @return void + * + * @throws TaskValidationException On any refused blocker. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + public function assertDeclarable(Task $task): void { + $blockedBy = (string)$task->getBlockedBy(); + if ($blockedBy === '') { + return; + } + + $own = (string)$task->getUuid(); + if ($blockedBy === $own) { + throw new TaskValidationException(message: 'A task cannot wait on itself.'); + } + + $next = $blockedBy; + for ($step = 0; $step < self::MAX_CHAIN && $next !== ''; $step++) { + try { + $blocker = $this->tasks->findByUuid(uuid: $next); + } catch (DoesNotExistException) { + if ($next === $blockedBy) { + throw new TaskValidationException(message: sprintf("blockedBy names task '%s', and no task has that uuid.", $blockedBy)); + } + + // A removed task further up the chain blocks nothing. + return; + } + + $next = (string)$blocker->getBlockedBy(); + if ($next === $own) { + throw new TaskValidationException( + message: sprintf("blockedBy '%s' would make a loop: that task already waits, through its chain, on this one.", $blockedBy) + ); + } + } + }//end assertDeclarable() +}//end class diff --git a/lib/Service/Task/TaskBuilder.php b/lib/Service/Task/TaskBuilder.php index bbb2a01f13..712d51291a 100644 --- a/lib/Service/Task/TaskBuilder.php +++ b/lib/Service/Task/TaskBuilder.php @@ -65,6 +65,7 @@ class TaskBuilder { * @throws TaskValidationException On any refused value. * * @spec openspec/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ public function fromData(array $data, ?string $actor): Task { $task = new Task(); @@ -165,6 +166,7 @@ public function fromData(array $data, ?string $actor): Task { $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->setBlockedBy($this->stringOrNull(value: $data['blockedBy'] ?? 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)); diff --git a/lib/Service/Task/TaskInboxService.php b/lib/Service/Task/TaskInboxService.php index 6598cd0152..729a9086ba 100644 --- a/lib/Service/Task/TaskInboxService.php +++ b/lib/Service/Task/TaskInboxService.php @@ -40,6 +40,7 @@ use OCA\OpenRegister\Db\PortalTaskDeliveryMapper; use OCA\OpenRegister\Db\Task; use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Db\TaskBlockerMapper; use OCA\OpenRegister\Db\TaskMapper; use Psr\Log\LoggerInterface; use Throwable; @@ -83,6 +84,9 @@ class TaskInboxService { * hand-built test services; * absent, every subject is * resolved by the cross-table search. + * @param TaskBlockerMapper|null $blockers Answers which blockers are still + * open, for the derived `blocked` + * flag. Absent, no row is blocked. */ public function __construct( private readonly TaskMapper $tasks, @@ -91,6 +95,7 @@ public function __construct( private readonly ?AbstractObjectMapper $objects = null, private readonly ?PortalTaskDeliveryMapper $deliveries = null, private readonly ?TaskSubjectLocator $locator = null, + private readonly ?TaskBlockerMapper $blockers = null, ) { }//end __construct() @@ -111,6 +116,7 @@ public function __construct( * @return array{results: array>, total: int, limit: int, offset: int} The page. * * @spec openspec/specs/flow-tasks/spec.md#requirement-the-inbox-answers-what-is-waiting-for-me-in-one-query + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ public function inbox(TaskInboxCriteria $criteria, int $limit = 25, int $offset = 0): array { $limit = max(1, min($limit, 500)); @@ -128,11 +134,12 @@ public function inbox(TaskInboxCriteria $criteria, int $limit = 25, int $offset $page = $this->tasks->findInbox(criteria: $criteria, limit: $limit, offset: $offset); $total = $this->tasks->countInbox(criteria: $criteria); $subjects = $this->subjectContexts(tasks: $page); + $openBlockers = $this->openBlockers(tasks: $page); $now = $this->temporal->now(); $results = []; foreach ($page as $task) { - $results[] = $this->row(task: $task, subjects: $subjects, now: $now); + $results[] = $this->row(task: $task, subjects: $subjects, now: $now, openBlockers: $openBlockers); } return [ @@ -151,14 +158,23 @@ public function inbox(TaskInboxCriteria $criteria, int $limit = 25, int $offset * @param Task $task The task. * @param array> $subjects Subject context by object uuid. * @param \DateTimeInterface $now The clock instant for the projection. + * @param array|null $openBlockers The page's blocker uuids that + * are still open; null asks + * for this task's own. * * @return array The row. * * @spec openspec/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ - public function row(Task $task, array $subjects, \DateTimeInterface $now): array { + public function row(Task $task, array $subjects, \DateTimeInterface $now, ?array $openBlockers = null): array { $row = $task->jsonSerialize(); + // Derived, never stored: blocked while the named blocker is open. + $blockedBy = (string)$task->getBlockedBy(); + $openBlockers ??= $this->openBlockers(tasks: [$task]); + $row['blocked'] = ($blockedBy !== '' && in_array($blockedBy, $openBlockers, true) === true); + $subject = null; $objectUuid = (string)$task->getObjectUuid(); if ($objectUuid !== '' && array_key_exists($objectUuid, $subjects) === true) { @@ -253,6 +269,40 @@ public function enrich(Task $task): array { return $this->row(task: $task, subjects: $this->subjectContexts(tasks: [$task]), now: $this->temporal->now()); }//end enrich() + /** + * Which blockers of these tasks are still open, in ONE lookup. + * + * Never throws: a failed lookup reads as "not blocked", logged at debug, + * because the inbox does not fail over a derived flag. + * + * @param array $tasks The page. + * + * @return array The open blocker uuids. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + private function openBlockers(array $tasks): array { + $uuids = []; + foreach ($tasks as $task) { + $blockedBy = (string)$task->getBlockedBy(); + if ($blockedBy !== '') { + $uuids[] = $blockedBy; + } + } + + if ($uuids === [] || $this->blockers === null) { + return []; + } + + try { + return $this->blockers->openBlockerUuids(uuids: array_values(array_unique($uuids))); + } catch (Throwable $failure) { + $this->logger->debug('[TaskInboxService] Could not read blocker state: ' . $failure->getMessage()); + + return []; + } + }//end openBlockers() + /** * Synthesize a display title for a titleless task — on read, never persisted. * diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php index cfb62fa623..0ca82ba984 100644 --- a/lib/Service/Task/TaskService.php +++ b/lib/Service/Task/TaskService.php @@ -218,6 +218,7 @@ public function __construct( * * @spec openspec/specs/flow-tasks/spec.md#requirement-a-task-is-a-first-class-record-not-a-flow-artefact * @spec openspec/specs/flow-tasks/spec.md#requirement-a-task-may-only-be-created-on-an-object-its-creator-may-read + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ public function create(array $data, ?string $actor): Task { // Located first, so the access guard below reads the subject from its @@ -225,6 +226,10 @@ public function create(array $data, ?string $actor): Task { // reads the same either way: location says where, never whether. $data = $this->locate(data: $data); + // Blocked is derived from blockedBy, for administrators too: nobody + // writes the state by hand (a-task-may-wait-on-another-task D-6). + (new TaskBlockerGuard(tasks: $this->tasks))->refuseHandWrittenState(data: $data); + if ($this->authorization->isAdministrator(uid: $actor) === false) { // A task is an annotation ON an object, so it inherits that // object's read authorization: knowing a uuid is not entitlement @@ -270,11 +275,13 @@ public function create(array $data, ?string $actor): Task { * @throws TaskAccessDeniedException Without an acting identity. * * @spec openspec/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ public function import(array $data, ?string $actor): Task { $data = $this->locate(data: $data); $task = $this->builder->fromData(data: $data, actor: $actor); $this->refuseUnrenderableForm(task: $task); + (new TaskBlockerGuard(tasks: $this->tasks))->assertDeclarable(task: $task); $this->authorizeOrRecord(verb: 'create', task: $task, actor: $actor); $this->pending = [ 'assignee' => null, diff --git a/openspec/changes/a-task-may-wait-on-another-task/.openspec.yaml b/openspec/changes/a-task-may-wait-on-another-task/.openspec.yaml new file mode 100644 index 0000000000..81264324b0 --- /dev/null +++ b/openspec/changes/a-task-may-wait-on-another-task/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/a-task-may-wait-on-another-task/design.md b/openspec/changes/a-task-may-wait-on-another-task/design.md new file mode 100644 index 0000000000..839dfe2df6 --- /dev/null +++ b/openspec/changes/a-task-may-wait-on-another-task/design.md @@ -0,0 +1,59 @@ +# Design: a-task-may-wait-on-another-task + +## D-1. One column for the declaration, nothing stored for the state + +`blocked_by` holds the blocker's uuid. `blocked` is computed from the +blocker's `is_terminal` whenever a task is read. A stored flag would need a +writer on every terminal path, and `terminateForRun()` and the timer sweep +close tasks without passing through one place a listener could trust. A +derived flag cannot drift: a blocker closed by any path releases the task on +the next read. + +The declaration stays after release. It is history: the case can still say +which task this one waited on. + +## D-2. The inbox predicate is one uncorrelated subquery + +The inbox adds `blocked_by IS NULL OR blocked_by NOT IN (SELECT uuid FROM +tasks WHERE is_terminal = false AND uuid IS NOT NULL)`. Same predicate builder +for the page and for the total, so the badge count agrees with the list by +construction. `uuid IS NOT NULL` keeps `NOT IN` from turning every row +unknown. A blocker that was removed is not open, so its dependants are not +blocked. + +An object or run anchor skips the predicate. That read is the case looking at +its own work, the same exception `applyExternalExclusion()` makes, and the +case must show a waiting task with its blocker. + +## D-3. The flag on the row is batched + +`TaskInboxService::inbox()` asks the mapper once which of the page's +blockers are still open, and each row gets `blocked` from that set. A single +task read (`enrich()`) asks for its one blocker. No query per row. + +## D-4. Release is post-event work + +`TaskBlockerReleaseListener` listens for the committed `TaskTerminalEvent`. +It finds the open tasks whose `blocked_by` is the closed task and calls +`TaskService::record()` with action `released` for each. `record()` appends +the audit entry and announces the task, so its notification and calendar +entry are written again. A failure for one dependant is logged and the rest +still run: the blocker is closed either way. The listener is idempotent: a +second event for the same blocker writes a second `released` entry, which is +true and harmless. + +The in-transaction dispatch (`committed: false`) is ignored. Releasing inside +the blocker's transaction would make closing a task wait on its dependants. + +## D-5. Cycles are refused at create + +A task created with a blocker walks the chain of `blocked_by` uuids from that +blocker, at most 50 steps. Meeting the new task's own uuid is a cycle and is +refused. Because there is no update verb, only a caller that sets its own uuid +can build a cycle at all, so the walk is cheap insurance, not a hot path. + +## D-6. Writing `blocked` by hand is refused on the HTTP path + +`TaskService::create()` refuses a payload carrying `blocked`, and a state +that normalises from `blocked`. The trusted `import()` path keeps the legacy +mapping, because migrated approval chains still arrive as `blocked`. diff --git a/openspec/changes/a-task-may-wait-on-another-task/proposal.md b/openspec/changes/a-task-may-wait-on-another-task/proposal.md new file mode 100644 index 0000000000..7144d1f022 --- /dev/null +++ b/openspec/changes/a-task-may-wait-on-another-task/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Proposal: a-task-may-wait-on-another-task + +Decision 137 (9 Oct, Ruben, answer to Q-openregister-2). Asked by dossiq's +`task-dependencies-and-the-next-planned-action` tasks 3.1 to 3.3 (its design +D-7 and D-8, requirement REQ-TDP-02). Owner openregister, because the task, +its inbox and its close event are the engine's. + +## Why + +A caseworker sees tasks in their list that they cannot start yet. The advice +cannot be written before the site visit is done. A list full of tasks that +wait on something else is noise, and noise makes people stop reading it. + +dossiq has no task table since `remove-casetask`. Its task page is the +engine's inbox. So dossiq cannot keep a row out of a list it does not build, +and it cannot derive a state on a record it does not own. + +## What is actually there + +- `Task` has `blockedReason`, a free text, and the legacy state `blocked` + maps onto `active` plus that reason (`TaskState::LEGACY`). Nothing derives + it and nothing releases it. +- `relation-types-with-inverses` gives `blocks` and `blocked by` between + objects. Tasks are not objects. +- `TaskTerminalEvent` fires after the commit of every terminal transition + (`TaskService::transactional()`). That is the release point. + +## What changes + +- A task may name the task it waits on: `blockedBy`, a task uuid, accepted + on create and returned on every read. +- `blocked` is derived on read: the named blocker exists and is not terminal. + It is never stored and never accepted from a caller. +- The inbox leaves blocked tasks out, in the badge total as well. A read + anchored to an object or a run still lists them, with the flag, so the + case shows what waits and on what. `includeBlocked=true` brings them back + in any inbox read. +- When the blocker reaches a terminal state, each task it blocked gets an + audit entry `released` and is announced again, so the notification and the + calendar entry follow without anybody editing the task. +- Create refuses a task that blocks itself, a blocker that does not exist, a + chain that loops back, and a write of `blocked` or of the state `blocked`. + +## Out of scope + +- Changing a blocker after creation. There is no task update verb today. +- Refusing work on a blocked task. A blocked task can still be claimed and + completed by somebody who opens it directly: the block hides it from the + list, it does not lock it. +- More than one blocker per task. diff --git a/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md b/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md new file mode 100644 index 0000000000..fa705d3be5 --- /dev/null +++ b/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: A task may wait on another task, and waits out of sight + +A task SHALL accept an OPTIONAL `blockedBy` on create: the uuid of the task +it waits on. `blockedBy` SHALL be returned on every task read. + +Every task read SHALL carry `blocked`: true while the named blocker exists and +is not terminal, false otherwise. `blocked` SHALL be derived on read and SHALL +NOT be stored. A create that carries `blocked`, or a state of `blocked`, SHALL +be refused. + +A create SHALL be refused when `blockedBy` names the task itself, names no +existing task, or closes a loop of blockers. + +An inbox read SHALL leave out every blocked task, in its results and in its +total. A read anchored to an object or a run SHALL still list blocked tasks, +carrying `blocked` and `blockedBy`. A read with `includeBlocked=true` SHALL +list them in any scope. + +When a blocker reaches a terminal state, every open task it blocked SHALL be +released with no person acting: it SHALL get an audit entry `released` naming +the blocker, it SHALL be announced again to the projections, and it SHALL +return to the inbox. + +#### Scenario: A blocked task stays out of the due list +@e2e exclude unit; TaskBlockedByTest covers the predicate, the flag and the total + +- **GIVEN** a task assigned to Anna, blocked by an open task +- **WHEN** Anna opens her inbox +- **THEN** the blocked task SHALL NOT be listed +- **AND** the total SHALL NOT count it + +#### Scenario: The case shows what waits and on what +@e2e exclude unit; TaskBlockedByTest covers the anchored read + +- **GIVEN** the same pair, both anchored to one case +- **WHEN** the case's tasks are read +- **THEN** the blocked task SHALL be listed with `blocked` true and `blockedBy` naming the blocker + +#### Scenario: Closing the blocker releases the task +@e2e exclude unit; TaskBlockerReleaseListenerTest covers the release, TaskBlockedByTest the flag + +- **GIVEN** the same pair +- **WHEN** the blocking task is completed +- **THEN** the blocked task SHALL get an audit entry `released` +- **AND** its next read SHALL carry `blocked` false +- **AND** it SHALL be back in Anna's inbox without anybody editing it + +#### Scenario: The blocked state cannot be set by hand +@e2e exclude unit; TaskBlockedByTest + +- **GIVEN** a task with no blocker +- **WHEN** a create sets `blocked` to true, or its state to `blocked` +- **THEN** the create SHALL be refused + +#### Scenario: A task cannot wait on itself or on a loop +@e2e exclude unit; TaskBlockedByTest + +- **GIVEN** task A blocked by task B +- **WHEN** a task is created blocked by itself, by a uuid no task has, or as B's blocker with A's chain leading back to it +- **THEN** the create SHALL be refused diff --git a/openspec/changes/a-task-may-wait-on-another-task/tasks.md b/openspec/changes/a-task-may-wait-on-another-task/tasks.md new file mode 100644 index 0000000000..ddb8891632 --- /dev/null +++ b/openspec/changes/a-task-may-wait-on-another-task/tasks.md @@ -0,0 +1,25 @@ +# Tasks: a-task-may-wait-on-another-task + +Decision 137. Owner openregister. Asked by dossiq +`task-dependencies-and-the-next-planned-action` 3.1 to 3.3. + +## 1. The declaration + +- [ ] 1.1 `blocked_by` column on `openregister_tasks` (migration, index), `Task::blockedBy`, serialised, carried by `TaskBuilder`; info.xml version moves. + - `tests/Unit/Db/TaskBlockedByTest.php` +- [ ] 1.2 Create refuses `blocked`, the state `blocked`, a self-block, an unknown blocker and a loop (D-5, D-6). + - `tests/Unit/Service/Task/TaskBlockedByCreateTest.php` + +## 2. The derived flag and the inbox + +- [ ] 2.1 `TaskMapper::openBlockerUuids()`; `TaskInboxService` adds `blocked` to every row, batched per page (D-3). +- [ ] 2.2 The inbox predicate leaves blocked tasks out of page and total, except on an object or run anchor or with `includeBlocked` (D-2); the controller reads `includeBlocked`. + +## 3. The release + +- [ ] 3.1 `TaskBlockerReleaseListener` on the committed `TaskTerminalEvent`: `released` audit and re-announcement per open dependant; registered in `Application` (D-4). + - `tests/Unit/Listener/TaskBlockerReleaseListenerTest.php` + +## 4. Live + +- [ ] 4.1 Live probe: create A, create B blocked by A for the same assignee, read the inbox (B absent, total excludes it), complete A, read again (B present, audit has `released`). diff --git a/tests/Unit/Controller/TaskControllerTest.php b/tests/Unit/Controller/TaskControllerTest.php index 99684e1aee..9448ab6c0c 100644 --- a/tests/Unit/Controller/TaskControllerTest.php +++ b/tests/Unit/Controller/TaskControllerTest.php @@ -588,6 +588,29 @@ function (TaskInboxCriteria $criteria): array { $this->controller->index(); }//end testIndexWithoutADueWindowCarriesNone() + /** + * `includeBlocked=true` reaches the criteria; without it blocked tasks stay out. + * + * @return void + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + public function testIndexPassesIncludeBlockedIntoTheCriteria(): void { + $seen = []; + $this->inbox->expects($this->exactly(2))->method('inbox')->willReturnCallback( + function (TaskInboxCriteria $criteria) use (&$seen): array { + $seen[] = $criteria->includeBlocked; + + return ['results' => [], 'total' => 0, 'limit' => 25, 'offset' => 0]; + } + ); + + $this->controller->index(includeBlocked: 'true'); + $this->controller->index(); + + $this->assertSame([true, false], $seen); + }//end testIndexPassesIncludeBlockedIntoTheCriteria() + /** * With overdue unset the criteria carry no clock instant. * diff --git a/tests/Unit/Db/FluentQueryBuilderTrait.php b/tests/Unit/Db/FluentQueryBuilderTrait.php index 4a4154a274..0d30c7f639 100644 --- a/tests/Unit/Db/FluentQueryBuilderTrait.php +++ b/tests/Unit/Db/FluentQueryBuilderTrait.php @@ -141,7 +141,7 @@ public function __toString(): string { } }; $expr = $this->createMock(originalClassName: IExpressionBuilder::class); - foreach (['eq', 'neq', 'lt', 'gt', 'in', 'like', 'isNull', 'isNotNull'] as $method) { + foreach (['eq', 'neq', 'lt', 'gt', 'in', 'notIn', 'like', 'isNull', 'isNotNull'] as $method) { $expr->method($method)->willReturnCallback( function (mixed ...$args) use ($method): string { $this->calls[] = ['expr.' . $method, ($args[0] ?? null)]; @@ -195,6 +195,9 @@ function (mixed ...$args) use ($method): string { $this->stubAllRowsReader($result, $rows); $qb->method('executeQuery')->willReturn($result); $qb->method('getTableName')->willReturn('openregister_tasks'); + // A subquery is embedded through createFunction(getSQL()); the text is + // not under test, the predicate around it is. + $qb->method('getSQL')->willReturn('SELECT subquery'); return $qb; }//end fluentBuilder() diff --git a/tests/Unit/Db/TaskBlockedByTest.php b/tests/Unit/Db/TaskBlockedByTest.php new file mode 100644 index 0000000000..d84274f6d1 --- /dev/null +++ b/tests/Unit/Db/TaskBlockedByTest.php @@ -0,0 +1,224 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskBlockerMapper; +use OCA\OpenRegister\Db\TaskInboxCriteria; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCA\OpenRegister\Service\Task\TaskInboxService; +use OCA\OpenRegister\Service\Task\TaskTemporalProjection; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * The blocker travels from create to read, and the inbox leaves blocked work out. + * + * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Db\TaskMapper + * @covers \OCA\OpenRegister\Db\TaskBlockerMapper + * @covers \OCA\OpenRegister\Db\TaskInboxCriteria + * @covers \OCA\OpenRegister\Service\Task\TaskBuilder + * @covers \OCA\OpenRegister\Service\Task\TaskInboxService + * @uses \OCA\OpenRegister\Service\Task\TaskPriority + * @uses \OCA\OpenRegister\Service\Task\TaskState + * @uses \OCA\OpenRegister\Service\Task\TaskTemporalProjection + */ +class TaskBlockedByTest extends TestCase { + use FluentQueryBuilderTrait; + + /** + * The builder carries `blockedBy`, and leaves it null when the payload is silent. + * + * @return void + */ + public function testTheBuilderCarriesTheBlocker(): void { + $builder = new TaskBuilder(); + + $waiting = $builder->fromData(data: ['title' => 'Write the advice', 'blockedBy' => 'task-visit'], actor: 'anna'); + $this->assertSame('task-visit', $waiting->getBlockedBy()); + + $plain = $builder->fromData(data: ['title' => 'Visit the site'], actor: 'anna'); + $this->assertNull($plain->getBlockedBy()); + }//end testTheBuilderCarriesTheBlocker() + + /** + * The stored row carries the declaration, and never a stored `blocked`. + * + * @return void + */ + public function testTheSerialisedRowCarriesTheBlockerButNoStoredFlag(): void { + $task = new Task(); + $task->setBlockedBy('task-visit'); + + $row = $task->jsonSerialize(); + + $this->assertSame('task-visit', $row['blockedBy']); + $this->assertArrayNotHasKey('blocked', $row, 'blocked is derived by the read surface, never stored on the entity'); + }//end testTheSerialisedRowCarriesTheBlockerButNoStoredFlag() + + /** + * An ordinary inbox read leaves out tasks whose blocker is still open. + * + * @return void + */ + public function testTheInboxLeavesBlockedTasksOut(): void { + $mapper = new TaskMapper(db: $this->connectionWith()); + + $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'anna')); + + $this->assertTrue($this->saw('expr.notIn', 'blocked_by'), 'the page must exclude tasks whose blocker is open'); + $this->assertTrue($this->saw('expr.isNull', 'blocked_by'), 'a task with no blocker must stay listed'); + }//end testTheInboxLeavesBlockedTasksOut() + + /** + * The total uses the same predicate, so the badge never counts a waiting task. + * + * @return void + */ + public function testTheTotalLeavesBlockedTasksOut(): void { + $mapper = new TaskMapper(db: $this->connectionWith(rows: [['total' => 0]])); + + $mapper->countInbox(criteria: new TaskInboxCriteria(uid: 'anna')); + + $this->assertTrue($this->saw('expr.notIn', 'blocked_by')); + }//end testTheTotalLeavesBlockedTasksOut() + + /** + * A read anchored to the case lists blocked tasks: the case shows what waits. + * + * @return void + */ + public function testAnObjectAnchoredReadKeepsBlockedTasks(): void { + $mapper = new TaskMapper(db: $this->connectionWith()); + + $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'anna', objectUuid: 'case-1')); + + $this->assertFalse($this->saw('expr.notIn', 'blocked_by')); + }//end testAnObjectAnchoredReadKeepsBlockedTasks() + + /** + * A run-anchored read lists them too, like the external exclusion it mirrors. + * + * @return void + */ + public function testARunAnchoredReadKeepsBlockedTasks(): void { + $mapper = new TaskMapper(db: $this->connectionWith()); + + $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'anna', runUuid: 'run-1')); + + $this->assertFalse($this->saw('expr.notIn', 'blocked_by')); + }//end testARunAnchoredReadKeepsBlockedTasks() + + /** + * `includeBlocked` brings them back in any scope. + * + * @return void + */ + public function testIncludeBlockedBringsThemBack(): void { + $mapper = new TaskMapper(db: $this->connectionWith()); + + $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'anna', includeBlocked: true)); + + $this->assertFalse($this->saw('expr.notIn', 'blocked_by')); + }//end testIncludeBlockedBringsThemBack() + + /** + * The mapper answers which of the named uuids are still open tasks. + * + * @return void + */ + public function testOpenBlockerUuidsAsksForOpenTasksByUuid(): void { + $mapper = new TaskBlockerMapper(db: $this->connectionWith(rows: [['uuid' => 'task-visit']])); + + $open = $mapper->openBlockerUuids(uuids: ['task-visit', 'task-closed']); + + $this->assertSame(['task-visit'], $open); + $this->assertTrue($this->saw('expr.in', 'uuid')); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + }//end testOpenBlockerUuidsAsksForOpenTasksByUuid() + + /** + * No uuids, no query. + * + * @return void + */ + public function testOpenBlockerUuidsOfNothingIsNothing(): void { + $mapper = new TaskBlockerMapper(db: $this->connectionWith()); + + $this->assertSame([], $mapper->openBlockerUuids(uuids: [])); + $this->assertFalse($this->saw('select')); + }//end testOpenBlockerUuidsOfNothingIsNothing() + + /** + * The inbox row carries the derived flag, from ONE lookup for the page. + * + * @return void + */ + public function testEveryRowCarriesTheDerivedFlagFromOneLookup(): void { + $waiting = new Task(); + $waiting->setUuid('task-advice'); + $waiting->setBlockedBy('task-visit'); + $released = new Task(); + $released->setUuid('task-letter'); + $released->setBlockedBy('task-closed'); + $free = new Task(); + $free->setUuid('task-call'); + + $tasks = $this->createMock(TaskMapper::class); + $tasks->method('findInbox')->willReturn([$waiting, $released, $free]); + $tasks->method('countInbox')->willReturn(3); + $blockers = $this->createMock(TaskBlockerMapper::class); + $blockers->expects($this->once()) + ->method('openBlockerUuids') + ->with(['task-visit', 'task-closed']) + ->willReturn(['task-visit']); + + $inbox = new TaskInboxService(tasks: $tasks, temporal: new TaskTemporalProjection(), logger: new NullLogger(), objects: null, blockers: $blockers); + $rows = $inbox->inbox(criteria: new TaskInboxCriteria(uid: 'anna', objectUuid: 'case-1'))['results']; + + $this->assertTrue($rows[0]['blocked']); + $this->assertSame('task-visit', $rows[0]['blockedBy']); + $this->assertFalse($rows[1]['blocked'], 'a closed blocker releases the task on the next read'); + $this->assertFalse($rows[2]['blocked']); + }//end testEveryRowCarriesTheDerivedFlagFromOneLookup() + + /** + * A single read (the projections' row) derives the flag for its one blocker. + * + * @return void + */ + public function testASingleReadDerivesTheFlagToo(): void { + $waiting = new Task(); + $waiting->setUuid('task-advice'); + $waiting->setBlockedBy('task-visit'); + + $tasks = $this->createMock(TaskMapper::class); + $blockers = $this->createMock(TaskBlockerMapper::class); + $blockers->method('openBlockerUuids')->willReturn(['task-visit']); + + $inbox = new TaskInboxService(tasks: $tasks, temporal: new TaskTemporalProjection(), logger: new NullLogger(), objects: null, blockers: $blockers); + + $this->assertTrue($inbox->enrich(task: $waiting)['blocked']); + }//end testASingleReadDerivesTheFlagToo() +}//end class diff --git a/tests/Unit/Db/TaskMapperQueriesTest.php b/tests/Unit/Db/TaskMapperQueriesTest.php index 7c148f3c68..b16041837a 100644 --- a/tests/Unit/Db/TaskMapperQueriesTest.php +++ b/tests/Unit/Db/TaskMapperQueriesTest.php @@ -268,8 +268,11 @@ public function testFindInboxScopesForAnAdmin(): void { $this->calls = []; $this->functions = []; $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'root', isAdmin: true, scope: TaskInboxCriteria::SCOPE_WATCHED)); - $this->assertCount(1, $this->functions); - $this->assertStringContainsString('LIKE', $this->functions[0]); + // The blocked-task subquery is a function too; the watcher match is + // the one function besides it. + $watched = array_values(array_filter($this->functions, static fn (string $sql): bool => $sql !== 'SELECT subquery')); + $this->assertCount(1, $watched); + $this->assertStringContainsString('LIKE', $watched[0]); $this->calls = []; $mapper->findInbox(criteria: new TaskInboxCriteria(uid: 'root', isAdmin: true, scope: TaskInboxCriteria::SCOPE_ALL)); diff --git a/tests/Unit/Listener/TaskBlockerReleaseListenerTest.php b/tests/Unit/Listener/TaskBlockerReleaseListenerTest.php new file mode 100644 index 0000000000..98f5a22722 --- /dev/null +++ b/tests/Unit/Listener/TaskBlockerReleaseListenerTest.php @@ -0,0 +1,145 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskBlockerMapper as TaskMapper; +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Listener\TaskBlockerReleaseListener; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\EventDispatcher\Event; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * The release is post-event work on the committed terminal event. + * + * @covers \OCA\OpenRegister\Listener\TaskBlockerReleaseListener + * @uses \OCA\OpenRegister\Db\Task + * @uses \OCA\OpenRegister\Event\TaskTerminalEvent + */ +class TaskBlockerReleaseListenerTest extends TestCase { + + /** + * A task in the given state. + * + * @param string $uuid The uuid. + * @param string $state The state. + * + * @return Task The task. + */ + private function task(string $uuid, string $state = Task::STATE_COMPLETED): Task { + $task = new Task(); + $task->setUuid($uuid); + $task->setState($state); + $task->setIsTerminal(in_array($state, Task::TERMINAL_STATES, true)); + + return $task; + }//end task() + + /** + * Every open dependant gets a `released` audit entry naming the blocker. + * + * @return void + */ + public function testClosingTheBlockerReleasesEveryDependant(): void { + $tasks = $this->createMock(TaskMapper::class); + $tasks->expects($this->once())->method('findOpenBlockedBy')->with('task-visit') + ->willReturn([$this->task(uuid: 'task-advice', state: Task::STATE_ACTIVE), $this->task(uuid: 'task-letter', state: Task::STATE_ENABLED)]); + + $recorded = []; + $service = $this->createMock(TaskService::class); + $service->method('record')->willReturnCallback( + function (string $uuid, string $action, ?string $actor, string $reason) use (&$recorded): Task { + $recorded[] = [$uuid, $action, $actor, $reason]; + + return $this->task(uuid: $uuid, state: Task::STATE_ACTIVE); + } + ); + + $listener = new TaskBlockerReleaseListener(tasks: $tasks, service: $service, logger: new NullLogger()); + $listener->handle(event: new TaskTerminalEvent(task: $this->task(uuid: 'task-visit'))); + + $this->assertSame(['task-advice', 'task-letter'], array_column($recorded, 0)); + $this->assertSame(['released', 'released'], array_column($recorded, 1)); + $this->assertNull($recorded[0][2], 'nobody acts: the system releases'); + $this->assertStringContainsString('task-visit', $recorded[0][3]); + }//end testClosingTheBlockerReleasesEveryDependant() + + /** + * The in-transaction dispatch is ignored: closing a task never waits on its dependants. + * + * @return void + */ + public function testTheUncommittedDispatchReleasesNothing(): void { + $tasks = $this->createMock(TaskMapper::class); + $tasks->expects($this->never())->method('findOpenBlockedBy'); + $service = $this->createMock(TaskService::class); + $service->expects($this->never())->method('record'); + + $listener = new TaskBlockerReleaseListener(tasks: $tasks, service: $service, logger: new NullLogger()); + $listener->handle(event: new TaskTerminalEvent(task: $this->task(uuid: 'task-visit'), committed: false)); + }//end testTheUncommittedDispatchReleasesNothing() + + /** + * One failing dependant does not stop the others. + * + * @return void + */ + public function testOneFailureDoesNotStopTheRest(): void { + $tasks = $this->createMock(TaskMapper::class); + $tasks->method('findOpenBlockedBy') + ->willReturn([$this->task(uuid: 'task-advice', state: Task::STATE_ACTIVE), $this->task(uuid: 'task-letter', state: Task::STATE_ACTIVE)]); + + $recorded = []; + $service = $this->createMock(TaskService::class); + $service->method('record')->willReturnCallback( + function (string $uuid) use (&$recorded): Task { + if ($uuid === 'task-advice') { + throw new RuntimeException('database went away'); + } + + $recorded[] = $uuid; + + return $this->task(uuid: $uuid, state: Task::STATE_ACTIVE); + } + ); + + $listener = new TaskBlockerReleaseListener(tasks: $tasks, service: $service, logger: new NullLogger()); + $listener->handle(event: new TaskTerminalEvent(task: $this->task(uuid: 'task-visit'))); + + $this->assertSame(['task-letter'], $recorded); + }//end testOneFailureDoesNotStopTheRest() + + /** + * Any other event is ignored. + * + * @return void + */ + public function testAnotherEventIsIgnored(): void { + $tasks = $this->createMock(TaskMapper::class); + $tasks->expects($this->never())->method('findOpenBlockedBy'); + + $listener = new TaskBlockerReleaseListener(tasks: $tasks, service: $this->createMock(TaskService::class), logger: new NullLogger()); + $listener->handle(event: new Event()); + }//end testAnotherEventIsIgnored() +}//end class diff --git a/tests/Unit/Service/Task/TaskBlockedByCreateTest.php b/tests/Unit/Service/Task/TaskBlockedByCreateTest.php new file mode 100644 index 0000000000..63a02d05cc --- /dev/null +++ b/tests/Unit/Service/Task/TaskBlockedByCreateTest.php @@ -0,0 +1,208 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Task; + +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskAuditMapper; +use OCA\OpenRegister\Db\TaskCandidateMapper; +use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Db\TaskRelationMapper; +use OCA\OpenRegister\Exception\TaskValidationException; +use OCA\OpenRegister\Service\Task\TaskAuthorizationService; +use OCA\OpenRegister\Service\Task\TaskBuilder; +use OCA\OpenRegister\Service\Task\TaskPerformerResolver; +use OCA\OpenRegister\Service\Task\TaskService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * The blocked state is derived, so a caller can declare a blocker but never the state. + * + * @covers \OCA\OpenRegister\Service\Task\TaskService + * @uses \OCA\OpenRegister\Db\Task + * @uses \OCA\OpenRegister\Service\Task\TaskBuilder + * @uses \OCA\OpenRegister\Service\Task\TaskPriority + * @uses \OCA\OpenRegister\Service\Task\TaskState + */ +class TaskBlockedByCreateTest extends TestCase { + + /** + * The task mapper. + * + * @var TaskMapper&MockObject + */ + private TaskMapper&MockObject $tasks; + + /** + * Stored tasks by uuid, as findByUuid answers them. + * + * @var array + */ + private array $stored = []; + + /** + * Fresh mapper per test, answering from $stored. + * + * @return void + */ + protected function setUp(): void { + $this->stored = []; + $this->tasks = $this->createMock(TaskMapper::class); + $this->tasks->method('findByUuid')->willReturnCallback( + function (string $uuid): Task { + if (array_key_exists($uuid, $this->stored) === false) { + throw new DoesNotExistException('none'); + } + + return $this->stored[$uuid]; + } + ); + $this->tasks->method('insert')->willReturnCallback( + static function (Task $task): Task { + $task->setId(41); + + return $task; + } + ); + }//end setUp() + + /** + * The service under test, with real builder and mocked stores. + * + * @return TaskService The service. + */ + private function service(): TaskService { + $audits = $this->createMock(TaskAuditMapper::class); + $audits->method('insert')->willReturnArgument(0); + $authorization = $this->createMock(TaskAuthorizationService::class); + $authorization->method('isAdministrator')->willReturn(true); + + return new TaskService( + tasks: $this->tasks, + candidates: $this->createMock(TaskCandidateMapper::class), + relations: $this->createMock(TaskRelationMapper::class), + audits: $audits, + authorization: $authorization, + resolver: $this->createMock(TaskPerformerResolver::class), + db: $this->createMock(IDBConnection::class), + logger: new NullLogger(), + builder: new TaskBuilder() + ); + }//end service() + + /** + * Store a task. + * + * @param string $uuid The uuid. + * @param string|null $blockedBy Its blocker. + * + * @return void + */ + private function store(string $uuid, ?string $blockedBy = null): void { + $task = new Task(); + $task->setUuid($uuid); + $task->setState(Task::STATE_ACTIVE); + $task->setIsTerminal(false); + $task->setBlockedBy($blockedBy); + $this->stored[$uuid] = $task; + }//end store() + + /** + * A task blocked by an existing open task is created with the declaration. + * + * @return void + */ + public function testATaskMayNameTheTaskItWaitsOn(): void { + $this->store(uuid: 'task-visit'); + + $created = $this->service()->create(data: ['title' => 'Write the advice', 'blockedBy' => 'task-visit'], actor: 'anna'); + + $this->assertSame('task-visit', $created->getBlockedBy()); + }//end testATaskMayNameTheTaskItWaitsOn() + + /** + * Writing `blocked` by hand is refused, and nothing is inserted. + * + * @return void + */ + public function testWritingBlockedByHandIsRefused(): void { + $this->tasks->expects($this->never())->method('insert'); + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage('blockedBy'); + + $this->service()->create(data: ['title' => 'Write the advice', 'blocked' => true], actor: 'anna'); + }//end testWritingBlockedByHandIsRefused() + + /** + * The legacy state `blocked` is refused on the create path too. + * + * @return void + */ + public function testTheStateBlockedIsRefusedOnCreate(): void { + $this->tasks->expects($this->never())->method('insert'); + $this->expectException(TaskValidationException::class); + + $this->service()->create(data: ['title' => 'Write the advice', 'state' => 'blocked'], actor: 'anna'); + }//end testTheStateBlockedIsRefusedOnCreate() + + /** + * A task cannot wait on itself. + * + * @return void + */ + public function testATaskCannotWaitOnItself(): void { + $this->tasks->expects($this->never())->method('insert'); + $this->expectException(TaskValidationException::class); + + $this->service()->create(data: ['uuid' => 'task-a', 'title' => 'Loop', 'blockedBy' => 'task-a'], actor: 'anna'); + }//end testATaskCannotWaitOnItself() + + /** + * A blocker no task carries is refused, naming it. + * + * @return void + */ + public function testAnUnknownBlockerIsRefused(): void { + $this->tasks->expects($this->never())->method('insert'); + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage('task-ghost'); + + $this->service()->create(data: ['title' => 'Write the advice', 'blockedBy' => 'task-ghost'], actor: 'anna'); + }//end testAnUnknownBlockerIsRefused() + + /** + * A chain that leads back to the new task is a loop, and is refused. + * + * @return void + */ + public function testALoopOfBlockersIsRefused(): void { + // B waits on A; creating A blocked by B closes the loop. + $this->store(uuid: 'task-b', blockedBy: 'task-a'); + $this->tasks->expects($this->never())->method('insert'); + $this->expectException(TaskValidationException::class); + $this->expectExceptionMessage('loop'); + + $this->service()->create(data: ['uuid' => 'task-a', 'title' => 'A', 'blockedBy' => 'task-b'], actor: 'anna'); + }//end testALoopOfBlockersIsRefused() +}//end class From 84e074ae392a867b2344a3b16c9d73113df21aa3 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 09:49:12 +0200 Subject: [PATCH 009/183] feat(tasks): a task may wait on another task, and leaves the inbox until that task closes A task names its blocker in blockedBy. Every read carries a derived blocked flag; the inbox and its total leave blocked tasks out, an object or run anchored read still lists them, includeBlocked=true brings them back. Closing the blocker writes a released audit entry on each open dependant and announces it again. Create refuses a hand-written blocked state, a self-block, an unknown blocker and a loop. Change a-task-may-wait-on-another-task (decision 137, asked by dossiq task-dependencies-and-the-next-planned-action 3.1-3.3). Verifies the coordinator's wip 10ad0e6fa2. --- .../a-task-may-wait-on-another-task/tasks.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openspec/changes/a-task-may-wait-on-another-task/tasks.md b/openspec/changes/a-task-may-wait-on-another-task/tasks.md index ddb8891632..4c03bea137 100644 --- a/openspec/changes/a-task-may-wait-on-another-task/tasks.md +++ b/openspec/changes/a-task-may-wait-on-another-task/tasks.md @@ -5,21 +5,22 @@ Decision 137. Owner openregister. Asked by dossiq ## 1. The declaration -- [ ] 1.1 `blocked_by` column on `openregister_tasks` (migration, index), `Task::blockedBy`, serialised, carried by `TaskBuilder`; info.xml version moves. +- [x] 1.1 `blocked_by` column on `openregister_tasks` (migration, index), `Task::blockedBy`, serialised, carried by `TaskBuilder`; info.xml version moves. - `tests/Unit/Db/TaskBlockedByTest.php` -- [ ] 1.2 Create refuses `blocked`, the state `blocked`, a self-block, an unknown blocker and a loop (D-5, D-6). - - `tests/Unit/Service/Task/TaskBlockedByCreateTest.php` +- [x] 1.2 Create refuses `blocked`, the state `blocked`, a self-block, an unknown blocker and a loop (D-5, D-6). + - `tests/Unit/Service/Task/TaskBlockedByCreateTest.php` (`TaskBlockerGuard`, called from `TaskService::create()` and `import()`) ## 2. The derived flag and the inbox -- [ ] 2.1 `TaskMapper::openBlockerUuids()`; `TaskInboxService` adds `blocked` to every row, batched per page (D-3). -- [ ] 2.2 The inbox predicate leaves blocked tasks out of page and total, except on an object or run anchor or with `includeBlocked` (D-2); the controller reads `includeBlocked`. +- [x] 2.1 `TaskBlockerMapper::openBlockerUuids()` (its own mapper: TaskMapper sat at the phpmd class-length limit); `TaskInboxService` adds `blocked` to every row, batched per page (D-3). +- [x] 2.2 The inbox predicate leaves blocked tasks out of page and total, except on an object or run anchor or with `includeBlocked` (D-2); the controller reads `includeBlocked`. + - `tests/Unit/Db/TaskBlockedByTest.php`, `tests/Unit/Controller/TaskControllerTest.php::testIndexPassesIncludeBlockedIntoTheCriteria` ## 3. The release -- [ ] 3.1 `TaskBlockerReleaseListener` on the committed `TaskTerminalEvent`: `released` audit and re-announcement per open dependant; registered in `Application` (D-4). +- [x] 3.1 `TaskBlockerReleaseListener` on the committed `TaskTerminalEvent`: `released` audit and re-announcement per open dependant; registered in `Application` (D-4). - `tests/Unit/Listener/TaskBlockerReleaseListenerTest.php` ## 4. Live -- [ ] 4.1 Live probe: create A, create B blocked by A for the same assignee, read the inbox (B absent, total excludes it), complete A, read again (B present, audit has `released`). +- [ ] 4.1 Live probe: create A, create B blocked by A for the same assignee, read the inbox (B absent, total excludes it), complete A, read again (B present, audit has `released`). (not run: needs the live instance after the migration; the NOT IN subquery's SQL is only proven on a real database) From 5564805d198994344f4775142c006d2a062d81b4 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 10:55:40 +0200 Subject: [PATCH 010/183] docs(openspec): one engine for BPMN and CMMN on a shared core (change one-engine-bpmn-and-cmmn) --- .../one-engine-bpmn-and-cmmn/.openspec.yaml | 2 + .../one-engine-bpmn-and-cmmn/design.md | 188 ++++++++++++++++++ .../one-engine-bpmn-and-cmmn/proposal.md | 106 ++++++++++ .../specs/flow-cases/spec.md | 147 ++++++++++++++ .../changes/one-engine-bpmn-and-cmmn/tasks.md | 34 ++++ 5 files changed, 477 insertions(+) create mode 100644 openspec/changes/one-engine-bpmn-and-cmmn/.openspec.yaml create mode 100644 openspec/changes/one-engine-bpmn-and-cmmn/design.md create mode 100644 openspec/changes/one-engine-bpmn-and-cmmn/proposal.md create mode 100644 openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md create mode 100644 openspec/changes/one-engine-bpmn-and-cmmn/tasks.md diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/.openspec.yaml b/openspec/changes/one-engine-bpmn-and-cmmn/.openspec.yaml new file mode 100644 index 0000000000..53f62d7060 --- /dev/null +++ b/openspec/changes/one-engine-bpmn-and-cmmn/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/design.md b/openspec/changes/one-engine-bpmn-and-cmmn/design.md new file mode 100644 index 0000000000..6bb8e78422 --- /dev/null +++ b/openspec/changes/one-engine-bpmn-and-cmmn/design.md @@ -0,0 +1,188 @@ +# Design: one-engine-bpmn-and-cmmn + +## 1. Inventory (development @5d733a7185, 10 Oct 2026) + +| Concern | BPMN (flow engine) | CMMN (case layer) | Today | +|---|---|---|---| +| Model | flow graph, `FlowService`, versions, BPMN import/export (`Service/Flow/Bpmn/`) | plan definition, `CasePlanDefinition` (validated at create) | separate by nature | +| Walker / evaluator | `FlowEngine`, `FlowRunAdvancer`, `FlowTokenRouter` | `CasePlanCascade`, `CasePlanTree` | separate by nature | +| Runtime state | `openregister_flow_runs` + marking (`FlowRunMarkingStore`) | `openregister_case_items` rows | separate by nature | +| Lifecycle table | task states (`Task::STATES`, `TaskState`) | `CasePlanTransitions` over `CaseItem::STATES` | same six states, declared twice (gap 4) | +| Human work | `UserTaskNode` creates a task via `TaskService` | `CaseRealisationService::realise()` creates a task via `TaskService::import()` | **shared** | +| Conditions | `FlowExpression` (JSONLogic) | `CaseSentryEvaluator` calls `FlowExpression::isTrue()` | **shared** | +| Events | `EventCatalogService` triggers | on-parts from the same catalog, plus `case.item.*` | **shared** | +| Subject | run subject `{uuid, register, schema}` | plan anchor triple, same shape | **shared** | +| Timers | `FlowTimerService` (subjects `task`, `object`, `run`), `WorkingCalendar` | `dueAt` / `expiresAt` stored, never armed | gap 3 | +| CMMN starts BPMN | n/a | stage realised by a run (`CaseRunTerminalListener`) | stage only (gap 1) | +| BPMN touches CMMN | no node | n/a | missing (gap 2) | +| Authorization | `FlowRunAuthorization`, `TaskAuthorizationService` | `CasePlanAuthorizationService` (user/role/group rules, fail-closed) | one model per subject kind, all fail-closed | +| Audit | run steps / history (`FlowStepHistory`), task events | `openregister_case_item_audit` (append-only) | one table per state shape | +| System callers | runs execute as their attributed identity (`FlowRunAsScope`) | every verb refuses a null identity | gap 5 | +| Bring-over | `FlowRunMigrationService` (run to new version) | none | gap 6 | + +dossiq's needs (dossiq/STATE.md "Lane L7", decision 146, and dossiq +`retire-cmmn-caseplanstate` design sections 1-3): start a plan from a +definition for an object, read it, transition, enable discretionary, list +enableable, attach ad-hoc, find objects by item type and state (all exist); +human items as engine tasks (exists); sentries in the one dialect (exists); +timers from the shared core (gap 3); an idempotent "ensure these items with +these states" for the drain, with an `imported` flag on history (gap 6); and +calls from listeners and `occ` without a session (gap 5). dossiq already +calls `createPlan(uid: null)` and `getPlan(uid: null)`; both are refused +today, so its projection and rollback are silent no-ops until it moves to the +system methods. + +## 2. The boundary + +``` + BPMN process walker CMMN case-plan evaluator + (FlowEngine, marking) (CasePlanCascade, plan rows) + \ / + \ owns: its state shape / + --------------------------------------------------------------- + shared core: TaskService | FlowTimerService + WorkingCalendar | + EventCatalogService + FlowExpression | subject anchor | + authorization primitives (fail-closed) | append-only audit +``` + +Rule: a model evaluator owns the shape of its own runtime state and the +algorithm that advances it. It SHALL NOT own a task store, a clock, a +condition dialect, an event vocabulary or a second authorization model. A +new need in either evaluator that fits one of those goes into the core. + +Cross-links are explicit and one-directional per call: + +- CMMN to BPMN: a `stage` or `processTask` realised by a run + (`REALISATION_RUN`). The run's terminal state drives the item + (`CaseRunTerminalListener`, existing). +- BPMN to CMMN: the `case-open` and `case-advance` nodes call + `CasePlanService`; the plan's cascade then runs as for any other + transition. + +Loops are bounded where they already are: the cascade's fixpoint bound and +the run advance budget. A run started by a `processTask` that advances its +own item is an ordinary transition of an active item. + +## 3. The gaps, as built + +### 3.1 `processTask` (gap 1) + +`CaseItem::TYPE_PROCESS_TASK = 'processTask'`, added to `TYPES`. +`CasePlanTransitions` maps it to the work-item edges. `CasePlanDefinition` +accepts it; a process task MUST name its flow in `planSettings.flows[key]` +(validated at create: a process task without a flow is refused, because it +would be a human task with no human). `CaseRealisationService::realise()` +queues the run for stage and processTask alike. A process task has no +children. + +### 3.2 Flow nodes (gap 2) + +- `openregister.case-open`: config `{definition}` (the same shape + `createPlan` takes). Creates the plan on the run's subject through + `createPlan`, acting as the run's attributed identity. An object that + already has a plan is not an error for the node: it reports + `{opened: false, reason: "exists"}` on its output, so a re-run is safe. +- `openregister.case-advance`: config `{item, to, reason?}` where `item` is + the plan-item key. Resolves the item on the run's subject and calls + `transition`. Refusals (illegal edge, authorization) fail the step with the + engine's message; they are never swallowed. + +Both register through `FlowNodeRegistry` like every other node. + +### 3.3 Plan-item timers (gap 3) + +`FlowTimer::SUBJECT_TYPES` gains `case-item`. `CasePlanItemTimers` (new): + +- on an item entering `active` with `dueAt` set, arm a `due` timer anchored + on now with `fireAt` at `dueAt` (advisory, `legalEffect: none`); +- with `expiresAt` set, arm an `expiry` timer; legal effect + `servicenorm` unless the plan settings mark the item `wettelijk`, in which + case `onExpiry: terminate` is allowed by the core's own rule; +- on an item reaching a terminal state, `cancelForSubject('case-item', uuid)`; +- a `FlowTimerFiredEvent` of kind expiry for a `case-item` subject with an + enforcing outcome terminates the item with cause `realisation` and the + timer uuid as cause reference. + +The core decides when; the case layer only decides what an expiry means for +an item. Arming failures are logged and never block the transition. + +### 3.4 One state list (gap 4) + +`CaseItem::STATES = Task::STATES` and +`CaseItem::TERMINAL_STATES = Task::TERMINAL_STATES`. A unit test asserts both +the identity and the six values, so a seventh task state has to be decided +for plan items too. + +### 3.5 System access (gap 5) + +```php +public function createPlanAsSystem(string $objectUuid, ?int $registerId, ?int $schemaId, array $definition, string $app): array; +public function getPlanAsSystem(string $objectUuid, string $app): array; +public function ensureItems(string $objectUuid, ?int $registerId, ?int $schemaId, array $items, array $history, string $app): array; +``` + +- `$app` must be a non-empty app id (`[a-z0-9_]+`); the audit actor is + `system:`. A Nextcloud uid cannot contain `:`, so the actor never + collides with a user. +- No authorization check: these are reachable only from PHP running inside + the server, which can already read and write every row. A structural test + asserts that no file under `lib/Controller/` references `AsSystem` or + `ensureItems`. +- The existing user verbs are unchanged: a null identity is still refused. + +### 3.6 `ensureItems` (gap 6) + +Input: the same nested node shape `createPlan` takes, where a node MAY carry +`state` (one of the six; default `available`). History entries are +`{item, from, to, at, actor?, reason?}` keyed by item key. + +Algorithm, in one transaction: + +1. Validate the nodes with `CasePlanDefinition::validate()` (unknown type, + bad sentry, duplicate key refused before anything is written) and every + `state` against the lifecycle: a milestone may only carry `available`, + `completed` or `terminated`. +2. Read the object's rows. If a row with a key exists, keep it untouched + (its state is never overwritten). If a row exists whose register or schema + differs from the call, refuse: the anchor is one object. +3. Insert only missing nodes, under their parent's row (existing or just + inserted), with the recorded state, `entered_at` set for a non-available + state, and no realisation: an imported active human item gets no new task, + because the history it came from had none. +4. For each row inserted in this call: one audit entry (`from: null`, + `to: state`, cause `import`, cause ref `ensure:`, actor + `system:`), then each history entry for that key as an audit row with + `imported: true` and `created` = its `at`. History for a key that already + existed is skipped, so a re-run appends nothing. +5. No cascade runs. A drained case resumes evaluation on its next event or + an explicit `evaluate`; evaluation in the middle of an import would act on + a half-written plan. + +Returns `{created: [keys], existing: [keys], items: [rows]}`. + +## 4. Why the state tables stay separate + +A run's marking is a token distribution over places; a plan is a tree of +items each with a lifecycle state. Forcing either into the other's table +means a column set that is half empty for every row and a migration of every +existing run and plan, for no behaviour a user sees. The overlap Ruben named +lives in the services, and those are the ones this change makes one. If a +single "what is running on this object" read is wanted later, it is a query +over runs, plan items and tasks by the shared anchor, not a merged table. + +## 5. Migration + +Additive. `Version1Date20261010120000` adds `imported` (boolean, not null, +default false) to `openregister_case_item_audit`. Existing rows read as not +imported, which is true. No data is backfilled. Rollback: drop the column; +nothing else depends on it. + +## 6. Risks + +- **A process task's run fails.** Same as a stage today: the run's terminal + state drives the item, a failed run terminates it. +- **Timer arming on a busy cascade.** One insert per item entering active + with a deadline; plans are small by the cascade bound. +- **System methods misused from a controller.** Held by the structural test. +- **dossiq calls the user methods with a null uid today.** That stays + refused; dossiq moves to the system methods (sibling ask). diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/proposal.md b/openspec/changes/one-engine-bpmn-and-cmmn/proposal.md new file mode 100644 index 0000000000..a65a7f8823 --- /dev/null +++ b/openspec/changes/one-engine-bpmn-and-cmmn/proposal.md @@ -0,0 +1,106 @@ +--- +kind: code +depends_on: [flow-cmmn-case-semantics, flow-business-timers, flow-task-entity] +--- + +# Proposal: one-engine-bpmn-and-cmmn + +## Summary + +OpenRegister runs one engine with two model types. A BPMN process is walked +by the flow engine; a CMMN case plan is evaluated by the case layer. Both +stand on one shared core: the task service, the timer core and working +calendar, the event catalog and the one condition dialect, the subject +anchor, and the audit. This change names that boundary, closes the gaps that +still make the two feel like separate engines, and adds what dossiq's +`retire-cmmn-caseplanstate` needs to drain its own CMMN runtime into it. + +## Why + +Ruben (decision 146): "can we make the cmmn engine and the flow engine one +thing that supports both? feels like a lot of overlap." + +An inventory of `development` (design.md section 1) shows most of the +overlap is already resolved, because `flow-cmmn-case-semantics` built the +case layer on the flow engine's primitives instead of beside them: + +- if-parts are evaluated by `FlowExpression`, the flow engine's evaluator; +- on-parts are events from `EventCatalogService`, the flow engine's catalog; +- a human plan item is realised by a task from `TaskService`, the same row a + BPMN user task creates, with the same six states; +- a stage may be realised by a flow run queued through `FlowRunService`; +- a plan and a run name their subject with the same anchor triple. + +What is left is real, and it is what makes the two read as two engines: + +1. **CMMN can only start BPMN from a stage.** CMMN 1.1's ProcessTask (a plan + item whose work is a process) does not exist; a caseworker cannot draw + "this step is the advice flow" without wrapping it in a stage. +2. **BPMN cannot touch a case.** No flow node opens a case plan or advances a + plan item. A process that should reach a milestone has to call HTTP. +3. **Plan items have their own deadline fields and no clock.** `dueAt`, + `expiresAt`, `doorlooptijd` and `servicenorm` are stored on the row and + nothing arms a timer for them. Tasks and runs already run on + `FlowTimerService`; plan items do not. +4. **The six states are declared twice.** `CaseItem::STATES` and + `Task::STATES` are two literal lists that happen to agree. +5. **An app's own code cannot act on a case without a user.** Listeners, + `occ` commands and repair steps run without a session. Every case-layer + verb refuses a null identity, so dossiq's case-start projection + (`CasePlanProjectionService`, merged in dossiq #2551) is refused on every + call and only logs it. No caller can say "this is app X acting". +6. **A case plan cannot be brought over from another engine.** `createPlan` + refuses an object that already has rows and starts every item in + `available`. Draining an existing runtime needs the opposite: create only + the missing items, keep their recorded states, and import their history + marked as imported, so a crash mid-case resumes without duplicates. + +## What changes + +- **Shared core, named.** design.md section 2 fixes the boundary: a model + evaluator owns its state shape (the BPMN marking, the plan-item rows) and + nothing else. It SHALL NOT own a task store, a clock, a condition dialect, + an event vocabulary or an authorization model. +- **ADD** plan-item type `processTask`, realised by a flow run, with the + work-item lifecycle. CMMN starts BPMN from any work item, not only a stage. +- **ADD** two flow nodes: `openregister.case-open` creates a case plan on the + run's subject; `openregister.case-advance` transitions a plan item of the + run's subject by its key. BPMN opens and advances cases. +- **ADD** plan-item timers on the shared clock: an item with `dueAt` or + `expiresAt` arms a `FlowTimer` (subject type `case-item`) when it becomes + active; the timer is cancelled when the item ends; an enforcing expiry + terminates the item. No case-local clock. +- **CHANGE** `CaseItem::STATES` and `TERMINAL_STATES` to reference the task's + lists, so one declaration governs both. +- **ADD** in-process system access: `createPlanAsSystem`, `getPlanAsSystem` + and `ensureItems` on `CasePlanService`, each naming the acting app and + recorded in the audit as `system:`. Never routed; a structural test + holds that no controller reaches them. +- **ADD** `ensureItems`: convergent, keyed on (object uuid, item key), creates + only missing items with their recorded states, appends audit entries only + for rows it created, and imports supplied history as audit entries flagged + `imported`. Re-running it changes nothing. +- **ADD** column `imported` (boolean, default false) on + `openregister_case_item_audit`. + +## Out of scope + +- Merging the plan-item rows and the run marking into one table. Each + evaluator keeps its state shape; design.md section 4 says why, and + Q-openregister-C1 asks Ruben to confirm. +- dossiq's own projection, migration and removal: dossiq repoints its callers + after this lands (dossiq `retire-cmmn-caseplanstate`). +- CMMN XML. `flow-cases` already refuses the notation; that stands. + +## Impact + +Additive only. One new column with a default, one new value in +two enum lists (`CaseItem::TYPES`, `FlowTimer::SUBJECT_TYPES`), two new flow +nodes, new service methods. No existing flow, run, task, timer, case-item or +audit row is rewritten, and no existing verb changes its answer. + +## Affected projects + +- [x] `openregister`: this change. +- [ ] `dossiq`: consumer. `retire-cmmn-caseplanstate` moves its calls to the + system methods and `ensureItems` (sibling ask filed). diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md b/openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md new file mode 100644 index 0000000000..db9fac823a --- /dev/null +++ b/openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md @@ -0,0 +1,147 @@ +# flow-cases (delta) + +## ADDED Requirements + +### Requirement: One engine carries both model types on a shared core + +The system SHALL run BPMN processes and CMMN case plans as two model types of +one engine. Each model type SHALL own only the shape of its runtime state and +the algorithm that advances it. Human work, deadlines, conditions, events, +the subject anchor and authorization SHALL come from the shared core: the +task service, the timer service with its working calendar, the event catalog +with the flow expression evaluator, the anchor triple, and fail-closed +authorization. A plan item's lifecycle states SHALL be the task's lifecycle +states, declared once. + +#### Scenario: Plan items and tasks share one list of states + +- **GIVEN** the task lifecycle's states and terminal states +- **WHEN** the plan item's states and terminal states are read +- **THEN** they MUST be the same lists, by reference to one declaration +- @e2e exclude a declaration invariant; asserted by a unit test + +### Requirement: A process task starts a flow + +A plan item of type `processTask` SHALL be realised by a flow run on the +plan's subject when it becomes active, and the run's terminal state SHALL +drive the item exactly as it does for a stage realised by a run. A process +task SHALL name its flow in the plan settings; a definition with a process +task that names no flow SHALL be refused at create time. A process task SHALL +follow the work-item lifecycle and SHALL NOT have children. + +#### Scenario: A process task queues its flow + +- **GIVEN** a plan whose process task `advies` names flow `advice-flow` +- **WHEN** `advies` becomes active +- **THEN** a run of `advice-flow` MUST be queued on the plan's subject +- **AND** the item MUST record the run as its realisation +- @e2e exclude realisation is asserted by unit tests over the real realisation service + +#### Scenario: A process task without a flow is refused + +- **GIVEN** a definition with a process task whose key has no flow in the plan settings +- **WHEN** the plan is created +- **THEN** creation MUST be refused naming the item, and nothing MUST be written +- @e2e exclude save-time validation; asserted by unit tests + +### Requirement: A flow step opens or advances a case + +The flow engine SHALL offer a node that creates a case plan on the run's +subject from a definition, and a node that transitions a plan item of the +run's subject by its key. Both SHALL act as the run's attributed identity and +SHALL go through the same service verbs, validation and authorization as any +other caller. Opening a plan on an object that already has one SHALL be +reported on the node's output, not raised. A refused transition SHALL fail +the step with the engine's message. + +#### Scenario: A flow reaches a milestone + +- **GIVEN** a case plan on object O with milestone `besluit-genomen` in `available` +- **WHEN** a run on O executes `case-advance` with item `besluit-genomen` and `to: completed` +- **THEN** the milestone MUST be completed with the run's identity in the audit +- @e2e exclude node behaviour; asserted by unit tests over the real plan service + +#### Scenario: A refused advance fails the step + +- **GIVEN** a milestone already `completed` +- **WHEN** `case-advance` asks to complete it again +- **THEN** the step MUST fail with the message naming item, type, from-state and to-state +- @e2e exclude asserted by unit tests + +### Requirement: Plan-item deadlines run on the shared clock + +A plan item with a due date or an expiry SHALL arm a timer on the shared +timer service, subject type `case-item`, when it becomes active, and every +open timer of an item SHALL be cancelled when the item reaches a terminal +state. An enforcing expiry SHALL terminate the item. The case layer SHALL NOT +keep a clock of its own. A failure to arm SHALL be logged and SHALL NOT +block the transition. + +#### Scenario: An active item with a due date gets a timer + +- **GIVEN** a human item with `dueAt` set +- **WHEN** it becomes active +- **THEN** a `due` timer with subject type `case-item` and the item's uuid MUST be armed + +#### Scenario: Completing an item cancels its timers + +- **GIVEN** an active item with an armed timer +- **WHEN** the item is completed +- **THEN** its timer MUST be cancelled + +- @e2e exclude timer wiring; asserted by unit tests over the real timer service + +### Requirement: In-process callers act as a named app + +The case layer SHALL offer in-process verbs to create a plan, read a plan and +ensure items as a named app, for code that runs without a user session +(listeners, commands, repair steps). The audit SHALL record the actor as +`system:`. These verbs SHALL NOT be reachable over HTTP. The user verbs +SHALL keep refusing a missing identity. + +#### Scenario: An app's listener creates a plan + +- **GIVEN** an object without a plan +- **WHEN** app `dossiq` creates a plan for it in-process as the system +- **THEN** the plan MUST exist and its creation audit MUST name `system:dossiq` + +#### Scenario: No route reaches a system verb + +- **GIVEN** the controllers of the app +- **WHEN** they are searched for the system verbs +- **THEN** none MUST reference them + +- @e2e exclude no HTTP surface by design; asserted by unit and structural tests + +### Requirement: Plan items can be ensured convergently with their recorded states + +The case layer SHALL offer an ensure verb that, keyed on the object and the +item key, creates only the items an object does not have yet, with the state +each carries, and leaves existing items untouched. It SHALL append audit +entries only for items it created in that call, and SHALL import supplied +history for those items as audit entries flagged as imported, with their +original moments. It SHALL validate every item and state before writing +anything, SHALL create no realisation for imported items, and SHALL run no +cascade. Running it twice with the same input SHALL change nothing the +second time. + +#### Scenario: A half-written plan resumes without duplicates + +- **GIVEN** an object holding items `intake` and `beoordeling` and an ensure call naming `intake`, `beoordeling` and `besluit` +- **WHEN** the call runs +- **THEN** only `besluit` MUST be created, with its recorded state +- **AND** `intake` and `beoordeling` MUST keep their states and gain no audit entry + +#### Scenario: A second run appends nothing + +- **GIVEN** an ensure call that has already run +- **WHEN** it runs again with the same items and history +- **THEN** no row and no audit entry MUST be added + +#### Scenario: An illegal recorded state is refused before writing + +- **GIVEN** an ensure call with a milestone carrying state `active` +- **WHEN** the call runs +- **THEN** it MUST be refused naming the item and state, and nothing MUST be written + +- @e2e exclude occ-level behaviour with no browser surface; asserted by unit tests diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md new file mode 100644 index 0000000000..ef6f40316e --- /dev/null +++ b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md @@ -0,0 +1,34 @@ +# Tasks: one-engine-bpmn-and-cmmn + +Small PRs, in this order. PR 1 is what dossiq's drain waits on. + +## 1. System access and convergent ensure (PR 1) + +- [ ] 1.1 Migration `Version1Date20261010120000`: column `imported` (boolean, not null, default false) on `openregister_case_item_audit`; `CaseItemAudit` gains `imported`; `CaseItemAuditMapper` keeps a supplied `created` for imported rows. Bump `appinfo/info.xml`. +- [ ] 1.2 `CasePlanService::createPlanAsSystem()` and `getPlanAsSystem()`: app id validated, actor `system:`, no authorization; unit tests for the main path and a refused app id. +- [ ] 1.3 `CasePlanService::ensureItems()` per design section 3.6, in a new collaborator `CasePlanEnsurer` with its own unit test: missing-only insert, recorded states, lifecycle check of each state, anchor mismatch refused, audit only for created rows, history imported with `imported: true`, no realisation, no cascade, double run idempotent. +- [ ] 1.4 Structural test: no file under `lib/Controller/` references `AsSystem` or `ensureItems`. + +## 2. One state list and the process task (PR 2) + +- [ ] 2.1 `CaseItem::STATES` / `TERMINAL_STATES` reference `Task::STATES` / `TERMINAL_STATES`; unit test asserts identity. +- [ ] 2.2 `CaseItem::TYPE_PROCESS_TASK`; `CasePlanTransitions` work-item edges; `CasePlanDefinition` accepts it, refuses one without a flow or with children. +- [ ] 2.3 `CaseRealisationService::realise()` queues the run for a process task; `CaseRunTerminalListener` drives it as for a stage; unit tests. + +## 3. Plan-item timers on the shared clock (PR 3) + +- [ ] 3.1 `FlowTimer::SUBJECT_TYPES` gains `case-item`. +- [ ] 3.2 `CasePlanItemTimers` (new, own unit test): arm on entering active with `dueAt` / `expiresAt`; cancel on terminal; arming failure logged, never thrown. +- [ ] 3.3 Wire it into `CasePlanStateMachine` transitions; listener for `FlowTimerFiredEvent` (expiry, subject `case-item`) terminates the item; unit tests. + +## 4. Flow nodes that open and advance a case (PR 4) + +- [ ] 4.1 `CaseOpenNode` (`openregister.case-open`), own unit test: creates on the run's subject as the run's identity; existing plan reported on output. +- [ ] 4.2 `CaseAdvanceNode` (`openregister.case-advance`), own unit test: transitions by key; refusal fails the step with the engine's message. +- [ ] 4.3 Register both in `FlowNodeRegistry`; node catalogue labels in every shipped locale. + +## 5. Close + +- [ ] 5.1 Engine docs: one page on the shared core and the two model types (docs/). +- [ ] 5.2 Sibling ask for dossiq: move `CasePlanProjectionService` / `CasePlanRollbackService` to `createPlanAsSystem` / `getPlanAsSystem`, and the drain to `ensureItems`. +- [ ] 5.3 opsx-verify, then archive into `openspec/specs/flow-cases/spec.md`. From efef05a80ec6e542df576c58c82304769cb42550 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 10:55:51 +0200 Subject: [PATCH 011/183] fix(ci): list ObjectEntity as @uses so the notifier tests count toward coverage --- .../Unit/Service/Notification/ReferencedAddressNotifierTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php b/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php index 4394f83b4e..02d091d293 100644 --- a/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php +++ b/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php @@ -37,6 +37,8 @@ * The `email` recipient kind: an address read off the object, directly or through a reference. * * @covers \OCA\OpenRegister\Service\Notification\ReferencedAddressNotifier + * + * @uses \OCA\OpenRegister\Db\ObjectEntity */ class ReferencedAddressNotifierTest extends TestCase { From 5bb1886c104cad97b8c8bf7644ea2739fa26eaf7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 11:10:30 +0200 Subject: [PATCH 012/183] feat(cases): an app's own code can create, read and bring over a case plan as itself createPlanAsSystem, getPlanAsSystem and ensureItems on CasePlanService, for listeners, commands and repair steps without a session; the audit names system:. ensureItems (CasePlanEnsurer) creates only missing items with their recorded states and imports history flagged imported, so a drained case resumes without duplicates. Never routed (structural test). Change one-engine-bpmn-and-cmmn, tasks 1.1-1.4. --- appinfo/info.xml | 2 +- lib/Db/CaseItemAudit.php | 13 + lib/Migration/Version1Date20261010120000.php | 76 +++ lib/Service/Case/CasePlanEnsurer.php | 453 ++++++++++++++++++ lib/Service/Case/CasePlanService.php | 225 +++++++-- .../one-engine-bpmn-and-cmmn/design.md | 12 +- .../changes/one-engine-bpmn-and-cmmn/tasks.md | 8 +- .../Version1Date20261010120000Test.php | 68 +++ .../Unit/Service/Case/CasePlanEnsurerTest.php | 271 +++++++++++ .../Unit/Service/Case/CasePlanServiceTest.php | 67 +++ .../Case/CaseSystemVerbsUnroutedTest.php | 57 +++ 11 files changed, 1209 insertions(+), 43 deletions(-) create mode 100644 lib/Migration/Version1Date20261010120000.php create mode 100644 lib/Service/Case/CasePlanEnsurer.php create mode 100644 tests/Unit/Migration/Version1Date20261010120000Test.php create mode 100644 tests/Unit/Service/Case/CasePlanEnsurerTest.php create mode 100644 tests/Unit/Service/Case/CaseSystemVerbsUnroutedTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 611e320354..bdd062af32 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.1.35-unstable.20261009130000 + 2.1.35-unstable.20261010120000 EUPL-1.2 Conduction OpenRegister diff --git a/lib/Db/CaseItemAudit.php b/lib/Db/CaseItemAudit.php index b94e4b63a2..c3bb59d962 100644 --- a/lib/Db/CaseItemAudit.php +++ b/lib/Db/CaseItemAudit.php @@ -50,6 +50,8 @@ * @method void setReason(?string $reason) * @method bool|null getAuthorized() * @method void setAuthorized(?bool $authorized) + * @method bool|null getImported() + * @method void setImported(?bool $imported) * @method DateTime|null getCreated() * @method void setCreated(?DateTime $created) * @@ -128,6 +130,15 @@ class CaseItemAudit extends Entity implements JsonSerializable { */ protected ?bool $authorized = true; + /** + * True on history brought over from another engine + * (`CasePlanEnsurer`); its `created` is the moment it originally + * happened, not the moment it was imported. + * + * @var boolean|null + */ + protected ?bool $imported = false; + /** * When it happened. * @@ -147,6 +158,7 @@ public function __construct() { $this->addType(fieldName: 'actor', type: 'string'); $this->addType(fieldName: 'reason', type: 'string'); $this->addType(fieldName: 'authorized', type: 'boolean'); + $this->addType(fieldName: 'imported', type: 'boolean'); $this->addType(fieldName: 'created', type: 'datetime'); }//end __construct() @@ -169,6 +181,7 @@ public function jsonSerialize(): array { 'actor' => $this->actor, 'reason' => $this->reason, 'authorized' => $this->authorized, + 'imported' => ($this->imported === true), 'created' => $this->created?->format('c'), ]; }//end jsonSerialize() diff --git a/lib/Migration/Version1Date20261010120000.php b/lib/Migration/Version1Date20261010120000.php new file mode 100644 index 0000000000..b44f3befb9 --- /dev/null +++ b/lib/Migration/Version1Date20261010120000.php @@ -0,0 +1,76 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Adds `imported` to `openregister_case_item_audit`: true on history brought + * over from another engine by `CasePlanEnsurer`, whose `created` is the moment + * it originally happened. Additive; existing rows read as not imported, which + * is true. + * + * @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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + +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 imported flag to the plan-item audit. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ +class Version1Date20261010120000 extends SimpleMigrationStep { + + /** + * The audit table. + */ + public const AUDIT_TABLE = 'openregister_case_item_audit'; + + /** + * Add `imported` when the table is there and lacks it. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the schema wrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The schema, changed or not. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The signature is Nextcloud's. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable(self::AUDIT_TABLE) === false) { + return $schema; + } + + $table = $schema->getTable(self::AUDIT_TABLE); + if ($table->hasColumn('imported') === true) { + return $schema; + } + + // Nullable on purpose: Nextcloud refuses NOT NULL booleans on Oracle. + $table->addColumn('imported', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + + return $schema; + + }//end changeSchema() +}//end class diff --git a/lib/Service/Case/CasePlanEnsurer.php b/lib/Service/Case/CasePlanEnsurer.php new file mode 100644 index 0000000000..4afe88b259 --- /dev/null +++ b/lib/Service/Case/CasePlanEnsurer.php @@ -0,0 +1,453 @@ + + * 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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + +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\Exception\CaseValidationException; +use OCP\IDBConnection; +use Throwable; + +/** + * Ensures plan items with recorded states, idempotently. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ +class CasePlanEnsurer { + + /** + * Constructor. + * + * @param CaseItemMapper $items The plan-item table. + * @param CaseItemAuditMapper $audits The append-only audit. + * @param CasePlanDefinition $definitions The one definition validator. + * @param CasePlanTransitions $transitions The one lifecycle table. + * @param IDBConnection $db Holds the transaction. + */ + public function __construct( + private readonly CaseItemMapper $items, + private readonly CaseItemAuditMapper $audits, + private readonly CasePlanDefinition $definitions, + private readonly CasePlanTransitions $transitions, + private readonly IDBConnection $db, + ) { + + }//end __construct() + + /** + * Ensure the definition's items exist on the object with their states. + * + * @param string $objectUuid The anchoring object. + * @param int|null $registerId Its register. + * @param int|null $schemaId Its schema. + * @param array $definition `settings` + nested `items`; a node MAY carry `state`. + * @param array $history Entries `{item, from?, to, at, actor?, reason?}`. + * @param string $actor The importing identity (`system:`). + * + * @return array{created: array, existing: array, items: array>} + * + * @throws CaseValidationException On the first refused value, before anything is written. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + public function ensure( + string $objectUuid, + ?int $registerId, + ?int $schemaId, + array $definition, + array $history, + string $actor, + ): array { + $normalised = $this->definitions->validate(definition: $definition); + $types = []; + $this->checkStates(nodes: $normalised['items'], types: $types); + $historyByKey = $this->checkHistory(history: $history, types: $types); + + $existing = []; + foreach ($this->items->findByObject(objectUuid: $objectUuid) as $row) { + $this->checkAnchor(row: $row, registerId: $registerId, schemaId: $schemaId); + $existing[(string)$row->getItemKey()] = $row; + } + + foreach ($types as $key => $type) { + if (isset($existing[$key]) === true && $existing[$key]->getPlanItemType() !== $type) { + throw new CaseValidationException( + message: sprintf("'%s' exists as a %s on this object; it cannot be ensured as a %s.", $key, (string)$existing[$key]->getPlanItemType(), $type) + ); + } + } + + $created = []; + $kept = []; + $this->db->beginTransaction(); + try { + $this->ensureLevel( + nodes: $normalised['items'], + parentId: null, + context: [ + 'object' => $objectUuid, + 'register' => $registerId, + 'schema' => $schemaId, + 'settings' => $normalised['settings'], + 'actor' => $actor, + 'history' => $historyByKey, + 'existing' => $existing, + ], + created: $created, + kept: $kept + ); + $this->db->commit(); + } catch (Throwable $failure) { + $this->db->rollBack(); + throw $failure; + } + + return [ + 'created' => $created, + 'existing' => $kept, + 'items' => array_map(static fn (CaseItem $row): array => $row->jsonSerialize(), $this->items->findByObject(objectUuid: $objectUuid)), + ]; + }//end ensure() + + /** + * Every node's state must be one its type can reach from `available`. + * + * @param array> $nodes A level of validated nodes. + * @param array $types Collects key => type. + * + * @return void + * + * @throws CaseValidationException Naming the item, its type and the state. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + private function checkStates(array $nodes, array &$types): void { + foreach ($nodes as $node) { + $key = (string)$node['key']; + $type = (string)$node['type']; + $state = (string)($node['state'] ?? CaseItem::STATE_AVAILABLE); + if (in_array($state, $this->reachable(type: $type), true) === false) { + throw new CaseValidationException( + message: sprintf("'%s' (%s) cannot carry state '%s'; its lifecycle reaches %s.", $key, $type, $state, implode(', ', $this->reachable(type: $type))) + ); + } + + $types[$key] = $type; + $this->checkStates(nodes: ($node['children'] ?? []), types: $types); + } + }//end checkStates() + + /** + * The states a type can be in: `available` and everything the table + * reaches from it. + * + * @param string $type The plan-item type. + * + * @return array The reachable states. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + private function reachable(string $type): array { + $seen = [CaseItem::STATE_AVAILABLE]; + $queue = [CaseItem::STATE_AVAILABLE]; + while ($queue !== []) { + $from = array_shift($queue); + foreach ($this->transitions->targetsFor(type: $type, from: $from) as $target) { + if (in_array($target, $seen, true) === false) { + $seen[] = $target; + $queue[] = $target; + } + } + } + + return $seen; + }//end reachable() + + /** + * Validate history and group it by item key. + * + * @param array $history The entries. + * @param array $types The definition's key => type. + * + * @return array> + * + * @throws CaseValidationException On an entry that is not usable. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + private function checkHistory(array $history, array $types): array { + $byKey = []; + foreach ($history as $index => $entry) { + $label = sprintf('history[%d]', (int)$index); + if (is_array($entry) === false) { + throw new CaseValidationException(message: sprintf('%s must be an object.', $label)); + } + + $key = trim((string)($entry['item'] ?? '')); + if (isset($types[$key]) === false) { + throw new CaseValidationException(message: sprintf("%s names item '%s', which the definition does not declare.", $label, $key)); + } + + $from = trim((string)($entry['from'] ?? '')); + $to = trim((string)($entry['to'] ?? '')); + if (in_array($to, CaseItem::STATES, true) === false || ($from !== '' && in_array($from, CaseItem::STATES, true) === false)) { + throw new CaseValidationException(message: sprintf("%s has from-state '%s' and to-state '%s'; both must be plan-item states.", $label, $from, $to)); + } + + $moment = $this->moment(value: ($entry['at'] ?? null)); + if ($moment === null) { + throw new CaseValidationException(message: sprintf('%s has an unreadable moment; `at` must be an ISO 8601 date-time.', $label)); + } + + $byKey[$key][] = [ + 'from' => $from, + 'to' => $to, + 'at' => $moment, + 'actor' => $this->stringOrNull(value: ($entry['actor'] ?? null)), + 'reason' => $this->stringOrNull(value: ($entry['reason'] ?? null)), + ]; + }//end foreach + + return $byKey; + }//end checkHistory() + + /** + * A plan is anchored to ONE object: an existing row that names another + * register or schema refuses the call. + * + * @param CaseItem $row An existing row. + * @param int|null $registerId The call's register. + * @param int|null $schemaId The call's schema. + * + * @return void + * + * @throws CaseValidationException On a mismatch. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + private function checkAnchor(CaseItem $row, ?int $registerId, ?int $schemaId): void { + $registerDiffers = $registerId !== null && $row->getRegisterId() !== null && $row->getRegisterId() !== $registerId; + $schemaDiffers = $schemaId !== null && $row->getSchemaId() !== null && $row->getSchemaId() !== $schemaId; + if ($registerDiffers === true || $schemaDiffers === true) { + throw new CaseValidationException( + message: sprintf( + 'The plan on this object is anchored to register %d schema %d, not register %d schema %d.', + (int)$row->getRegisterId(), + (int)$row->getSchemaId(), + (int)$registerId, + (int)$schemaId + ) + ); + } + }//end checkAnchor() + + /** + * Ensure one level and recurse into stages. + * + * @param array> $nodes The level. + * @param int|null $parentId The containing stage's row id. + * @param array $context object, register, schema, settings, actor, history, existing. + * @param array $created Collects created keys. + * @param array $kept Collects existing keys. + * + * @return void + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + private function ensureLevel(array $nodes, ?int $parentId, array $context, array &$created, array &$kept): void { + foreach ($nodes as $position => $node) { + $key = (string)$node['key']; + $row = ($context['existing'][$key] ?? null); + if ($row instanceof CaseItem) { + $kept[] = $key; + } + + if ($row instanceof CaseItem === false) { + $row = $this->insert(node: $node, parentId: $parentId, position: (int)$position, context: $context); + $created[] = $key; + } + + $this->ensureLevel(nodes: ($node['children'] ?? []), parentId: (int)$row->getId(), context: $context, created: $created, kept: $kept); + } + }//end ensureLevel() + + /** + * Insert one missing item with its recorded state, its creation audit and + * its imported history. + * + * @param array $node The validated node. + * @param int|null $parentId The containing stage's row id. + * @param int $position Its position among its siblings. + * @param array $context See {@see ensureLevel()}. + * + * @return CaseItem The inserted row. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + private function insert(array $node, ?int $parentId, int $position, array $context): CaseItem { + $origin = CaseItem::ORIGIN_DEFINED; + if (($node['discretionary'] ?? false) === true) { + $origin = CaseItem::ORIGIN_DISCRETIONARY; + } + + $actor = (string)$context['actor']; + $row = $this->definitions->rowFrom( + node: $node, + objectUuid: (string)$context['object'], + registerId: $context['register'], + schemaId: $context['schema'], + parentId: $parentId, + position: $position, + settings: $context['settings'], + origin: $origin, + actor: $actor + ); + $state = (string)($node['state'] ?? CaseItem::STATE_AVAILABLE); + $row->setState($state); + $row->setIsTerminal(in_array($state, CaseItem::TERMINAL_STATES, true)); + if ($state !== CaseItem::STATE_AVAILABLE) { + $row->setEnteredAt(new DateTime()); + } + + $persisted = $this->items->insert($row); + $this->appendAudit(item: $persisted, from: '', to: $state, actor: $actor, reason: null, imported: false, moment: null, actorOfRecord: $actor); + + foreach (($context['history'][(string)$node['key']] ?? []) as $entry) { + $this->appendAudit( + item: $persisted, + from: $entry['from'], + to: $entry['to'], + actor: ($entry['actor'] ?? $actor), + reason: $entry['reason'], + imported: true, + moment: $entry['at'], + actorOfRecord: $actor + ); + } + + return $persisted; + }//end insert() + + /** + * Append one audit entry. + * + * @param CaseItem $item The row. + * @param string $from The from-state ('' on creation). + * @param string $to The to-state. + * @param string $actor The identity the entry names. + * @param string|null $reason Free text. + * @param bool $imported Whether it is brought-over history. + * @param DateTime|null $moment Its original moment, for imported history. + * @param string $actorOfRecord The importer, recorded as the cause reference. + * + * @return void + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The flag IS the stored column. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + private function appendAudit( + CaseItem $item, + string $from, + string $to, + string $actor, + ?string $reason, + bool $imported, + ?DateTime $moment, + string $actorOfRecord, + ): void { + $entry = new CaseItemAudit(); + $entry->setCaseItemId((int)$item->getId()); + $entry->setFromState($from); + $entry->setToState($to); + $entry->setCause(CaseItemAudit::CAUSE_IMPORT); + $entry->setCauseRef('ensure:' . $actorOfRecord); + $entry->setActor($actor); + $entry->setReason($reason); + $entry->setAuthorized(true); + $entry->setImported($imported); + if ($moment !== null) { + $entry->setCreated($moment); + } + + $this->audits->insert($entry); + }//end appendAudit() + + /** + * Parse an ISO 8601 moment. + * + * @param mixed $value The value. + * + * @return DateTime|null The moment, or null when unreadable. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + private function moment(mixed $value): ?DateTime { + if (is_string($value) === false || trim($value) === '') { + return null; + } + + // Only a full ISO 8601 date-time with an offset: relative phrases that + // DateTime would happily accept ("yesterday") are not a moment. + $pattern = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/'; + if (preg_match($pattern, trim($value)) !== 1) { + return null; + } + + try { + return new DateTime(trim($value)); + } catch (Throwable) { + return null; + } + }//end moment() + + /** + * A trimmed non-empty string, or null. + * + * @param mixed $value The value. + * + * @return string|null The string. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + private function stringOrNull(mixed $value): ?string { + if (is_string($value) === false || trim($value) === '') { + return null; + } + + return trim($value); + }//end stringOrNull() +}//end class diff --git a/lib/Service/Case/CasePlanService.php b/lib/Service/Case/CasePlanService.php index 63f39cf7d7..95701f5f3a 100644 --- a/lib/Service/Case/CasePlanService.php +++ b/lib/Service/Case/CasePlanService.php @@ -81,6 +81,8 @@ class CasePlanService { * @param CasePlanDefinition $definitions Validates and compiles definitions. * @param IDBConnection $db Holds the creation transactions. * @param LoggerInterface $logger Failure reporting. + * @param CasePlanEnsurer|null $ensurer Convergent bring-over; built from the + * same collaborators when not injected. */ public function __construct( private readonly CaseItemMapper $items, @@ -94,6 +96,7 @@ public function __construct( private readonly CasePlanDefinition $definitions, private readonly IDBConnection $db, private readonly LoggerInterface $logger, + private ?CasePlanEnsurer $ensurer = null, ) { }//end __construct() @@ -112,18 +115,7 @@ public function __construct( * @spec openspec/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)), - ]; + return $this->planOf(objectUuid: $objectUuid, rows: $this->visibleRows(objectUuid: $objectUuid, uid: $uid)); }//end getPlan() /** @@ -161,31 +153,114 @@ public function createPlan( $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->insertPlan( + objectUuid: $objectUuid, + registerId: $registerId, + schemaId: $schemaId, + normalised: $normalised, + actor: $uid, + provenance: ['flowUuid' => $flowUuid, 'flowVersion' => $flowVersion] ); - $this->cascade->evaluate(objectUuid: $objectUuid, actor: $uid); - return $this->getPlan(objectUuid: $objectUuid, uid: $uid); }//end createPlan() + /** + * Create a plan as an app's own in-process code, without a user session + * (a listener, a command, a repair step). Same validation and the same + * evaluation as {@see createPlan()}; no authorization, because only PHP + * running inside the server reaches it, and it can already write every + * row. The audit names `system:`. Never routed. + * + * @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 $app The acting app id. + * + * @return array The plan as {@see getPlan()} returns it. + * + * @throws CaseValidationException When the app id or the definition is refused, or a plan exists. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + */ + public function createPlanAsSystem(string $objectUuid, ?int $registerId, ?int $schemaId, array $definition, string $app): array { + $actor = $this->systemActor(app: $app); + $this->insertPlan( + objectUuid: $objectUuid, + registerId: $registerId, + schemaId: $schemaId, + normalised: $this->definitions->validate(definition: $definition), + actor: $actor, + provenance: ['flowUuid' => null, 'flowVersion' => null] + ); + + return $this->getPlanAsSystem(objectUuid: $objectUuid, app: $app); + }//end createPlanAsSystem() + + /** + * Read a plan as an app's own in-process code: no visibility check. + * Never routed. + * + * @param string $objectUuid The anchoring object. + * @param string $app The acting app id. + * + * @return array objectUuid, items, audit, settings. + * + * @throws CaseValidationException When the app id is refused. + * @throws DoesNotExistException When the object has no plan. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + */ + public function getPlanAsSystem(string $objectUuid, string $app): array { + $this->systemActor(app: $app); + $rows = $this->items->findByObject(objectUuid: $objectUuid); + if ($rows === []) { + throw new DoesNotExistException(sprintf('Object %s has no case plan.', $objectUuid)); + } + + return $this->planOf(objectUuid: $objectUuid, rows: $rows); + }//end getPlanAsSystem() + + /** + * Bring plan items over from another engine, convergently, as an app's + * own in-process code: see {@see CasePlanEnsurer}. Never routed. + * + * @param string $objectUuid The anchoring object. + * @param int|null $registerId Its register. + * @param int|null $schemaId Its schema. + * @param array $definition `settings` + nested `items`, a node MAY carry `state`. + * @param array $history Entries `{item, from?, to, at, actor?, reason?}`. + * @param string $app The acting app id. + * + * @return array{created: array, existing: array, items: array>} + * + * @throws CaseValidationException On the first refused value, before anything is written. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + public function ensureItems(string $objectUuid, ?int $registerId, ?int $schemaId, array $definition, array $history, string $app): array { + $actor = $this->systemActor(app: $app); + if ($this->ensurer === null) { + $this->ensurer = new CasePlanEnsurer( + items: $this->items, + audits: $this->audits, + definitions: $this->definitions, + transitions: new CasePlanTransitions(), + db: $this->db + ); + } + + return $this->ensurer->ensure( + objectUuid: $objectUuid, + registerId: $registerId, + schemaId: $schemaId, + definition: $definition, + history: $history, + actor: $actor + ); + }//end ensureItems() + /** * A user-driven transition of one item (terminate a stage, reach a * milestone by hand, complete a work item). @@ -558,6 +633,92 @@ private function isEnableable(CaseItem $item, CasePlanTree $tree): bool { return $this->sentries->entrySentry(item: $item, tree: $tree, object: $object) !== null; }//end isEnableable() + /** + * The read shape of a plan: settings, rows and their audit. + * + * @param string $objectUuid The object. + * @param array $rows Its rows. + * + * @return array objectUuid, settings, items, audit. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-the-case-is-the-openregister-object + */ + private function planOf(string $objectUuid, array $rows): array { + $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 planOf() + + /** + * Insert a validated plan in one transaction, then evaluate it. + * + * @param string $objectUuid The anchoring object. + * @param int|null $registerId Its register. + * @param int|null $schemaId Its schema. + * @param array $normalised The validated definition. + * @param string|null $actor The creating identity. + * @param array{flowUuid: string|null, flowVersion: int|null} $provenance Definition provenance. + * + * @return void + * + * @throws CaseValidationException When the object already has a plan. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob + */ + private function insertPlan(string $objectUuid, ?int $registerId, ?int $schemaId, array $normalised, ?string $actor, array $provenance): void { + 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, $actor, $provenance): void { + $this->insertNodes( + nodes: $normalised['items'], + objectUuid: $objectUuid, + registerId: $registerId, + schemaId: $schemaId, + parentId: null, + settings: $normalised['settings'], + actor: $actor, + flowUuid: $provenance['flowUuid'], + flowVersion: $provenance['flowVersion'] + ); + } + ); + + $this->cascade->evaluate(objectUuid: $objectUuid, actor: $actor); + }//end insertPlan() + + /** + * The audit identity of an app's in-process code. A Nextcloud uid cannot + * contain `:`, so it never collides with a user. + * + * @param string $app The app id. + * + * @return string `system:`. + * + * @throws CaseValidationException When the app id is not one. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + */ + private function systemActor(string $app): string { + if (preg_match('/^[a-z][a-z0-9_]{0,63}$/', $app) !== 1) { + throw new CaseValidationException( + message: sprintf("'%s' is not an app id; a system caller names its app in lower case letters, digits and `_`.", $app) + ); + } + + return 'system:' . $app; + }//end systemActor() + /** * The plan's rows, only if the caller may read the anchoring object. * diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/design.md b/openspec/changes/one-engine-bpmn-and-cmmn/design.md index 6bb8e78422..20f00492ef 100644 --- a/openspec/changes/one-engine-bpmn-and-cmmn/design.md +++ b/openspec/changes/one-engine-bpmn-and-cmmn/design.md @@ -118,7 +118,7 @@ for plan items too. ```php public function createPlanAsSystem(string $objectUuid, ?int $registerId, ?int $schemaId, array $definition, string $app): array; public function getPlanAsSystem(string $objectUuid, string $app): array; -public function ensureItems(string $objectUuid, ?int $registerId, ?int $schemaId, array $items, array $history, string $app): array; +public function ensureItems(string $objectUuid, ?int $registerId, ?int $schemaId, array $definition, array $history, string $app): array; ``` - `$app` must be a non-empty app id (`[a-z0-9_]+`); the audit actor is @@ -132,8 +132,8 @@ public function ensureItems(string $objectUuid, ?int $registerId, ?int $schemaId ### 3.6 `ensureItems` (gap 6) -Input: the same nested node shape `createPlan` takes, where a node MAY carry -`state` (one of the six; default `available`). History entries are +Input: the same definition `createPlan` takes (`settings` plus nested +`items`), where a node MAY carry `state` (one of the six; default `available`). History entries are `{item, from, to, at, actor?, reason?}` keyed by item key. Algorithm, in one transaction: @@ -150,8 +150,8 @@ Algorithm, in one transaction: state, and no realisation: an imported active human item gets no new task, because the history it came from had none. 4. For each row inserted in this call: one audit entry (`from: null`, - `to: state`, cause `import`, cause ref `ensure:`, actor - `system:`), then each history entry for that key as an audit row with + `to: state`, cause `import`, cause ref `ensure:system:`, + actor `system:`), then each history entry for that key as an audit row with `imported: true` and `created` = its `at`. History for a key that already existed is skipped, so a re-run appends nothing. 5. No cascade runs. A drained case resumes evaluation on its next event or @@ -172,7 +172,7 @@ over runs, plan items and tasks by the shared anchor, not a merged table. ## 5. Migration -Additive. `Version1Date20261010120000` adds `imported` (boolean, not null, +Additive. `Version1Date20261010120000` adds `imported` (boolean, nullable because Nextcloud refuses NOT NULL booleans on Oracle, default false) to `openregister_case_item_audit`. Existing rows read as not imported, which is true. No data is backfilled. Rollback: drop the column; nothing else depends on it. diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md index ef6f40316e..241af7b58d 100644 --- a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md +++ b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md @@ -4,10 +4,10 @@ Small PRs, in this order. PR 1 is what dossiq's drain waits on. ## 1. System access and convergent ensure (PR 1) -- [ ] 1.1 Migration `Version1Date20261010120000`: column `imported` (boolean, not null, default false) on `openregister_case_item_audit`; `CaseItemAudit` gains `imported`; `CaseItemAuditMapper` keeps a supplied `created` for imported rows. Bump `appinfo/info.xml`. -- [ ] 1.2 `CasePlanService::createPlanAsSystem()` and `getPlanAsSystem()`: app id validated, actor `system:`, no authorization; unit tests for the main path and a refused app id. -- [ ] 1.3 `CasePlanService::ensureItems()` per design section 3.6, in a new collaborator `CasePlanEnsurer` with its own unit test: missing-only insert, recorded states, lifecycle check of each state, anchor mismatch refused, audit only for created rows, history imported with `imported: true`, no realisation, no cascade, double run idempotent. -- [ ] 1.4 Structural test: no file under `lib/Controller/` references `AsSystem` or `ensureItems`. +- [x] 1.1 Migration `Version1Date20261010120000`: column `imported` (boolean, nullable for Oracle, default false) on `openregister_case_item_audit`; `CaseItemAudit` gains `imported`; `CaseItemAuditMapper` already keeps a supplied `created`. Bump `appinfo/info.xml`. (lib/Migration/Version1Date20261010120000.php, tests/Unit/Migration/Version1Date20261010120000Test.php) +- [x] 1.2 `CasePlanService::createPlanAsSystem()` and `getPlanAsSystem()`: app id validated, actor `system:`, no authorization; unit tests for the main path and a refused app id. (CasePlanServiceTest::testSystemCallersActAsANamedApp) +- [x] 1.3 `CasePlanService::ensureItems()` per design section 3.6, in a new collaborator `CasePlanEnsurer` with its own unit test: missing-only insert, recorded states, lifecycle check of each state, anchor mismatch refused, audit only for created rows, history imported with `imported: true`, no realisation, no cascade, double run idempotent. (lib/Service/Case/CasePlanEnsurer.php, CasePlanEnsurerTest, CasePlanServiceTest::testEnsureItemsBringsAPlanOverAsTheApp) +- [x] 1.4 Structural test: no file under `lib/Controller/` references `AsSystem` or `ensureItems`. (CaseSystemVerbsUnroutedTest) ## 2. One state list and the process task (PR 2) diff --git a/tests/Unit/Migration/Version1Date20261010120000Test.php b/tests/Unit/Migration/Version1Date20261010120000Test.php new file mode 100644 index 0000000000..d6c28aedbe --- /dev/null +++ b/tests/Unit/Migration/Version1Date20261010120000Test.php @@ -0,0 +1,68 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Tests + * @package OCA\OpenRegister\Tests\Unit + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Migration; + +use OCP\DB\Schema\ITable; +use OCA\OpenRegister\Migration\Version1Date20261010120000; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; + +/** + * The imported flag lands once, nullable with a false default. + */ +class Version1Date20261010120000Test extends TestCase { + + /** + * A table without the column gains it. + * + * @return void + */ + public function testItAddsTheImportedColumn(): void { + $table = $this->createMock(ITable::class); + $table->method('hasColumn')->with('imported')->willReturn(false); + $table->expects($this->once())->method('addColumn')->with('imported', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->with('openregister_case_item_audit')->willReturn(true); + $schema->method('getTable')->willReturn($table); + + $this->assertSame($schema, (new Version1Date20261010120000())->changeSchema($this->createMock(IOutput::class), static fn () => $schema, [])); + }//end testItAddsTheImportedColumn() + + /** + * A second run, or a missing table, changes nothing. + * + * @return void + */ + public function testItIsIdempotentAndSkipsAMissingTable(): void { + $table = $this->createMock(ITable::class); + $table->method('hasColumn')->willReturn(true); + $table->expects($this->never())->method('addColumn'); + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->willReturn(true); + $schema->method('getTable')->willReturn($table); + (new Version1Date20261010120000())->changeSchema($this->createMock(IOutput::class), static fn () => $schema, []); + + $absent = $this->createMock(ISchemaWrapper::class); + $absent->method('hasTable')->willReturn(false); + $absent->expects($this->never())->method('getTable'); + $this->assertSame($absent, (new Version1Date20261010120000())->changeSchema($this->createMock(IOutput::class), static fn () => $absent, [])); + }//end testItIsIdempotentAndSkipsAMissingTable() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanEnsurerTest.php b/tests/Unit/Service/Case/CasePlanEnsurerTest.php new file mode 100644 index 0000000000..ee6ac4ea8f --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanEnsurerTest.php @@ -0,0 +1,271 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Tests + * @package OCA\OpenRegister\Tests\Unit\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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemAudit; +use OCA\OpenRegister\Exception\CaseValidationException; +use OCA\OpenRegister\Service\Case\CasePlanDefinition; +use OCA\OpenRegister\Service\Case\CasePlanEnsurer; +use OCA\OpenRegister\Service\Case\CasePlanTransitions; +use OCA\OpenRegister\Service\Case\CaseSentryEvaluator; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; + +/** + * Real definition validator, real transition table, in-memory rows. + */ +class CasePlanEnsurerTest extends TestCase { + + private FakeCaseItemMapper $items; + + private RecordingAuditMapper $audits; + + /** + * Fresh tables. + * + * @return void + */ + protected function setUp(): void { + $this->items = new FakeCaseItemMapper($this); + $this->audits = new RecordingAuditMapper($this); + }//end setUp() + + /** + * The ensurer under test. + * + * @return CasePlanEnsurer The ensurer. + */ + private function ensurer(): CasePlanEnsurer { + $db = $this->createMock(IDBConnection::class); + + return new CasePlanEnsurer( + items: $this->items, + audits: $this->audits, + definitions: new CasePlanDefinition(sentries: new CaseSentryEvaluator(catalog: new EventCatalogService())), + transitions: new CasePlanTransitions(), + db: $db + ); + }//end ensurer() + + /** + * A migrated plan: a stage with a completed task, an active task and a + * reached milestone. + * + * @return array The definition with recorded states. + */ + public static function migrated(): array { + return [ + 'settings' => ['authorization' => ['demo-behandelaars']], + 'items' => [ + [ + 'key' => 'intake', + 'type' => CaseItem::TYPE_STAGE, + 'name' => 'Intake', + 'state' => CaseItem::STATE_ACTIVE, + 'children' => [ + ['key' => 'check', 'type' => CaseItem::TYPE_HUMAN_TASK, 'name' => 'Check', 'state' => CaseItem::STATE_COMPLETED], + ['key' => 'beoordeling', 'type' => CaseItem::TYPE_HUMAN_TASK, 'name' => 'Beoordeling', 'state' => CaseItem::STATE_ACTIVE], + ['key' => 'volledig', 'type' => CaseItem::TYPE_MILESTONE, 'name' => 'Volledig', 'state' => CaseItem::STATE_COMPLETED], + ], + ], + ['key' => 'besluit', 'type' => CaseItem::TYPE_HUMAN_TASK, 'name' => 'Besluit'], + ], + ]; + }//end migrated() + + /** + * History as the source engine recorded it. + * + * @return array> The entries. + */ + private static function history(): array { + return [ + ['item' => 'check', 'from' => 'available', 'to' => 'active', 'at' => '2026-09-01T10:00:00+00:00', 'actor' => 'alice'], + ['item' => 'check', 'from' => 'active', 'to' => 'completed', 'at' => '2026-09-02T10:00:00+00:00', 'actor' => 'alice'], + ['item' => 'volledig', 'from' => 'available', 'to' => 'completed', 'at' => '2026-09-02T10:00:01+00:00'], + ]; + }//end history() + + /** + * States by key of what the table holds. + * + * @return array key => state. + */ + private function states(): array { + $states = []; + foreach ($this->items->findByObject(objectUuid: CaseFixtures::OBJECT) as $row) { + $states[(string)$row->getItemKey()] = (string)$row->getState(); + } + + ksort($states); + + return $states; + }//end states() + + /** + * A fresh bring-over carries states verbatim, nests under the right + * parent, creates no realisation, and imports history flagged as such + * with the original moments. + * + * @return void + */ + public function testCreatesItemsWithRecordedStatesAndImportsHistory(): void { + $result = $this->ensurer()->ensure( + objectUuid: CaseFixtures::OBJECT, + registerId: 1, + schemaId: 2, + definition: self::migrated(), + history: self::history(), + actor: 'system:dossiq' + ); + + $this->assertSame(['intake', 'check', 'beoordeling', 'volledig', 'besluit'], $result['created']); + $this->assertSame([], $result['existing']); + $this->assertSame(['beoordeling' => 'active', 'besluit' => 'available', 'check' => 'completed', 'intake' => 'active', 'volledig' => 'completed'], $this->states()); + + $byKey = []; + foreach ($this->items->findByObject(objectUuid: CaseFixtures::OBJECT) as $row) { + $byKey[(string)$row->getItemKey()] = $row; + } + + $this->assertSame($byKey['intake']->getId(), $byKey['check']->getParentItemId()); + $this->assertNull($byKey['besluit']->getParentItemId()); + $this->assertTrue($byKey['check']->getIsTerminal()); + $this->assertFalse($byKey['beoordeling']->getIsTerminal()); + $this->assertNull($byKey['beoordeling']->getRealisationUuid(), 'An imported active item gets no new task.'); + $this->assertNotNull($byKey['beoordeling']->getEnteredAt()); + $this->assertNull($byKey['besluit']->getEnteredAt()); + $this->assertSame(2, $byKey['check']->getSchemaId()); + $this->assertSame(['authorization' => ['demo-behandelaars']], $byKey['check']->getPlanSettings()); + + $checkTrail = $this->audits->findForItem((int)$byKey['check']->getId()); + $this->assertCount(3, $checkTrail); + $this->assertSame(CaseItemAudit::CAUSE_IMPORT, $checkTrail[0]->getCause()); + $this->assertSame('ensure:system:dossiq', $checkTrail[0]->getCauseRef()); + $this->assertSame('system:dossiq', $checkTrail[0]->getActor()); + $this->assertFalse($checkTrail[0]->getImported()); + $this->assertTrue($checkTrail[1]->getImported()); + $this->assertSame('alice', $checkTrail[1]->getActor()); + $this->assertSame('2026-09-01T10:00:00+00:00', $checkTrail[1]->getCreated()->format('c')); + $this->assertSame('completed', $checkTrail[2]->getToState()); + $this->assertSame('system:dossiq', $this->audits->findForItem((int)$byKey['volledig']->getId())[1]->getActor(), 'History without an actor names the importer.'); + }//end testCreatesItemsWithRecordedStatesAndImportsHistory() + + /** + * A half-written plan resumes: existing rows keep their state and gain + * no audit entry; only the missing ones are created, under the existing + * parent. A second run then appends nothing at all. + * + * @return void + */ + public function testHalfWrittenPlanResumesAndSecondRunChangesNothing(): void { + $this->items->seed( + [ + CaseFixtures::row(id: 10, key: 'intake', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE), + CaseFixtures::row(id: 11, key: 'check', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE, parentId: 10), + ] + ); + + $first = $this->ensurer()->ensure(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: self::migrated(), history: self::history(), actor: 'system:dossiq'); + $this->assertSame(['beoordeling', 'volledig', 'besluit'], $first['created']); + $this->assertSame(['intake', 'check'], $first['existing']); + $this->assertSame('active', $this->states()['check'], 'An existing row keeps its state; the recorded one is never written over it.'); + $this->assertSame([], $this->audits->findForItem(11), 'No audit for a row this call did not create, history included.'); + + $rows = $this->items->findByObject(objectUuid: CaseFixtures::OBJECT); + foreach ($rows as $row) { + if ($row->getItemKey() === 'beoordeling') { + $this->assertSame(10, $row->getParentItemId()); + } + } + + $auditCount = count($this->audits->entries); + $second = $this->ensurer()->ensure(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: self::migrated(), history: self::history(), actor: 'system:dossiq'); + $this->assertSame([], $second['created']); + $this->assertCount(5, $second['items']); + $this->assertCount(5, $this->items->findByObject(objectUuid: CaseFixtures::OBJECT)); + $this->assertCount($auditCount, $this->audits->entries); + }//end testHalfWrittenPlanResumesAndSecondRunChangesNothing() + + /** + * Refusals happen before anything is written: a state the type cannot + * reach, a state outside the six, history about an unknown item or with + * an unreadable moment, an existing key of another type, an anchor that + * names another schema. + * + * @return void + */ + public function testRefusesBeforeWriting(): void { + $cases = []; + + $milestoneActive = self::migrated(); + $milestoneActive['items'][0]['children'][2]['state'] = CaseItem::STATE_ACTIVE; + $cases["'volledig' (milestone) cannot carry state 'active'"] = [$milestoneActive, [], []]; + + $unknownState = self::migrated(); + $unknownState['items'][1]['state'] = 'suspended'; + $cases["'besluit' (humanTask) cannot carry state 'suspended'"] = [$unknownState, [], []]; + + $cases["names item 'ghost'"] = [self::migrated(), [['item' => 'ghost', 'to' => 'active', 'at' => '2026-09-01T10:00:00+00:00']], []]; + $cases['unreadable moment'] = [self::migrated(), [['item' => 'check', 'to' => 'active', 'at' => 'yesterday-ish']], []]; + $cases["to-state 'done'"] = [self::migrated(), [['item' => 'check', 'to' => 'done', 'at' => '2026-09-01T10:00:00+00:00']], []]; + $cases["'check' exists as a milestone"] = [self::migrated(), [], [CaseFixtures::row(id: 11, key: 'check', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_AVAILABLE)]]; + + $otherSchema = CaseFixtures::row(id: 12, key: 'intake', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE); + $otherSchema->setSchemaId(9); + $cases['anchored to register 1 schema 9'] = [self::migrated(), [], [$otherSchema]]; + + foreach ($cases as $expected => [$definition, $history, $seed]) { + $this->setUp(); + $this->items->seed($seed); + $before = count($this->items->findByObject(objectUuid: CaseFixtures::OBJECT)); + try { + $this->ensurer()->ensure(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: $definition, history: $history, actor: 'system:dossiq'); + $this->fail('expected a refusal: ' . $expected); + } catch (CaseValidationException $refusal) { + $this->assertStringContainsString($expected, $refusal->getMessage()); + } + + $this->assertCount($before, $this->items->findByObject(objectUuid: CaseFixtures::OBJECT), $expected); + $this->assertSame([], $this->audits->entries, $expected); + } + }//end testRefusesBeforeWriting() + + /** + * A definition the validator refuses (an unknown event) is refused here + * too: ensure is not a side door around save-time validation. + * + * @return void + */ + public function testDefinitionValidationStillApplies(): void { + $bad = self::migrated(); + $bad['items'][1]['entryCriteria'] = [['on' => ['event' => 'case.item.started']]]; + + $this->expectException(CaseValidationException::class); + $this->expectExceptionMessage("'case.item.started'"); + $this->ensurer()->ensure(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: $bad, history: [], actor: 'system:dossiq'); + }//end testDefinitionValidationStillApplies() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanServiceTest.php b/tests/Unit/Service/Case/CasePlanServiceTest.php index 3cd2da0314..178d19711c 100644 --- a/tests/Unit/Service/Case/CasePlanServiceTest.php +++ b/tests/Unit/Service/Case/CasePlanServiceTest.php @@ -544,4 +544,71 @@ public function testEventDrivenEvaluationFailuresAreSwallowed(): void { $completions = array_filter($this->audits->findForItem(2), static fn (CaseItemAudit $entry): bool => $entry->getToState() === CaseItem::STATE_COMPLETED); $this->assertSame([], $completions); }//end testEventDrivenEvaluationFailuresAreSwallowed() + /** + * An app's own code acts without a session as `system:`: it creates + * and reads a plan nobody could see by visibility, the audit names the + * app, and a malformed app id is refused. The user verbs keep refusing a + * missing identity. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + * + * @return void + */ + public function testSystemCallersActAsANamedApp(): void { + $this->anchor = $this->createMock(CaseAnchorReader::class); + $this->anchor->method('read')->willReturn(['status' => 'open']); + $this->anchor->method('mayRead')->willReturn(false); + + $plan = $this->service()->createPlanAsSystem(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), app: 'dossiq'); + $this->assertCount(7, $plan['items']); + $this->assertSame('system:dossiq', $this->audits->findForItem(1)[0]->getActor()); + $this->assertCount(7, $this->service()->getPlanAsSystem(objectUuid: CaseFixtures::OBJECT, app: 'dossiq')['items']); + + foreach (['', 'Dossiq', 'dos:siq', 'dos siq'] as $bad) { + try { + $this->service()->getPlanAsSystem(objectUuid: CaseFixtures::OBJECT, app: $bad); + $this->fail('app id refused: ' . $bad); + } catch (CaseValidationException $refusal) { + $this->assertStringContainsString('app id', $refusal->getMessage()); + } + } + + try { + $this->service()->createPlan(objectUuid: 'other-object', registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: null); + $this->fail('a null identity stays refused on the user verb'); + } catch (CaseAccessDeniedException $denied) { + $this->assertStringContainsString('no acting identity', $denied->getMessage()); + } + + $this->expectException(DoesNotExistException::class); + $this->service()->getPlanAsSystem(objectUuid: 'no-plan', app: 'dossiq'); + }//end testSystemCallersActAsANamedApp() + + /** + * ensureItems brings a plan over with its recorded states as the named + * app and runs no cascade: an imported active stage does not enter its + * children. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * + * @return void + */ + public function testEnsureItemsBringsAPlanOverAsTheApp(): void { + $result = $this->service()->ensureItems( + objectUuid: CaseFixtures::OBJECT, + registerId: 1, + schemaId: 1, + definition: CasePlanEnsurerTest::migrated(), + history: [], + app: 'dossiq' + ); + + $this->assertSame(['intake', 'check', 'beoordeling', 'volledig', 'besluit'], $result['created']); + $states = array_column($result['items'], 'state', 'key'); + $this->assertSame('available', $states['besluit'], 'No cascade: nothing entered besluit.'); + $this->assertSame('system:dossiq', $this->audits->findForItem(1)[0]->getActor()); + + $this->expectException(CaseValidationException::class); + $this->service()->ensureItems(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanEnsurerTest::migrated(), history: [], app: 'Not An App'); + }//end testEnsureItemsBringsAPlanOverAsTheApp() }//end class diff --git a/tests/Unit/Service/Case/CaseSystemVerbsUnroutedTest.php b/tests/Unit/Service/Case/CaseSystemVerbsUnroutedTest.php new file mode 100644 index 0000000000..0521de6706 --- /dev/null +++ b/tests/Unit/Service/Case/CaseSystemVerbsUnroutedTest.php @@ -0,0 +1,57 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Tests + * @package OCA\OpenRegister\Tests\Unit + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use PHPUnit\Framework\TestCase; + +/** + * The system verbs carry no authorization, so no HTTP surface may reach + * them: no controller references them. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + */ +class CaseSystemVerbsUnroutedTest extends TestCase { + + /** + * Scan every controller for the system verbs. + * + * @return void + */ + public function testNoControllerReachesASystemVerb(): void { + $root = dirname(__DIR__, 4) . '/lib/Controller'; + $this->assertDirectoryExists($root); + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + $scanned = 0; + $offenders = []; + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $scanned++; + $source = (string)file_get_contents($file->getPathname()); + if (preg_match('/\b(createPlanAsSystem|getPlanAsSystem|ensureItems)\b/', $source) === 1) { + $offenders[] = $file->getPathname(); + } + } + + $this->assertGreaterThan(10, $scanned, 'The scan must actually see the controllers.'); + $this->assertSame([], $offenders, 'A system verb has no authorization; route the user verb instead.'); + }//end testNoControllerReachesASystemVerb() +}//end class From 1728df85ce3eb4b9160057cd238e7a77d763ef5b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 11:34:29 +0200 Subject: [PATCH 013/183] style(cases): wrap two long refusal messages --- lib/Service/Case/CasePlanEnsurer.php | 12 ++++++++++-- tests/Unit/Service/Case/CasePlanTransitionsTest.php | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/Service/Case/CasePlanEnsurer.php b/lib/Service/Case/CasePlanEnsurer.php index 4afe88b259..8b62ebdd7d 100644 --- a/lib/Service/Case/CasePlanEnsurer.php +++ b/lib/Service/Case/CasePlanEnsurer.php @@ -161,7 +161,13 @@ private function checkStates(array $nodes, array &$types): void { $state = (string)($node['state'] ?? CaseItem::STATE_AVAILABLE); if (in_array($state, $this->reachable(type: $type), true) === false) { throw new CaseValidationException( - message: sprintf("'%s' (%s) cannot carry state '%s'; its lifecycle reaches %s.", $key, $type, $state, implode(', ', $this->reachable(type: $type))) + message: sprintf( + "'%s' (%s) cannot carry state '%s'; its lifecycle reaches %s.", + $key, + $type, + $state, + implode(', ', $this->reachable(type: $type)) + ) ); } @@ -224,7 +230,9 @@ private function checkHistory(array $history, array $types): array { $from = trim((string)($entry['from'] ?? '')); $to = trim((string)($entry['to'] ?? '')); if (in_array($to, CaseItem::STATES, true) === false || ($from !== '' && in_array($from, CaseItem::STATES, true) === false)) { - throw new CaseValidationException(message: sprintf("%s has from-state '%s' and to-state '%s'; both must be plan-item states.", $label, $from, $to)); + throw new CaseValidationException( + message: sprintf("%s has from-state '%s' and to-state '%s'; both must be plan-item states.", $label, $from, $to) + ); } $moment = $this->moment(value: ($entry['at'] ?? null)); diff --git a/tests/Unit/Service/Case/CasePlanTransitionsTest.php b/tests/Unit/Service/Case/CasePlanTransitionsTest.php index fd529b7505..7156ddf210 100644 --- a/tests/Unit/Service/Case/CasePlanTransitionsTest.php +++ b/tests/Unit/Service/Case/CasePlanTransitionsTest.php @@ -175,7 +175,7 @@ public function testSelfLoopsAndUnknownTypesHaveNoEdges(): void { $this->assertFalse($table->isLegal(type: CaseItem::TYPE_STAGE, from: $state, to: $state)); } - $this->assertSame([], $table->targetsFor(type: 'processTask', from: CaseItem::STATE_AVAILABLE)); + $this->assertSame([], $table->targetsFor(type: 'eventListener', from: CaseItem::STATE_AVAILABLE)); }//end testSelfLoopsAndUnknownTypesHaveNoEdges() /** From eeab5908e372f8ac71fbb5d3f31bd83fc767897d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 11:46:39 +0200 Subject: [PATCH 014/183] feat(cases): a case plan can start a flow from any work item, and shares the task's states processTask plan items are realised by a flow run on the plan's subject, like a flow-bound stage; a process task without a flow is refused at create time. CaseItem's states now reference Task's, so one list governs both. Change one-engine-bpmn-and-cmmn, tasks 2.1-2.3. --- lib/Db/CaseItem.php | 41 +++-- lib/Service/Case/CasePlanDefinition.php | 17 ++- lib/Service/Case/CasePlanTransitions.php | 1 + lib/Service/Case/CaseRealisationService.php | 3 +- .../changes/one-engine-bpmn-and-cmmn/tasks.md | 6 +- .../Service/Case/CasePlanTransitionsTest.php | 6 +- .../Unit/Service/Case/CaseProcessTaskTest.php | 144 ++++++++++++++++++ 7 files changed, 187 insertions(+), 31 deletions(-) create mode 100644 tests/Unit/Service/Case/CaseProcessTaskTest.php diff --git a/lib/Db/CaseItem.php b/lib/Db/CaseItem.php index 69de0e767c..70ae852d84 100644 --- a/lib/Db/CaseItem.php +++ b/lib/Db/CaseItem.php @@ -133,47 +133,37 @@ class CaseItem extends Entity implements JsonSerializable { /** - * The six CMMN plan-item states: the same six a task carries. + * The six CMMN plan-item states: the task's states, declared once on + * {@see Task} (one-engine-bpmn-and-cmmn). */ - public const STATE_AVAILABLE = 'available'; + public const STATE_AVAILABLE = Task::STATE_AVAILABLE; - public const STATE_ENABLED = 'enabled'; + public const STATE_ENABLED = Task::STATE_ENABLED; - public const STATE_ACTIVE = 'active'; + public const STATE_ACTIVE = Task::STATE_ACTIVE; - public const STATE_COMPLETED = 'completed'; + public const STATE_COMPLETED = Task::STATE_COMPLETED; - public const STATE_TERMINATED = 'terminated'; + public const STATE_TERMINATED = Task::STATE_TERMINATED; - public const STATE_DISABLED = 'disabled'; + public const STATE_DISABLED = Task::STATE_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, - ]; + public const STATES = Task::STATES; /** * 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, - ]; + public const TERMINAL_STATES = Task::TERMINAL_STATES; /** - * The three plan-item types. + * The plan-item types. */ public const TYPE_STAGE = 'stage'; @@ -181,6 +171,12 @@ class CaseItem extends Entity implements JsonSerializable { public const TYPE_MILESTONE = 'milestone'; + /** + * A work item realised by a flow run (CMMN ProcessTask): the case layer + * starting a BPMN process. + */ + public const TYPE_PROCESS_TASK = 'processTask'; + /** * Every plan-item type. * @@ -189,6 +185,7 @@ class CaseItem extends Entity implements JsonSerializable { public const TYPES = [ self::TYPE_STAGE, self::TYPE_HUMAN_TASK, + self::TYPE_PROCESS_TASK, self::TYPE_MILESTONE, ]; @@ -308,7 +305,7 @@ class CaseItem extends Entity implements JsonSerializable { protected ?int $parentItemId = null; /** - * stage | humanTask | milestone. + * stage | humanTask | processTask | milestone. * * @var string|null */ diff --git a/lib/Service/Case/CasePlanDefinition.php b/lib/Service/Case/CasePlanDefinition.php index a4cb5ecf69..c97f2b4d34 100644 --- a/lib/Service/Case/CasePlanDefinition.php +++ b/lib/Service/Case/CasePlanDefinition.php @@ -293,11 +293,24 @@ private function validateNode(mixed $node, string $path, array &$keys, array &$f 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)); + if (isset($node['flow']) === true && $type !== CaseItem::TYPE_PROCESS_TASK) { + throw new CaseValidationException( + message: sprintf("'%s' is a %s and cannot bind a flow; only a stage or a process task is realised by a run.", $key, $type) + ); } } + if ($type === CaseItem::TYPE_PROCESS_TASK) { + $flow = trim((string)($node['flow'] ?? '')); + if ($flow === '') { + throw new CaseValidationException( + message: sprintf("'%s' is a process task and names no flow; a process task without a flow would be work nobody does.", $key) + ); + } + + $flows[$key] = $flow; + } + $node['key'] = $key; $children = ($node['children'] ?? []); $node['children'] = []; diff --git a/lib/Service/Case/CasePlanTransitions.php b/lib/Service/Case/CasePlanTransitions.php index 9cedd62cd9..282cd7e373 100644 --- a/lib/Service/Case/CasePlanTransitions.php +++ b/lib/Service/Case/CasePlanTransitions.php @@ -97,6 +97,7 @@ class CasePlanTransitions { private const TABLE = [ CaseItem::TYPE_STAGE => self::WORK_ITEM_EDGES, CaseItem::TYPE_HUMAN_TASK => self::WORK_ITEM_EDGES, + CaseItem::TYPE_PROCESS_TASK => self::WORK_ITEM_EDGES, CaseItem::TYPE_MILESTONE => self::MILESTONE_EDGES, ]; diff --git a/lib/Service/Case/CaseRealisationService.php b/lib/Service/Case/CaseRealisationService.php index 3aefa9af1f..b9332700a4 100644 --- a/lib/Service/Case/CaseRealisationService.php +++ b/lib/Service/Case/CaseRealisationService.php @@ -98,7 +98,8 @@ public function realise(CaseItem $item, string $actor): void { } $flow = trim((string)($item->getPlanSettings()['flows'][(string)$item->getItemKey()] ?? '')); - if ($item->getPlanItemType() === CaseItem::TYPE_STAGE && $flow !== '') { + $runnable = in_array($item->getPlanItemType(), [CaseItem::TYPE_STAGE, CaseItem::TYPE_PROCESS_TASK], true); + if ($runnable === true && $flow !== '') { $run = $this->runs->queue( flowId: $flow, subject: [ diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md index 241af7b58d..2a5fe00729 100644 --- a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md +++ b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md @@ -11,9 +11,9 @@ Small PRs, in this order. PR 1 is what dossiq's drain waits on. ## 2. One state list and the process task (PR 2) -- [ ] 2.1 `CaseItem::STATES` / `TERMINAL_STATES` reference `Task::STATES` / `TERMINAL_STATES`; unit test asserts identity. -- [ ] 2.2 `CaseItem::TYPE_PROCESS_TASK`; `CasePlanTransitions` work-item edges; `CasePlanDefinition` accepts it, refuses one without a flow or with children. -- [ ] 2.3 `CaseRealisationService::realise()` queues the run for a process task; `CaseRunTerminalListener` drives it as for a stage; unit tests. +- [x] 2.1 `CaseItem::STATES` / `TERMINAL_STATES` reference `Task::STATES` / `TERMINAL_STATES`; unit test asserts identity. (CaseProcessTaskTest::testPlanItemsAndTasksShareOneListOfStates) +- [x] 2.2 `CaseItem::TYPE_PROCESS_TASK`; `CasePlanTransitions` work-item edges; `CasePlanDefinition` accepts it, refuses one without a flow or with children. (CaseProcessTaskTest, CasePlanTransitionsTest matrix) +- [x] 2.3 `CaseRealisationService::realise()` queues the run for a process task; `CaseRunTerminalListener` drives it as for a stage (the cascade's realisation check is type-agnostic, `CasePlanCascade.php:223`); unit tests. (CaseProcessTaskTest::testAProcessTaskQueuesItsFlow) ## 3. Plan-item timers on the shared clock (PR 3) diff --git a/tests/Unit/Service/Case/CasePlanTransitionsTest.php b/tests/Unit/Service/Case/CasePlanTransitionsTest.php index 7156ddf210..449baecfa5 100644 --- a/tests/Unit/Service/Case/CasePlanTransitionsTest.php +++ b/tests/Unit/Service/Case/CasePlanTransitionsTest.php @@ -43,7 +43,7 @@ class CasePlanTransitionsTest extends TestCase { */ public static function legalEdges(): array { $cases = []; - foreach ([CaseItem::TYPE_STAGE, CaseItem::TYPE_HUMAN_TASK] as $type) { + foreach ([CaseItem::TYPE_STAGE, CaseItem::TYPE_HUMAN_TASK, CaseItem::TYPE_PROCESS_TASK] as $type) { foreach ([ [CaseItem::STATE_AVAILABLE, CaseItem::STATE_ENABLED], [CaseItem::STATE_AVAILABLE, CaseItem::STATE_ACTIVE], @@ -111,8 +111,8 @@ public function testEveryEdgeOutsideTheTableIsRefused(): void { } } - // 3 types x 36 pairs = 108 triples, 20 legal. - $this->assertSame(88, $refused); + // 4 types x 36 pairs = 144 triples, 29 legal. + $this->assertSame(115, $refused); }//end testEveryEdgeOutsideTheTableIsRefused() /** diff --git a/tests/Unit/Service/Case/CaseProcessTaskTest.php b/tests/Unit/Service/Case/CaseProcessTaskTest.php new file mode 100644 index 0000000000..5ce3dd3c3a --- /dev/null +++ b/tests/Unit/Service/Case/CaseProcessTaskTest.php @@ -0,0 +1,144 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Tests + * @package OCA\OpenRegister\Tests\Unit\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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-process-task-starts-a-flow + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\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\Exception\CaseValidationException; +use OCA\OpenRegister\Service\Case\CasePlanDefinition; +use OCA\OpenRegister\Service\Case\CasePlanTransitions; +use OCA\OpenRegister\Service\Case\CaseRealisationService; +use OCA\OpenRegister\Service\Case\CaseSentryEvaluator; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Task\TaskService; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Real transition table, real definition validator, real realisation + * service over mocked task and run stores. + */ +class CaseProcessTaskTest extends TestCase { + + /** + * The plan item's states ARE the task's states: one declaration. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-one-engine-carries-both-model-types-on-a-shared-core + * + * @return void + */ + public function testPlanItemsAndTasksShareOneListOfStates(): void { + $this->assertSame(Task::STATES, CaseItem::STATES); + $this->assertSame(Task::TERMINAL_STATES, CaseItem::TERMINAL_STATES); + $this->assertSame(['available', 'enabled', 'active', 'completed', 'terminated', 'disabled'], array_values(CaseItem::STATES)); + }//end testPlanItemsAndTasksShareOneListOfStates() + + /** + * A process task follows the work-item lifecycle. + * + * @return void + */ + public function testAProcessTaskHasTheWorkItemLifecycle(): void { + $this->assertContains(CaseItem::TYPE_PROCESS_TASK, CaseItem::TYPES); + $table = new CasePlanTransitions(); + foreach (CaseItem::STATES as $from) { + $this->assertSame( + $table->targetsFor(type: CaseItem::TYPE_HUMAN_TASK, from: $from), + $table->targetsFor(type: CaseItem::TYPE_PROCESS_TASK, from: $from), + $from + ); + } + }//end testAProcessTaskHasTheWorkItemLifecycle() + + /** + * A process task names its flow, which lands in the plan settings like a + * stage's; without a flow, or with children, it is refused. + * + * @return void + */ + public function testTheDefinitionBindsAProcessTaskToItsFlow(): void { + $definitions = new CasePlanDefinition(sentries: new CaseSentryEvaluator(catalog: new EventCatalogService())); + $normalised = $definitions->validate( + definition: ['items' => [['key' => 'advies', 'type' => CaseItem::TYPE_PROCESS_TASK, 'name' => 'Advies', 'flow' => 'advice-flow']]] + ); + $this->assertSame(['advies' => 'advice-flow'], $normalised['settings']['flows']); + + $refusals = [ + "'advies' is a process task and names no flow" => ['key' => 'advies', 'type' => CaseItem::TYPE_PROCESS_TASK], + "'advies' is a processTask and cannot contain children" => [ + 'key' => 'advies', + 'type' => CaseItem::TYPE_PROCESS_TASK, + 'flow' => 'advice-flow', + 'children' => [['key' => 'x', 'type' => CaseItem::TYPE_HUMAN_TASK]], + ], + ]; + foreach ($refusals as $expected => $node) { + try { + $definitions->validate(definition: ['items' => [$node]]); + $this->fail('refused: ' . $expected); + } catch (CaseValidationException $refusal) { + $this->assertStringContainsString($expected, $refusal->getMessage()); + } + } + }//end testTheDefinitionBindsAProcessTaskToItsFlow() + + /** + * Entering active queues the bound flow on the plan's subject and records + * the run as the realisation, exactly as for a flow-bound stage. + * + * @return void + */ + public function testAProcessTaskQueuesItsFlow(): void { + $runs = $this->createMock(FlowRunService::class); + $run = new FlowRun(); + $run->setUuid('run-9'); + $runs->expects($this->once())->method('queue')->with( + 'advice-flow', + ['uuid' => CaseFixtures::OBJECT, 'register' => '1', 'schema' => '1'], + CaseRealisationService::RUN_TRIGGER, + ['caseItem' => 'item-4'], + 'alice' + )->willReturn($run); + $tasks = $this->createMock(TaskService::class); + $tasks->expects($this->never())->method('import'); + + $item = CaseFixtures::row(id: 4, key: 'advies', type: CaseItem::TYPE_PROCESS_TASK, state: CaseItem::STATE_ENABLED); + $item->setPlanSettings(['flows' => ['advies' => 'advice-flow']]); + $service = new CaseRealisationService( + tasks: $tasks, + taskRows: $this->createMock(TaskMapper::class), + runs: $runs, + runRows: $this->createMock(FlowRunMapper::class), + logger: new NullLogger() + ); + $service->realise(item: $item, actor: 'alice'); + + $this->assertSame(CaseItem::REALISATION_RUN, $item->getRealisationKind()); + $this->assertSame('run-9', $item->getRealisationUuid()); + }//end testAProcessTaskQueuesItsFlow() +}//end class From 71fe955224605611d3424c1a88a0dcea1ffe325a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 11:51:34 +0200 Subject: [PATCH 015/183] feat(cases): plan-item deadlines run on the shared clock An item that becomes active with a dueAt or expiresAt arms a FlowTimer (subject case-item) landing exactly on that moment; a terminal state cancels them; an enforcing expiry on a statutory item terminates it through the state machine. The case layer keeps no clock of its own. Change one-engine-bpmn-and-cmmn, tasks 3.1-3.3. --- lib/AppInfo/Application.php | 11 + lib/Db/FlowTimer.php | 2 +- lib/Listener/CaseItemTimerListener.php | 92 +++++++ lib/Service/Case/CasePlanItemTimers.php | 250 ++++++++++++++++++ lib/Service/Case/CasePlanService.php | 48 ++++ .../one-engine-bpmn-and-cmmn/design.md | 36 ++- .../changes/one-engine-bpmn-and-cmmn/tasks.md | 6 +- .../Listener/CaseItemTimerListenerTest.php | 76 ++++++ .../Service/Case/CasePlanItemTimersTest.php | 219 +++++++++++++++ .../Unit/Service/Case/CasePlanServiceTest.php | 28 ++ 10 files changed, 750 insertions(+), 18 deletions(-) create mode 100644 lib/Listener/CaseItemTimerListener.php create mode 100644 lib/Service/Case/CasePlanItemTimers.php create mode 100644 tests/Unit/Listener/CaseItemTimerListenerTest.php create mode 100644 tests/Unit/Service/Case/CasePlanItemTimersTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 395956d625..ae9a7e5e04 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -3254,6 +3254,17 @@ private function registerEventListeners(IRegistrationContext $context): void { \OCA\OpenRegister\Event\CaseItemTransitionedEvent::class, \OCA\OpenRegister\Listener\FlowTriggerListener::class ); + // Plan items on the shared clock (one-engine-bpmn-and-cmmn): entering + // active arms an item's timers, a terminal state cancels them, and an + // enforcing expiry terminates the item. The case layer keeps no clock. + $context->registerEventListener( + \OCA\OpenRegister\Event\CaseItemTransitionedEvent::class, + \OCA\OpenRegister\Listener\CaseItemTimerListener::class + ); + $context->registerEventListener( + \OCA\OpenRegister\Event\FlowTimerFiredEvent::class, + \OCA\OpenRegister\Listener\CaseItemTimerListener::class + ); // Business-timer cancellation propagation (flow-business-timers, design // D-9): the SAME terminal event, and the run-terminal one, also cancel diff --git a/lib/Db/FlowTimer.php b/lib/Db/FlowTimer.php index 4278828c90..e02223c8ce 100644 --- a/lib/Db/FlowTimer.php +++ b/lib/Db/FlowTimer.php @@ -212,7 +212,7 @@ class FlowTimer extends Entity implements JsonSerializable { * * @var array */ - public const SUBJECT_TYPES = ['task', 'object', 'run']; + public const SUBJECT_TYPES = ['task', 'object', 'run', 'case-item']; /** * The three reserved enforcing outcomes; `transition:` is the fourth shape. diff --git a/lib/Listener/CaseItemTimerListener.php b/lib/Listener/CaseItemTimerListener.php new file mode 100644 index 0000000000..a50273b1f2 --- /dev/null +++ b/lib/Listener/CaseItemTimerListener.php @@ -0,0 +1,92 @@ + + * 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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Event\CaseItemTransitionedEvent; +use OCA\OpenRegister\Event\FlowTimerFiredEvent; +use OCA\OpenRegister\Service\Case\CasePlanItemTimers; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** + * Wires plan items to the timer core. + * + * @template-implements IEventListener + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ +class CaseItemTimerListener implements IEventListener { + + /** + * Constructor. + * + * @param CasePlanItemTimers $timers What a deadline means for an item. + * @param CasePlanService $plans The one transition path. + */ + public function __construct( + private readonly CasePlanItemTimers $timers, + private readonly CasePlanService $plans, + ) { + + }//end __construct() + + /** + * Handle a plan-item transition or a fired timer. + * + * @param Event $event The event. + * + * @return void + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function handle(Event $event): void { + if ($event instanceof CaseItemTransitionedEvent) { + $item = $event->getItem(); + if ($item->getState() === CaseItem::STATE_ACTIVE) { + $this->timers->onEntered(item: $item, actor: null); + } + + if ($item->isInTerminalState() === true) { + $this->timers->onTerminal(item: $item); + } + + return; + } + + if ($event instanceof FlowTimerFiredEvent) { + $itemUuid = $this->timers->itemToTerminate(event: $event); + if ($itemUuid !== null) { + $this->plans->onTimerExpired(itemUuid: $itemUuid, timerUuid: (string)$event->getTimer()->getUuid()); + } + } + }//end handle() +}//end class diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php new file mode 100644 index 0000000000..ed35c32290 --- /dev/null +++ b/lib/Service/Case/CasePlanItemTimers.php @@ -0,0 +1,250 @@ + + * 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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use DateTimeImmutable; +use DateTimeInterface; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Event\FlowTimerFiredEvent; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use OCA\OpenRegister\Service\Flow\Timer\SlaCalculator; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Arms, cancels and interprets the timers of plan items. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ +class CasePlanItemTimers { + + /** + * The timer subject type of a plan item. + */ + public const SUBJECT_TYPE = 'case-item'; + + /** + * The enforcing outcome of a statutory item's expiry. + */ + public const ON_EXPIRY = 'transition:terminate'; + + /** + * The actor recorded on cancellations. + */ + private const ACTOR = 'case-plan'; + + /** + * Constructor. + * + * @param FlowTimerService $timers The one clock. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly FlowTimerService $timers, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Arm the timers of an item that just became active. + * + * @param CaseItem $item The item. + * @param string|null $actor The identity that moved it. + * @param DateTimeInterface|null $now The clock; null is the real clock. + * + * @return int How many timers were armed. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function onEntered(CaseItem $item, ?string $actor, ?DateTimeInterface $now = null): int { + $moment = DateTimeImmutable::createFromInterface($now ?? new DateTimeImmutable()); + $armed = 0; + + $due = $item->getDueAt(); + if ($due !== null && $this->arm(item: $item, purpose: FlowTimer::PURPOSE_DUE, deadline: $due, actor: $actor, now: $moment) === true) { + $armed++; + } + + $expires = $item->getExpiresAt(); + if ($expires !== null && $this->arm(item: $item, purpose: FlowTimer::PURPOSE_EXPIRY, deadline: $expires, actor: $actor, now: $moment) === true) { + $armed++; + } + + return $armed; + }//end onEntered() + + /** + * Cancel the open timers of an item that reached a terminal state. + * + * @param CaseItem $item The item. + * + * @return int How many timers were cancelled. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function onTerminal(CaseItem $item): int { + try { + return $this->timers->cancelForSubject( + subjectType: self::SUBJECT_TYPE, + subjectUuid: (string)$item->getUuid(), + reason: sprintf("Plan item '%s' is %s.", (string)$item->getItemKey(), (string)$item->getState()), + actor: self::ACTOR + ); + } catch (Throwable $failure) { + $this->logger->warning( + '[CasePlanItemTimers] Could not cancel a plan item\'s timers; the sweep will find them moot: ' . $failure->getMessage(), + ['item' => $item->getUuid(), 'exception' => $failure] + ); + + return 0; + } + }//end onTerminal() + + /** + * The item an expiry should terminate, if any: only an enforcing expiry + * whose subject is a plan item. + * + * @param FlowTimerFiredEvent $event The fired timer. + * + * @return string|null The item uuid, or null. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function itemToTerminate(FlowTimerFiredEvent $event): ?string { + $timer = $event->getTimer(); + if ($event->getKind() !== FlowTimerFiredEvent::KIND_EXPIRY + || $timer->getSubjectType() !== self::SUBJECT_TYPE + || $timer->isEnforcing() === false + ) { + return null; + } + + $uuid = trim((string)$timer->getSubjectUuid()); + if ($uuid === '') { + return null; + } + + return $uuid; + }//end itemToTerminate() + + /** + * Arm one timer whose deadline lands exactly on `$deadline`: the budget + * is a whole number of hours (calendar days past the hour bound), and the + * anchor is set back so anchor + budget is the deadline. + * + * @param CaseItem $item The item. + * @param string $purpose `due` or `expiry`. + * @param DateTimeInterface $deadline The moment. + * @param string|null $actor The arming identity. + * @param DateTimeImmutable $now The clock. + * + * @return boolean True when armed. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadline, ?string $actor, DateTimeImmutable $now): bool { + $seconds = ($deadline->getTimestamp() - $now->getTimestamp()); + if ($seconds <= 0) { + $this->logger->info( + '[CasePlanItemTimers] A plan item became active after its deadline; no timer is armed.', + ['item' => $item->getUuid(), 'purpose' => $purpose] + ); + + return false; + } + + $unit = SlaCalculator::UNIT_HOURS; + $step = 3600; + if ((int)ceil($seconds / 3600) > SlaCalculator::MAX_VALUE) { + $unit = SlaCalculator::UNIT_CALENDAR_DAYS; + $step = 86400; + } + + $value = max(SlaCalculator::MIN_VALUE, (int)ceil($seconds / $step)); + $anchor = DateTimeImmutable::createFromInterface($deadline)->modify(sprintf('-%d seconds', $value * $step)); + + $config = [ + 'subjectType' => self::SUBJECT_TYPE, + 'subjectUuid' => (string)$item->getUuid(), + 'purpose' => $purpose, + 'legalEffect' => $this->legalEffect(item: $item, purpose: $purpose), + 'sla' => ['value' => $value, 'unit' => $unit], + 'anchorEvent' => 'case.item.active', + 'anchorEventAt' => $anchor->format(DATE_ATOM), + 'appId' => 'openregister', + 'title' => (string)($item->getName() ?? $item->getItemKey()), + 'metadata' => ['objectUuid' => $item->getObjectUuid(), 'itemKey' => $item->getItemKey()], + ]; + if ($config['legalEffect'] === FlowTimer::LEGAL_WETTELIJK) { + $config['onExpiry'] = self::ON_EXPIRY; + } + + try { + $this->timers->arm(config: $config, actor: $actor); + + return true; + } catch (Throwable $failure) { + $this->logger->warning( + '[CasePlanItemTimers] A plan-item timer was refused; the transition stands: ' . $failure->getMessage(), + ['item' => $item->getUuid(), 'purpose' => $purpose, 'exception' => $failure] + ); + + return false; + } + }//end arm() + + /** + * A due date advises; an expiry enforces only on a statutory item and + * otherwise counts as a service norm. + * + * @param CaseItem $item The item. + * @param string $purpose `due` or `expiry`. + * + * @return string The legal effect. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + private function legalEffect(CaseItem $item, string $purpose): string { + if ($purpose === FlowTimer::PURPOSE_DUE) { + return FlowTimer::LEGAL_NONE; + } + + $statutory = ($item->getPlanSettings()['statutory'] ?? []); + if (is_array($statutory) === true && in_array($item->getItemKey(), $statutory, true) === true) { + return FlowTimer::LEGAL_WETTELIJK; + } + + return FlowTimer::LEGAL_SERVICENORM; + }//end legalEffect() +}//end class diff --git a/lib/Service/Case/CasePlanService.php b/lib/Service/Case/CasePlanService.php index 95701f5f3a..7453f02a36 100644 --- a/lib/Service/Case/CasePlanService.php +++ b/lib/Service/Case/CasePlanService.php @@ -585,6 +585,54 @@ public function onRealisationTerminal(string $taskUuid): void { } }//end onRealisationTerminal() + /** + * An enforcing expiry reached its deadline: terminate the item through + * the one transition path, cause `realisation`, the timer as reference, + * then evaluate the plan. An item that is already terminal or gone is + * left alone; a failure is logged, never rethrown into the sweep. + * + * @param string $itemUuid The item. + * @param string $timerUuid The timer that fired. + * + * @return boolean True when the item was terminated. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function onTimerExpired(string $itemUuid, string $timerUuid): bool { + try { + $item = $this->items->findByUuid(uuid: $itemUuid); + } catch (DoesNotExistException) { + return false; + } + + if ($item->isInTerminalState() === true) { + return false; + } + + try { + $this->machine->transition( + item: $item, + to: CaseItem::STATE_TERMINATED, + cause: CaseItemAudit::CAUSE_REALISATION, + causeRef: 'flow-timer:' . $timerUuid, + actor: null, + reason: 'Its statutory term expired.', + tree: $this->treeFor(item: $item) + ); + } catch (Throwable $failure) { + $this->logger->error( + '[CasePlanService] An expired term could not terminate its plan item: ' . $failure->getMessage(), + ['item' => $itemUuid, 'timer' => $timerUuid, 'exception' => $failure] + ); + + return false; + } + + $this->evaluateQuietly(objectUuid: (string)$item->getObjectUuid(), event: null, payload: []); + + return true; + }//end onTimerExpired() + /** * An object changed: evaluate its plan, if it has a live one, with the * event in hand so object on-parts can fire. diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/design.md b/openspec/changes/one-engine-bpmn-and-cmmn/design.md index 20f00492ef..7929e8085b 100644 --- a/openspec/changes/one-engine-bpmn-and-cmmn/design.md +++ b/openspec/changes/one-engine-bpmn-and-cmmn/design.md @@ -91,20 +91,28 @@ Both register through `FlowNodeRegistry` like every other node. ### 3.3 Plan-item timers (gap 3) -`FlowTimer::SUBJECT_TYPES` gains `case-item`. `CasePlanItemTimers` (new): - -- on an item entering `active` with `dueAt` set, arm a `due` timer anchored - on now with `fireAt` at `dueAt` (advisory, `legalEffect: none`); -- with `expiresAt` set, arm an `expiry` timer; legal effect - `servicenorm` unless the plan settings mark the item `wettelijk`, in which - case `onExpiry: terminate` is allowed by the core's own rule; -- on an item reaching a terminal state, `cancelForSubject('case-item', uuid)`; -- a `FlowTimerFiredEvent` of kind expiry for a `case-item` subject with an - enforcing outcome terminates the item with cause `realisation` and the - timer uuid as cause reference. - -The core decides when; the case layer only decides what an expiry means for -an item. Arming failures are logged and never block the transition. +`FlowTimer::SUBJECT_TYPES` gains `case-item`. `CasePlanItemTimers` (new), +driven by `CaseItemTimerListener` on `CaseItemTransitionedEvent` (after +commit) and `FlowTimerFiredEvent`: + +- an item entering `active` with `dueAt` arms a `due` timer (advisory, + `legalEffect: none`). The core measures whole hours (calendar days past + its 10,000-hour bound) from an anchor, so the anchor is set back from the + deadline by the rounded budget: anchor + budget lands on `dueAt` exactly; +- with `expiresAt` it arms an `expiry` timer, legal effect `servicenorm` + (advisory) unless the plan settings list the item key under `statutory`, + in which case it is `wettelijk` with `onExpiry: transition:terminate`, the + core's own rule for an enforcing outcome; +- an item reaching a terminal state: `cancelForSubject('case-item', uuid)`; +- a fired enforcing expiry on a `case-item` subject: + `CasePlanService::onTimerExpired()` terminates the item through the state + machine, cause `realisation`, cause ref `flow-timer:`, then + evaluates the plan. An item already terminal is left alone. + +A deadline already past at activation arms nothing (logged). Arming and +cancelling failures are logged and never block the transition. Items +brought over by `ensureItems` emit no transition event and so get no timers; +their source engine had none. ### 3.4 One state list (gap 4) diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md index 2a5fe00729..0dc5f83f97 100644 --- a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md +++ b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md @@ -17,9 +17,9 @@ Small PRs, in this order. PR 1 is what dossiq's drain waits on. ## 3. Plan-item timers on the shared clock (PR 3) -- [ ] 3.1 `FlowTimer::SUBJECT_TYPES` gains `case-item`. -- [ ] 3.2 `CasePlanItemTimers` (new, own unit test): arm on entering active with `dueAt` / `expiresAt`; cancel on terminal; arming failure logged, never thrown. -- [ ] 3.3 Wire it into `CasePlanStateMachine` transitions; listener for `FlowTimerFiredEvent` (expiry, subject `case-item`) terminates the item; unit tests. +- [x] 3.1 `FlowTimer::SUBJECT_TYPES` gains `case-item`. (CasePlanItemTimersTest::testCaseItemIsATimerSubject) +- [x] 3.2 `CasePlanItemTimers` (new, own unit test): arm on entering active with `dueAt` / `expiresAt`; cancel on terminal; arming failure logged, never thrown. (lib/Service/Case/CasePlanItemTimers.php, CasePlanItemTimersTest) +- [x] 3.3 Wire it through `CaseItemTimerListener` on `CaseItemTransitionedEvent` (no change to the state machine) and `FlowTimerFiredEvent` (expiry, subject `case-item`) -> `CasePlanService::onTimerExpired()` terminates the item; unit tests. (CaseItemTimerListenerTest, CasePlanServiceTest::testAnExpiredTimerTerminatesItsItem) ## 4. Flow nodes that open and advance a case (PR 4) diff --git a/tests/Unit/Listener/CaseItemTimerListenerTest.php b/tests/Unit/Listener/CaseItemTimerListenerTest.php new file mode 100644 index 0000000000..c9669a21f2 --- /dev/null +++ b/tests/Unit/Listener/CaseItemTimerListenerTest.php @@ -0,0 +1,76 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Tests + * @package OCA\OpenRegister\Tests\Unit\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Event\CaseItemTransitionedEvent; +use OCA\OpenRegister\Event\FlowTimerFiredEvent; +use OCA\OpenRegister\Listener\CaseItemTimerListener; +use OCA\OpenRegister\Service\Case\CasePlanItemTimers; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use OCA\OpenRegister\Tests\Unit\Service\Case\CaseFixtures; +use OCP\EventDispatcher\Event; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * Real CasePlanItemTimers over a mocked clock; the plan service is mocked. + */ +class CaseItemTimerListenerTest extends TestCase { + + /** + * Active arms, terminal cancels, an enforcing expiry terminates, anything + * else is ignored. + * + * @return void + */ + public function testTransitionsAndExpiriesReachTheRightVerb(): void { + $clock = $this->createMock(FlowTimerService::class); + $clock->expects($this->once())->method('arm')->willReturn(new FlowTimer()); + $clock->expects($this->once())->method('cancelForSubject')->willReturn(1); + $plans = $this->createMock(CasePlanService::class); + $plans->expects($this->once())->method('onTimerExpired')->with('item-5', 'timer-1')->willReturn(true); + $listener = new CaseItemTimerListener(timers: new CasePlanItemTimers(timers: $clock, logger: new NullLogger()), plans: $plans); + + $active = CaseFixtures::row(id: 5, key: 'beoordeling', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $active->setDueAt(new \DateTime('+2 days')); + $listener->handle(new CaseItemTransitionedEvent(item: $active, fromState: CaseItem::STATE_ENABLED)); + + $done = CaseFixtures::row(id: 5, key: 'beoordeling', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_COMPLETED); + $listener->handle(new CaseItemTransitionedEvent(item: $done, fromState: CaseItem::STATE_ACTIVE)); + + $enabled = CaseFixtures::row(id: 6, key: 'x', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ENABLED); + $listener->handle(new CaseItemTransitionedEvent(item: $enabled, fromState: CaseItem::STATE_AVAILABLE)); + + $timer = new FlowTimer(); + $timer->setUuid('timer-1'); + $timer->setSubjectType(CasePlanItemTimers::SUBJECT_TYPE); + $timer->setSubjectUuid('item-5'); + $timer->setPurpose(FlowTimer::PURPOSE_EXPIRY); + $timer->setLegalEffect(FlowTimer::LEGAL_WETTELIJK); + $timer->setOnExpiry(CasePlanItemTimers::ON_EXPIRY); + $listener->handle(new FlowTimerFiredEvent(timer: $timer, kind: FlowTimerFiredEvent::KIND_EXPIRY, transition: 'expiry', rungKey: null, recipients: [], priority: null, message: null)); + $listener->handle(new FlowTimerFiredEvent(timer: $timer, kind: FlowTimerFiredEvent::KIND_RUNG, transition: 'rung', rungKey: 'r1', recipients: [], priority: null, message: null)); + $listener->handle(new Event()); + }//end testTransitionsAndExpiriesReachTheRightVerb() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanItemTimersTest.php b/tests/Unit/Service/Case/CasePlanItemTimersTest.php new file mode 100644 index 0000000000..b005deed4c --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanItemTimersTest.php @@ -0,0 +1,219 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Tests + * @package OCA\OpenRegister\Tests\Unit\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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use DateTime; +use DateTimeImmutable; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Event\FlowTimerFiredEvent; +use OCA\OpenRegister\Service\Case\CasePlanItemTimers; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * The case layer decides what a deadline means for an item; the timer core + * decides when. + */ +class CasePlanItemTimersTest extends TestCase { + + private FlowTimerService&MockObject $timers; + + /** + * Fresh core. + * + * @return void + */ + protected function setUp(): void { + $this->timers = $this->createMock(FlowTimerService::class); + }//end setUp() + + /** + * The service. + * + * @return CasePlanItemTimers The service. + */ + private function service(): CasePlanItemTimers { + return new CasePlanItemTimers(timers: $this->timers, logger: new NullLogger()); + }//end service() + + /** + * `case-item` is a subject the core accepts. + * + * @return void + */ + public function testCaseItemIsATimerSubject(): void { + $this->assertContains(CasePlanItemTimers::SUBJECT_TYPE, FlowTimer::SUBJECT_TYPES); + $this->assertLessThanOrEqual(16, strlen(CasePlanItemTimers::SUBJECT_TYPE), 'subject_type is a 16-character column.'); + }//end testCaseItemIsATimerSubject() + + /** + * An item entering active with a due date arms an advisory due timer + * that lands exactly on the due date; an expiry on a statutory item arms + * an enforcing one that terminates it; an expiry elsewhere advises. + * + * @return void + */ + public function testArmsDueAndExpiryTimersWhenAnItemBecomesActive(): void { + $now = new DateTimeImmutable('2026-10-10T10:00:00+00:00'); + $item = CaseFixtures::row(id: 5, key: 'beoordeling', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $item->setDueAt(new DateTime('2026-10-12T10:30:00+00:00')); + $item->setExpiresAt(new DateTime('2026-11-10T10:00:00+00:00')); + $item->setPlanSettings(['statutory' => ['beoordeling']]); + + $armed = []; + $this->timers->expects($this->exactly(2))->method('arm')->willReturnCallback( + static function (array $config, ?string $actor) use (&$armed): FlowTimer { + $armed[] = [$config, $actor]; + + return new FlowTimer(); + } + ); + + $this->assertSame(2, $this->service()->onEntered(item: $item, actor: 'alice', now: $now)); + + [$due, $actor] = $armed[0]; + $this->assertSame('alice', $actor); + $this->assertSame(CasePlanItemTimers::SUBJECT_TYPE, $due['subjectType']); + $this->assertSame('item-5', $due['subjectUuid']); + $this->assertSame(FlowTimer::PURPOSE_DUE, $due['purpose']); + $this->assertSame(FlowTimer::LEGAL_NONE, $due['legalEffect']); + $this->assertArrayNotHasKey('onExpiry', $due); + $this->assertSame(['value' => 49, 'unit' => 'hours'], $due['sla']); + $landing = (new DateTimeImmutable($due['anchorEventAt']))->modify('+49 hours'); + $this->assertSame('2026-10-12T10:30:00+00:00', $landing->format('c'), 'The anchor is chosen so the deadline lands on dueAt exactly.'); + + [$expiry] = $armed[1]; + $this->assertSame(FlowTimer::PURPOSE_EXPIRY, $expiry['purpose']); + $this->assertSame(FlowTimer::LEGAL_WETTELIJK, $expiry['legalEffect']); + $this->assertSame(CasePlanItemTimers::ON_EXPIRY, $expiry['onExpiry']); + + $item->setPlanSettings([]); + $armed = []; + $this->setUp(); + $this->timers->method('arm')->willReturnCallback( + static function (array $config) use (&$armed): FlowTimer { + $armed[] = $config; + + return new FlowTimer(); + } + ); + $item->setDueAt(null); + $this->service()->onEntered(item: $item, actor: 'alice', now: $now); + $this->assertSame(FlowTimer::LEGAL_SERVICENORM, $armed[0]['legalEffect']); + $this->assertArrayNotHasKey('onExpiry', $armed[0], 'A non-statutory expiry advises; it never terminates.'); + }//end testArmsDueAndExpiryTimersWhenAnItemBecomesActive() + + /** + * A deadline beyond the hour budget is measured in calendar days; a + * deadline already past, or an item without deadlines, arms nothing; a + * refused arm is logged and never thrown. + * + * @return void + */ + public function testEdgesNeverBlockTheTransition(): void { + $now = new DateTimeImmutable('2026-10-10T10:00:00+00:00'); + $far = CaseFixtures::row(id: 6, key: 'far', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $far->setDueAt(new DateTime('2028-10-10T10:00:00+00:00')); + $configs = []; + $this->timers->method('arm')->willReturnCallback( + static function (array $config) use (&$configs): FlowTimer { + $configs[] = $config; + + return new FlowTimer(); + } + ); + $this->service()->onEntered(item: $far, actor: null, now: $now); + $this->assertSame('calendarDays', $configs[0]['sla']['unit']); + $this->assertSame(731, $configs[0]['sla']['value']); + + $past = CaseFixtures::row(id: 7, key: 'past', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $past->setDueAt(new DateTime('2026-10-09T10:00:00+00:00')); + $this->assertSame(0, $this->service()->onEntered(item: $past, actor: null, now: $now)); + $plain = CaseFixtures::row(id: 8, key: 'plain', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $this->assertSame(0, $this->service()->onEntered(item: $plain, actor: null, now: $now)); + $this->assertCount(1, $configs); + + $this->setUp(); + $this->timers->method('arm')->willThrowException(new RuntimeException('calendar unknown')); + $this->assertSame(0, $this->service()->onEntered(item: $far, actor: null, now: $now)); + }//end testEdgesNeverBlockTheTransition() + + /** + * Reaching a terminal state cancels the item's open timers; a failure to + * cancel is logged, never thrown. + * + * @return void + */ + public function testTerminalCancels(): void { + $item = CaseFixtures::row(id: 5, key: 'beoordeling', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_COMPLETED); + $this->timers->expects($this->once())->method('cancelForSubject') + ->with(CasePlanItemTimers::SUBJECT_TYPE, 'item-5', $this->stringContains('completed'), 'case-plan') + ->willReturn(2); + $this->assertSame(2, $this->service()->onTerminal(item: $item)); + + $this->setUp(); + $this->timers->method('cancelForSubject')->willThrowException(new RuntimeException('db down')); + $this->assertSame(0, $this->service()->onTerminal(item: $item)); + }//end testTerminalCancels() + + /** + * Only an enforcing expiry on a case-item subject terminates an item. + * + * @return void + */ + public function testOnlyAnEnforcingExpiryOnACaseItemTerminates(): void { + $timer = new FlowTimer(); + $timer->setUuid('timer-1'); + $timer->setSubjectType(CasePlanItemTimers::SUBJECT_TYPE); + $timer->setSubjectUuid('item-5'); + $timer->setPurpose(FlowTimer::PURPOSE_EXPIRY); + $timer->setLegalEffect(FlowTimer::LEGAL_WETTELIJK); + $timer->setOnExpiry(CasePlanItemTimers::ON_EXPIRY); + + $fire = static fn (FlowTimer $t, string $kind): FlowTimerFiredEvent => new FlowTimerFiredEvent( + timer: $t, + kind: $kind, + transition: 'expiry', + rungKey: null, + recipients: [], + priority: null, + message: null + ); + + $this->assertSame('item-5', $this->service()->itemToTerminate(event: $fire($timer, FlowTimerFiredEvent::KIND_EXPIRY))); + $this->assertNull($this->service()->itemToTerminate(event: $fire($timer, FlowTimerFiredEvent::KIND_RUNG))); + + $advisory = clone $timer; + $advisory->setLegalEffect(FlowTimer::LEGAL_SERVICENORM); + $advisory->setOnExpiry(null); + $this->assertNull($this->service()->itemToTerminate(event: $fire($advisory, FlowTimerFiredEvent::KIND_EXPIRY))); + + $task = clone $timer; + $task->setSubjectType('task'); + $this->assertNull($this->service()->itemToTerminate(event: $fire($task, FlowTimerFiredEvent::KIND_EXPIRY))); + }//end testOnlyAnEnforcingExpiryOnACaseItemTerminates() +}//end class diff --git a/tests/Unit/Service/Case/CasePlanServiceTest.php b/tests/Unit/Service/Case/CasePlanServiceTest.php index 178d19711c..4a55bf6fd2 100644 --- a/tests/Unit/Service/Case/CasePlanServiceTest.php +++ b/tests/Unit/Service/Case/CasePlanServiceTest.php @@ -611,4 +611,32 @@ public function testEnsureItemsBringsAPlanOverAsTheApp(): void { $this->expectException(CaseValidationException::class); $this->service()->ensureItems(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanEnsurerTest::migrated(), history: [], app: 'Not An App'); }//end testEnsureItemsBringsAPlanOverAsTheApp() + /** + * An enforcing expiry terminates the item through the one transition + * path, with the timer as the cause; an item already terminal, or gone, + * is left alone. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * + * @return void + */ + public function testAnExpiredTimerTerminatesItsItem(): void { + $this->items->seed( + [ + CaseFixtures::row(id: 1, key: 'intake', type: CaseItem::TYPE_STAGE, state: CaseItem::STATE_ACTIVE), + CaseFixtures::row(id: 2, key: 'check', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE, parentId: 1), + CaseFixtures::row(id: 3, key: 'done', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_COMPLETED, parentId: 1), + ] + ); + + $this->assertTrue($this->service()->onTimerExpired(itemUuid: 'item-2', timerUuid: 'timer-9')); + $this->assertSame(CaseItem::STATE_TERMINATED, $this->items->findByUuid(uuid: 'item-2')->getState()); + $last = $this->audits->findForItem(2)[count($this->audits->findForItem(2)) - 1]; + $this->assertSame(CaseItemAudit::CAUSE_REALISATION, $last->getCause()); + $this->assertSame('flow-timer:timer-9', $last->getCauseRef()); + + $this->assertFalse($this->service()->onTimerExpired(itemUuid: 'item-3', timerUuid: 'timer-9'), 'A terminal item is left alone.'); + $this->assertSame(CaseItem::STATE_COMPLETED, $this->items->findByUuid(uuid: 'item-3')->getState()); + $this->assertFalse($this->service()->onTimerExpired(itemUuid: 'item-404', timerUuid: 'timer-9'), 'A vanished item is not an error.'); + }//end testAnExpiredTimerTerminatesItsItem() }//end class From bd29014a221fb253a0439ebbc5d391a9da6356ab Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 11:53:02 +0200 Subject: [PATCH 016/183] fix(flow): a completed task wakes its run with the outcome bag, not an empty payload FlowTaskBridge::continueRun() signalled the run with []. FlowRunService::signal() assigns the payload outright, so when another listener on TaskTerminalEvent had already signalled the run with its own payload (dossiq's ask step sends who answered), the empty one erased it, and the node read the empty signal as a missed wake. The signal now carries outcomeBagFor(task), the same bag the items get, completedBy included. Asked by dossiq remove-casetask task 7.2. --- lib/Service/Flow/FlowTaskBridge.php | 13 +++++--- .../Unit/Service/Flow/FlowTaskBridgeTest.php | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lib/Service/Flow/FlowTaskBridge.php b/lib/Service/Flow/FlowTaskBridge.php index b5592d9179..82c46d4d9a 100644 --- a/lib/Service/Flow/FlowTaskBridge.php +++ b/lib/Service/Flow/FlowTaskBridge.php @@ -10,8 +10,9 @@ * the vocabulary. * * BACKWARD: when a task the graph raised reaches a terminal state, this class - * wakes the run (D-2: `signal()` with an EMPTY payload, because the answer is - * in the task row, not in transit) and spends the node's `advance` budget by + * wakes the run (D-2: the answer is in the task row, not in transit; the + * signal carries the task's outcome bag so it cannot erase what another + * listener on the same event signalled) and spends the node's `advance` budget by * calling the SAME advance path the worker and a synchronous run use * ({@see FlowRunAdvancer::advance()}). There is no second walk implementation: * the budget travels as a per-walk ceiling on the run context and the engine's @@ -323,7 +324,11 @@ public static function outcomeBagFor(Task $task): array { * * Three steps, each of which may legitimately do nothing: * - * 1. `signal()` with an empty payload parks the run as due. Null means the + * 1. `signal()` with the task's outcome bag parks the run as due. It used + * to send an empty payload, and `signal()` assigns the payload outright, + * so a second listener's payload (dossiq's ask step sends who answered) + * was erased whenever this one ran last. The bag carries the same + * fields the items get, `completedBy` included. 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, @@ -361,7 +366,7 @@ public function continueRun(Task $task): ?FlowRun { } $runService = $this->container->get(FlowRunService::class); - $woken = $runService->signal(run: $run, payload: []); + $woken = $runService->signal(run: $run, payload: self::outcomeBagFor(task: $task)); if ($woken === null) { return null; } diff --git a/tests/Unit/Service/Flow/FlowTaskBridgeTest.php b/tests/Unit/Service/Flow/FlowTaskBridgeTest.php index ace75fa33c..22e6b66435 100644 --- a/tests/Unit/Service/Flow/FlowTaskBridgeTest.php +++ b/tests/Unit/Service/Flow/FlowTaskBridgeTest.php @@ -201,6 +201,38 @@ public function testTheDefaultBudgetParksForTheWorker(): void { $this->assertNotNull($after->getResumeAt(), 'the run must be due for the worker'); }//end testTheDefaultBudgetParksForTheWorker() + /** + * The wake carries the task's outcome bag, never an empty payload. + * + * A second listener on the same terminal event may signal the run with its + * own payload (dossiq's ask step sends who answered). `signal()` assigns the + * payload outright, so an empty one sent here erased that, and the node read + * the empty signal as a missed wake. The bag is what `outcomeBagFor()` + * already assembles for the items, `completedBy` included. + */ + public function testTheWakeCarriesTheOutcomeBagNotAnEmptyPayload(): void { + $run = $this->suspendedRun(advance: null); + $this->runs->method('findByUuid')->willReturn($run); + $task = $this->terminalTask(); + $task->setCompletedBy('anna'); + $payloads = []; + $this->runService->method('signal')->willReturnCallback( + static function (FlowRun $run, array $payload = []) use (&$payloads): FlowRun { + $payloads[] = $payload; + $run->setResumeAt(new DateTime()); + + return $run; + } + ); + + $this->bridge->continueRun(task: $task); + + $this->assertCount(1, $payloads); + $this->assertNotSame([], $payloads[0], 'an empty payload overwrites what another listener signalled'); + $this->assertSame(FlowTaskBridge::outcomeBagFor($task), $payloads[0]); + $this->assertSame('anna', $payloads[0]['completedBy']); + }//end testTheWakeCarriesTheOutcomeBagNotAnEmptyPayload() + /** * A budget of N continues THE STREAM parked on the node, for N + 1 * firings (the node's own re-entry is the completion landing), through From 075935d06d44f11b71a9dc6b849e194dc210b0fe Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 11:58:16 +0200 Subject: [PATCH 017/183] feat(flows): a flow can open a case or move one of its plan items Two built-in steps, openregister.case-open and openregister.case-advance, act on the object the step receives as the run's identity through the same CasePlanService verbs any caller uses. Opening an object that already has a plan is reported on the item (CasePlanExistsException), not raised; any refused move fails the step with the engine's own message. Change one-engine-bpmn-and-cmmn, tasks 4.1-4.3. --- lib/Exception/CasePlanExistsException.php | 34 +++ lib/Listener/FlowNodeRegistrationListener.php | 11 + lib/Service/Case/CasePlanService.php | 5 +- lib/Service/Flow/Nodes/CaseAdvanceNode.php | 231 ++++++++++++++ lib/Service/Flow/Nodes/CaseNodeBase.php | 215 +++++++++++++ lib/Service/Flow/Nodes/CaseOpenNode.php | 202 +++++++++++++ .../one-engine-bpmn-and-cmmn/design.md | 24 +- .../changes/one-engine-bpmn-and-cmmn/tasks.md | 6 +- .../FlowNodeRegistrationListenerTest.php | 8 +- .../Unit/Service/Case/CasePlanServiceTest.php | 1 + .../Flow/FlowNodeDeclaredTaxonomyTest.php | 5 + .../Service/Flow/Nodes/CaseFlowNodesTest.php | 282 ++++++++++++++++++ 12 files changed, 1009 insertions(+), 15 deletions(-) create mode 100644 lib/Exception/CasePlanExistsException.php create mode 100644 lib/Service/Flow/Nodes/CaseAdvanceNode.php create mode 100644 lib/Service/Flow/Nodes/CaseNodeBase.php create mode 100644 lib/Service/Flow/Nodes/CaseOpenNode.php create mode 100644 tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php diff --git a/lib/Exception/CasePlanExistsException.php b/lib/Exception/CasePlanExistsException.php new file mode 100644 index 0000000000..5c0152f0fe --- /dev/null +++ b/lib/Exception/CasePlanExistsException.php @@ -0,0 +1,34 @@ + + * 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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +/** + * The object already has a case plan. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ +class CasePlanExistsException extends CaseValidationException { +}//end class diff --git a/lib/Listener/FlowNodeRegistrationListener.php b/lib/Listener/FlowNodeRegistrationListener.php index 4d32a6fa97..a03ac343ec 100644 --- a/lib/Listener/FlowNodeRegistrationListener.php +++ b/lib/Listener/FlowNodeRegistrationListener.php @@ -53,6 +53,8 @@ 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\CaseAdvanceNode; +use OCA\OpenRegister\Service\Flow\Nodes\CaseOpenNode; use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; use OCA\OpenRegister\Service\Flow\Nodes\WaitNode; use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; @@ -95,6 +97,8 @@ class FlowNodeRegistrationListener implements IEventListener { * @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. + * @param CaseOpenNode $caseOpen The built-in "Open a case" node. + * @param CaseAdvanceNode $caseAdvance The built-in "Advance a case" node. */ public function __construct( private readonly SetFieldsNode $setFields, @@ -124,6 +128,8 @@ public function __construct( private readonly UserTaskNode $userTask, private readonly PortalTaskNode $portalTask, private readonly DecisionTableNode $decisionTable, + private readonly CaseOpenNode $caseOpen, + private readonly CaseAdvanceNode $caseAdvance, ) { }//end __construct() @@ -195,6 +201,11 @@ public function handle(Event $event): void { $event->registerNode(node: $this->lockObject); $event->registerNode(node: $this->unlockObject); + // The flow engine touching the case layer (one-engine-bpmn-and-cmmn): + // a process opens a case plan on its object, or moves one plan item. + $event->registerNode(node: $this->caseOpen); + $event->registerNode(node: $this->caseAdvance); + // 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/Service/Case/CasePlanService.php b/lib/Service/Case/CasePlanService.php index 7453f02a36..8180544229 100644 --- a/lib/Service/Case/CasePlanService.php +++ b/lib/Service/Case/CasePlanService.php @@ -39,6 +39,7 @@ use OCA\OpenRegister\Db\CaseItemAuditMapper; use OCA\OpenRegister\Db\CaseItemMapper; use OCA\OpenRegister\Exception\CaseAccessDeniedException; +use OCA\OpenRegister\Exception\CasePlanExistsException; use OCA\OpenRegister\Exception\CaseValidationException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IDBConnection; @@ -717,13 +718,13 @@ private function planOf(string $objectUuid, array $rows): array { * * @return void * - * @throws CaseValidationException When the object already has a plan. + * @throws CasePlanExistsException When the object already has a plan. * * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-state-is-stored-as-rows-never-as-an-encoded-blob */ private function insertPlan(string $objectUuid, ?int $registerId, ?int $schemaId, array $normalised, ?string $actor, array $provenance): void { if ($this->items->findByObject(objectUuid: $objectUuid) !== []) { - throw new CaseValidationException(message: sprintf('Object %s already has a case plan; delete it before creating another.', $objectUuid)); + throw new CasePlanExistsException(message: sprintf('Object %s already has a case plan; delete it before creating another.', $objectUuid)); } $this->transactional( diff --git a/lib/Service/Flow/Nodes/CaseAdvanceNode.php b/lib/Service/Flow/Nodes/CaseAdvanceNode.php new file mode 100644 index 0000000000..0d7ca450ee --- /dev/null +++ b/lib/Service/Flow/Nodes/CaseAdvanceNode.php @@ -0,0 +1,231 @@ + + * 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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Service\Flow\FlowItems; +use RuntimeException; +use UnexpectedValueException; + +/** + * The `openregister.case-advance` step. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ +class CaseAdvanceNode extends CaseNodeBase { + + /** + * The step type this node answers to. + * + * @var string + */ + public const TYPE = 'openregister.case-advance'; + + /** + * The step type. + * + * @return string The node id. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function getId(): string { + return self::TYPE; + }//end getId() + + /** + * The palette label. + * + * @return string The display name. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function getDisplayName(): string { + return $this->l10n->t('Advance a case'); + }//end getDisplayName() + + /** + * The palette description. + * + * @return string The description. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function getDescription(): string { + return $this->l10n->t( + 'Move one item of the case plan on the object this step receives, such as a milestone. A move the plan does not allow stops the step.' + ); + }//end getDescription() + + /** + * Top-level configuration keys. + * + * @return array The keys. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function configKeys(): array { + return ['item', 'to', 'reason', 'uuid']; + }//end configKeys() + + /** + * The editor form. + * + * @return array> The fields. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function configForm(): array { + return [ + [ + 'key' => 'item', + 'label' => $this->l10n->t('Plan item'), + 'type' => 'text', + 'required' => true, + 'help' => $this->l10n->t('The key of the stage, task or milestone to move.'), + ], + [ + 'key' => 'to', + 'label' => $this->l10n->t('New state'), + 'type' => 'select', + 'required' => true, + 'options' => CaseItem::STATES, + ], + [ + 'key' => 'reason', + 'label' => $this->l10n->t('Reason'), + 'type' => 'text', + ], + [ + 'key' => 'uuid', + 'label' => $this->l10n->t('Object'), + 'type' => 'text', + 'help' => $this->l10n->t('Which object the case is. Leave empty to use the object the step receives.'), + ], + ]; + }//end configForm() + + /** + * An advance step names an item and one of the six states. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When it does not. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function validateConfig(array $config): void { + if (trim((string)($config['item'] ?? '')) === '') { + throw new UnexpectedValueException($this->l10n->t('An advance-a-case step needs the key of the plan item to move.')); + } + + if (in_array(($config['to'] ?? null), CaseItem::STATES, true) === false) { + throw new UnexpectedValueException( + $this->l10n->t('An advance-a-case step needs a new state: one of %s.', [implode(', ', CaseItem::STATES)]) + ); + } + }//end validateConfig() + + /** + * Move the item on each distinct object. + * + * @param array $items The incoming items. + * @param array $config The step configuration. + * @param array $context The run context. + * + * @return array The items, each carrying `case` = {item, state}. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function execute(array $items, array $config, array $context): array { + if ($items === []) { + return $items; + } + + $this->validateConfig(config: $config); + $uid = $this->actingUid(context: $context); + $key = trim((string)$config['item']); + $reason = null; + if (is_string(($config['reason'] ?? null)) === true && trim($config['reason']) !== '') { + $reason = trim($config['reason']); + } + + $results = []; + foreach (array_keys($this->targets(items: $items, config: $config)) as $uuid) { + $results[$uuid] = $this->scope->call( + context: $context, + operation: fn (): array => $this->advance(objectUuid: (string)$uuid, key: $key, to: (string)$config['to'], uid: $uid, reason: $reason) + ); + } + + $out = []; + foreach ($items as $index => $item) { + $json = ($item[FlowItems::JSON] ?? []); + $json['case'] = $results[(string)array_key_first($this->targets(items: [$item], config: $config))]; + $item[FlowItems::JSON] = $json; + $out[$index] = $item; + } + + return $out; + }//end execute() + + /** + * Find the item by key and transition it. + * + * @param string $objectUuid The object. + * @param string $key The plan-item key. + * @param string $to The target state. + * @param string $uid The acting identity. + * @param string|null $reason Free text. + * + * @return array{item: string, state: string} The item and its state afterwards. + * + * @throws RuntimeException When the plan has no such item. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + private function advance(string $objectUuid, string $key, string $to, string $uid, ?string $reason): array { + $plan = $this->plans->getPlan(objectUuid: $objectUuid, uid: $uid); + foreach (($plan['items'] ?? []) as $row) { + if (($row['key'] ?? null) !== $key) { + continue; + } + + $moved = $this->plans->transition(itemUuid: (string)$row['uuid'], to: $to, uid: $uid, reason: $reason); + + return ['item' => $key, 'state' => (string)$moved->getState()]; + } + + throw new RuntimeException( + $this->l10n->t('The case plan on object %1$s has no item "%2$s".', [$objectUuid, $key]) + ); + }//end advance() +}//end class diff --git a/lib/Service/Flow/Nodes/CaseNodeBase.php b/lib/Service/Flow/Nodes/CaseNodeBase.php new file mode 100644 index 0000000000..29806aaaa5 --- /dev/null +++ b/lib/Service/Flow/Nodes/CaseNodeBase.php @@ -0,0 +1,215 @@ + + * 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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowRunAsScope; +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\Flow\IFlowNodeTaxonomy; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\WorkflowEngine\IManager; +use RuntimeException; + +/** + * Shared resolution for the case steps. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ +abstract class CaseNodeBase implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm, IFlowNodeTaxonomy { + + /** + * Constructor. + * + * @param CasePlanService $plans The case layer's verbs. + * @param FlowRunAsScope $scope Runs the work as the run's identity. + * @param IUserSession $session The interactive user, when the run names nobody. + * @param IL10N $l10n Translations. + * @param IURLGenerator $urls For the palette icon. + */ + public function __construct( + protected readonly CasePlanService $plans, + protected readonly FlowRunAsScope $scope, + protected readonly IUserSession $session, + protected readonly IL10N $l10n, + protected readonly IURLGenerator $urls, + ) { + + }//end __construct() + + /** + * The palette icon. + * + * @return string The icon path. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function getIcon(): string { + return $this->urls->imagePath('core', 'actions/checkmark.svg'); + }//end getIcon() + + /** + * Available in both flow scopes. + * + * @param int $scope The workflow engine scope. + * + * @return bool True when available. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function isAvailableForScope(int $scope): bool { + return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); + }//end isAvailableForScope() + + /** + * A case step calls the register's case layer. + * + * @return string The BPMN kind. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function getKind(): string { + return IFlowNodeTaxonomy::KIND_SERVICE_TASK; + }//end getKind() + + /** + * Listed with the object steps: a case IS an object. + * + * @return string The palette category. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function getCategory(): string { + return IFlowNodeTaxonomy::CATEGORY_OBJECTS; + }//end getCategory() + + /** + * The identity the case verb acts as. + * + * @param array $context The run context. + * + * @return string The uid. + * + * @throws RuntimeException When nobody can be named. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + protected function actingUid(array $context): string { + $uid = trim((string)($context[FlowRunService::RUN_AS_CONTEXT_KEY] ?? '')); + if ($uid === '') { + $uid = trim((string)$this->session->getUser()?->getUID()); + } + + if ($uid === '') { + throw new RuntimeException( + $this->l10n->t('A case step must act as someone, and this run names nobody (runAs) and has no signed-in user.') + ); + } + + return $uid; + }//end actingUid() + + /** + * The distinct objects the step acts on, with the register and schema + * each names in its `@self` block, when numeric. + * + * @param array $items The incoming items. + * @param array $config The step configuration. + * + * @return array By uuid. + * + * @throws RuntimeException When an item names no object. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate is the engine's + * stateless template renderer and every node calls it this way. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + protected function targets(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'] ?? ($json['id'] ?? ''))); + if (is_string($configured) === true && trim($configured) !== '') { + $uuid = trim((string)FlowValueTemplate::render($configured, $json)); + } + + if ($uuid === '') { + throw new RuntimeException($this->l10n->t('A case step could not work out which object the case is from the item it received.')); + } + + $self = ($json['@self'] ?? []); + if (is_array($self) === false) { + $self = []; + } + + $targets[$uuid] = [ + 'uuid' => $uuid, + 'register' => $this->intOrNull(value: ($self['register'] ?? null)), + 'schema' => $this->intOrNull(value: ($self['schema'] ?? null)), + ]; + }//end foreach + + return $targets; + }//end targets() + + /** + * A numeric id, or null. + * + * @param mixed $value The value. + * + * @return int|null The id. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + private function intOrNull(mixed $value): ?int { + if (is_int($value) === true) { + return $value; + } + + if (is_string($value) === true && ctype_digit($value) === true) { + return (int)$value; + } + + return null; + }//end intOrNull() +}//end class diff --git a/lib/Service/Flow/Nodes/CaseOpenNode.php b/lib/Service/Flow/Nodes/CaseOpenNode.php new file mode 100644 index 0000000000..40c08dacce --- /dev/null +++ b/lib/Service/Flow/Nodes/CaseOpenNode.php @@ -0,0 +1,202 @@ + + * 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/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow\Nodes; + +use OCA\OpenRegister\Exception\CasePlanExistsException; +use OCA\OpenRegister\Service\Flow\FlowItems; +use UnexpectedValueException; + +/** + * The `openregister.case-open` step. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ +class CaseOpenNode extends CaseNodeBase { + + /** + * The step type this node answers to. + * + * @var string + */ + public const TYPE = 'openregister.case-open'; + + /** + * The step type. + * + * @return string The node id. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function getId(): string { + return self::TYPE; + }//end getId() + + /** + * The palette label. + * + * @return string The display name. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function getDisplayName(): string { + return $this->l10n->t('Open a case'); + }//end getDisplayName() + + /** + * The palette description. + * + * @return string The description. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function getDescription(): string { + return $this->l10n->t( + 'Start a case plan on the object this step receives. If the object already has one, the step says so and carries on.' + ); + }//end getDescription() + + /** + * Top-level configuration keys. + * + * @return array The keys. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function configKeys(): array { + return ['definition', 'uuid']; + }//end configKeys() + + /** + * The editor form. + * + * @return array> The fields. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function configForm(): array { + return [ + [ + 'key' => 'definition', + 'label' => $this->l10n->t('Case plan'), + 'type' => 'json', + 'required' => true, + 'help' => $this->l10n->t('The plan to start: settings and items, the same shape the case plan API takes.'), + ], + [ + 'key' => 'uuid', + 'label' => $this->l10n->t('Object'), + 'type' => 'text', + 'help' => $this->l10n->t('Which object the case is. Leave empty to use the object the step receives.'), + ], + ]; + }//end configForm() + + /** + * A case-open step needs a plan with items. + * + * @param array $config The step configuration. + * + * @return void + * + * @throws UnexpectedValueException When it has none. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function validateConfig(array $config): void { + $definition = ($config['definition'] ?? null); + if (is_array($definition) === false || is_array(($definition['items'] ?? null)) === false || $definition['items'] === []) { + throw new UnexpectedValueException($this->l10n->t('An open-a-case step needs a case plan with at least one item.')); + } + }//end validateConfig() + + /** + * Open the case on each distinct object. + * + * @param array $items The incoming items. + * @param array $config The step configuration. + * @param array $context The run context. + * + * @return array The items, each carrying `case` = {opened, reason}. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + public function execute(array $items, array $config, array $context): array { + if ($items === []) { + return $items; + } + + $this->validateConfig(config: $config); + $uid = $this->actingUid(context: $context); + $results = []; + foreach ($this->targets(items: $items, config: $config) as $uuid => $target) { + $results[$uuid] = $this->scope->call( + context: $context, + operation: fn (): array => $this->open(target: $target, definition: $config['definition'], uid: $uid) + ); + } + + $out = []; + foreach ($items as $index => $item) { + $json = ($item[FlowItems::JSON] ?? []); + $targets = $this->targets(items: [$item], config: $config); + $json['case'] = $results[(string)array_key_first($targets)]; + $item[FlowItems::JSON] = $json; + $out[$index] = $item; + } + + return $out; + }//end execute() + + /** + * Create the plan, or report that one exists. + * + * @param array{uuid: string, register: int|null, schema: int|null} $target The object. + * @param array $definition The plan. + * @param string $uid The acting identity. + * + * @return array{opened: bool, reason: string} What happened. + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + private function open(array $target, array $definition, string $uid): array { + try { + $this->plans->createPlan( + objectUuid: $target['uuid'], + registerId: $target['register'], + schemaId: $target['schema'], + definition: $definition, + uid: $uid + ); + } catch (CasePlanExistsException) { + return ['opened' => false, 'reason' => 'exists']; + } + + return ['opened' => true, 'reason' => 'opened']; + }//end open() +}//end class diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/design.md b/openspec/changes/one-engine-bpmn-and-cmmn/design.md index 7929e8085b..787e808d25 100644 --- a/openspec/changes/one-engine-bpmn-and-cmmn/design.md +++ b/openspec/changes/one-engine-bpmn-and-cmmn/design.md @@ -77,14 +77,22 @@ children. ### 3.2 Flow nodes (gap 2) -- `openregister.case-open`: config `{definition}` (the same shape - `createPlan` takes). Creates the plan on the run's subject through - `createPlan`, acting as the run's attributed identity. An object that - already has a plan is not an error for the node: it reports - `{opened: false, reason: "exists"}` on its output, so a re-run is safe. -- `openregister.case-advance`: config `{item, to, reason?}` where `item` is - the plan-item key. Resolves the item on the run's subject and calls - `transition`. Refusals (illegal edge, authorization) fail the step with the +Both act on the object the step receives (its `uuid`, or a configured +`uuid` template), with the register and schema its `@self` block names, as +the run's acting identity (`runAs`, else the signed-in user; neither is a +refusal), inside `FlowRunAsScope` so the case layer's reads are checked +against that identity. Shared resolution lives in the abstract +`CaseNodeBase`. + +- `openregister.case-open`: config `{definition, uuid?}` (the same shape + `createPlan` takes). Calls `createPlan`. An object that already has a plan + is reported on the item (`case: {opened: false, reason: "exists"}`) via a + new `CasePlanExistsException` (a `CaseValidationException`, so the API's + 400 is unchanged), so a re-run is safe. +- `openregister.case-advance`: config `{item, to, reason?, uuid?}` where + `item` is the plan-item key. Reads the plan with `getPlan`, finds the key, + calls `transition`; the item carries `case: {item, state}`. Refusals + (illegal edge, authorization, no plan, unknown key) fail the step with the engine's message; they are never swallowed. Both register through `FlowNodeRegistry` like every other node. diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md index 0dc5f83f97..c799c20fb1 100644 --- a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md +++ b/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md @@ -23,9 +23,9 @@ Small PRs, in this order. PR 1 is what dossiq's drain waits on. ## 4. Flow nodes that open and advance a case (PR 4) -- [ ] 4.1 `CaseOpenNode` (`openregister.case-open`), own unit test: creates on the run's subject as the run's identity; existing plan reported on output. -- [ ] 4.2 `CaseAdvanceNode` (`openregister.case-advance`), own unit test: transitions by key; refusal fails the step with the engine's message. -- [ ] 4.3 Register both in `FlowNodeRegistry`; node catalogue labels in every shipped locale. +- [x] 4.1 `CaseOpenNode` (`openregister.case-open`), own unit test: creates on the run's subject as the run's identity; existing plan reported on output (`CasePlanExistsException`). (lib/Service/Flow/Nodes/CaseOpenNode.php, CaseNodeBase.php, CaseFlowNodesTest) +- [x] 4.2 `CaseAdvanceNode` (`openregister.case-advance`), own unit test: transitions by key; refusal fails the step with the engine's message. (lib/Service/Flow/Nodes/CaseAdvanceNode.php, CaseFlowNodesTest) +- [x] 4.3 Register both through `FlowNodeRegistrationListener` (FlowNodeRegistrationListenerTest: 29 built-ins) and the declared taxonomy table (FlowNodeDeclaredTaxonomyTest). Palette strings follow every other node: through IL10N, not in the backend catalogue (no built-in node's strings are in `l10n/en.json`; adding them would oblige all required locales at once). ## 5. Close diff --git a/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php b/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php index bdb3578c33..47f0902882 100644 --- a/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php +++ b/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php @@ -47,6 +47,8 @@ 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\CaseAdvanceNode; +use OCA\OpenRegister\Service\Flow\Nodes\CaseOpenNode; use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; use OCA\OpenRegister\Service\Flow\Nodes\WaitNode; use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; @@ -98,6 +100,8 @@ private function listener(array &$registered): FlowNodeRegistrationListener { userTask: $mock(UserTaskNode::class), portalTask: $mock(PortalTaskNode::class), decisionTable: $mock(DecisionTableNode::class), + caseOpen: $mock(CaseOpenNode::class), + caseAdvance: $mock(CaseAdvanceNode::class), ); return $listener; @@ -122,9 +126,9 @@ static function (IFlowNode $node) use (&$registered): void { $listener->handle(new RegisterFlowNodesEvent(registry: $registry)); - $this->assertCount(27, $registered, 'all twenty-seven built-ins are registered'); + $this->assertCount(29, $registered, 'all twenty-nine built-ins are registered'); $classes = array_map(static fn (IFlowNode $node): string => get_parent_class($node) ?: get_class($node), $registered); - foreach ([PortalTaskNode::class, UserTaskNode::class, AwaitSignalNode::class] as $waiter) { + foreach ([PortalTaskNode::class, UserTaskNode::class, AwaitSignalNode::class, CaseOpenNode::class, CaseAdvanceNode::class] as $waiter) { $this->assertContains($waiter, $classes, "$waiter is registered"); } }//end testEveryBuiltInReachesTheRegistryPortalTaskIncluded() diff --git a/tests/Unit/Service/Case/CasePlanServiceTest.php b/tests/Unit/Service/Case/CasePlanServiceTest.php index 4a55bf6fd2..c67dc361ef 100644 --- a/tests/Unit/Service/Case/CasePlanServiceTest.php +++ b/tests/Unit/Service/Case/CasePlanServiceTest.php @@ -224,6 +224,7 @@ public function testCreateEntersThePlanAndReadsAreVisibilityGated(): void { $this->service()->createPlan(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: CasePlanDefinitionTest::permitDefinition(), uid: 'alice'); $this->fail('one plan per object'); } catch (CaseValidationException $refusal) { + $this->assertInstanceOf(\OCA\OpenRegister\Exception\CasePlanExistsException::class, $refusal, 'An existing plan is told apart by type.'); $this->assertStringContainsString('already has a case plan', $refusal->getMessage()); } diff --git a/tests/Unit/Service/Flow/FlowNodeDeclaredTaxonomyTest.php b/tests/Unit/Service/Flow/FlowNodeDeclaredTaxonomyTest.php index b9c86b8266..1bd9864711 100644 --- a/tests/Unit/Service/Flow/FlowNodeDeclaredTaxonomyTest.php +++ b/tests/Unit/Service/Flow/FlowNodeDeclaredTaxonomyTest.php @@ -75,6 +75,8 @@ * @covers \OCA\OpenRegister\Service\Flow\Nodes\TriggerObjectNode * @covers \OCA\OpenRegister\Service\Flow\Nodes\TriggerScheduleNode * @covers \OCA\OpenRegister\Service\Flow\Nodes\UnlockObjectNode + * @covers \OCA\OpenRegister\Service\Flow\Nodes\CaseOpenNode + * @covers \OCA\OpenRegister\Service\Flow\Nodes\CaseAdvanceNode * @covers \OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode * @covers \OCA\OpenRegister\Service\Flow\Nodes\WaitNode */ @@ -112,6 +114,9 @@ private function expected(): array { 'ObjectWriteNode' => ['serviceTask', 'objects'], 'LockObjectNode' => ['serviceTask', 'objects'], 'UnlockObjectNode' => ['serviceTask', 'objects'], + // The flow engine touching the case layer (one-engine-bpmn-and-cmmn). + 'CaseOpenNode' => ['serviceTask', 'objects'], + 'CaseAdvanceNode' => ['serviceTask', 'objects'], 'LoopNode' => ['serviceTask', 'objects'], // Logic. Branches and joins are gateways; reshaping is a scriptTask. diff --git a/tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php b/tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php new file mode 100644 index 0000000000..5435f85e11 --- /dev/null +++ b/tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php @@ -0,0 +1,282 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Tests + * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Nodes + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Nodes; + +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Exception\CaseAccessDeniedException; +use OCA\OpenRegister\Exception\CaseTransitionException; +use OCA\OpenRegister\Service\Case\CaseAnchorReader; +use OCA\OpenRegister\Service\Case\CaseBusinessStateWriter; +use OCA\OpenRegister\Service\Case\CasePlanAuthorizationService; +use OCA\OpenRegister\Service\Case\CasePlanCascade; +use OCA\OpenRegister\Service\Case\CasePlanDefinition; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCA\OpenRegister\Service\Case\CasePlanStateMachine; +use OCA\OpenRegister\Service\Case\CasePlanTransitions; +use OCA\OpenRegister\Service\Case\CaseRealisationService; +use OCA\OpenRegister\Service\Case\CaseSentryEvaluator; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowRunAsScope; +use OCA\OpenRegister\Service\Flow\FlowRunService; +use OCA\OpenRegister\Service\Flow\IFlowNodeTaxonomy; +use OCA\OpenRegister\Service\Flow\Nodes\CaseAdvanceNode; +use OCA\OpenRegister\Service\Flow\Nodes\CaseOpenNode; +use OCA\OpenRegister\Tests\Unit\Service\Case\CaseFixtures; +use OCA\OpenRegister\Tests\Unit\Service\Case\FakeCaseItemMapper; +use OCA\OpenRegister\Tests\Unit\Service\Case\RecordingAuditMapper; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; +use OCP\WorkflowEngine\IManager; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; +use UnexpectedValueException; + +/** + * Real CasePlanService over in-memory rows; the run-as scope runs the work. + */ +class CaseFlowNodesTest extends TestCase { + + private FakeCaseItemMapper $items; + + private RecordingAuditMapper $audits; + + private ?string $sessionUid = null; + + /** + * Fresh tables. + * + * @return void + */ + protected function setUp(): void { + $this->items = new FakeCaseItemMapper($this); + $this->audits = new RecordingAuditMapper($this); + $this->sessionUid = null; + }//end setUp() + + /** + * The real case layer, with alice a caseworker and nobody an admin. + * + * @return CasePlanService The service. + */ + private function plans(): CasePlanService { + $db = $this->createMock(IDBConnection::class); + $db->method('inTransaction')->willReturn(false); + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturn(false); + $groups->method('isInGroup')->willReturnCallback(static fn (string $uid, string $gid): bool => $uid === 'alice' && $gid === 'demo-behandelaars'); + $groups->method('groupExists')->willReturn(true); + $realiser = $this->createMock(CaseRealisationService::class); + $realiser->method('realise')->willReturnCallback(static function (CaseItem $row): void { + $row->setRealisationKind(CaseItem::REALISATION_NONE); + }); + $anchor = $this->createMock(CaseAnchorReader::class); + $anchor->method('read')->willReturn([]); + $anchor->method('mayRead')->willReturn(true); + $writer = $this->createMock(CaseBusinessStateWriter::class); + $sentries = new CaseSentryEvaluator(catalog: new EventCatalogService()); + $machine = new CasePlanStateMachine(items: $this->items, audits: $this->audits, table: new CasePlanTransitions(), realiser: $realiser, writer: $writer, db: $db, logger: new NullLogger()); + + return new CasePlanService( + items: $this->items, + audits: $this->audits, + machine: $machine, + cascade: new CasePlanCascade(items: $this->items, machine: $machine, sentries: $sentries, realiser: $realiser, anchor: $anchor, db: $db, logger: new NullLogger()), + sentries: $sentries, + authorization: new CasePlanAuthorizationService(groupManager: $groups), + anchor: $anchor, + writer: $writer, + definitions: new CasePlanDefinition(sentries: $sentries), + db: $db, + logger: new NullLogger() + ); + }//end plans() + + /** + * The collaborators every case node takes. + * + * @return array Named constructor arguments. + */ + private function collaborators(): array { + $scope = $this->createMock(FlowRunAsScope::class); + $scope->method('call')->willReturnCallback(static fn (array $context, callable $operation): mixed => $operation()); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturnCallback( + function (): ?IUser { + if ($this->sessionUid === null) { + return null; + } + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($this->sessionUid); + + return $user; + } + ); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text, array $params = []): string => vsprintf($text, $params)); + $urls = $this->createMock(IURLGenerator::class); + $urls->method('imagePath')->willReturn('/icon.svg'); + + return ['plans' => $this->plans(), 'scope' => $scope, 'session' => $session, 'l10n' => $l10n, 'urls' => $urls]; + }//end collaborators() + + /** + * A small permit plan: an intake task and a decision milestone. + * + * @return array The definition. + */ + private static function definition(): array { + return [ + 'settings' => ['authorization' => ['demo-behandelaars']], + 'items' => [ + ['key' => 'intake', 'type' => CaseItem::TYPE_HUMAN_TASK, 'name' => 'Intake'], + [ + 'key' => 'besluit-genomen', + 'type' => CaseItem::TYPE_MILESTONE, + 'name' => 'Besluit genomen', + 'entryCriteria' => [['id' => 'after-intake', 'on' => ['event' => 'case.item.completed', 'item' => 'intake']]], + ], + ], + ]; + }//end definition() + + /** + * The item a run on an object carries. + * + * @return array> One item. + */ + private static function items(): array { + return [[FlowItems::JSON => ['uuid' => CaseFixtures::OBJECT, '@self' => ['register' => '1', 'schema' => 2]]]]; + }//end items() + + /** + * The two nodes describe themselves like every other step. + * + * @return void + */ + public function testTheNodesDescribeThemselves(): void { + $open = new CaseOpenNode(...$this->collaborators()); + $advance = new CaseAdvanceNode(...$this->collaborators()); + $this->assertSame('openregister.case-open', $open->getId()); + $this->assertSame('openregister.case-advance', $advance->getId()); + foreach ([$open, $advance] as $node) { + $this->assertTrue($node->isAvailableForScope(IManager::SCOPE_ADMIN)); + $this->assertSame(IFlowNodeTaxonomy::KIND_SERVICE_TASK, $node->getKind()); + $this->assertSame(IFlowNodeTaxonomy::CATEGORY_OBJECTS, $node->getCategory()); + $this->assertNotSame('', $node->getDisplayName()); + $this->assertNotSame('', $node->getDescription()); + $this->assertSame('/icon.svg', $node->getIcon()); + $this->assertSame(array_column($node->configForm(), 'key'), $node->configKeys()); + } + }//end testTheNodesDescribeThemselves() + + /** + * Opening creates the plan on the received object as the run's identity, + * with the object's register and schema; a second open reports `exists`. + * + * @return void + */ + public function testOpenCreatesThePlanAndASecondOpenSaysSo(): void { + $node = new CaseOpenNode(...$this->collaborators()); + $out = $node->execute(items: self::items(), config: ['definition' => self::definition()], context: [FlowRunService::RUN_AS_CONTEXT_KEY => 'alice']); + + $this->assertSame(['opened' => true, 'reason' => 'opened'], $out[0][FlowItems::JSON]['case']); + $rows = $this->items->findByObject(objectUuid: CaseFixtures::OBJECT); + $this->assertCount(2, $rows); + $this->assertSame(1, $rows[0]->getRegisterId()); + $this->assertSame(2, $rows[0]->getSchemaId()); + $this->assertSame('alice', $this->audits->findForItem((int)$rows[0]->getId())[0]->getActor()); + + $again = $node->execute(items: self::items(), config: ['definition' => self::definition()], context: [FlowRunService::RUN_AS_CONTEXT_KEY => 'alice']); + $this->assertSame(['opened' => false, 'reason' => 'exists'], $again[0][FlowItems::JSON]['case']); + $this->assertCount(2, $this->items->findByObject(objectUuid: CaseFixtures::OBJECT)); + }//end testOpenCreatesThePlanAndASecondOpenSaysSo() + + /** + * Refusals: no plan in the config, nobody to act as, a caller the plan + * does not admit. None is swallowed. + * + * @return void + */ + public function testOpenRefusals(): void { + $node = new CaseOpenNode(...$this->collaborators()); + try { + $node->validateConfig(config: ['definition' => ['items' => []]]); + $this->fail('an empty plan is refused at save time'); + } catch (UnexpectedValueException $refusal) { + $this->assertStringContainsString('at least one item', $refusal->getMessage()); + } + + try { + $node->execute(items: self::items(), config: ['definition' => self::definition()], context: []); + $this->fail('nobody to act as'); + } catch (RuntimeException $refusal) { + $this->assertStringContainsString('names nobody', $refusal->getMessage()); + } + + $this->expectException(CaseAccessDeniedException::class); + $node->execute(items: self::items(), config: ['definition' => self::definition()], context: [FlowRunService::RUN_AS_CONTEXT_KEY => 'mallory']); + }//end testOpenRefusals() + + /** + * Advancing reaches the milestone by key as the interactive user; a + * second advance of a terminal item fails the step with the engine's + * four facts; an unknown key fails naming it. + * + * @return void + */ + public function testAdvanceMovesTheItemAndRefusalsFailTheStep(): void { + $collaborators = $this->collaborators(); + (new CaseOpenNode(...$collaborators))->execute(items: self::items(), config: ['definition' => self::definition()], context: [FlowRunService::RUN_AS_CONTEXT_KEY => 'alice']); + + $this->sessionUid = 'alice'; + $node = new CaseAdvanceNode(...$collaborators); + $config = ['item' => 'besluit-genomen', 'to' => CaseItem::STATE_COMPLETED, 'reason' => 'Besluit verzonden']; + $out = $node->execute(items: self::items(), config: $config, context: []); + $this->assertSame(['item' => 'besluit-genomen', 'state' => 'completed'], $out[0][FlowItems::JSON]['case']); + + try { + $node->execute(items: self::items(), config: $config, context: []); + $this->fail('a terminal milestone accepts nothing further'); + } catch (CaseTransitionException $refusal) { + $this->assertStringContainsString('milestone', $refusal->getMessage()); + $this->assertStringContainsString('completed', $refusal->getMessage()); + } + + try { + $node->execute(items: self::items(), config: ['item' => 'ghost', 'to' => CaseItem::STATE_COMPLETED], context: []); + $this->fail('unknown key'); + } catch (RuntimeException $refusal) { + $this->assertStringContainsString('"ghost"', $refusal->getMessage()); + } + + $this->expectException(UnexpectedValueException::class); + $node->validateConfig(config: ['item' => 'besluit-genomen', 'to' => 'done']); + }//end testAdvanceMovesTheItemAndRefusalsFailTheStep() +}//end class From a6e457fad992ce1cf05804668853bb08d9a92808 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 12:00:08 +0200 Subject: [PATCH 018/183] docs(cases): processes and cases run on one engine; archive one-engine-bpmn-and-cmmn Folds the six requirements into openspec/specs/flow-cases/spec.md, repoints the @spec tags at the main spec, records the change on the auto-case-plan row, and adds docs/features/processes-and-cases.md. --- docs/features/processes-and-cases.md | 36 +++++ lib/Exception/CasePlanExistsException.php | 4 +- lib/Listener/CaseItemTimerListener.php | 6 +- lib/Migration/Version1Date20261010120000.php | 6 +- lib/Service/Case/CasePlanEnsurer.php | 24 +-- lib/Service/Case/CasePlanItemTimers.php | 14 +- lib/Service/Case/CasePlanService.php | 10 +- lib/Service/Flow/Nodes/CaseAdvanceNode.php | 20 +-- lib/Service/Flow/Nodes/CaseNodeBase.php | 18 +-- lib/Service/Flow/Nodes/CaseOpenNode.php | 20 +-- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/flow-cases/spec.md | 0 .../tasks.md | 6 +- openspec/parity/capabilities.json | 5 +- openspec/specs/flow-cases/spec.md | 144 ++++++++++++++++++ .../Listener/CaseItemTimerListenerTest.php | 2 +- .../Unit/Service/Case/CasePlanEnsurerTest.php | 2 +- .../Service/Case/CasePlanItemTimersTest.php | 2 +- .../Unit/Service/Case/CasePlanServiceTest.php | 6 +- .../Unit/Service/Case/CaseProcessTaskTest.php | 4 +- .../Case/CaseSystemVerbsUnroutedTest.php | 2 +- .../Service/Flow/Nodes/CaseFlowNodesTest.php | 2 +- 24 files changed, 257 insertions(+), 76 deletions(-) create mode 100644 docs/features/processes-and-cases.md rename openspec/changes/{one-engine-bpmn-and-cmmn => archive/2026-10-10-one-engine-bpmn-and-cmmn}/.openspec.yaml (100%) rename openspec/changes/{one-engine-bpmn-and-cmmn => archive/2026-10-10-one-engine-bpmn-and-cmmn}/design.md (100%) rename openspec/changes/{one-engine-bpmn-and-cmmn => archive/2026-10-10-one-engine-bpmn-and-cmmn}/proposal.md (100%) rename openspec/changes/{one-engine-bpmn-and-cmmn => archive/2026-10-10-one-engine-bpmn-and-cmmn}/specs/flow-cases/spec.md (100%) rename openspec/changes/{one-engine-bpmn-and-cmmn => archive/2026-10-10-one-engine-bpmn-and-cmmn}/tasks.md (87%) diff --git a/docs/features/processes-and-cases.md b/docs/features/processes-and-cases.md new file mode 100644 index 0000000000..e0c3f6697f --- /dev/null +++ b/docs/features/processes-and-cases.md @@ -0,0 +1,36 @@ +# Processes and cases: one engine + +OpenRegister runs two kinds of work on one engine. A process (BPMN) is a fixed route: the flow engine walks it step by step. A case (CMMN) is adaptive work: a case plan of stages, tasks and milestones that open when their conditions are met. You can use either, or both on the same object. + +## What both share + +Processes and cases use the same building blocks. You learn them once. + +| Building block | Used by processes | Used by cases | +|---|---|---| +| Tasks for people (inbox, assignees, mandates) | a user task step | a human task in the plan | +| Deadlines on the working calendar | a timer on a task or run | a `dueAt` or `expiresAt` on a plan item | +| Conditions | step conditions in JSONLogic | entry and exit criteria in JSONLogic | +| Events | flow triggers | on-parts of a sentry | +| The subject | the object a run is about | the object the case plan is on | + +The only thing each keeps for itself is its own state: a run keeps its position in the route, a case keeps one row per plan item. + +## How they call each other + +- **A case starts a process.** A stage or a `processTask` plan item can name a flow. When the item becomes active, OpenRegister starts that flow on the case object. When the run ends, the item completes or ends with it. +- **A process opens or moves a case.** The step **Open a case** creates a case plan on the object the step receives. The step **Advance a case** moves one plan item, for example to reach a milestone. A move the plan does not allow stops the step with the reason. + +## Deadlines on plan items + +Give a plan item a `dueAt` and OpenRegister arms a timer when the item becomes active. The timer lands exactly on that moment and shows up where task deadlines show up. An `expiresAt` arms an expiry timer. It only ends the item when the plan lists the item under `statutory`; otherwise it reminds. Completing the item cancels its timers. + +## For app developers + +Code in your own app that runs without a signed-in user (a listener, an `occ` command, a repair step) uses three PHP methods on `CasePlanService`: + +- `createPlanAsSystem(objectUuid, registerId, schemaId, definition, app)` starts a case plan. +- `getPlanAsSystem(objectUuid, app)` reads it. +- `ensureItems(objectUuid, registerId, schemaId, definition, history, app)` brings a plan over from another engine. It creates only the items the object does not have yet, keeps the state each item carries, and imports history marked as imported. Running it twice changes nothing the second time. + +The audit names your app as `system:`. These methods are not reachable over HTTP. Signed-in users keep using `/api/cases`. diff --git a/lib/Exception/CasePlanExistsException.php b/lib/Exception/CasePlanExistsException.php index 5c0152f0fe..7a471d4c1a 100644 --- a/lib/Exception/CasePlanExistsException.php +++ b/lib/Exception/CasePlanExistsException.php @@ -18,7 +18,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ declare(strict_types=1); @@ -28,7 +28,7 @@ /** * The object already has a case plan. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ class CasePlanExistsException extends CaseValidationException { }//end class diff --git a/lib/Listener/CaseItemTimerListener.php b/lib/Listener/CaseItemTimerListener.php index a50273b1f2..2d8981d82b 100644 --- a/lib/Listener/CaseItemTimerListener.php +++ b/lib/Listener/CaseItemTimerListener.php @@ -22,7 +22,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ declare(strict_types=1); @@ -42,7 +42,7 @@ * * @template-implements IEventListener * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ class CaseItemTimerListener implements IEventListener { @@ -66,7 +66,7 @@ public function __construct( * * @return void * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ public function handle(Event $event): void { if ($event instanceof CaseItemTransitionedEvent) { diff --git a/lib/Migration/Version1Date20261010120000.php b/lib/Migration/Version1Date20261010120000.php index b44f3befb9..378d262cb8 100644 --- a/lib/Migration/Version1Date20261010120000.php +++ b/lib/Migration/Version1Date20261010120000.php @@ -18,7 +18,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ declare(strict_types=1); @@ -34,7 +34,7 @@ /** * Adds the imported flag to the plan-item audit. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ class Version1Date20261010120000 extends SimpleMigrationStep { @@ -54,7 +54,7 @@ class Version1Date20261010120000 extends SimpleMigrationStep { * * @SuppressWarnings(PHPMD.UnusedFormalParameter) The signature is Nextcloud's. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { $schema = $schemaClosure(); diff --git a/lib/Service/Case/CasePlanEnsurer.php b/lib/Service/Case/CasePlanEnsurer.php index 8b62ebdd7d..da7472448f 100644 --- a/lib/Service/Case/CasePlanEnsurer.php +++ b/lib/Service/Case/CasePlanEnsurer.php @@ -25,7 +25,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ declare(strict_types=1); @@ -44,7 +44,7 @@ /** * Ensures plan items with recorded states, idempotently. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ class CasePlanEnsurer { @@ -81,7 +81,7 @@ public function __construct( * * @throws CaseValidationException On the first refused value, before anything is written. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ public function ensure( string $objectUuid, @@ -152,7 +152,7 @@ public function ensure( * * @throws CaseValidationException Naming the item, its type and the state. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ private function checkStates(array $nodes, array &$types): void { foreach ($nodes as $node) { @@ -184,7 +184,7 @@ private function checkStates(array $nodes, array &$types): void { * * @return array The reachable states. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ private function reachable(string $type): array { $seen = [CaseItem::STATE_AVAILABLE]; @@ -212,7 +212,7 @@ private function reachable(string $type): array { * * @throws CaseValidationException On an entry that is not usable. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ private function checkHistory(array $history, array $types): array { $byKey = []; @@ -264,7 +264,7 @@ private function checkHistory(array $history, array $types): array { * * @throws CaseValidationException On a mismatch. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ private function checkAnchor(CaseItem $row, ?int $registerId, ?int $schemaId): void { $registerDiffers = $registerId !== null && $row->getRegisterId() !== null && $row->getRegisterId() !== $registerId; @@ -293,7 +293,7 @@ private function checkAnchor(CaseItem $row, ?int $registerId, ?int $schemaId): v * * @return void * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ private function ensureLevel(array $nodes, ?int $parentId, array $context, array &$created, array &$kept): void { foreach ($nodes as $position => $node) { @@ -323,7 +323,7 @@ private function ensureLevel(array $nodes, ?int $parentId, array $context, array * * @return CaseItem The inserted row. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ private function insert(array $node, ?int $parentId, int $position, array $context): CaseItem { $origin = CaseItem::ORIGIN_DEFINED; @@ -385,7 +385,7 @@ private function insert(array $node, ?int $parentId, int $position, array $conte * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The flag IS the stored column. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ private function appendAudit( CaseItem $item, @@ -421,7 +421,7 @@ private function appendAudit( * * @return DateTime|null The moment, or null when unreadable. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ private function moment(mixed $value): ?DateTime { if (is_string($value) === false || trim($value) === '') { @@ -449,7 +449,7 @@ private function moment(mixed $value): ?DateTime { * * @return string|null The string. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ private function stringOrNull(mixed $value): ?string { if (is_string($value) === false || trim($value) === '') { diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php index ed35c32290..34ce0f3b86 100644 --- a/lib/Service/Case/CasePlanItemTimers.php +++ b/lib/Service/Case/CasePlanItemTimers.php @@ -24,7 +24,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ declare(strict_types=1); @@ -44,7 +44,7 @@ /** * Arms, cancels and interprets the timers of plan items. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ class CasePlanItemTimers { @@ -85,7 +85,7 @@ public function __construct( * * @return int How many timers were armed. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ public function onEntered(CaseItem $item, ?string $actor, ?DateTimeInterface $now = null): int { $moment = DateTimeImmutable::createFromInterface($now ?? new DateTimeImmutable()); @@ -111,7 +111,7 @@ public function onEntered(CaseItem $item, ?string $actor, ?DateTimeInterface $no * * @return int How many timers were cancelled. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ public function onTerminal(CaseItem $item): int { try { @@ -139,7 +139,7 @@ public function onTerminal(CaseItem $item): int { * * @return string|null The item uuid, or null. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ public function itemToTerminate(FlowTimerFiredEvent $event): ?string { $timer = $event->getTimer(); @@ -171,7 +171,7 @@ public function itemToTerminate(FlowTimerFiredEvent $event): ?string { * * @return boolean True when armed. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadline, ?string $actor, DateTimeImmutable $now): bool { $seconds = ($deadline->getTimestamp() - $now->getTimestamp()); @@ -233,7 +233,7 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin * * @return string The legal effect. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ private function legalEffect(CaseItem $item, string $purpose): string { if ($purpose === FlowTimer::PURPOSE_DUE) { diff --git a/lib/Service/Case/CasePlanService.php b/lib/Service/Case/CasePlanService.php index 8180544229..bfca18bf46 100644 --- a/lib/Service/Case/CasePlanService.php +++ b/lib/Service/Case/CasePlanService.php @@ -183,7 +183,7 @@ public function createPlan( * * @throws CaseValidationException When the app id or the definition is refused, or a plan exists. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + * @spec openspec/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app */ public function createPlanAsSystem(string $objectUuid, ?int $registerId, ?int $schemaId, array $definition, string $app): array { $actor = $this->systemActor(app: $app); @@ -211,7 +211,7 @@ public function createPlanAsSystem(string $objectUuid, ?int $registerId, ?int $s * @throws CaseValidationException When the app id is refused. * @throws DoesNotExistException When the object has no plan. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + * @spec openspec/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app */ public function getPlanAsSystem(string $objectUuid, string $app): array { $this->systemActor(app: $app); @@ -238,7 +238,7 @@ public function getPlanAsSystem(string $objectUuid, string $app): array { * * @throws CaseValidationException On the first refused value, before anything is written. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ public function ensureItems(string $objectUuid, ?int $registerId, ?int $schemaId, array $definition, array $history, string $app): array { $actor = $this->systemActor(app: $app); @@ -597,7 +597,7 @@ public function onRealisationTerminal(string $taskUuid): void { * * @return boolean True when the item was terminated. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ public function onTimerExpired(string $itemUuid, string $timerUuid): bool { try { @@ -756,7 +756,7 @@ private function insertPlan(string $objectUuid, ?int $registerId, ?int $schemaId * * @throws CaseValidationException When the app id is not one. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + * @spec openspec/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app */ private function systemActor(string $app): string { if (preg_match('/^[a-z][a-z0-9_]{0,63}$/', $app) !== 1) { diff --git a/lib/Service/Flow/Nodes/CaseAdvanceNode.php b/lib/Service/Flow/Nodes/CaseAdvanceNode.php index 0d7ca450ee..afa137ff49 100644 --- a/lib/Service/Flow/Nodes/CaseAdvanceNode.php +++ b/lib/Service/Flow/Nodes/CaseAdvanceNode.php @@ -22,7 +22,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ declare(strict_types=1); @@ -37,7 +37,7 @@ /** * The `openregister.case-advance` step. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ class CaseAdvanceNode extends CaseNodeBase { @@ -53,7 +53,7 @@ class CaseAdvanceNode extends CaseNodeBase { * * @return string The node id. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function getId(): string { return self::TYPE; @@ -64,7 +64,7 @@ public function getId(): string { * * @return string The display name. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function getDisplayName(): string { return $this->l10n->t('Advance a case'); @@ -75,7 +75,7 @@ public function getDisplayName(): string { * * @return string The description. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function getDescription(): string { return $this->l10n->t( @@ -88,7 +88,7 @@ public function getDescription(): string { * * @return array The keys. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function configKeys(): array { return ['item', 'to', 'reason', 'uuid']; @@ -99,7 +99,7 @@ public function configKeys(): array { * * @return array> The fields. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function configForm(): array { return [ @@ -140,7 +140,7 @@ public function configForm(): array { * * @throws UnexpectedValueException When it does not. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function validateConfig(array $config): void { if (trim((string)($config['item'] ?? '')) === '') { @@ -163,7 +163,7 @@ public function validateConfig(array $config): void { * * @return array The items, each carrying `case` = {item, state}. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function execute(array $items, array $config, array $context): array { if ($items === []) { @@ -210,7 +210,7 @@ public function execute(array $items, array $config, array $context): array { * * @throws RuntimeException When the plan has no such item. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ private function advance(string $objectUuid, string $key, string $to, string $uid, ?string $reason): array { $plan = $this->plans->getPlan(objectUuid: $objectUuid, uid: $uid); diff --git a/lib/Service/Flow/Nodes/CaseNodeBase.php b/lib/Service/Flow/Nodes/CaseNodeBase.php index 29806aaaa5..2eae281d38 100644 --- a/lib/Service/Flow/Nodes/CaseNodeBase.php +++ b/lib/Service/Flow/Nodes/CaseNodeBase.php @@ -23,7 +23,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ declare(strict_types=1); @@ -48,7 +48,7 @@ /** * Shared resolution for the case steps. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ abstract class CaseNodeBase implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm, IFlowNodeTaxonomy { @@ -76,7 +76,7 @@ public function __construct( * * @return string The icon path. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function getIcon(): string { return $this->urls->imagePath('core', 'actions/checkmark.svg'); @@ -89,7 +89,7 @@ public function getIcon(): string { * * @return bool True when available. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function isAvailableForScope(int $scope): bool { return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true); @@ -100,7 +100,7 @@ public function isAvailableForScope(int $scope): bool { * * @return string The BPMN kind. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function getKind(): string { return IFlowNodeTaxonomy::KIND_SERVICE_TASK; @@ -111,7 +111,7 @@ public function getKind(): string { * * @return string The palette category. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function getCategory(): string { return IFlowNodeTaxonomy::CATEGORY_OBJECTS; @@ -126,7 +126,7 @@ public function getCategory(): string { * * @throws RuntimeException When nobody can be named. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ protected function actingUid(array $context): string { $uid = trim((string)($context[FlowRunService::RUN_AS_CONTEXT_KEY] ?? '')); @@ -157,7 +157,7 @@ protected function actingUid(array $context): string { * @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate is the engine's * stateless template renderer and every node calls it this way. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ protected function targets(array $items, array $config): array { $configured = ($config['uuid'] ?? null); @@ -199,7 +199,7 @@ protected function targets(array $items, array $config): array { * * @return int|null The id. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ private function intOrNull(mixed $value): ?int { if (is_int($value) === true) { diff --git a/lib/Service/Flow/Nodes/CaseOpenNode.php b/lib/Service/Flow/Nodes/CaseOpenNode.php index 40c08dacce..d0d63f706e 100644 --- a/lib/Service/Flow/Nodes/CaseOpenNode.php +++ b/lib/Service/Flow/Nodes/CaseOpenNode.php @@ -21,7 +21,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ declare(strict_types=1); @@ -35,7 +35,7 @@ /** * The `openregister.case-open` step. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ class CaseOpenNode extends CaseNodeBase { @@ -51,7 +51,7 @@ class CaseOpenNode extends CaseNodeBase { * * @return string The node id. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function getId(): string { return self::TYPE; @@ -62,7 +62,7 @@ public function getId(): string { * * @return string The display name. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function getDisplayName(): string { return $this->l10n->t('Open a case'); @@ -73,7 +73,7 @@ public function getDisplayName(): string { * * @return string The description. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function getDescription(): string { return $this->l10n->t( @@ -86,7 +86,7 @@ public function getDescription(): string { * * @return array The keys. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function configKeys(): array { return ['definition', 'uuid']; @@ -97,7 +97,7 @@ public function configKeys(): array { * * @return array> The fields. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function configForm(): array { return [ @@ -126,7 +126,7 @@ public function configForm(): array { * * @throws UnexpectedValueException When it has none. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function validateConfig(array $config): void { $definition = ($config['definition'] ?? null); @@ -144,7 +144,7 @@ public function validateConfig(array $config): void { * * @return array The items, each carrying `case` = {opened, reason}. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ public function execute(array $items, array $config, array $context): array { if ($items === []) { @@ -182,7 +182,7 @@ public function execute(array $items, array $config, array $context): array { * * @return array{opened: bool, reason: string} What happened. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ private function open(array $target, array $definition, string $uid): array { try { diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/.openspec.yaml b/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/.openspec.yaml similarity index 100% rename from openspec/changes/one-engine-bpmn-and-cmmn/.openspec.yaml rename to openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/.openspec.yaml diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/design.md b/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/design.md similarity index 100% rename from openspec/changes/one-engine-bpmn-and-cmmn/design.md rename to openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/design.md diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/proposal.md b/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/proposal.md similarity index 100% rename from openspec/changes/one-engine-bpmn-and-cmmn/proposal.md rename to openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/proposal.md diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md b/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md similarity index 100% rename from openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md rename to openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md diff --git a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md b/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/tasks.md similarity index 87% rename from openspec/changes/one-engine-bpmn-and-cmmn/tasks.md rename to openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/tasks.md index c799c20fb1..73d041f42a 100644 --- a/openspec/changes/one-engine-bpmn-and-cmmn/tasks.md +++ b/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/tasks.md @@ -29,6 +29,6 @@ Small PRs, in this order. PR 1 is what dossiq's drain waits on. ## 5. Close -- [ ] 5.1 Engine docs: one page on the shared core and the two model types (docs/). -- [ ] 5.2 Sibling ask for dossiq: move `CasePlanProjectionService` / `CasePlanRollbackService` to `createPlanAsSystem` / `getPlanAsSystem`, and the drain to `ensureItems`. -- [ ] 5.3 opsx-verify, then archive into `openspec/specs/flow-cases/spec.md`. +- [x] 5.1 Engine docs: one page on the shared core and the two model types (docs/features/processes-and-cases.md). +- [x] 5.2 Sibling ask for dossiq (filed in for-ruben/dossiq-sibling-asks.md, 10 Oct): move `CasePlanProjectionService` / `CasePlanRollbackService` to `createPlanAsSystem` / `getPlanAsSystem`, and the drain to `ensureItems`. +- [x] 5.3 opsx-verify (every scenario maps to a unit test named above; all scenarios are `@e2e exclude` with reasons), then archive into `openspec/specs/flow-cases/spec.md`. diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 3bfb79fccc..0cd5f6255a 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -8771,8 +8771,9 @@ "built": { "state": "built", "owner": "ConductionNL/openregister", - "evidence": "Code carrying @spec openspec/specs/flow-cases: lib/Controller/CaseController.php, lib/Db/CaseItem.php, lib/Db/CaseItemAudit.php, lib/Db/CaseItemAuditMapper.php.", - "spec": "openspec/specs/flow-cases" + "evidence": "Code carrying @spec openspec/specs/flow-cases: lib/Controller/CaseController.php, lib/Db/CaseItem.php, lib/Db/CaseItemAudit.php, lib/Db/CaseItemAuditMapper.php. One engine (one-engine-bpmn-and-cmmn): lib/Service/Case/CasePlanEnsurer.php, lib/Service/Case/CasePlanItemTimers.php, lib/Service/Flow/Nodes/CaseOpenNode.php, lib/Service/Flow/Nodes/CaseAdvanceNode.php.", + "spec": "openspec/specs/flow-cases", + "change": "2026-10-10-one-engine-bpmn-and-cmmn" }, "reachedOn": "API only", "provider": "openregister", diff --git a/openspec/specs/flow-cases/spec.md b/openspec/specs/flow-cases/spec.md index a83a2fa1a4..3ab781e194 100644 --- a/openspec/specs/flow-cases/spec.md +++ b/openspec/specs/flow-cases/spec.md @@ -448,3 +448,147 @@ reachable only via a BPMN document. - **THEN** no such endpoint MUST exist - @e2e exclude covered by the route inventory test asserting the absence of CMMN endpoints + +### Requirement: One engine carries both model types on a shared core + +The system SHALL run BPMN processes and CMMN case plans as two model types of +one engine. Each model type SHALL own only the shape of its runtime state and +the algorithm that advances it. Human work, deadlines, conditions, events, +the subject anchor and authorization SHALL come from the shared core: the +task service, the timer service with its working calendar, the event catalog +with the flow expression evaluator, the anchor triple, and fail-closed +authorization. A plan item's lifecycle states SHALL be the task's lifecycle +states, declared once. + +#### Scenario: Plan items and tasks share one list of states + +- **GIVEN** the task lifecycle's states and terminal states +- **WHEN** the plan item's states and terminal states are read +- **THEN** they MUST be the same lists, by reference to one declaration +- @e2e exclude a declaration invariant; asserted by a unit test + +### Requirement: A process task starts a flow + +A plan item of type `processTask` SHALL be realised by a flow run on the +plan's subject when it becomes active, and the run's terminal state SHALL +drive the item exactly as it does for a stage realised by a run. A process +task SHALL name its flow in the plan settings; a definition with a process +task that names no flow SHALL be refused at create time. A process task SHALL +follow the work-item lifecycle and SHALL NOT have children. + +#### Scenario: A process task queues its flow + +- **GIVEN** a plan whose process task `advies` names flow `advice-flow` +- **WHEN** `advies` becomes active +- **THEN** a run of `advice-flow` MUST be queued on the plan's subject +- **AND** the item MUST record the run as its realisation +- @e2e exclude realisation is asserted by unit tests over the real realisation service + +#### Scenario: A process task without a flow is refused + +- **GIVEN** a definition with a process task whose key has no flow in the plan settings +- **WHEN** the plan is created +- **THEN** creation MUST be refused naming the item, and nothing MUST be written +- @e2e exclude save-time validation; asserted by unit tests + +### Requirement: A flow step opens or advances a case + +The flow engine SHALL offer a node that creates a case plan on the run's +subject from a definition, and a node that transitions a plan item of the +run's subject by its key. Both SHALL act as the run's attributed identity and +SHALL go through the same service verbs, validation and authorization as any +other caller. Opening a plan on an object that already has one SHALL be +reported on the node's output, not raised. A refused transition SHALL fail +the step with the engine's message. + +#### Scenario: A flow reaches a milestone + +- **GIVEN** a case plan on object O with milestone `besluit-genomen` in `available` +- **WHEN** a run on O executes `case-advance` with item `besluit-genomen` and `to: completed` +- **THEN** the milestone MUST be completed with the run's identity in the audit +- @e2e exclude node behaviour; asserted by unit tests over the real plan service + +#### Scenario: A refused advance fails the step + +- **GIVEN** a milestone already `completed` +- **WHEN** `case-advance` asks to complete it again +- **THEN** the step MUST fail with the message naming item, type, from-state and to-state +- @e2e exclude asserted by unit tests + +### Requirement: Plan-item deadlines run on the shared clock + +A plan item with a due date or an expiry SHALL arm a timer on the shared +timer service, subject type `case-item`, when it becomes active, and every +open timer of an item SHALL be cancelled when the item reaches a terminal +state. An enforcing expiry SHALL terminate the item. The case layer SHALL NOT +keep a clock of its own. A failure to arm SHALL be logged and SHALL NOT +block the transition. + +#### Scenario: An active item with a due date gets a timer + +- **GIVEN** a human item with `dueAt` set +- **WHEN** it becomes active +- **THEN** a `due` timer with subject type `case-item` and the item's uuid MUST be armed + +#### Scenario: Completing an item cancels its timers + +- **GIVEN** an active item with an armed timer +- **WHEN** the item is completed +- **THEN** its timer MUST be cancelled + +- @e2e exclude timer wiring; asserted by unit tests over the real timer service + +### Requirement: In-process callers act as a named app + +The case layer SHALL offer in-process verbs to create a plan, read a plan and +ensure items as a named app, for code that runs without a user session +(listeners, commands, repair steps). The audit SHALL record the actor as +`system:`. These verbs SHALL NOT be reachable over HTTP. The user verbs +SHALL keep refusing a missing identity. + +#### Scenario: An app's listener creates a plan + +- **GIVEN** an object without a plan +- **WHEN** app `dossiq` creates a plan for it in-process as the system +- **THEN** the plan MUST exist and its creation audit MUST name `system:dossiq` + +#### Scenario: No route reaches a system verb + +- **GIVEN** the controllers of the app +- **WHEN** they are searched for the system verbs +- **THEN** none MUST reference them + +- @e2e exclude no HTTP surface by design; asserted by unit and structural tests + +### Requirement: Plan items can be ensured convergently with their recorded states + +The case layer SHALL offer an ensure verb that, keyed on the object and the +item key, creates only the items an object does not have yet, with the state +each carries, and leaves existing items untouched. It SHALL append audit +entries only for items it created in that call, and SHALL import supplied +history for those items as audit entries flagged as imported, with their +original moments. It SHALL validate every item and state before writing +anything, SHALL create no realisation for imported items, and SHALL run no +cascade. Running it twice with the same input SHALL change nothing the +second time. + +#### Scenario: A half-written plan resumes without duplicates + +- **GIVEN** an object holding items `intake` and `beoordeling` and an ensure call naming `intake`, `beoordeling` and `besluit` +- **WHEN** the call runs +- **THEN** only `besluit` MUST be created, with its recorded state +- **AND** `intake` and `beoordeling` MUST keep their states and gain no audit entry + +#### Scenario: A second run appends nothing + +- **GIVEN** an ensure call that has already run +- **WHEN** it runs again with the same items and history +- **THEN** no row and no audit entry MUST be added + +#### Scenario: An illegal recorded state is refused before writing + +- **GIVEN** an ensure call with a milestone carrying state `active` +- **WHEN** the call runs +- **THEN** it MUST be refused naming the item and state, and nothing MUST be written + +- @e2e exclude occ-level behaviour with no browser surface; asserted by unit tests diff --git a/tests/Unit/Listener/CaseItemTimerListenerTest.php b/tests/Unit/Listener/CaseItemTimerListenerTest.php index c9669a21f2..33f820d4e3 100644 --- a/tests/Unit/Listener/CaseItemTimerListenerTest.php +++ b/tests/Unit/Listener/CaseItemTimerListenerTest.php @@ -13,7 +13,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ declare(strict_types=1); diff --git a/tests/Unit/Service/Case/CasePlanEnsurerTest.php b/tests/Unit/Service/Case/CasePlanEnsurerTest.php index ee6ac4ea8f..0db7fb266a 100644 --- a/tests/Unit/Service/Case/CasePlanEnsurerTest.php +++ b/tests/Unit/Service/Case/CasePlanEnsurerTest.php @@ -16,7 +16,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ declare(strict_types=1); diff --git a/tests/Unit/Service/Case/CasePlanItemTimersTest.php b/tests/Unit/Service/Case/CasePlanItemTimersTest.php index b005deed4c..b4306a3101 100644 --- a/tests/Unit/Service/Case/CasePlanItemTimersTest.php +++ b/tests/Unit/Service/Case/CasePlanItemTimersTest.php @@ -15,7 +15,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ declare(strict_types=1); diff --git a/tests/Unit/Service/Case/CasePlanServiceTest.php b/tests/Unit/Service/Case/CasePlanServiceTest.php index c67dc361ef..ecc98f3867 100644 --- a/tests/Unit/Service/Case/CasePlanServiceTest.php +++ b/tests/Unit/Service/Case/CasePlanServiceTest.php @@ -551,7 +551,7 @@ public function testEventDrivenEvaluationFailuresAreSwallowed(): void { * app, and a malformed app id is refused. The user verbs keep refusing a * missing identity. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + * @spec openspec/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app * * @return void */ @@ -590,7 +590,7 @@ public function testSystemCallersActAsANamedApp(): void { * app and runs no cascade: an imported active stage does not enter its * children. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states * * @return void */ @@ -617,7 +617,7 @@ public function testEnsureItemsBringsAPlanOverAsTheApp(): void { * path, with the timer as the cause; an item already terminal, or gone, * is left alone. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock * * @return void */ diff --git a/tests/Unit/Service/Case/CaseProcessTaskTest.php b/tests/Unit/Service/Case/CaseProcessTaskTest.php index 5ce3dd3c3a..7920b2f6e5 100644 --- a/tests/Unit/Service/Case/CaseProcessTaskTest.php +++ b/tests/Unit/Service/Case/CaseProcessTaskTest.php @@ -16,7 +16,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-process-task-starts-a-flow + * @spec openspec/specs/flow-cases/spec.md#requirement-a-process-task-starts-a-flow */ declare(strict_types=1); @@ -48,7 +48,7 @@ class CaseProcessTaskTest extends TestCase { /** * The plan item's states ARE the task's states: one declaration. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-one-engine-carries-both-model-types-on-a-shared-core + * @spec openspec/specs/flow-cases/spec.md#requirement-one-engine-carries-both-model-types-on-a-shared-core * * @return void */ diff --git a/tests/Unit/Service/Case/CaseSystemVerbsUnroutedTest.php b/tests/Unit/Service/Case/CaseSystemVerbsUnroutedTest.php index 0521de6706..4224314278 100644 --- a/tests/Unit/Service/Case/CaseSystemVerbsUnroutedTest.php +++ b/tests/Unit/Service/Case/CaseSystemVerbsUnroutedTest.php @@ -24,7 +24,7 @@ * The system verbs carry no authorization, so no HTTP surface may reach * them: no controller references them. * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app + * @spec openspec/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app */ class CaseSystemVerbsUnroutedTest extends TestCase { diff --git a/tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php b/tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php index 5435f85e11..d5dda5207b 100644 --- a/tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php +++ b/tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php @@ -15,7 +15,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/changes/one-engine-bpmn-and-cmmn/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case */ declare(strict_types=1); From 2d9f4e93fedf78ba423f582ff831d9de2730bae9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 12:18:25 +0200 Subject: [PATCH 019/183] feat(mcp): a curated #[McpTool] can declare its reach #[McpTool] gains an optional reach (self, user, instance, external). AttributeToolScanner validates it against ToolReachResolver::ORDER at scan time (an unrecognised value skips the tool with a warning, like scope) and forwards a declared one under REACH_KEY; AttributeToolProvider::getTools() carries it through its rebuild. Until now every curated attribute tool resolved to external, so a plain read always needed an explicit grant. An undeclared reach still fails closed to external, and requiresGrant() stays a union, so a declared reach can never un-gate a write. Asked by dossiq hermiq-ai-tooling (REQ-MCP-205). Spec: ai-mcp REQ-ATTR-006. --- lib/Mcp/Attribute/McpTool.php | 9 ++++ lib/Mcp/AttributeToolScanner.php | 47 ++++++++++++++++-- lib/Mcp/BuiltIn/AttributeToolProvider.php | 2 +- .../proposal.md | 21 ++++++++ .../screens.md | 1 + .../specs/ai-mcp/spec.md | 35 ++++++++++++++ .../tasks.md | 6 +++ openspec/specs/ai-mcp/screens.md | 1 + openspec/specs/ai-mcp/spec.md | 31 ++++++++++++ .../Unit/Mcp/AttributeToolDualSurfaceTest.php | 48 +++++++++++++++++++ tests/Unit/Mcp/AttributeToolScannerTest.php | 44 +++++++++++++++++ .../Mcp/BuiltIn/AttributeToolProviderTest.php | 17 +++++++ .../Unit/Mcp/Fixtures/ReachFixtureService.php | 47 ++++++++++++++++++ 13 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/proposal.md create mode 100644 openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/screens.md create mode 100644 openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/specs/ai-mcp/spec.md create mode 100644 openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/tasks.md create mode 100644 openspec/specs/ai-mcp/screens.md create mode 100644 tests/Unit/Mcp/Fixtures/ReachFixtureService.php diff --git a/lib/Mcp/Attribute/McpTool.php b/lib/Mcp/Attribute/McpTool.php index 4347fff62b..abc77241b3 100644 --- a/lib/Mcp/Attribute/McpTool.php +++ b/lib/Mcp/Attribute/McpTool.php @@ -57,6 +57,8 @@ * (Requirement: REQ-ATTR-001 — The #[McpTool] service-method attribute) * @spec openspec/specs/ai-mcp/spec.md * (Requirement: REQ-ATTR-005 — Attribute-declared hints/scope reach both MCP surfaces) + * @spec openspec/specs/ai-mcp/spec.md + * (Requirement: REQ-ATTR-006 — A curated attribute tool declares its reach) */ declare(strict_types=1); @@ -100,6 +102,12 @@ final class McpTool { * to CRUD — a tool that sends, converts or delegates should say so * rather than be filed under the nearest CRUD verb, since the matrix * grants on this value. + * @param string|null $reach The widest set of principals a successful call can affect or + * disclose to; when set, MUST be one of + * {@see \OCA\OpenRegister\Service\Capability\ToolReachResolver::ORDER} + * (`self`, `user`, `instance`, `external`), validated by the scanner at + * scan time. A curated two-segment id carries no verb to infer a reach + * from, so an omitted reach resolves to `external` (fail closed). * * @spec openspec/specs/ai-mcp/spec.md * (Requirement: REQ-ATTR-001 — The #[McpTool] service-method attribute) @@ -115,6 +123,7 @@ public function __construct( public readonly ?string $scope = null, public readonly ?string $subject = null, public readonly ?string $action = null, + public readonly ?string $reach = null, ) { }//end __construct() }//end class diff --git a/lib/Mcp/AttributeToolScanner.php b/lib/Mcp/AttributeToolScanner.php index 9d003d16fd..4fc2146eb9 100644 --- a/lib/Mcp/AttributeToolScanner.php +++ b/lib/Mcp/AttributeToolScanner.php @@ -45,6 +45,7 @@ namespace OCA\OpenRegister\Mcp; use OCA\OpenRegister\Mcp\Attribute\McpTool; +use OCA\OpenRegister\Service\Capability\ToolReachResolver; use OCA\OpenRegister\Service\Mcp\McpAnnotationValidator; use Psr\Log\LoggerInterface; use ReflectionClass; @@ -163,15 +164,16 @@ public function scanClass(string $appId, string $className, LoggerInterface $log continue; } - if ($this->hasUnknownScope(attribute: $attributeInstance) === true) { + $invalid = $this->unrecognisedDeclaration(attribute: $attributeInstance); + if ($invalid !== null) { $logger->warning( - '[AttributeToolScanner] #[McpTool] declares an unrecognised `scope`; tool skipped', + sprintf('[AttributeToolScanner] #[McpTool] declares an unrecognised `%s`; tool skipped', $invalid['key']), [ 'appId' => $appId, 'class' => $className, 'method' => $method->getName(), - 'scope' => $attributeInstance->scope, - 'allowedScopes' => McpAnnotationValidator::SCOPES, + $invalid['key'] => $invalid['value'], + 'allowed' => $invalid['allowed'], ] ); continue; @@ -256,7 +258,7 @@ private function buildDescriptor(string $appId, string $className, ReflectionMet }//end buildDescriptor() /** - * The grant-matrix taxonomy an attribute declared, or an empty array. + * The grant-matrix taxonomy (subject, action) and the declared reach, or an empty array. * * Forwarded on the same additive terms as the annotation hints: a key is * present ONLY when the author declared it, never inferred from the method @@ -285,6 +287,12 @@ private function taxonomyOf(McpTool $attribute): array { $taxonomy['subject'] = $attribute->subject; } + // Already validated against ToolReachResolver::ORDER at scan time + // (REQ-ATTR-006); forwarded only when declared, never inferred. + if ($attribute->reach !== null) { + $taxonomy[ToolReachResolver::REACH_KEY] = $attribute->reach; + } + if ($attribute->action !== null && trim($attribute->action) !== '') { $taxonomy['action'] = $attribute->action; } @@ -329,6 +337,35 @@ private function hasUnknownScope(McpTool $attribute): bool { return in_array($attribute->scope, McpAnnotationValidator::SCOPES, true) === false; }//end hasUnknownScope() + /** + * The first closed-vocabulary declaration on the attribute that holds a + * value outside its vocabulary, or null when every declared one is valid. + * + * `scope` is checked against {@see McpAnnotationValidator::SCOPES} + * (REQ-ATTR-005) and `reach` against {@see ToolReachResolver::ORDER} + * (REQ-ATTR-006). A misspelt reach would otherwise resolve to `external` + * in silence: safe, but the author would never learn why the tool keeps + * asking for a grant, so the scanner refuses it loudly instead. + * + * @param McpTool $attribute The resolved attribute instance. + * + * @return array{key: string, value: string, allowed: list}|null The offending declaration. + * + * @spec openspec/specs/ai-mcp/spec.md + * (Requirement: REQ-ATTR-006 — A curated attribute tool declares its reach) + */ + private function unrecognisedDeclaration(McpTool $attribute): ?array { + if ($this->hasUnknownScope(attribute: $attribute) === true) { + return ['key' => 'scope', 'value' => (string) $attribute->scope, 'allowed' => array_values(McpAnnotationValidator::SCOPES)]; + } + + if ($attribute->reach !== null && in_array($attribute->reach, ToolReachResolver::ORDER, true) === false) { + return ['key' => 'reach', 'value' => $attribute->reach, 'allowed' => array_values(ToolReachResolver::ORDER)]; + } + + return null; + }//end unrecognisedDeclaration() + /** * Infer the `inputSchema` from the method's parameter type hints and * docblock `@param` tags. Parameters without a default value are diff --git a/lib/Mcp/BuiltIn/AttributeToolProvider.php b/lib/Mcp/BuiltIn/AttributeToolProvider.php index 090e0625fb..6bfcc6dc88 100644 --- a/lib/Mcp/BuiltIn/AttributeToolProvider.php +++ b/lib/Mcp/BuiltIn/AttributeToolProvider.php @@ -156,7 +156,7 @@ public function getTools(): array { // `#[McpTool]` reached the scanner and then vanished before the // grant matrix: the attribute, the scanner and the bridge all // carried them, and this one copy step did not. - foreach (['subject', 'action'] as $taxonomyKey) { + foreach (['subject', 'action', 'reach'] as $taxonomyKey) { if (array_key_exists($taxonomyKey, $entry) === true) { $descriptor[$taxonomyKey] = $entry[$taxonomyKey]; } diff --git a/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/proposal.md b/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/proposal.md new file mode 100644 index 0000000000..b37a07e0fa --- /dev/null +++ b/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/proposal.md @@ -0,0 +1,21 @@ +# A curated #[McpTool] declares its reach + +## Why + +`ToolReachResolver::resolve()` reads a declared `reach` off the tool descriptor and, for a curated two-segment id such as `dossiq.getWorkload`, falls back to `external` when none is declared. Array-descriptor providers (hermiq, the flow tools) can set `reach`, and `McpProviderBridge` already forwards it. Attribute-scanned apps cannot: `#[McpTool]` has no `reach` parameter, `AttributeToolScanner` forwards none, and `AttributeToolProvider::getTools()` rebuilds the descriptor from a fixed key list. So every curated attribute tool resolves to `external`, a plain read like dossiq's `getKpiOverview` always needs an explicit grant, and a read cannot be told apart from a write that mails a resident. + +Asked by dossiq (change hermiq-ai-tooling, REQ-MCP-205), which wants to declare `user` on its reads and the honest reach on its writes. + +## What changes + +- `#[McpTool]` gains an optional `reach` parameter (`self`, `user`, `instance`, `external`). +- `AttributeToolScanner` validates it against `ToolReachResolver::ORDER` at scan time (an unrecognised value skips the tool with a warning, the same as an unrecognised `scope`) and forwards it under `ToolReachResolver::REACH_KEY` only when declared. +- `AttributeToolProvider::getTools()` carries `reach` through. + +Nothing else moves: an undeclared reach still fails closed to `external`, and reach still only ever adds tools to the gated set (`ToolGrantResolver::requiresGrant()` is a union). + +## Impact + +- Affected spec: `ai-mcp` (REQ-ATTR-006 added). +- Code: `lib/Mcp/Attribute/McpTool.php`, `lib/Mcp/AttributeToolScanner.php`, `lib/Mcp/BuiltIn/AttributeToolProvider.php`. +- No schema, register or migration change. diff --git a/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/screens.md b/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/screens.md new file mode 100644 index 0000000000..2fa77648e2 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/screens.md @@ -0,0 +1 @@ +- No screen: a PHP attribute parameter read by the MCP tool scanner; it changes tool descriptors, not a page. diff --git a/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/specs/ai-mcp/spec.md b/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/specs/ai-mcp/spec.md new file mode 100644 index 0000000000..ec40aca9ed --- /dev/null +++ b/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/specs/ai-mcp/spec.md @@ -0,0 +1,35 @@ +# ai-mcp delta + +## ADDED Requirements + +### Requirement: REQ-ATTR-006 — A curated attribute tool declares its reach + +`#[McpTool]` MUST accept an optional `reach` parameter whose value, when set, is one of `ToolReachResolver::ORDER` (`self`, `user`, `instance`, `external`). `AttributeToolScanner` MUST reject a declared `reach` outside that vocabulary at scan time (logged, the offending tool skipped, sibling tools unaffected), and MUST forward a valid declared `reach` into the descriptor under `ToolReachResolver::REACH_KEY` only when the author set it, never inferred. `AttributeToolProvider::getTools()` MUST carry the key through unchanged, so the value reaches `ToolReachResolver::resolve()` on both the JSON-RPC and the chat/facade surface. An undeclared `reach` on a two-segment curated id MUST still resolve to `external` (agent-capability-reach), and a declared `reach` MUST NOT remove a tool from the gated set that `ToolGrantResolver::isWriteOrDestructive()` already gates. + +#### Scenario: A declared reach appears in the descriptor +- **GIVEN** app `dossiq` exposes `#[McpTool(readOnlyHint: true, scope: 'read', reach: 'user')] getWorkload(string $userId)` +- **WHEN** `AttributeToolScanner` builds the descriptor +- **THEN** the descriptor MUST include `reach: 'user'` +@e2e exclude Descriptor-shape assertion with no UI surface; asserted by AttributeToolScannerTest. + +#### Scenario: An undeclared reach stays omitted and fails closed +- **GIVEN** a method `#[McpTool(scope: 'update')] reassignCase(string $id)` on app `dossiq` +- **WHEN** the descriptor is built and its reach resolved +- **THEN** the descriptor MUST NOT carry a `reach` key +- **AND** `ToolReachResolver::resolve('dossiq.reassignCase', $descriptor)` MUST return `external` +@e2e exclude Fail-closed default reachable only by constructing a descriptor; asserted by unit tests. + +#### Scenario: An unrecognised reach is rejected at scan time +- **GIVEN** a method `#[McpTool(reach: 'everyone')]` +- **WHEN** `AttributeToolScanner` scans the declaring class +- **THEN** no tool MUST be registered for that method +- **AND** a warning MUST be logged naming the invalid `reach` +@e2e exclude Malformed-attribute path with no UI surface; asserted by AttributeToolScannerTest. + +#### Scenario: A declared reach arrives at the resolver on both surfaces +- **GIVEN** the `getWorkload` descriptor is registered via `AttributeToolProvider` +- **WHEN** it is read from `McpToolsService::listTools()` and from `ToolRegistryFacade::listTools()` +- **THEN** `ToolReachResolver::resolve()` MUST return `user` for both entries +- **AND** `ToolGrantResolver::requiresGrant()` MUST return false for a tool that is also read-only +@e2e exclude Wiring assertion through the real registry, bridge and facade chain; asserted by AttributeToolDualSurfaceTest. + diff --git a/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/tasks.md b/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/tasks.md new file mode 100644 index 0000000000..0602f18972 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-mcp-tool-declares-reach/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] 1.1 `#[McpTool]` gains `public readonly ?string $reach = null` (lib/Mcp/Attribute/McpTool.php) +- [x] 1.2 `AttributeToolScanner` rejects a reach outside `ToolReachResolver::ORDER` at scan time and forwards a declared one under `REACH_KEY` (lib/Mcp/AttributeToolScanner.php; AttributeToolScannerTest::testDescriptorForwardsDeclaredReach, testUndeclaredReachStaysOmittedNeverDefaulted, testUnknownReachIsRejectedAndLogged) +- [x] 1.3 `AttributeToolProvider::getTools()` carries `reach` through its rebuild (AttributeToolProviderTest::testGetToolsForwardsDeclaredReachAndOmitsAnUndeclaredOne) +- [x] 1.4 The declared reach arrives at `ToolReachResolver` and `ToolGrantResolver` on both surfaces (AttributeToolDualSurfaceTest::testDeclaredReachArrivesAtTheResolverOnBothSurfaces) diff --git a/openspec/specs/ai-mcp/screens.md b/openspec/specs/ai-mcp/screens.md new file mode 100644 index 0000000000..2fa77648e2 --- /dev/null +++ b/openspec/specs/ai-mcp/screens.md @@ -0,0 +1 @@ +- No screen: a PHP attribute parameter read by the MCP tool scanner; it changes tool descriptors, not a page. diff --git a/openspec/specs/ai-mcp/spec.md b/openspec/specs/ai-mcp/spec.md index 17441d2f57..d7a926b69e 100644 --- a/openspec/specs/ai-mcp/spec.md +++ b/openspec/specs/ai-mcp/spec.md @@ -666,6 +666,37 @@ invocation behavior. - **THEN** the invocation MUST still fail with the service method's authorization error - **AND** the `readOnlyHint` value MUST NOT bypass, weaken, or otherwise affect that outcome +### Requirement: REQ-ATTR-006 — A curated attribute tool declares its reach + +`#[McpTool]` MUST accept an optional `reach` parameter whose value, when set, is one of `ToolReachResolver::ORDER` (`self`, `user`, `instance`, `external`). `AttributeToolScanner` MUST reject a declared `reach` outside that vocabulary at scan time (logged, the offending tool skipped, sibling tools unaffected), and MUST forward a valid declared `reach` into the descriptor under `ToolReachResolver::REACH_KEY` only when the author set it, never inferred. `AttributeToolProvider::getTools()` MUST carry the key through unchanged, so the value reaches `ToolReachResolver::resolve()` on both the JSON-RPC and the chat/facade surface. An undeclared `reach` on a two-segment curated id MUST still resolve to `external` (agent-capability-reach), and a declared `reach` MUST NOT remove a tool from the gated set that `ToolGrantResolver::isWriteOrDestructive()` already gates. + +#### Scenario: A declared reach appears in the descriptor +- **GIVEN** app `dossiq` exposes `#[McpTool(readOnlyHint: true, scope: 'read', reach: 'user')] getWorkload(string $userId)` +- **WHEN** `AttributeToolScanner` builds the descriptor +- **THEN** the descriptor MUST include `reach: 'user'` +@e2e exclude Descriptor-shape assertion with no UI surface; asserted by AttributeToolScannerTest. + +#### Scenario: An undeclared reach stays omitted and fails closed +- **GIVEN** a method `#[McpTool(scope: 'update')] reassignCase(string $id)` on app `dossiq` +- **WHEN** the descriptor is built and its reach resolved +- **THEN** the descriptor MUST NOT carry a `reach` key +- **AND** `ToolReachResolver::resolve('dossiq.reassignCase', $descriptor)` MUST return `external` +@e2e exclude Fail-closed default reachable only by constructing a descriptor; asserted by unit tests. + +#### Scenario: An unrecognised reach is rejected at scan time +- **GIVEN** a method `#[McpTool(reach: 'everyone')]` +- **WHEN** `AttributeToolScanner` scans the declaring class +- **THEN** no tool MUST be registered for that method +- **AND** a warning MUST be logged naming the invalid `reach` +@e2e exclude Malformed-attribute path with no UI surface; asserted by AttributeToolScannerTest. + +#### Scenario: A declared reach arrives at the resolver on both surfaces +- **GIVEN** the `getWorkload` descriptor is registered via `AttributeToolProvider` +- **WHEN** it is read from `McpToolsService::listTools()` and from `ToolRegistryFacade::listTools()` +- **THEN** `ToolReachResolver::resolve()` MUST return `user` for both entries +- **AND** `ToolGrantResolver::requiresGrant()` MUST return false for a tool that is also read-only +@e2e exclude Wiring assertion through the real registry, bridge and facade chain; asserted by AttributeToolDualSurfaceTest. + ### Requirement: REQ-006 — ToolRegistryFacade is the public read/invoke surface for cross-app tool-loop consumers `OCA\OpenRegister\Service\Mcp\ToolRegistryFacade` MUST be OpenRegister's diff --git a/tests/Unit/Mcp/AttributeToolDualSurfaceTest.php b/tests/Unit/Mcp/AttributeToolDualSurfaceTest.php index 586693e15f..b9bdbba441 100644 --- a/tests/Unit/Mcp/AttributeToolDualSurfaceTest.php +++ b/tests/Unit/Mcp/AttributeToolDualSurfaceTest.php @@ -34,6 +34,9 @@ use OCA\OpenRegister\Service\ToolRegistry; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\AttributeFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\HintScopeFixtureService; +use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\ReachFixtureService; +use OCA\OpenRegister\Service\Capability\ToolGrantResolver; +use OCA\OpenRegister\Service\Capability\ToolReachResolver; use OCA\OpenRegister\Tool\AgentTool; use OCA\OpenRegister\Tool\ApplicationTool; use OCA\OpenRegister\Tool\ObjectsTool; @@ -46,6 +49,7 @@ require_once __DIR__ . '/Fixtures/AttributeFixtureService.php'; require_once __DIR__ . '/Fixtures/HintScopeFixtureService.php'; +require_once __DIR__ . '/Fixtures/ReachFixtureService.php'; /** * Dual-surface tests for the attribute-derived provider. @@ -267,4 +271,48 @@ public function testDeclaredHintsAndScopeAreVisibleOnFacadeSurface(): void { $this->assertSame('delete', $deleteLead['scope']); }//end testDeclaredHintsAndScopeAreVisibleOnFacadeSurface() + + // ── Declared reach end to end (REQ-ATTR-006) ───────────────────── + + /** + * The ReachFixtureService wrapped in a real provider, under app id `dossiq`. + * + * @return AttributeToolProvider + */ + private function reachProvider(): AttributeToolProvider { + $instance = new ReachFixtureService(); + $entries = (new AttributeToolScanner())->scanClass(appId: 'dossiq', className: get_class($instance), logger: $this->logger); + foreach ($entries as &$entry) { + $entry['instance'] = $instance; + } + + return new AttributeToolProvider( + appId: 'dossiq', + entries: $entries, + auditTrailMapper: $this->auditTrailMapper, + logger: $this->logger + ); + + }//end reachProvider() + + public function testDeclaredReachArrivesAtTheResolverOnBothSurfaces(): void { + $service = new McpToolsService(providers: [$this->reachProvider()], logger: $this->logger); + $jsonRpc = []; + foreach ($service->listTools()['tools'] as $tool) { + $jsonRpc[$tool['id']] = $tool; + } + + $facade = []; + foreach ($this->buildFacade($this->reachProvider())->listTools() as $descriptor) { + $facade[$descriptor['mcpId']] = $descriptor; + } + + foreach ([$jsonRpc, $facade] as $surface) { + $this->assertSame('user', ToolReachResolver::resolve(toolId: 'dossiq.getWorkload', descriptor: $surface['dossiq.getWorkload'])); + $this->assertFalse(ToolGrantResolver::requiresGrant(id: 'dossiq.getWorkload', descriptor: $surface['dossiq.getWorkload'])); + // Undeclared on a two-segment id: still fails closed. + $this->assertSame('external', ToolReachResolver::resolve(toolId: 'dossiq.reassignCase', descriptor: $surface['dossiq.reassignCase'])); + } + + }//end testDeclaredReachArrivesAtTheResolverOnBothSurfaces() }//end class diff --git a/tests/Unit/Mcp/AttributeToolScannerTest.php b/tests/Unit/Mcp/AttributeToolScannerTest.php index e8b67ff217..c97cf62758 100644 --- a/tests/Unit/Mcp/AttributeToolScannerTest.php +++ b/tests/Unit/Mcp/AttributeToolScannerTest.php @@ -27,12 +27,14 @@ use OCA\OpenRegister\Mcp\AttributeToolScanner; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\AttributeFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\HintScopeFixtureService; +use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\ReachFixtureService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; require_once __DIR__ . '/Fixtures/AttributeFixtureService.php'; require_once __DIR__ . '/Fixtures/HintScopeFixtureService.php'; +require_once __DIR__ . '/Fixtures/ReachFixtureService.php'; /** * Unit tests for AttributeToolScanner. @@ -280,4 +282,46 @@ public function testUnknownScopeDoesNotSuppressSiblingValidTools(): void { $this->assertArrayHasKey('getLead', $descriptors); }//end testUnknownScopeDoesNotSuppressSiblingValidTools() + + // ── Declared reach (REQ-ATTR-006) ──────────────────────────────── + + /** + * @return array> descriptor keyed by `name` + */ + private function scanReachFixture(): array { + $byName = []; + foreach ($this->scanner->scanClass(appId: 'dossiq', className: ReachFixtureService::class, logger: $this->logger) as $descriptor) { + $byName[$descriptor['name']] = $descriptor; + } + + return $byName; + }//end scanReachFixture() + + public function testDescriptorForwardsDeclaredReach(): void { + $descriptor = $this->scanReachFixture()['getWorkload']; + + $this->assertSame('user', $descriptor['reach']); + $this->assertSame('read', $descriptor['scope']); + + }//end testDescriptorForwardsDeclaredReach() + + public function testUndeclaredReachStaysOmittedNeverDefaulted(): void { + $descriptor = $this->scanReachFixture()['reassignCase']; + + $this->assertArrayNotHasKey('reach', $descriptor); + + }//end testUndeclaredReachStaysOmittedNeverDefaulted() + + public function testUnknownReachIsRejectedAndLogged(): void { + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('unrecognised `reach`')); + + $descriptors = $this->scanReachFixture(); + + $this->assertArrayNotHasKey('badReachTool', $descriptors); + $this->assertArrayHasKey('getWorkload', $descriptors); + $this->assertArrayHasKey('reassignCase', $descriptors); + + }//end testUnknownReachIsRejectedAndLogged() }//end class diff --git a/tests/Unit/Mcp/BuiltIn/AttributeToolProviderTest.php b/tests/Unit/Mcp/BuiltIn/AttributeToolProviderTest.php index d2f74a4cd6..ce26fd0adc 100644 --- a/tests/Unit/Mcp/BuiltIn/AttributeToolProviderTest.php +++ b/tests/Unit/Mcp/BuiltIn/AttributeToolProviderTest.php @@ -33,6 +33,7 @@ use OCA\OpenRegister\Mcp\BuiltIn\AttributeToolProvider; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\AttributeFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\HintScopeFixtureService; +use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\ReachFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\ThrowingFixtureService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -41,6 +42,7 @@ require_once __DIR__ . '/../Fixtures/AttributeFixtureService.php'; require_once __DIR__ . '/../Fixtures/HintScopeFixtureService.php'; +require_once __DIR__ . '/../Fixtures/ReachFixtureService.php'; require_once __DIR__ . '/../Fixtures/ThrowingFixtureService.php'; /** @@ -310,4 +312,19 @@ public function testAuditFailureDoesNotMaskSuccessfulResult(): void { $this->assertSame(['subject' => 'x'], $result); }//end testAuditFailureDoesNotMaskSuccessfulResult() + + // ── getTools: declared reach (REQ-ATTR-006) ────────────────────── + + public function testGetToolsForwardsDeclaredReachAndOmitsAnUndeclaredOne(): void { + $provider = $this->provider($this->entriesFor(new ReachFixtureService(), 'dossiq'), 'dossiq'); + + $byId = []; + foreach ($provider->getTools() as $tool) { + $byId[$tool['id']] = $tool; + } + + $this->assertSame('user', $byId['dossiq.getWorkload']['reach']); + $this->assertArrayNotHasKey('reach', $byId['dossiq.reassignCase']); + + }//end testGetToolsForwardsDeclaredReachAndOmitsAnUndeclaredOne() }//end class diff --git a/tests/Unit/Mcp/Fixtures/ReachFixtureService.php b/tests/Unit/Mcp/Fixtures/ReachFixtureService.php new file mode 100644 index 0000000000..ab92b730fa --- /dev/null +++ b/tests/Unit/Mcp/Fixtures/ReachFixtureService.php @@ -0,0 +1,47 @@ + + * @copyright 2026 Conduction BV + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/specs/ai-mcp/spec.md + * (Requirement: REQ-ATTR-006 — A curated attribute tool declares its reach) + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Mcp\Fixtures; + +use OCA\OpenRegister\Mcp\Attribute\McpTool; + +class ReachFixtureService { + + #[McpTool(readOnlyHint: true, scope: 'read', reach: 'user')] + public function getWorkload(string $userId): array { + return ['userId' => $userId]; + }//end getWorkload() + + #[McpTool(scope: 'update')] + public function reassignCase(string $id): array { + return ['id' => $id]; + }//end reassignCase() + + #[McpTool(reach: 'everyone')] + public function badReachTool(string $id): array { + return ['id' => $id]; + }//end badReachTool() +}//end class From a7520f5d25ba1b78d695c2bd1c4c3e57b2728f34 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 12:24:38 +0200 Subject: [PATCH 020/183] feat(files): a frozen or archived object's files refuse every write, through the files API and through Files or WebDAV --- lib/AppInfo/Application.php | 8 + lib/Controller/FilesController.php | 68 ++- lib/Controller/ObjectStateController.php | 24 +- lib/Exception/ObjectStateWriteException.php | 41 ++ lib/Listener/FrozenNodeWriteListener.php | 251 ++++++++++ lib/Service/File/FileOwnershipHandler.php | 2 +- lib/Service/Object/FileWriteGuard.php | 83 ++++ .../changes/object-archive-state/screens.md | 1 + .../changes/object-archive-state/tasks.md | 10 +- .../Contract/FrozenFileWriteContractTest.php | 90 ++++ .../Controller/FilesFrozenResponseTest.php | 122 +++++ .../Listener/FrozenNodeWriteListenerTest.php | 194 ++++++++ .../Service/Object/FileWriteGuardTest.php | 272 +++++++++++ tests/e2e/ci/object-archive-state.spec.ts | 25 + ...ister-frozen-files.postman_collection.json | 453 ++++++++++++++++++ 15 files changed, 1632 insertions(+), 12 deletions(-) create mode 100644 lib/Listener/FrozenNodeWriteListener.php create mode 100644 lib/Service/Object/FileWriteGuard.php create mode 100644 openspec/changes/object-archive-state/screens.md create mode 100644 tests/Unit/Contract/FrozenFileWriteContractTest.php create mode 100644 tests/Unit/Controller/FilesFrozenResponseTest.php create mode 100644 tests/Unit/Listener/FrozenNodeWriteListenerTest.php create mode 100644 tests/Unit/Service/Object/FileWriteGuardTest.php create mode 100644 tests/newman/openregister-frozen-files.postman_collection.json diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 395956d625..a301a9143a 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -2973,6 +2973,14 @@ private function registerEventListeners(IRegistrationContext $context): void { $context->registerEventListener(NodeCreatedEvent::class, FileChangeListener::class); $context->registerEventListener(NodeWrittenEvent::class, FileChangeListener::class); + // REQ-OAS-007: a write into a frozen or archived object's folder + // through Nextcloud Files or WebDAV is aborted; the files API refuses + // through FileWriteGuard in FilesController. + $context->registerEventListener(\OCP\Files\Events\Node\BeforeNodeWrittenEvent::class, \OCA\OpenRegister\Listener\FrozenNodeWriteListener::class); + $context->registerEventListener(\OCP\Files\Events\Node\BeforeNodeCreatedEvent::class, \OCA\OpenRegister\Listener\FrozenNodeWriteListener::class); + $context->registerEventListener(\OCP\Files\Events\Node\BeforeNodeDeletedEvent::class, \OCA\OpenRegister\Listener\FrozenNodeWriteListener::class); + $context->registerEventListener(\OCP\Files\Events\Node\BeforeNodeRenamedEvent::class, \OCA\OpenRegister\Listener\FrozenNodeWriteListener::class); + // Access derived from what an identity provider asserted, once per // sign-in. Costs one app-config read on an instance that declares no // rule, and never fails a sign-in: see the listener. diff --git a/lib/Controller/FilesController.php b/lib/Controller/FilesController.php index f47d9b1e77..bae82e5ddd 100644 --- a/lib/Controller/FilesController.php +++ b/lib/Controller/FilesController.php @@ -35,12 +35,14 @@ use OCA\OpenRegister\Event\FileVersionRestoredEvent; use OCA\OpenRegister\Exception\NotAuthorizedException; use OCA\OpenRegister\Exception\ObjectFileAccessDeniedException; +use OCA\OpenRegister\Exception\ObjectStateWriteException; use OCA\OpenRegister\Exception\OfficeOpenRefusedException; use OCA\OpenRegister\Service\File\ObjectFileAccess; use OCA\OpenRegister\Service\File\OfficeSessionService; use OCA\OpenRegister\Service\File\FileMetadataFormHandler; use OCA\OpenRegister\Service\FileService; use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\Object\FileWriteGuard; use OCA\OpenRegister\Service\File\AttachNodeHandler; use OCA\OpenRegister\Service\Integration\AttachTargetFilter; use OCA\OpenRegister\Service\Integration\TalkChatExporter; @@ -134,6 +136,7 @@ class FilesController extends Controller { * Null only in legacy fixtures. * @param OfficeSessionService|null $officeSessionService Opens documents in Nextcloud Office. * @param AttachNodeHandler|null $attachNodeHandler Copies a Files node into an object through the upload pipeline. + * @param FileWriteGuard|null $fileWriteGuard Refuses a file write on a frozen or archived object. Null only in legacy fixtures. * * @return void * @@ -154,6 +157,7 @@ public function __construct( private readonly ?ObjectFileAccess $objectFileAccess = null, private readonly ?OfficeSessionService $officeSessionService = null, private readonly ?AttachNodeHandler $attachNodeHandler = null, + private readonly ?FileWriteGuard $fileWriteGuard = null, ) { // Call parent constructor to initialize base controller. parent::__construct(appName: $appName, request: $request); @@ -274,6 +278,7 @@ private function setObjectContext(string $register, string $schema): void { * * @throws \OCA\OpenRegister\Exception\NotAuthorizedException When the * authenticated caller may not access the object. + * @throws ObjectStateWriteException When $change is true and the object is frozen or archived (REQ-OAS-007). * * @SuppressWarnings(PHPMD.StaticAccess) WriteCause::asLookup() is the ambient audit-cause frame; there is no instance to inject. */ @@ -303,7 +308,11 @@ private function ensureObjectAccess( // update (403 for a reader who may not update). if ($this->objectFileAccess !== null) { if ($change === true) { - $this->objectFileAccess->changeable(register: $register, schema: $schema, id: $id); + $object = $this->objectFileAccess->changeable(register: $register, schema: $schema, id: $id); + // REQ-OAS-007: a frozen or archived object's files do not + // change either. Asked after the access rule, so a caller + // without access still gets 404/403 and learns nothing of the state. + $this->fileWriteGuard?->assertWritable(object: $object); return; } @@ -312,7 +321,10 @@ private function ensureObjectAccess( } // Legacy wiring without the guard: the object read check alone. - WriteCause::asLookup(fn () => $this->objectService->find(id: $id, register: $register, schema: $schema, _rbac: true)); + $object = WriteCause::asLookup(fn () => $this->objectService->find(id: $id, register: $register, schema: $schema, _rbac: true)); + if ($change === true && $object instanceof ObjectEntity) { + $this->fileWriteGuard?->assertWritable(object: $object); + } }//end ensureObjectAccess() @@ -368,6 +380,8 @@ public function attach(string $register, string $schema, string $id): JSONRespon return $this->accessDenied(e: $e); } catch (DoesNotExistException $e) { return new JSONResponse(data: ['error' => $this->translate(text: 'Object not found')], statusCode: 404); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); }//end try @@ -422,6 +436,8 @@ public function saveChat(string $register, string $schema, string $id): JSONResp return $this->accessDenied(e: $e); } catch (DoesNotExistException $e) { return new JSONResponse(data: ['error' => $this->translate(text: 'Object not found')], statusCode: 404); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); }//end try @@ -476,6 +492,24 @@ private function accessDenied(NotAuthorizedException $e): JSONResponse { ); }//end accessDenied() + /** + * The 409 a file write on a frozen or archived object answers with. + * + * The body carries the sentence and the marker (`state`, `by`, `at`, + * `reason`), the same shape for every write action, so a client reads one + * refusal whatever it tried. 409 and not 423: 423 is a file LOCK, which an + * unlock lifts; a freeze is lifted only by an authorised unfreeze. + * + * @param ObjectStateWriteException $e The refusal. + * + * @return JSONResponse The 409 response. + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + private function stateRefused(ObjectStateWriteException $e): JSONResponse { + return new JSONResponse(data: $e->toResponseBody(), statusCode: ObjectStateWriteException::HTTP_STATUS); + }//end stateRefused() + /** * Record a download event: bump the OR-side download counter and * write an audit-trail row. Best-effort — failures here MUST NOT @@ -820,6 +854,8 @@ public function create( return $this->accessDenied(e: $e); } catch (DoesNotExistException $e) { return new JSONResponse(data: ['error' => $this->translate(text: 'Object not found')], statusCode: 404); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); }//end try @@ -933,6 +969,8 @@ public function save( return $this->accessDenied(e: $e); } catch (DoesNotExistException $e) { return new JSONResponse(data: ['error' => $this->translate(text: 'Object not found')], statusCode: 404); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); }//end try @@ -1047,6 +1085,8 @@ public function createMultipart( return new JSONResponse($formattedFiles['results']); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(['error' => $e->getMessage()], 400); }//end try @@ -1376,6 +1416,8 @@ public function update( return $this->accessDenied(e: $e); } catch (DoesNotExistException $e) { return new JSONResponse(data: ['error' => $this->translate(text: 'Object not found')], statusCode: 404); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); }//end try @@ -1429,6 +1471,8 @@ public function delete( return $this->accessDenied(e: $e); } catch (DoesNotExistException $e) { return new JSONResponse(data: ['error' => $this->translate(text: 'Object not found')], statusCode: 404); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse( data: ['error' => $e->getMessage()], @@ -1769,6 +1813,8 @@ public function rename(string $register, string $schema, string $id, int $fileId return new JSONResponse(data: $this->fileService->formatFile($file)); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { $statusCode = match (true) { str_contains($e->getMessage(), 'already exists') => 409, @@ -1872,6 +1918,8 @@ public function copy(string $register, string $schema, string $id, int $fileId): return new JSONResponse(data: $this->fileService->formatFile($newFile), statusCode: 201); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { $statusCode = 400; if (str_contains($e->getMessage(), 'not found') === true) { @@ -1971,6 +2019,8 @@ public function move(string $register, string $schema, string $id, int $fileId): return new JSONResponse(data: $this->fileService->formatFile($movedFile)); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { $statusCode = match (true) { str_contains($e->getMessage(), 'not found') => 404, @@ -2092,6 +2142,8 @@ public function restoreVersion( return new JSONResponse(data: $this->fileService->formatFile($file)); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { $statusCode = 400; if (str_contains($e->getMessage(), 'not found') === true) { @@ -2154,6 +2206,8 @@ public function lock(string $register, string $schema, string $id, int $fileId): return new JSONResponse(data: $result); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { $statusCode = 400; if (str_contains($e->getMessage(), 'locked') === true) { @@ -2224,6 +2278,8 @@ public function unlock(string $register, string $schema, string $id, int $fileId return new JSONResponse(data: $result); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { $statusCode = match (true) { str_contains($e->getMessage(), 'Only the lock owner') => 403, @@ -2286,6 +2342,8 @@ public function batch(string $register, string $schema, string $id): JSONRespons return new JSONResponse(data: $result, statusCode: $statusCode); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); }//end try @@ -2441,6 +2499,8 @@ public function updateMetadata(string $register, string $schema, string $id, int return new JSONResponse(data: $entity->jsonSerialize()); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); }//end try @@ -2506,6 +2566,8 @@ public function saveMetadataForm(string $register, string $schema, string $id): return new JSONResponse(data: $handler->save(object: $object, entries: $entries)); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); }//end try @@ -2560,6 +2622,8 @@ public function updateLabels(string $register, string $schema, string $id, int $ return new JSONResponse(data: $this->fileService->formatFile($result)); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return $this->accessDenied(e: $e); + } catch (ObjectStateWriteException $e) { + return $this->stateRefused(e: $e); } catch (Exception $e) { return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); }//end try diff --git a/lib/Controller/ObjectStateController.php b/lib/Controller/ObjectStateController.php index a990aaadf0..afa1304eba 100644 --- a/lib/Controller/ObjectStateController.php +++ b/lib/Controller/ObjectStateController.php @@ -167,6 +167,7 @@ public function freeze(string $register, string $schema, string $id): JSONRespon $result = $this->archiveHandler->freeze( identifier: $id, reason: $this->reasonFromRequest(), + state: $this->textFromRequest(key: 'state'), register: $register, schema: $schema ); @@ -235,14 +236,29 @@ private function requireUser(): ?JSONResponse { * @return string|null The reason, or null when none was sent. */ private function reasonFromRequest(): ?string { - $reason = $this->request->getParam('reason'); + return $this->textFromRequest(key: 'reason'); + }//end reasonFromRequest() + + /** + * Read an optional text parameter from the request body. + * + * `state` on a freeze names the lifecycle state (or the consumer's own + * moment, such as a Woo delivery) that froze the object, so the refusal a + * later write gets can say why (REQ-OAS-007 contract). + * + * @param string $key The parameter name. + * + * @return string|null The trimmed text, or null when none was sent. + */ + private function textFromRequest(string $key): ?string { + $value = $this->request->getParam($key); - if (is_string($reason) === false || trim($reason) === '') { + if (is_string($value) === false || trim($value) === '') { return null; } - return trim($reason); - }//end reasonFromRequest() + return trim($value); + }//end textFromRequest() /** * Map a refusal to its status. diff --git a/lib/Exception/ObjectStateWriteException.php b/lib/Exception/ObjectStateWriteException.php index e97f07065f..b911d0929b 100644 --- a/lib/Exception/ObjectStateWriteException.php +++ b/lib/Exception/ObjectStateWriteException.php @@ -93,6 +93,13 @@ class ObjectStateWriteException extends Exception { */ private ?string $occurredAt = null; + /** + * Why the object was put into that state, as the marker recorded it. + * + * @var string|null + */ + private ?string $reason = null; + /** * Build the refusal for an archived object. * @@ -124,6 +131,7 @@ public static function archived(ObjectEntity $object): self { $exception->state = 'archived'; $exception->actor = $actor; $exception->occurredAt = $occurredAt; + $exception->reason = self::stringOrNull(value: ($marker['reason'] ?? null)); return $exception; }//end archived() @@ -161,6 +169,7 @@ public static function frozen(ObjectEntity $object): self { $exception->state = 'frozen'; $exception->actor = $actor; $exception->occurredAt = $occurredAt; + $exception->reason = self::stringOrNull(value: ($marker['reason'] ?? null)); return $exception; }//end frozen() @@ -198,6 +207,38 @@ public function getAt(): ?string { return $this->occurredAt; }//end getAt() + /** + * Why the object entered the refusing state. + * + * @return string|null The reason from the marker, or null when it recorded none. + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + public function getReason(): ?string { + return $this->reason; + }//end getReason() + + /** + * The 409 body a file write answers with: the sentence and the marker. + * + * One shape for every door (the files API, the batch, a consumer that + * relays it), so a client reads `state`, `by`, `at` and `reason` the same + * way wherever the refusal came from. + * + * @return array{error: string, state: string|null, by: string|null, at: string|null, reason: string|null} + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + public function toResponseBody(): array { + return [ + 'error' => $this->getMessage(), + 'state' => $this->state, + 'by' => $this->actor, + 'at' => $this->occurredAt, + 'reason' => $this->reason, + ]; + }//end toResponseBody() + /** * Narrow a marker value to a non-empty string. * diff --git a/lib/Listener/FrozenNodeWriteListener.php b/lib/Listener/FrozenNodeWriteListener.php new file mode 100644 index 0000000000..268f30c53c --- /dev/null +++ b/lib/Listener/FrozenNodeWriteListener.php @@ -0,0 +1,251 @@ + + * @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://www.OpenRegister.app + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Exception\ObjectStateWriteException; +use OCA\OpenRegister\Service\File\FileOwnershipHandler; +use OCA\OpenRegister\Service\Object\FileWriteGuard; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\AbstractNodeEvent; +use OCP\Files\Events\Node\AbstractNodesEvent; +use OCP\Files\Events\Node\BeforeNodeCreatedEvent; +use OCP\Files\Events\Node\BeforeNodeDeletedEvent; +use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\BeforeNodeWrittenEvent; +use OCP\Files\Node; +use OCP\HintException; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Aborts a write, create, delete or rename of a node that sits in a frozen + * or archived object's folder. + * + * The files API refuses through {@see FileWriteGuard} in the controller, but + * an object's folder is also reachable through Nextcloud Files and WebDAV, + * and those never pass the controller. This listener is that door. + * + * How a node finds its object: object folders live in the `openregister` + * account's home and are named by the object's UUID. A node outside that + * home is ignored at once (one string compare, so the rest of the instance + * pays nothing). Inside it, the nearest ancestor named like a UUID is the + * candidate owner, and it counts only when that object's own `folder` is + * that ancestor's id, the same proof `FolderManagementHandler::getObjectForFile()` + * asks for. + * + * Fail closed: when the lookup of a candidate owner FAILS (a database error, + * anything but "no such object"), the write is refused and logged with the + * node id. "No such object" is not a failure: it is the folder of an object + * that is still being created, and refusing it would break every upload. + * + * How the abort reaches the caller: delete and rename have + * `abortOperation()`, which Nextcloud turns into a cancelled operation. + * Write and create have no abort; Nextcloud's hook emitter swallows every + * exception there except a {@see HintException}, so the refusal is thrown as + * one, and the write never starts. + * + * @template-implements IEventListener + */ +class FrozenNodeWriteListener implements IEventListener { + + /** + * How far up from a node the owner search climbs. + * + * Object folders sit directly under a register folder; a few levels of + * subfolders inside an object folder are allowed for, no more. + * + * @var integer + */ + private const MAX_DEPTH = 8; + + /** + * Constructor. + * + * @param FileWriteGuard $guard Decides whether the owning object refuses file writes. + * @param MagicMapper $objects Resolves the owning object by its UUID. + * @param LoggerInterface $logger Records a refusal on an unresolvable owner. + */ + public function __construct( + private readonly FileWriteGuard $guard, + private readonly MagicMapper $objects, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Abort the operation when a node it touches sits in a frozen or archived object's folder. + * + * @param Event $event The node event. + * + * @return void + * + * @throws HintException On a refused write or create (Nextcloud has no abort for those). + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + public function handle(Event $event): void { + $nodes = $this->nodesOf(event: $event); + foreach ($nodes as $node) { + $refusal = $this->refusalFor(node: $node); + if ($refusal === null) { + continue; + } + + if ($event instanceof BeforeNodeRenamedEvent || $event instanceof BeforeNodeDeletedEvent) { + $event->abortOperation(ex: $refusal); + } + + throw new HintException(message: $refusal->getMessage(), hint: $refusal->getMessage(), code: ObjectStateWriteException::HTTP_STATUS, previous: $refusal); + } + }//end handle() + + /** + * The nodes an event writes to: the node itself, or both ends of a rename. + * + * @param Event $event The event. + * + * @return list The nodes to check; empty for an event this listener does not handle. + */ + private function nodesOf(Event $event): array { + if ($event instanceof BeforeNodeRenamedEvent && $event instanceof AbstractNodesEvent) { + return [$event->getSource(), $event->getTarget()]; + } + + $handled = ($event instanceof BeforeNodeWrittenEvent + || $event instanceof BeforeNodeCreatedEvent + || $event instanceof BeforeNodeDeletedEvent); + if ($handled === true && $event instanceof AbstractNodeEvent) { + return [$event->getNode()]; + } + + return []; + }//end nodesOf() + + /** + * The refusal for a write to this node, or null when it may go ahead. + * + * @param Node $node The node about to change. + * + * @return ObjectStateWriteException|null The refusal, or null. + */ + private function refusalFor(Node $node): ?ObjectStateWriteException { + try { + $path = $node->getPath(); + } catch (Throwable $e) { + return null; + } + + $home = '/' . FileOwnershipHandler::APP_USER . '/files/'; + if (str_starts_with($path, $home) === false) { + return null; + } + + $candidate = $this->candidateOwnerFolder(node: $node); + if ($candidate === null) { + return null; + } + + try { + $found = $this->objects->findAcrossAllSources( + identifier: $candidate['uuid'], + _rbac: false, + _multitenancy: false + ); + } catch (DoesNotExistException $e) { + return null; + } catch (Throwable $e) { + $this->logger->warning( + '[FrozenNodeWriteListener] A file write was refused: the owning object of node {node} could not be resolved.', + ['node' => $this->nodeId(node: $node), 'path' => $path, 'exception' => $e] + ); + return new ObjectStateWriteException( + message: 'Cannot write to this file: the object that owns it could not be checked. Try again later.', + previous: $e + ); + } + + $object = ($found['object'] ?? null); + if ($object instanceof ObjectEntity === false) { + return null; + } + + if ((string)$object->getFolder() !== (string)$candidate['folderId']) { + return null; + } + + return $this->guard->refusalFor(object: $object); + }//end refusalFor() + + /** + * The nearest ancestor of a node named like a UUID, with its folder id. + * + * @param Node $node The node. + * + * @return array{uuid: string, folderId: int|string}|null The candidate, or null when no ancestor qualifies. + */ + private function candidateOwnerFolder(Node $node): ?array { + $current = $node; + for ($depth = 0; $depth < self::MAX_DEPTH; $depth++) { + try { + $current = $current->getParent(); + $name = $current->getName(); + } catch (Throwable $e) { + return null; + } + + if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $name) === 1) { + try { + return ['uuid' => $name, 'folderId' => (int)$current->getId()]; + } catch (Throwable $e) { + return null; + } + } + + if ($name === 'files' || $name === '') { + return null; + } + } + + return null; + }//end candidateOwnerFolder() + + /** + * The node's id for the log, or null when it has none yet. + * + * @param Node $node The node. + * + * @return int|null The id. + */ + private function nodeId(Node $node): ?int { + try { + return $node->getId(); + } catch (Throwable $e) { + return null; + } + }//end nodeId() +}//end class diff --git a/lib/Service/File/FileOwnershipHandler.php b/lib/Service/File/FileOwnershipHandler.php index 49cfe63478..0820fa4cc3 100644 --- a/lib/Service/File/FileOwnershipHandler.php +++ b/lib/Service/File/FileOwnershipHandler.php @@ -51,7 +51,7 @@ class FileOwnershipHandler { * * @var string */ - private const APP_USER = 'openregister'; + public const APP_USER = 'openregister'; /** * Application group name. diff --git a/lib/Service/Object/FileWriteGuard.php b/lib/Service/Object/FileWriteGuard.php new file mode 100644 index 0000000000..5c90b718ae --- /dev/null +++ b/lib/Service/Object/FileWriteGuard.php @@ -0,0 +1,83 @@ + + * @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://www.OpenRegister.app + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Object; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Exception\ObjectStateWriteException; + +/** + * Refuses a file write on a frozen or archived object. + * + * The data half already refuses in `SaveObject`. Without this, a frozen + * object's data was fixed while its attachments could still be replaced, + * renamed or deleted, so a frozen record (a withdrawn publication, a + * delivered Woo set) could still change under the same name. One class + * decides, and both doors call it: the files API through + * `FilesController::ensureObjectAccess()` and Nextcloud Files or WebDAV through + * `FrozenNodeWriteListener`. + * + * Archived is asked before frozen, the same order `SaveObject` uses, so an + * object that is both refuses in the same words through every door. + */ +class FileWriteGuard { + + /** + * Refuse when the object is archived or frozen. + * + * @param ObjectEntity $object The object that owns the files. + * + * @return void + * + * @throws ObjectStateWriteException When the object is archived or frozen. + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + public function assertWritable(ObjectEntity $object): void { + $refusal = $this->refusalFor(object: $object); + if ($refusal !== null) { + throw $refusal; + } + }//end assertWritable() + + /** + * The refusal for this object, or null when its files may change. + * + * @param ObjectEntity $object The object that owns the files. + * + * @return ObjectStateWriteException|null The refusal naming the state, or null. + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + public function refusalFor(ObjectEntity $object): ?ObjectStateWriteException { + if ($object->isArchived() === true) { + return ObjectStateWriteException::archived($object); + } + + if ($object->isFrozen() === true) { + return ObjectStateWriteException::frozen($object); + } + + return null; + }//end refusalFor() +}//end class diff --git a/openspec/changes/object-archive-state/screens.md b/openspec/changes/object-archive-state/screens.md new file mode 100644 index 0000000000..3ce6df1eaf --- /dev/null +++ b/openspec/changes/object-archive-state/screens.md @@ -0,0 +1 @@ +- No screen: the archive and freeze are object states set through the API; the file half (REQ-OAS-007) is a refusal on the files API and on the Files/WebDAV door, which the files tab shows as the API's error text. diff --git a/openspec/changes/object-archive-state/tasks.md b/openspec/changes/object-archive-state/tasks.md index 59e0612c9f..f61fbf7a70 100644 --- a/openspec/changes/object-archive-state/tasks.md +++ b/openspec/changes/object-archive-state/tasks.md @@ -65,11 +65,11 @@ ## Woo programme amendment: file writes honour the freeze (REQ-OAS-007, supports 5.18 and 19.15) -- [ ] W.1 Add `lib/Service/Object/FileWriteGuard.php` (resolves the owning object, throws `ObjectStateWriteException::frozen()` or `::archived()`); call it from every write action in `FilesController` (`create`, `save`, `createMultipart`, `update`, `delete`, `rename`, `move`, `batch`, `lock`, `unlock`) and from the `FileService` write methods those actions use. Verify: `tests/Unit/Service/Object/FileWriteGuardTest.php::testEveryFilesControllerWriteActionCallsTheGuard` enumerates the controller's public methods by reflection and fails on a write action without the call; `testAFrozenObjectRefusesAnUpload` through `FilesController::create()` with a real `ObjectEntity` carrying the marker (fails today: the upload succeeds); `testAnUnfrozenObjectAcceptsFileWritesAgain`; `testReadsStayAllowed`. -- [ ] W.2 Add `lib/Listener/FrozenNodeWriteListener.php` on `BeforeNodeWrittenEvent`, `BeforeNodeDeletedEvent`, `BeforeNodeRenamedEvent` and `BeforeNodeCreatedEvent`, registered in `lib/AppInfo/Application.php`, calling `abortOperation()` for a node in a frozen or archived object's folder and for an unresolvable owner inside the register tree. Verify: `tests/Unit/Listener/FrozenNodeWriteListenerTest.php::testAWriteIntoAFrozenObjectFolderIsAborted` and `testAnUnresolvableOwnerInTheRegisterTreeIsRefused`, constructing the real event classes; `testANodeOutsideTheRegisterTreeIsIgnored`. -- [ ] W.3 Map the exception to 409 with `{error, state, by, at, reason}` in the files controller responses. Verify: `tests/Unit/Controller/FilesFrozenResponseTest.php`. -- [ ] W.4 Through the callers: a Newman sequence in `tests/newman/` freezes an object, gets 409 on `POST .../files` and a failed WebDAV `PUT` into its folder, unfreezes, and gets 200; `tests/e2e/ci/object-archive-state.spec.ts` gains a case that freezes an object and sees the upload refused with the reason in the files tab. -- [ ] W.5 Contract for the consumers: freeze through `POST /api/objects/{register}/{schema}/{id}/freeze` with `{reason, state?}`, the 409 body above on a file write. Verify: `tests/Contract/FrozenFileWriteContractTest.php` pins both; `opencatalogi/publication-withdrawal-aftercare` and `dossiq/woo-delivered-set-is-a-record` carry their consumer tests. Every consumer requires OpenRegister, so there is no absent-app path. +- [x] W.1 (`lib/Service/Object/FileWriteGuard.php`, called on the change path of `FilesController::ensureObjectAccess()`, so every write action goes through it; `FileService` writes reach the guard through `FrozenNodeWriteListener`, because they go through the Nextcloud node API; `tests/Unit/Service/Object/FileWriteGuardTest.php`) Add `lib/Service/Object/FileWriteGuard.php` (resolves the owning object, throws `ObjectStateWriteException::frozen()` or `::archived()`); call it from every write action in `FilesController` (`create`, `save`, `createMultipart`, `update`, `delete`, `rename`, `move`, `batch`, `lock`, `unlock`) and from the `FileService` write methods those actions use. Verify: `tests/Unit/Service/Object/FileWriteGuardTest.php::testEveryFilesControllerWriteActionCallsTheGuard` enumerates the controller's public methods by reflection and fails on a write action without the call; `testAFrozenObjectRefusesAnUpload` through `FilesController::create()` with a real `ObjectEntity` carrying the marker (fails today: the upload succeeds); `testAnUnfrozenObjectAcceptsFileWritesAgain`; `testReadsStayAllowed`. +- [x] W.2 (`lib/Listener/FrozenNodeWriteListener.php`; write and create throw a `HintException`, because Nextcloud's hook emitter swallows every other exception there; delete and rename use `abortOperation()`. "Unresolvable" means the owner lookup FAILED; "no such object" is the folder of an object still being created and is let through; `tests/Unit/Listener/FrozenNodeWriteListenerTest.php`) Add `lib/Listener/FrozenNodeWriteListener.php` on `BeforeNodeWrittenEvent`, `BeforeNodeDeletedEvent`, `BeforeNodeRenamedEvent` and `BeforeNodeCreatedEvent`, registered in `lib/AppInfo/Application.php`, calling `abortOperation()` for a node in a frozen or archived object's folder and for an unresolvable owner inside the register tree. Verify: `tests/Unit/Listener/FrozenNodeWriteListenerTest.php::testAWriteIntoAFrozenObjectFolderIsAborted` and `testAnUnresolvableOwnerInTheRegisterTreeIsRefused`, constructing the real event classes; `testANodeOutsideTheRegisterTreeIsIgnored`. +- [x] W.3 (`ObjectStateWriteException::toResponseBody()`, `FilesController::stateRefused()`; `tests/Unit/Controller/FilesFrozenResponseTest.php`) Map the exception to 409 with `{error, state, by, at, reason}` in the files controller responses. Verify: `tests/Unit/Controller/FilesFrozenResponseTest.php`. +- [ ] W.4 (written, live pass, decision 139: `tests/newman/openregister-frozen-files.postman_collection.json` (not registered in run-all.sh until its first live run; the WebDAV PUT is left out because no API user reaches the openregister home over WebDAV without a share) and the new case in `tests/e2e/ci/object-archive-state.spec.ts`) Through the callers: a Newman sequence in `tests/newman/` freezes an object, gets 409 on `POST .../files` and a failed WebDAV `PUT` into its folder, unfreezes, and gets 200; `tests/e2e/ci/object-archive-state.spec.ts` gains a case that freezes an object and sees the upload refused with the reason in the files tab. +- [x] W.5 (`tests/Unit/Contract/FrozenFileWriteContractTest.php`, in the unit suite so it runs on every PR; the freeze route now passes `state` through) Contract for the consumers: freeze through `POST /api/objects/{register}/{schema}/{id}/freeze` with `{reason, state?}`, the 409 body above on a file write. Verify: `tests/Contract/FrozenFileWriteContractTest.php` pins both; `opencatalogi/publication-withdrawal-aftercare` and `dossiq/woo-delivered-set-is-a-record` carry their consumer tests. Every consumer requires OpenRegister, so there is no absent-app path. ## V. Verification and done diff --git a/tests/Unit/Contract/FrozenFileWriteContractTest.php b/tests/Unit/Contract/FrozenFileWriteContractTest.php new file mode 100644 index 0000000000..3c9c7848e5 --- /dev/null +++ b/tests/Unit/Contract/FrozenFileWriteContractTest.php @@ -0,0 +1,90 @@ + + * @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://www.OpenRegister.app + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Contract; + +use OCA\OpenRegister\Controller\ObjectStateController; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Exception\ObjectStateWriteException; +use OCA\OpenRegister\Service\Object\ArchiveHandler; +use OCA\OpenRegister\Service\Object\FileWriteGuard; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; + +/** + * The freeze route and the refusal body. + */ +class FrozenFileWriteContractTest extends TestCase { + + public function testTheFreezeRouteIsPostOnTheObject(): void { + $routes = require __DIR__ . '/../../../appinfo/routes.php'; + $freeze = array_values(array_filter( + $routes['routes'], + static fn (array $route): bool => $route['name'] === 'objectState#freeze' + )); + + $this->assertCount(1, $freeze); + $this->assertSame('/api/objects/{register}/{schema}/{id}/freeze', $freeze[0]['url']); + $this->assertSame('POST', $freeze[0]['verb']); + }//end testTheFreezeRouteIsPostOnTheObject() + + public function testTheFreezeTakesAReasonAndAState(): void { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturnMap([ + ['reason', null, 'Woo-levering'], + ['state', null, 'geleverd'], + ]); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('dossiq-publicatie'); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + + $handler = $this->createMock(ArchiveHandler::class); + $handler->expects($this->once())->method('freeze') + ->with('obj-1', 'Woo-levering', 'geleverd', 'woo', 'wooDeliveredSet') + ->willReturn(['uuid' => 'obj-1', 'frozen' => ['state' => 'geleverd']]); + + $controller = new ObjectStateController('openregister', $request, $handler, $session); + $response = $controller->freeze(register: 'woo', schema: 'wooDeliveredSet', id: 'obj-1'); + + $this->assertSame(200, $response->getStatus()); + }//end testTheFreezeTakesAReasonAndAState() + + public function testAFileWriteRefusalIs409WithTheMarker(): void { + $object = new ObjectEntity(); + $object->setFrozen(['by' => 'dossiq-publicatie', 'at' => '2026-10-10T09:00:00+00:00', 'reason' => 'Woo-levering', 'state' => 'geleverd']); + + $refusal = (new FileWriteGuard())->refusalFor(object: $object); + + $this->assertInstanceOf(ObjectStateWriteException::class, $refusal); + $this->assertSame(409, ObjectStateWriteException::HTTP_STATUS); + $this->assertSame(['error', 'state', 'by', 'at', 'reason'], array_keys($refusal->toResponseBody())); + }//end testAFileWriteRefusalIs409WithTheMarker() +}//end class diff --git a/tests/Unit/Controller/FilesFrozenResponseTest.php b/tests/Unit/Controller/FilesFrozenResponseTest.php new file mode 100644 index 0000000000..e73b1fd361 --- /dev/null +++ b/tests/Unit/Controller/FilesFrozenResponseTest.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://www.OpenRegister.app + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use OCA\OpenRegister\Controller\FilesController; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Exception\ObjectStateWriteException; +use OCA\OpenRegister\Service\File\FileAuditHandler; +use OCA\OpenRegister\Service\File\ObjectFileAccess; +use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Object\FileWriteGuard; +use OCA\OpenRegister\Service\ObjectService; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IRootFolder; +use OCP\IRequest; +use OCP\IUserManager; +use PHPUnit\Framework\TestCase; + +/** + * One body shape for every write action: error, state, by, at, reason. + */ +class FilesFrozenResponseTest extends TestCase { + + private const KEYS = ['error', 'state', 'by', 'at', 'reason']; + + private function frozenObject(?string $state = null): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid('d00d0000-0000-4000-8000-000000000409'); + $object->setRegister('7'); + $object->setSchema('42'); + $object->setFrozen(['by' => 'dossiq-publicatie', 'at' => '2026-10-10T09:00:00+00:00', 'reason' => 'Woo-levering', 'state' => $state]); + + return $object; + }//end frozenObject() + + private function controller(ObjectEntity $object): FilesController { + $objectService = $this->createMock(ObjectService::class); + $objectService->method('setRegister')->willReturnSelf(); + $objectService->method('setSchema')->willReturnSelf(); + $objectService->method('setObject')->willReturnSelf(); + $objectService->method('getObject')->willReturn($object); + + $access = $this->createMock(ObjectFileAccess::class); + $access->method('changeable')->willReturn($object); + + $fileService = $this->createMock(FileService::class); + $fileService->expects($this->never())->method('deleteFile'); + $fileService->expects($this->never())->method('updateFile'); + + return new FilesController( + 'openregister', + $this->createMock(IRequest::class), + $fileService, + $objectService, + $this->createMock(IRootFolder::class), + $this->createMock(IUserManager::class), + $this->createMock(IEventDispatcher::class), + null, + $this->createMock(FileAuditHandler::class), + null, + null, + $access, + null, + null, + new FileWriteGuard() + ); + }//end controller() + + public function testTheBodyCarriesTheMarker(): void { + $body = ObjectStateWriteException::frozen($this->frozenObject(state: 'geleverd'))->toResponseBody(); + + $this->assertSame(self::KEYS, array_keys($body)); + $this->assertSame('frozen', $body['state']); + $this->assertSame('dossiq-publicatie', $body['by']); + $this->assertSame('2026-10-10T09:00:00+00:00', $body['at']); + $this->assertSame('Woo-levering', $body['reason']); + $this->assertStringContainsString('on entering the state "geleverd"', $body['error']); + }//end testTheBodyCarriesTheMarker() + + public function testAnArchiveWithoutAReasonAnswersNull(): void { + $object = new ObjectEntity(); + $object->setArchived(['by' => 'archivaris', 'at' => '2026-10-01T10:00:00+00:00']); + + $body = ObjectStateWriteException::archived($object)->toResponseBody(); + + $this->assertSame('archived', $body['state']); + $this->assertNull($body['reason']); + }//end testAnArchiveWithoutAReasonAnswersNull() + + public function testADeleteAndAnUpdateAnswer409WithTheSameBody(): void { + $object = $this->frozenObject(); + + $delete = $this->controller($object)->delete(register: '7', schema: '42', id: (string)$object->getUuid(), fileId: 12); + $update = $this->controller($object)->update(register: '7', schema: '42', id: (string)$object->getUuid(), fileId: 12); + + foreach ([$delete, $update] as $response) { + $this->assertSame(409, $response->getStatus()); + $this->assertSame(self::KEYS, array_keys($response->getData())); + $this->assertSame('Woo-levering', $response->getData()['reason']); + } + }//end testADeleteAndAnUpdateAnswer409WithTheSameBody() +}//end class diff --git a/tests/Unit/Listener/FrozenNodeWriteListenerTest.php b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php new file mode 100644 index 0000000000..3f90b1198a --- /dev/null +++ b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php @@ -0,0 +1,194 @@ + + * @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://www.OpenRegister.app + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Listener\FrozenNodeWriteListener; +use OCA\OpenRegister\Service\Object\FileWriteGuard; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\DB\Exception as DbException; +use OCP\Exceptions\AbortedEventException; +use OCP\Files\Events\Node\BeforeNodeCreatedEvent; +use OCP\Files\Events\Node\BeforeNodeDeletedEvent; +use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\BeforeNodeWrittenEvent; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\HintException; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Real Nextcloud event classes, a node tree under the openregister home. + */ +class FrozenNodeWriteListenerTest extends TestCase { + + private const UUID = 'c0ffee00-0000-4000-8000-00000000d5e7'; + + private const FOLDER_ID = 4711; + + private MagicMapper&MockObject $objects; + + private LoggerInterface&MockObject $logger; + + protected function setUp(): void { + parent::setUp(); + $this->objects = $this->createMock(MagicMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + }//end setUp() + + private function listener(): FrozenNodeWriteListener { + return new FrozenNodeWriteListener(guard: new FileWriteGuard(), objects: $this->objects, logger: $this->logger); + }//end listener() + + /** + * A folder node with a name, an id and a parent. + */ + private function folder(string $name, int $id, string $path, ?Folder $parent): Folder&MockObject { + $folder = $this->createMock(Folder::class); + $folder->method('getName')->willReturn($name); + $folder->method('getId')->willReturn($id); + $folder->method('getPath')->willReturn($path); + if ($parent !== null) { + $folder->method('getParent')->willReturn($parent); + } + + return $folder; + }//end folder() + + /** + * `/openregister/files/Woo Register//levering-manifest.pdf`. + */ + private function fileInObjectFolder(string $home = '/openregister/files'): File&MockObject { + $files = $this->folder('files', 2, $home, null); + $register = $this->folder('Woo Register', 3, $home . '/Woo Register', $files); + $objectFolder = $this->folder(self::UUID, self::FOLDER_ID, $home . '/Woo Register/' . self::UUID, $register); + + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('levering-manifest.pdf'); + $file->method('getId')->willReturn(9001); + $file->method('getPath')->willReturn($home . '/Woo Register/' . self::UUID . '/levering-manifest.pdf'); + $file->method('getParent')->willReturn($objectFolder); + + return $file; + }//end fileInObjectFolder() + + private function frozenObject(): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid(self::UUID); + $object->setFolder((string)self::FOLDER_ID); + $object->setFrozen(['by' => 'dossiq', 'at' => '2026-10-10T09:00:00+00:00', 'reason' => 'geleverd', 'state' => null]); + + return $object; + }//end frozenObject() + + public function testAWriteIntoAFrozenObjectFolderIsAborted(): void { + $this->objects->method('findAcrossAllSources')->willReturn(['object' => $this->frozenObject(), 'register' => null, 'schema' => null]); + + try { + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileInObjectFolder())); + $this->fail('A write into a frozen object folder went through'); + } catch (HintException $e) { + $this->assertStringContainsString('frozen by dossiq', $e->getMessage()); + $this->assertSame(409, $e->getCode()); + } + }//end testAWriteIntoAFrozenObjectFolderIsAborted() + + public function testACreateInAFrozenObjectFolderIsAborted(): void { + $this->objects->method('findAcrossAllSources')->willReturn(['object' => $this->frozenObject()]); + + $this->expectException(HintException::class); + $this->listener()->handle(new BeforeNodeCreatedEvent($this->fileInObjectFolder())); + }//end testACreateInAFrozenObjectFolderIsAborted() + + public function testADeleteAndARenameAreAbortedThroughAbortOperation(): void { + $this->objects->method('findAcrossAllSources')->willReturn(['object' => $this->frozenObject()]); + + try { + $this->listener()->handle(new BeforeNodeDeletedEvent($this->fileInObjectFolder())); + $this->fail('A delete in a frozen object folder went through'); + } catch (AbortedEventException $e) { + $this->assertStringContainsString('frozen by dossiq', $e->getMessage()); + } + + // Moving a file OUT of the frozen folder is a rename whose source is in it. + $outside = $this->createMock(File::class); + $outside->method('getPath')->willReturn('/alice/files/elsewhere.pdf'); + + $this->expectException(AbortedEventException::class); + $this->listener()->handle(new BeforeNodeRenamedEvent($this->fileInObjectFolder(), $outside)); + }//end testADeleteAndARenameAreAbortedThroughAbortOperation() + + public function testAnUnfrozenObjectFolderAcceptsTheWrite(): void { + $object = $this->frozenObject(); + $object->setFrozen(null); + $this->objects->method('findAcrossAllSources')->willReturn(['object' => $object]); + + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileInObjectFolder())); + $this->addToAssertionCount(1); + }//end testAnUnfrozenObjectFolderAcceptsTheWrite() + + public function testAnUnresolvableOwnerInTheRegisterTreeIsRefused(): void { + $this->objects->method('findAcrossAllSources')->willThrowException(new DbException('connection lost')); + $this->logger->expects($this->once())->method('warning')->with( + $this->stringContains('could not be resolved'), + $this->callback(static fn (array $context): bool => $context['node'] === 9001) + ); + + $this->expectException(HintException::class); + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileInObjectFolder())); + }//end testAnUnresolvableOwnerInTheRegisterTreeIsRefused() + + public function testAFolderOfAnObjectStillBeingCreatedIsNotRefused(): void { + $this->objects->method('findAcrossAllSources')->willThrowException(new DoesNotExistException('none')); + + $this->listener()->handle(new BeforeNodeCreatedEvent($this->fileInObjectFolder())); + $this->addToAssertionCount(1); + }//end testAFolderOfAnObjectStillBeingCreatedIsNotRefused() + + public function testAUuidFolderWhoseObjectPointsElsewhereIsNotTheOwner(): void { + $object = $this->frozenObject(); + $object->setFolder('1234'); + $this->objects->method('findAcrossAllSources')->willReturn(['object' => $object]); + + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileInObjectFolder())); + $this->addToAssertionCount(1); + }//end testAUuidFolderWhoseObjectPointsElsewhereIsNotTheOwner() + + public function testANodeOutsideTheRegisterTreeIsIgnored(): void { + $this->objects->expects($this->never())->method('findAcrossAllSources'); + + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileInObjectFolder('/alice/files'))); + $this->addToAssertionCount(1); + }//end testANodeOutsideTheRegisterTreeIsIgnored() + + public function testAnOtherEventIsIgnored(): void { + $this->objects->expects($this->never())->method('findAcrossAllSources'); + + $this->listener()->handle(new \OCP\Files\Events\Node\BeforeNodeReadEvent($this->createMock(Node::class))); + $this->addToAssertionCount(1); + }//end testAnOtherEventIsIgnored() +}//end class diff --git a/tests/Unit/Service/Object/FileWriteGuardTest.php b/tests/Unit/Service/Object/FileWriteGuardTest.php new file mode 100644 index 0000000000..c63fe9145d --- /dev/null +++ b/tests/Unit/Service/Object/FileWriteGuardTest.php @@ -0,0 +1,272 @@ + + * @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://www.OpenRegister.app + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use OCA\OpenRegister\Controller\FilesController; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Exception\ObjectStateWriteException; +use OCA\OpenRegister\Service\File\FileAuditHandler; +use OCA\OpenRegister\Service\File\ObjectFileAccess; +use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Object\FileWriteGuard; +use OCA\OpenRegister\Service\ObjectService; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\File; +use OCP\Files\IRootFolder; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionMethod; + +/** + * The guard on its own, and the guard as FilesController calls it. + */ +class FileWriteGuardTest extends TestCase { + + private const OBJECT_UUID = 'b2c3d4e5-0000-4000-8000-000000000007'; + + /** + * The write actions REQ-OAS-007 names; each must refuse on a frozen object. + * + * @var list + */ + private const NAMED_WRITE_ACTIONS = [ + 'create', + 'save', + 'createMultipart', + 'update', + 'delete', + 'rename', + 'move', + 'batch', + 'lock', + 'unlock', + ]; + + private FileService&MockObject $fileService; + + private ObjectService&MockObject $objectService; + + private ObjectEntity $object; + + private IUserSession&MockObject $userSession; + + protected function setUp(): void { + parent::setUp(); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('functioneel-beheer'); + $this->userSession = $this->createMock(IUserSession::class); + $this->userSession->method('getUser')->willReturn($user); + + $this->object = new ObjectEntity(); + $this->object->setUuid(self::OBJECT_UUID); + $this->object->setRegister('7'); + $this->object->setSchema('42'); + + $this->fileService = $this->createMock(FileService::class); + $this->fileService->method('getAuditHandler')->willReturn($this->createMock(FileAuditHandler::class)); + $this->fileService->method('formatFile')->willReturn(['id' => 1]); + $this->fileService->method('formatFiles')->willReturnCallback( + static fn (array $files): array => ['results' => $files, 'total' => count($files)] + ); + + $this->objectService = $this->createMock(ObjectService::class); + $this->objectService->method('setRegister')->willReturnSelf(); + $this->objectService->method('setSchema')->willReturnSelf(); + $this->objectService->method('setObject')->willReturnSelf(); + $this->objectService->method('getObject')->willReturn($this->object); + }//end setUp() + + /** + * A controller whose access rule lets the caller in, with the REAL guard. + * + * @param array $params The request parameters. + * + * @return FilesController + */ + private function controller(array $params = []): FilesController { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + $request->method('getParam')->willReturnCallback( + static fn (string $key, mixed $default = null): mixed => ($params[$key] ?? $default) + ); + + $access = $this->createMock(ObjectFileAccess::class); + $access->method('changeable')->willReturn($this->object); + $access->method('readable')->willReturn($this->object); + + $userManager = $this->createMock(IUserManager::class); + + return new FilesController( + 'openregister', + $request, + $this->fileService, + $this->objectService, + $this->createMock(IRootFolder::class), + $userManager, + $this->createMock(IEventDispatcher::class), + null, + $this->createMock(FileAuditHandler::class), + $this->userSession, + null, + $access, + null, + null, + new FileWriteGuard() + ); + }//end controller() + + /** + * Every public action that changes files catches the refusal and answers 409. + * + * Enumerated by reflection, so a write action added later without the + * catch fails here instead of answering 400 with the refusal as text. + * + * @return void + */ + public function testEveryFilesControllerWriteActionCallsTheGuard(): void { + $class = new ReflectionClass(FilesController::class); + $lines = file((string)$class->getFileName()); + $this->assertIsArray($lines); + + $writeActions = []; + foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() !== FilesController::class) { + continue; + } + + $body = implode('', array_slice($lines, ($method->getStartLine() - 1), ($method->getEndLine() - $method->getStartLine() + 1))); + $changes = preg_match_all('/ensureObjectAccess\((?![^)]*change: false)[^)]*\)/', $body); + if ($changes === 0) { + continue; + } + + $writeActions[] = $method->getName(); + $this->assertStringContainsString( + 'catch (ObjectStateWriteException $e)', + $body, + 'FilesController::' . $method->getName() . '() changes files but does not answer a frozen object with 409' + ); + } + + foreach (self::NAMED_WRITE_ACTIONS as $action) { + $this->assertContains($action, $writeActions, 'FilesController::' . $action . '() must go through the guard'); + } + + // The guard itself sits on the change path of ensureObjectAccess(). + $ensure = $class->getMethod('ensureObjectAccess'); + $ensureBody = implode('', array_slice($lines, ($ensure->getStartLine() - 1), ($ensure->getEndLine() - $ensure->getStartLine() + 1))); + $this->assertSame(2, substr_count($ensureBody, 'fileWriteGuard?->assertWritable(')); + }//end testEveryFilesControllerWriteActionCallsTheGuard() + + /** + * An upload to a frozen object is refused with the marker in the body. + * + * @return void + */ + public function testAFrozenObjectRefusesAnUpload(): void { + $this->object->freeze(userSession: $this->userSession, reason: 'ingetrokken, besluit 2026-114'); + $this->fileService->expects($this->never())->method('addFile'); + + $response = $this->controller(['name' => 'bijlage.pdf', 'content' => 'x']) + ->create(register: '7', schema: '42', id: self::OBJECT_UUID); + + $this->assertSame(409, $response->getStatus()); + $body = $response->getData(); + $this->assertSame('frozen', $body['state']); + $this->assertSame('functioneel-beheer', $body['by']); + $this->assertSame('ingetrokken, besluit 2026-114', $body['reason']); + $this->assertNotEmpty($body['at']); + $this->assertStringContainsString('frozen by functioneel-beheer', $body['error']); + }//end testAFrozenObjectRefusesAnUpload() + + /** + * An archived object refuses a lock as well: a lock is a write. + * + * @return void + */ + public function testAnArchivedObjectRefusesALock(): void { + $this->object->setArchived(['by' => 'archivaris', 'at' => '2026-10-01T10:00:00+00:00', 'reason' => 'overgebracht']); + $this->fileService->expects($this->never())->method('getLockHandler'); + + $response = $this->controller()->lock(register: '7', schema: '42', id: self::OBJECT_UUID, fileId: 5); + + $this->assertSame(409, $response->getStatus()); + $this->assertSame('archived', $response->getData()['state']); + $this->assertSame('overgebracht', $response->getData()['reason']); + }//end testAnArchivedObjectRefusesALock() + + /** + * After an unfreeze the same upload goes through. + * + * @return void + */ + public function testAnUnfrozenObjectAcceptsFileWritesAgain(): void { + $this->object->freeze(userSession: $this->userSession, reason: 'tijdelijk'); + $this->object->setFrozen(null); + $this->fileService->expects($this->once())->method('addFile')->willReturn($this->createMock(File::class)); + + $response = $this->controller(['name' => 'bijlage.pdf', 'content' => 'x']) + ->create(register: '7', schema: '42', id: self::OBJECT_UUID); + + $this->assertSame(200, $response->getStatus()); + }//end testAnUnfrozenObjectAcceptsFileWritesAgain() + + /** + * Reading a frozen object's files stays allowed. + * + * @return void + */ + public function testReadsStayAllowed(): void { + $this->object->freeze(userSession: $this->userSession, reason: 'ingetrokken'); + $this->fileService->expects($this->once())->method('getFiles')->willReturn([$this->createMock(File::class)]); + + $response = $this->controller()->index(register: '7', schema: '42', id: self::OBJECT_UUID); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame(1, $response->getData()['total']); + }//end testReadsStayAllowed() + + /** + * The guard on its own: archived before frozen, nothing for a plain object. + * + * @return void + */ + public function testTheGuardNamesTheStateAndPassesAPlainObject(): void { + $guard = new FileWriteGuard(); + $this->assertNull($guard->refusalFor(object: $this->object)); + $guard->assertWritable(object: $this->object); + + $this->object->freeze(userSession: $this->userSession, reason: 'r'); + $this->object->setArchived(['by' => 'a', 'at' => '2026-10-01T10:00:00+00:00']); + $this->assertSame('archived', $guard->refusalFor(object: $this->object)?->getState()); + + $this->expectException(ObjectStateWriteException::class); + $guard->assertWritable(object: $this->object); + }//end testTheGuardNamesTheStateAndPassesAPlainObject() +}//end class diff --git a/tests/e2e/ci/object-archive-state.spec.ts b/tests/e2e/ci/object-archive-state.spec.ts index ba5c1895e2..3daea26fd3 100644 --- a/tests/e2e/ci/object-archive-state.spec.ts +++ b/tests/e2e/ci/object-archive-state.spec.ts @@ -395,6 +395,31 @@ test.describe('object archive state over HTTP', () => { expect(await write.text()).toMatch(/frozen/i) }) + // REQ-OAS-007 (task W.4): the freeze reaches the object's files. The + // refusal is 409 with the marker, a read of the files still answers, and + // an unfreeze lifts the refusal. + test('a frozen object refuses a file upload with the reason, and accepts it after an unfreeze', async () => { + const filesUrl = `${API}/objects/${registerId}/${schemaId}/${frozenUuid}/files` + const upload = { data: { name: `bijlage-${RUN}.txt`, content: 'levering' } } + + const refused = await admin.post(filesUrl, upload) + expect(refused.status(), 'an upload to a frozen object answers 409').toBe(409) + const body = await refused.json() + expect(body.state).toBe('frozen') + expect(body.reason).toBe('bezwaar') + expect(body.by, 'the refusal names who froze it').toBeTruthy() + expect(body.error).toMatch(/frozen/i) + + const listed = await admin.get(filesUrl) + expect(listed.ok(), 'reading a frozen object\'s files stays allowed').toBeTruthy() + + const unfrozen = await admin.delete(`${API}/objects/${registerId}/${schemaId}/${frozenUuid}/freeze`) + expect(unfrozen.ok(), `unfreeze failed: ${await unfrozen.text()}`).toBeTruthy() + + const accepted = await admin.post(filesUrl, upload) + expect(accepted.ok(), `after an unfreeze the upload goes through: ${await accepted.text()}`).toBeTruthy() + }) + test('an immutable property accepts its first value and refuses the next', async () => { const first = await admin.put( `${API}/objects/${registerId}/${besluitSchemaId}/${besluitUuid}`, diff --git a/tests/newman/openregister-frozen-files.postman_collection.json b/tests/newman/openregister-frozen-files.postman_collection.json new file mode 100644 index 0000000000..0e6853e416 --- /dev/null +++ b/tests/newman/openregister-frozen-files.postman_collection.json @@ -0,0 +1,453 @@ +{ + "info": { + "name": "OpenRegister frozen object files (REQ-OAS-007)", + "description": "object-archive-state task W.4. Freezes an object, gets 409 with the marker on a file upload, reads its files, unfreezes, uploads again. The WebDAV PUT into the frozen folder is not in this collection: the folder lives in the openregister account's home, which no API user reaches over WebDAV without a share; that door is covered by FrozenNodeWriteListenerTest with the real Nextcloud events. Not registered in run-all.sh until its first live pass (decision 139).", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "if (!pm.collectionVariables.get('run')) { pm.collectionVariables.set('run', Math.random().toString(36).slice(2, 10)); }" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080" + }, + { + "key": "adminUser", + "value": "admin" + }, + { + "key": "adminPassword", + "value": "admin" + }, + { + "key": "run", + "value": "" + } + ], + "item": [ + { + "name": "Create register", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/registers" + }, + "body": { + "mode": "raw", + "raw": "{\"title\": \"frozen-files-{{run}}\", \"slug\": \"frozen-files-{{run}}\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('register created', () => pm.expect(pm.response.code).to.be.oneOf([200,201]));", + "pm.collectionVariables.set('registerId', String(pm.response.json().id));" + ] + } + } + ] + }, + { + "name": "Create schema", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/schemas" + }, + "body": { + "mode": "raw", + "raw": "{\"title\": \"frozen-files-{{run}}\", \"slug\": \"frozen-files-{{run}}\", \"properties\": {\"title\": {\"type\": \"string\"}}}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('schema created', () => pm.expect(pm.response.code).to.be.oneOf([200,201]));", + "pm.collectionVariables.set('schemaId', String(pm.response.json().id));" + ] + } + } + ] + }, + { + "name": "Attach schema to register", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/registers/{{registerId}}" + }, + "body": { + "mode": "raw", + "raw": "{\"title\": \"frozen-files-{{run}}\", \"slug\": \"frozen-files-{{run}}\", \"schemas\": [\"{{schemaId}}\"]}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('register updated', () => pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Create object", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}" + }, + "body": { + "mode": "raw", + "raw": "{\"title\": \"levering\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('object created', () => pm.expect(pm.response.code).to.be.oneOf([200,201]));", + "const b = pm.response.json();", + "pm.collectionVariables.set('objectId', (b['@self'] && b['@self'].id) || b.uuid || b.id);" + ] + } + } + ] + }, + { + "name": "Freeze the object", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{objectId}}/freeze" + }, + "body": { + "mode": "raw", + "raw": "{\"reason\": \"Woo-levering\", \"state\": \"geleverd\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('frozen', () => pm.response.to.have.status(200));", + "pm.test('marker carries the state', () => pm.expect(pm.response.json().frozen.state).to.eql('geleverd'));" + ] + } + } + ] + }, + { + "name": "Upload to the frozen object is 409 with the marker", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{objectId}}/files" + }, + "body": { + "mode": "raw", + "raw": "{\"name\": \"manifest.txt\", \"content\": \"x\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('409', () => pm.response.to.have.status(409));", + "const b = pm.response.json();", + "pm.test('body names state, by, at, reason', () => { pm.expect(b.state).to.eql('frozen'); pm.expect(b.reason).to.eql('Woo-levering'); pm.expect(b.by).to.be.a('string'); pm.expect(b.at).to.be.a('string'); pm.expect(b.error).to.match(/frozen/i); });" + ] + } + } + ] + }, + { + "name": "Reading the files stays allowed", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{objectId}}/files" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Unfreeze", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{objectId}}/freeze" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('unfrozen', () => pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Upload after the unfreeze goes through", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{objectId}}/files" + }, + "body": { + "mode": "raw", + "raw": "{\"name\": \"manifest.txt\", \"content\": \"x\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Cleanup object", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{objectId}}" + } + } + }, + { + "name": "Cleanup schema", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/schemas/{{schemaId}}" + } + } + }, + { + "name": "Cleanup register", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "OCS-APIRequest", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/index.php/apps/openregister/api/registers/{{registerId}}" + } + } + } + ] +} \ No newline at end of file From 367f261f7520dfd48fc8142a36708bfeba25e075 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 12:36:28 +0200 Subject: [PATCH 021/183] fix(mcp): drop two array_values calls phpstan proves have no effect --- lib/Mcp/AttributeToolScanner.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Mcp/AttributeToolScanner.php b/lib/Mcp/AttributeToolScanner.php index 4fc2146eb9..e807535534 100644 --- a/lib/Mcp/AttributeToolScanner.php +++ b/lib/Mcp/AttributeToolScanner.php @@ -356,11 +356,11 @@ private function hasUnknownScope(McpTool $attribute): bool { */ private function unrecognisedDeclaration(McpTool $attribute): ?array { if ($this->hasUnknownScope(attribute: $attribute) === true) { - return ['key' => 'scope', 'value' => (string) $attribute->scope, 'allowed' => array_values(McpAnnotationValidator::SCOPES)]; + return ['key' => 'scope', 'value' => (string) $attribute->scope, 'allowed' => McpAnnotationValidator::SCOPES]; } if ($attribute->reach !== null && in_array($attribute->reach, ToolReachResolver::ORDER, true) === false) { - return ['key' => 'reach', 'value' => $attribute->reach, 'allowed' => array_values(ToolReachResolver::ORDER)]; + return ['key' => 'reach', 'value' => $attribute->reach, 'allowed' => ToolReachResolver::ORDER]; } return null; From 083ac5dfebf3d1c227eb05c09ab457c81491befe Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 12:36:55 +0200 Subject: [PATCH 022/183] fix(registry): an inbound registry update patches the row instead of replacing it applyInboundUpdate() saved the registry's changed properties through saveObject() with the object's uuid. saveObject() is PUT-semantic and writes every absent property as null, so each BRP or KvK update nulled the identity the row is matched on (BSN, KvK number) and every field the registry does not own. It now goes through patchObject(), which merges onto the stored object. Found building dossiq contacts-domain 4.3 (Tier B B22), which depends on this path. --- .../Registry/RegistrySubscriptionService.php | 13 ++++++++++--- .../RegistrySubscriptionServiceTest.php | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/Service/Registry/RegistrySubscriptionService.php b/lib/Service/Registry/RegistrySubscriptionService.php index a50c3d8b70..0c1158a468 100644 --- a/lib/Service/Registry/RegistrySubscriptionService.php +++ b/lib/Service/Registry/RegistrySubscriptionService.php @@ -236,11 +236,18 @@ public function applyInboundUpdate( } try { - $updated = $this->objectService->saveObject( - object: $properties, + // A PATCH, never saveObject(): saveObject() is PUT-semantic and + // writes every property absent from the payload as null. An + // inbound update carries only what the registry changed, so a + // save would null the identity this row is matched on (the + // BSN, the KvK number) and every field the registry does not + // own, on every update. patchObject() merges onto the stored + // object and leaves the rest alone. + $updated = $this->objectService->patchObject( + objectId: (string)$row->getObjectUuid(), + data: $properties, register: $row->getRegister(), schema: $row->getSchema(), - uuid: $row->getObjectUuid(), ); } catch (Throwable $e) { $this->logger->error( diff --git a/tests/Unit/Service/Registry/RegistrySubscriptionServiceTest.php b/tests/Unit/Service/Registry/RegistrySubscriptionServiceTest.php index 1cca1a0f4b..2f525b92ff 100644 --- a/tests/Unit/Service/Registry/RegistrySubscriptionServiceTest.php +++ b/tests/Unit/Service/Registry/RegistrySubscriptionServiceTest.php @@ -168,7 +168,7 @@ public function testApplyInboundUpdateWithNoMatchesAppliesNothing(): void { $this->assertSame([], $result['rejected']); } - public function testApplyInboundUpdateAppliesOwnedPropertiesThroughSaveObject(): void { + public function testApplyInboundUpdatePatchesOnlyTheOwnedPropertiesItCarries(): void { $row = new RegistrySubscription(); $row->setObjectUuid('obj-uuid-1'); $row->setRegister('dossiq'); @@ -188,14 +188,20 @@ public function testApplyInboundUpdateAppliesOwnedPropertiesThroughSaveObject(): // service calls both methods with named arguments) would make the // mock fall through unmatched and this test would then be // exercising nothing. + // 🔴 A PATCH, NEVER saveObject(). saveObject() is PUT-semantic: a + // property absent from the payload is written as null. An inbound + // update carries only the properties the registry changed, so saving + // it whole would null the identity (the BSN this row is matched on) + // and every other field, on every update. $saved = $this->personObject(); + $this->objectService->expects($this->never())->method('saveObject'); $this->objectService->expects($this->once()) - ->method('saveObject') - ->willReturnCallback(function ($object, $extend = [], $register = null, $schema = null, $uuid = null) use ($saved) { - $this->assertSame(['address' => 'Dam 1'], $object); + ->method('patchObject') + ->willReturnCallback(function ($objectId, $data, $register = null, $schema = null) use ($saved) { + $this->assertSame('obj-uuid-1', $objectId); + $this->assertSame(['address' => 'Dam 1'], $data); $this->assertSame('dossiq', $register); $this->assertSame('brpPerson', $schema); - $this->assertSame('obj-uuid-1', $uuid); return $saved; }); @@ -227,6 +233,7 @@ public function testApplyInboundUpdateRejectsAPropertyOutsideOwned(): void { $this->updateTargetGuard->method('evaluate')->willReturn(['allowed' => false, 'rejected' => ['notes']]); $this->objectService->expects($this->never())->method('saveObject'); + $this->objectService->expects($this->never())->method('patchObject'); $result = $this->service->applyInboundUpdate('brp', '999990019', ['notes' => 'secret'], 'evt-1'); @@ -249,6 +256,7 @@ public function testApplyInboundUpdateWithEmptyPayloadAnnouncesNothing(): void { $this->updateTargetGuard->method('evaluate')->willReturn(['allowed' => true, 'rejected' => []]); $this->objectService->expects($this->never())->method('saveObject'); + $this->objectService->expects($this->never())->method('patchObject'); $this->notifier->expects($this->never())->method('auditInboundUpdate'); $result = $this->service->applyInboundUpdate('brp', '999990019', [], 'evt-1'); From e681a4274d39203f5501d8e62b721029d438119b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 13:24:20 +0200 Subject: [PATCH 023/183] fix(files): @spec on the guard and the listener, one instanceof fewer --- lib/Listener/FrozenNodeWriteListener.php | 12 +++++++++--- lib/Service/Object/FileWriteGuard.php | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/Listener/FrozenNodeWriteListener.php b/lib/Listener/FrozenNodeWriteListener.php index 268f30c53c..5bf377de6d 100644 --- a/lib/Listener/FrozenNodeWriteListener.php +++ b/lib/Listener/FrozenNodeWriteListener.php @@ -32,7 +32,6 @@ use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Files\Events\Node\AbstractNodeEvent; -use OCP\Files\Events\Node\AbstractNodesEvent; use OCP\Files\Events\Node\BeforeNodeCreatedEvent; use OCP\Files\Events\Node\BeforeNodeDeletedEvent; use OCP\Files\Events\Node\BeforeNodeRenamedEvent; @@ -70,6 +69,8 @@ * one, and the write never starts. * * @template-implements IEventListener + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 */ class FrozenNodeWriteListener implements IEventListener { @@ -120,7 +121,12 @@ public function handle(Event $event): void { $event->abortOperation(ex: $refusal); } - throw new HintException(message: $refusal->getMessage(), hint: $refusal->getMessage(), code: ObjectStateWriteException::HTTP_STATUS, previous: $refusal); + throw new HintException( + message: $refusal->getMessage(), + hint: $refusal->getMessage(), + code: ObjectStateWriteException::HTTP_STATUS, + previous: $refusal + ); } }//end handle() @@ -132,7 +138,7 @@ public function handle(Event $event): void { * @return list The nodes to check; empty for an event this listener does not handle. */ private function nodesOf(Event $event): array { - if ($event instanceof BeforeNodeRenamedEvent && $event instanceof AbstractNodesEvent) { + if ($event instanceof BeforeNodeRenamedEvent) { return [$event->getSource(), $event->getTarget()]; } diff --git a/lib/Service/Object/FileWriteGuard.php b/lib/Service/Object/FileWriteGuard.php index 5c90b718ae..4dd13ea3c3 100644 --- a/lib/Service/Object/FileWriteGuard.php +++ b/lib/Service/Object/FileWriteGuard.php @@ -39,6 +39,8 @@ * * Archived is asked before frozen, the same order `SaveObject` uses, so an * object that is both refuses in the same words through every door. + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 */ class FileWriteGuard { From b072f9db6253e037a2389a6ecba6f3e85afdefe8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 13:26:11 +0200 Subject: [PATCH 024/183] fix(cases): the case steps live in the case layer, so the flow engine never depends on it CaseControllerTest holds that nothing under lib/Service/Flow references Service\Case. The open-a-case and advance-a-case steps move to lib/Service/Case/Nodes; they are registered from lib/Listener as before. --- lib/Listener/FlowNodeRegistrationListener.php | 4 ++-- lib/Service/{Flow => Case}/Nodes/CaseAdvanceNode.php | 4 ++-- lib/Service/{Flow => Case}/Nodes/CaseNodeBase.php | 4 ++-- lib/Service/{Flow => Case}/Nodes/CaseOpenNode.php | 4 ++-- .../archive/2026-10-10-one-engine-bpmn-and-cmmn/tasks.md | 4 ++-- openspec/parity/capabilities.json | 2 +- tests/Unit/Listener/FlowNodeRegistrationListenerTest.php | 4 ++-- .../Service/{Flow => Case}/Nodes/CaseFlowNodesTest.php | 8 ++++---- tests/Unit/Service/Flow/FlowNodeDeclaredTaxonomyTest.php | 5 ----- 9 files changed, 17 insertions(+), 22 deletions(-) rename lib/Service/{Flow => Case}/Nodes/CaseAdvanceNode.php (98%) rename lib/Service/{Flow => Case}/Nodes/CaseNodeBase.php (98%) rename lib/Service/{Flow => Case}/Nodes/CaseOpenNode.php (98%) rename tests/Unit/Service/{Flow => Case}/Nodes/CaseFlowNodesTest.php (97%) diff --git a/lib/Listener/FlowNodeRegistrationListener.php b/lib/Listener/FlowNodeRegistrationListener.php index a03ac343ec..3a2e3557a0 100644 --- a/lib/Listener/FlowNodeRegistrationListener.php +++ b/lib/Listener/FlowNodeRegistrationListener.php @@ -53,8 +53,8 @@ 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\CaseAdvanceNode; -use OCA\OpenRegister\Service\Flow\Nodes\CaseOpenNode; +use OCA\OpenRegister\Service\Case\Nodes\CaseAdvanceNode; +use OCA\OpenRegister\Service\Case\Nodes\CaseOpenNode; use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; use OCA\OpenRegister\Service\Flow\Nodes\WaitNode; use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; diff --git a/lib/Service/Flow/Nodes/CaseAdvanceNode.php b/lib/Service/Case/Nodes/CaseAdvanceNode.php similarity index 98% rename from lib/Service/Flow/Nodes/CaseAdvanceNode.php rename to lib/Service/Case/Nodes/CaseAdvanceNode.php index afa137ff49..85990ca4fa 100644 --- a/lib/Service/Flow/Nodes/CaseAdvanceNode.php +++ b/lib/Service/Case/Nodes/CaseAdvanceNode.php @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EUPL-1.2 * * @category Service - * @package OCA\OpenRegister\Service\Flow\Nodes + * @package OCA\OpenRegister\Service\Case\Nodes * * @author Conduction Development Team * @copyright 2026 Conduction B.V. @@ -27,7 +27,7 @@ declare(strict_types=1); -namespace OCA\OpenRegister\Service\Flow\Nodes; +namespace OCA\OpenRegister\Service\Case\Nodes; use OCA\OpenRegister\Db\CaseItem; use OCA\OpenRegister\Service\Flow\FlowItems; diff --git a/lib/Service/Flow/Nodes/CaseNodeBase.php b/lib/Service/Case/Nodes/CaseNodeBase.php similarity index 98% rename from lib/Service/Flow/Nodes/CaseNodeBase.php rename to lib/Service/Case/Nodes/CaseNodeBase.php index 2eae281d38..87d36fe8fe 100644 --- a/lib/Service/Flow/Nodes/CaseNodeBase.php +++ b/lib/Service/Case/Nodes/CaseNodeBase.php @@ -15,7 +15,7 @@ * SPDX-License-Identifier: EUPL-1.2 * * @category Service - * @package OCA\OpenRegister\Service\Flow\Nodes + * @package OCA\OpenRegister\Service\Case\Nodes * * @author Conduction Development Team * @copyright 2026 Conduction B.V. @@ -28,7 +28,7 @@ declare(strict_types=1); -namespace OCA\OpenRegister\Service\Flow\Nodes; +namespace OCA\OpenRegister\Service\Case\Nodes; use OCA\OpenRegister\Service\Case\CasePlanService; use OCA\OpenRegister\Service\Flow\FlowItems; diff --git a/lib/Service/Flow/Nodes/CaseOpenNode.php b/lib/Service/Case/Nodes/CaseOpenNode.php similarity index 98% rename from lib/Service/Flow/Nodes/CaseOpenNode.php rename to lib/Service/Case/Nodes/CaseOpenNode.php index d0d63f706e..37c486a9fa 100644 --- a/lib/Service/Flow/Nodes/CaseOpenNode.php +++ b/lib/Service/Case/Nodes/CaseOpenNode.php @@ -13,7 +13,7 @@ * SPDX-License-Identifier: EUPL-1.2 * * @category Service - * @package OCA\OpenRegister\Service\Flow\Nodes + * @package OCA\OpenRegister\Service\Case\Nodes * * @author Conduction Development Team * @copyright 2026 Conduction B.V. @@ -26,7 +26,7 @@ declare(strict_types=1); -namespace OCA\OpenRegister\Service\Flow\Nodes; +namespace OCA\OpenRegister\Service\Case\Nodes; use OCA\OpenRegister\Exception\CasePlanExistsException; use OCA\OpenRegister\Service\Flow\FlowItems; diff --git a/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/tasks.md b/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/tasks.md index 73d041f42a..2cedb62416 100644 --- a/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/tasks.md +++ b/openspec/changes/archive/2026-10-10-one-engine-bpmn-and-cmmn/tasks.md @@ -23,8 +23,8 @@ Small PRs, in this order. PR 1 is what dossiq's drain waits on. ## 4. Flow nodes that open and advance a case (PR 4) -- [x] 4.1 `CaseOpenNode` (`openregister.case-open`), own unit test: creates on the run's subject as the run's identity; existing plan reported on output (`CasePlanExistsException`). (lib/Service/Flow/Nodes/CaseOpenNode.php, CaseNodeBase.php, CaseFlowNodesTest) -- [x] 4.2 `CaseAdvanceNode` (`openregister.case-advance`), own unit test: transitions by key; refusal fails the step with the engine's message. (lib/Service/Flow/Nodes/CaseAdvanceNode.php, CaseFlowNodesTest) +- [x] 4.1 `CaseOpenNode` (`openregister.case-open`), own unit test: creates on the run's subject as the run's identity; existing plan reported on output (`CasePlanExistsException`). (lib/Service/Case/Nodes/CaseOpenNode.php, CaseNodeBase.php, CaseFlowNodesTest) +- [x] 4.2 `CaseAdvanceNode` (`openregister.case-advance`), own unit test: transitions by key; refusal fails the step with the engine's message. (lib/Service/Case/Nodes/CaseAdvanceNode.php, CaseFlowNodesTest) - [x] 4.3 Register both through `FlowNodeRegistrationListener` (FlowNodeRegistrationListenerTest: 29 built-ins) and the declared taxonomy table (FlowNodeDeclaredTaxonomyTest). Palette strings follow every other node: through IL10N, not in the backend catalogue (no built-in node's strings are in `l10n/en.json`; adding them would oblige all required locales at once). ## 5. Close diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 0cd5f6255a..6615baf922 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -8771,7 +8771,7 @@ "built": { "state": "built", "owner": "ConductionNL/openregister", - "evidence": "Code carrying @spec openspec/specs/flow-cases: lib/Controller/CaseController.php, lib/Db/CaseItem.php, lib/Db/CaseItemAudit.php, lib/Db/CaseItemAuditMapper.php. One engine (one-engine-bpmn-and-cmmn): lib/Service/Case/CasePlanEnsurer.php, lib/Service/Case/CasePlanItemTimers.php, lib/Service/Flow/Nodes/CaseOpenNode.php, lib/Service/Flow/Nodes/CaseAdvanceNode.php.", + "evidence": "Code carrying @spec openspec/specs/flow-cases: lib/Controller/CaseController.php, lib/Db/CaseItem.php, lib/Db/CaseItemAudit.php, lib/Db/CaseItemAuditMapper.php. One engine (one-engine-bpmn-and-cmmn): lib/Service/Case/CasePlanEnsurer.php, lib/Service/Case/CasePlanItemTimers.php, lib/Service/Case/Nodes/CaseOpenNode.php, lib/Service/Case/Nodes/CaseAdvanceNode.php.", "spec": "openspec/specs/flow-cases", "change": "2026-10-10-one-engine-bpmn-and-cmmn" }, diff --git a/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php b/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php index 47f0902882..3ceb6de206 100644 --- a/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php +++ b/tests/Unit/Listener/FlowNodeRegistrationListenerTest.php @@ -47,8 +47,8 @@ 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\CaseAdvanceNode; -use OCA\OpenRegister\Service\Flow\Nodes\CaseOpenNode; +use OCA\OpenRegister\Service\Case\Nodes\CaseAdvanceNode; +use OCA\OpenRegister\Service\Case\Nodes\CaseOpenNode; use OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode; use OCA\OpenRegister\Service\Flow\Nodes\WaitNode; use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; diff --git a/tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php b/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php similarity index 97% rename from tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php rename to tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php index d5dda5207b..c7abef1b2e 100644 --- a/tests/Unit/Service/Flow/Nodes/CaseFlowNodesTest.php +++ b/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: EUPL-1.2 * * @category Tests - * @package OCA\OpenRegister\Tests\Unit\Service\Flow\Nodes + * @package OCA\OpenRegister\Tests\Unit\Service\Case\Nodes * * @author Conduction Development Team * @copyright 2026 Conduction B.V. @@ -20,7 +20,7 @@ declare(strict_types=1); -namespace OCA\OpenRegister\Tests\Unit\Service\Flow\Nodes; +namespace OCA\OpenRegister\Tests\Unit\Service\Case\Nodes; use OCA\OpenRegister\Db\CaseItem; use OCA\OpenRegister\Exception\CaseAccessDeniedException; @@ -40,8 +40,8 @@ use OCA\OpenRegister\Service\Flow\FlowRunAsScope; use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\IFlowNodeTaxonomy; -use OCA\OpenRegister\Service\Flow\Nodes\CaseAdvanceNode; -use OCA\OpenRegister\Service\Flow\Nodes\CaseOpenNode; +use OCA\OpenRegister\Service\Case\Nodes\CaseAdvanceNode; +use OCA\OpenRegister\Service\Case\Nodes\CaseOpenNode; use OCA\OpenRegister\Tests\Unit\Service\Case\CaseFixtures; use OCA\OpenRegister\Tests\Unit\Service\Case\FakeCaseItemMapper; use OCA\OpenRegister\Tests\Unit\Service\Case\RecordingAuditMapper; diff --git a/tests/Unit/Service/Flow/FlowNodeDeclaredTaxonomyTest.php b/tests/Unit/Service/Flow/FlowNodeDeclaredTaxonomyTest.php index 1bd9864711..b9c86b8266 100644 --- a/tests/Unit/Service/Flow/FlowNodeDeclaredTaxonomyTest.php +++ b/tests/Unit/Service/Flow/FlowNodeDeclaredTaxonomyTest.php @@ -75,8 +75,6 @@ * @covers \OCA\OpenRegister\Service\Flow\Nodes\TriggerObjectNode * @covers \OCA\OpenRegister\Service\Flow\Nodes\TriggerScheduleNode * @covers \OCA\OpenRegister\Service\Flow\Nodes\UnlockObjectNode - * @covers \OCA\OpenRegister\Service\Flow\Nodes\CaseOpenNode - * @covers \OCA\OpenRegister\Service\Flow\Nodes\CaseAdvanceNode * @covers \OCA\OpenRegister\Service\Flow\Nodes\UserTaskNode * @covers \OCA\OpenRegister\Service\Flow\Nodes\WaitNode */ @@ -114,9 +112,6 @@ private function expected(): array { 'ObjectWriteNode' => ['serviceTask', 'objects'], 'LockObjectNode' => ['serviceTask', 'objects'], 'UnlockObjectNode' => ['serviceTask', 'objects'], - // The flow engine touching the case layer (one-engine-bpmn-and-cmmn). - 'CaseOpenNode' => ['serviceTask', 'objects'], - 'CaseAdvanceNode' => ['serviceTask', 'objects'], 'LoopNode' => ['serviceTask', 'objects'], // Logic. Branches and joins are gateways; reshaping is a scriptTask. From 063dbf22edaacd88badd3941b53bf0e25b308b55 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 13:49:08 +0200 Subject: [PATCH 025/183] chore(cases): mark the two cross-app entry points for the orphan gates createPlanAsSystem and ensureItems are called from dossiq, not from this repo; reason-bearing excludes as AuthorizationService does for integriq. --- lib/Service/Case/CasePlanService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Service/Case/CasePlanService.php b/lib/Service/Case/CasePlanService.php index bfca18bf46..356a2acf21 100644 --- a/lib/Service/Case/CasePlanService.php +++ b/lib/Service/Case/CasePlanService.php @@ -183,6 +183,8 @@ public function createPlan( * * @throws CaseValidationException When the app id or the definition is refused, or a plan exists. * + * @orphaned-write-capability exclude cross-app entry point; caller is dossiq's CasePlanProjectionService (retire-cmmn-caseplanstate) + * * @spec openspec/specs/flow-cases/spec.md#requirement-in-process-callers-act-as-a-named-app */ public function createPlanAsSystem(string $objectUuid, ?int $registerId, ?int $schemaId, array $definition, string $app): array { @@ -238,6 +240,8 @@ public function getPlanAsSystem(string $objectUuid, string $app): array { * * @throws CaseValidationException On the first refused value, before anything is written. * + * @orphan-auth exclude cross-app entry point, not an access check; caller is dossiq's retire-cmmn-caseplanstate drain + * * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ public function ensureItems(string $objectUuid, ?int $registerId, ?int $schemaId, array $definition, array $history, string $app): array { From 7e6c1f013c28be74af013c86a2c98bc1df5f58fd Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 13:50:21 +0200 Subject: [PATCH 026/183] chore(phpmd): name why the guard calls the exception's constructors and why the listener couples to the node events --- lib/Listener/FrozenNodeWriteListener.php | 2 ++ lib/Service/Object/FileWriteGuard.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/Listener/FrozenNodeWriteListener.php b/lib/Listener/FrozenNodeWriteListener.php index 5bf377de6d..6546168726 100644 --- a/lib/Listener/FrozenNodeWriteListener.php +++ b/lib/Listener/FrozenNodeWriteListener.php @@ -70,6 +70,8 @@ * * @template-implements IEventListener * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The four node events, the node and the abort are the contract. + * * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 */ class FrozenNodeWriteListener implements IEventListener { diff --git a/lib/Service/Object/FileWriteGuard.php b/lib/Service/Object/FileWriteGuard.php index 4dd13ea3c3..fb9b62e774 100644 --- a/lib/Service/Object/FileWriteGuard.php +++ b/lib/Service/Object/FileWriteGuard.php @@ -69,6 +69,8 @@ public function assertWritable(ObjectEntity $object): void { * * @return ObjectStateWriteException|null The refusal naming the state, or null. * + * @SuppressWarnings(PHPMD.StaticAccess) archived() and frozen() are the exception's named constructors, as in SaveObject. + * * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 */ public function refusalFor(ObjectEntity $object): ?ObjectStateWriteException { From 0f887f509f27c7ef9678f5539be34e54dd8b974c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 13:57:16 +0200 Subject: [PATCH 027/183] test(migration): mock the audit table through SchemaTableMockTrait, so the test runs on every server version --- tests/Unit/Migration/Version1Date20261010120000Test.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Migration/Version1Date20261010120000Test.php b/tests/Unit/Migration/Version1Date20261010120000Test.php index d6c28aedbe..94f0f55cb6 100644 --- a/tests/Unit/Migration/Version1Date20261010120000Test.php +++ b/tests/Unit/Migration/Version1Date20261010120000Test.php @@ -18,8 +18,8 @@ namespace OCA\OpenRegister\Tests\Unit\Migration; -use OCP\DB\Schema\ITable; use OCA\OpenRegister\Migration\Version1Date20261010120000; +use OCA\OpenRegister\Tests\Support\SchemaTableMockTrait; use OCP\DB\ISchemaWrapper; use OCP\DB\Types; use OCP\Migration\IOutput; @@ -29,6 +29,7 @@ * The imported flag lands once, nullable with a false default. */ class Version1Date20261010120000Test extends TestCase { + use SchemaTableMockTrait; /** * A table without the column gains it. @@ -36,7 +37,7 @@ class Version1Date20261010120000Test extends TestCase { * @return void */ public function testItAddsTheImportedColumn(): void { - $table = $this->createMock(ITable::class); + $table = $this->createTableMock(); $table->method('hasColumn')->with('imported')->willReturn(false); $table->expects($this->once())->method('addColumn')->with('imported', Types::BOOLEAN, ['notnull' => false, 'default' => false]); $schema = $this->createMock(ISchemaWrapper::class); @@ -52,7 +53,7 @@ public function testItAddsTheImportedColumn(): void { * @return void */ public function testItIsIdempotentAndSkipsAMissingTable(): void { - $table = $this->createMock(ITable::class); + $table = $this->createTableMock(); $table->method('hasColumn')->willReturn(true); $table->expects($this->never())->method('addColumn'); $schema = $this->createMock(ISchemaWrapper::class); From c968a5dc4bb75d7ee76bf5a3f94bb9d074c4002a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 14:07:09 +0200 Subject: [PATCH 028/183] style(e2e): prettier on the frozen-files case --- tests/e2e/ci/object-archive-state.spec.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/e2e/ci/object-archive-state.spec.ts b/tests/e2e/ci/object-archive-state.spec.ts index 3daea26fd3..dba75b095b 100644 --- a/tests/e2e/ci/object-archive-state.spec.ts +++ b/tests/e2e/ci/object-archive-state.spec.ts @@ -403,7 +403,9 @@ test.describe('object archive state over HTTP', () => { const upload = { data: { name: `bijlage-${RUN}.txt`, content: 'levering' } } const refused = await admin.post(filesUrl, upload) - expect(refused.status(), 'an upload to a frozen object answers 409').toBe(409) + expect(refused.status(), 'an upload to a frozen object answers 409').toBe( + 409, + ) const body = await refused.json() expect(body.state).toBe('frozen') expect(body.reason).toBe('bezwaar') @@ -411,13 +413,24 @@ test.describe('object archive state over HTTP', () => { expect(body.error).toMatch(/frozen/i) const listed = await admin.get(filesUrl) - expect(listed.ok(), 'reading a frozen object\'s files stays allowed').toBeTruthy() + expect( + listed.ok(), + "reading a frozen object's files stays allowed", + ).toBeTruthy() - const unfrozen = await admin.delete(`${API}/objects/${registerId}/${schemaId}/${frozenUuid}/freeze`) - expect(unfrozen.ok(), `unfreeze failed: ${await unfrozen.text()}`).toBeTruthy() + const unfrozen = await admin.delete( + `${API}/objects/${registerId}/${schemaId}/${frozenUuid}/freeze`, + ) + expect( + unfrozen.ok(), + `unfreeze failed: ${await unfrozen.text()}`, + ).toBeTruthy() const accepted = await admin.post(filesUrl, upload) - expect(accepted.ok(), `after an unfreeze the upload goes through: ${await accepted.text()}`).toBeTruthy() + expect( + accepted.ok(), + `after an unfreeze the upload goes through: ${await accepted.text()}`, + ).toBeTruthy() }) test('an immutable property accepts its first value and refuses the next', async () => { From 0123099376a47f331d95de44c7fc2a437ccf43f9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 16:06:00 +0200 Subject: [PATCH 029/183] feat(notifications): an email recipient reads its address through a reference A rule may now address {kind: email, field: supplierRef.contactEmail}: OpenRegister follows the reference as a system read bounded to the triggering object's organisation, asks integriq once, and mails the address with the unsubscribe link (REQ-ERO-007). The validator accepts the kind and refuses four malformed shapes. Built for dossiq's supplier portal rules (portal-contribution T3, decision 156). --- .../AnnotationNotificationDispatcher.php | 88 +++++ .../NotificationAnnotationValidator.php | 91 ++++- .../ReferencedAddressNotifier.php | 320 ++++++++++++++++++ .../proposal.md | 14 + .../screens.md | 1 + .../specs/external-recipient-opt-out/spec.md | 5 + .../tasks.md | 6 + .../external-recipient-opt-out/screens.md | 1 + .../specs/external-recipient-opt-out/spec.md | 25 ++ .../AnnotationNotificationDispatcherTest.php | 68 ++++ .../NotificationAnnotationValidatorTest.php | 33 ++ .../ReferencedAddressNotifierTest.php | 287 ++++++++++++++++ 12 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 lib/Service/Notification/ReferencedAddressNotifier.php create mode 100644 openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/proposal.md create mode 100644 openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/screens.md create mode 100644 openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/specs/external-recipient-opt-out/spec.md create mode 100644 openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/tasks.md create mode 100644 openspec/specs/external-recipient-opt-out/screens.md create mode 100644 tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php diff --git a/lib/Service/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php index 0facc184a1..501df9692c 100644 --- a/lib/Service/Notification/AnnotationNotificationDispatcher.php +++ b/lib/Service/Notification/AnnotationNotificationDispatcher.php @@ -576,6 +576,19 @@ public function dispatchWithSchema(ObjectEntity $object, string $trigger, array category: $partiesCategory ); + // An `email` entry reads its address through a reference (REQ-ERO-007): + // a supplier holds its own contact address and has no account either. + $partiesReached += $this->dispatchToAddresses( + recipientsSpec: (array)($spec['recipients'] ?? []), + object: $object, + data: $data, + channels: $channels, + ruleId: (string)$name, + subject: $broadcastSubject, + body: $partiesBody, + category: $partiesCategory + ); + if (count($recipients) === 0) { // 🔴 IT USED TO `continue` IN SILENCE. No log, no counter, no // complaint — and declared groups ship EMPTY across this fleet, @@ -3319,6 +3332,81 @@ private function dispatchToParties( return $reached; }//end dispatchToParties() + /** + * Send a rule's `email` entries: addresses read through a reference. + * + * Resolved from the container for the same reason as the party service: + * it reaches the object layer, which dispatches notifications. A rule + * without an `email` entry never resolves it. + * + * @param array $recipientsSpec The rule's `recipients` declaration. + * @param ObjectEntity $object The triggering object. + * @param array $data Its data. + * @param array $channels The rule's channels. + * @param string $ruleId The rule, for the history row. + * @param string $subject The rule's subject, in the default locale. + * @param string $body The rule's message; the subject when it has none. + * @param string $category The rule's message category. + * + * @return int How many entries were handed to the mailer. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ + private function dispatchToAddresses( + array $recipientsSpec, + ObjectEntity $object, + array $data, + array $channels, + string $ruleId, + string $subject, + string $body, + string $category, + ): int { + if (in_array('email', $channels, true) === false) { + return 0; + } + + $hasEntry = false; + foreach ($recipientsSpec as $recipient) { + if (is_array($recipient) === true && (string)($recipient['kind'] ?? '') === ReferencedAddressNotifier::KIND) { + $hasEntry = true; + break; + } + } + + if ($hasEntry === false) { + return 0; + } + + $outcomes = $this->serverContainer->get(ReferencedAddressNotifier::class)->notify( + object: $object, + data: $data, + recipientsSpec: $recipientsSpec, + subject: $subject, + body: $body, + category: $category + ); + + $reached = 0; + foreach ($outcomes as $outcome) { + if ($outcome['outcome'] === EmailSender::OUTCOME_DISPATCHED) { + $reached++; + } + + $this->recordHistory( + ruleId: $ruleId, + channel: 'email', + recipient: 'field:' . $outcome['field'], + status: $outcome['outcome'], + object: $object, + subject: $subject, + locale: null + ); + } + + return $reached; + }//end dispatchToAddresses() + /** * Send a transactional email to a Nextcloud user. * diff --git a/lib/Service/Notification/NotificationAnnotationValidator.php b/lib/Service/Notification/NotificationAnnotationValidator.php index 44f5c0c118..c96c3a7784 100644 --- a/lib/Service/Notification/NotificationAnnotationValidator.php +++ b/lib/Service/Notification/NotificationAnnotationValidator.php @@ -49,7 +49,18 @@ final class NotificationAnnotationValidator { private const VALID_TRIGGERS = ['created', 'updated', 'transition', 'scheduled', 'threshold', 'calculatedChange']; - private const VALID_RECIPIENT_KINDS = ['users', 'field', 'groups', 'role', 'relation', 'object-acl', 'expression', 'watchers', 'parties']; + private const VALID_RECIPIENT_KINDS = [ + 'users', + 'field', + 'groups', + 'role', + 'relation', + 'object-acl', + 'expression', + 'watchers', + 'parties', + ReferencedAddressNotifier::KIND, + ]; private const VALID_CHANNELS = ['nc-notification', 'email', 'activity', 'webhook', 'talk', 'web-push']; @@ -833,6 +844,12 @@ public function validate(array $schema): array { } } + if ($kind === ReferencedAddressNotifier::KIND) { + foreach ($this->emailRecipientErrors(recipient: $recipient, spec: $spec, propKeys: $propKeys, name: (string)$name, index: (int)$i) as $error) { + $errors[] = $error; + } + } + if ($kind === 'object-acl') { $perm = (string)($recipient['permission'] ?? ''); if (in_array($perm, ['read', 'manage'], true) === false) { @@ -895,6 +912,78 @@ public function validate(array $schema): array { return $errors; }//end validate() + /** + * Errors for one `email` recipient entry (REQ-ERO-007). + * + * The first path segment must be a declared property, the path may follow + * at most ReferencedAddressNotifier::MAX_HOPS references, and the rule must + * send e-mail: an `email` entry on a rule without the email channel would + * reach nobody, every time, in silence. + * + * @param array $recipient The entry. + * @param array $spec The rule. + * @param array $propKeys The schema's declared properties. + * @param string $name The rule name, for messages. + * @param int $index The entry's index, for messages. + * + * @return array + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ + private function emailRecipientErrors(array $recipient, array $spec, array $propKeys, string $name, int $index): array { + $errors = []; + $path = trim((string)($recipient['field'] ?? '')); + $segments = array_values(array_filter(explode('.', $path), static fn (string $s): bool => $s !== '')); + if ($segments === []) { + return [[ + 'code' => 'notification-recipient-email-field-missing', + 'message' => sprintf( + 'Notification "%s" recipient[%d] kind=email requires a `field` naming the address, e.g. "supplierRef.contactEmail".', + $name, + $index + ), + ]]; + } + + if (in_array($segments[0], $propKeys, true) === false) { + $errors[] = [ + 'code' => 'notification-recipient-field-unknown', + 'message' => sprintf( + 'Notification "%s" recipient[%d] field "%s" is not declared on the schema.', + $name, + $index, + $segments[0] + ), + ]; + } + + if (count($segments) > (ReferencedAddressNotifier::MAX_HOPS + 1)) { + $errors[] = [ + 'code' => 'notification-recipient-email-path-too-deep', + 'message' => sprintf( + 'Notification "%s" recipient[%d] field "%s" follows more than %d references.', + $name, + $index, + $path, + ReferencedAddressNotifier::MAX_HOPS + ), + ]; + } + + if (in_array('email', (array)($spec['channels'] ?? []), true) === false) { + $errors[] = [ + 'code' => 'notification-recipient-email-needs-email-channel', + 'message' => sprintf( + 'Notification "%s" recipient[%d] is kind=email but the rule has no "email" channel, so it would reach nobody.', + $name, + $index + ), + ]; + } + + return $errors; + }//end emailRecipientErrors() + /** * Validate the optional `actions[]` array (foundation contract / ADR-031). * diff --git a/lib/Service/Notification/ReferencedAddressNotifier.php b/lib/Service/Notification/ReferencedAddressNotifier.php new file mode 100644 index 0000000000..0fc8342820 --- /dev/null +++ b/lib/Service/Notification/ReferencedAddressNotifier.php @@ -0,0 +1,320 @@ + + * + * @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/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\ObjectService; +use Psr\Log\LoggerInterface; + +/** + * Resolves and mails the addresses an `email` recipient entry names. + * + * The referenced object is read as the system, not as the person whose save + * fired the rule: a melder may not be allowed to read the supplier, and the + * rule still has to reach it. The read is bounded to the triggering object's + * own organisation instead, so a reference can never pull an address out of + * another tenant. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ +class ReferencedAddressNotifier { + + /** + * The recipient kind this unit sends. + */ + public const KIND = 'email'; + + /** + * The most references one path may follow. + */ + public const MAX_HOPS = 3; + + /** + * The path names no value, or an empty one. + */ + public const REASON_NO_REFERENCE = 'no-reference'; + + /** + * The reference points at an object that does not exist (or cannot be read). + */ + public const REASON_REFERENCE_NOT_FOUND = 'reference-not-found'; + + /** + * The referenced object belongs to another organisation. + */ + public const REASON_OTHER_TENANT = 'refused-other-tenant'; + + /** + * The value at the end of the path is not an e-mail address. + */ + public const REASON_NOT_AN_ADDRESS = 'not-an-address'; + + /** + * integriq says the address opted out. + */ + public const OUTCOME_REFUSED_OPTED_OUT = 'refused-opted-out'; + + /** + * integriq did not answer, which is refused like an opt-out. + */ + public const OUTCOME_AUTHORITY_UNAVAILABLE = OptOutAuthority::CODE_AUTHORITY_UNAVAILABLE; + + /** + * Constructor. + * + * @param ObjectService $objects The object layer, for the referenced object. + * @param EmailSender $email The email channel. + * @param OptOutAuthority $optOut Asks integriq whether an address may be mailed. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly ObjectService $objects, + private readonly EmailSender $email, + private readonly OptOutAuthority $optOut, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Mail every `email` entry of a rule that resolves to an address. + * + * Every resolved address is put to integriq in one question. An entry that + * does not resolve, or that integriq refuses, is reported with its reason, + * never dropped in silence. + * + * @param ObjectEntity $object The triggering object. + * @param array $data Its data. + * @param array $recipientsSpec The rule's `recipients` declaration. + * @param string $subject The rule's subject. + * @param string $body The rule's message. + * @param string $category The rule's message category. + * + * @return array One outcome per `email` entry. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ + public function notify( + ObjectEntity $object, + array $data, + array $recipientsSpec, + string $subject, + string $body, + string $category, + ): array { + $entries = []; + foreach ($recipientsSpec as $recipient) { + if (is_array($recipient) === false || (string)($recipient['kind'] ?? '') !== self::KIND) { + continue; + } + + $path = trim((string)($recipient['field'] ?? '')); + $entries[] = ['field' => $path] + $this->resolveAddress(object: $object, data: $data, path: $path); + } + + if ($entries === []) { + return []; + } + + $askable = []; + foreach ($entries as $entry) { + if ($entry['address'] !== null) { + $askable[] = $entry['address']; + } + } + + $decisions = $this->optOut->ask( + channel: 'email', + category: $category, + addresses: $askable, + correlationId: 'openregister-email:' . (string)($object->getUuid() ?? '') + ); + + $outcomes = []; + foreach ($entries as $entry) { + $outcomes[] = [ + 'field' => $entry['field'], + 'outcome' => $this->sendOne(entry: $entry, decisions: $decisions, subject: $subject, body: $body), + ]; + } + + return $outcomes; + }//end notify() + + /** + * The address a path names, or the reason it names none. + * + * Each segment but the last is read from the current data; when its value + * is a reference (a uuid string, or an object carrying `id` or `uuid`) the + * referenced object's data becomes the current data. The last segment is + * the address. + * + * @param ObjectEntity $object The triggering object. + * @param array $data Its data. + * @param string $path The dotted path, e.g. `supplierRef.contactEmail`. + * + * @return array{address: string|null, reason: string|null} + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ + public function resolveAddress(ObjectEntity $object, array $data, string $path): array { + $segments = array_values(array_filter(explode('.', $path), static fn (string $s): bool => $s !== '')); + if ($segments === [] || count($segments) > (self::MAX_HOPS + 1)) { + return ['address' => null, 'reason' => self::REASON_NO_REFERENCE]; + } + + $last = array_pop($segments); + $current = $data; + foreach ($segments as $segment) { + $reference = $this->referenceOf(value: ($current[$segment] ?? null)); + if ($reference === null) { + return ['address' => null, 'reason' => self::REASON_NO_REFERENCE]; + } + + $related = $this->findReferenced(id: $reference); + if ($related === null) { + return ['address' => null, 'reason' => self::REASON_REFERENCE_NOT_FOUND]; + } + + if ($this->sameTenant(origin: $object, related: $related) === false) { + return ['address' => null, 'reason' => self::REASON_OTHER_TENANT]; + } + + $current = $related->getObject(); + } + + $value = ($current[$last] ?? null); + if (is_string($value) === false || trim($value) === '') { + return ['address' => null, 'reason' => self::REASON_NO_REFERENCE]; + } + + $address = trim($value); + if (filter_var($address, FILTER_VALIDATE_EMAIL) === false) { + return ['address' => null, 'reason' => self::REASON_NOT_AN_ADDRESS]; + } + + return ['address' => $address, 'reason' => null]; + }//end resolveAddress() + + /** + * Send one resolved entry, or name why it was not sent. + * + * @param array{field: string, address: string|null, reason: string|null} $entry The entry. + * @param array> $decisions integriq's answers. + * @param string $subject The subject. + * @param string $body The body. + * + * @return string The outcome. + */ + private function sendOne(array $entry, array $decisions, string $subject, string $body): string { + if ($entry['address'] === null) { + return (string)$entry['reason']; + } + + $decision = $this->optOut->decisionFor(decisions: $decisions, address: $entry['address']); + if (($decision['send'] ?? false) !== true) { + if (($decision['code'] ?? '') === OptOutAuthority::CODE_AUTHORITY_UNAVAILABLE) { + return self::OUTCOME_AUTHORITY_UNAVAILABLE; + } + + return self::OUTCOME_REFUSED_OPTED_OUT; + } + + $unsubscribe = null; + if (is_array($decision['unsubscribe'] ?? null) === true) { + $unsubscribe = $decision['unsubscribe']; + } + + return $this->email->sendToAddress( + address: $entry['address'], + displayName: '', + subject: $subject, + body: $this->optOut->withLink(body: $body, unsubscribe: $unsubscribe), + unsubscribe: $unsubscribe + ); + }//end sendOne() + + /** + * The id a stored reference value names, or null. + * + * @param mixed $value The stored value. + * + * @return string|null + */ + private function referenceOf(mixed $value): ?string { + if (is_array($value) === true) { + $value = ($value['id'] ?? ($value['uuid'] ?? null)); + } + + if (is_string($value) === false || trim($value) === '') { + return null; + } + + return trim($value); + }//end referenceOf() + + /** + * Read the referenced object as the system. + * + * @param string $id The reference. + * + * @return ObjectEntity|null + */ + private function findReferenced(string $id): ?ObjectEntity { + try { + return $this->objects->find(id: $id, _rbac: false, _multitenancy: false, _render: false, _audit: false); + } catch (\Throwable $e) { + $this->logger->debug( + sprintf('[ReferencedAddressNotifier] reference "%s" did not resolve: %s', $id, $e->getMessage()) + ); + return null; + } + }//end findReferenced() + + /** + * Whether the referenced object may be read for the triggering one. + * + * A referenced object without an organisation is not tenant-bound and may + * be read. One with an organisation is read only for an object of that + * same organisation; an object without one may not reach into a tenant. + * + * @param ObjectEntity $origin The triggering object. + * @param ObjectEntity $related The referenced object. + * + * @return bool + */ + private function sameTenant(ObjectEntity $origin, ObjectEntity $related): bool { + $mine = (string)($origin->getOrganisation() ?? ''); + $theirs = (string)($related->getOrganisation() ?? ''); + if ($theirs === '') { + return true; + } + + return $mine === $theirs; + }//end sameTenant() +}//end class diff --git a/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/proposal.md b/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/proposal.md new file mode 100644 index 0000000000..5ec62e460c --- /dev/null +++ b/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/proposal.md @@ -0,0 +1,14 @@ +# An email recipient reads its address through a reference + +## Why + +dossiq's supplier portal (dossiq change `portal-contribution` task T3, decision 128) has to mail a supplier when a message, an expiring contract, a due invoice or a published tender concerns it. A supplier has no Nextcloud account and is no party on those objects. Its contact address sits on the supplier object, which every one of them points at through `supplierRef`. No recipient kind could reach it: `field` needs a uid, and `parties` reads contact links. A rule that declared a new kind failed `NotificationAnnotationValidator`, which refuses unknown kinds, so the whole schema failed at import. + +Decision 156 says to build the dependency rather than park it. + +## What changes + +- New recipient kind `email`: `{kind: "email", field: "supplierRef.contactEmail"}`. `ReferencedAddressNotifier` follows up to three references as a system read, bounded to the triggering object's organisation. It asks integriq once for the resolved addresses, appends the unsubscribe link and sends through `EmailSender::sendToAddress()`. +- `AnnotationNotificationDispatcher` sends `email` entries next to `parties` entries. Each entry leaves a history row (`field:`, with the outcome), and a delivered entry counts as reached for the reached-nobody check. +- `NotificationAnnotationValidator` accepts the kind and refuses four malformed shapes. +- `external-recipient-opt-out` gains REQ-ERO-007. diff --git a/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/screens.md b/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/screens.md new file mode 100644 index 0000000000..087a46952f --- /dev/null +++ b/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/screens.md @@ -0,0 +1 @@ +- No screen: a notification recipient kind evaluated on the server; the mail it sends is not a board. diff --git a/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/specs/external-recipient-opt-out/spec.md b/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/specs/external-recipient-opt-out/spec.md new file mode 100644 index 0000000000..7542e8108b --- /dev/null +++ b/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/specs/external-recipient-opt-out/spec.md @@ -0,0 +1,5 @@ +## ADDED Requirements + +### Requirement: An email recipient reads its address through a reference (REQ-ERO-007) + +See the main spec `openspec/specs/external-recipient-opt-out/spec.md` for the full text and its three scenarios: a supplier message mails the supplier's contact address; a reference into another tenant is refused; an email entry on a rule without the email channel is refused at save. diff --git a/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/tasks.md b/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/tasks.md new file mode 100644 index 0000000000..0d7a866050 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-email-recipient-through-a-reference/tasks.md @@ -0,0 +1,6 @@ +# Tasks: an email recipient reads its address through a reference + +- [x] 1. `lib/Service/Notification/ReferencedAddressNotifier.php`: path resolution through references (system read, tenant-bounded, max three hops), integriq question, send. Test: `tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php`. +- [x] 2. `AnnotationNotificationDispatcher::dispatchToAddresses()`: sends `email` entries on rules with the email channel, records history, counts reach. Test: `AnnotationNotificationDispatcherTest::testAnEmailRuleIsSentThroughTheReferencedAddressNotifier`, `::testAnEmailEntryWithoutTheEmailChannelIsNotSent`. +- [x] 3. `NotificationAnnotationValidator`: kind `email` accepted; four refusal codes. Test: `NotificationAnnotationValidatorTest::testAnEmailRecipientNeedsADeclaredFieldAndTheEmailChannel`. +- [x] 4. Spec: REQ-ERO-007 in `openspec/specs/external-recipient-opt-out/spec.md`. diff --git a/openspec/specs/external-recipient-opt-out/screens.md b/openspec/specs/external-recipient-opt-out/screens.md new file mode 100644 index 0000000000..996228844a --- /dev/null +++ b/openspec/specs/external-recipient-opt-out/screens.md @@ -0,0 +1 @@ +- No screen: opt-out questions and unsubscribe links on outbound mail, decided on the server. diff --git a/openspec/specs/external-recipient-opt-out/spec.md b/openspec/specs/external-recipient-opt-out/spec.md index a0139da851..be422dc136 100644 --- a/openspec/specs/external-recipient-opt-out/spec.md +++ b/openspec/specs/external-recipient-opt-out/spec.md @@ -122,3 +122,28 @@ A `parties` notification MUST send the rule's resolved `message` as the mail bod - **WHEN** the rule fires for a party - **THEN** the mail's body starts with the subject - @e2e exclude notification dispatcher path, covered by PHPUnit + +### Requirement: An email recipient reads its address through a reference (REQ-ERO-007) + +An `x-openregister-notifications` rule MAY address `{kind: "email", field: ""}`. The path's last segment names the address; every segment before it names a reference that OpenRegister MUST follow to the referenced object, at most three references deep. OpenRegister MUST read the referenced object as the system, so the rule reaches the address even when the person whose save fired the rule may not read that object, and MUST refuse a referenced object that belongs to another organisation than the triggering object (outcome `refused-other-tenant`). A value that is not an e-mail address MUST NOT be mailed (`not-an-address`); a missing reference or object MUST be named (`no-reference`, `reference-not-found`). A resolved address MUST be put to integriq like a party address (REQ-ERO-003) and carry the unsubscribe link (REQ-ERO-004). Every entry MUST leave a history row with its outcome, recorded against the path and not the address. The validator MUST refuse an `email` entry without a `field` (`notification-recipient-email-field-missing`), whose first segment is not a declared property (`notification-recipient-field-unknown`), that follows more than three references (`notification-recipient-email-path-too-deep`), or on a rule without the `email` channel (`notification-recipient-email-needs-email-channel`). + +#### Scenario: A supplier message mails the supplier's contact address + +- **GIVEN** a `supplierMessage` schema with the rule `{kind: email, field: supplierRef.contactEmail}` on the `email` channel +- **AND** a message whose `supplierRef` names a supplier of the same organisation with `contactEmail: info@bouw.example` +- **WHEN** the message is created +- **THEN** `info@bouw.example` gets the rule's subject and message, with integriq's unsubscribe link +- @e2e exclude notification dispatcher path, covered by PHPUnit `ReferencedAddressNotifierTest` and `AnnotationNotificationDispatcherTest::testAnEmailRuleIsSentThroughTheReferencedAddressNotifier` + +#### Scenario: A reference into another tenant is refused + +- **GIVEN** the same rule and a message whose `supplierRef` names a supplier of another organisation +- **WHEN** the message is created +- **THEN** nothing is mailed and the history row reads `refused-other-tenant` +- @e2e exclude tenant boundary in the dispatcher, covered by PHPUnit `ReferencedAddressNotifierTest::testAnUnresolvableAddressIsNamedAndNothingIsSent` + +#### Scenario: An email entry on a rule without the email channel is refused at save + +- **WHEN** a schema is saved with an `email` recipient on a rule whose channels are `["nc-notification"]` +- **THEN** the save is refused with code `notification-recipient-email-needs-email-channel` +- @e2e exclude schema validator, covered by PHPUnit `NotificationAnnotationValidatorTest::testAnEmailRecipientNeedsADeclaredFieldAndTheEmailChannel` diff --git a/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php b/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php index e97b946547..9caa278f8b 100644 --- a/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php +++ b/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php @@ -12,6 +12,8 @@ use OCA\OpenRegister\Service\Notification\NotificationPreferenceService; use OCA\OpenRegister\Service\Notification\RecipientResolverInterface; use OCA\OpenRegister\Service\Party\PartyNotificationService; +use OCA\OpenRegister\Service\Notification\ReferencedAddressNotifier; +use OCA\OpenRegister\Service\Notification\EmailSender; use OCP\Activity\IManager as IActivityManager; use OCP\App\IAppManager; use OCP\Http\Client\IClient; @@ -1096,6 +1098,72 @@ public function testAPartyMailWithoutAMessageKeepsTheSubjectAsItsBody(): void { $this->assertSame('service', $calls[0]['category']); }//end testAPartyMailWithoutAMessageKeepsTheSubjectAsItsBody() + /** + * An `email` rule hands its entries, the rule's subject, message and + * category to the referenced-address notifier; a rule without one never + * resolves it. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ + public function testAnEmailRuleIsSentThroughTheReferencedAddressNotifier(): void { + $calls = []; + $notifier = $this->createMock(ReferencedAddressNotifier::class); + $notifier->method('notify')->willReturnCallback( + function (ObjectEntity $object, array $data, array $recipientsSpec, string $subject, string $body, string $category) use (&$calls): array { + $calls[] = ['uuid' => $object->getUuid(), 'recipients' => $recipientsSpec, 'subject' => $subject, 'body' => $body, 'category' => $category]; + return [['field' => 'supplierRef.contactEmail', 'outcome' => EmailSender::OUTCOME_DISPATCHED]]; + } + ); + $this->serverServices[ReferencedAddressNotifier::class] = $notifier; + + $recipients = [['kind' => 'email', 'field' => 'supplierRef.contactEmail']]; + $schema = $this->schemaWithNotification( + [ + 'tell-the-supplier' => [ + 'trigger' => ['type' => 'updated'], + 'channels' => ['email'], + 'recipients' => $recipients, + 'subject' => 'Nieuw bericht', + 'message' => 'U heeft een nieuw bericht in het portaal.', + 'messageCategory' => 'case-update', + ], + ] + ); + $this->schemaMapper->method('find')->willReturn($schema); + + $this->makeDispatcher()->dispatch($this->object($schema), 'updated'); + + $this->assertSame( + [['uuid' => 'uuid-1', 'recipients' => $recipients, 'subject' => 'Nieuw bericht', 'body' => 'U heeft een nieuw bericht in het portaal.', 'category' => 'case-update']], + $calls + ); + }//end testAnEmailRuleIsSentThroughTheReferencedAddressNotifier() + + /** + * An `email` entry on a rule without the email channel is not sent. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ + public function testAnEmailEntryWithoutTheEmailChannelIsNotSent(): void { + $notifier = $this->createMock(ReferencedAddressNotifier::class); + $notifier->expects($this->never())->method('notify'); + $this->serverServices[ReferencedAddressNotifier::class] = $notifier; + + $schema = $this->schemaWithNotification( + [ + 'x' => [ + 'trigger' => ['type' => 'updated'], + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'email', 'field' => 'supplierRef.contactEmail']], + 'subject' => 's', + ], + ] + ); + $this->schemaMapper->method('find')->willReturn($schema); + + $this->makeDispatcher()->dispatch($this->object($schema), 'updated'); + }//end testAnEmailEntryWithoutTheEmailChannelIsNotSent() + /** * @param array $notifications */ diff --git a/tests/Unit/Service/Notification/NotificationAnnotationValidatorTest.php b/tests/Unit/Service/Notification/NotificationAnnotationValidatorTest.php index 91cfa58b83..d1512e54f2 100644 --- a/tests/Unit/Service/Notification/NotificationAnnotationValidatorTest.php +++ b/tests/Unit/Service/Notification/NotificationAnnotationValidatorTest.php @@ -100,6 +100,39 @@ public function testRecipientFieldMustExist(): void { $this->assertContains('notification-recipient-field-unknown', $codes); } + /** + * An `email` recipient names a declared property, possibly followed + * through references, on a rule that sends e-mail. + * + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ + public function testAnEmailRecipientNeedsADeclaredFieldAndTheEmailChannel(): void { + $properties = ['supplierRef' => ['type' => 'string', 'format' => 'uuid'], 'subject' => ['type' => 'string']]; + $rule = static fn (array $recipient, array $channels = ['email']): array => [ + 'x-openregister-notifications' => [ + 'x' => [ + 'trigger' => ['type' => 'created'], + 'recipients' => [$recipient], + 'channels' => $channels, + 'subject' => 'Nieuw bericht', + ], + ], + 'properties' => $properties, + ]; + + $this->assertSame([], $this->v->validate($rule(['kind' => 'email', 'field' => 'supplierRef.contactEmail']))); + + $cases = [ + 'notification-recipient-field-unknown' => $rule(['kind' => 'email', 'field' => 'nope.contactEmail']), + 'notification-recipient-email-field-missing' => $rule(['kind' => 'email']), + 'notification-recipient-email-path-too-deep' => $rule(['kind' => 'email', 'field' => 'supplierRef.a.b.c.d']), + 'notification-recipient-email-needs-email-channel' => $rule(['kind' => 'email', 'field' => 'supplierRef.contactEmail'], ['nc-notification']), + ]; + foreach ($cases as $code => $schema) { + $this->assertContains($code, array_column($this->v->validate($schema), 'code'), $code); + } + } + public function testValidNoErrors(): void { $errors = $this->v->validate([ 'x-openregister-notifications' => [ diff --git a/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php b/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php new file mode 100644 index 0000000000..c41deed1e6 --- /dev/null +++ b/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php @@ -0,0 +1,287 @@ + + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\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 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Notification; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Notification\EmailSender; +use OCA\OpenRegister\Service\Notification\OptOutAuthority; +use OCA\OpenRegister\Service\Notification\ReferencedAddressNotifier; +use OCA\OpenRegister\Service\ObjectService; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 + */ +class ReferencedAddressNotifierTest extends TestCase { + + /** + * The object layer, for the referenced object. + * + * @var ObjectService&MockObject + */ + private $objects; + + /** + * The email channel. + * + * @var EmailSender&MockObject + */ + private $email; + + /** + * The opt-out authority. + * + * @var OptOutAuthority&MockObject + */ + private $optOut; + + /** + * What reached the mailer. + * + * @var array> + */ + private array $sent = []; + + /** + * Referenced objects by id. + * + * @var array + */ + private array $store = []; + + /** + * Build the notifier on doubles. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->objects = $this->createMock(ObjectService::class); + $this->objects->method('find')->willReturnCallback( + fn (int|string $id): ?ObjectEntity => ($this->store[(string)$id] ?? null) + ); + $this->email = $this->createMock(EmailSender::class); + $this->email->method('sendToAddress')->willReturnCallback( + function (string $address, string $displayName, string $subject, string $body, ?array $unsubscribe = null): string { + $this->sent[] = ['address' => $address, 'subject' => $subject, 'body' => $body, 'unsubscribe' => $unsubscribe]; + return EmailSender::OUTCOME_DISPATCHED; + } + ); + $this->optOut = $this->createMock(OptOutAuthority::class); + $this->optOut->method('withLink')->willReturnCallback( + static fn (string $body, ?array $unsubscribe): string => $body . (($unsubscribe['url'] ?? '') !== '' ? "\n\nstop: " . $unsubscribe['url'] : '') + ); + $this->optOut->method('decisionFor')->willReturnCallback( + static fn (array $decisions, string $address): array => ($decisions[$address] ?? ['send' => false, 'code' => 'authority-unavailable', 'unsubscribe' => null]) + ); + }//end setUp() + + /** + * The notifier under test. + * + * @return ReferencedAddressNotifier + */ + private function notifier(): ReferencedAddressNotifier { + return new ReferencedAddressNotifier( + objects: $this->objects, + email: $this->email, + optOut: $this->optOut, + logger: $this->createMock(LoggerInterface::class) + ); + }//end notifier() + + /** + * A stored object. + * + * @param string $uuid Its uuid. + * @param array $data Its data. + * @param string|null $organisation Its organisation. + * + * @return ObjectEntity + */ + private function object(string $uuid, array $data, ?string $organisation = 'org-1'): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid($uuid); + $object->setObject($data); + $object->setOrganisation($organisation); + return $object; + }//end object() + + /** + * integriq allows every address it is asked about. + * + * @return void + */ + private function integriqAllows(): void { + $this->optOut->method('ask')->willReturnCallback( + static function (string $channel, string $category, array $addresses): array { + $decisions = []; + foreach ($addresses as $address) { + $decisions[$address] = ['send' => true, 'code' => 'allowed', 'unsubscribe' => ['url' => 'https://nc.example/u/1']]; + } + + return $decisions; + } + ); + }//end integriqAllows() + + /** + * The address is read from the referenced object and mailed with the + * rule's subject and body plus integriq's link. + * + * @return void + */ + public function testTheAddressIsReadThroughTheReferenceAndMailed(): void { + $this->integriqAllows(); + $this->store['sup-1'] = $this->object('sup-1', ['name' => 'Bouw BV', 'contactEmail' => 'info@bouw.example']); + $message = $this->object('msg-1', ['supplierRef' => 'sup-1', 'subject' => 'Hallo']); + + $outcomes = $this->notifier()->notify( + object: $message, + data: $message->getObject(), + recipientsSpec: [['kind' => 'email', 'field' => 'supplierRef.contactEmail']], + subject: 'Nieuw bericht', + body: 'U heeft een nieuw bericht.', + category: 'service' + ); + + $this->assertSame([['field' => 'supplierRef.contactEmail', 'outcome' => EmailSender::OUTCOME_DISPATCHED]], $outcomes); + $this->assertCount(1, $this->sent); + $this->assertSame('info@bouw.example', $this->sent[0]['address']); + $this->assertSame('Nieuw bericht', $this->sent[0]['subject']); + $this->assertStringContainsString('stop: https://nc.example/u/1', $this->sent[0]['body']); + }//end testTheAddressIsReadThroughTheReferenceAndMailed() + + /** + * A field without a dot reads the address from the object itself, and a + * reference stored as an object (`{id: ...}`) is followed too. + * + * @return void + */ + public function testAPlainFieldAndAnObjectShapedReferenceBothResolve(): void { + $this->integriqAllows(); + $this->store['sup-2'] = $this->object('sup-2', ['contactEmail' => 'b@example.org']); + $object = $this->object('x-1', ['email' => 'a@example.org', 'supplier' => ['id' => 'sup-2']]); + + $notifier = $this->notifier(); + $this->assertSame('a@example.org', $notifier->resolveAddress(object: $object, data: $object->getObject(), path: 'email')['address']); + $this->assertSame('b@example.org', $notifier->resolveAddress(object: $object, data: $object->getObject(), path: 'supplier.contactEmail')['address']); + }//end testAPlainFieldAndAnObjectShapedReferenceBothResolve() + + /** + * Every way the address can fail to resolve is named, and nothing is sent. + * + * @return void + */ + public function testAnUnresolvableAddressIsNamedAndNothingIsSent(): void { + $this->integriqAllows(); + $this->store['sup-other'] = $this->object('sup-other', ['contactEmail' => 'x@other.example'], 'org-2'); + $this->store['sup-bad'] = $this->object('sup-bad', ['contactEmail' => 'not an address']); + $object = $this->object('m', ['empty' => '', 'missingRef' => 'gone', 'foreign' => 'sup-other', 'bad' => 'sup-bad']); + $notifier = $this->notifier(); + + $cases = [ + 'empty.contactEmail' => ReferencedAddressNotifier::REASON_NO_REFERENCE, + 'missingRef.contactEmail' => ReferencedAddressNotifier::REASON_REFERENCE_NOT_FOUND, + 'foreign.contactEmail' => ReferencedAddressNotifier::REASON_OTHER_TENANT, + 'bad.contactEmail' => ReferencedAddressNotifier::REASON_NOT_AN_ADDRESS, + 'nothing' => ReferencedAddressNotifier::REASON_NO_REFERENCE, + ]; + foreach ($cases as $path => $reason) { + $resolved = $notifier->resolveAddress(object: $object, data: $object->getObject(), path: $path); + $this->assertNull($resolved['address'], $path); + $this->assertSame($reason, $resolved['reason'], $path); + } + + // An object without an organisation may not reach into a tenant, and + // any object may read a referenced object that has no organisation. + $this->store['global'] = $this->object('global', ['contactEmail' => 'g@example.org'], null); + $untenanted = $this->object('u', ['ref' => 'sup-other', 'glob' => 'global'], null); + $this->assertSame(ReferencedAddressNotifier::REASON_OTHER_TENANT, $notifier->resolveAddress(object: $untenanted, data: $untenanted->getObject(), path: 'ref.contactEmail')['reason']); + $this->assertSame('g@example.org', $notifier->resolveAddress(object: $object, data: ['glob' => 'global'], path: 'glob.contactEmail')['address']); + + $outcomes = $notifier->notify( + object: $object, + data: $object->getObject(), + recipientsSpec: [['kind' => 'email', 'field' => 'foreign.contactEmail']], + subject: 's', + body: 'b', + category: 'service' + ); + $this->assertSame([['field' => 'foreign.contactEmail', 'outcome' => ReferencedAddressNotifier::REASON_OTHER_TENANT]], $outcomes); + $this->assertSame([], $this->sent); + }//end testAnUnresolvableAddressIsNamedAndNothingIsSent() + + /** + * An opted-out address is not mailed, and an absent integriq reads as + * authority-unavailable, never as allowed. + * + * @return void + */ + public function testIntegriqRefusalsAreRespected(): void { + $this->optOut->method('ask')->willReturnCallback( + static fn (string $channel, string $category, array $addresses): array => [ + 'out@example.org' => ['send' => false, 'code' => 'opted-out', 'unsubscribe' => null], + ] + ); + $this->store['a'] = $this->object('a', ['contactEmail' => 'out@example.org']); + $this->store['b'] = $this->object('b', ['contactEmail' => 'silent@example.org']); + $object = $this->object('m', ['refA' => 'a', 'refB' => 'b']); + + $outcomes = $this->notifier()->notify( + object: $object, + data: $object->getObject(), + recipientsSpec: [ + ['kind' => 'email', 'field' => 'refA.contactEmail'], + ['kind' => 'parties'], + ['kind' => 'email', 'field' => 'refB.contactEmail'], + ], + subject: 's', + body: 'b', + category: 'service' + ); + + $this->assertSame( + [ + ['field' => 'refA.contactEmail', 'outcome' => ReferencedAddressNotifier::OUTCOME_REFUSED_OPTED_OUT], + ['field' => 'refB.contactEmail', 'outcome' => ReferencedAddressNotifier::OUTCOME_AUTHORITY_UNAVAILABLE], + ], + $outcomes + ); + $this->assertSame([], $this->sent); + }//end testIntegriqRefusalsAreRespected() + + /** + * A rule without an `email` entry asks nobody and sends nothing. + * + * @return void + */ + public function testARuleWithoutEmailEntriesDoesNothing(): void { + $this->optOut->expects($this->never())->method('ask'); + $object = $this->object('m', []); + $this->assertSame( + [], + $this->notifier()->notify(object: $object, data: [], recipientsSpec: [['kind' => 'users', 'users' => ['a']]], subject: 's', body: 'b', category: 'service') + ); + }//end testARuleWithoutEmailEntriesDoesNothing() +}//end class From 95862d4133af899e7d4ec81b7ca107e245f2f8d7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 16:09:50 +0200 Subject: [PATCH 030/183] spec: form destination validator and submit service (decision 179) Decision 179 (Ruben, 10 Oct 2026): a form submits into its destination object, with no in-between intake layer, and is valid against that object's schema when built. Cross-app change: hydra openspec/changes/form-submits-into-its-destination-object (ADR-117, hydra#748). Spec only. --- .../form-destination-validator/.openspec.yaml | 2 + .../form-destination-validator/proposal.md | 31 +++++++ .../form-destination-validator/screens.md | 3 + .../specs/form-and-journey-registry/spec.md | 22 +++++ .../specs/form-destination/spec.md | 91 +++++++++++++++++++ .../form-destination-validator/tasks.md | 35 +++++++ 6 files changed, 184 insertions(+) create mode 100644 openspec/changes/form-destination-validator/.openspec.yaml create mode 100644 openspec/changes/form-destination-validator/proposal.md create mode 100644 openspec/changes/form-destination-validator/screens.md create mode 100644 openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md create mode 100644 openspec/changes/form-destination-validator/specs/form-destination/spec.md create mode 100644 openspec/changes/form-destination-validator/tasks.md diff --git a/openspec/changes/form-destination-validator/.openspec.yaml b/openspec/changes/form-destination-validator/.openspec.yaml new file mode 100644 index 0000000000..53f62d7060 --- /dev/null +++ b/openspec/changes/form-destination-validator/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/form-destination-validator/proposal.md b/openspec/changes/form-destination-validator/proposal.md new file mode 100644 index 0000000000..c11b8bcdb2 --- /dev/null +++ b/openspec/changes/form-destination-validator/proposal.md @@ -0,0 +1,31 @@ +--- +kind: code +depends_on: [] +--- + +# Proposal: form-destination-validator + +OpenRegister's half of decision 179 (Ruben, 10 October 2026): "We dont intake to an intake, we intake into a case, or ticket or something else." Cross-app change: `hydra/openspec/changes/form-submits-into-its-destination-object`, architecture in hydra ADR-117. Every other app change in that chain waits on this one. + +## Why + +A form today is checked against its own field list. The destination schema judges the data later, in a background job, with nobody left to tell. OpenRegister already refuses a bad field at save time for flow task forms (`TaskFormReader::validate`), but nothing does the same for a form that creates an object. And `ObjectsController::create` validates only when a schema has hard validation on, then answers a bare 400 string, while update answers per-property errors. + +## What changes + +- **`FormDestinationValidator`**: judges a form's field-to-property mapping against a destination schema and returns findings (`required-unmapped`, `property-unknown`, `type-mismatch`, `format-mismatch`, `enum-unconstrained`, `enum-value-unknown`, `constraint-looser`, `fixed-value-invalid`, `destination-not-public`, `destination-is-staging`). Built by extending `TaskFormReader::validate`, not beside it. +- **`FormSubmitService`** and `POST /api/forms/{formId}/submit`: validates the payload with the destination's full validator whatever its hard-validation flag, creates the object or objects in one request, and returns `reference`, `receivedAt` and every property marked `x-openregister.confirmation: true`. A refusal is 422 with findings. An `Idempotency-Key` repeats the first answer. +- **All or nothing** for several writes: validate all, then write; on a later refusal, delete the earlier writes of that submit and audit it. +- **Upload tokens**: `POST /api/forms/{formId}/uploads` holds file bytes only, expires after 24 hours, purge counted. +- **Schema save checks dependent forms**: a schema change that invalidates a published form is refused or unpublishes the form, per the owning app's setting. +- **Create errors match update errors**: `ObjectsController::create` returns the per-property shape on a validation failure. +- **Two schema markers**: `x-openregister.confirmation` (returned to the submitter) and `x-openregister.serverSet` (filled by a listener, so not reported as unmapped). +- **`or-form-and-journey-registry` amended**: `journeyRun` no longer stages written ids; a step commits all or nothing. + +## Out of scope + +Authoring screens (buildiq), the public host (portaliq), the case fields (dossiq). + +## Rollback + +The validator ships in report mode for one release (findings returned, save allowed). The submit service is additive; callers switch to it in their own changes. diff --git a/openspec/changes/form-destination-validator/screens.md b/openspec/changes/form-destination-validator/screens.md new file mode 100644 index 0000000000..4e5bba93b2 --- /dev/null +++ b/openspec/changes/form-destination-validator/screens.md @@ -0,0 +1,3 @@ +# Screens + +- No screen: a validator, a submit service and two schema markers inside OpenRegister; the findings appear on buildiq's designer (BqPaginaOntwerperFouten) and on portaliq's forms, which carry their own boards. diff --git a/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md b/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md new file mode 100644 index 0000000000..f58bc65ee9 --- /dev/null +++ b/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md @@ -0,0 +1,22 @@ +# form-and-journey-registry Delta: form-destination-validator + +**Status**: draft +**Scope**: replaces the staging clauses of "The run API MUST stage answers and commit only at declared steps" in the open change `or-form-and-journey-registry`, per hydra ADR-117. Task 5.1 edits that change to match. + +## ADDED Requirements + +### Requirement: A journey run MUST store no written ids and MUST commit a step all or none + +The API SHALL expose start, answer, resume and submit operations over a `journeyRun`. Answers MAY be persisted to the run only while ADR-117 approves drafts. The run SHALL NOT store ids of written objects. Objects SHALL be created only when a step declaring `writes[]` is submitted, through `FormSubmitService`, all or none, with a later entry able to reference an earlier entry's id. + +#### Scenario: Advancing without a writes step creates nothing + +- **GIVEN** a run advanced past two steps, neither declaring `writes[]` +- **WHEN** the target registers are queried +- **THEN** no object has been created + +#### Scenario: A partial failure leaves nothing behind + +- **GIVEN** a step whose second write is refused after the first succeeded +- **WHEN** the step is submitted +- **THEN** the first object is deleted before the response, and the filer sees the second write's findings diff --git a/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md new file mode 100644 index 0000000000..34adff4102 --- /dev/null +++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md @@ -0,0 +1,91 @@ +# form-destination Delta: form-destination-validator + +**Status**: draft +**Scope**: OpenRegister validator, submit service and route, upload tokens, schema-save check. Canonical requirements: `hydra/openspec/changes/form-submits-into-its-destination-object/specs/form-destination/spec.md`. + +## ADDED Requirements + +### Requirement: OpenRegister MUST judge a form's mapping against its destination schema + +`FormDestinationValidator::validate(mapping, schema)` SHALL return one finding per problem, each `{ field?, property, code, message }`. It SHALL report a required property with no field, no fixed value, no `default` and no `x-openregister.serverSet` marker as `required-unmapped`; a field into a property the schema lacks as `property-unknown` when the schema forbids additional properties; a type or format that cannot produce the property's as `type-mismatch` or `format-mismatch`; a free-text field into an enum as `enum-unconstrained`; a choice option outside the enum as `enum-value-unknown`; a field constraint looser than the property's as `constraint-looser`; a fixed value the property refuses as `fixed-value-invalid`. It SHALL extend `TaskFormReader::validate` so flow task forms and object forms are judged by one service. + +#### Scenario: A required property with no source is reported + +- **GIVEN** schema `case` requiring `title` and `caseType` +- **AND** a mapping covering `title` only +- **WHEN** the validator runs +- **THEN** it returns one finding, property `caseType`, code `required-unmapped` + +#### Scenario: A property a listener fills is not reported + +- **GIVEN** schema `case` requiring `identifier`, marked `x-openregister.serverSet: true` +- **WHEN** a mapping without `identifier` is validated +- **THEN** no finding names `identifier` + +#### Scenario: A flow task form uses the same service + +- **GIVEN** a user-task step whose form names a read-only property +- **WHEN** the step is saved +- **THEN** the refusal comes from `FormDestinationValidator`, and `TaskFormReader` delegates to it + +### Requirement: A submit MUST create the destination in one request and return its reference + +`FormSubmitService::submit(destination, mapping, payload, subject, idempotencyKey)` SHALL validate the payload with the destination schema's full validator, regardless of the schema's hard-validation flag, then create the object under the subject's RBAC. It SHALL return `reference`, `id`, `receivedAt` and a `confirmation` map of every property marked `x-openregister.confirmation: true`, read after every create listener ran. `POST /api/forms/{formId}/submit` SHALL wrap it for forms stored in OpenRegister, with `#[PublicPage]`, `#[AnonRateLimit]` and `#[UserRateLimit]` per ADR-082. + +#### Scenario: The response carries the listener-computed fields + +- **GIVEN** a destination whose `termStartsAt` is set by an `ObjectCreatingEvent` listener and marked `x-openregister.confirmation: true` +- **WHEN** a valid payload is submitted +- **THEN** the response is 201 and `confirmation.termStartsAt` holds the listener's value + +#### Scenario: A schema without hard validation is still validated + +- **GIVEN** a destination schema with hard validation off +- **WHEN** a payload missing a required property is submitted +- **THEN** the response is 422 with a `required` finding and nothing is created + +#### Scenario: A repeated idempotency key repeats the answer + +- **GIVEN** a submit with key `k1` that created object 7 +- **WHEN** the same key is submitted again within 24 hours +- **THEN** the response repeats object 7's reference and no object is created + +### Requirement: A submit writing several objects MUST commit all or none + +The service SHALL validate every write before the first. When a write is refused after earlier writes succeeded, it SHALL delete those earlier writes before responding, and SHALL audit both the create and the delete. + +#### Scenario: A refused second write deletes the first + +- **GIVEN** writes `organisation` then `contact`, where `contact` breaks a uniqueness rule +- **WHEN** submitted +- **THEN** the response is 422 naming the `contact` finding, and the organisation no longer exists + +### Requirement: Upload tokens MUST hold bytes only and expire + +`POST /api/forms/{formId}/uploads` SHALL accept one file, check it against the destination's file rules, and return a token. A submit SHALL claim tokens by id. Unclaimed tokens SHALL be deleted after 24 hours by a background job that logs its count. + +#### Scenario: An oversized file is refused at upload + +- **GIVEN** a destination file rule of 10 MB +- **WHEN** a 12 MB file is uploaded +- **THEN** the response is 422 and no token is issued + +### Requirement: Saving a schema MUST re-check the forms that submit into it + +When a schema is saved, the validator SHALL run for every published form whose destination it is. The owning app's setting `formDestinationBreak` (`unpublish`, the default, or `refuse`) SHALL decide the outcome. The save response SHALL list every affected form with its findings. + +#### Scenario: A new required property unpublishes a form that lacks it + +- **GIVEN** a published form into `ticket` without `priority`, and the default setting +- **WHEN** `priority` becomes required +- **THEN** the form is unpublished, the response names it, and its author is notified + +### Requirement: A create refusal MUST use the per-property error shape + +`ObjectsController::create` SHALL answer a validation failure with the same `{ status, message, errors: [{ property, message }] }` body that update and patch use, with HTTP 422. + +#### Scenario: Create and update refuse alike + +- **GIVEN** an object payload missing a required property +- **WHEN** it is sent to create and, for an existing object, to update +- **THEN** both answers carry the property in `errors[]` with the same status diff --git a/openspec/changes/form-destination-validator/tasks.md b/openspec/changes/form-destination-validator/tasks.md new file mode 100644 index 0000000000..46930ad393 --- /dev/null +++ b/openspec/changes/form-destination-validator/tasks.md @@ -0,0 +1,35 @@ +# Tasks: form-destination-validator + +## 1. Validator + +- [ ] 1.1 `lib/Service/Form/FormDestinationValidator.php` with the finding codes; `TaskFormReader::validate` delegates to it + - Spec ref: specs/form-destination/spec.md, "OpenRegister MUST judge a form's mapping against its destination schema" + - Test: `tests/Unit/Service/Form/FormDestinationValidatorTest.php`, one case per code plus a zero-findings control +- [ ] 1.2 Read `x-openregister.serverSet` and `x-openregister.confirmation` in the schema property dialect; document both in the schema standards (ADR-011 table) +- [ ] 1.3 `POST /api/forms/validate` (authenticated, body: mapping and destination) so buildiq and portaliq can show findings while authoring; report mode setting `formDestinationMode` (`report` for one release, then `refuse`) + +## 2. Submit + +- [ ] 2.1 `lib/Service/Form/FormSubmitService.php`: full validation regardless of hard-validation flag, create under the subject's RBAC, confirmation map read after listeners + - Test: unit test with a fake creating listener; a control proving the field is absent when the marker is absent +- [ ] 2.2 All-or-none for several writes, with audited compensation +- [ ] 2.3 Idempotency store (24 h) keyed by form and key +- [ ] 2.4 `POST /api/forms/{formId}/submit` route, `#[PublicPage]`, ADR-082 attributes and `registerAttempt`; routes.php entry +- [ ] 2.5 `ObjectsController::create` answers 422 in the update error shape + +## 3. Uploads + +- [ ] 3.1 Upload token endpoint and claim in submit +- [ ] 3.2 Purge job (`TimedJob`, hourly), logs the count + +## 4. Schema save + +- [ ] 4.1 On schema save, re-validate dependent published forms; apply `formDestinationBreak`; list affected forms in the response; notify authors + +## 5. Journey registry + +- [ ] 5.1 Apply the MODIFIED requirement to `or-form-and-journey-registry` (no written ids on `journeyRun`; commit through `FormSubmitService`) + +## 6. Verification + +- [ ] 6.1 `composer check:strict`, `npm run lint`, `openspec validate form-destination-validator --strict` From 81026ba3252652d46f4f47d71692ad53b827ac8d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 16:24:37 +0200 Subject: [PATCH 031/183] feat(mcp): a curated #[McpTool] can carry free-form annotations to its consumers #[McpTool] gains an optional annotations map (string key to scalar). The scanner refuses a malformed map at scan time and forwards a non-empty one; AttributeToolProvider and McpProviderBridge carry it through, so hermiq reads the same map on both surfaces. OpenRegister interprets none of the marks. Decision 177 (Q-dossiq-L6-3): hermiq's intake surface recognises an intake tool by its citizenIntake mark, which no attribute tool could carry. REQ-ATTR-007. --- lib/Mcp/Attribute/McpTool.php | 9 ++++ lib/Mcp/AttributeToolScanner.php | 42 ++++++++++++++++- lib/Mcp/BuiltIn/AttributeToolProvider.php | 3 +- lib/Tool/McpProviderBridge.php | 7 ++- .../proposal.md | 21 +++++++++ .../screens.md | 1 + .../specs/ai-mcp/spec.md | 24 ++++++++++ .../tasks.md | 6 +++ openspec/specs/ai-mcp/spec.md | 23 +++++++++ .../Unit/Mcp/AttributeToolDualSurfaceTest.php | 34 ++++++++++++++ tests/Unit/Mcp/AttributeToolScannerTest.php | 43 +++++++++++++++++ .../Mcp/BuiltIn/AttributeToolProviderTest.php | 17 +++++++ .../Mcp/Fixtures/AnnotationFixtureService.php | 47 +++++++++++++++++++ 13 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/proposal.md create mode 100644 openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/screens.md create mode 100644 openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/specs/ai-mcp/spec.md create mode 100644 openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/tasks.md create mode 100644 tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php diff --git a/lib/Mcp/Attribute/McpTool.php b/lib/Mcp/Attribute/McpTool.php index abc77241b3..2371f32a19 100644 --- a/lib/Mcp/Attribute/McpTool.php +++ b/lib/Mcp/Attribute/McpTool.php @@ -59,6 +59,8 @@ * (Requirement: REQ-ATTR-005 — Attribute-declared hints/scope reach both MCP surfaces) * @spec openspec/specs/ai-mcp/spec.md * (Requirement: REQ-ATTR-006 — A curated attribute tool declares its reach) + * @spec openspec/specs/ai-mcp/spec.md + * (Requirement: REQ-ATTR-007 — A curated attribute tool forwards free-form annotations) */ declare(strict_types=1); @@ -108,6 +110,12 @@ final class McpTool { * (`self`, `user`, `instance`, `external`), validated by the scanner at * scan time. A curated two-segment id carries no verb to infer a reach * from, so an omitted reach resolves to `external` (fail closed). + * @param array $annotations Free-form marks a consuming app reads + * off the descriptor, e.g. `['citizenIntake' => true]` for hermiq's + * intake surface. OpenRegister interprets none of them; it forwards the + * map under `annotations` only when non-empty. Keys MUST be non-empty + * strings and values scalars, validated by the scanner at scan time. + * A mark is a claim: a consumer MUST NOT treat one as a grant. * * @spec openspec/specs/ai-mcp/spec.md * (Requirement: REQ-ATTR-001 — The #[McpTool] service-method attribute) @@ -124,6 +132,7 @@ public function __construct( public readonly ?string $subject = null, public readonly ?string $action = null, public readonly ?string $reach = null, + public readonly array $annotations = [], ) { }//end __construct() }//end class diff --git a/lib/Mcp/AttributeToolScanner.php b/lib/Mcp/AttributeToolScanner.php index e807535534..802071801f 100644 --- a/lib/Mcp/AttributeToolScanner.php +++ b/lib/Mcp/AttributeToolScanner.php @@ -278,7 +278,7 @@ private function buildDescriptor(string $appId, string $className, ReflectionMet * * @param McpTool $attribute The resolved attribute instance. * - * @return array The declared taxonomy keys only. + * @return array The declared taxonomy keys only. */ private function taxonomyOf(McpTool $attribute): array { $taxonomy = []; @@ -297,6 +297,12 @@ private function taxonomyOf(McpTool $attribute): array { $taxonomy['action'] = $attribute->action; } + // Free-form marks a consuming app reads (REQ-ATTR-007), validated at + // scan time; an empty map is no declaration and is not forwarded. + if ($attribute->annotations !== []) { + $taxonomy['annotations'] = $attribute->annotations; + } + return $taxonomy; }//end taxonomyOf() @@ -343,7 +349,8 @@ private function hasUnknownScope(McpTool $attribute): bool { * * `scope` is checked against {@see McpAnnotationValidator::SCOPES} * (REQ-ATTR-005) and `reach` against {@see ToolReachResolver::ORDER} - * (REQ-ATTR-006). A misspelt reach would otherwise resolve to `external` + * (REQ-ATTR-006), and every `annotations` entry must be a string key + * holding a scalar (REQ-ATTR-007). A misspelt reach would otherwise resolve to `external` * in silence: safe, but the author would never learn why the tool keeps * asking for a grant, so the scanner refuses it loudly instead. * @@ -363,9 +370,40 @@ private function unrecognisedDeclaration(McpTool $attribute): ?array { return ['key' => 'reach', 'value' => $attribute->reach, 'allowed' => ToolReachResolver::ORDER]; } + $badAnnotation = $this->malformedAnnotation(annotations: $attribute->annotations); + if ($badAnnotation !== null) { + return ['key' => 'annotations', 'value' => $badAnnotation, 'allowed' => ['non-empty string key => scalar value']]; + } + return null; }//end unrecognisedDeclaration() + /** + * The first entry of a declared annotations map that is not a non-empty + * string key holding a scalar, or null when the whole map is well formed. + * + * The map is free-form on purpose (OpenRegister reads none of it), but + * not shapeless: a nested array or an integer key would reach a consumer + * as something it cannot compare against a mark, and the tool would + * silently never qualify. Refusing it at scan time names the mistake. + * + * @param array $annotations The declared map. + * + * @return string|null The offending key, or null. + * + * @spec openspec/specs/ai-mcp/spec.md + * (Requirement: REQ-ATTR-007 — A curated attribute tool forwards free-form annotations) + */ + private function malformedAnnotation(array $annotations): ?string { + foreach ($annotations as $key => $value) { + if (is_string($key) === false || trim($key) === '' || is_scalar($value) === false) { + return (string) $key; + } + } + + return null; + }//end malformedAnnotation() + /** * Infer the `inputSchema` from the method's parameter type hints and * docblock `@param` tags. Parameters without a default value are diff --git a/lib/Mcp/BuiltIn/AttributeToolProvider.php b/lib/Mcp/BuiltIn/AttributeToolProvider.php index 6bfcc6dc88..f769b7a2e2 100644 --- a/lib/Mcp/BuiltIn/AttributeToolProvider.php +++ b/lib/Mcp/BuiltIn/AttributeToolProvider.php @@ -156,7 +156,8 @@ public function getTools(): array { // `#[McpTool]` reached the scanner and then vanished before the // grant matrix: the attribute, the scanner and the bridge all // carried them, and this one copy step did not. - foreach (['subject', 'action', 'reach'] as $taxonomyKey) { + // `annotations` (REQ-ATTR-007) rides the same list for the same reason. + foreach (['subject', 'action', 'reach', 'annotations'] as $taxonomyKey) { if (array_key_exists($taxonomyKey, $entry) === true) { $descriptor[$taxonomyKey] = $entry[$taxonomyKey]; } diff --git a/lib/Tool/McpProviderBridge.php b/lib/Tool/McpProviderBridge.php index ff96a237df..5cff131097 100644 --- a/lib/Tool/McpProviderBridge.php +++ b/lib/Tool/McpProviderBridge.php @@ -69,9 +69,14 @@ class McpProviderBridge implements ToolInterface { * doing that resorted to splitting camelCase and singularising English. * Carrying them costs three keys and removes the guessing entirely. * + * `annotations` is the free-form map a curated `#[McpTool]` declares + * (REQ-ATTR-007), such as hermiq's `citizenIntake` mark. Carried whole and + * never read here; the LLM never sees it, because the chat path builds its + * function payload from name, description and parameters only. + * * @var array */ - private const PASSTHROUGH_KEYS = ['scope', 'reach', 'app', 'subject', 'action']; + private const PASSTHROUGH_KEYS = ['scope', 'reach', 'app', 'subject', 'action', 'annotations']; /** * Optional agent context attached by the registry. diff --git a/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/proposal.md b/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/proposal.md new file mode 100644 index 0000000000..f5fce8d9c0 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/proposal.md @@ -0,0 +1,21 @@ +# A curated #[McpTool] forwards free-form annotations + +## Why + +hermiq's conversational intake may only call a tool that its owning app marks `citizenIntake`. An array-descriptor provider can put an `annotations` map on its descriptor, but an attribute-scanned app cannot: `#[McpTool]` has no way to carry a mark, `AttributeToolScanner` builds none, `AttributeToolProvider::getTools()` rebuilds the descriptor from a fixed key list and `McpProviderBridge` forwards a fixed key list too. So no curated tool can ever reach hermiq's intake surface. + +Asked by dossiq (change ai-features-on-the-case-consume-hermiq, tasks 5.1 and 5.2) under decision 177: OpenRegister forwards a free-form `annotations` map from `#[McpTool]`, at the same copy points as `reach`. + +## What changes + +- `#[McpTool]` gains an optional `annotations` map (string key to scalar). +- `AttributeToolScanner` refuses a malformed map at scan time (an integer or empty key, or a non-scalar value, skips the tool with a warning, like an unrecognised `scope` or `reach`) and forwards a non-empty map under `annotations`. +- `AttributeToolProvider::getTools()` and `McpProviderBridge::getFunctions()` carry `annotations` through. + +OpenRegister reads none of the marks. A mark is a claim, not a grant: grant resolution, reach and RBAC are untouched. + +## Impact + +- Affected spec: `ai-mcp` (REQ-ATTR-007 added). +- Code: `lib/Mcp/Attribute/McpTool.php`, `lib/Mcp/AttributeToolScanner.php`, `lib/Mcp/BuiltIn/AttributeToolProvider.php`, `lib/Tool/McpProviderBridge.php`. +- No schema, register or migration change. Stacked on #4546 (reach), which touches the same lines. diff --git a/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/screens.md b/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/screens.md new file mode 100644 index 0000000000..2fa77648e2 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/screens.md @@ -0,0 +1 @@ +- No screen: a PHP attribute parameter read by the MCP tool scanner; it changes tool descriptors, not a page. diff --git a/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/specs/ai-mcp/spec.md b/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/specs/ai-mcp/spec.md new file mode 100644 index 0000000000..800b1d8cfc --- /dev/null +++ b/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/specs/ai-mcp/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: REQ-ATTR-007 — A curated attribute tool forwards free-form annotations + +`#[McpTool]` MUST accept an optional `annotations` map whose keys are non-empty strings and whose values are scalars. `AttributeToolScanner` MUST reject a malformed map at scan time (logged, the offending tool skipped, sibling tools unaffected) and MUST forward a non-empty map into the descriptor under `annotations`; an empty map MUST NOT be forwarded. `AttributeToolProvider::getTools()` and `McpProviderBridge::getFunctions()` MUST carry the key through unchanged, so a consumer reads the same map on the JSON-RPC surface and on `ToolRegistryFacade::listTools()`. OpenRegister MUST NOT interpret any mark: a mark is a claim, and grant resolution, reach and RBAC MUST NOT change because of one. + +#### Scenario: A declared mark appears on both surfaces +- **GIVEN** app `dossiq` exposes `#[McpTool(scope: 'create', action: 'create', annotations: ['citizenIntake' => true])] fileCase(string $title)` +- **WHEN** the descriptor is read from `McpToolsService::listTools()` and from `ToolRegistryFacade::listTools()` +- **THEN** both entries MUST carry `annotations: {citizenIntake: true}`, `scope: 'create'` and `action: 'create'` +@e2e exclude Wiring assertion through the real registry, bridge and facade chain; asserted by AttributeToolDualSurfaceTest. + +#### Scenario: No declaration, no key +- **GIVEN** a method `#[McpTool(scope: 'update')] reassignCase(string $id)` +- **WHEN** its descriptor is built +- **THEN** the descriptor MUST NOT carry an `annotations` key +@e2e exclude Descriptor-shape assertion with no UI surface; asserted by AttributeToolScannerTest. + +#### Scenario: A malformed map is rejected at scan time +- **GIVEN** a method `#[McpTool(annotations: ['citizenIntake' => ['nested' => true]])]` +- **WHEN** `AttributeToolScanner` scans the declaring class +- **THEN** no tool MUST be registered for that method +- **AND** a warning MUST be logged naming `annotations` +@e2e exclude Malformed-attribute path with no UI surface; asserted by AttributeToolScannerTest. diff --git a/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/tasks.md b/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/tasks.md new file mode 100644 index 0000000000..948ea6c4dc --- /dev/null +++ b/openspec/changes/archive/2026-10-10-mcp-tool-forwards-annotations/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] 1.1 `#[McpTool]` gains `public readonly array $annotations = []` (lib/Mcp/Attribute/McpTool.php) +- [x] 1.2 `AttributeToolScanner` rejects a malformed map at scan time and forwards a non-empty one under `annotations` (lib/Mcp/AttributeToolScanner.php; AttributeToolScannerTest::testDescriptorForwardsDeclaredAnnotations, testUndeclaredAnnotationsStayOmitted, testMalformedAnnotationsAreRejectedAndLogged) +- [x] 1.3 `AttributeToolProvider::getTools()` carries `annotations` through its rebuild (AttributeToolProviderTest::testGetToolsForwardsDeclaredAnnotationsAndOmitsAnEmptyMap) +- [x] 1.4 `McpProviderBridge` carries `annotations` to the facade, so the map arrives on both surfaces (AttributeToolDualSurfaceTest::testDeclaredAnnotationsArriveOnBothSurfaces) diff --git a/openspec/specs/ai-mcp/spec.md b/openspec/specs/ai-mcp/spec.md index d7a926b69e..c5d5934445 100644 --- a/openspec/specs/ai-mcp/spec.md +++ b/openspec/specs/ai-mcp/spec.md @@ -697,6 +697,29 @@ invocation behavior. - **AND** `ToolGrantResolver::requiresGrant()` MUST return false for a tool that is also read-only @e2e exclude Wiring assertion through the real registry, bridge and facade chain; asserted by AttributeToolDualSurfaceTest. +### Requirement: REQ-ATTR-007 — A curated attribute tool forwards free-form annotations + +`#[McpTool]` MUST accept an optional `annotations` map whose keys are non-empty strings and whose values are scalars. `AttributeToolScanner` MUST reject a malformed map at scan time (logged, the offending tool skipped, sibling tools unaffected) and MUST forward a non-empty map into the descriptor under `annotations`; an empty map MUST NOT be forwarded. `AttributeToolProvider::getTools()` and `McpProviderBridge::getFunctions()` MUST carry the key through unchanged, so a consumer reads the same map on the JSON-RPC surface and on `ToolRegistryFacade::listTools()`. OpenRegister MUST NOT interpret any mark: a mark is a claim, and grant resolution, reach and RBAC MUST NOT change because of one. + +#### Scenario: A declared mark appears on both surfaces +- **GIVEN** app `dossiq` exposes `#[McpTool(scope: 'create', action: 'create', annotations: ['citizenIntake' => true])] fileCase(string $title)` +- **WHEN** the descriptor is read from `McpToolsService::listTools()` and from `ToolRegistryFacade::listTools()` +- **THEN** both entries MUST carry `annotations: {citizenIntake: true}`, `scope: 'create'` and `action: 'create'` +@e2e exclude Wiring assertion through the real registry, bridge and facade chain; asserted by AttributeToolDualSurfaceTest. + +#### Scenario: No declaration, no key +- **GIVEN** a method `#[McpTool(scope: 'update')] reassignCase(string $id)` +- **WHEN** its descriptor is built +- **THEN** the descriptor MUST NOT carry an `annotations` key +@e2e exclude Descriptor-shape assertion with no UI surface; asserted by AttributeToolScannerTest. + +#### Scenario: A malformed map is rejected at scan time +- **GIVEN** a method `#[McpTool(annotations: ['citizenIntake' => ['nested' => true]])]` +- **WHEN** `AttributeToolScanner` scans the declaring class +- **THEN** no tool MUST be registered for that method +- **AND** a warning MUST be logged naming `annotations` +@e2e exclude Malformed-attribute path with no UI surface; asserted by AttributeToolScannerTest. + ### Requirement: REQ-006 — ToolRegistryFacade is the public read/invoke surface for cross-app tool-loop consumers `OCA\OpenRegister\Service\Mcp\ToolRegistryFacade` MUST be OpenRegister's diff --git a/tests/Unit/Mcp/AttributeToolDualSurfaceTest.php b/tests/Unit/Mcp/AttributeToolDualSurfaceTest.php index b9bdbba441..29353e4af7 100644 --- a/tests/Unit/Mcp/AttributeToolDualSurfaceTest.php +++ b/tests/Unit/Mcp/AttributeToolDualSurfaceTest.php @@ -32,6 +32,7 @@ use OCA\OpenRegister\Service\Mcp\McpToolsService; use OCA\OpenRegister\Service\Mcp\ToolRegistryFacade; use OCA\OpenRegister\Service\ToolRegistry; +use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\AnnotationFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\AttributeFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\HintScopeFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\ReachFixtureService; @@ -47,6 +48,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +require_once __DIR__ . '/Fixtures/AnnotationFixtureService.php'; require_once __DIR__ . '/Fixtures/AttributeFixtureService.php'; require_once __DIR__ . '/Fixtures/HintScopeFixtureService.php'; require_once __DIR__ . '/Fixtures/ReachFixtureService.php'; @@ -315,4 +317,36 @@ public function testDeclaredReachArrivesAtTheResolverOnBothSurfaces(): void { } }//end testDeclaredReachArrivesAtTheResolverOnBothSurfaces() + + // ── Declared annotations end to end (REQ-ATTR-007) ─────────────── + + public function testDeclaredAnnotationsArriveOnBothSurfaces(): void { + $instance = new AnnotationFixtureService(); + $entries = (new AttributeToolScanner())->scanClass(appId: 'dossiq', className: get_class($instance), logger: $this->logger); + foreach ($entries as &$entry) { + $entry['instance'] = $instance; + } + + unset($entry); + $provider = new AttributeToolProvider(appId: 'dossiq', entries: $entries, auditTrailMapper: $this->auditTrailMapper, logger: $this->logger); + + $service = new McpToolsService(providers: [$provider], logger: $this->logger); + $jsonRpc = []; + foreach ($service->listTools()['tools'] as $tool) { + $jsonRpc[$tool['id']] = $tool; + } + + $facade = []; + foreach ($this->buildFacade($provider)->listTools() as $descriptor) { + $facade[$descriptor['mcpId']] = $descriptor; + } + + foreach ([$jsonRpc, $facade] as $surface) { + $this->assertSame(['citizenIntake' => true], $surface['dossiq.fileCase']['annotations']); + $this->assertSame('create', $surface['dossiq.fileCase']['scope']); + $this->assertSame('create', $surface['dossiq.fileCase']['action']); + $this->assertArrayNotHasKey('annotations', $surface['dossiq.reassignCase']); + } + + }//end testDeclaredAnnotationsArriveOnBothSurfaces() }//end class diff --git a/tests/Unit/Mcp/AttributeToolScannerTest.php b/tests/Unit/Mcp/AttributeToolScannerTest.php index c97cf62758..e9c585c8cf 100644 --- a/tests/Unit/Mcp/AttributeToolScannerTest.php +++ b/tests/Unit/Mcp/AttributeToolScannerTest.php @@ -25,6 +25,7 @@ namespace OCA\OpenRegister\Tests\Unit\Mcp; use OCA\OpenRegister\Mcp\AttributeToolScanner; +use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\AnnotationFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\AttributeFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\HintScopeFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\ReachFixtureService; @@ -32,6 +33,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +require_once __DIR__ . '/Fixtures/AnnotationFixtureService.php'; require_once __DIR__ . '/Fixtures/AttributeFixtureService.php'; require_once __DIR__ . '/Fixtures/HintScopeFixtureService.php'; require_once __DIR__ . '/Fixtures/ReachFixtureService.php'; @@ -324,4 +326,45 @@ public function testUnknownReachIsRejectedAndLogged(): void { $this->assertArrayHasKey('reassignCase', $descriptors); }//end testUnknownReachIsRejectedAndLogged() + + // ── Declared annotations (REQ-ATTR-007) ────────────────────────── + + /** + * @return array> descriptor keyed by `name` + */ + private function scanAnnotationFixture(): array { + $byName = []; + foreach ($this->scanner->scanClass(appId: 'dossiq', className: AnnotationFixtureService::class, logger: $this->logger) as $descriptor) { + $byName[$descriptor['name']] = $descriptor; + } + + return $byName; + }//end scanAnnotationFixture() + + public function testDescriptorForwardsDeclaredAnnotations(): void { + $descriptor = $this->scanAnnotationFixture()['fileCase']; + + $this->assertSame(['citizenIntake' => true], $descriptor['annotations']); + $this->assertSame('create', $descriptor['scope']); + $this->assertSame('create', $descriptor['action']); + + }//end testDescriptorForwardsDeclaredAnnotations() + + public function testUndeclaredAnnotationsStayOmitted(): void { + $this->assertArrayNotHasKey('annotations', $this->scanAnnotationFixture()['reassignCase']); + + }//end testUndeclaredAnnotationsStayOmitted() + + public function testMalformedAnnotationsAreRejectedAndLogged(): void { + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('unrecognised `annotations`')); + + $descriptors = $this->scanAnnotationFixture(); + + $this->assertArrayNotHasKey('badAnnotationTool', $descriptors); + $this->assertArrayHasKey('fileCase', $descriptors); + $this->assertArrayHasKey('reassignCase', $descriptors); + + }//end testMalformedAnnotationsAreRejectedAndLogged() }//end class diff --git a/tests/Unit/Mcp/BuiltIn/AttributeToolProviderTest.php b/tests/Unit/Mcp/BuiltIn/AttributeToolProviderTest.php index ce26fd0adc..f14d4baf19 100644 --- a/tests/Unit/Mcp/BuiltIn/AttributeToolProviderTest.php +++ b/tests/Unit/Mcp/BuiltIn/AttributeToolProviderTest.php @@ -31,6 +31,7 @@ use OCA\OpenRegister\Db\AuditTrailMapper; use OCA\OpenRegister\Mcp\AttributeToolScanner; use OCA\OpenRegister\Mcp\BuiltIn\AttributeToolProvider; +use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\AnnotationFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\AttributeFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\HintScopeFixtureService; use OCA\OpenRegister\Tests\Unit\Mcp\Fixtures\ReachFixtureService; @@ -40,6 +41,7 @@ use Psr\Log\LoggerInterface; use RuntimeException; +require_once __DIR__ . '/../Fixtures/AnnotationFixtureService.php'; require_once __DIR__ . '/../Fixtures/AttributeFixtureService.php'; require_once __DIR__ . '/../Fixtures/HintScopeFixtureService.php'; require_once __DIR__ . '/../Fixtures/ReachFixtureService.php'; @@ -327,4 +329,19 @@ public function testGetToolsForwardsDeclaredReachAndOmitsAnUndeclaredOne(): void $this->assertArrayNotHasKey('reach', $byId['dossiq.reassignCase']); }//end testGetToolsForwardsDeclaredReachAndOmitsAnUndeclaredOne() + + // ── getTools: declared annotations (REQ-ATTR-007) ──────────────── + + public function testGetToolsForwardsDeclaredAnnotationsAndOmitsAnEmptyMap(): void { + $provider = $this->provider($this->entriesFor(new AnnotationFixtureService(), 'dossiq'), 'dossiq'); + + $byId = []; + foreach ($provider->getTools() as $tool) { + $byId[$tool['id']] = $tool; + } + + $this->assertSame(['citizenIntake' => true], $byId['dossiq.fileCase']['annotations']); + $this->assertArrayNotHasKey('annotations', $byId['dossiq.reassignCase']); + + }//end testGetToolsForwardsDeclaredAnnotationsAndOmitsAnEmptyMap() }//end class diff --git a/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php b/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php new file mode 100644 index 0000000000..3d79b70bfe --- /dev/null +++ b/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php @@ -0,0 +1,47 @@ + + * @copyright 2026 Conduction BV + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/specs/ai-mcp/spec.md + * (Requirement: REQ-ATTR-007 — A curated attribute tool forwards free-form annotations) + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Mcp\Fixtures; + +use OCA\OpenRegister\Mcp\Attribute\McpTool; + +class AnnotationFixtureService { + + #[McpTool(scope: 'create', action: 'create', reach: 'user', annotations: ['citizenIntake' => true])] + public function fileCase(string $title): array { + return ['title' => $title]; + }//end fileCase() + + #[McpTool(scope: 'update')] + public function reassignCase(string $id): array { + return ['id' => $id]; + }//end reassignCase() + + #[McpTool(annotations: ['citizenIntake' => ['nested' => true]])] + public function badAnnotationTool(string $id): array { + return ['id' => $id]; + }//end badAnnotationTool() +}//end class From 539857faf58378e40ace006b1e705a91442c500a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 16:43:30 +0200 Subject: [PATCH 032/183] fix(flow): a run-node caller's config reaches the node FlowNodeRunController::run() merged the caller's config into a step, but executeNode() re-pins the published graph, which replaced that step, so a picker's choice (a template id) never reached the node. executeNode() now takes the caller's config and overlays it on the pinned step after pinning (caller keys win, authored keys stay); the controller passes it. or-flow-run-node: new scenario 'The caller's config reaches the node', task 6. --- lib/Controller/FlowNodeRunController.php | 6 ++- lib/Service/Flow/FlowRunService.php | 10 ++++- .../specs/flow-run-node/spec.md | 9 ++++ openspec/changes/or-flow-run-node/tasks.md | 8 ++++ .../Controller/FlowNodeRunControllerTest.php | 2 +- .../Flow/FlowRunServiceExecuteNodeTest.php | 41 ++++++++++++++++++- 6 files changed, 72 insertions(+), 4 deletions(-) diff --git a/lib/Controller/FlowNodeRunController.php b/lib/Controller/FlowNodeRunController.php index e42f789871..17d427fd73 100644 --- a/lib/Controller/FlowNodeRunController.php +++ b/lib/Controller/FlowNodeRunController.php @@ -280,11 +280,15 @@ public function run(string $id, string $nodeId): JSONResponse { $flowDocument = ($this->subjects->resolveFlow(flowId: $id) ?? ['id' => $id, 'nodes' => [$mergedStep], 'edges' => []]); + // The caller's config goes in explicitly: executeNode() re-pins the + // PUBLISHED graph, which replaces every node in $flowDocument, so a + // $mergedStep placed there alone never reached the node. $run = $this->runner->executeNode( run: $run, flow: $flowDocument, subject: $object, - nodeId: $nodeId + nodeId: $nodeId, + config: $callerConfig ); return new JSONResponse($run->jsonSerialize(), Http::STATUS_CREATED); diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index cdbb43bfb1..f7da616e9d 100644 --- a/lib/Service/Flow/FlowRunService.php +++ b/lib/Service/Flow/FlowRunService.php @@ -1051,19 +1051,23 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed * @param array $flow The flow document (used only to resolve the published pin). * @param object $subject The subject object the node acts on. * @param string $nodeId The node to run. + * @param array $config The caller's config, overlaid on the published step's config after + * pinning (caller keys win). The pin replaces the graph's nodes, so + * this is the only way a picker's choice reaches the node. * * @return FlowRun The updated run: COMPLETED with the node's output items, * or FAILED with the node's exception recorded. Never * SUSPENDED — see {@see failSuspendedNode()}. * * @spec openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md#requirement-a-node-type-opts-in-to-direct-invocation + * @spec openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md#scenario-the-callers-config-reaches-the-node * * @SuppressWarnings(PHPMD.StaticAccess) FlowItems::fromSubject/normalise are * stateless value normalisers, the same ones execute() calls under the same * suppression reasoning already given there: injecting a factory to call * them would add a dependency without removing any coupling. */ - public function executeNode(FlowRun $run, array $flow, object $subject, string $nodeId): FlowRun { + public function executeNode(FlowRun $run, array $flow, object $subject, string $nodeId, array $config = []): FlowRun { if ($run->getStatus() !== FlowRun::STATUS_QUEUED) { // Parked awaiting delegated-identity consent, or otherwise not // ready to proceed — the same "do not double-act" rule execute() @@ -1082,6 +1086,10 @@ public function executeNode(FlowRun $run, array $flow, object $subject, string $ return $this->failMissingNode(run: $run, nodeId: $nodeId); } + if ($config !== []) { + $step['config'] = array_merge((array)($step['config'] ?? []), $config); + } + $run->setStatus(FlowRun::STATUS_RUNNING); $run->setUpdated(new DateTime()); $this->mapper->update($run); diff --git a/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md b/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md index f2ed2c46bf..64ad7e5962 100644 --- a/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md +++ b/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md @@ -23,6 +23,15 @@ refused (404), even when the flow and node id otherwise resolve. - **THEN** only that node runs, against the given subject, and a `FlowRun` record is created scoped to it +#### Scenario: The caller's config reaches the node + +- **GIVEN** a published node whose authored config is + `{templateId: "authored", folder: "Besluiten"}` +- **WHEN** the caller runs it directly with `config: {templateId: "picked"}` +- **THEN** the node executes with `{templateId: "picked", folder: "Besluiten"}`: + caller keys win, authored keys the caller did not send stay, and the + published version pin does not drop the caller's config + ### Requirement: Direct invocation is authorized against the subject object The endpoint MUST NOT authorize solely on the flow named-rights matrix diff --git a/openspec/changes/or-flow-run-node/tasks.md b/openspec/changes/or-flow-run-node/tasks.md index 4d396d15c6..68f5cc4f9d 100644 --- a/openspec/changes/or-flow-run-node/tasks.md +++ b/openspec/changes/or-flow-run-node/tasks.md @@ -45,6 +45,14 @@ Implementation below. field unchanged; a node with no `IFlowNodeConfigForm` returns a body with NO `configForm` key (existing "absent, not empty" convention from `FlowNodeRegistry::palette()`, not a new failure mode). +- [x] 6. The caller's `config` reaches the node. `run()` built a merged step + but `executeNode()` re-pins the PUBLISHED graph, which replaced it, so a + picker's choice was dropped. `executeNode()` now takes `config` and overlays + it on the pinned step (caller keys win); the controller passes it. + - unit tests: `FlowRunServiceExecuteNodeTest::testTheCallersConfigOverlaysThePublishedStep`, + `testExecuteNodeRunsWithThePublishedConfigByDefault`; + `FlowNodeRunControllerTest::testAnAuthorizedCallerRunsTheNodeAndReceivesTheRun` + asserts the config is passed. - [ ] 4. `composer check:strict` exits 0. - [x] 5. `@spec openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md` on every changed/added method. diff --git a/tests/Unit/Controller/FlowNodeRunControllerTest.php b/tests/Unit/Controller/FlowNodeRunControllerTest.php index 5f744de9cf..cda924a928 100644 --- a/tests/Unit/Controller/FlowNodeRunControllerTest.php +++ b/tests/Unit/Controller/FlowNodeRunControllerTest.php @@ -320,7 +320,7 @@ public function testAnAuthorizedCallerRunsTheNodeAndReceivesTheRun(): void { $done = new FlowRun(); $done->setStatus(FlowRun::STATUS_COMPLETED); $this->runner->expects($this->once())->method('executeNode') - ->with($queued, ['id' => 'f1', 'nodes' => []], $object, 'n1') + ->with($queued, ['id' => 'f1', 'nodes' => []], $object, 'n1', ['templateSlug' => 'welcome']) ->willReturn($done); $response = $this->controller->run(id: 'f1', nodeId: 'n1'); diff --git a/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php b/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php index 1053d1f533..5d4ee4144a 100644 --- a/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php +++ b/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php @@ -38,6 +38,9 @@ public function jsonSerialize(): array { class CountingNode implements IFlowNode { public int $calls = 0; + /** @var array The config the node last ran with. */ + public array $lastConfig = []; + public function __construct( private readonly string $id, ) { @@ -68,6 +71,7 @@ public function validateConfig(array $config): void { public function execute(array $items, array $config, array $context): array { $this->calls++; + $this->lastConfig = $config; return $items; } @@ -183,7 +187,7 @@ function (Event $event): void { $pin = $this->pinReturning( graph: [ 'nodes' => [ - ['id' => 'target', 'type' => 'test.target'], + ['id' => 'target', 'type' => 'test.target', 'config' => ['templateId' => 'authored', 'folder' => 'Besluiten']], ['id' => 'downstream', 'type' => 'test.downstream'], ['id' => 'boom', 'type' => 'test.explode'], ['id' => 'wait', 'type' => 'test.suspend'], @@ -299,4 +303,39 @@ public function testExecuteNodeDoesNothingWhenTheRunIsNotQueued(): void { $this->assertSame(0, $this->target->calls); }//end testExecuteNodeDoesNothingWhenTheRunIsNotQueued() + /** + * The step runs with the config the flow author published when the caller + * sends none. + * + * @return void + */ + public function testExecuteNodeRunsWithThePublishedConfigByDefault(): void { + $run = $this->service->queue(flowId: 'f1', trigger: FlowRunService::TRIGGER_DIRECT_NODE, user: 'alice'); + + $this->service->executeNode(run: $run, flow: $this->flowDoc(), subject: new ExecuteNodeSubject(), nodeId: 'target'); + + $this->assertSame(['templateId' => 'authored', 'folder' => 'Besluiten'], $this->target->lastConfig); + }//end testExecuteNodeRunsWithThePublishedConfigByDefault() + + /** + * The caller's config (a picker's choice) overlays the published step's + * config: caller keys win, authored keys the caller did not send stay. + * Before this, the pin replaced whatever step the controller built, so a + * picked template never reached the node. + * + * @return void + */ + public function testTheCallersConfigOverlaysThePublishedStep(): void { + $run = $this->service->queue(flowId: 'f1', trigger: FlowRunService::TRIGGER_DIRECT_NODE, user: 'alice'); + + $this->service->executeNode( + run: $run, + flow: $this->flowDoc(), + subject: new ExecuteNodeSubject(), + nodeId: 'target', + config: ['templateId' => 'picked'] + ); + + $this->assertSame(['templateId' => 'picked', 'folder' => 'Besluiten'], $this->target->lastConfig); + }//end testTheCallersConfigOverlaysThePublishedStep() }//end class From e3be100b08ea832f00944dcfd0c3295dfff032ad Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 16:53:52 +0200 Subject: [PATCH 033/183] refactor(notifications): the reference walk is its own method (phpmd complexity) --- .../ReferencedAddressNotifier.php | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/lib/Service/Notification/ReferencedAddressNotifier.php b/lib/Service/Notification/ReferencedAddressNotifier.php index 0fc8342820..db136f19ef 100644 --- a/lib/Service/Notification/ReferencedAddressNotifier.php +++ b/lib/Service/Notification/ReferencedAddressNotifier.php @@ -183,43 +183,61 @@ public function notify( * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 */ public function resolveAddress(ObjectEntity $object, array $data, string $path): array { - $segments = array_values(array_filter(explode('.', $path), static fn (string $s): bool => $s !== '')); + $segments = array_values(array_filter(explode('.', $path), static fn (string $segment): bool => $segment !== '')); if ($segments === [] || count($segments) > (self::MAX_HOPS + 1)) { return ['address' => null, 'reason' => self::REASON_NO_REFERENCE]; } $last = array_pop($segments); + $followed = $this->follow(object: $object, data: $data, segments: $segments); + if ($followed['reason'] !== null) { + return ['address' => null, 'reason' => $followed['reason']]; + } + + $value = ($followed['data'][$last] ?? null); + if (is_string($value) === false || trim($value) === '') { + return ['address' => null, 'reason' => self::REASON_NO_REFERENCE]; + } + + $address = trim($value); + if (filter_var($address, FILTER_VALIDATE_EMAIL) === false) { + return ['address' => null, 'reason' => self::REASON_NOT_AN_ADDRESS]; + } + + return ['address' => $address, 'reason' => null]; + }//end resolveAddress() + + /** + * Follow each reference segment to the object it names. + * + * @param ObjectEntity $object The triggering object. + * @param array $data Its data. + * @param array $segments The reference segments, in order. + * + * @return array{data: array, reason: string|null} The data reached, or why it was not. + */ + private function follow(ObjectEntity $object, array $data, array $segments): array { $current = $data; foreach ($segments as $segment) { $reference = $this->referenceOf(value: ($current[$segment] ?? null)); if ($reference === null) { - return ['address' => null, 'reason' => self::REASON_NO_REFERENCE]; + return ['data' => [], 'reason' => self::REASON_NO_REFERENCE]; } $related = $this->findReferenced(id: $reference); if ($related === null) { - return ['address' => null, 'reason' => self::REASON_REFERENCE_NOT_FOUND]; + return ['data' => [], 'reason' => self::REASON_REFERENCE_NOT_FOUND]; } if ($this->sameTenant(origin: $object, related: $related) === false) { - return ['address' => null, 'reason' => self::REASON_OTHER_TENANT]; + return ['data' => [], 'reason' => self::REASON_OTHER_TENANT]; } $current = $related->getObject(); } - $value = ($current[$last] ?? null); - if (is_string($value) === false || trim($value) === '') { - return ['address' => null, 'reason' => self::REASON_NO_REFERENCE]; - } - - $address = trim($value); - if (filter_var($address, FILTER_VALIDATE_EMAIL) === false) { - return ['address' => null, 'reason' => self::REASON_NOT_AN_ADDRESS]; - } - - return ['address' => $address, 'reason' => null]; - }//end resolveAddress() + return ['data' => $current, 'reason' => null]; + }//end follow() /** * Send one resolved entry, or name why it was not sent. From 23b791150a4cb23e1b4de4b965e3cf8bffbd8114 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 16:56:50 +0200 Subject: [PATCH 034/183] feat(search): a search hit's deep link can name the object's own properties The unified search formatter handed the deep-link registry only @self plus uuid, register and schema, so a template like /apps/dossiq/cases/{case} opened with a literal {case}. It now also passes the object's own declared scalar properties, URL-encoded, with @self and the ids merged over them, and falls back to OpenRegister's own page when a placeholder stays unfilled. Change: search-deep-link-names-object-properties (task 1; task 2 live). --- .../Search/ObjectSearchResultFormatter.php | 89 ++++++++++++++--- .../proposal.md | 20 ++++ .../screens.md | 1 + .../specs/deep-link-registry/spec.md | 27 +++++ .../tasks.md | 5 + .../ObjectSearchResultFormatterTest.php | 99 +++++++++++++++++++ 6 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 openspec/changes/search-deep-link-names-object-properties/proposal.md create mode 100644 openspec/changes/search-deep-link-names-object-properties/screens.md create mode 100644 openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md create mode 100644 openspec/changes/search-deep-link-names-object-properties/tasks.md diff --git a/lib/Service/Search/ObjectSearchResultFormatter.php b/lib/Service/Search/ObjectSearchResultFormatter.php index 560af39ea3..8e805b573e 100644 --- a/lib/Service/Search/ObjectSearchResultFormatter.php +++ b/lib/Service/Search/ObjectSearchResultFormatter.php @@ -121,23 +121,11 @@ public function format(array|ObjectEntity $result, ?string $term): SearchResultE $selfArray = $selfData; } - $flatData = array_merge( - $selfArray, - ['uuid' => $uuid, 'register' => $registerId, 'schema' => $schemaId] - ); - - // Try deep link registry first, fall back to OpenRegister's own route. - $objectUrl = $this->deepLinkRegistry->resolveUrl( - registerId: $registerId, - schemaId: $schemaId, - objectData: $flatData + $objectUrl = $this->resolveObjectUrl( + object: $result, + selfData: $selfArray, + ids: ['uuid' => (string)$uuid, 'register' => $registerId, 'schema' => $schemaId] ); - if ($objectUrl === null) { - $objectUrl = $this->urlGenerator->linkToRoute( - 'openregister.objects.show', - ['register' => $registerId, 'schema' => $schemaId, 'id' => $uuid] - ); - } // Resolve the per-app label for this (register, schema) pair. $appLabel = $this->deepLinkRegistry->resolveDisplayName( @@ -177,6 +165,75 @@ public function format(array|ObjectEntity $result, ?string $term): SearchResultE ); }//end format() + /** + * The hit's link: the owning app's deep link when one is registered and + * every placeholder in it filled, otherwise OpenRegister's own page. + * + * @param array $object The serialised object. + * @param array $selfData The object's `@self` metadata. + * @param array{uuid:string,register:int,schema:int} $ids The resolved uuid, register and schema. + * + * @return string The link. + * + * @spec openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md#requirement-a-search-hits-deep-link-can-name-the-objects-own-properties + */ + private function resolveObjectUrl(array $object, array $selfData, array $ids): string { + // The object's own scalar properties come first, so a template can + // name one (`/apps/dossiq/cases/{case}`), and `@self` plus the + // resolved uuid, register and schema are merged over them: an own + // property named `uuid` or `owner` never hijacks the metadata. + $flatData = array_merge($this->ownLinkProperties(object: $object), $selfData, $ids); + + $objectUrl = $this->deepLinkRegistry->resolveUrl( + registerId: $ids['register'], + schemaId: $ids['schema'], + objectData: $flatData + ); + + // A placeholder the object could not fill (its `case` is empty) would + // leave a literal `{case}` in the link. Such a hit opens OpenRegister's + // own page instead of a broken one. + if ($objectUrl !== null && preg_match('/\{[A-Za-z0-9_]+\}/', $objectUrl) !== 1) { + return $objectUrl; + } + + return $this->urlGenerator->linkToRoute( + 'openregister.objects.show', + ['register' => $ids['register'], 'schema' => $ids['schema'], 'id' => $ids['uuid']] + ); + }//end resolveObjectUrl() + + /** + * The object's own properties a deep-link template may name: declared + * (not `@self`, not an `_`-prefixed key the pipeline attached, the same + * line {@see buildExcerpt()} draws) and scalar. Values are URL-encoded, + * because they land in a path and a value like `../settings` must not + * walk out of it. + * + * @param array $object The serialised object. + * + * @return array Property name to encoded value. + * + * @spec openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md#requirement-a-search-hits-deep-link-can-name-the-objects-own-properties + */ + private function ownLinkProperties(array $object): array { + $properties = []; + foreach ($object as $key => $value) { + $key = (string)$key; + if ($key === '@self' || str_starts_with($key, '_') === true) { + continue; + } + + if (is_scalar($value) === false) { + continue; + } + + $properties[$key] = rawurlencode((string)$value); + } + + return $properties; + }//end ownLinkProperties() + /** * Icon precedence: * 1. the schema's own MDI icon (an explicit, per-schema choice by the diff --git a/openspec/changes/search-deep-link-names-object-properties/proposal.md b/openspec/changes/search-deep-link-names-object-properties/proposal.md new file mode 100644 index 0000000000..2d148e2042 --- /dev/null +++ b/openspec/changes/search-deep-link-names-object-properties/proposal.md @@ -0,0 +1,20 @@ +# Proposal: search-deep-link-names-object-properties + +## Summary + +A deep-link template can name any top-level key of the object data, but the unified search formatter only ever handed the registry `@self` plus the uuid, register and schema. So a template like `/apps/dossiq/cases/{case}`, which sends a hit on a case-owned record (a document, a contact moment, a decision) to the case it belongs to, opened with a literal `{case}` in it. + +The formatter now also passes the object's own scalar properties, URL-encoded, with `@self` and the resolved ids merged over them. A link whose placeholder the object cannot fill falls back to OpenRegister's own page instead of a broken URL. + +## Why + +Dossiq's `unified-search-opens-every-hit-in-dossiq` tasks 2.1 and 2.2: 47 case-owned schemas stay searchable but open OpenRegister's raw object page until this lands (decision 156: build the dependency in the other repo). + +## What changes + +- `ObjectSearchResultFormatter::format()` builds the link through `resolveObjectUrl()`, which merges `ownLinkProperties()` (declared, scalar, not `@self`, not `_`-prefixed, URL-encoded) under `@self` and the ids. +- An unfilled `{placeholder}` in the resolved link falls back to `openregister.objects.show`. + +## Out of scope + +`DeepLinkRegistration::resolveUrl()` is unchanged. Notification and smart-picker links build their own object data and are not touched. diff --git a/openspec/changes/search-deep-link-names-object-properties/screens.md b/openspec/changes/search-deep-link-names-object-properties/screens.md new file mode 100644 index 0000000000..4984a7354c --- /dev/null +++ b/openspec/changes/search-deep-link-names-object-properties/screens.md @@ -0,0 +1 @@ +- No screen: server-side link building for Nextcloud's unified search; the search box itself is Nextcloud's. diff --git a/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md b/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md new file mode 100644 index 0000000000..68918778b2 --- /dev/null +++ b/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md @@ -0,0 +1,27 @@ +# deep-link-registry Specification + +## ADDED Requirements + +### Requirement: A search hit's deep link can name the object's own properties + +When the unified search formats a hit, the object data handed to the deep-link registry MUST include the object's own declared scalar properties, URL-encoded, so a template can name one (for example `/apps/dossiq/cases/{case}`). `@self` metadata and the resolved `uuid`, `register` and `schema` MUST win over an own property of the same name. Keys starting with `_` (attached by the pipeline, not declared by the schema) and non-scalar values MUST NOT be passed. When the resolved link still contains an unfilled `{placeholder}`, the hit MUST link to OpenRegister's own object page instead. + +#### Scenario: A case-owned record opens its case +- **GIVEN** an app registered `/apps/dossiq/cases/{case}` for schema `caseDocument` +- **WHEN** a search hit is a `caseDocument` whose `case` is `c-1` +- **THEN** the hit's URL is `/apps/dossiq/cases/c-1` + +#### Scenario: An own property cannot spoof the uuid +- **GIVEN** an object with an own property `uuid: "spoofed"` and `@self.id: "u1"` +- **WHEN** the hit is formatted +- **THEN** `{uuid}` resolves to `u1` + +#### Scenario: A value cannot walk out of the path +- **GIVEN** an object whose `case` is `../settings` +- **WHEN** the hit is formatted +- **THEN** `{case}` resolves to `..%2Fsettings` + +#### Scenario: An unfilled placeholder falls back +- **GIVEN** the `{case}` template and an object with no `case` +- **WHEN** the hit is formatted +- **THEN** the hit's URL is OpenRegister's `openregister.objects.show` route for the object diff --git a/openspec/changes/search-deep-link-names-object-properties/tasks.md b/openspec/changes/search-deep-link-names-object-properties/tasks.md new file mode 100644 index 0000000000..bfe06e4c76 --- /dev/null +++ b/openspec/changes/search-deep-link-names-object-properties/tasks.md @@ -0,0 +1,5 @@ +# Tasks: search-deep-link-names-object-properties + +- [x] 1. `lib/Service/Search/ObjectSearchResultFormatter.php`: pass the object's own scalar properties (declared, URL-encoded) to the deep-link registry under `@self` and the ids; fall back to `openregister.objects.show` when a placeholder stays unfilled. + - unit tests in `tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php`: `testADeepLinkTemplateCanNameAnOwnProperty`, `testMetadataWinsOverAnOwnPropertyOfTheSameName`, `testOwnPropertiesAreEncodedAndOnlyDeclaredScalarsPass`, `testAnUnfilledPlaceholderFallsBackToTheOpenRegisterRoute`. +- [ ] 2. Live: with dossiq's `{case}` deep links registered, search a seeded `caseDocument` title in the Nextcloud header; the hit opens `/apps/dossiq/cases/` (live pass, decision 139). diff --git a/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php b/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php index 51ab6ef4f4..df8d87c846 100644 --- a/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php +++ b/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php @@ -375,4 +375,103 @@ public function testFormatTitleFallsBackToUuid(): void { $entry = $this->formatter->format(['@self' => ['id' => 'uuid-fallback', 'register' => 1, 'schema' => 2]], null); $this->assertSame('uuid-fallback', $entry->jsonSerialize()['title']); }//end testFormatTitleFallsBackToUuid() + + /** + * Point the mocks at a deep link and capture the object data the + * formatter hands the registry. + * + * @param string $resolved What the registry answers. + * @param array|null $captured Receives the object data. + * + * @return void + */ + private function captureDeepLinkData(string $resolved, ?array &$captured): void { + $this->schemaMapper->method('find')->willReturn(new Schema()); + $this->deepLinkRegistry->method('resolveIcon')->willReturn(null); + $this->deepLinkRegistry->method('resolveDisplayName')->willReturn('Dossiq'); + $this->deepLinkRegistry->method('resolveUrl')->willReturnCallback( + function (int $registerId, int $schemaId, array $objectData) use ($resolved, &$captured): string { + $captured = $objectData; + return $resolved; + } + ); + }//end captureDeepLinkData() + + /** + * A case-owned record links to its case: the template names an own + * property (`{case}`), so the formatter hands the registry the object's + * own scalar properties, not only `@self`. + * + * @return void + */ + public function testADeepLinkTemplateCanNameAnOwnProperty(): void { + $captured = null; + $this->captureDeepLinkData('/apps/dossiq/cases/c-1', $captured); + + $entry = $this->formatter->format( + ['title' => 'Besluit', 'case' => 'c-1', '@self' => ['id' => 'u1', 'register' => 1, 'schema' => 2]], + null + ); + + $this->assertSame('c-1', $captured['case']); + $this->assertSame('u1', $captured['uuid']); + $this->assertSame('/apps/dossiq/cases/c-1', $entry->jsonSerialize()['resourceUrl']); + }//end testADeepLinkTemplateCanNameAnOwnProperty() + + /** + * An own property named like a metadata key does not hijack it: `@self` + * and the resolved uuid, register and schema win. + * + * @return void + */ + public function testMetadataWinsOverAnOwnPropertyOfTheSameName(): void { + $captured = null; + $this->captureDeepLinkData('/apps/dossiq/x', $captured); + + $this->formatter->format( + ['uuid' => 'spoofed', 'schema' => 'spoofed', 'owner' => 'spoofed', '@self' => ['id' => 'u1', 'register' => 1, 'schema' => 2, 'owner' => 'alice']], + null + ); + + $this->assertSame('u1', $captured['uuid']); + $this->assertSame(2, $captured['schema']); + $this->assertSame('alice', $captured['owner']); + }//end testMetadataWinsOverAnOwnPropertyOfTheSameName() + + /** + * Own property values are URL-encoded before they go into a path, and + * `_`-prefixed (attached, not declared) and non-scalar keys are not + * passed at all. + * + * @return void + */ + public function testOwnPropertiesAreEncodedAndOnlyDeclaredScalarsPass(): void { + $captured = null; + $this->captureDeepLinkData('/apps/dossiq/x', $captured); + + $this->formatter->format( + ['case' => '../settings', '_chunk' => 'file text', 'tags' => ['a'], '@self' => ['id' => 'u1', 'register' => 1, 'schema' => 2]], + null + ); + + $this->assertSame('..%2Fsettings', $captured['case']); + $this->assertArrayNotHasKey('_chunk', $captured); + $this->assertArrayNotHasKey('tags', $captured); + }//end testOwnPropertiesAreEncodedAndOnlyDeclaredScalarsPass() + + /** + * A template whose placeholder the object cannot fill (its `case` is + * empty) does not produce a link with a literal `{case}` in it: the hit + * opens OpenRegister's own page instead. + * + * @return void + */ + public function testAnUnfilledPlaceholderFallsBackToTheOpenRegisterRoute(): void { + $captured = null; + $this->captureDeepLinkData('/apps/dossiq/cases/{case}', $captured); + + $entry = $this->formatter->format(['title' => 'Losse notitie', '@self' => ['id' => 'u1', 'register' => 1, 'schema' => 2]], null); + + $this->assertSame('/objects/u1', $entry->jsonSerialize()['resourceUrl']); + }//end testAnUnfilledPlaceholderFallsBackToTheOpenRegisterRoute() }//end class From 4b5eaa03b03cf8345ea9bd76a78b02bd3d31be03 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 16:57:36 +0200 Subject: [PATCH 035/183] feat(forms): a form's mapping is judged against its destination schema FormDestinationValidator returns one finding per problem (required-unmapped, property-unknown, property-read-only, type-mismatch, format-mismatch, enum-unconstrained, enum-value-unknown, constraint-looser, fixed-value-invalid, destination-not-public, destination-is-staging). TaskFormReader delegates its field check to it, so flow task forms and object forms are judged by one service. Schema configuration keeps staging and additionalProperties instead of dropping them on save. Decision 179, ADR-117. --- lib/Db/Schema.php | 7 +- lib/Service/Form/FormDestinationValidator.php | 637 ++++++++++++++++++ lib/Service/Form/FormFieldRules.php | 366 ++++++++++ lib/Service/Task/TaskFormReader.php | 34 +- tests/Unit/Db/SchemaFormConfigurationTest.php | 37 + .../Form/FormDestinationValidatorTest.php | 379 +++++++++++ .../Unit/Service/Form/FormFieldRulesTest.php | 117 ++++ .../Unit/Service/Task/TaskFormReaderTest.php | 24 + 8 files changed, 1581 insertions(+), 20 deletions(-) create mode 100644 lib/Service/Form/FormDestinationValidator.php create mode 100644 lib/Service/Form/FormFieldRules.php create mode 100644 tests/Unit/Db/SchemaFormConfigurationTest.php create mode 100644 tests/Unit/Service/Form/FormDestinationValidatorTest.php create mode 100644 tests/Unit/Service/Form/FormFieldRulesTest.php diff --git a/lib/Db/Schema.php b/lib/Db/Schema.php index 811aa27fdc..def40ef664 100644 --- a/lib/Db/Schema.php +++ b/lib/Db/Schema.php @@ -2875,7 +2875,12 @@ private function validateConfigurationArray(array $configuration): array { // `exportable` opts the schema into nextcloud-vue's native Export menu // on an index page (or#4103). Off the allowlist it was dropped without // a log line, so `allowExport: true` on every app page was a no-op. - $boolFields = ['allowFiles', 'autoPublish', 'defaultAutoShare', 'exportable']; + // `staging` and `additionalProperties` are read by FormDestinationValidator + // (decision 179): a staging schema is no form destination, and a form + // field into an undeclared property is a finding unless the schema opts + // into extras. Off this list both were dropped on save, so neither could + // ever be declared. + $boolFields = ['allowFiles', 'autoPublish', 'defaultAutoShare', 'exportable', 'staging', 'additionalProperties']; // `implements` + `x-schema-org` carry the cross-app semantic-type // markers (ADR-048); they must round-trip through the configuration // column so SemanticTypeResolver can discover the schema. Their IRI diff --git a/lib/Service/Form/FormDestinationValidator.php b/lib/Service/Form/FormDestinationValidator.php new file mode 100644 index 0000000000..4b3621f6e0 --- /dev/null +++ b/lib/Service/Form/FormDestinationValidator.php @@ -0,0 +1,637 @@ +" } + * } + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Form + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Form; + +use OCA\OpenRegister\Db\Schema; +use OCP\IL10N; + +/** + * The one validator for a form's mapping against its destination. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ +class FormDestinationValidator { + + public const REQUIRED_UNMAPPED = 'required-unmapped'; + public const PROPERTY_UNKNOWN = 'property-unknown'; + public const PROPERTY_READ_ONLY = 'property-read-only'; + public const TYPE_MISMATCH = 'type-mismatch'; + public const FORMAT_MISMATCH = 'format-mismatch'; + public const ENUM_UNCONSTRAINED = 'enum-unconstrained'; + public const ENUM_VALUE_UNKNOWN = 'enum-value-unknown'; + public const CONSTRAINT_LOOSER = 'constraint-looser'; + public const FIXED_VALUE_INVALID = 'fixed-value-invalid'; + public const DESTINATION_NOT_PUBLIC = 'destination-not-public'; + public const DESTINATION_IS_STAGING = 'destination-is-staging'; + + /** + * The property dialect block the two form markers live in. + * + * @var string + */ + public const DIALECT = 'x-openregister'; + + /** + * Marker: a listener or the server fills this property, so no field needs to. + * + * @var string + */ + public const MARKER_SERVER_SET = 'serverSet'; + + /** + * Marker: the submit response returns this property to the submitter. + * + * @var string + */ + public const MARKER_CONFIRMATION = 'confirmation'; + + /** + * Marker: this property is the human-readable reference a submit returns. + * + * @var string + */ + public const MARKER_REFERENCE = 'reference'; + + /** + * Constructor. + * + * @param IL10N $l10n Translations, for findings an author reads. + * @param FormFieldRules $rules The field-to-property compatibility rules. + */ + public function __construct( + private readonly IL10N $l10n, + private readonly FormFieldRules $rules, + ) { + + }//end __construct() + + /** + * Every problem with a mapping into a destination schema. + * + * @param array $mapping The mapping: `fields` and `fixed`. + * @param Schema $schema The destination schema. + * @param array $options `audience`: `public` or `authenticated` when the form is not internal. + * + * @return array The findings; empty when valid. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public function validate(array $mapping, Schema $schema, array $options = []): array { + $fields = $this->fieldsOf(mapping: $mapping); + $fixed = $this->fixedOf(mapping: $mapping); + + $findings = $this->destinationFindings(schema: $schema, audience: (string)($options['audience'] ?? '')); + foreach ($fields as $field) { + array_push($findings, ...$this->fieldFindings(field: $field, schema: $schema)); + } + + foreach ($fixed as $property => $value) { + array_push($findings, ...$this->fixedFindings(property: (string)$property, value: $value, schema: $schema)); + } + + $sourced = array_merge( + array_map(static fn (array $field): string => (string)($field['property'] ?? ''), $fields), + array_map('strval', array_keys($fixed)) + ); + + return array_merge($findings, $this->requiredFindings(schema: $schema, sourced: $sourced)); + }//end validate() + + /** + * Why a task-form 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. Moved here from TaskFormReader, which now + * delegates, so the step-save refusal and the form-save findings come from + * one service. + * + * @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/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + 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() + + /** + * Whether a property carries a form marker in its `x-openregister` block. + * + * Only a literal `true` counts: a marker is a declaration, and "yes" or 1 + * is a typo the author should see fail, not a value to coerce. + * + * @param array $property The schema property. + * @param string $name The marker name. + * + * @return bool True when marked. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public static function marker(array $property, string $name): bool { + $block = ($property[self::DIALECT] ?? null); + if (is_array($block) === false) { + return false; + } + + return ($block[$name] ?? null) === true; + }//end marker() + + /** + * The properties of a schema that carry a marker, in declared order. + * + * @param Schema $schema The schema. + * @param string $name The marker name. + * + * @return array The property names. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + public static function markedProperties(Schema $schema, string $name): array { + $marked = []; + foreach ($schema->getProperties() as $propertyName => $property) { + if (is_array($property) === true && self::marker(property: $property, name: $name) === true) { + $marked[] = (string)$propertyName; + } + } + + return $marked; + }//end markedProperties() + + /** + * Findings about the destination itself: staging, or closed to the form's audience. + * + * @param Schema $schema The destination schema. + * @param string $audience `public`, `authenticated`, or empty for an internal form. + * + * @return array + */ + private function destinationFindings(Schema $schema, string $audience): array { + $findings = []; + $slug = (string)$schema->getSlug(); + if (($schema->getConfiguration()['staging'] ?? false) === true) { + $findings[] = $this->finding( + property: '', + code: self::DESTINATION_IS_STAGING, + message: $this->l10n->t('Schema "%1$s" is marked as a staging object. A form submits into the object a person works on, not into a layer before it.', [$slug]) + ); + } + + if ($audience !== '' && $this->grantsCreateTo(schema: $schema, audience: $audience) === false) { + $findings[] = $this->finding( + property: '', + code: self::DESTINATION_NOT_PUBLIC, + message: $this->l10n->t('Schema "%1$s" does not let a %2$s visitor create objects, so this form could never be submitted.', [$slug, $audience]) + ); + } + + return $findings; + }//end destinationFindings() + + /** + * Whether the schema lets the audience create objects. + * + * An empty authorization block is open to everyone; a non-empty one is + * default-deny per action, the same rule the save path applies. + * + * @param Schema $schema The destination schema. + * @param string $audience `public` or `authenticated`. + * + * @return bool True when granted. + */ + private function grantsCreateTo(Schema $schema, string $audience): bool { + $authorization = ($schema->getAuthorization() ?? []); + if ($authorization === []) { + return true; + } + + $accepted = ['public']; + if ($audience === 'authenticated') { + $accepted[] = 'authenticated'; + } + + foreach ((array)($authorization['create'] ?? []) as $entry) { + $group = $entry; + if (is_array($entry) === true) { + $group = ($entry['group'] ?? null); + } + + if (is_string($group) === true && in_array($group, $accepted, true) === true) { + return true; + } + } + + return false; + }//end grantsCreateTo() + + /** + * Findings for one mapped field. + * + * @param array $field The mapping entry. + * @param Schema $schema The destination schema. + * + * @return array + */ + private function fieldFindings(array $field, Schema $schema): array { + $name = (string)($field['field'] ?? ''); + $propertyName = (string)($field['property'] ?? ''); + $properties = $schema->getProperties(); + if ($propertyName === '' || array_key_exists($propertyName, $properties) === false) { + if ($propertyName !== '' && $this->allowsExtras(schema: $schema) === true) { + return []; + } + + return [ + $this->finding( + property: $propertyName, + code: self::PROPERTY_UNKNOWN, + message: $this->l10n->t('Field "%1$s" writes into "%2$s", which schema "%3$s" does not have.', [$name, $propertyName, (string)$schema->getSlug()]), + field: $name + ), + ]; + } + + $property = (array)$properties[$propertyName]; + if (($property['readOnly'] ?? false) === true) { + return [ + $this->finding( + property: $propertyName, + code: self::PROPERTY_READ_ONLY, + message: $this->l10n->t('Field "%1$s" writes into "%2$s", which is read-only, so the save would refuse it.', [$name, $propertyName]), + field: $name + ), + ]; + } + + $shape = $this->shapeFindings(field: $field, property: $property, name: $name, propertyName: $propertyName); + if ($shape !== [] && $shape[0]['code'] === self::TYPE_MISMATCH) { + // A text field into an integer: its bounds and options say nothing more. + return $shape; + } + + return array_merge( + $shape, + $this->enumFindings(field: $field, property: $property, name: $name, propertyName: $propertyName), + $this->constraintFindings(field: $field, property: $property, name: $name, propertyName: $propertyName) + ); + }//end fieldFindings() + + /** + * Type and format findings for one field. + * + * @param array $field The mapping entry. + * @param array $property The schema property. + * @param string $name The field name. + * @param string $propertyName The property name. + * + * @return array + */ + private function shapeFindings(array $field, array $property, string $name, string $propertyName): array { + if ($this->rules->typeFits(field: $field, property: $property) === false) { + return [ + $this->finding( + property: $propertyName, + code: self::TYPE_MISMATCH, + message: $this->l10n->t( + 'Field "%1$s" is a %2$s field and cannot produce the %3$s that "%4$s" holds.', + [$name, (string)($field['type'] ?? ''), $this->typeLabel(property: $property), $propertyName] + ), + field: $name + ), + ]; + } + + if ($this->rules->formatFits(field: $field, property: $property) === false) { + return [ + $this->finding( + property: $propertyName, + code: self::FORMAT_MISMATCH, + message: $this->l10n->t( + 'Field "%1$s" does not produce the %2$s format that "%3$s" demands.', + [$name, (string)($property['format'] ?? ''), $propertyName] + ), + field: $name + ), + ]; + } + + return []; + }//end shapeFindings() + + /** + * Enum findings for one field: free text into an enum, or options outside it. + * + * @param array $field The mapping entry. + * @param array $property The schema property. + * @param string $name The field name. + * @param string $propertyName The property name. + * + * @return array + */ + private function enumFindings(array $field, array $property, string $name, string $propertyName): array { + $enum = $this->rules->enumOf(property: $property); + if ($enum === null) { + return []; + } + + $options = $this->rules->optionsOf(field: $field); + if ($options === null) { + return [ + $this->finding( + property: $propertyName, + code: self::ENUM_UNCONSTRAINED, + message: $this->l10n->t('Field "%1$s" lets anything be typed, but "%2$s" accepts only: %3$s.', [$name, $propertyName, $this->listOf(values: $enum)]), + field: $name + ), + ]; + } + + $findings = []; + foreach ($options as $option) { + if (in_array($option, $enum, true) === true) { + continue; + } + + $findings[] = $this->finding( + property: $propertyName, + code: self::ENUM_VALUE_UNKNOWN, + message: $this->l10n->t('Field "%1$s" offers "%2$s", which "%3$s" does not accept.', [$name, $this->scalarLabel(value: $option), $propertyName]), + field: $name + ); + } + + return $findings; + }//end enumFindings() + + /** + * Constraint findings for one field: one per bound the field leaves looser. + * + * @param array $field The mapping entry. + * @param array $property The schema property. + * @param string $name The field name. + * @param string $propertyName The property name. + * + * @return array + */ + private function constraintFindings(array $field, array $property, string $name, string $propertyName): array { + $findings = []; + foreach ($this->rules->looserConstraints(field: $field, property: $property) as $constraint) { + $findings[] = $this->finding( + property: $propertyName, + code: self::CONSTRAINT_LOOSER, + message: $this->l10n->t( + 'Field "%1$s" allows more than "%2$s" does: set %3$s to %4$s or tighter.', + [$name, $propertyName, $constraint, $this->scalarLabel(value: $property[$constraint])] + ), + field: $name + ); + } + + return $findings; + }//end constraintFindings() + + /** + * Findings for one fixed value. + * + * @param string $property The property the value is fixed into. + * @param mixed $value The fixed value. + * @param Schema $schema The destination schema. + * + * @return array + */ + private function fixedFindings(string $property, mixed $value, Schema $schema): array { + $properties = $schema->getProperties(); + if (array_key_exists($property, $properties) === false) { + if ($this->allowsExtras(schema: $schema) === true) { + return []; + } + + return [ + $this->finding( + property: $property, + code: self::PROPERTY_UNKNOWN, + message: $this->l10n->t('A fixed value writes into "%1$s", which schema "%2$s" does not have.', [$property, (string)$schema->getSlug()]) + ), + ]; + } + + if ($this->rules->fixedValueFits(value: $value, property: (array)$properties[$property]) === true) { + return []; + } + + return [ + $this->finding( + property: $property, + code: self::FIXED_VALUE_INVALID, + message: $this->l10n->t('The fixed value for "%1$s" is one the property does not accept.', [$property]) + ), + ]; + }//end fixedFindings() + + /** + * Findings for required properties no field, fixed value or server default fills. + * + * @param Schema $schema The destination schema. + * @param array $sourced The properties the mapping fills. + * + * @return array + */ + private function requiredFindings(Schema $schema, array $sourced): array { + $properties = $schema->getProperties(); + $required = $schema->getRequired(); + foreach ($properties as $name => $property) { + if (is_array($property) === true && ($property['required'] ?? false) === true) { + $required[] = (string)$name; + } + } + + $findings = []; + foreach (array_values(array_unique(array_map('strval', $required))) as $name) { + $property = (array)($properties[$name] ?? []); + if (in_array($name, $sourced, true) === true || $this->isServerFilled(property: $property) === true) { + continue; + } + + $findings[] = $this->finding( + property: $name, + code: self::REQUIRED_UNMAPPED, + message: $this->l10n->t('"%1$s" is required, and no field, fixed value or server default fills it.', [$name]) + ); + } + + return $findings; + }//end requiredFindings() + + /** + * Whether the server fills a property: a default, a computation, or the serverSet marker. + * + * @param array $property The schema property. + * + * @return bool True when no field needs to. + */ + private function isServerFilled(array $property): bool { + if (array_key_exists('default', $property) === true || isset($property['computed']) === true) { + return true; + } + + return self::marker(property: $property, name: self::MARKER_SERVER_SET); + }//end isServerFilled() + + /** + * Whether the schema explicitly accepts properties it does not declare. + * + * @param Schema $schema The destination schema. + * + * @return bool True only for an explicit opt-in. + */ + private function allowsExtras(Schema $schema): bool { + return (($schema->getConfiguration() ?? [])['additionalProperties'] ?? false) === true; + }//end allowsExtras() + + /** + * The `fields` list of a mapping, entries that are not arrays dropped. + * + * @param array $mapping The mapping. + * + * @return array> + */ + private function fieldsOf(array $mapping): array { + return array_values(array_filter((array)($mapping['fields'] ?? []), 'is_array')); + }//end fieldsOf() + + /** + * The `fixed` map of a mapping. + * + * @param array $mapping The mapping. + * + * @return array + */ + private function fixedOf(array $mapping): array { + $fixed = ($mapping['fixed'] ?? []); + if (is_array($fixed) === false) { + return []; + } + + return $fixed; + }//end fixedOf() + + /** + * One finding, the field key present only when a field is named. + * + * @param string $property The property. + * @param string $code The finding code. + * @param string $message The translated message. + * @param string|null $field The field, when the finding is about one. + * + * @return array{field?: string, property: string, code: string, message: string} + */ + private function finding(string $property, string $code, string $message, ?string $field = null): array { + $finding = []; + if ($field !== null) { + $finding['field'] = $field; + } + + $finding['property'] = $property; + $finding['code'] = $code; + $finding['message'] = $message; + + return $finding; + }//end finding() + + /** + * A property's type, for a message. + * + * @param array $property The schema property. + * + * @return string The type, or types joined. + */ + private function typeLabel(array $property): string { + $type = ($property['type'] ?? ''); + if (is_array($type) === true) { + return implode('/', array_map('strval', $type)); + } + + return (string)$type; + }//end typeLabel() + + /** + * A list of values, for a message. + * + * @param array $values The values. + * + * @return string The values joined. + */ + private function listOf(array $values): string { + return implode(', ', array_map(fn ($value): string => $this->scalarLabel(value: $value), $values)); + }//end listOf() + + /** + * A value, for a message. + * + * @param mixed $value The value. + * + * @return string The printable form. + */ + private function scalarLabel(mixed $value): string { + if (is_scalar($value) === true) { + return (string)$value; + } + + return (string)json_encode($value); + }//end scalarLabel() +}//end class diff --git a/lib/Service/Form/FormFieldRules.php b/lib/Service/Form/FormFieldRules.php new file mode 100644 index 0000000000..75b3d7a8b6 --- /dev/null +++ b/lib/Service/Form/FormFieldRules.php @@ -0,0 +1,366 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Form + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Form; + +/** + * The field-to-property compatibility rules. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ +class FormFieldRules { + + /** + * The JSON type each known field type produces. + * + * A field type not in this table is not judged on type: an unknown widget + * is the author's extension, and a guess would be a false finding. + * + * @var array + */ + public const PRODUCES = [ + 'text' => 'string', + 'string' => 'string', + 'textarea' => 'string', + 'email' => 'string', + 'url' => 'string', + 'tel' => 'string', + 'date' => 'string', + 'datetime' => 'string', + 'time' => 'string', + 'choice' => 'string', + 'radio' => 'string', + 'select' => 'string', + 'reference' => 'string', + 'number' => 'number', + 'integer' => 'integer', + 'checkbox' => 'boolean', + 'boolean' => 'boolean', + 'multichoice' => 'array', + 'array' => 'array', + 'files' => 'array', + 'object' => 'object', + 'file' => 'file', + ]; + + /** + * The format a field type produces when it names none itself. + * + * @var array + */ + public const TYPE_FORMATS = [ + 'email' => 'email', + 'url' => 'uri', + 'date' => 'date', + 'datetime' => 'date-time', + 'time' => 'time', + 'reference' => 'uuid', + ]; + + /** + * Spellings of one format that mean the same thing. + * + * @var array + */ + private const FORMAT_ALIASES = [ + 'datetime' => 'date-time', + 'url' => 'uri', + ]; + + /** + * Upper bounds: a field bound above the property's, or absent, is looser. + * + * @var array + */ + private const UPPER_BOUNDS = ['maxLength', 'maximum', 'maxItems']; + + /** + * Lower bounds: a field bound below the property's, or absent, is looser. + * + * @var array + */ + private const LOWER_BOUNDS = ['minLength', 'minimum', 'minItems']; + + /** + * Whether the field's type can produce a value of the property's type. + * + * @param array $field The mapping entry. + * @param array $property The schema property. + * + * @return bool True when it can, or when either side is not judged. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public function typeFits(array $field, array $property): bool { + $produces = (self::PRODUCES[strtolower((string)($field['type'] ?? ''))] ?? null); + $accepts = $this->acceptedTypes(property: $property); + if ($produces === null || $accepts === []) { + return true; + } + + if (in_array($produces, $accepts, true) === true) { + return true; + } + + // An integer is a number; the reverse is the 1.5 an integer refuses. + return $produces === 'integer' && in_array('number', $accepts, true) === true; + }//end typeFits() + + /** + * The format a field produces: its own `format`, else the one its type implies. + * + * @param array $field The mapping entry. + * + * @return string|null The format, or null for a plain value. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public function effectiveFormat(array $field): ?string { + $explicit = trim((string)($field['format'] ?? '')); + if ($explicit !== '') { + return $explicit; + } + + return (self::TYPE_FORMATS[strtolower((string)($field['type'] ?? ''))] ?? null); + }//end effectiveFormat() + + /** + * Whether the field produces the format the property demands. + * + * @param array $field The mapping entry. + * @param array $property The schema property. + * + * @return bool True when the property demands none or the field produces it. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public function formatFits(array $field, array $property): bool { + $demanded = trim((string)($property['format'] ?? '')); + if ($demanded === '') { + return true; + } + + $produced = $this->effectiveFormat(field: $field); + if ($produced === null) { + return false; + } + + return $this->canonicalFormat(format: $produced) === $this->canonicalFormat(format: $demanded); + }//end formatFits() + + /** + * The enum a property restricts its value to, or that of its items for an array. + * + * @param array $property The schema property. + * + * @return array|null The allowed values, or null when unrestricted. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public function enumOf(array $property): ?array { + if (is_array($property['enum'] ?? null) === true) { + return array_values($property['enum']); + } + + $items = ($property['items'] ?? null); + if (is_array($items) === true && is_array($items['enum'] ?? null) === true) { + return array_values($items['enum']); + } + + return null; + }//end enumOf() + + /** + * The values a choice field offers, read from plain values or `{value, label}` objects. + * + * @param array $field The mapping entry. + * + * @return array|null The offered values, or null for a free field. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public function optionsOf(array $field): ?array { + $options = ($field['options'] ?? null); + if (is_array($options) === false) { + return null; + } + + $values = []; + foreach ($options as $option) { + if (is_array($option) === true) { + $option = ($option['value'] ?? null); + } + + $values[] = $option; + } + + return $values; + }//end optionsOf() + + /** + * The constraints on which the field is looser than the property. + * + * A bound the property sets and the field leaves open is looser: a + * 500-character field into a 100-character property lets the resident type + * what the save will refuse. A pattern cannot be compared with another, so + * only a missing one counts. + * + * @param array $field The mapping entry. + * @param array $property The schema property. + * + * @return array The constraint names, in a stable order. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public function looserConstraints(array $field, array $property): array { + $looser = []; + foreach (self::UPPER_BOUNDS as $bound) { + if ($this->isLooser(field: $field, property: $property, bound: $bound, upper: true) === true) { + $looser[] = $bound; + } + } + + foreach (self::LOWER_BOUNDS as $bound) { + if ($this->isLooser(field: $field, property: $property, bound: $bound, upper: false) === true) { + $looser[] = $bound; + } + } + + $pattern = (string)($property['pattern'] ?? ''); + if ($pattern !== '' && trim((string)($field['pattern'] ?? '')) === '') { + $looser[] = 'pattern'; + } + + return $looser; + }//end looserConstraints() + + /** + * Whether a fixed value is one the property accepts, by JSON type and enum. + * + * @param mixed $value The fixed value. + * @param array $property The schema property. + * + * @return bool True when the property accepts it. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public function fixedValueFits(mixed $value, array $property): bool { + $enum = $this->enumOf(property: $property); + if ($enum !== null && is_array($value) === false && in_array($value, $enum, true) === false) { + return false; + } + + $accepts = $this->acceptedTypes(property: $property); + if ($accepts === []) { + return true; + } + + $actual = $this->jsonTypeOf(value: $value); + if (in_array($actual, $accepts, true) === true) { + return true; + } + + return $actual === 'integer' && in_array('number', $accepts, true) === true; + }//end fixedValueFits() + + /** + * The JSON types a property accepts, from a string or a list `type`. + * + * @param array $property The schema property. + * + * @return array The accepted types; empty when untyped. + */ + private function acceptedTypes(array $property): array { + $type = ($property['type'] ?? null); + if (is_string($type) === true && $type !== '') { + return [$type]; + } + + if (is_array($type) === true) { + return array_values(array_filter($type, 'is_string')); + } + + return []; + }//end acceptedTypes() + + /** + * Whether one bound of the field is looser than the property's. + * + * @param array $field The mapping entry. + * @param array $property The schema property. + * @param string $bound The constraint name. + * @param bool $upper True for an upper bound. + * + * @return bool True when looser. + */ + private function isLooser(array $field, array $property, string $bound, bool $upper): bool { + if (is_numeric($property[$bound] ?? null) === false) { + return false; + } + + if (is_numeric($field[$bound] ?? null) === false) { + return true; + } + + if ($upper === true) { + return (float)$field[$bound] > (float)$property[$bound]; + } + + return (float)$field[$bound] < (float)$property[$bound]; + }//end isLooser() + + /** + * The JSON type of a PHP value. + * + * @param mixed $value The value. + * + * @return string The JSON type name. + */ + private function jsonTypeOf(mixed $value): string { + return match (true) { + $value === null => 'null', + is_bool($value) === true => 'boolean', + is_int($value) === true => 'integer', + is_float($value) === true => 'number', + is_string($value) === true => 'string', + is_array($value) === true && array_is_list($value) === true => 'array', + default => 'object', + }; + }//end jsonTypeOf() + + /** + * One spelling per format. + * + * @param string $format The format. + * + * @return string The canonical spelling. + */ + private function canonicalFormat(string $format): string { + $format = strtolower(trim($format)); + + return (self::FORMAT_ALIASES[$format] ?? $format); + }//end canonicalFormat() +}//end class diff --git a/lib/Service/Task/TaskFormReader.php b/lib/Service/Task/TaskFormReader.php index b5540a8c9f..e2eaee15cc 100644 --- a/lib/Service/Task/TaskFormReader.php +++ b/lib/Service/Task/TaskFormReader.php @@ -38,6 +38,8 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Form\FormDestinationValidator; +use OCA\OpenRegister\Service\Form\FormFieldRules; use OCA\OpenRegister\Service\Lifecycle\TransitionEngine; use OCP\App\IAppManager; use OCP\IL10N; @@ -79,6 +81,13 @@ class TaskFormReader { 'formRequireChecklist', ]; + /** + * The one validator for a form against its schema (decision 179). + * + * @var FormDestinationValidator + */ + private readonly FormDestinationValidator $validator; + /** * Constructor. * @@ -86,14 +95,16 @@ class TaskFormReader { * @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. + * @param FormDestinationValidator|null $validator The field check this reader delegates to; built from $l10n when absent. */ public function __construct( private readonly SchemaMapper $schemas, private readonly TransitionEngine $engine, private readonly IAppManager $apps, private readonly IL10N $l10n, + ?FormDestinationValidator $validator = null, ) { - + $this->validator = ($validator ?? new FormDestinationValidator(l10n: $l10n, rules: new FormFieldRules())); }//end __construct() /** @@ -247,9 +258,8 @@ public function declaredFields(TaskForm $form, Schema $schema): array { /** * 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. + * Delegates to {@see FormDestinationValidator}, so a flow task form and an + * object form are judged by one service (decision 179, ADR-117). * * @param Schema $schema The live subject schema. * @param string $field The property name. @@ -259,21 +269,7 @@ public function declaredFields(TaskForm $form, Schema $schema): array { * @spec openspec/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; + return $this->validator->unrenderableReason(schema: $schema, field: $field); }//end unrenderableReason() /** diff --git a/tests/Unit/Db/SchemaFormConfigurationTest.php b/tests/Unit/Db/SchemaFormConfigurationTest.php new file mode 100644 index 0000000000..6d5fde834c --- /dev/null +++ b/tests/Unit/Db/SchemaFormConfigurationTest.php @@ -0,0 +1,37 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\Schema; +use PHPUnit\Framework\TestCase; + +/** + * An unknown configuration key is dropped in silence; these two must not be. + * + * @covers \OCA\OpenRegister\Db\Schema + */ +class SchemaFormConfigurationTest extends TestCase { + + /** + * `staging` and `additionalProperties` round-trip as booleans. + */ + public function testStagingAndAdditionalPropertiesAreKept(): void { + $schema = new Schema(); + $schema->setConfiguration(['staging' => true, 'additionalProperties' => true]); + + $configuration = $schema->getConfiguration(); + $this->assertTrue($configuration['staging'] ?? null); + $this->assertTrue($configuration['additionalProperties'] ?? null); + }//end testStagingAndAdditionalPropertiesAreKept() +}//end class diff --git a/tests/Unit/Service/Form/FormDestinationValidatorTest.php b/tests/Unit/Service/Form/FormDestinationValidatorTest.php new file mode 100644 index 0000000000..17a6926681 --- /dev/null +++ b/tests/Unit/Service/Form/FormDestinationValidatorTest.php @@ -0,0 +1,379 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\Form\FormDestinationValidator; +use OCA\OpenRegister\Service\Form\FormFieldRules; +use OCP\IL10N; +use PHPUnit\Framework\TestCase; + +/** + * One case per code, plus a zero-findings control. + * + * @covers \OCA\OpenRegister\Service\Form\FormDestinationValidator + * @uses \OCA\OpenRegister\Service\Form\FormFieldRules + * @uses \OCA\OpenRegister\Db\Schema + */ +class FormDestinationValidatorTest extends TestCase { + + private FormDestinationValidator $validator; + + protected function setUp(): void { + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static fn (string $text, array $parameters = []): string => $parameters === [] ? $text : vsprintf($text, $parameters) + ); + + $this->validator = new FormDestinationValidator(l10n: $l10n, rules: new FormFieldRules()); + }//end setUp() + + /** + * The `case` destination the spec's scenarios use. + * + * @param array $extraProperties Properties to add or replace. + * @param array $required The required list. + * @param array $configuration Schema configuration. + */ + private function caseSchema(array $extraProperties = [], array $required = ['title', 'caseType'], array $configuration = []): Schema { + $schema = new Schema(); + $schema->setSlug('case'); + $schema->setRequired($required); + $schema->setConfiguration($configuration); + $schema->setProperties( + array_merge( + [ + 'title' => ['type' => 'string', 'maxLength' => 200], + 'caseType' => ['type' => 'string', 'format' => 'uuid'], + 'description' => ['type' => 'string'], + 'email' => ['type' => 'string', 'format' => 'email'], + 'channel' => ['type' => 'string', 'enum' => ['website', 'phone', 'mail']], + 'amount' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 1000], + 'identifier' => ['type' => 'string', 'readOnly' => true, 'x-openregister' => ['serverSet' => true]], + ], + $extraProperties + ) + ); + + return $schema; + }//end caseSchema() + + /** + * The codes of a finding list. + * + * @param array> $findings The findings. + * + * @return array + */ + private function codes(array $findings): array { + return array_map(static fn (array $finding): string => (string)$finding['code'], $findings); + }//end codes() + + /** + * A complete, well-typed mapping has nothing to report. + */ + public function testACompleteMappingHasNoFindings(): void { + $findings = $this->validator->validate( + mapping: [ + 'fields' => [ + ['field' => 'onderwerp', 'property' => 'title', 'type' => 'text', 'maxLength' => 120], + ['field' => 'mail', 'property' => 'email', 'type' => 'email'], + ['field' => 'kanaal', 'property' => 'channel', 'type' => 'choice', 'options' => ['website', 'phone']], + ['field' => 'bedrag', 'property' => 'amount', 'type' => 'integer', 'minimum' => 0, 'maximum' => 500], + ], + 'fixed' => ['caseType' => '0b5c1e9e-4f3a-4b8e-9c7d-2a1f0e6d5c4b'], + ], + schema: $this->caseSchema() + ); + + $this->assertSame([], $findings); + }//end testACompleteMappingHasNoFindings() + + /** + * Spec scenario: a required property with no source is reported. + */ + public function testARequiredPropertyWithNoSourceIsReported(): void { + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'onderwerp', 'property' => 'title', 'type' => 'text', 'maxLength' => 100]]], + schema: $this->caseSchema() + ); + + $this->assertCount(1, $findings); + $this->assertSame('caseType', $findings[0]['property']); + $this->assertSame(FormDestinationValidator::REQUIRED_UNMAPPED, $findings[0]['code']); + $this->assertArrayNotHasKey('field', $findings[0]); + }//end testARequiredPropertyWithNoSourceIsReported() + + /** + * Spec scenario: a property a listener fills is not reported; nor one with a default or a computation. + */ + public function testAServerSetDefaultedOrComputedPropertyIsNotReported(): void { + $schema = $this->caseSchema( + extraProperties: [ + 'status' => ['type' => 'string', 'default' => 'new'], + 'deadline' => ['type' => 'string', 'computed' => ['expression' => 'startDate + 56d']], + ], + required: ['title', 'identifier', 'status', 'deadline'] + ); + + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'onderwerp', 'property' => 'title', 'type' => 'text', 'maxLength' => 100]]], + schema: $schema + ); + + $this->assertSame([], $findings); + }//end testAServerSetDefaultedOrComputedPropertyIsNotReported() + + /** + * A property-level `required: true` counts as required too. + */ + public function testAPropertyLevelRequiredFlagCounts(): void { + $schema = $this->caseSchema(extraProperties: ['priority' => ['type' => 'string', 'required' => true]], required: []); + + $findings = $this->validator->validate(mapping: ['fields' => []], schema: $schema); + + $this->assertSame([FormDestinationValidator::REQUIRED_UNMAPPED], $this->codes($findings)); + $this->assertSame('priority', $findings[0]['property']); + }//end testAPropertyLevelRequiredFlagCounts() + + /** + * A field into a property the schema lacks is reported, unless the schema explicitly allows extras. + * + * OpenRegister schemas carry no top-level `additionalProperties`, so "forbids extras" would never + * hold and the check would be hollow; the explicit opt-in is the only way out of the finding. + */ + public function testAnUnknownPropertyIsReportedUnlessExtrasAreAllowed(): void { + $mapping = [ + 'fields' => [['field' => 'x', 'property' => 'nonsense', 'type' => 'text']], + 'fixed' => ['caseType' => '0b5c1e9e-4f3a-4b8e-9c7d-2a1f0e6d5c4b'], + ]; + + $closed = $this->validator->validate(mapping: $mapping, schema: $this->caseSchema(required: ['caseType'])); + $this->assertSame([FormDestinationValidator::PROPERTY_UNKNOWN], $this->codes($closed)); + $this->assertSame('x', $closed[0]['field']); + $this->assertSame('nonsense', $closed[0]['property']); + + $open = $this->validator->validate( + mapping: $mapping, + schema: $this->caseSchema(required: ['caseType'], configuration: ['additionalProperties' => true]) + ); + $this->assertSame([], $open); + }//end testAnUnknownPropertyIsReportedUnlessExtrasAreAllowed() + + /** + * A field into a read-only property is reported: the save would refuse the value. + */ + public function testAFieldIntoAReadOnlyPropertyIsReported(): void { + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'nr', 'property' => 'identifier', 'type' => 'text']]], + schema: $this->caseSchema(required: []) + ); + + $this->assertSame([FormDestinationValidator::PROPERTY_READ_ONLY], $this->codes($findings)); + }//end testAFieldIntoAReadOnlyPropertyIsReported() + + /** + * A field whose type cannot produce the property's type. + */ + public function testATypeThatCannotProduceThePropertyIsReported(): void { + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'bedrag', 'property' => 'amount', 'type' => 'text']]], + schema: $this->caseSchema(required: []) + ); + + $this->assertSame([FormDestinationValidator::TYPE_MISMATCH], $this->codes($findings)); + $this->assertSame('bedrag', $findings[0]['field']); + }//end testATypeThatCannotProduceThePropertyIsReported() + + /** + * A decimal field cannot produce an integer property; an integer field can produce a number property. + */ + public function testNumberAndIntegerAreJudgedByWhatTheFieldProduces(): void { + $schema = $this->caseSchema(extraProperties: ['ratio' => ['type' => 'number']], required: []); + + $findings = $this->validator->validate( + mapping: [ + 'fields' => [ + ['field' => 'a', 'property' => 'amount', 'type' => 'number', 'minimum' => 0, 'maximum' => 10], + ['field' => 'r', 'property' => 'ratio', 'type' => 'integer'], + ], + ], + schema: $schema + ); + + $this->assertSame([FormDestinationValidator::TYPE_MISMATCH], $this->codes($findings)); + $this->assertSame('a', $findings[0]['field']); + }//end testNumberAndIntegerAreJudgedByWhatTheFieldProduces() + + /** + * A plain text field into an e-mail property is a format mismatch. + */ + public function testAFormatThatCannotProduceThePropertyIsReported(): void { + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'mail', 'property' => 'email', 'type' => 'text']]], + schema: $this->caseSchema(required: []) + ); + + $this->assertSame([FormDestinationValidator::FORMAT_MISMATCH], $this->codes($findings)); + }//end testAFormatThatCannotProduceThePropertyIsReported() + + /** + * A free-text field into an enum property is unconstrained. + */ + public function testAFreeTextFieldIntoAnEnumIsReported(): void { + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'kanaal', 'property' => 'channel', 'type' => 'text']]], + schema: $this->caseSchema(required: []) + ); + + $this->assertSame([FormDestinationValidator::ENUM_UNCONSTRAINED], $this->codes($findings)); + }//end testAFreeTextFieldIntoAnEnumIsReported() + + /** + * A choice option outside the enum is reported once per option. + */ + public function testAChoiceOptionOutsideTheEnumIsReported(): void { + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'kanaal', 'property' => 'channel', 'type' => 'choice', 'options' => ['website', 'fax']]]], + schema: $this->caseSchema(required: []) + ); + + $this->assertSame([FormDestinationValidator::ENUM_VALUE_UNKNOWN], $this->codes($findings)); + $this->assertStringContainsString('fax', $findings[0]['message']); + }//end testAChoiceOptionOutsideTheEnumIsReported() + + /** + * Options given as `{value, label}` objects are read by their value. + */ + public function testChoiceOptionsAsObjectsAreReadByValue(): void { + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'kanaal', 'property' => 'channel', 'type' => 'choice', 'options' => [['value' => 'phone', 'label' => 'Telefoon']]]]], + schema: $this->caseSchema(required: []) + ); + + $this->assertSame([], $findings); + }//end testChoiceOptionsAsObjectsAreReadByValue() + + /** + * A 500-character field into a 200-character property, and an unbounded number into a bounded one. + */ + public function testAConstraintLooserThanThePropertyIsReported(): void { + $findings = $this->validator->validate( + mapping: [ + 'fields' => [ + ['field' => 'onderwerp', 'property' => 'title', 'type' => 'text', 'maxLength' => 500], + ['field' => 'bedrag', 'property' => 'amount', 'type' => 'integer', 'minimum' => 0], + ], + ], + schema: $this->caseSchema(required: []) + ); + + $this->assertSame([FormDestinationValidator::CONSTRAINT_LOOSER, FormDestinationValidator::CONSTRAINT_LOOSER], $this->codes($findings)); + $this->assertStringContainsString('maxLength', $findings[0]['message']); + $this->assertStringContainsString('maximum', $findings[1]['message']); + }//end testAConstraintLooserThanThePropertyIsReported() + + /** + * A fixed value the property refuses: wrong type, outside the enum. + */ + public function testAFixedValueThePropertyRefusesIsReported(): void { + $findings = $this->validator->validate( + mapping: ['fields' => [], 'fixed' => ['channel' => 'fax', 'amount' => 'veel']], + schema: $this->caseSchema(required: []) + ); + + $this->assertSame([FormDestinationValidator::FIXED_VALUE_INVALID, FormDestinationValidator::FIXED_VALUE_INVALID], $this->codes($findings)); + $this->assertSame('channel', $findings[0]['property']); + $this->assertSame('amount', $findings[1]['property']); + }//end testAFixedValueThePropertyRefusesIsReported() + + /** + * A public audience into a schema that does not grant public create. + */ + public function testAPublicFormIntoANonPublicDestinationIsReported(): void { + $schema = $this->caseSchema(required: []); + $schema->setAuthorization(['create' => ['behandelaars'], 'read' => ['behandelaars']]); + + $findings = $this->validator->validate(mapping: ['fields' => []], schema: $schema, options: ['audience' => 'public']); + $this->assertSame([FormDestinationValidator::DESTINATION_NOT_PUBLIC], $this->codes($findings)); + + $schema->setAuthorization(['create' => ['public']]); + $this->assertSame([], $this->validator->validate(mapping: ['fields' => []], schema: $schema, options: ['audience' => 'public'])); + + $schema->setAuthorization([]); + $this->assertSame([], $this->validator->validate(mapping: ['fields' => []], schema: $schema, options: ['audience' => 'public'])); + }//end testAPublicFormIntoANonPublicDestinationIsReported() + + /** + * An authenticated audience is served by a public or an authenticated grant. + */ + public function testAnAuthenticatedAudienceNeedsAnAuthenticatedOrPublicGrant(): void { + $schema = $this->caseSchema(required: []); + $schema->setAuthorization(['create' => [['group' => 'authenticated']]]); + $this->assertSame([], $this->validator->validate(mapping: ['fields' => []], schema: $schema, options: ['audience' => 'authenticated'])); + + $schema->setAuthorization(['create' => ['admin']]); + $this->assertSame( + [FormDestinationValidator::DESTINATION_NOT_PUBLIC], + $this->codes($this->validator->validate(mapping: ['fields' => []], schema: $schema, options: ['audience' => 'authenticated'])) + ); + }//end testAnAuthenticatedAudienceNeedsAnAuthenticatedOrPublicGrant() + + /** + * A schema marked as staging is not a destination. + */ + public function testAStagingSchemaIsNotADestination(): void { + $findings = $this->validator->validate( + mapping: ['fields' => []], + schema: $this->caseSchema(required: [], configuration: ['staging' => true]) + ); + + $this->assertSame([FormDestinationValidator::DESTINATION_IS_STAGING], $this->codes($findings)); + }//end testAStagingSchemaIsNotADestination() + + /** + * The two markers are read from the `x-openregister` block of a property. + */ + public function testMarkersAreReadFromTheXOpenregisterBlock(): void { + $this->assertTrue(FormDestinationValidator::marker(property: ['x-openregister' => ['confirmation' => true]], name: 'confirmation')); + $this->assertFalse(FormDestinationValidator::marker(property: ['x-openregister' => ['confirmation' => 'yes']], name: 'confirmation')); + $this->assertFalse(FormDestinationValidator::marker(property: ['type' => 'string'], name: 'serverSet')); + $this->assertFalse(FormDestinationValidator::marker(property: ['x-openregister' => 'serverSet'], name: 'serverSet')); + }//end testMarkersAreReadFromTheXOpenregisterBlock() + + /** + * The task-form reason a field cannot be rendered: absent, read-only, invisible, or renderable. + */ + public function testTheTaskFormRenderableReasonLivesHere(): void { + $schema = $this->caseSchema(extraProperties: ['hidden' => ['type' => 'string', 'visible' => false]]); + + $this->assertSame('the schema has no such property.', $this->validator->unrenderableReason(schema: $schema, field: 'nope')); + $this->assertStringContainsString('read-only', (string)$this->validator->unrenderableReason(schema: $schema, field: 'identifier')); + $this->assertStringContainsString('not visible', (string)$this->validator->unrenderableReason(schema: $schema, field: 'hidden')); + $this->assertNull($this->validator->unrenderableReason(schema: $schema, field: 'title')); + }//end testTheTaskFormRenderableReasonLivesHere() + + /** + * A mapping entry without a property is refused as malformed, naming the field. + */ + public function testAFieldWithoutAPropertyIsReportedAsUnknown(): void { + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'losse-vraag']]], + schema: $this->caseSchema(required: [], configuration: ['additionalProperties' => true]) + ); + + $this->assertSame([FormDestinationValidator::PROPERTY_UNKNOWN], $this->codes($findings)); + $this->assertSame('losse-vraag', $findings[0]['field']); + }//end testAFieldWithoutAPropertyIsReportedAsUnknown() +}//end class diff --git a/tests/Unit/Service/Form/FormFieldRulesTest.php b/tests/Unit/Service/Form/FormFieldRulesTest.php new file mode 100644 index 0000000000..7711cb0f2e --- /dev/null +++ b/tests/Unit/Service/Form/FormFieldRulesTest.php @@ -0,0 +1,117 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form; + +use OCA\OpenRegister\Service\Form\FormFieldRules; +use PHPUnit\Framework\TestCase; + +/** + * The compatibility table, the format derivation, the constraint comparison and fixed values. + * + * @covers \OCA\OpenRegister\Service\Form\FormFieldRules + */ +class FormFieldRulesTest extends TestCase { + + private FormFieldRules $rules; + + protected function setUp(): void { + $this->rules = new FormFieldRules(); + }//end setUp() + + /** + * A field type is judged by what it produces against what the property accepts. + */ + public function testTypeFit(): void { + $this->assertTrue($this->rules->typeFits(field: ['type' => 'text'], property: ['type' => 'string'])); + $this->assertTrue($this->rules->typeFits(field: ['type' => 'integer'], property: ['type' => 'number'])); + $this->assertFalse($this->rules->typeFits(field: ['type' => 'number'], property: ['type' => 'integer'])); + $this->assertTrue($this->rules->typeFits(field: ['type' => 'multichoice'], property: ['type' => 'array'])); + $this->assertTrue($this->rules->typeFits(field: ['type' => 'checkbox'], property: ['type' => ['boolean', 'null']])); + $this->assertFalse($this->rules->typeFits(field: ['type' => 'text'], property: ['type' => 'boolean'])); + $this->assertTrue($this->rules->typeFits(field: ['type' => 'file'], property: ['type' => 'file'])); + $this->assertTrue($this->rules->typeFits(field: ['type' => 'files'], property: ['type' => 'array', 'items' => ['type' => 'file']])); + }//end testTypeFit() + + /** + * An unknown or absent field type, or an untyped property, is not judged. + */ + public function testUnknownTypesAreNotJudged(): void { + $this->assertTrue($this->rules->typeFits(field: [], property: ['type' => 'integer'])); + $this->assertTrue($this->rules->typeFits(field: ['type' => 'signature-pad'], property: ['type' => 'integer'])); + $this->assertTrue($this->rules->typeFits(field: ['type' => 'text'], property: [])); + }//end testUnknownTypesAreNotJudged() + + /** + * The format a field produces: explicit first, else derived from its type. + */ + public function testEffectiveFormat(): void { + $this->assertSame('email', $this->rules->effectiveFormat(field: ['type' => 'email'])); + $this->assertSame('date', $this->rules->effectiveFormat(field: ['type' => 'date'])); + $this->assertSame('date-time', $this->rules->effectiveFormat(field: ['type' => 'datetime'])); + $this->assertSame('uuid', $this->rules->effectiveFormat(field: ['type' => 'reference'])); + $this->assertSame('bsn', $this->rules->effectiveFormat(field: ['type' => 'text', 'format' => 'bsn'])); + $this->assertNull($this->rules->effectiveFormat(field: ['type' => 'text'])); + }//end testEffectiveFormat() + + /** + * A property format is met only by the same format; a property without one accepts any. + */ + public function testFormatFit(): void { + $this->assertTrue($this->rules->formatFits(field: ['type' => 'email'], property: ['type' => 'string', 'format' => 'email'])); + $this->assertFalse($this->rules->formatFits(field: ['type' => 'text'], property: ['type' => 'string', 'format' => 'email'])); + $this->assertTrue($this->rules->formatFits(field: ['type' => 'email'], property: ['type' => 'string'])); + $this->assertTrue($this->rules->formatFits(field: ['type' => 'datetime'], property: ['type' => 'string', 'format' => 'datetime'])); + }//end testFormatFit() + + /** + * Enum options as plain values or `{value, label}` objects; an array property's items enum counts. + */ + public function testEnumAndOptions(): void { + $this->assertSame(['a', 'b'], $this->rules->enumOf(property: ['enum' => ['a', 'b']])); + $this->assertSame(['x'], $this->rules->enumOf(property: ['type' => 'array', 'items' => ['enum' => ['x']]])); + $this->assertNull($this->rules->enumOf(property: ['type' => 'string'])); + $this->assertSame(['a', 'b'], $this->rules->optionsOf(field: ['options' => ['a', ['value' => 'b', 'label' => 'B']]])); + $this->assertNull($this->rules->optionsOf(field: ['type' => 'text'])); + }//end testEnumAndOptions() + + /** + * A missing or wider field bound is looser; an equal or tighter one is not. + */ + public function testLooserConstraints(): void { + $this->assertSame( + ['maxLength', 'minLength'], + $this->rules->looserConstraints(field: ['maxLength' => 300, 'minLength' => 1], property: ['maxLength' => 200, 'minLength' => 2]) + ); + $this->assertSame(['maximum'], $this->rules->looserConstraints(field: ['minimum' => 5], property: ['minimum' => 0, 'maximum' => 10])); + $this->assertSame(['pattern'], $this->rules->looserConstraints(field: [], property: ['pattern' => '^[0-9]{4}$'])); + $this->assertSame([], $this->rules->looserConstraints(field: ['maxLength' => 100, 'pattern' => '^x$'], property: ['maxLength' => 200, 'pattern' => '^[a-z]$'])); + $this->assertSame([], $this->rules->looserConstraints(field: [], property: ['type' => 'string'])); + }//end testLooserConstraints() + + /** + * A fixed value fits by JSON type and enum; null fits only a nullable property. + */ + public function testFixedValueFit(): void { + $this->assertTrue($this->rules->fixedValueFits(value: 'web', property: ['type' => 'string'])); + $this->assertFalse($this->rules->fixedValueFits(value: 'veel', property: ['type' => 'integer'])); + $this->assertTrue($this->rules->fixedValueFits(value: 3, property: ['type' => 'number'])); + $this->assertFalse($this->rules->fixedValueFits(value: 3.5, property: ['type' => 'integer'])); + $this->assertFalse($this->rules->fixedValueFits(value: 'fax', property: ['type' => 'string', 'enum' => ['web']])); + $this->assertTrue($this->rules->fixedValueFits(value: null, property: ['type' => ['string', 'null']])); + $this->assertFalse($this->rules->fixedValueFits(value: null, property: ['type' => 'string'])); + $this->assertTrue($this->rules->fixedValueFits(value: ['a'], property: ['type' => 'array'])); + $this->assertTrue($this->rules->fixedValueFits(value: ['k' => 'v'], property: ['type' => 'object'])); + $this->assertTrue($this->rules->fixedValueFits(value: 'anything', property: [])); + }//end testFixedValueFit() +}//end class diff --git a/tests/Unit/Service/Task/TaskFormReaderTest.php b/tests/Unit/Service/Task/TaskFormReaderTest.php index 8237618023..0c31fc098d 100644 --- a/tests/Unit/Service/Task/TaskFormReaderTest.php +++ b/tests/Unit/Service/Task/TaskFormReaderTest.php @@ -15,6 +15,7 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Form\FormDestinationValidator; use OCA\OpenRegister\Service\Lifecycle\TransitionEngine; use OCA\OpenRegister\Service\Task\TaskForm; use OCA\OpenRegister\Service\Task\TaskFormReader; @@ -392,4 +393,27 @@ public function testTheRecordShapeRoundTrips(): void { $this->assertTrue($again->requireChecklist); $this->assertSame(TaskForm::KIND_FIELDS, $again->kind); }//end testTheRecordShapeRoundTrips() + /** + * Spec scenario: a flow task form uses the same service; the refusal comes from FormDestinationValidator. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + public function testTheStepSaveRefusalComesFromTheFormDestinationValidator(): void { + $validator = $this->createMock(FormDestinationValidator::class); + $validator->expects($this->once()) + ->method('unrenderableReason') + ->with($this->isInstanceOf(Schema::class), 'locked') + ->willReturn('judged by the destination validator.'); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static fn (string $text, array $parameters = []): string => $parameters === [] ? $text : vsprintf($text, $parameters) + ); + $reader = new TaskFormReader(schemas: $this->schemas, engine: $this->engine, apps: $this->apps, l10n: $l10n, validator: $validator); + $this->schemas->method('find')->willReturn($this->caseSchema()); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('judged by the destination validator.'); + $reader->validate(form: $reader->fromConfig(config: ['formKind' => 'fields', 'formSchema' => 'case', 'formFields' => ['locked']])); + }//end testTheStepSaveRefusalComesFromTheFormDestinationValidator() }//end class From eccc3bc7e7709a66757779fd448088ffcbba6ab7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 17:04:42 +0200 Subject: [PATCH 036/183] feat(forms): upload tokens and idempotency keys for form submits FormUploadStore holds the bytes of one file for one form and property, checked against the property's file rules (FilePropertyRules, now shared with the save path), for 24 hours; a submit claims it and a purge counts what it deleted. FormIdempotencyStore repeats a submit's first answer for 24 hours. FormPayloadFindings answers the save path's four refusal dialects as per-property findings. Decision 179, ADR-117. --- lib/Exception/FormSubmitRefusedException.php | 90 +++++ lib/Service/File/FilePropertyRules.php | 116 ++++++ lib/Service/Form/FormIdempotencyStore.php | 201 +++++++++++ lib/Service/Form/FormPayloadFindings.php | 212 +++++++++++ lib/Service/Form/FormUploadStore.php | 331 ++++++++++++++++++ .../Object/SaveObject/FilePropertyHandler.php | 33 +- .../FormSubmitRefusedExceptionTest.php | 45 +++ .../Service/File/FilePropertyRulesTest.php | 58 +++ .../Unit/Service/Form/Fakes/MemoryAppData.php | 45 +++ tests/Unit/Service/Form/Fakes/MemoryFile.php | 75 ++++ .../Unit/Service/Form/Fakes/MemoryFolder.php | 79 +++++ .../Service/Form/FormIdempotencyStoreTest.php | 99 ++++++ .../Service/Form/FormPayloadFindingsTest.php | 139 ++++++++ .../Unit/Service/Form/FormUploadStoreTest.php | 196 +++++++++++ 14 files changed, 1689 insertions(+), 30 deletions(-) create mode 100644 lib/Exception/FormSubmitRefusedException.php create mode 100644 lib/Service/File/FilePropertyRules.php create mode 100644 lib/Service/Form/FormIdempotencyStore.php create mode 100644 lib/Service/Form/FormPayloadFindings.php create mode 100644 lib/Service/Form/FormUploadStore.php create mode 100644 tests/Unit/Exception/FormSubmitRefusedExceptionTest.php create mode 100644 tests/Unit/Service/File/FilePropertyRulesTest.php create mode 100644 tests/Unit/Service/Form/Fakes/MemoryAppData.php create mode 100644 tests/Unit/Service/Form/Fakes/MemoryFile.php create mode 100644 tests/Unit/Service/Form/Fakes/MemoryFolder.php create mode 100644 tests/Unit/Service/Form/FormIdempotencyStoreTest.php create mode 100644 tests/Unit/Service/Form/FormPayloadFindingsTest.php create mode 100644 tests/Unit/Service/Form/FormUploadStoreTest.php diff --git a/lib/Exception/FormSubmitRefusedException.php b/lib/Exception/FormSubmitRefusedException.php new file mode 100644 index 0000000000..650faee253 --- /dev/null +++ b/lib/Exception/FormSubmitRefusedException.php @@ -0,0 +1,90 @@ + + * 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/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use Exception; +use InvalidArgumentException; + +/** + * Carries the HTTP status the caller answers with, and the findings in the validator's shape. + * + * 422: the payload or the form was refused, nothing was created. + * 403: the subject may not create the destination. + * 404: the form or its destination does not exist. + * 503: the destination could not be reached; the resident tries again later (Q3). + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ +class FormSubmitRefusedException extends Exception { + + /** + * Constructor. + * + * @param string $message The sentence the caller shows. + * @param int $status The HTTP status, 4xx or 5xx. + * @param array> $findings The findings, `{ field?, property, code, message }`. + * + * @throws InvalidArgumentException When the status would read as success. + */ + public function __construct( + string $message, + private readonly int $status = 422, + private readonly array $findings = [], + ) { + if ($status < 400 || $status > 599) { + throw new InvalidArgumentException('A refusal answers with a 4xx or 5xx status, not ' . $status . '.'); + } + + parent::__construct(message: $message); + }//end __construct() + + /** + * The HTTP status. + * + * @return int The status. + */ + public function getStatus(): int { + return $this->status; + }//end getStatus() + + /** + * The findings. + * + * @return array> The findings. + */ + public function getFindings(): array { + return $this->findings; + }//end getFindings() + + /** + * The response body: `{ message, findings }`. + * + * @return array{message: string, findings: array>} The body. + */ + public function toBody(): array { + return [ + 'message' => $this->getMessage(), + 'findings' => $this->findings, + ]; + }//end toBody() +}//end class diff --git a/lib/Service/File/FilePropertyRules.php b/lib/Service/File/FilePropertyRules.php new file mode 100644 index 0000000000..166b52d4e4 --- /dev/null +++ b/lib/Service/File/FilePropertyRules.php @@ -0,0 +1,116 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\File + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\File; + +/** + * Reads `maxSize`, `allowedTypes` and their editor spellings off a file property. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ +class FilePropertyRules { + + /** + * The rule block that governs a file: the property itself, or its items for an array of files. + * + * @param array $property The schema property. + * + * @return array|null The file configuration, or null when the property holds no files. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + public function fileConfigOf(array $property): ?array { + if (($property['type'] ?? null) === 'file') { + return $property; + } + + $items = ($property['items'] ?? null); + if (($property['type'] ?? null) === 'array' && is_array($items) === true && ($items['type'] ?? null) === 'file') { + return $items; + } + + return null; + }//end fileConfigOf() + + /** + * The MIME types a file property accepts. + * + * @param array $fileConfig The file property configuration. + * + * @return array The accepted types; empty when any type is accepted. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + public function allowedTypes(array $fileConfig): array { + $types = ($fileConfig['allowedTypes'] ?? null); + if (is_array($types) === false || $types === []) { + $editorConfig = ($fileConfig['fileConfiguration'] ?? []); + $types = null; + if (is_array($editorConfig) === true) { + $types = ($editorConfig['allowedMimeTypes'] ?? null); + } + } + + if (is_array($types) === false) { + return []; + } + + return array_values(array_filter($types, 'is_string')); + }//end allowedTypes() + + /** + * The largest upload a file property accepts, in bytes. + * + * The top-level `maxSize` is in bytes. The editor's + * `fileConfiguration.maxSize` is labelled and entered in megabytes, so it + * is converted here; a plain rename would turn 10 MB into 10 bytes. + * + * @param array $fileConfig The file property configuration. + * + * @return int The limit in bytes, 0 when there is no limit. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + public function maxSizeBytes(array $fileConfig): int { + $bytes = ($fileConfig['maxSize'] ?? null); + if (is_numeric($bytes) === true && (float)$bytes > 0) { + return (int)$bytes; + } + + $editorConfig = ($fileConfig['fileConfiguration'] ?? []); + if (is_array($editorConfig) === false) { + return 0; + } + + $megabytes = ($editorConfig['maxSize'] ?? null); + if (is_numeric($megabytes) === true && (float)$megabytes > 0) { + return (int)round((float)$megabytes * 1024 * 1024); + } + + return 0; + }//end maxSizeBytes() +}//end class diff --git a/lib/Service/Form/FormIdempotencyStore.php b/lib/Service/Form/FormIdempotencyStore.php new file mode 100644 index 0000000000..7bdd3fef3d --- /dev/null +++ b/lib/Service/Form/FormIdempotencyStore.php @@ -0,0 +1,201 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Form + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Form; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; + +/** + * Find, remember and purge submit answers by key. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ +class FormIdempotencyStore { + + /** + * The app-data folder. + * + * @var string + */ + public const FOLDER = 'form-submit-keys'; + + /** + * How long an answer is repeated, in seconds. + * + * @var int + */ + public const TTL = 86400; + + /** + * Constructor. + * + * @param IAppData $appData The app's data store. + * @param ITimeFactory $time The clock. + */ + public function __construct( + private readonly IAppData $appData, + private readonly ITimeFactory $time, + ) { + + }//end __construct() + + /** + * The answer remembered under a key, or null when none is live. + * + * @param string $scope The form scope (form id, or the caller's own). + * @param string $key The Idempotency-Key. + * + * @return array|null The answer. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + public function find(string $scope, string $key): ?array { + $folder = $this->folder(create: false); + $name = $this->name(scope: $scope, key: $key); + if ($folder === null || $folder->fileExists($name) === false) { + return null; + } + + $file = $folder->getFile($name); + $entry = $this->read(file: $file); + if ($entry === null || $entry['expiresAt'] <= $this->time->getTime()) { + $file->delete(); + + return null; + } + + return $entry['response']; + }//end find() + + /** + * Remember an answer under a key for 24 hours. + * + * @param string $scope The form scope. + * @param string $key The Idempotency-Key. + * @param array $response The answer. + * + * @return void + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + public function remember(string $scope, string $key, array $response): void { + $folder = $this->folder(create: true); + $content = (string)json_encode(['expiresAt' => $this->time->getTime() + self::TTL, 'response' => $response]); + $name = $this->name(scope: $scope, key: $key); + if ($folder->fileExists($name) === true) { + $folder->getFile($name)->putContent($content); + + return; + } + + $folder->newFile($name, $content); + }//end remember() + + /** + * Delete every expired answer. + * + * @return int How many were deleted, so a zero is a counted zero. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + public function purge(): int { + $folder = $this->folder(create: false); + if ($folder === null) { + return 0; + } + + $deleted = 0; + $now = $this->time->getTime(); + foreach ($folder->getDirectoryListing() as $file) { + $entry = $this->read(file: $file); + if ($entry !== null && $entry['expiresAt'] > $now) { + continue; + } + + $file->delete(); + $deleted++; + } + + return $deleted; + }//end purge() + + /** + * The stored entry of a file, or null when unreadable. + * + * @param ISimpleFile $file The file. + * + * @return array{expiresAt: int, response: array}|null The entry. + */ + private function read(ISimpleFile $file): ?array { + $entry = json_decode($file->getContent(), true); + if (is_array($entry) === false || is_int($entry['expiresAt'] ?? null) === false || is_array($entry['response'] ?? null) === false) { + return null; + } + + return ['expiresAt' => $entry['expiresAt'], 'response' => $entry['response']]; + }//end read() + + /** + * The file name for a scope and key: a hash, so a key never names a path. + * + * @param string $scope The form scope. + * @param string $key The key. + * + * @return string The file name. + */ + private function name(string $scope, string $key): string { + return hash('sha256', $scope . "\0" . $key) . '.json'; + }//end name() + + /** + * The store's folder. + * + * @param bool $create Create it when absent. + * + * @return ISimpleFolder|null The folder; null only when absent and not created. + * + * @psalm-return ($create is true ? ISimpleFolder : ISimpleFolder|null) + */ + private function folder(bool $create): ?ISimpleFolder { + try { + return $this->appData->getFolder(self::FOLDER); + } catch (NotFoundException) { + if ($create === false) { + return null; + } + + return $this->appData->newFolder(self::FOLDER); + } + }//end folder() +}//end class diff --git a/lib/Service/Form/FormPayloadFindings.php b/lib/Service/Form/FormPayloadFindings.php new file mode 100644 index 0000000000..059a4631e5 --- /dev/null +++ b/lib/Service/Form/FormPayloadFindings.php @@ -0,0 +1,212 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Form + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Form; + +use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Exception\DuplicateBlockedException; +use OCA\OpenRegister\Exception\HookStoppedException; +use OCA\OpenRegister\Exception\ObjectExistsException; +use OCA\OpenRegister\Exception\ValidationException; +use Opis\JsonSchema\Errors\ErrorFormatter; +use Opis\JsonSchema\Errors\ValidationError; +use Opis\JsonSchema\ValidationResult; +use Throwable; + +/** + * The four save-path dialects, answered in one. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ +class FormPayloadFindings { + + /** + * The findings of an opis validation result; empty when valid. + * + * @param ValidationResult $result The result. + * @param string|null $write The write the payload belongs to, when several are written. + * + * @return array The findings. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + public function fromResult(ValidationResult $result, ?string $write = null): array { + $error = $result->error(); + if ($result->isValid() === true || $error === null) { + return []; + } + + return $this->fromError(error: $error, write: $write); + }//end fromResult() + + /** + * The findings of a save-path exception. + * + * @param Throwable $exception The exception. + * @param string|null $write The write it came from, when several are written. + * + * @return array The findings. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + public function fromThrowable(Throwable $exception, ?string $write = null): array { + if ($exception instanceof ValidationException && $exception->getErrors() !== null) { + return $this->fromError(error: $exception->getErrors(), write: $write); + } + + if ($exception instanceof CustomValidationException) { + return $this->fromMap(errors: $exception->getErrors(), code: $this->customCode(exception: $exception), write: $write); + } + + if ($exception instanceof HookStoppedException) { + return $this->fromMap(errors: $exception->getErrors(), code: 'refused', write: $write); + } + + $code = 'invalid'; + if ($exception instanceof DuplicateBlockedException || $exception instanceof ObjectExistsException) { + $code = 'duplicate'; + } + + return [$this->finding(property: '', code: $code, message: $exception->getMessage(), write: $write)]; + }//end fromThrowable() + + /** + * Whether a throwable refuses the payload (answer 422) rather than the moment (answer 503). + * + * @param Throwable $exception The exception. + * + * @return bool True for a refusal of what was filled in. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-writing-several-objects-must-commit-all-or-none + */ + public function isPayloadRefusal(Throwable $exception): bool { + return $exception instanceof ValidationException + || $exception instanceof CustomValidationException + || $exception instanceof HookStoppedException + || $exception instanceof DuplicateBlockedException + || $exception instanceof ObjectExistsException; + }//end isPayloadRefusal() + + /** + * The leaf errors of an opis error tree, one finding each. + * + * @param ValidationError $error The root error. + * @param string|null $write The write, when several are written. + * + * @return array The findings. + */ + private function fromError(ValidationError $error, ?string $write): array { + $subErrors = $error->subErrors(); + if ($subErrors !== []) { + $findings = []; + foreach ($subErrors as $subError) { + array_push($findings, ...$this->fromError(error: $subError, write: $write)); + } + + return $findings; + } + + $formatter = new ErrorFormatter(); + $message = $formatter->formatErrorMessage($error); + $path = implode('.', array_map('strval', $error->data()->fullPath())); + if ($error->keyword() !== 'required') { + return [$this->finding(property: $path, code: $error->keyword(), message: $message, write: $write)]; + } + + $findings = []; + foreach ((array)($error->args()['missing'] ?? []) as $missing) { + $property = ltrim($path . '.' . (string)$missing, '.'); + $findings[] = $this->finding(property: $property, code: 'required', message: $message, write: $write); + } + + return $findings; + }//end fromError() + + /** + * Findings from a `property => message` map or a `{property, message}` list. + * + * @param array $errors The errors. + * @param string $code The code every entry gets. + * @param string|null $write The write, when several are written. + * + * @return array The findings. + */ + private function fromMap(array $errors, string $code, ?string $write): array { + $findings = []; + foreach ($errors as $key => $entry) { + $property = ''; + if (is_string($key) === true) { + $property = $key; + } + + $message = $entry; + if (is_array($entry) === true) { + $property = (string)($entry['property'] ?? $entry['field'] ?? $property); + $message = ($entry['message'] ?? $entry['error'] ?? json_encode($entry)); + } + + $findings[] = $this->finding(property: $property, code: $code, message: (string)$message, write: $write); + } + + return $findings; + }//end fromMap() + + /** + * The code for a custom validation refusal: uniqueness, or invalid. + * + * @param CustomValidationException $exception The exception. + * + * @return string The code. + */ + private function customCode(CustomValidationException $exception): string { + if (str_contains(strtolower($exception->getMessage()), 'not unique') === true) { + return 'unique'; + } + + return 'invalid'; + }//end customCode() + + /** + * One finding, `write` present only when named. + * + * @param string $property The property path. + * @param string $code The code. + * @param string $message The message. + * @param string|null $write The write. + * + * @return array{property: string, code: string, message: string, write?: string} The finding. + */ + private function finding(string $property, string $code, string $message, ?string $write): array { + $finding = ['property' => $property, 'code' => $code, 'message' => $message]; + if ($write !== null) { + $finding['write'] = $write; + } + + return $finding; + }//end finding() +}//end class diff --git a/lib/Service/Form/FormUploadStore.php b/lib/Service/Form/FormUploadStore.php new file mode 100644 index 0000000000..701668d0d5 --- /dev/null +++ b/lib/Service/Form/FormUploadStore.php @@ -0,0 +1,331 @@ +.bin` (the bytes) and + * `.json` (form, property, name, type, size, expiry). + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Form + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Form; + +use OCA\OpenRegister\Exception\FormSubmitRefusedException; +use OCA\OpenRegister\Service\File\FilePropertyRules; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\IL10N; +use OCP\ITempManager; + +/** + * Issue, materialise, claim and purge upload tokens. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ +class FormUploadStore { + + /** + * The app-data folder. + * + * @var string + */ + public const FOLDER = 'form-uploads'; + + /** + * How long an unclaimed token lives, in seconds. + * + * @var int + */ + public const TTL = 86400; + + /** + * Constructor. + * + * @param IAppData $appData The app's data store. + * @param ITimeFactory $time The clock. + * @param ITempManager $temp Temporary files the save path reads a claimed upload from. + * @param FilePropertyRules $rules The file property's size and type rules. + * @param IL10N $l10n Translations, for refusals a resident reads. + */ + public function __construct( + private readonly IAppData $appData, + private readonly ITimeFactory $time, + private readonly ITempManager $temp, + private readonly FilePropertyRules $rules, + private readonly IL10N $l10n, + ) { + + }//end __construct() + + /** + * Hold one uploaded file and return its token. + * + * @param string $formId The form the token belongs to. + * @param string $property The destination property the file is for. + * @param array $rule That property's schema definition. + * @param array{name?: mixed, type?: mixed, tmp_name?: mixed, error?: mixed, size?: mixed} $file The PHP upload array. + * + * @return array{token: string, expiresAt: int} The token and its expiry (unix seconds). + * + * @throws FormSubmitRefusedException 422 when the file breaks the property's rules or did not arrive. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + public function issue(string $formId, string $property, array $rule, array $file): array { + $fileConfig = $this->rules->fileConfigOf(property: $rule); + if ($fileConfig === null) { + $this->refuse(property: $property, code: 'property-holds-no-files', message: $this->l10n->t('"%1$s" does not hold files.', [$property])); + } + + $path = (string)($file['tmp_name'] ?? ''); + if ((int)($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || is_readable($path) === false) { + $this->refuse(property: $property, code: 'upload-failed', message: $this->l10n->t('The file did not arrive. Please try again.')); + } + + $size = (int)($file['size'] ?? filesize($path)); + $limit = $this->rules->maxSizeBytes(fileConfig: $fileConfig); + if ($limit > 0 && $size > $limit) { + $this->refuse( + property: $property, + code: 'file-too-large', + message: $this->l10n->t('The file is %1$s MB; "%2$s" takes at most %3$s MB.', [$this->megabytes(bytes: $size), $property, $this->megabytes(bytes: $limit)]) + ); + } + + $type = (string)($file['type'] ?? 'application/octet-stream'); + $allowed = $this->rules->allowedTypes(fileConfig: $fileConfig); + if ($allowed !== [] && in_array($type, $allowed, true) === false) { + $this->refuse( + property: $property, + code: 'file-type-not-allowed', + message: $this->l10n->t('"%1$s" takes these file types: %2$s.', [$property, implode(', ', $allowed)]) + ); + } + + $token = bin2hex(random_bytes(16)); + $expiresAt = $this->time->getTime() + self::TTL; + $folder = $this->folder(create: true); + $folder->newFile($token . '.bin', (string)file_get_contents($path)); + $folder->newFile( + $token . '.json', + (string)json_encode( + [ + 'formId' => $formId, + 'property' => $property, + 'name' => basename((string)($file['name'] ?? 'upload')), + 'type' => $type, + 'size' => $size, + 'expiresAt' => $expiresAt, + ] + ) + ); + + return ['token' => $token, 'expiresAt' => $expiresAt]; + }//end issue() + + /** + * A token as the upload array the save path reads, bytes in a temporary file. + * + * @param string $formId The form submitting it; a token of another form is unknown here. + * @param string $token The token. + * + * @return array{property: string, name: string, type: string, tmp_name: string, error: int, size: int} The upload. + * + * @throws FormSubmitRefusedException 422 when the token is unknown, expired, claimed or another form's. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + public function materialise(string $formId, string $token): array { + $meta = $this->meta(token: $token); + if ($meta === null || $meta['formId'] !== $formId || $meta['expiresAt'] <= $this->time->getTime()) { + $this->refuse(property: '', code: 'upload-token-unknown', message: $this->l10n->t('An uploaded file has expired or is unknown. Please add it again.')); + } + + $path = $this->temp->getTemporaryFile(); + file_put_contents($path, $this->folder(create: true)->getFile($token . '.bin')->getContent()); + + return [ + 'property' => $meta['property'], + 'name' => $meta['name'], + 'type' => $meta['type'], + 'tmp_name' => $path, + 'error' => UPLOAD_ERR_OK, + 'size' => $meta['size'], + ]; + }//end materialise() + + /** + * Delete claimed tokens: their bytes now live on the destination object. + * + * @param array $tokens The tokens. + * + * @return void + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + public function claim(array $tokens): void { + $folder = $this->folder(create: false); + if ($folder === null) { + return; + } + + foreach ($tokens as $token) { + $this->deleteToken(folder: $folder, token: $token); + } + }//end claim() + + /** + * Delete every expired, unclaimed token. + * + * @return int How many tokens were deleted, so a zero is a counted zero. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + public function purge(): int { + $folder = $this->folder(create: false); + if ($folder === null) { + return 0; + } + + $now = $this->time->getTime(); + $deleted = 0; + foreach ($folder->getDirectoryListing() as $file) { + $name = $file->getName(); + if (str_ends_with($name, '.json') === false) { + continue; + } + + $token = substr($name, 0, -5); + $meta = $this->meta(token: $token); + if ($meta !== null && $meta['expiresAt'] > $now) { + continue; + } + + $this->deleteToken(folder: $folder, token: $token); + $deleted++; + } + + return $deleted; + }//end purge() + + /** + * A token's record, or null when absent or unreadable. + * + * @param string $token The token. + * + * @return array{formId: string, property: string, name: string, type: string, size: int, expiresAt: int}|null The record. + */ + private function meta(string $token): ?array { + if (preg_match('/^[a-f0-9]{32}$/', $token) !== 1) { + return null; + } + + $folder = $this->folder(create: false); + if ($folder === null || $folder->fileExists($token . '.json') === false || $folder->fileExists($token . '.bin') === false) { + return null; + } + + $meta = json_decode($folder->getFile($token . '.json')->getContent(), true); + if (is_array($meta) === false || is_int($meta['expiresAt'] ?? null) === false) { + return null; + } + + return [ + 'formId' => (string)($meta['formId'] ?? ''), + 'property' => (string)($meta['property'] ?? ''), + 'name' => (string)($meta['name'] ?? 'upload'), + 'type' => (string)($meta['type'] ?? 'application/octet-stream'), + 'size' => (int)($meta['size'] ?? 0), + 'expiresAt' => $meta['expiresAt'], + ]; + }//end meta() + + /** + * Delete a token's two files, whichever exist. + * + * @param ISimpleFolder $folder The store folder. + * @param string $token The token. + * + * @return void + */ + private function deleteToken(ISimpleFolder $folder, string $token): void { + foreach ([$token . '.bin', $token . '.json'] as $name) { + if ($folder->fileExists($name) === true) { + $folder->getFile($name)->delete(); + } + } + }//end deleteToken() + + /** + * Refuse with one finding. + * + * @param string $property The property. + * @param string $code The code. + * @param string $message The translated message. + * + * @return never + * + * @throws FormSubmitRefusedException Always, 422. + */ + private function refuse(string $property, string $code, string $message): never { + throw new FormSubmitRefusedException( + message: $message, + status: 422, + findings: [['property' => $property, 'code' => $code, 'message' => $message]] + ); + }//end refuse() + + /** + * Bytes as megabytes with one decimal, for a message. + * + * @param int $bytes The size. + * + * @return string The size in MB. + */ + private function megabytes(int $bytes): string { + return number_format($bytes / 1048576, 1); + }//end megabytes() + + /** + * The store's folder. + * + * @param bool $create Create it when absent. + * + * @return ISimpleFolder|null The folder; null only when absent and not created. + * + * @psalm-return ($create is true ? ISimpleFolder : ISimpleFolder|null) + */ + private function folder(bool $create): ?ISimpleFolder { + try { + return $this->appData->getFolder(self::FOLDER); + } catch (NotFoundException) { + if ($create === false) { + return null; + } + + return $this->appData->newFolder(self::FOLDER); + } + }//end folder() +}//end class diff --git a/lib/Service/Object/SaveObject/FilePropertyHandler.php b/lib/Service/Object/SaveObject/FilePropertyHandler.php index e5966458d0..f378d81523 100644 --- a/lib/Service/Object/SaveObject/FilePropertyHandler.php +++ b/lib/Service/Object/SaveObject/FilePropertyHandler.php @@ -23,6 +23,7 @@ use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Service\File\ExecutableContentDetector; +use OCA\OpenRegister\Service\File\FilePropertyRules; use OCA\OpenRegister\Service\FileService; use OCA\OpenRegister\Service\SecurityService; use OCP\Files\File; @@ -1143,20 +1144,7 @@ public function validateFileAgainstConfig( * @spec openspec/specs/content-versioning/spec.md */ private function resolveAllowedTypes(array $fileConfig): array { - $types = $fileConfig['allowedTypes'] ?? null; - if (is_array($types) === false || $types === []) { - $editorConfig = $fileConfig['fileConfiguration'] ?? []; - $types = null; - if (is_array($editorConfig) === true) { - $types = $editorConfig['allowedMimeTypes'] ?? null; - } - } - - if (is_array($types) === false) { - return []; - } - - return array_values(array_filter($types, 'is_string')); + return (new FilePropertyRules())->allowedTypes(fileConfig: $fileConfig); }//end resolveAllowedTypes() /** @@ -1176,22 +1164,7 @@ private function resolveAllowedTypes(array $fileConfig): array { * @spec openspec/specs/content-versioning/spec.md */ private function resolveMaxSizeBytes(array $fileConfig): int { - $bytes = $fileConfig['maxSize'] ?? null; - if (is_numeric($bytes) === true && (float) $bytes > 0) { - return (int) $bytes; - } - - $editorConfig = $fileConfig['fileConfiguration'] ?? []; - if (is_array($editorConfig) === false) { - return 0; - } - - $megabytes = $editorConfig['maxSize'] ?? null; - if (is_numeric($megabytes) === true && (float) $megabytes > 0) { - return (int) round((float) $megabytes * 1024 * 1024); - } - - return 0; + return (new FilePropertyRules())->maxSizeBytes(fileConfig: $fileConfig); }//end resolveMaxSizeBytes() /** diff --git a/tests/Unit/Exception/FormSubmitRefusedExceptionTest.php b/tests/Unit/Exception/FormSubmitRefusedExceptionTest.php new file mode 100644 index 0000000000..a3e6652a3f --- /dev/null +++ b/tests/Unit/Exception/FormSubmitRefusedExceptionTest.php @@ -0,0 +1,45 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Exception; + +use OCA\OpenRegister\Exception\FormSubmitRefusedException; +use PHPUnit\Framework\TestCase; + +/** + * Status, findings and the response body. + * + * @covers \OCA\OpenRegister\Exception\FormSubmitRefusedException + */ +class FormSubmitRefusedExceptionTest extends TestCase { + + /** + * The body is `{ message, findings }`; the status travels beside it. + */ + public function testTheBodyCarriesTheFindings(): void { + $findings = [['property' => 'caseType', 'code' => 'required', 'message' => 'caseType is required']]; + $refused = new FormSubmitRefusedException(message: 'The form was not accepted.', status: 422, findings: $findings); + + $this->assertSame(422, $refused->getStatus()); + $this->assertSame($findings, $refused->getFindings()); + $this->assertSame(['message' => 'The form was not accepted.', 'findings' => $findings], $refused->toBody()); + }//end testTheBodyCarriesTheFindings() + + /** + * A status outside 4xx/5xx is refused at construction: it would read as success. + */ + public function testANonErrorStatusIsRejected(): void { + $this->expectException(\InvalidArgumentException::class); + new FormSubmitRefusedException(message: 'x', status: 200); + }//end testANonErrorStatusIsRejected() +}//end class diff --git a/tests/Unit/Service/File/FilePropertyRulesTest.php b/tests/Unit/Service/File/FilePropertyRulesTest.php new file mode 100644 index 0000000000..f11200becc --- /dev/null +++ b/tests/Unit/Service/File/FilePropertyRulesTest.php @@ -0,0 +1,58 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\File; + +use OCA\OpenRegister\Service\File\FilePropertyRules; +use PHPUnit\Framework\TestCase; + +/** + * Bytes vs editor megabytes, the two allowed-type spellings, and the items rule. + * + * @covers \OCA\OpenRegister\Service\File\FilePropertyRules + */ +class FilePropertyRulesTest extends TestCase { + + /** + * Top-level maxSize is bytes; the editor's fileConfiguration.maxSize is megabytes. + */ + public function testMaxSize(): void { + $rules = new FilePropertyRules(); + + $this->assertSame(2048, $rules->maxSizeBytes(fileConfig: ['maxSize' => 2048])); + $this->assertSame(10 * 1024 * 1024, $rules->maxSizeBytes(fileConfig: ['fileConfiguration' => ['maxSize' => 10]])); + $this->assertSame(0, $rules->maxSizeBytes(fileConfig: ['type' => 'file'])); + }//end testMaxSize() + + /** + * allowedTypes first, else the editor's allowedMimeTypes; non-strings dropped. + */ + public function testAllowedTypes(): void { + $rules = new FilePropertyRules(); + + $this->assertSame(['image/png'], $rules->allowedTypes(fileConfig: ['allowedTypes' => ['image/png', 3]])); + $this->assertSame(['application/pdf'], $rules->allowedTypes(fileConfig: ['fileConfiguration' => ['allowedMimeTypes' => ['application/pdf']]])); + $this->assertSame([], $rules->allowedTypes(fileConfig: [])); + }//end testAllowedTypes() + + /** + * The rule that governs a file: the property for `file`, its items for an array of files, none otherwise. + */ + public function testFileConfigOf(): void { + $rules = new FilePropertyRules(); + + $this->assertSame(['type' => 'file', 'maxSize' => 1], $rules->fileConfigOf(property: ['type' => 'file', 'maxSize' => 1])); + $this->assertSame(['type' => 'file'], $rules->fileConfigOf(property: ['type' => 'array', 'items' => ['type' => 'file']])); + $this->assertNull($rules->fileConfigOf(property: ['type' => 'string'])); + }//end testFileConfigOf() +}//end class diff --git a/tests/Unit/Service/Form/Fakes/MemoryAppData.php b/tests/Unit/Service/Form/Fakes/MemoryAppData.php new file mode 100644 index 0000000000..92b6febc56 --- /dev/null +++ b/tests/Unit/Service/Form/Fakes/MemoryAppData.php @@ -0,0 +1,45 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form\Fakes; + +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFolder; + +/** + * Folders by name, each a MemoryFolder. + */ +class MemoryAppData implements IAppData { + + /** + * @var array + */ + public array $folders = []; + + public function getFolder(string $name): ISimpleFolder { + if (isset($this->folders[$name]) === false) { + throw new NotFoundException($name); + } + + return $this->folders[$name]; + }//end getFolder() + + public function getDirectoryListing(): array { + return array_values($this->folders); + }//end getDirectoryListing() + + public function newFolder(string $name): ISimpleFolder { + $this->folders[$name] = new MemoryFolder(name: $name); + + return $this->folders[$name]; + }//end newFolder() +}//end class diff --git a/tests/Unit/Service/Form/Fakes/MemoryFile.php b/tests/Unit/Service/Form/Fakes/MemoryFile.php new file mode 100644 index 0000000000..0d8d41abaa --- /dev/null +++ b/tests/Unit/Service/Form/Fakes/MemoryFile.php @@ -0,0 +1,75 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form\Fakes; + +use OCP\Files\SimpleFS\ISimpleFile; + +/** + * Content in a string; delete removes it from its folder. + */ +class MemoryFile implements ISimpleFile { + + public function __construct( + private readonly string $name, + private readonly MemoryFolder $folder, + private string $content, + ) { + }//end __construct() + + public function getName(): string { + return $this->name; + }//end getName() + + public function getSize(): int|float { + return strlen($this->content); + }//end getSize() + + public function getETag(): string { + return md5($this->content); + }//end getETag() + + public function getMTime(): int { + return 0; + }//end getMTime() + + public function getContent(): string { + return $this->content; + }//end getContent() + + public function putContent($data): void { + $this->content = is_resource($data) === true ? (string)stream_get_contents($data) : (string)$data; + }//end putContent() + + public function delete(): void { + $this->folder->forget(name: $this->name); + }//end delete() + + public function getMimeType(): string { + return 'application/octet-stream'; + }//end getMimeType() + + public function getExtension(): string { + return ''; + }//end getExtension() + + public function read() { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $this->content); + rewind($stream); + + return $stream; + }//end read() + + public function write() { + return fopen('php://memory', 'w'); + }//end write() +}//end class diff --git a/tests/Unit/Service/Form/Fakes/MemoryFolder.php b/tests/Unit/Service/Form/Fakes/MemoryFolder.php new file mode 100644 index 0000000000..13b239f388 --- /dev/null +++ b/tests/Unit/Service/Form/Fakes/MemoryFolder.php @@ -0,0 +1,79 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form\Fakes; + +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; + +/** + * Files by name; a deleted file disappears from the listing. + */ +class MemoryFolder implements ISimpleFolder { + + /** + * @var array + */ + public array $files = []; + + public function __construct(private readonly string $name) { + }//end __construct() + + public function getDirectoryListing(): array { + return array_values($this->files); + }//end getDirectoryListing() + + public function fileExists(string $name): bool { + return isset($this->files[$name]); + }//end fileExists() + + public function getFile(string $name): ISimpleFile { + if (isset($this->files[$name]) === false) { + throw new NotFoundException($name); + } + + return $this->files[$name]; + }//end getFile() + + public function newFile(string $name, $content = null): ISimpleFile { + $this->files[$name] = new MemoryFile(name: $name, folder: $this, content: (string)($content ?? '')); + + return $this->files[$name]; + }//end newFile() + + public function delete(): void { + $this->files = []; + }//end delete() + + public function getName(): string { + return $this->name; + }//end getName() + + public function getFolder(string $name): ISimpleFolder { + throw new NotFoundException($name); + }//end getFolder() + + public function newFolder(string $path): ISimpleFolder { + return new self(name: $path); + }//end newFolder() + + public function getOrCreateFolder(string $path, int $maxRetries = 5): ISimpleFolder { + return new self(name: $path); + }//end getOrCreateFolder() + + /** + * Remove one file, as MemoryFile::delete asks. + */ + public function forget(string $name): void { + unset($this->files[$name]); + }//end forget() +}//end class diff --git a/tests/Unit/Service/Form/FormIdempotencyStoreTest.php b/tests/Unit/Service/Form/FormIdempotencyStoreTest.php new file mode 100644 index 0000000000..1e149a94a6 --- /dev/null +++ b/tests/Unit/Service/Form/FormIdempotencyStoreTest.php @@ -0,0 +1,99 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form; + +use OCA\OpenRegister\Service\Form\FormIdempotencyStore; +use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData; +use OCP\AppFramework\Utility\ITimeFactory; +use PHPUnit\Framework\TestCase; + +/** + * Remember, find, expire, purge, and keys scoped per form. + * + * @covers \OCA\OpenRegister\Service\Form\FormIdempotencyStore + */ +class FormIdempotencyStoreTest extends TestCase { + + private int $now = 1_760_000_000; + + private MemoryAppData $appData; + + private FormIdempotencyStore $store; + + protected function setUp(): void { + $this->appData = new MemoryAppData(); + $time = $this->createMock(ITimeFactory::class); + $time->method('getTime')->willReturnCallback(fn (): int => $this->now); + $this->store = new FormIdempotencyStore(appData: $this->appData, time: $time); + }//end setUp() + + /** + * Nothing remembered: nothing found. + */ + public function testAnUnknownKeyFindsNothing(): void { + $this->assertNull($this->store->find(scope: 'form-1', key: 'k1')); + }//end testAnUnknownKeyFindsNothing() + + /** + * The remembered answer comes back unchanged within 24 hours. + */ + public function testARememberedAnswerIsFoundWithinTheWindow(): void { + $answer = ['reference' => '2026-0412', 'id' => 'uuid-7']; + $this->store->remember(scope: 'form-1', key: 'k1', response: $answer); + + $this->now += 86_399; + $this->assertSame($answer, $this->store->find(scope: 'form-1', key: 'k1')); + }//end testARememberedAnswerIsFoundWithinTheWindow() + + /** + * After 24 hours the key is free again, and the expired entry is gone. + */ + public function testAnExpiredAnswerIsNotFound(): void { + $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7']); + + $this->now += 86_401; + $this->assertNull($this->store->find(scope: 'form-1', key: 'k1')); + $this->assertSame([], $this->appData->folders[FormIdempotencyStore::FOLDER]->files); + }//end testAnExpiredAnswerIsNotFound() + + /** + * The same key on another form is another submit. + */ + public function testKeysAreScopedPerForm(): void { + $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7']); + + $this->assertNull($this->store->find(scope: 'form-2', key: 'k1')); + }//end testKeysAreScopedPerForm() + + /** + * The purge deletes only expired entries and counts them. + */ + public function testThePurgeCountsWhatItDeleted(): void { + $this->store->remember(scope: 'f', key: 'old', response: ['id' => 'a']); + $this->now += 50_000; + $this->store->remember(scope: 'f', key: 'new', response: ['id' => 'b']); + $this->now += 40_000; + + $this->assertSame(1, $this->store->purge()); + $this->assertSame(['id' => 'b'], $this->store->find(scope: 'f', key: 'new')); + $this->assertSame(0, $this->store->purge()); + }//end testThePurgeCountsWhatItDeleted() + + /** + * A purge before any submit counts zero instead of failing on the absent folder. + */ + public function testAPurgeWithNoFolderCountsZero(): void { + $this->assertSame(0, $this->store->purge()); + }//end testAPurgeWithNoFolderCountsZero() +}//end class diff --git a/tests/Unit/Service/Form/FormPayloadFindingsTest.php b/tests/Unit/Service/Form/FormPayloadFindingsTest.php new file mode 100644 index 0000000000..ab813e24a4 --- /dev/null +++ b/tests/Unit/Service/Form/FormPayloadFindingsTest.php @@ -0,0 +1,139 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form; + +use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Exception\HookStoppedException; +use OCA\OpenRegister\Exception\ValidationException; +use OCA\OpenRegister\Service\Form\FormPayloadFindings; +use Opis\JsonSchema\Validator; +use PHPUnit\Framework\TestCase; + +/** + * Real opis results, the three exception kinds the save path throws. + * + * @covers \OCA\OpenRegister\Service\Form\FormPayloadFindings + * @uses \OCA\OpenRegister\Exception\ValidationException + * @uses \OCA\OpenRegister\Exception\CustomValidationException + * @uses \OCA\OpenRegister\Exception\HookStoppedException + */ +class FormPayloadFindingsTest extends TestCase { + + private FormPayloadFindings $findings; + + protected function setUp(): void { + $this->findings = new FormPayloadFindings(); + }//end setUp() + + /** + * A real opis result against a small case schema. + * + * @param array $data The payload. + */ + private function opisResult(array $data): \Opis\JsonSchema\ValidationResult { + $schema = json_decode( + (string)json_encode( + [ + 'type' => 'object', + 'required' => ['title', 'caseType'], + 'properties' => [ + 'title' => ['type' => 'string', 'maxLength' => 5], + 'caseType' => ['type' => 'string'], + 'amount' => ['type' => 'integer'], + ], + ] + ) + ); + $validator = new Validator(); + $validator->setMaxErrors(10); + + return $validator->validate(json_decode((string)json_encode($data)), $schema); + }//end opisResult() + + /** + * A missing required property names the property with code `required`. + */ + public function testAMissingRequiredPropertyIsARequiredFinding(): void { + $result = $this->opisResult(['title' => 'abc']); + $findings = $this->findings->fromResult(result: $result); + + $this->assertCount(1, $findings); + $this->assertSame('caseType', $findings[0]['property']); + $this->assertSame('required', $findings[0]['code']); + $this->assertNotSame('', $findings[0]['message']); + }//end testAMissingRequiredPropertyIsARequiredFinding() + + /** + * Several problems give several findings, each with its property and keyword. + */ + public function testSeveralProblemsGiveSeveralFindings(): void { + $result = $this->opisResult(['title' => 'far too long', 'caseType' => 'x', 'amount' => 'veel']); + $findings = $this->findings->fromResult(result: $result); + + $byProperty = []; + foreach ($findings as $finding) { + $byProperty[$finding['property']] = $finding['code']; + } + + $this->assertSame(['title' => 'maxLength', 'amount' => 'type'], $byProperty); + }//end testSeveralProblemsGiveSeveralFindings() + + /** + * A valid result has no findings. + */ + public function testAValidResultHasNoFindings(): void { + $this->assertSame([], $this->findings->fromResult(result: $this->opisResult(['title' => 'abc', 'caseType' => 'x']))); + }//end testAValidResultHasNoFindings() + + /** + * The write a finding belongs to is named when several objects are written. + */ + public function testTheWriteIsNamed(): void { + $findings = $this->findings->fromResult(result: $this->opisResult(['title' => 'abc']), write: 'contact'); + + $this->assertSame('contact', $findings[0]['write']); + }//end testTheWriteIsNamed() + + /** + * The save path's exceptions: opis-backed, message-only, custom map and hook list. + */ + public function testTheSavePathExceptionsBecomeFindings(): void { + $opis = $this->opisResult(['title' => 'abc']); + $fromOpis = $this->findings->fromThrowable(exception: new ValidationException(message: 'Validation failed', errors: $opis->error())); + $this->assertSame('required', $fromOpis[0]['code']); + + $messageOnly = $this->findings->fromThrowable(exception: new ValidationException(message: 'cannot modify readOnly property identifier')); + $this->assertSame([['property' => '', 'code' => 'invalid', 'message' => 'cannot modify readOnly property identifier']], $messageOnly); + + $unique = $this->findings->fromThrowable( + exception: new CustomValidationException(message: 'Fields are not unique: kvk', errors: ['kvk' => 'The identifying fields (kvk) are not unique.']) + ); + $this->assertSame([['property' => 'kvk', 'code' => 'unique', 'message' => 'The identifying fields (kvk) are not unique.']], $unique); + + $hook = $this->findings->fromThrowable( + exception: new HookStoppedException(message: 'stopped', errors: [['property' => 'bsn', 'message' => 'BSN fails the eleven test']]) + ); + $this->assertSame([['property' => 'bsn', 'code' => 'refused', 'message' => 'BSN fails the eleven test']], $hook); + }//end testTheSavePathExceptionsBecomeFindings() + + /** + * Whether a throwable is a refusal of the payload (422) or of the moment (503). + */ + public function testPayloadRefusalsAreToldApartFromOutages(): void { + $this->assertTrue($this->findings->isPayloadRefusal(exception: new ValidationException(message: 'x'))); + $this->assertTrue($this->findings->isPayloadRefusal(exception: new CustomValidationException(message: 'x', errors: []))); + $this->assertTrue($this->findings->isPayloadRefusal(exception: new HookStoppedException())); + $this->assertFalse($this->findings->isPayloadRefusal(exception: new \RuntimeException('database went away'))); + }//end testPayloadRefusalsAreToldApartFromOutages() +}//end class diff --git a/tests/Unit/Service/Form/FormUploadStoreTest.php b/tests/Unit/Service/Form/FormUploadStoreTest.php new file mode 100644 index 0000000000..9dbbb64107 --- /dev/null +++ b/tests/Unit/Service/Form/FormUploadStoreTest.php @@ -0,0 +1,196 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form; + +use OCA\OpenRegister\Exception\FormSubmitRefusedException; +use OCA\OpenRegister\Service\File\FilePropertyRules; +use OCA\OpenRegister\Service\Form\FormUploadStore; +use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IL10N; +use OCP\ITempManager; +use PHPUnit\Framework\TestCase; + +/** + * Issue, refuse, materialise, claim and purge. + * + * @covers \OCA\OpenRegister\Service\Form\FormUploadStore + * @uses \OCA\OpenRegister\Service\File\FilePropertyRules + * @uses \OCA\OpenRegister\Exception\FormSubmitRefusedException + */ +class FormUploadStoreTest extends TestCase { + + private int $now = 1_760_000_000; + + private MemoryAppData $appData; + + private FormUploadStore $store; + + /** + * @var array + */ + private array $temporary = []; + + protected function setUp(): void { + $this->appData = new MemoryAppData(); + $time = $this->createMock(ITimeFactory::class); + $time->method('getTime')->willReturnCallback(fn (): int => $this->now); + $temp = $this->createMock(ITempManager::class); + $temp->method('getTemporaryFile')->willReturnCallback( + function (): string { + $path = (string)tempnam(sys_get_temp_dir(), 'or-form-test'); + $this->temporary[] = $path; + + return $path; + } + ); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static fn (string $text, array $parameters = []): string => $parameters === [] ? $text : vsprintf($text, $parameters) + ); + + $this->store = new FormUploadStore(appData: $this->appData, time: $time, temp: $temp, rules: new FilePropertyRules(), l10n: $l10n); + }//end setUp() + + protected function tearDown(): void { + foreach ($this->temporary as $path) { + if (is_file($path) === true) { + unlink($path); + } + } + }//end tearDown() + + /** + * A PHP upload array for some bytes. + * + * @return array{name: string, type: string, tmp_name: string, error: int, size: int} + */ + private function upload(string $bytes, string $type = 'application/pdf', int $size = -1): array { + $path = (string)tempnam(sys_get_temp_dir(), 'or-form-upload'); + file_put_contents($path, $bytes); + $this->temporary[] = $path; + + return ['name' => 'bewijs.pdf', 'type' => $type, 'tmp_name' => $path, 'error' => UPLOAD_ERR_OK, 'size' => $size < 0 ? strlen($bytes) : $size]; + }//end upload() + + /** + * A file within the rules gets a token that expires in 24 hours. + */ + public function testAFileWithinTheRulesGetsAToken(): void { + $issued = $this->store->issue(formId: 'form-1', property: 'bijlage', rule: ['type' => 'file', 'maxSize' => 1024], file: $this->upload('%PDF-1.7')); + + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $issued['token']); + $this->assertSame($this->now + 86_400, $issued['expiresAt']); + }//end testAFileWithinTheRulesGetsAToken() + + /** + * Spec scenario: an oversized file is refused at upload and no token is issued. + */ + public function testAnOversizedFileIsRefused(): void { + $rule = ['type' => 'file', 'fileConfiguration' => ['maxSize' => 10]]; + + try { + $this->store->issue(formId: 'form-1', property: 'bijlage', rule: $rule, file: $this->upload('x', size: 12 * 1024 * 1024)); + $this->fail('An oversized file was given a token.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(422, $refused->getStatus()); + $this->assertSame('file-too-large', $refused->getFindings()[0]['code']); + } + + $this->assertSame([], ($this->appData->folders[FormUploadStore::FOLDER] ?? null)?->files ?? []); + }//end testAnOversizedFileIsRefused() + + /** + * A type the property does not allow is refused; an items rule of an array property counts. + */ + public function testADisallowedTypeIsRefused(): void { + $this->expectException(FormSubmitRefusedException::class); + $this->store->issue( + formId: 'form-1', + property: 'bijlagen', + rule: ['type' => 'array', 'items' => ['type' => 'file', 'allowedTypes' => ['image/png']]], + file: $this->upload('%PDF') + ); + }//end testADisallowedTypeIsRefused() + + /** + * A property that holds no files takes no upload. + */ + public function testAPropertyThatHoldsNoFilesTakesNoUpload(): void { + $this->expectException(FormSubmitRefusedException::class); + $this->store->issue(formId: 'form-1', property: 'title', rule: ['type' => 'string'], file: $this->upload('x')); + }//end testAPropertyThatHoldsNoFilesTakesNoUpload() + + /** + * A failed PHP upload is refused. + */ + public function testAFailedUploadIsRefused(): void { + $file = $this->upload('x'); + $file['error'] = UPLOAD_ERR_PARTIAL; + + $this->expectException(FormSubmitRefusedException::class); + $this->store->issue(formId: 'form-1', property: 'bijlage', rule: ['type' => 'file'], file: $file); + }//end testAFailedUploadIsRefused() + + /** + * A token becomes an upload array the save path reads, for its own form only. + */ + public function testATokenMaterialisesForItsOwnFormOnly(): void { + $issued = $this->store->issue(formId: 'form-1', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('%PDF-1.7 bytes')); + + $file = $this->store->materialise(formId: 'form-1', token: $issued['token']); + $this->assertSame('bijlage', $file['property']); + $this->assertSame('bewijs.pdf', $file['name']); + $this->assertSame('%PDF-1.7 bytes', file_get_contents($file['tmp_name'])); + $this->assertSame(UPLOAD_ERR_OK, $file['error']); + + $this->expectException(FormSubmitRefusedException::class); + $this->store->materialise(formId: 'form-2', token: $issued['token']); + }//end testATokenMaterialisesForItsOwnFormOnly() + + /** + * A claimed token is gone; an unknown or expired one is refused. + */ + public function testAClaimedOrExpiredTokenIsRefused(): void { + $claimed = $this->store->issue(formId: 'f', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('a'))['token']; + $this->store->claim(tokens: [$claimed]); + + $expired = $this->store->issue(formId: 'f', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('b'))['token']; + $this->now += 86_401; + + foreach ([$claimed, $expired, 'not-a-token'] as $token) { + try { + $this->store->materialise(formId: 'f', token: $token); + $this->fail('Token ' . $token . ' materialised.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame('upload-token-unknown', $refused->getFindings()[0]['code']); + } + } + }//end testAClaimedOrExpiredTokenIsRefused() + + /** + * The purge deletes unclaimed expired tokens, bytes and record, and counts tokens. + */ + public function testThePurgeCountsExpiredTokens(): void { + $this->store->issue(formId: 'f', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('a')); + $this->now += 50_000; + $kept = $this->store->issue(formId: 'f', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('b'))['token']; + $this->now += 40_000; + + $this->assertSame(1, $this->store->purge()); + $this->assertCount(2, $this->appData->folders[FormUploadStore::FOLDER]->files); + $this->assertSame('b', file_get_contents($this->store->materialise(formId: 'f', token: $kept)['tmp_name'])); + $this->assertSame(0, $this->store->purge()); + }//end testThePurgeCountsExpiredTokens() +}//end class From b722ab1e5cae9f50d122a6e845c7de1be4a746bb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 17:08:25 +0200 Subject: [PATCH 037/183] feat(forms): a submit creates its destination in one request, all or none FormSubmitService validates the payload with the destination schema's full validator whatever its hard-validation flag, creates the object under the subject's RBAC, and answers with reference, id, receivedAt and every property marked x-openregister.confirmation, read after the create listeners ran. Several writes are all validated before the first; a late refusal deletes the earlier writes of that submit and logs it. An outage answers 503 (try again later), never a receipt. Upload tokens are claimed only after the last write; a retried Idempotency-Key repeats the answer. Decision 179, ADR-117. --- lib/Service/Form/FormSubmitService.php | 683 ++++++++++++++++++ .../Service/Form/FormSubmitServiceTest.php | 555 ++++++++++++++ 2 files changed, 1238 insertions(+) create mode 100644 lib/Service/Form/FormSubmitService.php create mode 100644 tests/Unit/Service/Form/FormSubmitServiceTest.php diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php new file mode 100644 index 0000000000..cbdf17a645 --- /dev/null +++ b/lib/Service/Form/FormSubmitService.php @@ -0,0 +1,683 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Form + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Form; + +use DateTimeInterface; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Exception\FormSubmitRefusedException; +use OCA\OpenRegister\Exception\NotAuthorizedException; +use OCA\OpenRegister\Service\Object\ValidateObject; +use OCA\OpenRegister\Service\ObjectService; +use OCP\IL10N; +use OCP\IUser; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Validate all, write in order, compensate on a late refusal, answer with the real record. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The submit IS the meeting point of + * mapping, validation, save, delete, uploads and idempotency; each collaborator is one + * of those steps and none is reached any other way. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ +class FormSubmitService { + + /** + * Stands in for an earlier write's id while every write is validated before the first. + * + * @var string + */ + public const PENDING_ID = '00000000-0000-4000-8000-000000000000'; + + /** + * Constructor. + * + * @param RegisterMapper $registers Resolves a destination's register. + * @param SchemaMapper $schemas Resolves a destination's schema. + * @param ValidateObject $validator The destination schema's full validator. + * @param ObjectService $objects Creates, re-reads and (to compensate) deletes objects. + * @param FormPayloadFindings $findings Turns a refusal into per-property findings. + * @param FormIdempotencyStore $keys Repeats the first answer for a retried key. + * @param FormUploadStore $uploads Holds and releases upload tokens. + * @param IL10N $l10n Translations, for answers a resident reads. + * @param LoggerInterface $logger Records each compensation. + */ + public function __construct( + private readonly RegisterMapper $registers, + private readonly SchemaMapper $schemas, + private readonly ValidateObject $validator, + private readonly ObjectService $objects, + private readonly FormPayloadFindings $findings, + private readonly FormIdempotencyStore $keys, + private readonly FormUploadStore $uploads, + private readonly IL10N $l10n, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Submit one form into one destination. + * + * @param array{register?: mixed, schema?: mixed} $destination The resolved destination pair. + * @param array|null $mapping The form's mapping; null takes the payload as the object. + * @param array $payload What the submitter filled in. + * @param IUser|null $subject The signed-in subject; null for an anonymous submit. + * @param string|null $idempotencyKey The Idempotency-Key, when the caller sent one. + * @param string $scope The form id the key and upload tokens belong to. + * + * @return array{reference: string, id: string, receivedAt: string|null, confirmation: array, objects: array>} The answer. + * + * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 503 try again later. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + public function submit( + array $destination, + ?array $mapping, + array $payload, + ?IUser $subject = null, + ?string $idempotencyKey = null, + string $scope = '', + ): array { + return $this->submitAll( + writes: [array_merge($destination, ['mapping' => $mapping])], + payload: $payload, + subject: $subject, + idempotencyKey: $idempotencyKey, + scope: $scope + ); + }//end submit() + + /** + * Submit one payload into several destinations, all or none. + * + * Each write is `{ register, schema, mapping?, as? }`. A fixed value + * `{ "$write": "" }` is the id of an earlier write of this submit. + * The answer's `reference`, `id`, `receivedAt` and `confirmation` are + * the first write's; `objects` lists every write. + * + * @param array> $writes The writes, in order. + * @param array $payload What the submitter filled in. + * @param IUser|null $subject The signed-in subject; null for anonymous. + * @param string|null $idempotencyKey The Idempotency-Key, when sent. + * @param string $scope The form id the key and tokens belong to. + * + * @return array{reference: string, id: string, receivedAt: string|null, confirmation: array, objects: array>} The answer. + * + * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 503 try again later. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-writing-several-objects-must-commit-all-or-none + */ + public function submitAll( + array $writes, + array $payload, + ?IUser $subject = null, + ?string $idempotencyKey = null, + string $scope = '', + ): array { + $key = trim((string)$idempotencyKey); + $keyScope = $this->keyScope(scope: $scope, writes: $writes); + if ($key !== '') { + $remembered = $this->keys->find(scope: $keyScope, key: $key); + if ($remembered !== null) { + /* + * @var array{reference: string, id: string, receivedAt: string|null, confirmation: array, objects: array>} $remembered + */ + return $remembered; + } + } + + $plans = $this->plan(writes: $writes, payload: $payload, scope: $scope); + $this->validateAll(plans: $plans); + $created = $this->writeAll(plans: $plans, subject: $subject); + + $this->uploads->claim(tokens: array_merge(...array_map(static fn (array $plan): array => $plan['tokens'], $plans))); + $answer = $this->answer(created: $created); + if ($key !== '') { + $this->keys->remember(scope: $keyScope, key: $key, response: $answer); + } + + return $answer; + }//end submitAll() + + /** + * Resolve each write's destination and map the payload onto it. + * + * @param array> $writes The writes. + * @param array $payload The payload. + * @param string $scope The form id upload tokens belong to. + * + * @return array|null, object: array, files: array>, tokens: array}> The plans. + * + * @throws FormSubmitRefusedException 404 when a destination does not exist; 422 for a bad upload token. + */ + private function plan(array $writes, array $payload, string $scope): array { + $plans = []; + foreach (array_values($writes) as $index => $write) { + $named = trim((string)($write['as'] ?? '')) !== ''; + $as = (string)($write['as'] ?? ('write' . $index)); + if ($named === false) { + $as = 'write' . $index; + } + + $mapping = null; + if (is_array($write['mapping'] ?? null) === true) { + $mapping = $write['mapping']; + } + + [$object, $files, $tokens] = $this->mapPayload(mapping: $mapping, payload: $payload, scope: $scope); + $plans[] = [ + 'as' => $as, + 'named' => $named && count($writes) > 1, + 'register' => $this->register(reference: $write['register'] ?? null), + 'schema' => $this->schema(reference: $write['schema'] ?? null), + 'mapping' => $mapping, + 'object' => $object, + 'files' => $files, + 'tokens' => $tokens, + ]; + } + + if ($plans === []) { + throw new FormSubmitRefusedException(message: $this->l10n->t('This form has no destination.'), status: 404); + } + + return $plans; + }//end plan() + + /** + * The object a mapping makes of a payload, plus the uploads it claims. + * + * Only mapped fields reach the object: the submitter does not choose which + * properties a form writes. Without a mapping the payload is the object, + * minus `_` control keys and `@` metadata, as the objects endpoint does. + * + * @param array|null $mapping The mapping. + * @param array $payload The payload. + * @param string $scope The form id upload tokens belong to. + * + * @return array{0: array, 1: array>, 2: array} The object, the uploads by property, the tokens. + * + * @throws FormSubmitRefusedException 422 for an unknown, expired or misdirected upload token. + */ + private function mapPayload(?array $mapping, array $payload, string $scope): array { + $pairs = []; + if ($mapping === null) { + foreach ($payload as $name => $value) { + $name = (string)$name; + if (str_starts_with($name, '_') === false && str_starts_with($name, '@') === false) { + $pairs[$name] = $value; + } + } + } + + foreach ((array)($mapping['fields'] ?? []) as $field) { + $name = (string)($field['field'] ?? ''); + $property = (string)($field['property'] ?? ''); + if ($property !== '' && array_key_exists($name, $payload) === true) { + $pairs[$property] = $payload[$name]; + } + } + + $object = []; + $files = []; + $tokens = []; + foreach ($pairs as $property => $value) { + $token = $this->tokenOf(value: $value); + if ($token === null) { + $object[$property] = $value; + continue; + } + + $file = $this->uploads->materialise(formId: $scope, token: $token); + if ($file['property'] !== $property) { + throw new FormSubmitRefusedException( + message: $this->l10n->t('An uploaded file was sent for another question. Please add it again.'), + status: 422, + findings: [['property' => $property, 'code' => 'upload-token-unknown', 'message' => $this->l10n->t('An uploaded file was sent for another question. Please add it again.')]] + ); + } + + unset($file['property']); + $files[$property] = $file; + $tokens[] = $token; + } + + $fixed = ($mapping['fixed'] ?? []); + if (is_array($fixed) === true) { + foreach ($fixed as $property => $value) { + $object[(string)$property] = $value; + } + } + + return [$object, $files, $tokens]; + }//end mapPayload() + + /** + * The upload token a payload value carries, or null. + * + * @param mixed $value The payload value. + * + * @return string|null The token. + */ + private function tokenOf(mixed $value): ?string { + if (is_array($value) === true && is_string($value['uploadToken'] ?? null) === true && count($value) === 1) { + return $value['uploadToken']; + } + + return null; + }//end tokenOf() + + /** + * Validate every write with its destination's full validator before the first write. + * + * @param array> $plans The plans. + * + * @return void + * + * @throws FormSubmitRefusedException 422 with every finding of every write. + */ + private function validateAll(array $plans): void { + $findings = []; + foreach ($plans as $plan) { + $write = null; + if ($plan['named'] === true) { + $write = $plan['as']; + } + + $object = $this->resolveWriteReferences(object: $plan['object'], ids: [], pending: true); + try { + $result = $this->validator->validateObject( + object: $object, + schema: $plan['schema'], + notSupplied: $this->serverFilled(schema: $plan['schema'], object: $object) + ); + array_push($findings, ...$this->findings->fromResult(result: $result, write: $write)); + } catch (CustomValidationException $exception) { + array_push($findings, ...$this->findings->fromThrowable(exception: $exception, write: $write)); + } + } + + if ($findings !== []) { + throw new FormSubmitRefusedException( + message: $this->l10n->t('The form was not accepted. Please check the answers marked below.'), + status: 422, + findings: $findings + ); + } + }//end validateAll() + + /** + * The properties the server fills, excused from `required` while the payload is judged. + * + * A property with a default, a computation or the serverSet marker is + * filled on save or by a creating listener, so a payload without it is + * complete. The build-time validator excuses the same set. + * + * @param Schema $schema The destination schema. + * @param array $object The mapped object. + * + * @return array Property => reason, for ValidateObject's notSupplied. + */ + private function serverFilled(Schema $schema, array $object): array { + $excused = []; + foreach ($schema->getProperties() as $name => $property) { + if (is_array($property) === false || array_key_exists((string)$name, $object) === true) { + continue; + } + + $filled = array_key_exists('default', $property) === true + || isset($property['computed']) === true + || FormDestinationValidator::marker(property: $property, name: FormDestinationValidator::MARKER_SERVER_SET); + if ($filled === true) { + $excused[(string)$name] = 'server-set'; + } + } + + return $excused; + }//end serverFilled() + + /** + * Write in order; on a refusal, delete what this submit already wrote, then refuse. + * + * @param array> $plans The plans. + * @param IUser|null $subject The subject. + * + * @return array, object: ObjectEntity}> The created objects, in order. + * + * @throws FormSubmitRefusedException 422, 403 or 503 after compensation. + */ + private function writeAll(array $plans, ?IUser $subject): array { + $created = []; + $ids = []; + foreach ($plans as $plan) { + try { + $entity = $this->objects->saveObject( + object: $this->resolveWriteReferences(object: $plan['object'], ids: $ids, pending: false), + register: $plan['register'], + schema: $plan['schema'], + _rbac: true, + _multitenancy: true, + uploadedFiles: ($plan['files'] === [] ? null : $plan['files']), + currentUser: $subject, + _unowned: $subject === null + ); + } catch (Throwable $exception) { + $this->compensate(created: $created, failedAt: $plan['as'], reason: $exception); + throw $this->refusalFor(exception: $exception, plan: $plan); + } + + $created[] = ['plan' => $plan, 'object' => $entity]; + $ids[$plan['as']] = (string)$entity->getUuid(); + } + + return $created; + }//end writeAll() + + /** + * Replace `{ "$write": "" }` values with that write's id (or a placeholder while validating). + * + * @param array $object The object. + * @param array $ids Ids of earlier writes, by `as`. + * @param bool $pending True while validating, before any write. + * + * @return array The object. + * + * @throws FormSubmitRefusedException 422 when a reference names no earlier write. + */ + private function resolveWriteReferences(array $object, array $ids, bool $pending): array { + foreach ($object as $property => $value) { + if (is_array($value) === false || count($value) !== 1 || is_string($value['$write'] ?? null) === false) { + continue; + } + + if ($pending === true) { + $object[$property] = self::PENDING_ID; + continue; + } + + if (isset($ids[$value['$write']]) === false) { + throw new FormSubmitRefusedException( + message: $this->l10n->t('Write "%1$s" is referenced before it is made.', [$value['$write']]), + status: 422, + findings: [['property' => (string)$property, 'code' => 'write-unknown', 'message' => $this->l10n->t('Write "%1$s" is referenced before it is made.', [$value['$write']])]] + ); + } + + $object[$property] = $ids[$value['$write']]; + } + + return $object; + }//end resolveWriteReferences() + + /** + * Delete the writes this submit made before a later one was refused, and record it. + * + * @param array, object: ObjectEntity}> $created The writes made. + * @param string $failedAt The write that was refused. + * @param Throwable $reason Why. + * + * @return void + */ + private function compensate(array $created, string $failedAt, Throwable $reason): void { + foreach (array_reverse($created) as $done) { + $uuid = (string)$done['object']->getUuid(); + try { + // Permanent: a submit that did not happen leaves no tombstone that + // holds a unique key against the resident's retry. The audit trail + // keeps both the create and this delete. + $this->objects->deleteObject( + uuid: $uuid, + register: $done['plan']['register'], + schema: $done['plan']['schema'], + _rbac: false, + _multitenancy: false, + permanent: true + ); + } catch (Throwable $exception) { + $this->logger->error( + message: '[FormSubmitService] Compensation failed: an earlier write of a refused submit could not be deleted', + context: ['uuid' => $uuid, 'failedAt' => $failedAt, 'exception' => $exception->getMessage()] + ); + continue; + } + + $this->logger->warning( + message: '[FormSubmitService] Compensated: deleted an earlier write of a refused submit', + context: ['uuid' => $uuid, 'write' => $done['plan']['as'], 'failedAt' => $failedAt, 'reason' => $reason->getMessage()] + ); + }//end foreach + }//end compensate() + + /** + * The refusal a failed write answers with. + * + * @param Throwable $exception The save's exception. + * @param array $plan The write that failed. + * + * @return FormSubmitRefusedException 422 for the payload, 403 for permission, 503 otherwise. + */ + private function refusalFor(Throwable $exception, array $plan): FormSubmitRefusedException { + if ($exception instanceof FormSubmitRefusedException) { + return $exception; + } + + $write = null; + if ($plan['named'] === true) { + $write = $plan['as']; + } + + if ($this->findings->isPayloadRefusal(exception: $exception) === true) { + return new FormSubmitRefusedException( + message: $this->l10n->t('The form was not accepted. Please check the answers marked below.'), + status: 422, + findings: $this->findings->fromThrowable(exception: $exception, write: $write) + ); + } + + if ($exception instanceof NotAuthorizedException) { + return new FormSubmitRefusedException( + message: $this->l10n->t('You may not send this form.'), + status: 403, + findings: [['property' => '', 'code' => 'not-allowed', 'message' => $this->l10n->t('You may not send this form.')]] + ); + } + + $this->logger->error( + message: '[FormSubmitService] A submit could not reach its destination', + context: ['schema' => $plan['schema']->getSlug(), 'exception' => $exception->getMessage()] + ); + + // Q3: refuse with "try again later"; the answers stay in the form. A + // queue would hand the resident a receipt for something that may never + // arrive, which is the defect decision 179 removes. + return new FormSubmitRefusedException( + message: $this->l10n->t('Your form could not be sent right now. Your answers are kept; please try again later.'), + status: 503 + ); + }//end refusalFor() + + /** + * The answer: the first write's reference, id, received moment and confirmation; every write listed. + * + * @param array, object: ObjectEntity}> $created The writes. + * + * @return array{reference: string, id: string, receivedAt: string|null, confirmation: array, objects: array>} The answer. + */ + private function answer(array $created): array { + $objects = []; + $first = null; + foreach ($created as $done) { + // Re-read: a creating listener has run by now, and a created listener + // that saves again is visible only on the stored row. + $schema = $done['plan']['schema']; + $entity = ($this->objects->find( + id: (string)$done['object']->getUuid(), + register: $done['plan']['register'], + schema: $schema, + _rbac: false, + _multitenancy: false + ) ?? $done['object']); + $data = $entity->getObject(); + $entry = [ + 'as' => $done['plan']['as'], + 'id' => (string)$entity->getUuid(), + 'reference' => $this->referenceOf(schema: $schema, data: $data, entity: $entity), + 'register' => $done['plan']['register']->getSlug(), + 'schema' => $schema->getSlug(), + ]; + $objects[] = $entry; + if ($first === null) { + $first = ['entry' => $entry, 'entity' => $entity, 'data' => $data, 'schema' => $schema]; + } + }//end foreach + + $confirmation = []; + foreach (FormDestinationValidator::markedProperties(schema: $first['schema'], name: FormDestinationValidator::MARKER_CONFIRMATION) as $property) { + if (array_key_exists($property, $first['data']) === true) { + $confirmation[$property] = $first['data'][$property]; + } + } + + $receivedAt = null; + $created = $first['entity']->getCreated(); + if ($created instanceof DateTimeInterface) { + $receivedAt = $created->format(DateTimeInterface::ATOM); + } + + return [ + 'reference' => $first['entry']['reference'], + 'id' => $first['entry']['id'], + 'receivedAt' => $receivedAt, + 'confirmation' => $confirmation, + 'objects' => $objects, + ]; + }//end answer() + + /** + * The human-readable reference: the property marked `reference`, else the uuid. + * + * @param Schema $schema The schema. + * @param array $data The stored object. + * @param ObjectEntity $entity The entity. + * + * @return string The reference. + */ + private function referenceOf(Schema $schema, array $data, ObjectEntity $entity): string { + foreach (FormDestinationValidator::markedProperties(schema: $schema, name: FormDestinationValidator::MARKER_REFERENCE) as $property) { + if (is_scalar($data[$property] ?? null) === true && (string)$data[$property] !== '') { + return (string)$data[$property]; + } + } + + return (string)$entity->getUuid(); + }//end referenceOf() + + /** + * The scope an idempotency key belongs to: the form, else the first destination. + * + * @param string $scope The form id. + * @param array> $writes The writes. + * + * @return string The scope. + */ + private function keyScope(string $scope, array $writes): string { + if ($scope !== '') { + return $scope; + } + + $first = ($writes[0] ?? []); + + return 'destination:' . (string)json_encode([$first['register'] ?? null, $first['schema'] ?? null]); + }//end keyScope() + + /** + * A destination's register. + * + * @param mixed $reference Id, uuid or slug. + * + * @return Register The register. + * + * @throws FormSubmitRefusedException 404 when it does not exist. + */ + private function register(mixed $reference): Register { + if ($reference instanceof Register) { + return $reference; + } + + try { + // Multitenancy and RBAC off: this resolves WHERE the form writes, + // fixed by its author. Whether the subject may write there is the + // save's question, asked under RBAC. + return $this->registers->find((string)$reference, _rbac: false, _multitenancy: false); + } catch (Throwable) { + throw new FormSubmitRefusedException(message: $this->l10n->t('The place this form sends to does not exist.'), status: 404); + } + }//end register() + + /** + * A destination's schema. + * + * @param mixed $reference Id, uuid or slug. + * + * @return Schema The schema. + * + * @throws FormSubmitRefusedException 404 when it does not exist. + */ + private function schema(mixed $reference): Schema { + if ($reference instanceof Schema) { + return $reference; + } + + try { + return $this->schemas->find((string)$reference, _multitenancy: false); + } catch (Throwable) { + throw new FormSubmitRefusedException(message: $this->l10n->t('The place this form sends to does not exist.'), status: 404); + } + }//end schema() +}//end class diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php new file mode 100644 index 0000000000..d131f4800b --- /dev/null +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -0,0 +1,555 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form; + +use DateTime; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Exception\FormSubmitRefusedException; +use OCA\OpenRegister\Exception\NotAuthorizedException; +use OCA\OpenRegister\Service\File\FilePropertyRules; +use OCA\OpenRegister\Service\Form\FormIdempotencyStore; +use OCA\OpenRegister\Service\Form\FormPayloadFindings; +use OCA\OpenRegister\Service\Form\FormSubmitService; +use OCA\OpenRegister\Service\Form\FormUploadStore; +use OCA\OpenRegister\Service\Object\ValidateObject; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IL10N; +use OCP\ITempManager; +use OCP\IUser; +use Opis\JsonSchema\ValidationResult; +use Opis\JsonSchema\Validator; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * The submit contract against real stores, a real opis validator and a fake creating listener. + * + * @covers \OCA\OpenRegister\Service\Form\FormSubmitService + * @uses \OCA\OpenRegister\Service\Form\FormPayloadFindings + * @uses \OCA\OpenRegister\Service\Form\FormIdempotencyStore + * @uses \OCA\OpenRegister\Service\Form\FormUploadStore + * @uses \OCA\OpenRegister\Service\Form\FormDestinationValidator + * @uses \OCA\OpenRegister\Service\File\FilePropertyRules + * @uses \OCA\OpenRegister\Exception\FormSubmitRefusedException + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Db\Register + * @uses \OCA\OpenRegister\Db\ObjectEntity + */ +class FormSubmitServiceTest extends TestCase { + + private ObjectService&MockObject $objects; + + private FormUploadStore $uploads; + + private FormSubmitService $service; + + /** + * The objects "stored", by uuid, after the fake creating listener ran. + * + * @var array + */ + private array $stored = []; + + /** + * Uuids deleted by compensation, in order. + * + * @var array + */ + private array $deleted = []; + + /** + * What each saveObject call received. + * + * @var array> + */ + private array $saves = []; + + /** + * Schemas by slug. + * + * @var array + */ + private array $schemaBySlug = []; + + /** + * A save to refuse, by schema slug, with the throwable to raise. + * + * @var array + */ + private array $refuseSave = []; + + /** + * @var array + */ + private array $temporary = []; + + protected function setUp(): void { + $this->schemaBySlug = [ + 'case' => $this->schema( + id: 1, + slug: 'case', + required: ['title', 'caseType', 'identifier'], + properties: [ + 'title' => ['type' => 'string', 'maxLength' => 200], + 'caseType' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'identifier' => ['type' => 'string', 'x-openregister' => ['serverSet' => true, 'reference' => true, 'confirmation' => true]], + 'termStartsAt' => ['type' => 'string', 'x-openregister' => ['serverSet' => true, 'confirmation' => true]], + 'internalNote' => ['type' => 'string', 'x-openregister' => ['serverSet' => true]], + 'bijlage' => ['type' => 'file', 'maxSize' => 1024], + ] + ), + 'organisation' => $this->schema(id: 2, slug: 'organisation', required: ['name'], properties: ['name' => ['type' => 'string'], 'kvk' => ['type' => 'string']]), + 'contact' => $this->schema( + id: 3, + slug: 'contact', + required: ['email', 'organisation'], + properties: ['email' => ['type' => 'string'], 'organisation' => ['type' => 'string', 'format' => 'uuid']] + ), + ]; + + $registers = $this->createMock(RegisterMapper::class); + $registers->method('find')->willReturnCallback( + static function (string|int $id): Register { + $register = new Register(); + $register->setId(10); + $register->setSlug((string)$id); + + return $register; + } + ); + + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturnCallback( + function (string|int $id): Schema { + if (isset($this->schemaBySlug[(string)$id]) === false) { + throw new DoesNotExistException('no schema ' . $id); + } + + return $this->schemaBySlug[(string)$id]; + } + ); + + $validateObject = $this->createMock(ValidateObject::class); + $validateObject->method('validateObject')->willReturnCallback($this->opisValidate(...)); + + $this->objects = $this->createMock(ObjectService::class); + $this->objects->method('saveObject')->willReturnCallback($this->fakeSave(...)); + $this->objects->method('find')->willReturnCallback(fn (string|int $id): ?ObjectEntity => ($this->stored[(string)$id] ?? null)); + $this->objects->method('deleteObject')->willReturnCallback( + function (string $uuid): bool { + $this->deleted[] = $uuid; + unset($this->stored[$uuid]); + + return true; + } + ); + + $time = $this->createMock(ITimeFactory::class); + $time->method('getTime')->willReturn(1_760_000_000); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static fn (string $text, array $parameters = []): string => $parameters === [] ? $text : vsprintf($text, $parameters) + ); + $temp = $this->createMock(ITempManager::class); + $temp->method('getTemporaryFile')->willReturnCallback( + function (): string { + $path = (string)tempnam(sys_get_temp_dir(), 'or-submit-test'); + $this->temporary[] = $path; + + return $path; + } + ); + + $appData = new MemoryAppData(); + $this->uploads = new FormUploadStore(appData: $appData, time: $time, temp: $temp, rules: new FilePropertyRules(), l10n: $l10n); + + $this->service = new FormSubmitService( + registers: $registers, + schemas: $schemas, + validator: $validateObject, + objects: $this->objects, + findings: new FormPayloadFindings(), + keys: new FormIdempotencyStore(appData: $appData, time: $time), + uploads: $this->uploads, + l10n: $l10n, + logger: $this->createMock(LoggerInterface::class) + ); + }//end setUp() + + protected function tearDown(): void { + foreach ($this->temporary as $path) { + if (is_file($path) === true) { + unlink($path); + } + } + }//end tearDown() + + /** + * A schema entity. + * + * @param int $id The id. + * @param string $slug The slug. + * @param array $required Required properties. + * @param array $properties Properties. + */ + private function schema(int $id, string $slug, array $required, array $properties): Schema { + $schema = new Schema(); + $schema->setId($id); + $schema->setSlug($slug); + $schema->setRequired($required); + $schema->setProperties($properties); + $schema->setHardValidation(false); + + return $schema; + }//end schema() + + /** + * A real opis validation of the object against the schema, honouring notSupplied excusals. + * + * @param array $object The object. + * @param Schema|int|string|null $schema The schema. + * @param object $schemaObject Unused. + * @param int $_depth Unused. + * @param array $notSupplied Excused properties. + */ + private function opisValidate(array $object, Schema|int|string|null $schema = null, object $schemaObject = new \stdClass(), int $_depth = 0, array $notSupplied = []): ValidationResult { + $this->assertInstanceOf(Schema::class, $schema); + $properties = []; + foreach ($schema->getProperties() as $name => $property) { + $property = array_diff_key($property, ['x-openregister' => true]); + if (($property['type'] ?? null) === 'file') { + $property = new \stdClass(); + } + + $properties[$name] = $property; + } + + $definition = [ + 'type' => 'object', + 'required' => array_values(array_diff($schema->getRequired(), array_keys($notSupplied))), + 'properties' => $properties, + ]; + $validator = new Validator(); + $validator->setMaxErrors(10); + + return $validator->validate(json_decode((string)json_encode($object)), json_decode((string)json_encode($definition))); + }//end opisValidate() + + /** + * The save, with a fake creating listener that stamps identifier and termStartsAt on a case. + * + * @param array|ObjectEntity $object The object. + */ + private function fakeSave( + array|ObjectEntity $object, + ?array $extend = [], + Register|string|int|null $register = null, + Schema|string|int|null $schema = null, + ?string $uuid = null, + bool $_rbac = true, + bool $_multitenancy = true, + bool $silent = false, + bool $_validation = true, + ?array $uploadedFiles = null, + ?IUser $currentUser = null, + bool $failIfExists = false, + bool $_unowned = false, + ): ObjectEntity { + $this->assertInstanceOf(Schema::class, $schema); + $this->saves[] = ['object' => $object, 'schema' => $schema->getSlug(), 'rbac' => $_rbac, 'user' => $currentUser, 'unowned' => $_unowned, 'files' => $uploadedFiles]; + if (isset($this->refuseSave[$schema->getSlug()]) === true) { + throw $this->refuseSave[$schema->getSlug()]; + } + + $data = (array)$object; + if ($schema->getSlug() === 'case') { + // The fake ObjectCreatingEvent listener. + $data['identifier'] = '2026-0412'; + $data['termStartsAt'] = '2026-10-13T09:00:00+02:00'; + $data['internalNote'] = 'not for the resident'; + } + + $entity = new ObjectEntity(); + $entity->setUuid('uuid-' . $schema->getSlug() . '-' . count($this->saves)); + $entity->setObject($data); + $entity->setCreated(new DateTime('2026-10-10T14:03:11+02:00')); + $this->stored[$entity->getUuid()] = $entity; + + return $entity; + }//end fakeSave() + + /** + * Spec scenario: the response carries the listener-computed fields, and only marked ones. + */ + public function testTheResponseCarriesTheListenerComputedFields(): void { + $answer = $this->service->submit( + destination: ['register' => 'dossiq', 'schema' => 'case'], + mapping: ['fields' => [['field' => 'onderwerp', 'property' => 'title']], 'fixed' => ['caseType' => 'ct-1']], + payload: ['onderwerp' => 'Kapvergunning', 'sneaky' => 'dropped'] + ); + + $this->assertSame('2026-0412', $answer['reference']); + $this->assertSame('uuid-case-1', $answer['id']); + $this->assertSame('2026-10-10T14:03:11+02:00', $answer['receivedAt']); + $this->assertSame(['identifier' => '2026-0412', 'termStartsAt' => '2026-10-13T09:00:00+02:00'], $answer['confirmation']); + // Control: a property the listener set WITHOUT the confirmation marker is not returned. + $this->assertArrayNotHasKey('internalNote', $answer['confirmation']); + // Only mapped fields and fixed values reach the object; the resident does not pick the scope. + $this->assertSame(['title' => 'Kapvergunning', 'caseType' => 'ct-1'], $this->saves[0]['object']); + }//end testTheResponseCarriesTheListenerComputedFields() + + /** + * An anonymous submit saves under RBAC with no owner; a signed-in subject saves as that user. + */ + public function testTheSubjectDecidesRbacAndOwnership(): void { + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b']); + $this->assertTrue($this->saves[0]['rbac']); + $this->assertTrue($this->saves[0]['unowned']); + $this->assertNull($this->saves[0]['user']); + + $user = $this->createMock(IUser::class); + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], subject: $user); + $this->assertTrue($this->saves[1]['rbac']); + $this->assertFalse($this->saves[1]['unowned']); + $this->assertSame($user, $this->saves[1]['user']); + }//end testTheSubjectDecidesRbacAndOwnership() + + /** + * Without a mapping the payload is the object, minus control and metadata keys. + */ + public function testWithoutAMappingThePayloadIsTheObject(): void { + $this->service->submit( + destination: ['register' => 'dossiq', 'schema' => 'case'], + mapping: null, + payload: ['title' => 'a', 'caseType' => 'b', '_rbac' => false, '@self' => ['owner' => 'x']] + ); + + $this->assertSame(['title' => 'a', 'caseType' => 'b'], $this->saves[0]['object']); + }//end testWithoutAMappingThePayloadIsTheObject() + + /** + * Spec scenario: a schema without hard validation is still validated, and nothing is created. + */ + public function testASchemaWithoutHardValidationIsStillValidated(): void { + $this->assertFalse($this->schemaBySlug['case']->getHardValidation()); + + try { + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a']); + $this->fail('A payload missing a required property was accepted.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(422, $refused->getStatus()); + $this->assertSame('caseType', $refused->getFindings()[0]['property']); + $this->assertSame('required', $refused->getFindings()[0]['code']); + } + + $this->assertSame([], $this->saves); + }//end testASchemaWithoutHardValidationIsStillValidated() + + /** + * Spec scenario: a repeated idempotency key repeats the answer and creates nothing. + */ + public function testARepeatedIdempotencyKeyRepeatsTheAnswer(): void { + $first = $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], idempotencyKey: 'k1', scope: 'form-1'); + $second = $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], idempotencyKey: 'k1', scope: 'form-1'); + + $this->assertSame($first, $second); + $this->assertCount(1, $this->saves); + + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], idempotencyKey: 'k2', scope: 'form-1'); + $this->assertCount(2, $this->saves); + }//end testARepeatedIdempotencyKeyRepeatsTheAnswer() + + /** + * Every write is validated before the first: an invalid second write means no write at all. + */ + public function testEveryWriteIsValidatedBeforeTheFirst(): void { + try { + $this->service->submitAll( + writes: $this->organisationThenContact(), + payload: ['naam' => 'Bakkerij De Korst'] + ); + $this->fail('An invalid second write was accepted.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(422, $refused->getStatus()); + $this->assertSame('contact', $refused->getFindings()[0]['write']); + $this->assertSame('email', $refused->getFindings()[0]['property']); + } + + $this->assertSame([], $this->saves); + }//end testEveryWriteIsValidatedBeforeTheFirst() + + /** + * A later write references an earlier write's id. + */ + public function testALaterWriteReferencesAnEarlierWritesId(): void { + $answer = $this->service->submitAll(writes: $this->organisationThenContact(), payload: ['naam' => 'De Korst', 'mail' => 'info@dekorst.nl']); + + $this->assertSame('uuid-organisation-1', $this->saves[1]['object']['organisation']); + $this->assertSame(['organisation', 'contact'], array_column($answer['objects'], 'as')); + $this->assertSame('uuid-organisation-1', $answer['id']); + }//end testALaterWriteReferencesAnEarlierWritesId() + + /** + * Spec scenario: a refused second write deletes the first, and the answer names the contact finding. + */ + public function testARefusedSecondWriteDeletesTheFirst(): void { + $this->refuseSave['contact'] = new CustomValidationException( + message: 'Fields are not unique: email', + errors: ['email' => 'The identifying fields (email) are not unique.'] + ); + + try { + $this->service->submitAll(writes: $this->organisationThenContact(), payload: ['naam' => 'De Korst', 'mail' => 'info@dekorst.nl']); + $this->fail('A refused second write was reported as success.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(422, $refused->getStatus()); + $this->assertSame(['property' => 'email', 'code' => 'unique', 'message' => 'The identifying fields (email) are not unique.', 'write' => 'contact'], $refused->getFindings()[0]); + } + + $this->assertSame(['uuid-organisation-1'], $this->deleted); + $this->assertArrayNotHasKey('uuid-organisation-1', $this->stored); + }//end testARefusedSecondWriteDeletesTheFirst() + + /** + * Q3: a destination that cannot be reached is a 503 "try again later", and nothing stays behind. + */ + public function testAnOutageIsATryAgainLaterAndLeavesNothing(): void { + $this->refuseSave['contact'] = new RuntimeException('SQLSTATE[HY000] [2002] Connection refused'); + + try { + $this->service->submitAll(writes: $this->organisationThenContact(), payload: ['naam' => 'De Korst', 'mail' => 'info@dekorst.nl']); + $this->fail('An outage was reported as success.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(503, $refused->getStatus()); + $this->assertStringNotContainsString('SQLSTATE', $refused->getMessage()); + } + + $this->assertSame(['uuid-organisation-1'], $this->deleted); + }//end testAnOutageIsATryAgainLaterAndLeavesNothing() + + /** + * A subject the destination does not let create is a 403. + */ + public function testARefusedPermissionIsA403(): void { + $this->refuseSave['case'] = new NotAuthorizedException(message: 'no create'); + + $this->expectException(FormSubmitRefusedException::class); + $this->expectExceptionCode(0); + try { + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b']); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(403, $refused->getStatus()); + throw $refused; + } + }//end testARefusedPermissionIsA403() + + /** + * A destination that does not exist is a 404, before anything is validated. + */ + public function testAnUnknownDestinationIsA404(): void { + try { + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'nope'], mapping: null, payload: []); + $this->fail('An unknown destination was accepted.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(404, $refused->getStatus()); + } + }//end testAnUnknownDestinationIsA404() + + /** + * An upload token lands on the object and is claimed; after a refusal it stays for the retry. + */ + public function testAnUploadTokenIsClaimedOnlyOnSuccess(): void { + $path = (string)tempnam(sys_get_temp_dir(), 'or-submit-upload'); + file_put_contents($path, '%PDF-1.7'); + $this->temporary[] = $path; + $token = $this->uploads->issue( + formId: 'form-1', + property: 'bijlage', + rule: $this->schemaBySlug['case']->getProperties()['bijlage'], + file: ['name' => 'a.pdf', 'type' => 'application/pdf', 'tmp_name' => $path, 'error' => UPLOAD_ERR_OK, 'size' => 8] + )['token']; + $mapping = ['fields' => [['field' => 'onderwerp', 'property' => 'title'], ['field' => 'bewijs', 'property' => 'bijlage']], 'fixed' => ['caseType' => 'ct']]; + + // Refused first: the token survives. + $this->refuseSave['case'] = new CustomValidationException(message: 'x', errors: ['title' => 'nope']); + try { + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: $mapping, payload: ['onderwerp' => 'a', 'bewijs' => ['uploadToken' => $token]], scope: 'form-1'); + $this->fail('Refused save accepted.'); + } catch (FormSubmitRefusedException) { + $this->assertSame('a.pdf', $this->uploads->materialise(formId: 'form-1', token: $token)['name']); + } + + unset($this->refuseSave['case']); + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: $mapping, payload: ['onderwerp' => 'a', 'bewijs' => ['uploadToken' => $token]], scope: 'form-1'); + + $files = $this->saves[1]['files']; + $this->assertSame('a.pdf', $files['bijlage']['name']); + $this->assertSame('%PDF-1.7', file_get_contents($files['bijlage']['tmp_name'])); + $this->assertArrayNotHasKey('bijlage', $this->saves[1]['object']); + + $this->expectException(FormSubmitRefusedException::class); + $this->uploads->materialise(formId: 'form-1', token: $token); + }//end testAnUploadTokenIsClaimedOnlyOnSuccess() + + /** + * A token issued for another property is refused, not attached elsewhere. + */ + public function testATokenForAnotherPropertyIsRefused(): void { + $path = (string)tempnam(sys_get_temp_dir(), 'or-submit-upload'); + file_put_contents($path, 'x'); + $this->temporary[] = $path; + $token = $this->uploads->issue(formId: 'form-1', property: 'bijlage', rule: ['type' => 'file'], file: ['name' => 'a', 'type' => 'text/plain', 'tmp_name' => $path, 'error' => 0, 'size' => 1])['token']; + + $this->expectException(FormSubmitRefusedException::class); + $this->service->submit( + destination: ['register' => 'dossiq', 'schema' => 'case'], + mapping: ['fields' => [['field' => 'x', 'property' => 'description']], 'fixed' => ['title' => 't', 'caseType' => 'c']], + payload: ['x' => ['uploadToken' => $token]], + scope: 'form-1' + ); + }//end testATokenForAnotherPropertyIsRefused() + + /** + * The two-write journey the spec's all-or-none scenario uses. + * + * @return array> + */ + private function organisationThenContact(): array { + return [ + [ + 'as' => 'organisation', + 'register' => 'crm', + 'schema' => 'organisation', + 'mapping' => ['fields' => [['field' => 'naam', 'property' => 'name']]], + ], + [ + 'as' => 'contact', + 'register' => 'crm', + 'schema' => 'contact', + 'mapping' => ['fields' => [['field' => 'mail', 'property' => 'email']], 'fixed' => ['organisation' => ['$write' => 'organisation']]], + ], + ]; + }//end organisationThenContact() +}//end class From b6af40da3f99bdb6385827a745d7bebe2df8838f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 17:14:26 +0200 Subject: [PATCH 038/183] feat(forms): POST /api/forms/{id}/submit, /uploads and /api/forms/validate A form stored in OpenRegister is submitted into its destination in one request (201 with reference, id, receivedAt, confirmation). Unknown, unpublished and destination-less forms answer one identical 404 and register a brute-force attempt; a filled honeypot answers 202 and stores nothing. Uploads return a token checked against the property's file rules. Authors validate a mapping while building a form; formDestination Mode decides whether findings block the save (report for one release). The hourly FormUploadPurgeJob purges expired tokens and keys, counted. Decision 179, ADR-117. --- appinfo/info.xml | 1 + appinfo/routes.php | 4 + lib/BackgroundJob/FormUploadPurgeJob.php | 89 +++++ lib/Controller/FormsController.php | 332 ++++++++++++++++++ lib/Service/Form/FormDefinitionResolver.php | 136 +++++++ lib/Service/Hardening/ThrottledSurfaces.php | 11 + .../BackgroundJob/FormUploadPurgeJobTest.php | 73 ++++ tests/Unit/Controller/FormsControllerTest.php | 324 +++++++++++++++++ .../Form/FormDefinitionResolverTest.php | 104 ++++++ 9 files changed, 1074 insertions(+) create mode 100644 lib/BackgroundJob/FormUploadPurgeJob.php create mode 100644 lib/Controller/FormsController.php create mode 100644 lib/Service/Form/FormDefinitionResolver.php create mode 100644 tests/Unit/BackgroundJob/FormUploadPurgeJobTest.php create mode 100644 tests/Unit/Controller/FormsControllerTest.php create mode 100644 tests/Unit/Service/Form/FormDefinitionResolverTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 611e320354..59b2f0874f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -131,6 +131,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\BackgroundJob\LogCleanUpTask + OCA\OpenRegister\BackgroundJob\FormUploadPurgeJob OCA\OpenRegister\BackgroundJob\RecomputeTimersForCalendarJob OCA\OpenRegister\BackgroundJob\StateHistoryRebuildJob OCA\OpenRegister\BackgroundJob\ViewAlertSweepJob diff --git a/appinfo/routes.php b/appinfo/routes.php index 4ea793dfea..affcfb36f8 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1193,6 +1193,10 @@ ['name' => 'objects#geocode', 'url' => '/api/geo/geocode', 'verb' => 'GET'], ['name' => 'objects#create', 'url' => '/api/objects/{register}/{schema}', 'verb' => 'POST'], + // Decision 179 (ADR-117): a form submits into its destination object. + ['name' => 'forms#validate', 'url' => '/api/forms/validate', 'verb' => 'POST'], + ['name' => 'forms#submit', 'url' => '/api/forms/{formId}/submit', 'verb' => 'POST'], + ['name' => 'forms#upload', 'url' => '/api/forms/{formId}/uploads', 'verb' => 'POST'], ['name' => 'objects#export', 'url' => '/api/objects/{register}/{schema}/export', 'verb' => 'GET'], // BEFORE objects#show, because `{id}` matches `[^/]+` and a route with // a longer path must be declared first or the generic one swallows it. diff --git a/lib/BackgroundJob/FormUploadPurgeJob.php b/lib/BackgroundJob/FormUploadPurgeJob.php new file mode 100644 index 0000000000..edab565d84 --- /dev/null +++ b/lib/BackgroundJob/FormUploadPurgeJob.php @@ -0,0 +1,89 @@ + + * 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/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\BackgroundJob; + +use OCA\OpenRegister\Service\Form\FormIdempotencyStore; +use OCA\OpenRegister\Service\Form\FormUploadStore; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Deletes what a submit never claimed, and says how many, a zero included. + * + * A zero in the log is a counted zero: it tells a purge that found nothing + * apart from a purge that never ran. A failure is logged and rethrown, so + * the job list does not record a run that did not happen. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ +class FormUploadPurgeJob extends TimedJob { + + /** + * Constructor. + * + * @param ITimeFactory $time The clock. + * @param FormUploadStore $uploads The upload tokens. + * @param FormIdempotencyStore $keys The remembered submit answers. + * @param LoggerInterface $logger Where the counts go. + */ + public function __construct( + ITimeFactory $time, + private readonly FormUploadStore $uploads, + private readonly FormIdempotencyStore $keys, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 3600); + }//end __construct() + + /** + * Purge both stores and log the counts. + * + * @param mixed $argument Unused. + * + * @return void + * + * @throws Throwable When a purge fails, after logging it. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) TimedJob's signature. + */ + protected function run(mixed $argument): void { + try { + $tokens = $this->uploads->purge(); + $keys = $this->keys->purge(); + } catch (Throwable $exception) { + $this->logger->error( + message: '[FormUploadPurgeJob] Purge failed', + context: ['exception' => $exception->getMessage()] + ); + throw $exception; + } + + $this->logger->info( + message: '[FormUploadPurgeJob] purged expired form uploads and submit keys', + context: ['uploadTokens' => $tokens, 'idempotencyKeys' => $keys] + ); + }//end run() +}//end class diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php new file mode 100644 index 0000000000..4ca7305779 --- /dev/null +++ b/lib/Controller/FormsController.php @@ -0,0 +1,332 @@ + + * 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/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\FormSubmitRefusedException; +use OCA\OpenRegister\Service\Form\FormDefinitionResolver; +use OCA\OpenRegister\Service\Form\FormDestinationValidator; +use OCA\OpenRegister\Service\Form\FormSubmitService; +use OCA\OpenRegister\Service\Form\FormUploadStore; +use OCA\OpenRegister\Service\Hardening\ThrottledSurfaces; +use OCP\AppFramework\Controller; +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\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\Security\Bruteforce\IThrottler; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Thin wrappers over the validator, the submit service and the upload store. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) One controller for the three form routes; + * each collaborator serves one of them. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ +class FormsController extends Controller { + + /** + * The app setting that decides whether findings block a form save. + * + * @var string + */ + public const MODE_SETTING = 'formDestinationMode'; + + /** + * Report mode: findings are shown, the save goes ahead (one release, Q9). + * + * @var string + */ + public const MODE_REPORT = 'report'; + + /** + * Refuse mode: a form with findings is not saved. + * + * @var string + */ + public const MODE_REFUSE = 'refuse'; + + /** + * The honeypot field: a person never fills it, a bot does. + * + * @var string + */ + public const HONEYPOT = '_hp'; + + /** + * Constructor. + * + * @param string $appName The app id. + * @param IRequest $request The request. + * @param FormDefinitionResolver $resolver Resolves a stored form. + * @param FormSubmitService $submitter Creates the destination. + * @param FormDestinationValidator $validator Judges a mapping. + * @param FormUploadStore $uploads Holds upload tokens. + * @param SchemaMapper $schemas Resolves a destination schema. + * @param IUserSession $userSession The signed-in subject, if any. + * @param IAppConfig $appConfig The report/refuse mode. + * @param IThrottler $throttler Counts guesses at form ids. + * @param IL10N $l10n Translations. + * @param LoggerInterface $logger Logs a throttler failure. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly FormDefinitionResolver $resolver, + private readonly FormSubmitService $submitter, + private readonly FormDestinationValidator $validator, + private readonly FormUploadStore $uploads, + private readonly SchemaMapper $schemas, + private readonly IUserSession $userSession, + private readonly IAppConfig $appConfig, + private readonly IThrottler $throttler, + private readonly IL10N $l10n, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * Judge a mapping against a destination schema, for an author building a form. + * + * Body: `{ mapping, destination: { register?, schema }, audience? }`. + * Answer: `{ mode, accepted, findings }`; `accepted` tells the caller + * whether to save the form under the current mode. + * + * @return JSONResponse 200 with the findings, 404 when the schema does not exist. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema + */ + #[NoAdminRequired] + public function validate(): JSONResponse { + $destination = $this->request->getParam('destination', []); + $mapping = $this->request->getParam('mapping', []); + $schemaRef = ''; + if (is_array($destination) === true) { + $schemaRef = trim((string)($destination['schema'] ?? '')); + } + + try { + $schema = $this->schemas->find($schemaRef, _multitenancy: false); + } catch (Throwable) { + return new JSONResponse(data: ['message' => $this->l10n->t('The destination schema does not exist.')], statusCode: 404); + } + + $options = []; + $audience = trim((string)$this->request->getParam('audience', '')); + if ($audience !== '') { + $options['audience'] = $audience; + } + + $findings = $this->validator->validate(mapping: (is_array($mapping) === true ? $mapping : []), schema: $schema, options: $options); + $mode = $this->mode(); + + return new JSONResponse( + data: [ + 'mode' => $mode, + 'accepted' => ($mode === self::MODE_REPORT || $findings === []), + 'findings' => $findings, + ] + ); + }//end validate() + + /** + * Submit a form stored in OpenRegister: create its destination in one request. + * + * @param string $formId The form object's uuid. + * + * @return JSONResponse 201 with reference, id, receivedAt and confirmation; 202 for a honeypot hit; + * 401, 403, 404, 422 or 503 with `{ message, findings }`. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 10, period: 60)] + #[UserRateLimit(limit: 60, period: 60)] + #[BruteForceProtection(action: ThrottledSurfaces::FORM_SUBMIT)] + public function submit(string $formId): JSONResponse { + // D7: the honeypot first. A hit stores nothing and tells the bot nothing. + if (trim((string)$this->request->getParam(self::HONEYPOT, '')) !== '') { + return new JSONResponse(data: [], statusCode: 202); + } + + try { + $form = $this->resolveForm(formId: $formId); + $subject = $this->userSession->getUser(); + if ($subject === null && $form['audience'] !== 'public') { + return new JSONResponse(data: ['message' => $this->l10n->t('Please sign in to send this form.'), 'findings' => []], statusCode: 401); + } + + $key = trim($this->request->getHeader('Idempotency-Key')); + $answer = $this->submitter->submitAll( + $form['writes'], + $this->payload(), + $subject, + ($key === '' ? null : $key), + $formId + ); + } catch (FormSubmitRefusedException $refused) { + return new JSONResponse(data: $refused->toBody(), statusCode: $refused->getStatus()); + } + + return new JSONResponse(data: $answer, statusCode: 201); + }//end submit() + + /** + * Hold one file for a later submit of this form, checked against the property's file rules. + * + * Multipart: `file` (the file) and `property` (the destination property). + * + * @param string $formId The form object's uuid. + * + * @return JSONResponse 201 `{ token, expiresAt }`; 404 or 422 with `{ message, findings }`. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 20, period: 60)] + #[UserRateLimit(limit: 120, period: 60)] + #[BruteForceProtection(action: ThrottledSurfaces::FORM_SUBMIT)] + public function upload(string $formId): JSONResponse { + $property = trim((string)$this->request->getParam('property', '')); + $file = $this->request->getUploadedFile('file'); + + try { + $form = $this->resolveForm(formId: $formId); + $rule = $this->ruleFor(writes: $form['writes'], property: $property); + $issued = $this->uploads->issue(formId: $formId, property: $property, rule: $rule, file: (is_array($file) === true ? $file : [])); + } catch (FormSubmitRefusedException $refused) { + return new JSONResponse(data: $refused->toBody(), statusCode: $refused->getStatus()); + } + + return new JSONResponse(data: $issued, statusCode: 201); + }//end upload() + + /** + * The stored form, registering a brute-force attempt when it does not resolve. + * + * @param string $formId The form id. + * + * @return array{id: string, writes: array>, audience: string} The form. + * + * @throws FormSubmitRefusedException 404. + */ + private function resolveForm(string $formId): array { + try { + return $this->resolver->resolve(formId: $formId); + } catch (FormSubmitRefusedException $refused) { + try { + $this->throttler->registerAttempt(ThrottledSurfaces::FORM_SUBMIT, $this->request->getRemoteAddress()); + } catch (Throwable $throttlerFailure) { + $this->logger->warning( + message: '[FormsController] registerAttempt failed', + context: ['exception' => $throttlerFailure->getMessage()] + ); + } + + throw $refused; + } + }//end resolveForm() + + /** + * The schema definition of a property on one of the form's destinations. + * + * @param array> $writes The form's writes. + * @param string $property The property. + * + * @return array The property definition. + * + * @throws FormSubmitRefusedException 422 when no destination has the property. + */ + private function ruleFor(array $writes, string $property): array { + foreach ($writes as $write) { + try { + $properties = $this->schemas->find((string)($write['schema'] ?? ''), _multitenancy: false)->getProperties(); + } catch (Throwable) { + continue; + } + + if ($property !== '' && is_array($properties[$property] ?? null) === true) { + return $properties[$property]; + } + } + + $message = $this->l10n->t('This form has no question that takes a file under that name.'); + throw new FormSubmitRefusedException( + message: $message, + status: 422, + findings: [['property' => $property, 'code' => 'property-unknown', 'message' => $message]] + ); + }//end ruleFor() + + /** + * The submitted answers: the request body minus the route id and `_` control keys. + * + * @return array The payload. + */ + private function payload(): array { + $payload = []; + foreach ($this->request->getParams() as $name => $value) { + $name = (string)$name; + if ($name === 'formId' || str_starts_with($name, '_') === true) { + continue; + } + + $payload[$name] = $value; + } + + return $payload; + }//end payload() + + /** + * The current mode: report (default, one release) or refuse. + * + * @return string The mode. + */ + private function mode(): string { + $mode = $this->appConfig->getValueString('openregister', self::MODE_SETTING, self::MODE_REPORT); + if ($mode !== self::MODE_REFUSE) { + return self::MODE_REPORT; + } + + return self::MODE_REFUSE; + }//end mode() +}//end class diff --git a/lib/Service/Form/FormDefinitionResolver.php b/lib/Service/Form/FormDefinitionResolver.php new file mode 100644 index 0000000000..a8d35ac7ec --- /dev/null +++ b/lib/Service/Form/FormDefinitionResolver.php @@ -0,0 +1,136 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Form + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Form; + +use OCA\OpenRegister\Exception\FormSubmitRefusedException; +use OCA\OpenRegister\Service\ObjectService; +use OCP\IL10N; +use Throwable; + +/** + * A published form's writes and audience, or one identical 404 for everything else. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ +class FormDefinitionResolver { + + /** + * Constructor. + * + * @param ObjectService $objects Reads the form object. + * @param IL10N $l10n Translations. + */ + public function __construct( + private readonly ObjectService $objects, + private readonly IL10N $l10n, + ) { + + }//end __construct() + + /** + * The form's id, writes and audience. + * + * Unknown, unpublished and destination-less forms answer the same 404, so + * the endpoint is not an oracle for which ids exist. + * + * @param string $formId The form object's uuid. + * + * @return array{id: string, writes: array>, audience: string} The form. + * + * @throws FormSubmitRefusedException 404. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + public function resolve(string $formId): array { + try { + // RBAC off to READ the form definition only: a public form is read + // by a visitor with no account. Nothing of the body is returned; + // what may be WRITTEN is decided by the destination's RBAC. + $entity = $this->objects->find(id: $formId, _rbac: false, _multitenancy: false); + } catch (Throwable) { + $entity = null; + } + + $body = []; + if ($entity !== null) { + $body = $entity->getObject(); + } + + $published = (($body['status'] ?? null) === 'published' || ($body['published'] ?? null) === true); + $writes = $this->writesOf(body: $body); + if ($published === false || $writes === []) { + throw new FormSubmitRefusedException(message: $this->l10n->t('This form does not exist.'), status: 404); + } + + $audience = (string)($body['audience'] ?? 'public'); + if ($audience === '') { + $audience = 'public'; + } + + return ['id' => $formId, 'writes' => $writes, 'audience' => $audience]; + }//end resolve() + + /** + * The writes a form body declares: its `writes` list, or one write from `destination` and `mapping`. + * + * @param array $body The form body. + * + * @return array> The writes; empty when none is declared. + */ + private function writesOf(array $body): array { + if (is_array($body['writes'] ?? null) === true && $body['writes'] !== []) { + return array_values(array_filter($body['writes'], 'is_array')); + } + + $destination = ($body['destination'] ?? null); + if (is_array($destination) === false || trim((string)($destination['schema'] ?? '')) === '') { + return []; + } + + $mapping = null; + if (is_array($body['mapping'] ?? null) === true) { + $mapping = $body['mapping']; + } + + return [ + [ + 'register' => ($destination['register'] ?? null), + 'schema' => $destination['schema'], + 'mapping' => $mapping, + 'as' => 'destination', + ], + ]; + }//end writesOf() +}//end class diff --git a/lib/Service/Hardening/ThrottledSurfaces.php b/lib/Service/Hardening/ThrottledSurfaces.php index fa0c4f98fa..a7fefb27aa 100644 --- a/lib/Service/Hardening/ThrottledSurfaces.php +++ b/lib/Service/Hardening/ThrottledSurfaces.php @@ -93,6 +93,16 @@ final class ThrottledSurfaces { */ public const ELEVATION = 'openregister_elevation'; + /** + * A form submit or upload into a destination object (decision 179). + * + * An unknown form id registers an attempt, so walking form ids to find + * one that accepts writes slows down like any other token guess. + * + * @var string + */ + public const FORM_SUBMIT = 'openregister_form_submit'; + /** * Every throttled surface, as `name => throttler action`. * @@ -106,6 +116,7 @@ final class ThrottledSurfaces { 'federationShareToken' => self::FEDERATION_SHARE_TOKEN, 'oauth2Callback' => self::OAUTH2_CALLBACK, 'elevation' => self::ELEVATION, + 'formSubmit' => self::FORM_SUBMIT, ]; /** diff --git a/tests/Unit/BackgroundJob/FormUploadPurgeJobTest.php b/tests/Unit/BackgroundJob/FormUploadPurgeJobTest.php new file mode 100644 index 0000000000..b0c445050b --- /dev/null +++ b/tests/Unit/BackgroundJob/FormUploadPurgeJobTest.php @@ -0,0 +1,73 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\BackgroundJob; + +use OCA\OpenRegister\BackgroundJob\FormUploadPurgeJob; +use OCA\OpenRegister\Service\Form\FormIdempotencyStore; +use OCA\OpenRegister\Service\Form\FormUploadStore; +use OCP\AppFramework\Utility\ITimeFactory; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; +use RuntimeException; + +/** + * Hourly, counted, and a failure surfaces instead of reading as success. + * + * @covers \OCA\OpenRegister\BackgroundJob\FormUploadPurgeJob + */ +class FormUploadPurgeJobTest extends TestCase { + + /** + * Run the protected run(). + */ + private function runJob(FormUploadPurgeJob $job): void { + $run = new ReflectionMethod($job, 'run'); + $run->invoke($job, null); + }//end runJob() + + /** + * Hourly, and both counts are logged, a zero included. + */ + public function testItRunsHourlyAndLogsBothCounts(): void { + $uploads = $this->createMock(FormUploadStore::class); + $uploads->expects($this->once())->method('purge')->willReturn(3); + $keys = $this->createMock(FormIdempotencyStore::class); + $keys->expects($this->once())->method('purge')->willReturn(0); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info')->with( + $this->stringContains('purged'), + $this->equalTo(['uploadTokens' => 3, 'idempotencyKeys' => 0]) + ); + + $job = new FormUploadPurgeJob(time: $this->createMock(ITimeFactory::class), uploads: $uploads, keys: $keys, logger: $logger); + $this->assertSame(3600, $job->getInterval()); + $this->runJob(job: $job); + }//end testItRunsHourlyAndLogsBothCounts() + + /** + * A failing purge is logged as an error and rethrown, so the job does not report success. + */ + public function testAFailingPurgeSurfaces(): void { + $uploads = $this->createMock(FormUploadStore::class); + $uploads->method('purge')->willThrowException(new RuntimeException('storage gone')); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $job = new FormUploadPurgeJob(time: $this->createMock(ITimeFactory::class), uploads: $uploads, keys: $this->createMock(FormIdempotencyStore::class), logger: $logger); + + $this->expectException(RuntimeException::class); + $this->runJob(job: $job); + }//end testAFailingPurgeSurfaces() +}//end class diff --git a/tests/Unit/Controller/FormsControllerTest.php b/tests/Unit/Controller/FormsControllerTest.php new file mode 100644 index 0000000000..585a8e1930 --- /dev/null +++ b/tests/Unit/Controller/FormsControllerTest.php @@ -0,0 +1,324 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use OCA\OpenRegister\Controller\FormsController; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\FormSubmitRefusedException; +use OCA\OpenRegister\Service\Form\FormDefinitionResolver; +use OCA\OpenRegister\Service\Form\FormDestinationValidator; +use OCA\OpenRegister\Service\Form\FormSubmitService; +use OCA\OpenRegister\Service\Form\FormUploadStore; +use OCA\OpenRegister\Service\Hardening\ThrottledSurfaces; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\IAppConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Security\Bruteforce\IThrottler; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * Status codes, bodies and the auth posture of each route. + * + * @covers \OCA\OpenRegister\Controller\FormsController + * @uses \OCA\OpenRegister\Exception\FormSubmitRefusedException + * @uses \OCA\OpenRegister\Db\Schema + */ +class FormsControllerTest extends TestCase { + + private IRequest&MockObject $request; + + private FormDefinitionResolver&MockObject $resolver; + + private FormSubmitService&MockObject $submitter; + + private FormDestinationValidator&MockObject $validator; + + private FormUploadStore&MockObject $uploads; + + private SchemaMapper&MockObject $schemas; + + private IUserSession&MockObject $session; + + private IAppConfig&MockObject $appConfig; + + private IThrottler&MockObject $throttler; + + private FormsController $controller; + + /** + * @var array + */ + private array $params = []; + + /** + * @var array + */ + private array $headers = []; + + protected function setUp(): void { + $this->request = $this->createMock(IRequest::class); + $this->request->method('getParams')->willReturnCallback(fn (): array => $this->params); + $this->request->method('getParam')->willReturnCallback(fn (string $key, mixed $default = null): mixed => ($this->params[$key] ?? $default)); + $this->request->method('getHeader')->willReturnCallback(fn (string $name): string => ($this->headers[$name] ?? '')); + $this->request->method('getRemoteAddress')->willReturn('192.0.2.7'); + + $this->resolver = $this->createMock(FormDefinitionResolver::class); + $this->submitter = $this->createMock(FormSubmitService::class); + $this->validator = $this->createMock(FormDestinationValidator::class); + $this->uploads = $this->createMock(FormUploadStore::class); + $this->schemas = $this->createMock(SchemaMapper::class); + $this->session = $this->createMock(IUserSession::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->throttler = $this->createMock(IThrottler::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $this->controller = new FormsController( + appName: 'openregister', + request: $this->request, + resolver: $this->resolver, + submitter: $this->submitter, + validator: $this->validator, + uploads: $this->uploads, + schemas: $this->schemas, + userSession: $this->session, + appConfig: $this->appConfig, + throttler: $this->throttler, + l10n: $l10n, + logger: $this->createMock(LoggerInterface::class) + ); + }//end setUp() + + /** + * A published public form. + * + * @return array + */ + private function form(string $audience = 'public'): array { + return [ + 'id' => 'form-1', + 'writes' => [['as' => 'destination', 'register' => 'dossiq', 'schema' => 'case', 'mapping' => null]], + 'audience' => $audience, + ]; + }//end form() + + /** + * The attribute names on a controller method. + * + * @return array + */ + private function attributes(string $method): array { + return array_map( + static fn (\ReflectionAttribute $attribute): string => $attribute->getName(), + (new ReflectionMethod(FormsController::class, $method))->getAttributes() + ); + }//end attributes() + + /** + * Submit and upload are public, throttled per ADR-082, and brute-force protected; validate is for signed-in authors. + */ + public function testEachRouteDeclaresItsPosture(): void { + foreach (['submit', 'upload'] as $method) { + $attributes = $this->attributes(method: $method); + $this->assertContains(PublicPage::class, $attributes, $method); + $this->assertContains(AnonRateLimit::class, $attributes, $method); + $this->assertContains(UserRateLimit::class, $attributes, $method); + $this->assertContains(BruteForceProtection::class, $attributes, $method); + } + + $this->assertContains(NoAdminRequired::class, $this->attributes(method: 'validate')); + $this->assertNotContains(PublicPage::class, $this->attributes(method: 'validate')); + $this->assertContains(ThrottledSurfaces::FORM_SUBMIT, ThrottledSurfaces::ALL); + }//end testEachRouteDeclaresItsPosture() + + /** + * A valid submit answers 201 with the service's answer; control keys and the route id never reach the payload. + */ + public function testASubmitAnswers201(): void { + $this->params = ['formId' => 'form-1', 'onderwerp' => 'Kapvergunning', '_route' => 'x']; + $this->headers = ['Idempotency-Key' => 'k1']; + $this->resolver->method('resolve')->with('form-1')->willReturn($this->form()); + $answer = ['reference' => '2026-0412', 'id' => 'u', 'receivedAt' => null, 'confirmation' => [], 'objects' => []]; + $this->submitter->expects($this->once())->method('submitAll') + ->with($this->form()['writes'], ['onderwerp' => 'Kapvergunning'], null, 'k1', 'form-1') + ->willReturn($answer); + + $response = $this->controller->submit(formId: 'form-1'); + + $this->assertSame(201, $response->getStatus()); + $this->assertSame($answer, $response->getData()); + }//end testASubmitAnswers201() + + /** + * A refusal answers with its own status and findings. + */ + public function testARefusalAnswersWithItsStatusAndFindings(): void { + $this->params = ['formId' => 'form-1']; + $this->resolver->method('resolve')->willReturn($this->form()); + $findings = [['property' => 'caseType', 'code' => 'required', 'message' => 'm']]; + $this->submitter->method('submitAll')->willThrowException(new FormSubmitRefusedException(message: 'Not accepted', status: 422, findings: $findings)); + + $response = $this->controller->submit(formId: 'form-1'); + + $this->assertSame(422, $response->getStatus()); + $this->assertSame(['message' => 'Not accepted', 'findings' => $findings], $response->getData()); + }//end testARefusalAnswersWithItsStatusAndFindings() + + /** + * An unknown form is a 404 and a registered brute-force attempt. + */ + public function testAnUnknownFormIsA404AndAnAttempt(): void { + $this->resolver->method('resolve')->willThrowException(new FormSubmitRefusedException(message: 'This form does not exist.', status: 404)); + $this->throttler->expects($this->once())->method('registerAttempt')->with(ThrottledSurfaces::FORM_SUBMIT, '192.0.2.7'); + $this->submitter->expects($this->never())->method('submitAll'); + + $this->assertSame(404, $this->controller->submit(formId: 'nope')->getStatus()); + }//end testAnUnknownFormIsA404AndAnAttempt() + + /** + * A filled honeypot answers 202 with no reference and stores nothing. + */ + public function testAFilledHoneypotStoresNothing(): void { + $this->params = ['formId' => 'form-1', '_hp' => 'http://spam.example']; + $this->submitter->expects($this->never())->method('submitAll'); + $this->resolver->expects($this->never())->method('resolve'); + + $response = $this->controller->submit(formId: 'form-1'); + + $this->assertSame(202, $response->getStatus()); + $this->assertSame([], $response->getData()); + }//end testAFilledHoneypotStoresNothing() + + /** + * An anonymous visitor cannot submit a form for signed-in people; a signed-in one passes as the subject. + */ + public function testTheAudienceIsHonoured(): void { + $this->params = ['formId' => 'form-1']; + $this->resolver->method('resolve')->willReturn($this->form(audience: 'authenticated')); + $this->submitter->expects($this->once())->method('submitAll')->willReturn(['reference' => 'r', 'id' => 'i', 'receivedAt' => null, 'confirmation' => [], 'objects' => []]); + + $this->assertSame(401, $this->controller->submit(formId: 'form-1')->getStatus()); + + $this->session->method('getUser')->willReturn($this->createMock(IUser::class)); + $this->assertSame(201, $this->controller->submit(formId: 'form-1')->getStatus()); + }//end testTheAudienceIsHonoured() + + /** + * Validate returns the findings and, in report mode, accepts the save anyway. + */ + public function testValidateInReportModeAcceptsWithFindings(): void { + $schema = new Schema(); + $this->params = ['mapping' => ['fields' => []], 'destination' => ['register' => 'dossiq', 'schema' => 'case'], 'audience' => 'public']; + $this->schemas->method('find')->with('case')->willReturn($schema); + $findings = [['property' => 'caseType', 'code' => 'required-unmapped', 'message' => 'm']]; + $this->validator->expects($this->once())->method('validate')->with(['fields' => []], $schema, ['audience' => 'public'])->willReturn($findings); + $this->appConfig->method('getValueString')->with('openregister', 'formDestinationMode', 'report')->willReturn('report'); + + $response = $this->controller->validate(); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame(['mode' => 'report', 'accepted' => true, 'findings' => $findings], $response->getData()); + }//end testValidateInReportModeAcceptsWithFindings() + + /** + * In refuse mode a mapping with findings is not accepted; one without is. + */ + public function testValidateInRefuseModeRefusesWithFindings(): void { + $this->params = ['mapping' => [], 'destination' => ['schema' => 'case']]; + $this->schemas->method('find')->willReturn(new Schema()); + $this->validator->method('validate')->willReturnOnConsecutiveCalls([['property' => 'x', 'code' => 'property-unknown', 'message' => 'm']], []); + $this->appConfig->method('getValueString')->willReturn('refuse'); + + $this->assertFalse($this->controller->validate()->getData()['accepted']); + $this->assertTrue($this->controller->validate()->getData()['accepted']); + }//end testValidateInRefuseModeRefusesWithFindings() + + /** + * Validate against a schema that does not exist is a 404. + */ + public function testValidateAgainstAnUnknownSchemaIsA404(): void { + $this->params = ['mapping' => [], 'destination' => ['schema' => 'nope']]; + $this->schemas->method('find')->willThrowException(new DoesNotExistException('nope')); + + $this->assertSame(404, $this->controller->validate()->getStatus()); + }//end testValidateAgainstAnUnknownSchemaIsA404() + + /** + * An upload is checked against the property of the form's destination and answers a token. + */ + public function testAnUploadAnswersAToken(): void { + $schema = new Schema(); + $schema->setProperties(['bijlage' => ['type' => 'file', 'maxSize' => 10]]); + $this->params = ['property' => 'bijlage']; + $file = ['name' => 'a.pdf', 'type' => 'application/pdf', 'tmp_name' => '/tmp/x', 'error' => 0, 'size' => 5]; + $this->request->method('getUploadedFile')->with('file')->willReturn($file); + $this->resolver->method('resolve')->willReturn($this->form()); + $this->schemas->method('find')->willReturn($schema); + $this->uploads->expects($this->once())->method('issue') + ->with('form-1', 'bijlage', ['type' => 'file', 'maxSize' => 10], $file) + ->willReturn(['token' => 'abc', 'expiresAt' => 1]); + + $response = $this->controller->upload(formId: 'form-1'); + + $this->assertSame(201, $response->getStatus()); + $this->assertSame(['token' => 'abc', 'expiresAt' => 1], $response->getData()); + }//end testAnUploadAnswersAToken() + + /** + * An upload for a property no destination of the form has is a 422, and no token. + */ + public function testAnUploadForAnUnknownPropertyIs422(): void { + $this->params = ['property' => 'nope']; + $this->request->method('getUploadedFile')->willReturn(['tmp_name' => '/tmp/x', 'error' => 0]); + $this->resolver->method('resolve')->willReturn($this->form()); + $this->schemas->method('find')->willReturn(new Schema()); + $this->uploads->expects($this->never())->method('issue'); + + $this->assertSame(422, $this->controller->upload(formId: 'form-1')->getStatus()); + }//end testAnUploadForAnUnknownPropertyIs422() + /** + * The three routes exist, with their verbs, and point at methods that exist. + */ + public function testTheRoutesAreRegistered(): void { + $routes = require __DIR__ . '/../../../appinfo/routes.php'; + $found = []; + foreach ($routes['routes'] as $route) { + if (str_starts_with((string)$route['name'], 'forms#') === true) { + $found[$route['name']] = [$route['url'], $route['verb']]; + $this->assertTrue(method_exists(FormsController::class, substr((string)$route['name'], 6)), (string)$route['name']); + } + } + + $this->assertSame( + [ + 'forms#validate' => ['/api/forms/validate', 'POST'], + 'forms#submit' => ['/api/forms/{formId}/submit', 'POST'], + 'forms#upload' => ['/api/forms/{formId}/uploads', 'POST'], + ], + $found + ); + }//end testTheRoutesAreRegistered() +}//end class diff --git a/tests/Unit/Service/Form/FormDefinitionResolverTest.php b/tests/Unit/Service/Form/FormDefinitionResolverTest.php new file mode 100644 index 0000000000..4d0ab95ff2 --- /dev/null +++ b/tests/Unit/Service/Form/FormDefinitionResolverTest.php @@ -0,0 +1,104 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Exception\FormSubmitRefusedException; +use OCA\OpenRegister\Service\Form\FormDefinitionResolver; +use OCA\OpenRegister\Service\ObjectService; +use OCP\IL10N; +use PHPUnit\Framework\TestCase; + +/** + * Published forms resolve; unknown, unpublished and destination-less ones answer the same 404. + * + * @covers \OCA\OpenRegister\Service\Form\FormDefinitionResolver + * @uses \OCA\OpenRegister\Exception\FormSubmitRefusedException + * @uses \OCA\OpenRegister\Db\ObjectEntity + */ +class FormDefinitionResolverTest extends TestCase { + + /** + * A resolver over one stored object (or none). + * + * @param array|null $body The stored form body. + */ + private function resolver(?array $body): FormDefinitionResolver { + $objects = $this->createMock(ObjectService::class); + $objects->method('find')->willReturnCallback( + static function () use ($body): ?ObjectEntity { + if ($body === null) { + return null; + } + + $entity = new ObjectEntity(); + $entity->setUuid('form-1'); + $entity->setObject($body); + + return $entity; + } + ); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + return new FormDefinitionResolver(objects: $objects, l10n: $l10n); + }//end resolver() + + /** + * A published form gives its destination, mapping and audience. + */ + public function testAPublishedFormResolves(): void { + $form = $this->resolver( + body: [ + 'status' => 'published', + 'destination' => ['register' => 'dossiq', 'schema' => 'case'], + 'mapping' => ['fields' => [['field' => 'a', 'property' => 'title']]], + 'audience' => 'authenticated', + ] + )->resolve(formId: 'form-1'); + + $this->assertSame('form-1', $form['id']); + $this->assertSame([['register' => 'dossiq', 'schema' => 'case', 'mapping' => ['fields' => [['field' => 'a', 'property' => 'title']]], 'as' => 'destination']], $form['writes']); + $this->assertSame('authenticated', $form['audience']); + }//end testAPublishedFormResolves() + + /** + * A journey step's writes list is taken as is; the audience defaults to public. + */ + public function testWritesAreTakenAsIs(): void { + $writes = [['as' => 'org', 'register' => 'crm', 'schema' => 'organisation'], ['as' => 'contact', 'register' => 'crm', 'schema' => 'contact']]; + $form = $this->resolver(body: ['published' => true, 'writes' => $writes])->resolve(formId: 'form-1'); + + $this->assertSame($writes, $form['writes']); + $this->assertSame('public', $form['audience']); + }//end testWritesAreTakenAsIs() + + /** + * Unknown, unpublished, and destination-less forms all answer one identical 404. + */ + public function testEverythingElseIsTheSame404(): void { + $messages = []; + foreach ([null, ['status' => 'draft', 'destination' => ['register' => 'r', 'schema' => 's']], ['status' => 'published']] as $body) { + try { + $this->resolver(body: $body)->resolve(formId: 'form-1'); + $this->fail('A form that should not resolve did.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(404, $refused->getStatus()); + $messages[] = $refused->toBody(); + } + } + + $this->assertCount(1, array_unique(array_map('serialize', $messages))); + }//end testEverythingElseIsTheSame404() +}//end class From 71d196d00ab74933ff9f54fa51ef28bd14b4df28 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 17:16:28 +0200 Subject: [PATCH 039/183] fix(objects): a refused create names each property, as update does ObjectsController::create answered a validation failure with a bare string. It now answers through handleValidationException, the same { status, message, errors[] } body and status update and patch use, so a form can mark the field the destination refused. Decision 179, ADR-117. --- lib/Controller/ObjectsController.php | 6 ++++-- tests/Unit/Controller/ObjectsControllerTest.php | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index a12d61651e..e3844e8a1d 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -3420,8 +3420,10 @@ public function create( // instead of being flattened by the generic validation handler. return new JSONResponse(data: $exception->toErrorBody(), statusCode: 400); } catch (ValidationException|CustomValidationException $exception) { - // Handle validation errors. - return new JSONResponse(data: $exception->getMessage(), statusCode: 400); + // Decision 179: refuse in the per-property shape update and patch + // use ({ status, message, errors[] }), not a bare string, so a form + // can mark the field the destination refused. + return $objectService->handleValidationException(exception: $exception); } catch (\OCA\OpenRegister\Exception\HookStoppedException $exception) { // Handle hook rejection — return 422 with validation errors from the workflow. return new JSONResponse( diff --git a/tests/Unit/Controller/ObjectsControllerTest.php b/tests/Unit/Controller/ObjectsControllerTest.php index ee99022f24..10456b4da8 100644 --- a/tests/Unit/Controller/ObjectsControllerTest.php +++ b/tests/Unit/Controller/ObjectsControllerTest.php @@ -1265,12 +1265,20 @@ public function testCreateReturns400OnValidationException(): void { $this->objectService->method('getRegister')->willReturn(1); $this->objectService->method('getSchema')->willReturn(2); // clearCreatedSubObjects is void — no willReturn needed - $this->objectService->method('saveObject') - ->willThrowException(new \OCA\OpenRegister\Exception\ValidationException('Invalid data')); + $exception = new \OCA\OpenRegister\Exception\ValidationException('Invalid data'); + $this->objectService->method('saveObject')->willThrowException($exception); + $shaped = new JSONResponse( + ['status' => 'error', 'message' => 'Validation failed', 'errors' => [['property' => 'title', 'message' => 'Invalid data', 'errors' => []]]], + 400 + ); + $this->objectService->expects($this->once())->method('handleValidationException')->with($exception)->willReturn($shaped); $result = $this->controller->create('1', '2', $this->objectService); + // Decision 179: create refuses in the per-property shape update uses, not a bare string. + $this->assertSame($shaped, $result); $this->assertSame(400, $result->getStatus()); + $this->assertSame('title', $result->getData()['errors'][0]['property']); } public function testCreateReturns422OnHookStoppedException(): void { @@ -2853,10 +2861,14 @@ public function testCreateReturns400OnCustomValidationException(): void { 'Custom validation failed', ['name' => 'Name is required'] )); + $this->objectService->expects($this->once())->method('handleValidationException') + ->willReturn(new JSONResponse(['status' => 'error', 'message' => 'Validation failed', 'errors' => [['name' => 'Name is required']]], 400)); $result = $this->controller->create('1', '2', $this->objectService); $this->assertSame(400, $result->getStatus()); + $this->assertIsArray($result->getData()); + $this->assertSame('Validation failed', $result->getData()['message']); } // ========================================================================= From c362ac48a601d80a79f0b9d283a47a7895ecd818 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 17:22:15 +0200 Subject: [PATCH 040/183] spec: draft lifecycle status and refuse-now for form-destination-validator (decisions 180, 181) A draft is the destination object with a stored metadata status draft: it may miss required properties but never fails a type, format or enum rule; leaving draft runs full validation and stamps receipt. journeyRun is replaced by draft objects. The destination check refuses from the first release. --- .../form-destination-validator/proposal.md | 8 +++-- .../specs/form-and-journey-registry/spec.md | 10 +++--- .../specs/form-destination/spec.md | 33 +++++++++++++++++++ .../form-destination-validator/tasks.md | 12 +++++-- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/openspec/changes/form-destination-validator/proposal.md b/openspec/changes/form-destination-validator/proposal.md index c11b8bcdb2..4fd975436a 100644 --- a/openspec/changes/form-destination-validator/proposal.md +++ b/openspec/changes/form-destination-validator/proposal.md @@ -5,7 +5,7 @@ depends_on: [] # Proposal: form-destination-validator -OpenRegister's half of decision 179 (Ruben, 10 October 2026): "We dont intake to an intake, we intake into a case, or ticket or something else." Cross-app change: `hydra/openspec/changes/form-submits-into-its-destination-object`, architecture in hydra ADR-117. Every other app change in that chain waits on this one. +OpenRegister's half of decision 179 (Ruben, 10 October 2026): "We dont intake to an intake, we intake into a case, or ticket or something else." Cross-app change: `hydra/openspec/changes/form-submits-into-its-destination-object`, architecture in hydra ADR-117. Ruben's answers: decisions 180 (drafts) and 181. Every other app change in that chain waits on this one. ## Why @@ -20,7 +20,9 @@ A form today is checked against its own field list. The destination schema judge - **Schema save checks dependent forms**: a schema change that invalidates a published form is refused or unpublishes the form, per the owning app's setting. - **Create errors match update errors**: `ObjectsController::create` returns the per-property shape on a validation failure. - **Two schema markers**: `x-openregister.confirmation` (returned to the submitter) and `x-openregister.serverSet` (filled by a listener, so not reported as unmapped). -- **`or-form-and-journey-registry` amended**: `journeyRun` no longer stages written ids; a step commits all or nothing. +- **Explicit lifecycle status `draft`** (decision 180): object metadata gains a stored `@self.status` with `draft` and `active`. A draft may miss required properties and is never type-invalid. Moving to `active` runs full validation and is the moment of receipt. Objects without the field keep today's date-deduced status. +- **`or-form-and-journey-registry` amended**: `journeyRun` is replaced by draft destination objects; a step commits all or nothing. +- **Refuse from the first release** (decision 181): no report-only mode. ## Out of scope @@ -28,4 +30,4 @@ Authoring screens (buildiq), the public host (portaliq), the case fields (dossiq ## Rollback -The validator ships in report mode for one release (findings returned, save allowed). The submit service is additive; callers switch to it in their own changes. +The submit service and the `draft` status are additive; callers switch to them in their own changes. The validator refuses from the first release (decision 181), so rolling back the check means reverting this change. diff --git a/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md b/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md index f58bc65ee9..641ede2505 100644 --- a/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md +++ b/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md @@ -5,15 +5,15 @@ ## ADDED Requirements -### Requirement: A journey run MUST store no written ids and MUST commit a step all or none +### Requirement: A saved journey MUST be its draft objects and MUST commit a step all or none -The API SHALL expose start, answer, resume and submit operations over a `journeyRun`. Answers MAY be persisted to the run only while ADR-117 approves drafts. The run SHALL NOT store ids of written objects. Objects SHALL be created only when a step declaring `writes[]` is submitted, through `FormSubmitService`, all or none, with a later entry able to reference an earlier entry's id. +There SHALL be no `journeyRun` object holding answers. A saved journey SHALL be its `writes[]` destination objects in status `draft` (decision 180). Objects SHALL leave `draft`, or be created, only when a step declaring `writes[]` is submitted, through `FormSubmitService`, all or none, with a later entry able to reference an earlier entry's id. -#### Scenario: Advancing without a writes step creates nothing +#### Scenario: Advancing without a writes step activates nothing -- **GIVEN** a run advanced past two steps, neither declaring `writes[]` +- **GIVEN** a journey advanced past two steps and saved, neither step declaring `writes[]` as submitted - **WHEN** the target registers are queried -- **THEN** no object has been created +- **THEN** any object for this journey is in status `draft`, and no `journeyRun` object exists #### Scenario: A partial failure leaves nothing behind diff --git a/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md index 34adff4102..2fa4dd0a36 100644 --- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md +++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md @@ -89,3 +89,36 @@ When a schema is saved, the validator SHALL run for every published form whose d - **GIVEN** an object payload missing a required property - **WHEN** it is sent to create and, for an existing object, to update - **THEN** both answers carry the property in `errors[]` with the same status + +### Requirement: An object MUST be able to carry the explicit lifecycle status `draft` + +Object metadata SHALL carry a stored `@self.status` with at least the values `draft` and `active` (decision 180). An object without the field SHALL keep its status deduced from its dates, as today. Saving an object in `draft` SHALL run every schema rule except `required`. Moving an object from `draft` to `active` SHALL go through `FormSubmitService`, SHALL run full validation, and SHALL fire the create-time effects that count as receipt (reference, `receivedAt`, listeners marked as receipt listeners). A form's draft save SHALL create or update its destination object in `draft`. + +#### Scenario: A draft without required data is saved +- **GIVEN** schema `case` requiring `description` +- **WHEN** a form saves a draft with no `description` +- **THEN** the object exists with `@self.status` `draft` and no `receivedAt` + +#### Scenario: A type-invalid draft is refused +- **GIVEN** a property `preferredDate` of format date-time +- **WHEN** a draft is saved with `preferredDate` holding a phone number +- **THEN** the save is refused with a `format-mismatch` finding and nothing is stored + +#### Scenario: Leaving draft runs full validation and stamps receipt +- **GIVEN** a draft that now holds every required property +- **WHEN** it is submitted +- **THEN** `@self.status` becomes `active`, `receivedAt` is set, and the response carries the reference and confirmation fields + +#### Scenario: An object without the field reads as today +- **GIVEN** an object created before this change, with no stored status +- **WHEN** it is read +- **THEN** its status is deduced from its dates exactly as before + +### Requirement: The form destination check MUST refuse from the first release + +Form save, journey save and schema save SHALL refuse on a finding from the first release that ships the validator. There SHALL be no report-only mode (decision 181). + +#### Scenario: A form with one finding is not saved +- **GIVEN** a form with one `required-unmapped` finding +- **WHEN** it is saved +- **THEN** the save is refused and the finding is returned diff --git a/openspec/changes/form-destination-validator/tasks.md b/openspec/changes/form-destination-validator/tasks.md index 46930ad393..ee9170efe0 100644 --- a/openspec/changes/form-destination-validator/tasks.md +++ b/openspec/changes/form-destination-validator/tasks.md @@ -6,7 +6,7 @@ - Spec ref: specs/form-destination/spec.md, "OpenRegister MUST judge a form's mapping against its destination schema" - Test: `tests/Unit/Service/Form/FormDestinationValidatorTest.php`, one case per code plus a zero-findings control - [ ] 1.2 Read `x-openregister.serverSet` and `x-openregister.confirmation` in the schema property dialect; document both in the schema standards (ADR-011 table) -- [ ] 1.3 `POST /api/forms/validate` (authenticated, body: mapping and destination) so buildiq and portaliq can show findings while authoring; report mode setting `formDestinationMode` (`report` for one release, then `refuse`) +- [ ] 1.3 `POST /api/forms/validate` (authenticated, body: mapping and destination) so buildiq and portaliq can show findings while authoring; refuses from the first release, no report mode (decision 181) ## 2. Submit @@ -17,6 +17,14 @@ - [ ] 2.4 `POST /api/forms/{formId}/submit` route, `#[PublicPage]`, ADR-082 attributes and `registerAttempt`; routes.php entry - [ ] 2.5 `ObjectsController::create` answers 422 in the update error shape +## 2b. Lifecycle status `draft` (decision 180) + +- [ ] 2b.1 Stored `@self.status` in object metadata (`draft`, `active`); objects without it keep the date-deduced status + - Spec ref: specs/form-destination/spec.md, "An object MUST be able to carry the explicit lifecycle status `draft`" + - Test: unit tests for each scenario, including the legacy read control +- [ ] 2b.2 Draft validation skips `required` only; the draft-to-active move goes through `FormSubmitService` with full validation and receipt effects +- [ ] 2b.3 Flagged to Ruben, not built here: staff list default for drafts, draft expiry + ## 3. Uploads - [ ] 3.1 Upload token endpoint and claim in submit @@ -28,7 +36,7 @@ ## 5. Journey registry -- [ ] 5.1 Apply the MODIFIED requirement to `or-form-and-journey-registry` (no written ids on `journeyRun`; commit through `FormSubmitService`) +- [ ] 5.1 Apply the MODIFIED requirement to `or-form-and-journey-registry` (`journeyRun` replaced by draft destination objects; commit through `FormSubmitService`) ## 6. Verification From 7d2d49d2fa0c4e67c54f200e176dc67b63474781 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 17:23:21 +0200 Subject: [PATCH 041/183] feat(schemas): saving a schema re-checks the published forms that submit into it Before a schema save, FormDestinationDependentsEvent asks the owning apps for their published forms into it, and each is judged against the proposed definition. An owning app with formDestinationBreak=refuse blocks the save (409, forms listed); by default the broken forms are unpublished after the save, their authors notified (form_unpublished), and the response lists them. No report-only release (Q9, Ruben). Decision 179, ADR-117. --- lib/Controller/FormsController.php | 51 +--- lib/Controller/SchemasController.php | 72 ++++++ lib/Event/FormDestinationDependentsEvent.php | 83 +++++++ lib/Notification/Notifier.php | 32 +++ lib/Service/Form/FormDependent.php | 59 +++++ lib/Service/Form/FormDependentsCheck.php | 180 ++++++++++++++ tests/Unit/Controller/FormsControllerTest.php | 38 +-- .../Unit/Controller/SchemasControllerTest.php | 54 +++++ tests/Unit/Notification/NotifierTest.php | 12 + .../Service/Form/FormDependentsCheckTest.php | 228 ++++++++++++++++++ 10 files changed, 733 insertions(+), 76 deletions(-) create mode 100644 lib/Event/FormDestinationDependentsEvent.php create mode 100644 lib/Service/Form/FormDependent.php create mode 100644 lib/Service/Form/FormDependentsCheck.php create mode 100644 tests/Unit/Service/Form/FormDependentsCheckTest.php diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php index 4ca7305779..ec8f202fac 100644 --- a/lib/Controller/FormsController.php +++ b/lib/Controller/FormsController.php @@ -43,7 +43,6 @@ use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\JSONResponse; -use OCP\IAppConfig; use OCP\IL10N; use OCP\IRequest; use OCP\IUserSession; @@ -61,27 +60,6 @@ */ class FormsController extends Controller { - /** - * The app setting that decides whether findings block a form save. - * - * @var string - */ - public const MODE_SETTING = 'formDestinationMode'; - - /** - * Report mode: findings are shown, the save goes ahead (one release, Q9). - * - * @var string - */ - public const MODE_REPORT = 'report'; - - /** - * Refuse mode: a form with findings is not saved. - * - * @var string - */ - public const MODE_REFUSE = 'refuse'; - /** * The honeypot field: a person never fills it, a bot does. * @@ -100,7 +78,6 @@ class FormsController extends Controller { * @param FormUploadStore $uploads Holds upload tokens. * @param SchemaMapper $schemas Resolves a destination schema. * @param IUserSession $userSession The signed-in subject, if any. - * @param IAppConfig $appConfig The report/refuse mode. * @param IThrottler $throttler Counts guesses at form ids. * @param IL10N $l10n Translations. * @param LoggerInterface $logger Logs a throttler failure. @@ -114,7 +91,6 @@ public function __construct( private readonly FormUploadStore $uploads, private readonly SchemaMapper $schemas, private readonly IUserSession $userSession, - private readonly IAppConfig $appConfig, private readonly IThrottler $throttler, private readonly IL10N $l10n, private readonly LoggerInterface $logger, @@ -126,8 +102,8 @@ public function __construct( * Judge a mapping against a destination schema, for an author building a form. * * Body: `{ mapping, destination: { register?, schema }, audience? }`. - * Answer: `{ mode, accepted, findings }`; `accepted` tells the caller - * whether to save the form under the current mode. + * Answer: `{ accepted, findings }`. A form with findings is not saved: + * the check refuses from day one (Q9, Ruben, 10 October 2026). * * @return JSONResponse 200 with the findings, 404 when the schema does not exist. * @@ -155,15 +131,8 @@ public function validate(): JSONResponse { } $findings = $this->validator->validate(mapping: (is_array($mapping) === true ? $mapping : []), schema: $schema, options: $options); - $mode = $this->mode(); - return new JSONResponse( - data: [ - 'mode' => $mode, - 'accepted' => ($mode === self::MODE_REPORT || $findings === []), - 'findings' => $findings, - ] - ); + return new JSONResponse(data: ['accepted' => ($findings === []), 'findings' => $findings]); }//end validate() /** @@ -315,18 +284,4 @@ private function payload(): array { return $payload; }//end payload() - - /** - * The current mode: report (default, one release) or refuse. - * - * @return string The mode. - */ - private function mode(): string { - $mode = $this->appConfig->getValueString('openregister', self::MODE_SETTING, self::MODE_REPORT); - if ($mode !== self::MODE_REFUSE) { - return self::MODE_REPORT; - } - - return self::MODE_REFUSE; - }//end mode() }//end class diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php index be73e68668..d55c8fb36e 100644 --- a/lib/Controller/SchemasController.php +++ b/lib/Controller/SchemasController.php @@ -44,6 +44,7 @@ use OCA\OpenRegister\Service\Rbac\ExternalGrantGuard; use OCA\OpenRegister\Service\Calculation\CalculationDeclarationException; use OCA\OpenRegister\Service\Consent\ConsentDeclarationException; +use OCA\OpenRegister\Service\Form\FormDependentsCheck; use OCA\OpenRegister\Service\Hinge\ListPresentationResolver; use OCA\OpenRegister\Service\BulkJob\ReversibilityDeclarationException; use OCA\OpenRegister\Service\Relation\RelationDeclarationException; @@ -1088,6 +1089,53 @@ public function update(int $id): JSONResponse { return $this->applyUpdate(id: $id, data: $data); }//end update() + /** + * The dependent-form assessment of an update that changes properties or required. + * + * @param Schema $existing The stored schema. + * @param array $data The update body. + * + * @return array{refused: bool, schema: string, affected: array>}|null The assessment, or null when the definition is unchanged. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + private function assessDependentForms(Schema $existing, array $data): ?array { + $check = $this->formDependentsCheck(); + if ($check === null || (isset($data['properties']) === false && isset($data['required']) === false)) { + return null; + } + + $proposed = clone $existing; + if (is_array($data['properties'] ?? null) === true) { + $proposed->setProperties($data['properties']); + } + + if (is_array($data['required'] ?? null) === true) { + $proposed->setRequired($data['required']); + } + + return $check->assess(schema: $proposed); + }//end assessDependentForms() + + /** + * The dependent-form check, resolved lazily like the audit service. + * + * @return FormDependentsCheck|null The check, or null when it cannot be built. + */ + private function formDependentsCheck(): ?FormDependentsCheck { + try { + $check = $this->container->get(FormDependentsCheck::class); + } catch (\Throwable) { + return null; + } + + if ($check instanceof FormDependentsCheck) { + return $check; + } + + return null; + }//end formDependentsCheck() + /** * Apply an update body to a schema: permission, validation, versioning gate, write, changelog. * @@ -1216,6 +1264,21 @@ private function applyUpdate(int $id, array $data): JSONResponse { } } + // Decision 179 (ADR-117 D2): judge the published forms that submit + // into this schema against what it is about to become. An owning app + // set to `refuse` blocks the save; otherwise the broken forms are + // unpublished after the save, and the response lists them. + $formAssessment = $this->assessDependentForms(existing: $existingSchema, data: $data); + if ($formAssessment !== null && $formAssessment['refused'] === true) { + return new JSONResponse( + data: [ + 'error' => 'This change would break published forms that submit into this schema. Their owning app refuses the change.', + 'affectedForms' => $this->formDependentsCheck()?->apply(assessment: $formAssessment) ?? [], + ], + statusCode: 409 + ); + } + try { // Update the schema with the provided data. $updatedSchema = $this->schemaMapper->updateFromArray(id: $id, object: $data); @@ -1256,6 +1319,15 @@ private function applyUpdate(int $id, array $data): JSONResponse { operation: 'update' ); + if ($formAssessment !== null && $formAssessment['affected'] !== []) { + return new JSONResponse( + data: array_merge( + $updatedSchema->jsonSerialize(), + ['affectedForms' => $this->formDependentsCheck()?->apply(assessment: $formAssessment) ?? []] + ) + ); + } + return new JSONResponse(data: $updatedSchema); } catch (UniqueHintException $e) { // A nomination naming a property the schema does not declare is the diff --git a/lib/Event/FormDestinationDependentsEvent.php b/lib/Event/FormDestinationDependentsEvent.php new file mode 100644 index 0000000000..90f951e613 --- /dev/null +++ b/lib/Event/FormDestinationDependentsEvent.php @@ -0,0 +1,83 @@ + + * 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/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\Form\FormDependent; +use OCP\EventDispatcher\Event; + +/** + * Dispatched before a schema save; a listener adds each published form of its app that writes into it. + * + * The schema carried is the PROPOSED definition, so the forms are judged + * against what the schema is about to become. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ +class FormDestinationDependentsEvent extends Event { + + /** + * The forms announced so far. + * + * @var array + */ + private array $forms = []; + + /** + * Constructor. + * + * @param Schema $schema The proposed schema definition. + */ + public function __construct(private readonly Schema $schema) { + parent::__construct(); + }//end __construct() + + /** + * The proposed schema. + * + * @return Schema The schema. + */ + public function getSchema(): Schema { + return $this->schema; + }//end getSchema() + + /** + * Announce a published form that submits into this schema. + * + * @param FormDependent $form The form. + * + * @return void + */ + public function addForm(FormDependent $form): void { + $this->forms[] = $form; + }//end addForm() + + /** + * Every announced form. + * + * @return array The forms. + */ + public function getForms(): array { + return $this->forms; + }//end getForms() +}//end class diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 57504fb7ad..2328542739 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -230,6 +230,7 @@ public function prepare(INotification $notification, string $languageCode): INot 'object_ownership_changed' => $this->prepareOwnershipChanged(...), 'security_setting_changed' => $this->prepareSecuritySettingChanged(...), 'view_alert_crossed' => $this->prepareViewAlertCrossed(...), + 'form_unpublished' => $this->prepareFormUnpublished(...), default => null, }; @@ -926,4 +927,35 @@ private function prepareViewAlertCrossed(INotification $notification, $l): INoti return $notification; }//end prepareViewAlertCrossed() + + /** + * Render "your form was taken offline" for the author of a form a schema change broke. + * + * WITHOUT THIS CASE THE NOTIFICATION NEVER RENDERS: an unknown subject + * throws out of prepare(), so the author would learn of the unpublish only + * when a resident reports the form gone. + * + * @param INotification $notification The notification to prepare + * @param mixed $l The localization instance + * + * @return INotification The prepared notification + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + private function prepareFormUnpublished(INotification $notification, $l): INotification { + $parameters = $notification->getSubjectParameters(); + $title = (string)($parameters['formTitle'] ?? ''); + $schema = (string)($parameters['schema'] ?? ''); + $count = (string)($parameters['count'] ?? ''); + + $notification->setParsedSubject($l->t('Your form "%1$s" was taken offline', [$title])); + $notification->setParsedMessage( + $l->t('A change to "%1$s" left %2$s problems in the form. Fix them and publish the form again.', [$schema, $count]) + ); + $notification->setIcon( + $this->urlGenerator->imagePath(appName: 'openregister', file: 'app.svg') + ); + + return $notification; + }//end prepareFormUnpublished() }//end class diff --git a/lib/Service/Form/FormDependent.php b/lib/Service/Form/FormDependent.php new file mode 100644 index 0000000000..13a53153b9 --- /dev/null +++ b/lib/Service/Form/FormDependent.php @@ -0,0 +1,59 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Form + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Form; + +use Closure; + +/** + * What the schema-save check needs of a form: its mapping, its author and how to unpublish it. + * + * The form lives in its owning app (buildiq, portaliq, pipelinq, ...), so + * the owning app unpublishes it; OpenRegister only calls the closure. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ +final class FormDependent { + + /** + * Constructor. + * + * @param string $id The form's id in its owning app. + * @param string $app The owning app's id; its `formDestinationBreak` setting decides. + * @param string $title The form's title, for the response and the notification. + * @param string|null $author The user id to notify, when known. + * @param array $mapping The form's mapping into the schema. + * @param Closure|null $unpublish Unpublishes the form in its owning app. + * @param string|null $audience `public` or `authenticated`, when the form is not internal. + */ + public function __construct( + public readonly string $id, + public readonly string $app, + public readonly string $title, + public readonly ?string $author, + public readonly array $mapping, + public readonly ?Closure $unpublish = null, + public readonly ?string $audience = null, + ) { + + }//end __construct() +}//end class diff --git a/lib/Service/Form/FormDependentsCheck.php b/lib/Service/Form/FormDependentsCheck.php new file mode 100644 index 0000000000..0739b47444 --- /dev/null +++ b/lib/Service/Form/FormDependentsCheck.php @@ -0,0 +1,180 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Form + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Form; + +use DateTime; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Event\FormDestinationDependentsEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\Notification\IManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Assess the dependent forms of a proposed schema, then apply the outcome. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ +class FormDependentsCheck { + + /** + * The owning app's setting: `unpublish` (default) or `refuse`. + * + * @var string + */ + public const BREAK_SETTING = 'formDestinationBreak'; + + /** + * The notification subject an author receives. + * + * @var string + */ + public const SUBJECT = 'form_unpublished'; + + /** + * Constructor. + * + * @param IEventDispatcher $dispatcher Asks the owning apps for their forms. + * @param FormDestinationValidator $validator Judges each form. + * @param IAppConfig $appConfig Reads each owning app's break setting. + * @param IManager $notifications Tells an author their form was unpublished. + * @param LoggerInterface $logger Records an unpublish that failed. + */ + public function __construct( + private readonly IEventDispatcher $dispatcher, + private readonly FormDestinationValidator $validator, + private readonly IAppConfig $appConfig, + private readonly IManager $notifications, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Judge every published form that submits into the proposed schema. + * + * @param Schema $schema The proposed schema definition. + * + * @return array{refused: bool, schema: string, affected: array>, form: FormDependent}>} The assessment. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + public function assess(Schema $schema): array { + $event = new FormDestinationDependentsEvent(schema: $schema); + $this->dispatcher->dispatchTyped($event); + + $affected = []; + $refused = false; + foreach ($event->getForms() as $form) { + $options = []; + if ($form->audience !== null) { + $options['audience'] = $form->audience; + } + + $findings = $this->validator->validate(mapping: $form->mapping, schema: $schema, options: $options); + if ($findings === []) { + continue; + } + + $break = $this->appConfig->getValueString($form->app, self::BREAK_SETTING, 'unpublish'); + if ($break !== 'refuse') { + $break = 'unpublish'; + } + + $refused = ($refused || $break === 'refuse'); + $affected[] = ['id' => $form->id, 'app' => $form->app, 'title' => $form->title, 'break' => $break, 'findings' => $findings, 'form' => $form]; + } + + return ['refused' => $refused, 'schema' => (string)$schema->getSlug(), 'affected' => $affected]; + }//end assess() + + /** + * After the save: unpublish each broken form and tell its author. + * + * @param array{refused: bool, schema: string, affected: array>} $assessment The assessment of the saved schema. + * + * @return array>}> Each affected form with its outcome, for the save response. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + public function apply(array $assessment): array { + $listed = []; + foreach ($assessment['affected'] as $entry) { + $outcome = 'refused'; + if ($assessment['refused'] === false) { + $outcome = $this->unpublish(form: $entry['form'], schema: $assessment['schema'], findings: $entry['findings']); + } + + $listed[] = ['id' => $entry['id'], 'app' => $entry['app'], 'title' => $entry['title'], 'outcome' => $outcome, 'findings' => $entry['findings']]; + } + + return $listed; + }//end apply() + + /** + * Unpublish one form through its owning app, and notify its author. + * + * @param FormDependent $form The form. + * @param string $schema The schema slug. + * @param array> $findings Why. + * + * @return string `unpublished`, or `unpublish-failed` when the owning app could not. + */ + private function unpublish(FormDependent $form, string $schema, array $findings): string { + try { + if ($form->unpublish !== null) { + ($form->unpublish)(); + } + } catch (Throwable $exception) { + $this->logger->error( + message: '[FormDependentsCheck] An owning app could not unpublish a form broken by a schema change', + context: ['form' => $form->id, 'app' => $form->app, 'schema' => $schema, 'exception' => $exception->getMessage()] + ); + + return 'unpublish-failed'; + } + + if ($form->author !== null && $form->author !== '') { + $notification = $this->notifications->createNotification(); + $notification->setApp('openregister') + ->setUser($form->author) + ->setDateTime(new DateTime()) + ->setObject('form', $form->id) + ->setSubject(self::SUBJECT, ['formTitle' => $form->title, 'schema' => $schema, 'count' => count($findings)]); + $this->notifications->notify($notification); + } + + return 'unpublished'; + }//end unpublish() +}//end class diff --git a/tests/Unit/Controller/FormsControllerTest.php b/tests/Unit/Controller/FormsControllerTest.php index 585a8e1930..c766eef078 100644 --- a/tests/Unit/Controller/FormsControllerTest.php +++ b/tests/Unit/Controller/FormsControllerTest.php @@ -28,7 +28,6 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UserRateLimit; -use OCP\IAppConfig; use OCP\IL10N; use OCP\IRequest; use OCP\IUser; @@ -62,8 +61,6 @@ class FormsControllerTest extends TestCase { private IUserSession&MockObject $session; - private IAppConfig&MockObject $appConfig; - private IThrottler&MockObject $throttler; private FormsController $controller; @@ -91,7 +88,6 @@ protected function setUp(): void { $this->uploads = $this->createMock(FormUploadStore::class); $this->schemas = $this->createMock(SchemaMapper::class); $this->session = $this->createMock(IUserSession::class); - $this->appConfig = $this->createMock(IAppConfig::class); $this->throttler = $this->createMock(IThrottler::class); $l10n = $this->createMock(IL10N::class); $l10n->method('t')->willReturnArgument(0); @@ -105,7 +101,6 @@ protected function setUp(): void { uploads: $this->uploads, schemas: $this->schemas, userSession: $this->session, - appConfig: $this->appConfig, throttler: $this->throttler, l10n: $l10n, logger: $this->createMock(LoggerInterface::class) @@ -227,34 +222,21 @@ public function testTheAudienceIsHonoured(): void { }//end testTheAudienceIsHonoured() /** - * Validate returns the findings and, in report mode, accepts the save anyway. + * Q9 (Ruben): refuse from day one. A mapping with findings is not accepted; one without is. */ - public function testValidateInReportModeAcceptsWithFindings(): void { + public function testValidateRefusesAMappingWithFindings(): void { $schema = new Schema(); $this->params = ['mapping' => ['fields' => []], 'destination' => ['register' => 'dossiq', 'schema' => 'case'], 'audience' => 'public']; $this->schemas->method('find')->with('case')->willReturn($schema); $findings = [['property' => 'caseType', 'code' => 'required-unmapped', 'message' => 'm']]; - $this->validator->expects($this->once())->method('validate')->with(['fields' => []], $schema, ['audience' => 'public'])->willReturn($findings); - $this->appConfig->method('getValueString')->with('openregister', 'formDestinationMode', 'report')->willReturn('report'); - - $response = $this->controller->validate(); - - $this->assertSame(200, $response->getStatus()); - $this->assertSame(['mode' => 'report', 'accepted' => true, 'findings' => $findings], $response->getData()); - }//end testValidateInReportModeAcceptsWithFindings() - - /** - * In refuse mode a mapping with findings is not accepted; one without is. - */ - public function testValidateInRefuseModeRefusesWithFindings(): void { - $this->params = ['mapping' => [], 'destination' => ['schema' => 'case']]; - $this->schemas->method('find')->willReturn(new Schema()); - $this->validator->method('validate')->willReturnOnConsecutiveCalls([['property' => 'x', 'code' => 'property-unknown', 'message' => 'm']], []); - $this->appConfig->method('getValueString')->willReturn('refuse'); - - $this->assertFalse($this->controller->validate()->getData()['accepted']); - $this->assertTrue($this->controller->validate()->getData()['accepted']); - }//end testValidateInRefuseModeRefusesWithFindings() + $this->validator->expects($this->exactly(2))->method('validate')->with(['fields' => []], $schema, ['audience' => 'public']) + ->willReturnOnConsecutiveCalls($findings, []); + + $refused = $this->controller->validate(); + $this->assertSame(200, $refused->getStatus()); + $this->assertSame(['accepted' => false, 'findings' => $findings], $refused->getData()); + $this->assertSame(['accepted' => true, 'findings' => []], $this->controller->validate()->getData()); + }//end testValidateRefusesAMappingWithFindings() /** * Validate against a schema that does not exist is a 404. diff --git a/tests/Unit/Controller/SchemasControllerTest.php b/tests/Unit/Controller/SchemasControllerTest.php index 481e0ffa79..033c8cfe92 100644 --- a/tests/Unit/Controller/SchemasControllerTest.php +++ b/tests/Unit/Controller/SchemasControllerTest.php @@ -68,6 +68,8 @@ class SchemasControllerTest extends TestCase { private \Psr\Container\ContainerInterface&MockObject $container; + private ?\OCA\OpenRegister\Service\Form\FormDependentsCheck $formCheck = null; + private \OCA\OpenRegister\Service\SchemaDeletionService&MockObject $schemaDeletionService; /** @@ -129,6 +131,10 @@ function ($id) use ($userSession, $groupManager, $schemaDeletionService) { return $schemaDeletionService; } + if ($id === \OCA\OpenRegister\Service\Form\FormDependentsCheck::class) { + return $this->formCheck; + } + return null; } ); @@ -315,6 +321,54 @@ public function testUpdateReturnsUpdatedSchema(): void { $this->assertSame(200, $result->getStatus()); }//end testUpdateReturnsUpdatedSchema() + /** + * Decision 179: an owning app set to refuse blocks a schema change that breaks its published form. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + public function testUpdateIsRefusedWhenAnOwningAppRefusesTheBreak(): void { + $schema = $this->createRealSchema(1, 'Ticket'); + $this->schemaMapper->method('find')->willReturn($schema); + $this->request->method('getParams')->willReturn(['required' => ['title', 'priority']]); + $this->schemaVersioningService->method('classify')->willReturn(new \OCA\OpenRegister\Service\Schema\SchemaChangeSet([], 'none', 'none')); + $assessment = ['refused' => true, 'schema' => 'ticket', 'affected' => [['id' => 'form-a']]]; + $check = $this->createMock(\OCA\OpenRegister\Service\Form\FormDependentsCheck::class); + $check->expects($this->once())->method('assess') + ->with($this->callback(static fn (Schema $proposed): bool => $proposed->getRequired() === ['title', 'priority'] && $proposed !== $schema)) + ->willReturn($assessment); + $check->method('apply')->willReturn([['id' => 'form-a', 'outcome' => 'refused']]); + $this->formCheck = $check; + $this->schemaMapper->expects($this->never())->method('updateFromArray'); + + $result = $this->controller->update(1); + + $this->assertSame(409, $result->getStatus()); + $this->assertSame([['id' => 'form-a', 'outcome' => 'refused']], $result->getData()['affectedForms']); + }//end testUpdateIsRefusedWhenAnOwningAppRefusesTheBreak() + + /** + * Decision 179: a save that breaks a published form goes ahead, and the response lists the unpublished form. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + public function testUpdateListsTheFormsItUnpublished(): void { + $schema = $this->createRealSchema(1, 'Ticket'); + $this->schemaMapper->method('find')->willReturn($schema); + $this->schemaMapper->method('updateFromArray')->willReturn($schema); + $this->request->method('getParams')->willReturn(['required' => ['title', 'priority']]); + $this->schemaVersioningService->method('classify')->willReturn(new \OCA\OpenRegister\Service\Schema\SchemaChangeSet([], 'none', 'none')); + $check = $this->createMock(\OCA\OpenRegister\Service\Form\FormDependentsCheck::class); + $check->method('assess')->willReturn(['refused' => false, 'schema' => 'ticket', 'affected' => [['id' => 'form-a']]]); + $check->expects($this->once())->method('apply')->willReturn([['id' => 'form-a', 'outcome' => 'unpublished']]); + $this->formCheck = $check; + + $result = $this->controller->update(1); + + $this->assertSame(200, $result->getStatus()); + $this->assertSame('Ticket', $result->getData()['title']); + $this->assertSame([['id' => 'form-a', 'outcome' => 'unpublished']], $result->getData()['affectedForms']); + }//end testUpdateListsTheFormsItUnpublished() + public function testPatchDelegatesToUpdate(): void { $schema = $this->createRealSchema(1, 'Patched'); diff --git a/tests/Unit/Notification/NotifierTest.php b/tests/Unit/Notification/NotifierTest.php index 0dda7bd3f3..28ee399af6 100644 --- a/tests/Unit/Notification/NotifierTest.php +++ b/tests/Unit/Notification/NotifierTest.php @@ -690,6 +690,18 @@ public function testPrepareViewAlertCrossedAbove(): void { $this->assertSame('The view Overdue cases counts 23, at or above its threshold of 20.', $parsed[1]); } + /** + * An author whose form a schema change broke is told which form, which schema, and what to do. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + public function testPrepareFormUnpublished(): void { + $parsed = $this->renderSubject('form_unpublished', ['formTitle' => 'Melding openbare ruimte', 'schema' => 'ticket', 'count' => 2]); + + $this->assertSame('Your form "Melding openbare ruimte" was taken offline', $parsed[0]); + $this->assertSame('A change to "ticket" left 2 problems in the form. Fix them and publish the form again.', $parsed[1]); + } + /** * A falling count reads as falling, not as the same sentence with the numbers swapped. */ diff --git a/tests/Unit/Service/Form/FormDependentsCheckTest.php b/tests/Unit/Service/Form/FormDependentsCheckTest.php new file mode 100644 index 0000000000..ae80ae64c0 --- /dev/null +++ b/tests/Unit/Service/Form/FormDependentsCheckTest.php @@ -0,0 +1,228 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Event\FormDestinationDependentsEvent; +use OCA\OpenRegister\Service\Form\FormDependent; +use OCA\OpenRegister\Service\Form\FormDependentsCheck; +use OCA\OpenRegister\Service\Form\FormDestinationValidator; +use OCA\OpenRegister\Service\Form\FormFieldRules; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IL10N; +use OCP\Notification\IManager; +use OCP\Notification\INotification; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Q9 (Ruben): the check refuses from day one, no report mode. A broken form is unpublished (default) or the save refused, per the owning app. + * + * @covers \OCA\OpenRegister\Service\Form\FormDependentsCheck + * @covers \OCA\OpenRegister\Service\Form\FormDependent + * @covers \OCA\OpenRegister\Event\FormDestinationDependentsEvent + * @uses \OCA\OpenRegister\Service\Form\FormDestinationValidator + * @uses \OCA\OpenRegister\Service\Form\FormFieldRules + * @uses \OCA\OpenRegister\Db\Schema + */ +class FormDependentsCheckTest extends TestCase { + + private IAppConfig&MockObject $appConfig; + + private IManager&MockObject $notifications; + + /** + * @var array + */ + private array $unpublished = []; + + /** + * @var array + */ + private array $settings = []; + + /** + * @var array> + */ + private array $sent = []; + + private FormDependentsCheck $check; + + protected function setUp(): void { + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static fn (string $text, array $parameters = []): string => $parameters === [] ? $text : vsprintf($text, $parameters) + ); + + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + function (Event $event): void { + $this->assertInstanceOf(FormDestinationDependentsEvent::class, $event); + $this->assertSame('ticket', $event->getSchema()->getSlug()); + $event->addForm( + new FormDependent( + id: 'form-a', + app: 'pipelinq', + title: 'Contact opnemen', + author: 'alice', + mapping: ['fields' => [['field' => 'onderwerp', 'property' => 'title']]], + unpublish: function (): void { + $this->unpublished[] = 'form-a'; + } + ) + ); + $event->addForm( + new FormDependent( + id: 'form-b', + app: 'portaliq', + title: 'Melding', + author: null, + mapping: ['fields' => [['field' => 'onderwerp', 'property' => 'title'], ['field' => 'prio', 'property' => 'priority', 'type' => 'choice', 'options' => ['low', 'high']]]], + unpublish: function (): void { + $this->unpublished[] = 'form-b'; + } + ) + ); + } + ); + + $this->appConfig = $this->createMock(IAppConfig::class); + $this->appConfig->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = ''): string => ($this->settings[$app . '.' . $key] ?? $default) + ); + + $this->notifications = $this->createMock(IManager::class); + $this->notifications->method('createNotification')->willReturnCallback(fn (): INotification => $this->recordingNotification()); + $this->notifications->method('notify')->willReturnCallback( + function (INotification $notification): void { + $this->sent[] = ['user' => $notification->getUser(), 'subject' => $notification->getSubject(), 'parameters' => $notification->getSubjectParameters()]; + } + ); + + $this->check = new FormDependentsCheck( + dispatcher: $dispatcher, + validator: new FormDestinationValidator(l10n: $l10n, rules: new FormFieldRules()), + appConfig: $this->appConfig, + notifications: $this->notifications, + logger: $this->createMock(LoggerInterface::class) + ); + }//end setUp() + + /** + * A notification that remembers what was set on it. + */ + private function recordingNotification(): INotification { + $state = ['user' => '', 'subject' => '', 'parameters' => []]; + $notification = $this->createMock(INotification::class); + foreach (['setApp', 'setDateTime', 'setObject'] as $setter) { + $notification->method($setter)->willReturnSelf(); + } + + $notification->method('setUser')->willReturnCallback( + function (string $user) use (&$state, $notification): INotification { + $state['user'] = $user; + + return $notification; + } + ); + $notification->method('setSubject')->willReturnCallback( + function (string $subject, array $parameters = []) use (&$state, $notification): INotification { + $state['subject'] = $subject; + $state['parameters'] = $parameters; + + return $notification; + } + ); + $notification->method('getUser')->willReturnCallback(static function () use (&$state): string { + return $state['user']; + }); + $notification->method('getSubject')->willReturnCallback(static function () use (&$state): string { + return $state['subject']; + }); + $notification->method('getSubjectParameters')->willReturnCallback(static function () use (&$state): array { + return $state['parameters']; + }); + + return $notification; + }//end recordingNotification() + + /** + * The ticket schema after `priority` became required. + */ + private function ticketWithRequiredPriority(): Schema { + $schema = new Schema(); + $schema->setSlug('ticket'); + $schema->setRequired(['title', 'priority']); + $schema->setProperties(['title' => ['type' => 'string'], 'priority' => ['type' => 'string', 'enum' => ['low', 'high']]]); + + return $schema; + }//end ticketWithRequiredPriority() + + /** + * Spec scenario: a new required property unpublishes a form that lacks it, and its author is told. + */ + public function testANewRequiredPropertyUnpublishesTheFormThatLacksIt(): void { + $assessment = $this->check->assess(schema: $this->ticketWithRequiredPriority()); + $this->assertFalse($assessment['refused']); + + $applied = $this->check->apply(assessment: $assessment); + + $this->assertSame([['id' => 'form-a', 'app' => 'pipelinq', 'title' => 'Contact opnemen', 'outcome' => 'unpublished']], array_map( + static fn (array $form): array => array_intersect_key($form, array_flip(['id', 'app', 'title', 'outcome'])), + $applied + )); + $this->assertSame(['form-a'], $this->unpublished); + $this->assertSame('alice', $this->sent[0]['user']); + $this->assertSame('form_unpublished', $this->sent[0]['subject']); + $this->assertSame('Contact opnemen', $this->sent[0]['parameters']['formTitle']); + }//end testANewRequiredPropertyUnpublishesTheFormThatLacksIt() + + /** + * An owning app set to refuse blocks the schema save, and nothing is unpublished. + */ + public function testAnOwningAppSetToRefuseBlocksTheSave(): void { + $this->settings['pipelinq.formDestinationBreak'] = 'refuse'; + + $assessment = $this->check->assess(schema: $this->ticketWithRequiredPriority()); + + $this->assertTrue($assessment['refused']); + $this->assertSame('refuse', $assessment['affected'][0]['break']); + $this->assertSame([], $this->unpublished); + }//end testAnOwningAppSetToRefuseBlocksTheSave() + + /** + * Unpublishing is a side effect of a save that happened: assess() alone changes nothing. + */ + public function testAssessAloneChangesNothing(): void { + $assessment = $this->check->assess(schema: $this->ticketWithRequiredPriority()); + + $this->assertSame(['form-a'], array_column($assessment['affected'], 'id')); + $this->assertSame('required-unmapped', $assessment['affected'][0]['findings'][0]['code']); + $this->assertSame([], $this->unpublished); + $this->assertSame([], $this->sent); + }//end testAssessAloneChangesNothing() + + /** + * A schema change no published form depends on affects nothing. + */ + public function testAChangeThatBreaksNoFormAffectsNothing(): void { + $schema = $this->ticketWithRequiredPriority(); + $schema->setRequired(['title']); + + $this->assertSame([], $this->check->assess(schema: $schema)['affected']); + }//end testAChangeThatBreaksNoFormAffectsNothing() +}//end class From 7954027b352dede029788bdce0e30986fd3b6a5e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 17:28:34 +0200 Subject: [PATCH 042/183] docs(forms): the form destination contract, Dutch strings, specs follow the build Feature page for the validator, markers, submit, uploads and the schema-save check. Dutch translations for every new backend string. The change's delta spec and proposal follow what was built and Ruben's answers (Q7 unpublish by default, Q9 refuse from day one, Q3 503); the or-form-and-journey-registry change no longer stages answers or written ids on a journeyRun (task 5.1). Decision 179, ADR-117. --- docs/features/README.md | 1 + docs/features/form-destination.md | 110 ++++++++++++++++++ l10n/nl.json | 35 +++++- .../form-destination-validator/proposal.md | 4 +- .../specs/form-destination/spec.md | 10 +- .../form-destination-validator/tasks.md | 24 ++-- .../or-form-and-journey-registry/proposal.md | 10 +- .../specs/form-and-journey-registry/spec.md | 25 ++-- .../or-form-and-journey-registry/tasks.md | 10 +- 9 files changed, 189 insertions(+), 40 deletions(-) create mode 100644 docs/features/form-destination.md diff --git a/docs/features/README.md b/docs/features/README.md index 366d7ecdd9..b5496aa506 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -59,6 +59,7 @@ OpenRegister implements or supports the following GEMMA (Gemeentelijke Model Arc | Favourites & Recently Opened | [favourites-and-recent.md](favourites-and-recent.md) | Collaboration | Implemented | Per-user star, view history, two index lenses | | Access Links | [access-links.md](access-links.md) | Collaboration | Implemented | Scoped, expiring link that opens one record without an account | | Generated Identifiers | [generated-identifier.md](generated-identifier.md) | Data Modelling | Implemented | Case numbers from a named sequence and a format, frozen after issue | +| Forms into their destination | [form-destination.md](form-destination.md) | Data Modelling | Implemented | A form is checked against its destination schema and submits into that object in one request | | Workflow Automation | [workflow-automation.md](workflow-automation.md) | Automation | Implemented | n8n, Windmill, BPMN | | Archiving & Records Management | [archiving.md](archiving.md) | Compliance | Implemented | Archiefwet, MDTO, NEN 15489, e-Depot | | OpenAPI & GraphQL APIs | [api-generation.md](api-generation.md) | Integration | Implemented | OpenAPI 3.1.0, GraphQL, NL API Design Rules | diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md new file mode 100644 index 0000000000..a0cf6bd531 --- /dev/null +++ b/docs/features/form-destination.md @@ -0,0 +1,110 @@ +# Forms that submit into their destination + +## Overview + +A form writes into the object a person works on: a case, a ticket, a lead. +There is no intake queue in between (decision 179, ADR-117). OpenRegister +checks a form against that object's schema while the form is built, and +creates the object in the same request as the submit. The resident's +confirmation names the real record. + +## Check a form while you build it + +`POST /index.php/apps/openregister/api/forms/validate` (signed in): + +```json +{ + "destination": { "register": "dossiq", "schema": "case" }, + "audience": "public", + "mapping": { + "fields": [ + { "field": "onderwerp", "property": "title", "type": "text", "maxLength": 200 }, + { "field": "kanaal", "property": "channel", "type": "choice", "options": ["website", "phone"] } + ], + "fixed": { "caseType": "0b5c1e9e-4f3a-4b8e-9c7d-2a1f0e6d5c4b" } + } +} +``` + +The answer is `{ "accepted": false, "findings": [...] }`. Save the form only +when `accepted` is true. Each finding is `{ field?, property, code, message }`: + +| Code | What is wrong | +|------|---------------| +| `required-unmapped` | A required property has no field, no fixed value and no server default | +| `property-unknown` | A field writes into a property the schema does not have | +| `property-read-only` | A field writes into a read-only property | +| `type-mismatch` | The field cannot produce the property's type | +| `format-mismatch` | The field cannot produce the property's format | +| `enum-unconstrained` | A free-text field writes into a list of allowed values | +| `enum-value-unknown` | A choice offers a value the property does not accept | +| `constraint-looser` | The field allows more than the property (length, range, pattern) | +| `fixed-value-invalid` | A fixed value is one the property refuses | +| `destination-not-public` | The schema does not let the form's audience create objects | +| `destination-is-staging` | The schema is marked `staging: true`, so it is no destination | + +Flow task forms use the same validator: `TaskFormReader` delegates to it. + +## Schema markers + +Put these in a property's `x-openregister` block: + +| Marker | Effect | +|--------|--------| +| `serverSet: true` | A listener fills this property, so no field has to | +| `confirmation: true` | The submit answer returns this property, read after the create listeners ran | +| `reference: true` | The submit answer returns this property as `reference` | + +```json +"termStartsAt": { "type": "string", "format": "date-time", + "x-openregister": { "serverSet": true, "confirmation": true } } +``` + +Two schema configuration keys apply: `staging: true` marks a schema that is +no destination, and `additionalProperties: true` lets fields write into +properties the schema does not declare. + +## Submit + +Apps call `FormSubmitService::submit()` (one destination) or `submitAll()` +(several `writes[]`, all or none) in process. A form stored as an +OpenRegister object (`status: published`, `destination` or `writes`, +`mapping`, `audience`) can be submitted over HTTP: + +`POST /index.php/apps/openregister/api/forms/{formId}/submit`, with an +`Idempotency-Key` header when the client may retry. + +| Status | Meaning | +|--------|---------| +| 201 | Created: `{ reference, id, receivedAt, confirmation, objects }` | +| 202 | The honeypot `_hp` was filled; nothing is stored | +| 401 | The form is for signed-in people | +| 403 | The subject may not create the destination | +| 404 | No such published form (also counted as a brute-force attempt) | +| 422 | The payload was refused: `{ message, findings }`, nothing was created | +| 503 | The destination could not be reached; the resident tries again later | + +A retried `Idempotency-Key` returns the first answer for 24 hours. When one +of several writes is refused, the earlier writes of that submit are deleted +before the answer, and the deletion is logged. + +## Files + +`POST /index.php/apps/openregister/api/forms/{formId}/uploads` with `file` and +`property` returns `{ token, expiresAt }`, checked against the property's +size and type rules. Put `{ "uploadToken": "" }` in the submit for +that property. The token is claimed only when the submit succeeds. An hourly +job deletes unclaimed tokens after 24 hours and logs how many. + +## When a schema changes + +Saving a schema's properties or required list asks the owning apps for their +published forms into it (`FormDestinationDependentsEvent`). A form the change +breaks is unpublished and its author notified, unless its app sets +`formDestinationBreak` to `refuse`: then the schema save is refused with 409. +The save answer lists every affected form under `affectedForms`. + +## Next + +Wire your app's form save to `POST /api/forms/validate`, and its public submit +to `FormSubmitService`. diff --git a/l10n/nl.json b/l10n/nl.json index 068bb72a00..e8b63ef954 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -3399,7 +3399,40 @@ "Generic bearer token (app-injected)": "Algemeen bearer-token (de app voegt het in)", "Generic HTTP Basic password (app-injected)": "Algemeen HTTP Basic-wachtwoord (de app voegt het in)", "Generic OAuth2 client secret (app-injected)": "Algemeen OAuth2-clientgeheim (de app voegt het in)", - "Generic JWT signing secret (app-injected)": "Algemeen JWT-ondertekeningsgeheim (de app voegt het in)" + "Generic JWT signing secret (app-injected)": "Algemeen JWT-ondertekeningsgeheim (de app voegt het in)", + "\"%1$s\" does not hold files.": "\"%1$s\" bevat geen bestanden.", + "The file did not arrive. Please try again.": "Het bestand is niet aangekomen. Probeer het opnieuw.", + "The file is %1$s MB; \"%2$s\" takes at most %3$s MB.": "Het bestand is %1$s MB; \"%2$s\" neemt maximaal %3$s MB aan.", + "\"%1$s\" takes these file types: %2$s.": "\"%1$s\" neemt deze bestandstypen aan: %2$s.", + "An uploaded file has expired or is unknown. Please add it again.": "Een geüpload bestand is verlopen of onbekend. Voeg het opnieuw toe.", + "the schema has no such property.": "het schema heeft deze eigenschap niet.", + "the schema marks it read-only, so a submitted value would be refused.": "het schema markeert het als alleen-lezen, dus een ingevulde waarde wordt geweigerd.", + "the schema marks it not visible, so no form can show it.": "het schema markeert het als niet zichtbaar, dus geen formulier kan het tonen.", + "Schema \"%1$s\" is marked as a staging object. A form submits into the object a person works on, not into a layer before it.": "Schema \"%1$s\" is gemarkeerd als tussenobject. Een formulier schrijft in het object waar iemand aan werkt, niet in een laag ervoor.", + "Schema \"%1$s\" does not let a %2$s visitor create objects, so this form could never be submitted.": "Schema \"%1$s\" laat een %2$s bezoeker geen objecten aanmaken, dus dit formulier kan nooit worden verstuurd.", + "Field \"%1$s\" writes into \"%2$s\", which schema \"%3$s\" does not have.": "Veld \"%1$s\" schrijft in \"%2$s\", dat schema \"%3$s\" niet heeft.", + "Field \"%1$s\" writes into \"%2$s\", which is read-only, so the save would refuse it.": "Veld \"%1$s\" schrijft in \"%2$s\", dat alleen-lezen is, dus het opslaan zou het weigeren.", + "Field \"%1$s\" is a %2$s field and cannot produce the %3$s that \"%4$s\" holds.": "Veld \"%1$s\" is een %2$s-veld en kan niet de %3$s leveren die \"%4$s\" bevat.", + "Field \"%1$s\" does not produce the %2$s format that \"%3$s\" demands.": "Veld \"%1$s\" levert niet het %2$s-formaat dat \"%3$s\" vraagt.", + "Field \"%1$s\" lets anything be typed, but \"%2$s\" accepts only: %3$s.": "Veld \"%1$s\" laat alles intypen, maar \"%2$s\" accepteert alleen: %3$s.", + "Field \"%1$s\" offers \"%2$s\", which \"%3$s\" does not accept.": "Veld \"%1$s\" biedt \"%2$s\" aan, dat \"%3$s\" niet accepteert.", + "Field \"%1$s\" allows more than \"%2$s\" does: set %3$s to %4$s or tighter.": "Veld \"%1$s\" staat meer toe dan \"%2$s\": zet %3$s op %4$s of strenger.", + "A fixed value writes into \"%1$s\", which schema \"%2$s\" does not have.": "Een vaste waarde schrijft in \"%1$s\", dat schema \"%2$s\" niet heeft.", + "The fixed value for \"%1$s\" is one the property does not accept.": "De vaste waarde voor \"%1$s\" wordt door de eigenschap niet geaccepteerd.", + "\"%1$s\" is required, and no field, fixed value or server default fills it.": "\"%1$s\" is verplicht, en geen veld, vaste waarde of standaardwaarde vult het.", + "This form has no destination.": "Dit formulier heeft geen bestemming.", + "An uploaded file was sent for another question. Please add it again.": "Een geüpload bestand hoort bij een andere vraag. Voeg het opnieuw toe.", + "The form was not accepted. Please check the answers marked below.": "Het formulier is niet geaccepteerd. Controleer de gemarkeerde antwoorden hieronder.", + "Write \"%1$s\" is referenced before it is made.": "Er wordt naar \"%1$s\" verwezen voordat het is aangemaakt.", + "You may not send this form.": "U mag dit formulier niet versturen.", + "Your form could not be sent right now. Your answers are kept; please try again later.": "Uw formulier kon nu niet worden verstuurd. Uw antwoorden blijven bewaard; probeer het later opnieuw.", + "The place this form sends to does not exist.": "De plek waar dit formulier naartoe gaat bestaat niet.", + "This form does not exist.": "Dit formulier bestaat niet.", + "The destination schema does not exist.": "Het bestemmingsschema bestaat niet.", + "Please sign in to send this form.": "Log in om dit formulier te versturen.", + "This form has no question that takes a file under that name.": "Dit formulier heeft geen vraag die onder die naam een bestand aanneemt.", + "Your form \"%1$s\" was taken offline": "Uw formulier \"%1$s\" is offline gehaald", + "A change to \"%1$s\" left %2$s problems in the form. Fix them and publish the form again.": "Een wijziging aan \"%1$s\" liet %2$s problemen achter in het formulier. Los ze op en publiceer het formulier opnieuw." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/openspec/changes/form-destination-validator/proposal.md b/openspec/changes/form-destination-validator/proposal.md index c11b8bcdb2..9fbe97c3e2 100644 --- a/openspec/changes/form-destination-validator/proposal.md +++ b/openspec/changes/form-destination-validator/proposal.md @@ -19,7 +19,7 @@ A form today is checked against its own field list. The destination schema judge - **Upload tokens**: `POST /api/forms/{formId}/uploads` holds file bytes only, expires after 24 hours, purge counted. - **Schema save checks dependent forms**: a schema change that invalidates a published form is refused or unpublishes the form, per the owning app's setting. - **Create errors match update errors**: `ObjectsController::create` returns the per-property shape on a validation failure. -- **Two schema markers**: `x-openregister.confirmation` (returned to the submitter) and `x-openregister.serverSet` (filled by a listener, so not reported as unmapped). +- **Three schema markers**: `x-openregister.confirmation` (returned to the submitter), `x-openregister.serverSet` (filled by a listener, so not reported as unmapped) and `x-openregister.reference` (the property returned as `reference`). Two schema configuration keys survive a save: `staging` and `additionalProperties`. - **`or-form-and-journey-registry` amended**: `journeyRun` no longer stages written ids; a step commits all or nothing. ## Out of scope @@ -28,4 +28,4 @@ Authoring screens (buildiq), the public host (portaliq), the case fields (dossiq ## Rollback -The validator ships in report mode for one release (findings returned, save allowed). The submit service is additive; callers switch to it in their own changes. +Q9 (Ruben, 10 October 2026): the checks refuse from the first release, with no report-only release. The submit service and routes are additive; callers switch to them in their own changes. The create error body changes from a bare string to the update shape (same status). Drafts (decision 180) are a separate, stacked change. diff --git a/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md index 34adff4102..9043ec59af 100644 --- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md +++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md @@ -7,7 +7,7 @@ ### Requirement: OpenRegister MUST judge a form's mapping against its destination schema -`FormDestinationValidator::validate(mapping, schema)` SHALL return one finding per problem, each `{ field?, property, code, message }`. It SHALL report a required property with no field, no fixed value, no `default` and no `x-openregister.serverSet` marker as `required-unmapped`; a field into a property the schema lacks as `property-unknown` when the schema forbids additional properties; a type or format that cannot produce the property's as `type-mismatch` or `format-mismatch`; a free-text field into an enum as `enum-unconstrained`; a choice option outside the enum as `enum-value-unknown`; a field constraint looser than the property's as `constraint-looser`; a fixed value the property refuses as `fixed-value-invalid`. It SHALL extend `TaskFormReader::validate` so flow task forms and object forms are judged by one service. +`FormDestinationValidator::validate(mapping, schema, options)` SHALL return one finding per problem, each `{ field?, property, code, message }`. The mapping is `{ fields: [ { field, property, type?, format?, options?, maxLength?, minLength?, maximum?, minimum?, pattern? } ], fixed: { property: value } }`. It SHALL report a required property (schema `required` list or property `required: true`) with no field, no fixed value, no `default`, no `computed` and no `x-openregister.serverSet` marker as `required-unmapped`; a field or fixed value into a property the schema lacks as `property-unknown`, unless the schema's configuration sets `additionalProperties: true` (OpenRegister schemas carry no top-level `additionalProperties`, so "forbids extras" would never hold); a field into a read-only property as `property-read-only`; a type or format that cannot produce the property's as `type-mismatch` or `format-mismatch`; a free-text field into an enum as `enum-unconstrained`; a choice option outside the enum as `enum-value-unknown`; a field bound looser than the property's (or missing where the property sets one) as `constraint-looser`; a fixed value the property refuses as `fixed-value-invalid`; a form for a `public` or `authenticated` audience into a schema whose authorization does not grant that audience create as `destination-not-public`; and a schema whose configuration sets `staging: true` as `destination-is-staging`. It SHALL extend `TaskFormReader::validate` so flow task forms and object forms are judged by one service. `POST /api/forms/validate` SHALL return `{ accepted, findings }` for an author; a mapping with findings is not accepted, from the first release (Q9, Ruben, 10 October 2026: no report-only release). #### Scenario: A required property with no source is reported @@ -30,7 +30,7 @@ ### Requirement: A submit MUST create the destination in one request and return its reference -`FormSubmitService::submit(destination, mapping, payload, subject, idempotencyKey)` SHALL validate the payload with the destination schema's full validator, regardless of the schema's hard-validation flag, then create the object under the subject's RBAC. It SHALL return `reference`, `id`, `receivedAt` and a `confirmation` map of every property marked `x-openregister.confirmation: true`, read after every create listener ran. `POST /api/forms/{formId}/submit` SHALL wrap it for forms stored in OpenRegister, with `#[PublicPage]`, `#[AnonRateLimit]` and `#[UserRateLimit]` per ADR-082. +`FormSubmitService::submit(destination, mapping, payload, subject, idempotencyKey, scope)` and `submitAll(writes, payload, subject, idempotencyKey, scope)` SHALL map the payload through the mapping (only mapped fields and fixed values reach the object; without a mapping the payload minus `_` and `@` keys is the object), validate it with the destination schema's full validator regardless of the schema's hard-validation flag (properties the server fills are excused from `required`), then create the object under the subject's RBAC; an anonymous submit is saved unowned. It SHALL return `reference` (the property marked `x-openregister.reference: true`, else the uuid), `id`, `receivedAt` (the object's created moment), a `confirmation` map of every property marked `x-openregister.confirmation: true`, read after every create listener ran, and `objects[]` listing each write. A refused payload SHALL answer 422 `{ message, findings }`; a subject not allowed to create 403; an unknown destination 404; an unreachable destination 503 "try again later" with nothing created (Q3). `POST /api/forms/{formId}/submit` SHALL wrap it for forms stored in OpenRegister, with `#[PublicPage]`, `#[AnonRateLimit]`, `#[UserRateLimit]` and `#[BruteForceProtection]` per ADR-082; an unknown, unpublished or destination-less form SHALL answer one identical 404 and register a brute-force attempt; a filled honeypot field `_hp` SHALL answer 202 and store nothing; a form whose `audience` is not `public` SHALL answer 401 to an anonymous visitor. A form stored in OpenRegister is any object whose body carries `status: published` (or `published: true`), `destination: { register, schema }` or `writes[]`, and optionally `mapping` and `audience`. #### Scenario: The response carries the listener-computed fields @@ -62,7 +62,7 @@ The service SHALL validate every write before the first. When a write is refused ### Requirement: Upload tokens MUST hold bytes only and expire -`POST /api/forms/{formId}/uploads` SHALL accept one file, check it against the destination's file rules, and return a token. A submit SHALL claim tokens by id. Unclaimed tokens SHALL be deleted after 24 hours by a background job that logs its count. +`POST /api/forms/{formId}/uploads` SHALL accept one file and the destination `property` it is for, check it against that property's file rules (`maxSize` in bytes or `fileConfiguration.maxSize` in MB, `allowedTypes` or `fileConfiguration.allowedMimeTypes`, the same reader the save path uses), and return `{ token, expiresAt }`. A submit SHALL claim tokens by a payload value `{ "uploadToken": "" }` for the property the token was issued for, and only after its last write succeeded, so a refused submit leaves the files for the retry. Unclaimed tokens SHALL be deleted after 24 hours by an hourly background job that logs its count, a zero included. #### Scenario: An oversized file is refused at upload @@ -72,7 +72,7 @@ The service SHALL validate every write before the first. When a write is refused ### Requirement: Saving a schema MUST re-check the forms that submit into it -When a schema is saved, the validator SHALL run for every published form whose destination it is. The owning app's setting `formDestinationBreak` (`unpublish`, the default, or `refuse`) SHALL decide the outcome. The save response SHALL list every affected form with its findings. +When a schema's properties or required list are saved, OpenRegister SHALL dispatch `FormDestinationDependentsEvent` with the proposed definition; each owning app adds its published forms into that schema, and the validator SHALL run for each. The owning app's setting `formDestinationBreak` (`unpublish`, the default, or `refuse`) SHALL decide the outcome (Q7): any `refuse` blocks the save with 409 listing the forms and nothing is unpublished; otherwise, after the save, each broken form is unpublished through its owning app, its author is notified (`form_unpublished`), and the save response SHALL list every affected form with its outcome and findings under `affectedForms`. This applies from the first release (Q9). #### Scenario: A new required property unpublishes a form that lacks it @@ -82,7 +82,7 @@ When a schema is saved, the validator SHALL run for every published form whose d ### Requirement: A create refusal MUST use the per-property error shape -`ObjectsController::create` SHALL answer a validation failure with the same `{ status, message, errors: [{ property, message }] }` body that update and patch use, with HTTP 422. +`ObjectsController::create` SHALL answer a validation failure through the same handler update and patch use, so the body is the same `{ status, message, errors: [{ property, message }] }` and the status is the same (400 today; moving all three to 422 is question Q-openregister-F1). #### Scenario: Create and update refuse alike diff --git a/openspec/changes/form-destination-validator/tasks.md b/openspec/changes/form-destination-validator/tasks.md index 46930ad393..cf64b5378f 100644 --- a/openspec/changes/form-destination-validator/tasks.md +++ b/openspec/changes/form-destination-validator/tasks.md @@ -2,33 +2,33 @@ ## 1. Validator -- [ ] 1.1 `lib/Service/Form/FormDestinationValidator.php` with the finding codes; `TaskFormReader::validate` delegates to it +- [x] 1.1 `lib/Service/Form/FormDestinationValidator.php` with the finding codes; `TaskFormReader::validate` delegates to it - Spec ref: specs/form-destination/spec.md, "OpenRegister MUST judge a form's mapping against its destination schema" - Test: `tests/Unit/Service/Form/FormDestinationValidatorTest.php`, one case per code plus a zero-findings control -- [ ] 1.2 Read `x-openregister.serverSet` and `x-openregister.confirmation` in the schema property dialect; document both in the schema standards (ADR-011 table) -- [ ] 1.3 `POST /api/forms/validate` (authenticated, body: mapping and destination) so buildiq and portaliq can show findings while authoring; report mode setting `formDestinationMode` (`report` for one release, then `refuse`) +- [x] 1.2 Read `x-openregister.serverSet` and `x-openregister.confirmation` in the schema property dialect; document both (plus `x-openregister.reference`) in `docs/features/form-destination.md`; the hydra ADR-011 table row is owed in hydra#748 +- [x] 1.3 `POST /api/forms/validate` (authenticated, body: mapping and destination) so buildiq and portaliq can show findings while authoring; refuses from the first release (Q9 answer: no report mode). `FormsController::validate`, `tests/Unit/Controller/FormsControllerTest.php` ## 2. Submit -- [ ] 2.1 `lib/Service/Form/FormSubmitService.php`: full validation regardless of hard-validation flag, create under the subject's RBAC, confirmation map read after listeners +- [x] 2.1 `lib/Service/Form/FormSubmitService.php`: full validation regardless of hard-validation flag, create under the subject's RBAC, confirmation map read after listeners - Test: unit test with a fake creating listener; a control proving the field is absent when the marker is absent -- [ ] 2.2 All-or-none for several writes, with audited compensation -- [ ] 2.3 Idempotency store (24 h) keyed by form and key -- [ ] 2.4 `POST /api/forms/{formId}/submit` route, `#[PublicPage]`, ADR-082 attributes and `registerAttempt`; routes.php entry -- [ ] 2.5 `ObjectsController::create` answers 422 in the update error shape +- [x] 2.2 All-or-none for several writes, with audited compensation +- [x] 2.3 Idempotency store (24 h) keyed by form and key +- [x] 2.4 `POST /api/forms/{formId}/submit` route, `#[PublicPage]`, ADR-082 attributes and `registerAttempt`; routes.php entry +- [x] 2.5 `ObjectsController::create` answers in the update error shape, through the same handler and status update uses (400; 422 for all three is Q-openregister-F1) ## 3. Uploads -- [ ] 3.1 Upload token endpoint and claim in submit -- [ ] 3.2 Purge job (`TimedJob`, hourly), logs the count +- [x] 3.1 Upload token endpoint and claim in submit +- [x] 3.2 Purge job (`TimedJob`, hourly), logs the count ## 4. Schema save -- [ ] 4.1 On schema save, re-validate dependent published forms; apply `formDestinationBreak`; list affected forms in the response; notify authors +- [x] 4.1 On schema save, re-validate dependent published forms; apply `formDestinationBreak`; list affected forms in the response; notify authors ## 5. Journey registry -- [ ] 5.1 Apply the MODIFIED requirement to `or-form-and-journey-registry` (no written ids on `journeyRun`; commit through `FormSubmitService`) +- [x] 5.1 Apply the MODIFIED requirement to `or-form-and-journey-registry` (no written ids on `journeyRun`; commit through `FormSubmitService`) ## 6. Verification diff --git a/openspec/changes/or-form-and-journey-registry/proposal.md b/openspec/changes/or-form-and-journey-registry/proposal.md index 84c76a64fa..4cfd298725 100644 --- a/openspec/changes/or-form-and-journey-registry/proposal.md +++ b/openspec/changes/or-form-and-journey-registry/proposal.md @@ -48,10 +48,12 @@ the two can drift, they will. `next` rules using `$defs.visibleWhen` verbatim, `writes[]` per step, and `access` (`anonymous` | `authenticated` | `minTrust`). -**A `journeyRun` stages everything.** Answers accumulate there; objects are -written only at steps declaring `writes[]`. This preserves the property the -React wizards have by accident — an abandoned registration leaves no -half-built organisation — while adding the resumability they lack. +**A `journeyRun` stages nothing** (amended by `form-destination-validator`, +decision 179, ADR-117). Objects are written only at steps declaring +`writes[]`, through `FormSubmitService`, all or none, so an abandoned +registration still leaves no half-built organisation. Resumability comes from +the destination object itself saved with lifecycle status `draft` +(decision 180), not from answers held on the run. **Retention ships with the schema, not after it.** A `journeyRun` holds names, addresses, e-mail, phone numbers and uploads before any of it is a record. diff --git a/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md b/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md index 1d6d30306c..44111af8da 100644 --- a/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md +++ b/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md @@ -64,12 +64,16 @@ canonical `$defs.visibleWhen`; optional per-step `writes[]` of - **WHEN** it is written - **THEN** the write is rejected — access is never inferred -### Requirement: The run API MUST stage answers and commit only at declared steps +### Requirement: A journey run MUST store no written ids and MUST commit a step all or none -The API SHALL expose start, answer, resume and submit operations over a -`journeyRun`. Answers SHALL be persisted to the run. Objects SHALL be created -or updated only at a step declaring `writes[]`, in declared order, with a later -entry able to reference an earlier entry's id. +Amended by `form-destination-validator` (decision 179, ADR-117; decision 180 +for drafts). The API SHALL expose start, answer, resume and submit operations +over a `journeyRun`. The run SHALL NOT stage answers or store ids of written +objects. A resumable draft is the destination object itself, saved with +lifecycle status `draft` (decision 180), not answers held on the run. Objects +SHALL be created only when a step declaring `writes[]` is submitted, through +`FormSubmitService`, all or none, in declared order, with a later entry able +to reference an earlier entry's id. #### Scenario: Advancing without a writes step creates nothing @@ -84,13 +88,12 @@ entry able to reference an earlier entry's id. - **WHEN** the step commits - **THEN** the contact carries the organisation's id -#### Scenario: A partial failure is recorded and is not duplicated on retry +#### Scenario: A partial failure leaves nothing behind -- **GIVEN** a step whose second write fails validation after the first - succeeded -- **WHEN** the step is re-submitted -- **THEN** the failure is recorded on the run, and the first object is updated - rather than created a second time +- **GIVEN** a step whose second write is refused after the first succeeded +- **WHEN** the step is submitted +- **THEN** the first object is deleted before the response, and the filer sees + the second write's findings #### Scenario: An answer for a field the current step does not declare is refused diff --git a/openspec/changes/or-form-and-journey-registry/tasks.md b/openspec/changes/or-form-and-journey-registry/tasks.md index d15fdc62ce..f4f3fe43d2 100644 --- a/openspec/changes/or-form-and-journey-registry/tasks.md +++ b/openspec/changes/or-form-and-journey-registry/tasks.md @@ -16,13 +16,13 @@ - [ ] Implement - [ ] Test -### Task 2: Journey run service — staged answers, committed writes -- **spec_ref**: `openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md#requirement-the-run-api-must-stage-answers-and-commit-only-at-declared-steps` +### Task 2: Journey run service: committed writes through FormSubmitService +- **spec_ref**: `openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md#requirement-a-journey-run-must-store-no-written-ids-and-must-commit-a-step-all-or-none` - **files**: `lib/Service/JourneyRunService.php`, `tests/Unit/Service/JourneyRunServiceTest.php` - **acceptance_criteria**: - - Answers persist to the `journeyRun`; no target-register object exists until a step declaring `writes[]` commits — proven by querying the target register mid-run - - Writes execute in declared order; a later entry resolves a preceding entry's id - - A mid-`writes[]` failure records the failure AND the already-written id; re-submitting UPDATES that object rather than creating a second + - No target-register object exists until a step declaring `writes[]` commits, proven by querying the target register mid-run; the run stores no answers and no written ids (decision 179; a draft is the destination object in status `draft`, decision 180) + - Writes go through `FormSubmitService::submitAll` (form-destination-validator), in declared order; a later entry resolves a preceding entry's id with `{ "$write": "" }` + - A mid-`writes[]` refusal deletes the earlier writes of that step before the response (all or none) - An answer for a field outside the current step is refused — the client does not select its own scope - [ ] Implement - [ ] Test From d8149261dc3d676b08629819b171cdf92906d654 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 17:41:27 +0200 Subject: [PATCH 043/183] style(forms): line length and inline ifs per phpcs --- lib/Controller/FormsController.php | 17 +++++++++-- lib/Controller/SchemasController.php | 2 +- lib/Service/Form/FormDependentsCheck.php | 6 ++-- lib/Service/Form/FormDestinationValidator.php | 15 ++++++++-- lib/Service/Form/FormSubmitService.php | 29 ++++++++++++------- lib/Service/Form/FormUploadStore.php | 8 +++-- 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php index ec8f202fac..3d87f706f0 100644 --- a/lib/Controller/FormsController.php +++ b/lib/Controller/FormsController.php @@ -130,7 +130,11 @@ public function validate(): JSONResponse { $options['audience'] = $audience; } - $findings = $this->validator->validate(mapping: (is_array($mapping) === true ? $mapping : []), schema: $schema, options: $options); + if (is_array($mapping) === false) { + $mapping = []; + } + + $findings = $this->validator->validate(mapping: $mapping, schema: $schema, options: $options); return new JSONResponse(data: ['accepted' => ($findings === []), 'findings' => $findings]); }//end validate() @@ -164,11 +168,15 @@ public function submit(string $formId): JSONResponse { } $key = trim($this->request->getHeader('Idempotency-Key')); + if ($key === '') { + $key = null; + } + $answer = $this->submitter->submitAll( $form['writes'], $this->payload(), $subject, - ($key === '' ? null : $key), + $key, $formId ); } catch (FormSubmitRefusedException $refused) { @@ -197,11 +205,14 @@ public function submit(string $formId): JSONResponse { public function upload(string $formId): JSONResponse { $property = trim((string)$this->request->getParam('property', '')); $file = $this->request->getUploadedFile('file'); + if (is_array($file) === false) { + $file = []; + } try { $form = $this->resolveForm(formId: $formId); $rule = $this->ruleFor(writes: $form['writes'], property: $property); - $issued = $this->uploads->issue(formId: $formId, property: $property, rule: $rule, file: (is_array($file) === true ? $file : [])); + $issued = $this->uploads->issue(formId: $formId, property: $property, rule: $rule, file: $file); } catch (FormSubmitRefusedException $refused) { return new JSONResponse(data: $refused->toBody(), statusCode: $refused->getStatus()); } diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php index d55c8fb36e..997237f86b 100644 --- a/lib/Controller/SchemasController.php +++ b/lib/Controller/SchemasController.php @@ -1095,7 +1095,7 @@ public function update(int $id): JSONResponse { * @param Schema $existing The stored schema. * @param array $data The update body. * - * @return array{refused: bool, schema: string, affected: array>}|null The assessment, or null when the definition is unchanged. + * @return array|null The assessment (refused, schema, affected), or null when the definition is unchanged. * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it */ diff --git a/lib/Service/Form/FormDependentsCheck.php b/lib/Service/Form/FormDependentsCheck.php index 0739b47444..321dc195a8 100644 --- a/lib/Service/Form/FormDependentsCheck.php +++ b/lib/Service/Form/FormDependentsCheck.php @@ -86,7 +86,9 @@ public function __construct( * * @param Schema $schema The proposed schema definition. * - * @return array{refused: bool, schema: string, affected: array>, form: FormDependent}>} The assessment. + * @return array The assessment: `refused`, `schema`, and `affected` (id, app, title, break, findings, form). + * + * @psalm-return array{refused: bool, schema: string, affected: list>} * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it */ @@ -124,7 +126,7 @@ public function assess(Schema $schema): array { * * @param array{refused: bool, schema: string, affected: array>} $assessment The assessment of the saved schema. * - * @return array>}> Each affected form with its outcome, for the save response. + * @return array> Each affected form (id, app, title, outcome, findings), for the save response. * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it */ diff --git a/lib/Service/Form/FormDestinationValidator.php b/lib/Service/Form/FormDestinationValidator.php index 4b3621f6e0..d302b747f3 100644 --- a/lib/Service/Form/FormDestinationValidator.php +++ b/lib/Service/Form/FormDestinationValidator.php @@ -228,7 +228,10 @@ private function destinationFindings(Schema $schema, string $audience): array { $findings[] = $this->finding( property: '', code: self::DESTINATION_IS_STAGING, - message: $this->l10n->t('Schema "%1$s" is marked as a staging object. A form submits into the object a person works on, not into a layer before it.', [$slug]) + message: $this->l10n->t( + 'Schema "%1$s" is marked as a staging object. A form submits into the object a person works on, not into a layer before it.', + [$slug] + ) ); } @@ -300,7 +303,10 @@ private function fieldFindings(array $field, Schema $schema): array { $this->finding( property: $propertyName, code: self::PROPERTY_UNKNOWN, - message: $this->l10n->t('Field "%1$s" writes into "%2$s", which schema "%3$s" does not have.', [$name, $propertyName, (string)$schema->getSlug()]), + message: $this->l10n->t( + 'Field "%1$s" writes into "%2$s", which schema "%3$s" does not have.', + [$name, $propertyName, (string)$schema->getSlug()] + ), field: $name ), ]; @@ -395,7 +401,10 @@ private function enumFindings(array $field, array $property, string $name, strin $this->finding( property: $propertyName, code: self::ENUM_UNCONSTRAINED, - message: $this->l10n->t('Field "%1$s" lets anything be typed, but "%2$s" accepts only: %3$s.', [$name, $propertyName, $this->listOf(values: $enum)]), + message: $this->l10n->t( + 'Field "%1$s" lets anything be typed, but "%2$s" accepts only: %3$s.', + [$name, $propertyName, $this->listOf(values: $enum)] + ), field: $name ), ]; diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index cbdf17a645..b213d15482 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -110,7 +110,7 @@ public function __construct( * @param string|null $idempotencyKey The Idempotency-Key, when the caller sent one. * @param string $scope The form id the key and upload tokens belong to. * - * @return array{reference: string, id: string, receivedAt: string|null, confirmation: array, objects: array>} The answer. + * @return array The answer: reference, id, receivedAt, confirmation, objects. * * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 503 try again later. * @@ -147,7 +147,7 @@ public function submit( * @param string|null $idempotencyKey The Idempotency-Key, when sent. * @param string $scope The form id the key and tokens belong to. * - * @return array{reference: string, id: string, receivedAt: string|null, confirmation: array, objects: array>} The answer. + * @return array The answer: reference, id, receivedAt, confirmation, objects. * * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 503 try again later. * @@ -166,7 +166,7 @@ public function submitAll( $remembered = $this->keys->find(scope: $keyScope, key: $key); if ($remembered !== null) { /* - * @var array{reference: string, id: string, receivedAt: string|null, confirmation: array, objects: array>} $remembered + * @var array $remembered */ return $remembered; } @@ -192,7 +192,7 @@ public function submitAll( * @param array $payload The payload. * @param string $scope The form id upload tokens belong to. * - * @return array|null, object: array, files: array>, tokens: array}> The plans. + * @return array> The plans: as, named, register, schema, mapping, object, files, tokens. * * @throws FormSubmitRefusedException 404 when a destination does not exist; 422 for a bad upload token. */ @@ -241,7 +241,7 @@ private function plan(array $writes, array $payload, string $scope): array { * @param array $payload The payload. * @param string $scope The form id upload tokens belong to. * - * @return array{0: array, 1: array>, 2: array} The object, the uploads by property, the tokens. + * @return array{0: array, 1: array>, 2: array} Object, uploads, tokens. * * @throws FormSubmitRefusedException 422 for an unknown, expired or misdirected upload token. */ @@ -276,10 +276,11 @@ private function mapPayload(?array $mapping, array $payload, string $scope): arr $file = $this->uploads->materialise(formId: $scope, token: $token); if ($file['property'] !== $property) { + $message = $this->l10n->t('An uploaded file was sent for another question. Please add it again.'); throw new FormSubmitRefusedException( - message: $this->l10n->t('An uploaded file was sent for another question. Please add it again.'), + message: $message, status: 422, - findings: [['property' => $property, 'code' => 'upload-token-unknown', 'message' => $this->l10n->t('An uploaded file was sent for another question. Please add it again.')]] + findings: [['property' => $property, 'code' => 'upload-token-unknown', 'message' => $message]] ); } @@ -396,6 +397,11 @@ private function writeAll(array $plans, ?IUser $subject): array { $created = []; $ids = []; foreach ($plans as $plan) { + $files = null; + if ($plan['files'] !== []) { + $files = $plan['files']; + } + try { $entity = $this->objects->saveObject( object: $this->resolveWriteReferences(object: $plan['object'], ids: $ids, pending: false), @@ -403,7 +409,7 @@ private function writeAll(array $plans, ?IUser $subject): array { schema: $plan['schema'], _rbac: true, _multitenancy: true, - uploadedFiles: ($plan['files'] === [] ? null : $plan['files']), + uploadedFiles: $files, currentUser: $subject, _unowned: $subject === null ); @@ -442,10 +448,11 @@ private function resolveWriteReferences(array $object, array $ids, bool $pending } if (isset($ids[$value['$write']]) === false) { + $message = $this->l10n->t('Write "%1$s" is referenced before it is made.', [$value['$write']]); throw new FormSubmitRefusedException( - message: $this->l10n->t('Write "%1$s" is referenced before it is made.', [$value['$write']]), + message: $message, status: 422, - findings: [['property' => (string)$property, 'code' => 'write-unknown', 'message' => $this->l10n->t('Write "%1$s" is referenced before it is made.', [$value['$write']])]] + findings: [['property' => (string)$property, 'code' => 'write-unknown', 'message' => $message]] ); } @@ -547,7 +554,7 @@ private function refusalFor(Throwable $exception, array $plan): FormSubmitRefuse * * @param array, object: ObjectEntity}> $created The writes. * - * @return array{reference: string, id: string, receivedAt: string|null, confirmation: array, objects: array>} The answer. + * @return array The answer: reference, id, receivedAt, confirmation, objects. */ private function answer(array $created): array { $objects = []; diff --git a/lib/Service/Form/FormUploadStore.php b/lib/Service/Form/FormUploadStore.php index 701668d0d5..df4b29257e 100644 --- a/lib/Service/Form/FormUploadStore.php +++ b/lib/Service/Form/FormUploadStore.php @@ -110,7 +110,10 @@ public function issue(string $formId, string $property, array $rule, array $file $this->refuse( property: $property, code: 'file-too-large', - message: $this->l10n->t('The file is %1$s MB; "%2$s" takes at most %3$s MB.', [$this->megabytes(bytes: $size), $property, $this->megabytes(bytes: $limit)]) + message: $this->l10n->t( + 'The file is %1$s MB; "%2$s" takes at most %3$s MB.', + [$this->megabytes(bytes: $size), $property, $this->megabytes(bytes: $limit)] + ) ); } @@ -160,7 +163,8 @@ public function issue(string $formId, string $property, array $rule, array $file public function materialise(string $formId, string $token): array { $meta = $this->meta(token: $token); if ($meta === null || $meta['formId'] !== $formId || $meta['expiresAt'] <= $this->time->getTime()) { - $this->refuse(property: '', code: 'upload-token-unknown', message: $this->l10n->t('An uploaded file has expired or is unknown. Please add it again.')); + $message = $this->l10n->t('An uploaded file has expired or is unknown. Please add it again.'); + $this->refuse(property: '', code: 'upload-token-unknown', message: $message); } $path = $this->temp->getTemporaryFile(); From a0db816c561383ab58ffd7a24d2d08743ad389da Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 18:06:25 +0200 Subject: [PATCH 044/183] spec(forms): journeyRun is replaced by draft destination objects (decision 180) --- .../form-destination-validator/tasks.md | 2 +- .../or-form-and-journey-registry/proposal.md | 13 +++++----- .../specs/form-and-journey-registry/spec.md | 26 ++++++++++--------- .../or-form-and-journey-registry/tasks.md | 2 +- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/openspec/changes/form-destination-validator/tasks.md b/openspec/changes/form-destination-validator/tasks.md index 0c7e0b5a61..8cfe3d60d8 100644 --- a/openspec/changes/form-destination-validator/tasks.md +++ b/openspec/changes/form-destination-validator/tasks.md @@ -36,7 +36,7 @@ ## 5. Journey registry -- [ ] 5.1 Apply the MODIFIED requirement to `or-form-and-journey-registry` (`journeyRun` replaced by draft destination objects; commit through `FormSubmitService`) +- [x] 5.1 Apply the MODIFIED requirement to `or-form-and-journey-registry` (`journeyRun` replaced by draft destination objects; commit through `FormSubmitService`); its remaining `journeyRun` text is marked superseded ## 6. Verification diff --git a/openspec/changes/or-form-and-journey-registry/proposal.md b/openspec/changes/or-form-and-journey-registry/proposal.md index 4cfd298725..10f59c2f08 100644 --- a/openspec/changes/or-form-and-journey-registry/proposal.md +++ b/openspec/changes/or-form-and-journey-registry/proposal.md @@ -48,12 +48,13 @@ the two can drift, they will. `next` rules using `$defs.visibleWhen` verbatim, `writes[]` per step, and `access` (`anonymous` | `authenticated` | `minTrust`). -**A `journeyRun` stages nothing** (amended by `form-destination-validator`, -decision 179, ADR-117). Objects are written only at steps declaring -`writes[]`, through `FormSubmitService`, all or none, so an abandoned -registration still leaves no half-built organisation. Resumability comes from -the destination object itself saved with lifecycle status `draft` -(decision 180), not from answers held on the run. +**There is no `journeyRun`** (amended by `form-destination-validator`, +decisions 179 and 180, ADR-117). A saved journey is its destination objects in +lifecycle status `draft`, owned by the filer. Objects leave draft only at steps +declaring `writes[]`, through `FormSubmitService`, all or none, so an abandoned +registration still leaves no active half-built organisation. The `journeyRun` +schema, its resume API and its retention below are superseded and are rewritten +when this change is built. **Retention ships with the schema, not after it.** A `journeyRun` holds names, addresses, e-mail, phone numbers and uploads before any of it is a record. diff --git a/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md b/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md index 44111af8da..05b02bb56a 100644 --- a/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md +++ b/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md @@ -64,22 +64,24 @@ canonical `$defs.visibleWhen`; optional per-step `writes[]` of - **WHEN** it is written - **THEN** the write is rejected — access is never inferred -### Requirement: A journey run MUST store no written ids and MUST commit a step all or none +### Requirement: A saved journey MUST be its draft objects and MUST commit a step all or none -Amended by `form-destination-validator` (decision 179, ADR-117; decision 180 -for drafts). The API SHALL expose start, answer, resume and submit operations -over a `journeyRun`. The run SHALL NOT stage answers or store ids of written -objects. A resumable draft is the destination object itself, saved with -lifecycle status `draft` (decision 180), not answers held on the run. Objects -SHALL be created only when a step declaring `writes[]` is submitted, through -`FormSubmitService`, all or none, in declared order, with a later entry able -to reference an earlier entry's id. +Amended by `form-destination-validator` (decision 179, ADR-117; decision 180). +There SHALL be no `journeyRun` object holding answers. A saved journey SHALL be +its `writes[]` destination objects in lifecycle status `draft`, owned by the +filer. Objects SHALL leave `draft`, or be created, only when a step declaring +`writes[]` is submitted, through `FormSubmitService`, all or none, in declared +order, with a later entry able to reference an earlier entry's id. Where this +change still names `journeyRun` (declaration, resume, retention), that text is +superseded by decision 180 and is rewritten when this change is built. -#### Scenario: Advancing without a writes step creates nothing +#### Scenario: Advancing without a writes step activates nothing -- **GIVEN** a run advanced past two steps, neither declaring `writes[]` +- **GIVEN** a journey advanced past two steps and saved, neither step declaring + `writes[]` as submitted - **WHEN** the target registers are queried -- **THEN** no object has been created +- **THEN** any object for this journey is in status `draft`, and no + `journeyRun` object exists #### Scenario: A dependent write receives the preceding write's id diff --git a/openspec/changes/or-form-and-journey-registry/tasks.md b/openspec/changes/or-form-and-journey-registry/tasks.md index f4f3fe43d2..12ba505394 100644 --- a/openspec/changes/or-form-and-journey-registry/tasks.md +++ b/openspec/changes/or-form-and-journey-registry/tasks.md @@ -20,7 +20,7 @@ - **spec_ref**: `openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md#requirement-a-journey-run-must-store-no-written-ids-and-must-commit-a-step-all-or-none` - **files**: `lib/Service/JourneyRunService.php`, `tests/Unit/Service/JourneyRunServiceTest.php` - **acceptance_criteria**: - - No target-register object exists until a step declaring `writes[]` commits, proven by querying the target register mid-run; the run stores no answers and no written ids (decision 179; a draft is the destination object in status `draft`, decision 180) + - No target-register object exists until a step declaring `writes[]` commits, proven by querying the target register mid-run; there is no `journeyRun`: a saved journey is its destination objects in status `draft` (decisions 179, 180) - Writes go through `FormSubmitService::submitAll` (form-destination-validator), in declared order; a later entry resolves a preceding entry's id with `{ "$write": "" }` - A mid-`writes[]` refusal deletes the earlier writes of that step before the response (all or none) - An answer for a field outside the current step is refused — the client does not select its own scope From f1606c6ece02dc825e598a89db94db940d6f5704 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 18:11:05 +0200 Subject: [PATCH 045/183] feat(objects): an object can be a draft: the destination itself, saved to carry on later Decision 180 (Ruben): a draft is the destination object with an explicit lifecycle status. @self.status is stored in a new _status column (draft, active; NULL keeps today's date-deduced status). A draft is validated whatever the hard-validation flag, with only required excused, so a wrong type, format or enum value is always refused. A client may create a draft and keep it one; only the form submit, after full validation, leaves draft, answers with the moment of receipt and dispatches ObjectActivatedEvent. Drafts answer to their owner only, on every list and read path, unless the schema sets draftsVisible. POST /api/forms/{id}/draft saves one; submit with _draft leaves it. info.xml version bump so AddObjectStateColumns adds _status. --- appinfo/info.xml | 2 +- appinfo/routes.php | 1 + docs/features/form-destination.md | 23 ++ l10n/nl.json | 6 +- lib/Controller/FormsController.php | 58 +++++- lib/Db/MagicMapper.php | 16 ++ lib/Db/MagicMapper/MagicRbacHandler.php | 33 ++- lib/Db/MagicMapper/MagicSearchHandler.php | 1 + lib/Db/ObjectEntity.php | 45 ++++ lib/Db/Schema.php | 2 +- lib/Event/ObjectActivatedEvent.php | 71 +++++++ lib/Repair/AddObjectStateColumns.php | 4 +- lib/Service/Form/FormSubmitService.php | 174 ++++++++++++++-- lib/Service/Object/DraftStatusPolicy.php | 196 ++++++++++++++++++ lib/Service/Object/SaveObject.php | 5 + lib/Service/ObjectService.php | 90 ++++++-- .../form-destination-validator/tasks.md | 6 +- tests/Unit/Controller/FormsControllerTest.php | 32 ++- .../MagicRbacDraftVisibilityTest.php | 111 ++++++++++ ...icRbacHandlerRegisterCascadeBypassTest.php | 2 +- tests/Unit/Db/ObjectEntityStatusTest.php | 50 +++++ tests/Unit/Event/ObjectActivatedEventTest.php | 42 ++++ .../Event/ObjectEventProxyEveryEventTest.php | 7 +- .../Service/Form/FormSubmitServiceTest.php | 139 ++++++++++++- .../Service/Object/DraftStatusPolicyTest.php | 141 +++++++++++++ .../Object/SaveObjectDraftStatusTest.php | 75 +++++++ .../ObjectServiceRefactoredMethodsTest.php | 30 +++ 27 files changed, 1308 insertions(+), 54 deletions(-) create mode 100644 lib/Event/ObjectActivatedEvent.php create mode 100644 lib/Service/Object/DraftStatusPolicy.php create mode 100644 tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php create mode 100644 tests/Unit/Db/ObjectEntityStatusTest.php create mode 100644 tests/Unit/Event/ObjectActivatedEventTest.php create mode 100644 tests/Unit/Service/Object/DraftStatusPolicyTest.php create mode 100644 tests/Unit/Service/Object/SaveObjectDraftStatusTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 59b2f0874f..32a31eb839 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.1.35-unstable.20261009130000 + 2.1.35-unstable.20261010180000 EUPL-1.2 Conduction OpenRegister diff --git a/appinfo/routes.php b/appinfo/routes.php index affcfb36f8..1eac3c7a13 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1197,6 +1197,7 @@ ['name' => 'forms#validate', 'url' => '/api/forms/validate', 'verb' => 'POST'], ['name' => 'forms#submit', 'url' => '/api/forms/{formId}/submit', 'verb' => 'POST'], ['name' => 'forms#upload', 'url' => '/api/forms/{formId}/uploads', 'verb' => 'POST'], + ['name' => 'forms#draft', 'url' => '/api/forms/{formId}/draft', 'verb' => 'POST'], ['name' => 'objects#export', 'url' => '/api/objects/{register}/{schema}/export', 'verb' => 'GET'], // BEFORE objects#show, because `{id}` matches `[^/]+` and a route with // a longer path must be declared first or the generic one swallows it. diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md index a0cf6bd531..55a75295ed 100644 --- a/docs/features/form-destination.md +++ b/docs/features/form-destination.md @@ -96,6 +96,29 @@ size and type rules. Put `{ "uploadToken": "" }` in the submit for that property. The token is claimed only when the submit succeeds. An hourly job deletes unclaimed tokens after 24 hours and logs how many. +## Drafts + +A draft is the destination object itself, saved with `@self.status: draft` +(decision 180). There is no separate draft store. + +- `POST /index.php/apps/openregister/api/forms/{formId}/draft` (signed in) + saves the answers so far and returns `{ id, status: "draft" }`. Send `_draft` + with that id to update it. +- A draft may miss required answers. A wrong type, format or enum value is + still refused, whatever the schema's hard-validation flag. +- A draft is visible to its owner only. Set `draftsVisible: true` in the + schema configuration to let everyone who may read the schema see drafts. +- Submit with `_draft` to leave draft. The submit runs full validation, sets + `@self.status` to `active`, answers with the moment it left draft as + `receivedAt`, and dispatches `ObjectActivatedEvent`. +- A client may create an object as a draft. It cannot turn a saved object + into a draft, and only the submit can leave draft. +- An object without a stored status keeps the status it has today. + +Receipt listeners: treat `ObjectCreatedEvent` for an object that is not a +draft, and `ObjectActivatedEvent`, as the moment of receipt. Skip an object +whose `isDraft()` is true. + ## When a schema changes Saving a schema's properties or required list asks the owning apps for their diff --git a/l10n/nl.json b/l10n/nl.json index e8b63ef954..dd65af9c47 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -3432,7 +3432,11 @@ "Please sign in to send this form.": "Log in om dit formulier te versturen.", "This form has no question that takes a file under that name.": "Dit formulier heeft geen vraag die onder die naam een bestand aanneemt.", "Your form \"%1$s\" was taken offline": "Uw formulier \"%1$s\" is offline gehaald", - "A change to \"%1$s\" left %2$s problems in the form. Fix them and publish the form again.": "Een wijziging aan \"%1$s\" liet %2$s problemen achter in het formulier. Los ze op en publiceer het formulier opnieuw." + "A change to \"%1$s\" left %2$s problems in the form. Fix them and publish the form again.": "Een wijziging aan \"%1$s\" liet %2$s problemen achter in het formulier. Los ze op en publiceer het formulier opnieuw.", + "Please sign in to save this form and carry on later.": "Log in om dit formulier op te slaan en later verder te gaan.", + "A draft is one object; a form that writes several cannot leave draft in one step.": "Een concept is één object; een formulier dat er meerdere schrijft kan niet in één stap uit concept.", + "This draft does not exist.": "Dit concept bestaat niet.", + "A draft is one object; a form that writes several cannot be saved as a draft.": "Een concept is één object; een formulier dat er meerdere schrijft kan niet als concept worden opgeslagen." }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php index 3d87f706f0..bd2da159c1 100644 --- a/lib/Controller/FormsController.php +++ b/lib/Controller/FormsController.php @@ -172,12 +172,18 @@ public function submit(string $formId): JSONResponse { $key = null; } + $draftId = trim((string)$this->request->getParam('_draft', '')); + if ($draftId === '') { + $draftId = null; + } + $answer = $this->submitter->submitAll( $form['writes'], $this->payload(), $subject, $key, - $formId + $formId, + $draftId ); } catch (FormSubmitRefusedException $refused) { return new JSONResponse(data: $refused->toBody(), statusCode: $refused->getStatus()); @@ -186,6 +192,56 @@ public function submit(string $formId): JSONResponse { return new JSONResponse(data: $answer, statusCode: 201); }//end submit() + /** + * Save the signed-in subject's draft of this form: the destination object in status `draft`. + * + * Decision 180. Body: the answers so far, and `_draft` to update an + * existing draft. A draft may miss required answers and is never + * type-invalid. It leaves draft through submit with the same `_draft`. + * + * @param string $formId The form object's uuid. + * + * @return JSONResponse 200 `{ id, status }`; 401, 404, 422 or 503 with `{ message, findings }`. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[UserRateLimit(limit: 120, period: 60)] + public function draft(string $formId): JSONResponse { + try { + $form = $this->resolveForm(formId: $formId); + if (count($form['writes']) !== 1) { + $message = $this->l10n->t('A draft is one object; a form that writes several cannot be saved as a draft.'); + throw new FormSubmitRefusedException(message: $message, status: 422); + } + + $draftId = trim((string)$this->request->getParam('_draft', '')); + if ($draftId === '') { + $draftId = null; + } + + $write = $form['writes'][0]; + $mapping = null; + if (is_array($write['mapping'] ?? null) === true) { + $mapping = $write['mapping']; + } + + $draft = $this->submitter->saveDraft( + ['register' => ($write['register'] ?? null), 'schema' => ($write['schema'] ?? null)], + $mapping, + $this->payload(), + $this->userSession->getUser(), + $draftId, + $formId + ); + } catch (FormSubmitRefusedException $refused) { + return new JSONResponse(data: $refused->toBody(), statusCode: $refused->getStatus()); + } + + return new JSONResponse(data: $draft); + }//end draft() + /** * Hold one file for a later submit of this form, checked against the property's file rules. * diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php index 018e999589..4c88a341a6 100644 --- a/lib/Db/MagicMapper.php +++ b/lib/Db/MagicMapper.php @@ -2822,6 +2822,7 @@ public function buildTableColumnsFromSchema(Schema $schema): array { * _deleted: array{name: '_deleted', type: 'json', nullable: true}, * _archived: array{name: '_archived', type: 'json', nullable: true}, * _frozen: array{name: '_frozen', type: 'json', nullable: true}, + * _status: array{name: '_status', type: 'string', length: 16, nullable: true, index: true}, * _geo: array{name: '_geo', type: 'json', nullable: true}, * _retention: array{name: '_retention', type: 'json', nullable: true}, * _groups: array{name: '_groups', type: 'json', nullable: true}} @@ -3018,6 +3019,18 @@ private function getMetadataColumns(): array { 'type' => 'json', 'nullable' => true, ], + // The explicit lifecycle status (decision 180): `draft`, `active`, or + // NULL for every object written before it existed, whose status is + // deduced from its dates as before. A column, not a JSON member, + // because the list query hides other people's drafts in SQL. Added + // to existing tables by addMissingColumns(), like `_frozen`. + self::METADATA_PREFIX . 'status' => [ + 'name' => self::METADATA_PREFIX . 'status', + 'type' => 'string', + 'length' => 16, + 'nullable' => true, + 'index' => true, + ], self::METADATA_PREFIX . 'geo' => [ 'name' => self::METADATA_PREFIX . 'geo', 'type' => 'json', @@ -4259,6 +4272,9 @@ private function prepareObjectDataForTable(array $objectData, Register $register // write that could ever clear it. 'archived', 'frozen', + // Carried forward like `frozen`: setSelfMetadata() lets a client + // set `draft` on create only, and only the submit leaves it. + 'status', 'geo', 'retention', 'groups', diff --git a/lib/Db/MagicMapper/MagicRbacHandler.php b/lib/Db/MagicMapper/MagicRbacHandler.php index f3a385e3df..5aeafe974d 100644 --- a/lib/Db/MagicMapper/MagicRbacHandler.php +++ b/lib/Db/MagicMapper/MagicRbacHandler.php @@ -41,6 +41,7 @@ namespace OCA\OpenRegister\Db\MagicMapper; use InvalidArgumentException; +use OCA\OpenRegister\Service\Object\DraftStatusPolicy; use OCA\OpenRegister\Service\AnonymousEvaluationContext; use OCA\OpenRegister\Service\SystemOperationContext; use OCA\OpenRegister\Db\Schema; @@ -325,6 +326,8 @@ private function ownerAdmitConditionsSql(array $userGroups, ?string $userId, str * @param string $uuidColumn The `_uuid` column as this emitter references it. * @param string|null $userId The caller, for grant resolution. * @param string $action The action being filtered; a grant only counts when it carries that permission. + * @param Schema|null $schema The schema, so its drafts can be kept to their owners (decision 180). + * @param string $columnPrefix Table alias with its dot for the `_status` column, empty when unaliased. * * @return string A SQL predicate true for rows this caller may reach. * @@ -336,14 +339,27 @@ private function reachableRowSqlFor( string $uuidColumn, ?string $userId, string $action, + ?Schema $schema = null, + string $columnPrefix = '', ): string { - return $this->objectScope()->notPrivateOrGrantedSql( + $reachable = $this->objectScope()->notPrivateOrGrantedSql( authColumn: $columnName, defaultPrivate: $this->objectScope()->schemaDefaultIsPrivate(schemaAuthorization: $authorization), isPostgres: $this->isPostgres(), uuidColumn: $uuidColumn, quotedUuids: $this->quotedGrantedUuids(userId: $userId, action: $action) ); + + // Decision 180: a draft answers to its owner only, like a private + // object, unless the schema lets others see drafts. Added HERE, the one + // predicate both list emitters use, so the owner admits beside it keep + // the owner's own drafts in view on every path. + $drafts = new DraftStatusPolicy(); + if ($schema !== null && $drafts->draftsVisibleToOthers(schema: $schema) === false) { + $reachable = '(' . $reachable . ' AND ' . $drafts->notADraftSql(columnPrefix: $columnPrefix) . ')'; + } + + return $reachable; }//end reachableRowSqlFor() /** @@ -576,7 +592,9 @@ public function applyRbacFilters( columnName: 't._authorization', uuidColumn: 't._uuid', userId: $userId, - action: $action + action: $action, + schema: $schema, + columnPrefix: 't.' ) ); @@ -1746,7 +1764,8 @@ public function buildRbacConditionsSql(Schema $schema, string $action = 'read', action: $action, userId: $userId, userGroups: $userGroups, - columnPrefix: $columnPrefix + columnPrefix: $columnPrefix, + schema: $schema ); if ($terms === null) { return ['bypass' => false, 'conditions' => []]; @@ -1820,6 +1839,7 @@ public function buildRbacConditionsSql(Schema $schema, string $action = 'read', * UNION member. Unqualified, a column name still parses inside a * subquery and binds to the innermost FROM, which is silently wrong * the moment the related table does not carry it. + * @param Schema|null $schema The schema, so its drafts can be kept to their owners (decision 180). * * @return array{denyTerm: string|null, notPrivate: string, ownerAdmits: string[]}|null The term pieces, or null * when the deny term @@ -1830,7 +1850,8 @@ private function buildRbacSqlTerms( string $action, ?string $userId, array $userGroups, - string $columnPrefix = '' + string $columnPrefix = '', + ?Schema $schema = null ): ?array { $denyTerm = $this->denyFilterSqlFor( authorization: $authorization, @@ -1849,7 +1870,9 @@ private function buildRbacSqlTerms( columnName: $columnPrefix . '_authorization', uuidColumn: $columnPrefix . '_uuid', userId: $userId, - action: $action + action: $action, + schema: $schema, + columnPrefix: $columnPrefix ); $ownerAdmits = $this->ownerAdmitConditionsSql( diff --git a/lib/Db/MagicMapper/MagicSearchHandler.php b/lib/Db/MagicMapper/MagicSearchHandler.php index ab43bfa2e6..4304680bd8 100644 --- a/lib/Db/MagicMapper/MagicSearchHandler.php +++ b/lib/Db/MagicMapper/MagicSearchHandler.php @@ -148,6 +148,7 @@ class MagicSearchHandler { '_deleted', '_archived', '_frozen', + '_status', '_locked', '_files', '_relations', diff --git a/lib/Db/ObjectEntity.php b/lib/Db/ObjectEntity.php index 2e16458585..709abd3b3c 100644 --- a/lib/Db/ObjectEntity.php +++ b/lib/Db/ObjectEntity.php @@ -100,6 +100,8 @@ * @method void setArchived(?array $archived) * @method array|null getFrozen() * @method void setFrozen(?array $frozen) + * @method string|null getStatus() + * @method void setStatus(?string $status) * @method array|null getGeo() * @method void setGeo(?array $geo) * @method array|null getRetention() @@ -166,6 +168,21 @@ */ class ObjectEntity extends Entity implements JsonSerializable, ObjectEntityInterface { + /** + * Lifecycle status: saved to carry on later, not yet received (decision 180). + * + * @var string + */ + public const STATUS_DRAFT = 'draft'; + + /** + * Lifecycle status: left draft through a submit, received. + * + * @var string + */ + public const STATUS_ACTIVE = 'active'; + + /** * Unique identifier for the object. * @@ -347,6 +364,18 @@ class ObjectEntity extends Entity implements JsonSerializable, ObjectEntityInter */ protected ?array $frozen = []; + /** + * The explicit lifecycle status, when one is stored (decision 180). + * + * `draft`: the object may miss required properties, is never type-invalid, + * and is visible to its owner only unless the schema says otherwise. + * `active`: it left draft through a submit. Null: no explicit status, so the + * status is deduced from the object's dates exactly as before. + * + * @var string|null + */ + protected ?string $status = null; + /** * Geographical details for the object. * @@ -1101,6 +1130,7 @@ public function __construct() { $this->addType(fieldName: 'deleted', type: 'json'); $this->addType(fieldName: 'archived', type: 'json'); $this->addType(fieldName: 'frozen', type: 'json'); + $this->addType(fieldName: 'status', type: 'string'); $this->addType(fieldName: 'geo', type: 'json'); $this->addType(fieldName: 'retention', type: 'json'); $this->addType(fieldName: 'tmlo', type: 'json'); @@ -1423,6 +1453,7 @@ public function getObjectArray(array $object = []): array { 'deleted' => $this->getDeleted(), 'archived' => $this->getArchived(), 'frozen' => $this->getFrozen(), + 'status' => $this->status, 'source' => $this->source, 'mail' => $this->getMail(), 'contacts' => $this->getContacts(), @@ -2099,6 +2130,20 @@ public function isFrozen(): bool { return $this->frozen !== null && $this->frozen !== []; }//end isFrozen() + /** + * Whether this object is a draft (decision 180). + * + * An object with no stored status is never a draft: its status is deduced + * from its dates, as it was before the field existed. + * + * @return bool True for a stored `draft` status. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function isDraft(): bool { + return $this->status === self::STATUS_DRAFT; + }//end isDraft() + /** * Archive the object. * diff --git a/lib/Db/Schema.php b/lib/Db/Schema.php index def40ef664..f53b4f999b 100644 --- a/lib/Db/Schema.php +++ b/lib/Db/Schema.php @@ -2880,7 +2880,7 @@ private function validateConfigurationArray(array $configuration): array { // field into an undeclared property is a finding unless the schema opts // into extras. Off this list both were dropped on save, so neither could // ever be declared. - $boolFields = ['allowFiles', 'autoPublish', 'defaultAutoShare', 'exportable', 'staging', 'additionalProperties']; + $boolFields = ['allowFiles', 'autoPublish', 'defaultAutoShare', 'exportable', 'staging', 'additionalProperties', 'draftsVisible']; // `implements` + `x-schema-org` carry the cross-app semantic-type // markers (ADR-048); they must round-trip through the configuration // column so SemanticTypeResolver can discover the schema. Their IRI diff --git a/lib/Event/ObjectActivatedEvent.php b/lib/Event/ObjectActivatedEvent.php new file mode 100644 index 0000000000..338c69b4bb --- /dev/null +++ b/lib/Event/ObjectActivatedEvent.php @@ -0,0 +1,71 @@ + + * 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/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use DateTimeImmutable; +use OCA\OpenRegister\Db\ObjectEntity; +use OCP\EventDispatcher\Event; + +/** + * Receipt listeners subscribe here. + * + * An object created straight into `active` (no draft) is received at create: + * a receipt listener treats `ObjectCreatedEvent` for a non-draft object and + * this event as the same moment. A draft's own create and updates are not a + * receipt, so a receipt listener skips an object whose `isDraft()` is true. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ +class ObjectActivatedEvent extends Event { + + /** + * Constructor. + * + * @param ObjectEntity $object The object, now `active`. + * @param DateTimeImmutable $receivedAt The moment it left draft. + */ + public function __construct( + private readonly ObjectEntity $object, + private readonly DateTimeImmutable $receivedAt, + ) { + parent::__construct(); + }//end __construct() + + /** + * The activated object. + * + * @return ObjectEntity The object. + */ + public function getObject(): ObjectEntity { + return $this->object; + }//end getObject() + + /** + * The moment of receipt. + * + * @return DateTimeImmutable The moment. + */ + public function getReceivedAt(): DateTimeImmutable { + return $this->receivedAt; + }//end getReceivedAt() +}//end class diff --git a/lib/Repair/AddObjectStateColumns.php b/lib/Repair/AddObjectStateColumns.php index 212d3b3291..8539f3d815 100644 --- a/lib/Repair/AddObjectStateColumns.php +++ b/lib/Repair/AddObjectStateColumns.php @@ -79,7 +79,7 @@ public function __construct( * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md */ public function getName(): string { - return 'Add the archive and freeze columns to existing object tables'; + return 'Add the archive, freeze and lifecycle status columns to existing object tables'; }//end getName() /** @@ -117,7 +117,7 @@ public function run(IOutput $output): void { $output->info( sprintf( - 'Reconciled %d object table(s) for the archive and freeze columns, %d could not be read.', + 'Reconciled %d object table(s) for the archive, freeze and lifecycle status columns, %d could not be read.', $reconciled, $failed ) diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index b213d15482..d275e2a9b0 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -39,17 +39,22 @@ namespace OCA\OpenRegister\Service\Form; +use DateTimeImmutable; use DateTimeInterface; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Register; use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Event\ObjectActivatedEvent; use OCA\OpenRegister\Exception\CustomValidationException; use OCA\OpenRegister\Exception\FormSubmitRefusedException; use OCA\OpenRegister\Exception\NotAuthorizedException; +use OCA\OpenRegister\Service\Object\DraftStatusPolicy; use OCA\OpenRegister\Service\Object\ValidateObject; use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IL10N; use OCP\IUser; use Psr\Log\LoggerInterface; @@ -85,6 +90,8 @@ class FormSubmitService { * @param FormUploadStore $uploads Holds and releases upload tokens. * @param IL10N $l10n Translations, for answers a resident reads. * @param LoggerInterface $logger Records each compensation. + * @param IEventDispatcher $events Announces a draft that left draft (receipt). + * @param ITimeFactory $time The moment of receipt. */ public function __construct( private readonly RegisterMapper $registers, @@ -96,6 +103,8 @@ public function __construct( private readonly FormUploadStore $uploads, private readonly IL10N $l10n, private readonly LoggerInterface $logger, + private readonly IEventDispatcher $events, + private readonly ITimeFactory $time, ) { }//end __construct() @@ -109,6 +118,7 @@ public function __construct( * @param IUser|null $subject The signed-in subject; null for an anonymous submit. * @param string|null $idempotencyKey The Idempotency-Key, when the caller sent one. * @param string $scope The form id the key and upload tokens belong to. + * @param string|null $draftId The subject's draft of this destination, to leave draft. * * @return array The answer: reference, id, receivedAt, confirmation, objects. * @@ -123,16 +133,62 @@ public function submit( ?IUser $subject = null, ?string $idempotencyKey = null, string $scope = '', + ?string $draftId = null, ): array { return $this->submitAll( writes: [array_merge($destination, ['mapping' => $mapping])], payload: $payload, subject: $subject, idempotencyKey: $idempotencyKey, - scope: $scope + scope: $scope, + draftId: $draftId ); }//end submit() + /** + * Save the subject's draft of a destination: the destination object itself, in status `draft`. + * + * Decision 180. The draft may miss required properties; every other rule + * holds, so a wrong type, format or enum value is refused. A draft is the + * signed-in subject's own; an anonymous visitor gets 401 (resuming an + * anonymous draft needs a resume token, question Q-openregister-F2). + * + * @param array{register?: mixed, schema?: mixed} $destination The resolved destination pair. + * @param array|null $mapping The form's mapping. + * @param array $payload What the subject filled in so far. + * @param IUser|null $subject The signed-in subject. + * @param string|null $draftId The draft to update; null starts one. + * @param string $scope The form id. + * + * @return array{id: string, status: string} The draft. + * + * @throws FormSubmitRefusedException 401 anonymous, 404 unknown draft, 422 type-invalid, 503. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function saveDraft( + array $destination, + ?array $mapping, + array $payload, + ?IUser $subject, + ?string $draftId = null, + string $scope = '', + ): array { + if ($subject === null) { + throw new FormSubmitRefusedException(message: $this->l10n->t('Please sign in to save this form and carry on later.'), status: 401); + } + + $plans = $this->plan(writes: [array_merge($destination, ['mapping' => $mapping])], payload: $payload, scope: $scope); + if ($draftId !== null) { + $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId), $plans[0]['object']); + } + + $this->validateAll(plans: $plans, draft: true); + $created = $this->writeAll(plans: $plans, subject: $subject, status: ObjectEntity::STATUS_DRAFT, uuid: $draftId); + + return ['id' => (string)$created[0]['object']->getUuid(), 'status' => ObjectEntity::STATUS_DRAFT]; + }//end saveDraft() + /** * Submit one payload into several destinations, all or none. * @@ -146,6 +202,7 @@ public function submit( * @param IUser|null $subject The signed-in subject; null for anonymous. * @param string|null $idempotencyKey The Idempotency-Key, when sent. * @param string $scope The form id the key and tokens belong to. + * @param string|null $draftId The subject's draft to leave draft (one write only). * * @return array The answer: reference, id, receivedAt, confirmation, objects. * @@ -159,6 +216,7 @@ public function submitAll( ?IUser $subject = null, ?string $idempotencyKey = null, string $scope = '', + ?string $draftId = null, ): array { $key = trim((string)$idempotencyKey); $keyScope = $this->keyScope(scope: $scope, writes: $writes); @@ -173,11 +231,16 @@ public function submitAll( } $plans = $this->plan(writes: $writes, payload: $payload, scope: $scope); - $this->validateAll(plans: $plans); - $created = $this->writeAll(plans: $plans, subject: $subject); + if ($draftId === null) { + $this->validateAll(plans: $plans); + $created = $this->writeAll(plans: $plans, subject: $subject); + $receivedAt = null; + } else { + [$created, $receivedAt] = $this->promoteDraft(plans: $plans, subject: $subject, draftId: $draftId); + } $this->uploads->claim(tokens: array_merge(...array_map(static fn (array $plan): array => $plan['tokens'], $plans))); - $answer = $this->answer(created: $created); + $answer = $this->answer(created: $created, receivedAt: $receivedAt); if ($key !== '') { $this->keys->remember(scope: $keyScope, key: $key, response: $answer); } @@ -185,6 +248,69 @@ public function submitAll( return $answer; }//end submitAll() + /** + * Leave draft: full validation of the draft plus the payload, then one allowed promotion, then receipt. + * + * @param array> $plans The plans (exactly one). + * @param IUser|null $subject The subject. + * @param string $draftId The draft. + * + * @return array{0: array, object: ObjectEntity}>, 1: DateTimeImmutable} The write and the moment of receipt. + * + * @throws FormSubmitRefusedException 404 unknown draft, 422 incomplete or several writes, 403, 503. + */ + private function promoteDraft(array $plans, ?IUser $subject, string $draftId): array { + if (count($plans) !== 1) { + $message = $this->l10n->t('A draft is one object; a form that writes several cannot leave draft in one step.'); + throw new FormSubmitRefusedException(message: $message, status: 422); + } + + $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId), $plans[0]['object']); + $this->validateAll(plans: $plans); + + DraftStatusPolicy::allowPromotion(uuid: $draftId); + try { + $created = $this->writeAll(plans: $plans, subject: $subject, status: ObjectEntity::STATUS_ACTIVE, uuid: $draftId); + } finally { + DraftStatusPolicy::resetPromotions(); + } + + $receivedAt = DateTimeImmutable::createFromMutable($this->time->getDateTime()); + $this->events->dispatchTyped(new ObjectActivatedEvent(object: $created[0]['object'], receivedAt: $receivedAt)); + + return [$created, $receivedAt]; + }//end promoteDraft() + + /** + * The stored data of the subject's draft, or 404 when it is not theirs or not a draft. + * + * Read under RBAC, so another person's draft is not found (drafts answer + * to their owner only, decision 180). + * + * @param array $plan The write. + * @param string $draftId The draft's uuid. + * + * @return array The draft's data, without its metadata. + * + * @throws FormSubmitRefusedException 404. + */ + private function draftData(array $plan, string $draftId): array { + try { + $draft = $this->objects->find(id: $draftId, register: $plan['register'], schema: $plan['schema'], _rbac: true, _multitenancy: true); + } catch (Throwable) { + $draft = null; + } + + if ($draft === null || $draft->isDraft() === false) { + throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404); + } + + $data = $draft->getObject(); + unset($data['id'], $data['@self']); + + return $data; + }//end draftData() + /** * Resolve each write's destination and map the payload onto it. * @@ -318,12 +444,13 @@ private function tokenOf(mixed $value): ?string { * Validate every write with its destination's full validator before the first write. * * @param array> $plans The plans. + * @param bool $draft True for a draft: every `required` is excused, nothing else. * * @return void * * @throws FormSubmitRefusedException 422 with every finding of every write. */ - private function validateAll(array $plans): void { + private function validateAll(array $plans, bool $draft = false): void { $findings = []; foreach ($plans as $plan) { $write = null; @@ -332,11 +459,16 @@ private function validateAll(array $plans): void { } $object = $this->resolveWriteReferences(object: $plan['object'], ids: [], pending: true); + $excused = $this->serverFilled(schema: $plan['schema'], object: $object); + if ($draft === true) { + $excused = array_merge((new DraftStatusPolicy())->requiredExcusals(schema: $plan['schema']), $excused); + } + try { $result = $this->validator->validateObject( object: $object, schema: $plan['schema'], - notSupplied: $this->serverFilled(schema: $plan['schema'], object: $object) + notSupplied: $excused ); array_push($findings, ...$this->findings->fromResult(result: $result, write: $write)); } catch (CustomValidationException $exception) { @@ -388,12 +520,14 @@ private function serverFilled(Schema $schema, array $object): array { * * @param array> $plans The plans. * @param IUser|null $subject The subject. + * @param string|null $status The lifecycle status to store (`draft`, `active`), or none. + * @param string|null $uuid The object to update (a draft), for a single write. * * @return array, object: ObjectEntity}> The created objects, in order. * * @throws FormSubmitRefusedException 422, 403 or 503 after compensation. */ - private function writeAll(array $plans, ?IUser $subject): array { + private function writeAll(array $plans, ?IUser $subject, ?string $status = null, ?string $uuid = null): array { $created = []; $ids = []; foreach ($plans as $plan) { @@ -402,11 +536,17 @@ private function writeAll(array $plans, ?IUser $subject): array { $files = $plan['files']; } + $object = $this->resolveWriteReferences(object: $plan['object'], ids: $ids, pending: false); + if ($status !== null) { + $object['@self'] = ['status' => $status]; + } + try { $entity = $this->objects->saveObject( - object: $this->resolveWriteReferences(object: $plan['object'], ids: $ids, pending: false), + object: $object, register: $plan['register'], schema: $plan['schema'], + uuid: $uuid, _rbac: true, _multitenancy: true, uploadedFiles: $files, @@ -552,11 +692,12 @@ private function refusalFor(Throwable $exception, array $plan): FormSubmitRefuse /** * The answer: the first write's reference, id, received moment and confirmation; every write listed. * - * @param array, object: ObjectEntity}> $created The writes. + * @param array, object: ObjectEntity}> $created The writes. + * @param DateTimeImmutable|null $receivedAt When a draft left draft; null uses the created moment. * * @return array The answer: reference, id, receivedAt, confirmation, objects. */ - private function answer(array $created): array { + private function answer(array $created, ?DateTimeImmutable $receivedAt = null): array { $objects = []; $first = null; foreach ($created as $done) { @@ -591,16 +732,21 @@ private function answer(array $created): array { } } - $receivedAt = null; + // A draft is received when it leaves draft, not when it was first saved. + $received = null; + if ($receivedAt !== null) { + $received = $receivedAt->format(DateTimeInterface::ATOM); + } + $created = $first['entity']->getCreated(); - if ($created instanceof DateTimeInterface) { - $receivedAt = $created->format(DateTimeInterface::ATOM); + if ($received === null && $created instanceof DateTimeInterface) { + $received = $created->format(DateTimeInterface::ATOM); } return [ 'reference' => $first['entry']['reference'], 'id' => $first['entry']['id'], - 'receivedAt' => $receivedAt, + 'receivedAt' => $received, 'confirmation' => $confirmation, 'objects' => $objects, ]; diff --git a/lib/Service/Object/DraftStatusPolicy.php b/lib/Service/Object/DraftStatusPolicy.php new file mode 100644 index 0000000000..79cd437a0c --- /dev/null +++ b/lib/Service/Object/DraftStatusPolicy.php @@ -0,0 +1,196 @@ + + * 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/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Object; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Exception\ValidationException; + +/** + * Set, keep, leave and hide drafts. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ +class DraftStatusPolicy { + + /** + * Object uuids the submit has validated in full and may promote, once. + * + * @var array + */ + private static array $promotions = []; + + /** + * Allow one promotion out of draft for an object, after full validation. + * + * @param string $uuid The object. + * + * @return void + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public static function allowPromotion(string $uuid): void { + self::$promotions[$uuid] = true; + }//end allowPromotion() + + /** + * Forget every allowance (tests, and a submit that was refused after allowing). + * + * @return void + */ + public static function resetPromotions(): void { + self::$promotions = []; + }//end resetPromotions() + + /** + * The status a save stores, given what the entity holds and what `@self` asks. + * + * @param ObjectEntity $entity The object being saved (new when it has no id). + * @param array $selfData The `@self` the caller sent. + * + * @return string|null The status to store; null keeps the date-deduced status. + * + * @throws ValidationException When the move is not allowed. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function resolveStatus(ObjectEntity $entity, array $selfData): ?string { + $current = $entity->getStatus(); + if (array_key_exists('status', $selfData) === false || $selfData['status'] === null || $selfData['status'] === '') { + return $current; + } + + $asked = (string)$selfData['status']; + if ($asked === $current) { + return $current; + } + + if ($asked === ObjectEntity::STATUS_DRAFT) { + if ($entity->getId() === null) { + return ObjectEntity::STATUS_DRAFT; + } + + throw new ValidationException(message: 'A saved object cannot go back to draft.'); + } + + if ($asked === ObjectEntity::STATUS_ACTIVE && $current === ObjectEntity::STATUS_DRAFT) { + $uuid = (string)$entity->getUuid(); + if (isset(self::$promotions[$uuid]) === true) { + unset(self::$promotions[$uuid]); + + return ObjectEntity::STATUS_ACTIVE; + } + + throw new ValidationException(message: 'A draft leaves draft through the form submit, which checks every required field first.'); + } + + throw new ValidationException(message: sprintf('Status "%s" is not one an object can be given here.', $asked)); + }//end resolveStatus() + + /** + * Whether a write saves a draft: the payload asks for draft, or the stored object is one and the payload does not leave it. + * + * @param array $object The payload, with `@self` when the caller sent one. + * @param ObjectEntity|null $existing The stored object, null on create. + * + * @return bool True when the write is a draft write. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function isDraftWrite(array $object, ?ObjectEntity $existing): bool { + $asked = null; + if (is_array($object['@self'] ?? null) === true && is_string($object['@self']['status'] ?? null) === true) { + $asked = $object['@self']['status']; + } + + if ($asked !== null) { + return $asked === ObjectEntity::STATUS_DRAFT; + } + + return $existing !== null && $existing->isDraft() === true; + }//end isDraftWrite() + + /** + * The `required` entries a draft is excused from: all of them, list and property-level. + * + * @param Schema $schema The schema. + * + * @return array Property => reason, in ValidateObject's notSupplied shape. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function requiredExcusals(Schema $schema): array { + $excused = []; + foreach ($schema->getRequired() as $name) { + $excused[(string)$name] = ObjectEntity::STATUS_DRAFT; + } + + foreach ($schema->getProperties() as $name => $property) { + if (is_array($property) === true && ($property['required'] ?? false) === true) { + $excused[(string)$name] = ObjectEntity::STATUS_DRAFT; + } + } + + return $excused; + }//end requiredExcusals() + + /** + * Whether the schema lets people other than the owner see its drafts. + * + * @param Schema $schema The schema. + * + * @return bool True when its configuration sets `draftsVisible: true`. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function draftsVisibleToOthers(Schema $schema): bool { + return (($schema->getConfiguration() ?? [])['draftsVisible'] ?? false) === true; + }//end draftsVisibleToOthers() + + /** + * The SQL clause true for rows that are not drafts, for both list emitters. + * + * @param string $columnPrefix The table alias with its dot, or empty. + * + * @return string The predicate. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function notADraftSql(string $columnPrefix): string { + $column = $columnPrefix . '_status'; + + return '(' . $column . ' IS NULL OR ' . $column . " <> '" . ObjectEntity::STATUS_DRAFT . "')"; + }//end notADraftSql() +}//end class diff --git a/lib/Service/Object/SaveObject.php b/lib/Service/Object/SaveObject.php index 71a6eea34c..6fe327566d 100644 --- a/lib/Service/Object/SaveObject.php +++ b/lib/Service/Object/SaveObject.php @@ -4575,6 +4575,11 @@ private function setSelfMetadata( // endpoint that does change an owner. See refuseOwnerClaim(). $this->refuseOwnerClaim(objectEntity: $objectEntity, selfData: $selfData); + // Decision 180: `@self.status`. A client may create an object as a + // draft and keep it one; it may not turn a saved object into a draft, + // and only the form submit (after full validation) may leave draft. + $objectEntity->setStatus((new DraftStatusPolicy())->resolveStatus(entity: $objectEntity, selfData: $selfData)); + // SECURITY (wave-11 SB1 / wave-12 Fix 3): organisation must only be accepted from // @self when the caller is an admin or has verified membership in that organisation. // Blindly applying a client-supplied organisation UUID allows any authenticated diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index f2bdde82f7..8bb8147378 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -51,6 +51,7 @@ use OCA\OpenRegister\Db\ViewMapper; use OCA\OpenRegister\Service\DateTimeNormalizer; use OCA\OpenRegister\Service\Lifecycle\AutoTransitionPass; +use OCA\OpenRegister\Service\Object\DraftStatusPolicy; use OCA\OpenRegister\Service\Object\CacheHandler; use OCA\OpenRegister\Service\Schemas\SchemaCacheHandler; use OCA\OpenRegister\Service\Schemas\FacetCacheHandler; @@ -1902,7 +1903,12 @@ public function saveObject( $this->enforceDeclaredShapes(object: $object); // Validate if hard validation is enabled. - $this->validateObjectIfRequired(object: $object); + $updateUuid = $uuid; + if ($uuidWasNull === true) { + $updateUuid = null; + } + + $this->validateObjectIfRequired(object: $object, uuid: $updateUuid); \OCA\OpenRegister\Service\WritePhaseProbe::mark('validate'); // Wave-12 Fix 1: enforce JSON-Schema `readOnly: true` on UPDATE. @@ -2352,15 +2358,22 @@ private function handleCascadingWithContextPreservation(array $object, ?string $ }//end handleCascadingWithContextPreservation() /** - * Validate object if hard validation is enabled. + * Validate an object when its schema has hard validation on, or when the write is a draft. * - * @param array $object Object data to validate + * A draft (decision 180) is validated whatever the hard-validation flag: + * it may miss required properties, so `required` is excused, but a wrong + * type, format or enum value is always refused. + * + * @param array $object Object data to validate + * @param string|null $uuid The object's uuid on an update, null on a create * * @return void * * @throws ValidationException If validation fails + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft */ - private function validateObjectIfRequired(array $object): void + private function validateObjectIfRequired(array $object, ?string $uuid=null): void { // BUG-OBJ-4: guard against a null schema reaching the // ->getHardValidation() dereference (raw TypeError 500). Callers @@ -2372,27 +2385,64 @@ private function validateObjectIfRequired(array $object): void ); } - // Validate the object against the current schema only if hard validation is enabled. - if ($this->currentSchema->getHardValidation() === true) { - // A property recorded as not supplied carries no value and is - // excused from the required rule, so the validator sees neither - // the record nor the properties it names. The record itself stays - // on the body that gets stored, which is how it reads back. - $notSupplied = $this->notSupplied()->declared(object: $object); + $draftPolicy = new DraftStatusPolicy(); + $isDraft = $draftPolicy->isDraftWrite(object: $object, existing: $this->existingForDraftCheck(object: $object, uuid: $uuid)); - $result = $this->validateHandler->validateObject( - object: $this->notSupplied()->stripForValidation(object: $object), - schema: $this->currentSchema, - notSupplied: $notSupplied - ); + if ($this->currentSchema->getHardValidation() !== true && $isDraft === false) { + return; + } - if ($result->isValid() === false) { - $meaningfulMessage = $this->validateHandler->generateErrorMessage(result: $result); - throw new ValidationException(message: $meaningfulMessage, errors: $result->error()); - } + // A property recorded as not supplied carries no value and is + // excused from the required rule, so the validator sees neither + // the record nor the properties it names. The record itself stays + // on the body that gets stored, which is how it reads back. + $notSupplied = $this->notSupplied()->declared(object: $object); + if ($isDraft === true) { + $notSupplied = array_merge($draftPolicy->requiredExcusals(schema: $this->currentSchema), $notSupplied); + } + + $result = $this->validateHandler->validateObject( + object: $this->notSupplied()->stripForValidation(object: $object), + schema: $this->currentSchema, + notSupplied: $notSupplied + ); + + if ($result->isValid() === false) { + $meaningfulMessage = $this->validateHandler->generateErrorMessage(result: $result); + throw new ValidationException(message: $meaningfulMessage, errors: $result->error()); } }//end validateObjectIfRequired() + /** + * The stored object, for deciding whether an update keeps a draft; null when the payload says. + * + * Only looked up when the payload names no status and the write is an + * update, so an ordinary create costs nothing. + * + * @param array $object The payload. + * @param string|null $uuid The uuid on an update. + * + * @return ObjectEntity|null The stored object, or null. + */ + private function existingForDraftCheck(array $object, ?string $uuid): ?ObjectEntity + { + if ($uuid === null || is_string($object['@self']['status'] ?? null) === true) { + return null; + } + + try { + return $this->objectMapper->find( + $uuid, + register: $this->currentRegister, + schema: $this->currentSchema, + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable) { + return null; + } + }//end existingForDraftCheck() + /** * The repeating-group validator, constructed on first use. * diff --git a/openspec/changes/form-destination-validator/tasks.md b/openspec/changes/form-destination-validator/tasks.md index 0c7e0b5a61..886d108c3d 100644 --- a/openspec/changes/form-destination-validator/tasks.md +++ b/openspec/changes/form-destination-validator/tasks.md @@ -19,11 +19,11 @@ ## 2b. Lifecycle status `draft` (decision 180) -- [ ] 2b.1 Stored `@self.status` in object metadata (`draft`, `active`); objects without it keep the date-deduced status +- [x] 2b.1 Stored `@self.status` in object metadata (`draft`, `active`); objects without it keep the date-deduced status - Spec ref: specs/form-destination/spec.md, "An object MUST be able to carry the explicit lifecycle status `draft`" - Test: unit tests for each scenario, including the legacy read control -- [ ] 2b.2 Draft validation skips `required` only; the draft-to-active move goes through `FormSubmitService` with full validation and receipt effects -- [ ] 2b.3 Flagged to Ruben, not built here: staff list default for drafts, draft expiry +- [x] 2b.2 Draft validation skips `required` only; the draft-to-active move goes through `FormSubmitService` with full validation and receipt effects +- [ ] 2b.3 Flagged to Ruben, not built here: staff list default for drafts, draft expiry, anonymous drafts (Q-openregister-F2) ## 3. Uploads diff --git a/tests/Unit/Controller/FormsControllerTest.php b/tests/Unit/Controller/FormsControllerTest.php index c766eef078..b3e03b9dad 100644 --- a/tests/Unit/Controller/FormsControllerTest.php +++ b/tests/Unit/Controller/FormsControllerTest.php @@ -158,7 +158,7 @@ public function testASubmitAnswers201(): void { $this->resolver->method('resolve')->with('form-1')->willReturn($this->form()); $answer = ['reference' => '2026-0412', 'id' => 'u', 'receivedAt' => null, 'confirmation' => [], 'objects' => []]; $this->submitter->expects($this->once())->method('submitAll') - ->with($this->form()['writes'], ['onderwerp' => 'Kapvergunning'], null, 'k1', 'form-1') + ->with($this->form()['writes'], ['onderwerp' => 'Kapvergunning'], null, 'k1', 'form-1', null) ->willReturn($answer); $response = $this->controller->submit(formId: 'form-1'); @@ -299,8 +299,38 @@ public function testTheRoutesAreRegistered(): void { 'forms#validate' => ['/api/forms/validate', 'POST'], 'forms#submit' => ['/api/forms/{formId}/submit', 'POST'], 'forms#upload' => ['/api/forms/{formId}/uploads', 'POST'], + 'forms#draft' => ['/api/forms/{formId}/draft', 'POST'], ], $found ); }//end testTheRoutesAreRegistered() + /** + * Decision 180: a signed-in subject saves a draft; submit with `_draft` leaves it. + */ + public function testADraftIsSavedAndSubmittedByItsId(): void { + $user = $this->createMock(IUser::class); + $this->session->method('getUser')->willReturn($user); + $this->resolver->method('resolve')->willReturn($this->form()); + $this->params = ['formId' => 'form-1', 'onderwerp' => 'Half af', '_draft' => 'd-1']; + $this->submitter->expects($this->once())->method('saveDraft') + ->with(['register' => 'dossiq', 'schema' => 'case'], null, ['onderwerp' => 'Half af'], $user, 'd-1', 'form-1') + ->willReturn(['id' => 'd-1', 'status' => 'draft']); + $this->submitter->expects($this->once())->method('submitAll') + ->with($this->anything(), ['onderwerp' => 'Half af'], $user, null, 'form-1', 'd-1') + ->willReturn(['reference' => 'r', 'id' => 'd-1', 'receivedAt' => 'now', 'confirmation' => [], 'objects' => []]); + + $saved = $this->controller->draft(formId: 'form-1'); + $this->assertSame(200, $saved->getStatus()); + $this->assertSame(['id' => 'd-1', 'status' => 'draft'], $saved->getData()); + + $this->assertSame(201, $this->controller->submit(formId: 'form-1')->getStatus()); + }//end testADraftIsSavedAndSubmittedByItsId() + + /** + * The draft route is for signed-in people and is not public. + */ + public function testTheDraftRouteIsNotPublic(): void { + $this->assertContains(NoAdminRequired::class, $this->attributes(method: 'draft')); + $this->assertNotContains(PublicPage::class, $this->attributes(method: 'draft')); + }//end testTheDraftRouteIsNotPublic() }//end class diff --git a/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php b/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php new file mode 100644 index 0000000000..a5dced671a --- /dev/null +++ b/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php @@ -0,0 +1,111 @@ + $groups The caller's groups. + */ + private function handlerFor(string $userId, array $groups = []): MagicRbacHandler { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($userId); + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn($user); + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('getUserGroupIds')->willReturn($groups); + + return new MagicRbacHandler( + $userSession, + $groupManager, + $this->createMock(IUserManager::class), + $this->createMock(IAppConfig::class), + $this->createMock(ConditionMatcher::class), + $this->createMock(ContainerInterface::class), + new NullLogger(), + new RbacResolvers(objectScopeResolver: null, objectGrantResolver: null, denyResolver: new DenyResolver(new DenyEntryMatcher())) + ); + }//end handlerFor() + + /** + * A schema with a block and configuration. + * + * @param array $authorization The block. + * @param array $configuration The configuration. + */ + private function schema(array $authorization, array $configuration = []): Schema { + $schema = new Schema(); + $schema->setId(3301); + $schema->setAuthorization($authorization); + $schema->setConfiguration($configuration); + + return $schema; + }//end schema() + + /** + * Others' drafts are hidden; the owner admit stays beside the clause, so the owner keeps their own. + */ + public function testOthersDraftsAreHiddenAndTheOwnerKeepsTheirs(): void { + $predicate = (string)$this->handlerFor('alice', ['behandelaars'])->buildRbacRowPredicateSql( + schema: $this->schema(['read' => ['behandelaars']]) + ); + + $this->assertStringContainsString("(_status IS NULL OR _status <> 'draft')", $predicate); + $this->assertStringContainsString("_owner = 'alice'", $predicate); + }//end testOthersDraftsAreHiddenAndTheOwnerKeepsTheirs() + + /** + * An open schema (no authorization) still hides other people's drafts. + */ + public function testAnOpenSchemaStillHidesOthersDrafts(): void { + $predicate = (string)$this->handlerFor('bob')->buildRbacRowPredicateSql(schema: $this->schema([])); + + $this->assertStringContainsString("_status <> 'draft'", $predicate); + }//end testAnOpenSchemaStillHidesOthersDrafts() + + /** + * A schema with draftsVisible shows drafts to everyone who may read; an admin is never restricted. + */ + public function testOptInAndAdminSeeDrafts(): void { + $optedIn = (string)$this->handlerFor('bob', ['behandelaars'])->buildRbacRowPredicateSql( + schema: $this->schema(['read' => ['behandelaars']], ['draftsVisible' => true]) + ); + $this->assertStringNotContainsString('_status', $optedIn); + + $this->assertNull($this->handlerFor('root', ['admin'])->buildRbacRowPredicateSql(schema: $this->schema(['read' => ['behandelaars']]))); + }//end testOptInAndAdminSeeDrafts() +}//end class diff --git a/tests/Unit/Db/MagicMapper/MagicRbacHandlerRegisterCascadeBypassTest.php b/tests/Unit/Db/MagicMapper/MagicRbacHandlerRegisterCascadeBypassTest.php index e69e20c0e9..e067e60414 100644 --- a/tests/Unit/Db/MagicMapper/MagicRbacHandlerRegisterCascadeBypassTest.php +++ b/tests/Unit/Db/MagicMapper/MagicRbacHandlerRegisterCascadeBypassTest.php @@ -260,7 +260,7 @@ private function visibleRows(Schema $schema, array $query = []): array { $pdo->sqliteCreateFunction('JSON_UNQUOTE', static fn ($value) => $value, 1); $pdo->exec( 'CREATE TABLE t (_uuid TEXT, _owner TEXT, _organisation TEXT, _authorization TEXT,' - . ' _deleted TEXT, _archived TEXT, _expires TEXT, _published TEXT, _depublished TEXT)' + . ' _deleted TEXT, _archived TEXT, _status TEXT, _expires TEXT, _published TEXT, _depublished TEXT)' ); $pdo->exec( "INSERT INTO t (_uuid, _owner, _organisation) VALUES" diff --git a/tests/Unit/Db/ObjectEntityStatusTest.php b/tests/Unit/Db/ObjectEntityStatusTest.php new file mode 100644 index 0000000000..574915faf0 --- /dev/null +++ b/tests/Unit/Db/ObjectEntityStatusTest.php @@ -0,0 +1,50 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\ObjectEntity; +use PHPUnit\Framework\TestCase; + +/** + * The legacy read control, and the draft round trip. + * + * @covers \OCA\OpenRegister\Db\ObjectEntity + */ +class ObjectEntityStatusTest extends TestCase { + + /** + * Spec scenario: an object without the field reads as today. No status, not a draft. + */ + public function testAnObjectWithoutTheFieldReadsAsToday(): void { + $entity = new ObjectEntity(); + $entity->setUuid('u'); + + $this->assertNull($entity->getStatus()); + $this->assertFalse($entity->isDraft()); + $this->assertNull($entity->getObjectArray()['status']); + }//end testAnObjectWithoutTheFieldReadsAsToday() + + /** + * A draft says so in `@self.status`. + */ + public function testADraftSaysSoInSelf(): void { + $entity = new ObjectEntity(); + $entity->setUuid('u'); + $entity->setStatus(ObjectEntity::STATUS_DRAFT); + + $this->assertTrue($entity->isDraft()); + $this->assertSame('draft', $entity->getObjectArray()['status']); + $this->assertSame('draft', $entity->jsonSerialize()['@self']['status']); + }//end testADraftSaysSoInSelf() +}//end class diff --git a/tests/Unit/Event/ObjectActivatedEventTest.php b/tests/Unit/Event/ObjectActivatedEventTest.php new file mode 100644 index 0000000000..63d38c3dea --- /dev/null +++ b/tests/Unit/Event/ObjectActivatedEventTest.php @@ -0,0 +1,42 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Event; + +use DateTimeImmutable; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Event\ObjectActivatedEvent; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Event\ObjectActivatedEvent + * @uses \OCA\OpenRegister\Db\ObjectEntity + */ +class ObjectActivatedEventTest extends TestCase { + + /** + * What the submit put in is what a receipt listener reads. + */ + public function testItCarriesTheObjectAndTheMoment(): void { + $object = new ObjectEntity(); + $object->setUuid('u'); + $object->setStatus(ObjectEntity::STATUS_ACTIVE); + $moment = new DateTimeImmutable('2026-10-12T09:15:00+02:00'); + + $event = new ObjectActivatedEvent(object: $object, receivedAt: $moment); + + $this->assertSame($object, $event->getObject()); + $this->assertSame($moment, $event->getReceivedAt()); + $this->assertFalse($event->getObject()->isDraft()); + }//end testItCarriesTheObjectAndTheMoment() +}//end class diff --git a/tests/Unit/Event/ObjectEventProxyEveryEventTest.php b/tests/Unit/Event/ObjectEventProxyEveryEventTest.php index 51794af9e9..b8dfe83b25 100644 --- a/tests/Unit/Event/ObjectEventProxyEveryEventTest.php +++ b/tests/Unit/Event/ObjectEventProxyEveryEventTest.php @@ -21,6 +21,7 @@ namespace Unit\Event; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Event\ObjectActivatedEvent; use OCA\OpenRegister\Event\ObjectCreatedEvent; use OCA\OpenRegister\Event\ObjectCreatingEvent; use OCA\OpenRegister\Event\ObjectDeletedEvent; @@ -169,6 +170,10 @@ public static function objectEvents(): array { ObjectTransitionedEvent::class, fn (ObjectEntity $o): Event => new ObjectTransitionedEvent($o, 'approve', 'draft', 'approved', 'u1', (string)$o->getRegister(), (string)$o->getSchema()), ], + 'activated' => [ + ObjectActivatedEvent::class, + fn (ObjectEntity $o): Event => new ObjectActivatedEvent($o, new \DateTimeImmutable('2026-10-12T09:15:00+02:00')), + ], ]; }//end objectEvents() @@ -276,7 +281,7 @@ public function testProviderCoversEveryObjectEventClass(): void { } $this->assertSame([], $missing, 'object event classes not covered by the filter tests'); - $this->assertCount(10, $listed); + $this->assertCount(11, $listed); }//end testProviderCoversEveryObjectEventClass() diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php index d131f4800b..6c1b9c332d 100644 --- a/tests/Unit/Service/Form/FormSubmitServiceTest.php +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -19,6 +19,7 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Event\ObjectActivatedEvent; use OCA\OpenRegister\Exception\CustomValidationException; use OCA\OpenRegister\Exception\FormSubmitRefusedException; use OCA\OpenRegister\Exception\NotAuthorizedException; @@ -32,6 +33,8 @@ use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IL10N; use OCP\ITempManager; use OCP\IUser; @@ -104,6 +107,13 @@ class FormSubmitServiceTest extends TestCase { */ private array $temporary = []; + /** + * Events the service dispatched. + * + * @var array + */ + private array $dispatched = []; + protected function setUp(): void { $this->schemaBySlug = [ 'case' => $this->schema( @@ -168,6 +178,13 @@ function (string $uuid): bool { $time = $this->createMock(ITimeFactory::class); $time->method('getTime')->willReturn(1_760_000_000); + $time->method('getDateTime')->willReturnCallback(static fn (): DateTime => new DateTime('2026-10-12T09:15:00+02:00')); + $events = $this->createMock(IEventDispatcher::class); + $events->method('dispatchTyped')->willReturnCallback( + function (Event $event): void { + $this->dispatched[] = $event; + } + ); $l10n = $this->createMock(IL10N::class); $l10n->method('t')->willReturnCallback( static fn (string $text, array $parameters = []): string => $parameters === [] ? $text : vsprintf($text, $parameters) @@ -194,7 +211,9 @@ function (): string { keys: new FormIdempotencyStore(appData: $appData, time: $time), uploads: $this->uploads, l10n: $l10n, - logger: $this->createMock(LoggerInterface::class) + logger: $this->createMock(LoggerInterface::class), + events: $events, + time: $time ); }//end setUp() @@ -278,12 +297,26 @@ private function fakeSave( bool $_unowned = false, ): ObjectEntity { $this->assertInstanceOf(Schema::class, $schema); - $this->saves[] = ['object' => $object, 'schema' => $schema->getSlug(), 'rbac' => $_rbac, 'user' => $currentUser, 'unowned' => $_unowned, 'files' => $uploadedFiles]; + $this->saves[] = ['object' => $object, 'schema' => $schema->getSlug(), 'rbac' => $_rbac, 'user' => $currentUser, 'unowned' => $_unowned, 'files' => $uploadedFiles, 'uuid' => $uuid]; if (isset($this->refuseSave[$schema->getSlug()]) === true) { throw $this->refuseSave[$schema->getSlug()]; } $data = (array)$object; + $status = ($data['@self']['status'] ?? null); + unset($data['@self']); + if ($status === 'draft') { + // The fake listener stamps receipt fields only on a received object. + $entity = new ObjectEntity(); + $entity->setUuid($uuid ?? ('uuid-draft-' . count($this->saves))); + $entity->setObject($data); + $entity->setStatus('draft'); + $entity->setCreated(new DateTime('2026-10-10T14:03:11+02:00')); + $this->stored[$entity->getUuid()] = $entity; + + return $entity; + } + if ($schema->getSlug() === 'case') { // The fake ObjectCreatingEvent listener. $data['identifier'] = '2026-0412'; @@ -292,8 +325,9 @@ private function fakeSave( } $entity = new ObjectEntity(); - $entity->setUuid('uuid-' . $schema->getSlug() . '-' . count($this->saves)); + $entity->setUuid($uuid ?? ('uuid-' . $schema->getSlug() . '-' . count($this->saves))); $entity->setObject($data); + $entity->setStatus($status); $entity->setCreated(new DateTime('2026-10-10T14:03:11+02:00')); $this->stored[$entity->getUuid()] = $entity; @@ -552,4 +586,103 @@ private function organisationThenContact(): array { ], ]; }//end organisationThenContact() + /** + * Spec scenario: a draft without required data is saved, in status draft, with no receipt. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function testADraftWithoutRequiredDataIsSaved(): void { + $user = $this->createMock(IUser::class); + $draft = $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'Half af'], subject: $user); + + $this->assertSame('draft', $draft['status']); + $this->assertSame('draft', $this->saves[0]['object']['@self']['status']); + $this->assertTrue($this->stored[$draft['id']]->isDraft()); + $this->assertArrayNotHasKey('termStartsAt', $this->stored[$draft['id']]->getObject()); + $this->assertSame([], $this->dispatched); + }//end testADraftWithoutRequiredDataIsSaved() + + /** + * Spec scenario: a type-invalid draft is refused and nothing is stored. + */ + public function testATypeInvalidDraftIsRefused(): void { + try { + $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => ['not', 'a', 'string']], subject: $this->createMock(IUser::class)); + $this->fail('A type-invalid draft was saved.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(422, $refused->getStatus()); + $this->assertSame('type', $refused->getFindings()[0]['code']); + } + + $this->assertSame([], $this->saves); + }//end testATypeInvalidDraftIsRefused() + + /** + * An anonymous visitor cannot save a draft (no way to find it again without an account). + */ + public function testAnAnonymousDraftIs401(): void { + $this->expectException(FormSubmitRefusedException::class); + try { + $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: [], subject: null); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(401, $refused->getStatus()); + throw $refused; + } + }//end testAnAnonymousDraftIs401() + + /** + * Spec scenario: leaving draft runs full validation and stamps receipt. + */ + public function testLeavingDraftRunsFullValidationAndStampsReceipt(): void { + $user = $this->createMock(IUser::class); + $draft = $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'Kapvergunning'], subject: $user); + + $answer = $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['caseType' => 'ct-1'], subject: $user, draftId: $draft['id']); + + $promotion = $this->saves[1]; + $this->assertSame($draft['id'], $promotion['uuid']); + $this->assertSame('active', $promotion['object']['@self']['status']); + $this->assertSame('Kapvergunning', $promotion['object']['title']); + $this->assertSame('2026-10-12T09:15:00+02:00', $answer['receivedAt']); + $this->assertSame('2026-0412', $answer['reference']); + $this->assertArrayHasKey('termStartsAt', $answer['confirmation']); + $this->assertCount(1, $this->dispatched); + $this->assertInstanceOf(ObjectActivatedEvent::class, $this->dispatched[0]); + }//end testLeavingDraftRunsFullValidationAndStampsReceipt() + + /** + * A draft still missing required data cannot leave draft, and stays a draft. + */ + public function testAnIncompleteDraftCannotLeaveDraft(): void { + $user = $this->createMock(IUser::class); + $draft = $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'Half af'], subject: $user); + + try { + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: [], subject: $user, draftId: $draft['id']); + $this->fail('An incomplete draft left draft.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(422, $refused->getStatus()); + $this->assertSame('caseType', $refused->getFindings()[0]['property']); + } + + $this->assertCount(1, $this->saves); + $this->assertTrue($this->stored[$draft['id']]->isDraft()); + $this->assertSame([], $this->dispatched); + }//end testAnIncompleteDraftCannotLeaveDraft() + + /** + * A draft id that is not a draft, or not found under the subject's RBAC, is a 404. + */ + public function testAnUnknownDraftIsA404(): void { + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b']); + + foreach (['uuid-case-1', 'nope'] as $id) { + try { + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], subject: $this->createMock(IUser::class), draftId: $id); + $this->fail('Draft ' . $id . ' was accepted.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(404, $refused->getStatus()); + } + } + }//end testAnUnknownDraftIsA404() }//end class diff --git a/tests/Unit/Service/Object/DraftStatusPolicyTest.php b/tests/Unit/Service/Object/DraftStatusPolicyTest.php new file mode 100644 index 0000000000..5533e28c23 --- /dev/null +++ b/tests/Unit/Service/Object/DraftStatusPolicyTest.php @@ -0,0 +1,141 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Exception\ValidationException; +use OCA\OpenRegister\Service\Object\DraftStatusPolicy; +use PHPUnit\Framework\TestCase; + +/** + * Decision 180: a draft is the destination object, stored with `@self.status: draft`. + * + * @covers \OCA\OpenRegister\Service\Object\DraftStatusPolicy + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Exception\ValidationException + */ +class DraftStatusPolicyTest extends TestCase { + + private DraftStatusPolicy $policy; + + protected function setUp(): void { + $this->policy = new DraftStatusPolicy(); + DraftStatusPolicy::resetPromotions(); + }//end setUp() + + /** + * An object entity, new (no id) or stored, in a given status. + */ + private function entity(?string $status, bool $stored = true): ObjectEntity { + $entity = new ObjectEntity(); + $entity->setUuid('uuid-1'); + if ($stored === true) { + $entity->setId(7); + } + + $entity->setStatus($status); + + return $entity; + }//end entity() + + /** + * A new object may be created as a draft; a stored draft stays one. + */ + public function testANewObjectOrADraftMayBeADraft(): void { + $this->assertSame('draft', $this->policy->resolveStatus(entity: $this->entity(status: null, stored: false), selfData: ['status' => 'draft'])); + $this->assertSame('draft', $this->policy->resolveStatus(entity: $this->entity(status: 'draft'), selfData: ['status' => 'draft'])); + }//end testANewObjectOrADraftMayBeADraft() + + /** + * A stored object that is not a draft cannot go back to draft. + */ + public function testAStoredObjectCannotBecomeADraft(): void { + $this->expectException(ValidationException::class); + $this->policy->resolveStatus(entity: $this->entity(status: null), selfData: ['status' => 'draft']); + }//end testAStoredObjectCannotBecomeADraft() + + /** + * Leaving draft is refused unless the submit allowed it for this object. + */ + public function testLeavingDraftNeedsTheSubmit(): void { + try { + $this->policy->resolveStatus(entity: $this->entity(status: 'draft'), selfData: ['status' => 'active']); + $this->fail('A draft left draft without the submit.'); + } catch (ValidationException $refused) { + $this->assertStringContainsString('submit', $refused->getMessage()); + } + + DraftStatusPolicy::allowPromotion(uuid: 'uuid-1'); + $this->assertSame('active', $this->policy->resolveStatus(entity: $this->entity(status: 'draft'), selfData: ['status' => 'active'])); + + // Used once: the allowance does not linger for a later write. + $this->expectException(ValidationException::class); + $this->policy->resolveStatus(entity: $this->entity(status: 'draft'), selfData: ['status' => 'active']); + }//end testLeavingDraftNeedsTheSubmit() + + /** + * No status in the payload keeps what is stored; the same status is no change; an unknown one is refused. + */ + public function testAbsentSameAndUnknownStatus(): void { + $this->assertSame('draft', $this->policy->resolveStatus(entity: $this->entity(status: 'draft'), selfData: [])); + $this->assertNull($this->policy->resolveStatus(entity: $this->entity(status: null), selfData: [])); + $this->assertSame('active', $this->policy->resolveStatus(entity: $this->entity(status: 'active'), selfData: ['status' => 'active'])); + + $this->expectException(ValidationException::class); + $this->policy->resolveStatus(entity: $this->entity(status: null, stored: false), selfData: ['status' => 'published']); + }//end testAbsentSameAndUnknownStatus() + + /** + * A write is a draft write when the payload asks for draft, or the stored object is one and the payload does not leave it. + */ + public function testIsDraftWrite(): void { + $this->assertTrue($this->policy->isDraftWrite(object: ['@self' => ['status' => 'draft']], existing: null)); + $this->assertTrue($this->policy->isDraftWrite(object: ['title' => 'x'], existing: $this->entity(status: 'draft'))); + $this->assertFalse($this->policy->isDraftWrite(object: ['@self' => ['status' => 'active']], existing: $this->entity(status: 'draft'))); + $this->assertFalse($this->policy->isDraftWrite(object: ['title' => 'x'], existing: $this->entity(status: null))); + $this->assertFalse($this->policy->isDraftWrite(object: ['title' => 'x'], existing: null)); + }//end testIsDraftWrite() + + /** + * A draft is excused from every required property, list or property-level, and from nothing else. + */ + public function testADraftIsExcusedFromRequiredOnly(): void { + $schema = new Schema(); + $schema->setRequired(['title']); + $schema->setProperties(['title' => ['type' => 'string'], 'description' => ['type' => 'string', 'required' => true], 'note' => ['type' => 'string']]); + + $this->assertSame(['title' => 'draft', 'description' => 'draft'], $this->policy->requiredExcusals(schema: $schema)); + }//end testADraftIsExcusedFromRequiredOnly() + + /** + * Drafts are owner-only unless the schema says otherwise. + */ + public function testDraftsAreOwnerOnlyUnlessTheSchemaSaysOtherwise(): void { + $schema = new Schema(); + $this->assertFalse($this->policy->draftsVisibleToOthers(schema: $schema)); + + $schema->setConfiguration(['draftsVisible' => true]); + $this->assertTrue($this->policy->draftsVisibleToOthers(schema: $schema)); + }//end testDraftsAreOwnerOnlyUnlessTheSchemaSaysOtherwise() + + /** + * The SQL clause that hides other people's drafts, for both emitters. + */ + public function testTheHiddenDraftClause(): void { + $this->assertSame("(t._status IS NULL OR t._status <> 'draft')", $this->policy->notADraftSql(columnPrefix: 't.')); + $this->assertSame("(_status IS NULL OR _status <> 'draft')", $this->policy->notADraftSql(columnPrefix: '')); + }//end testTheHiddenDraftClause() +}//end class diff --git a/tests/Unit/Service/Object/SaveObjectDraftStatusTest.php b/tests/Unit/Service/Object/SaveObjectDraftStatusTest.php new file mode 100644 index 0000000000..6c7354a041 --- /dev/null +++ b/tests/Unit/Service/Object/SaveObjectDraftStatusTest.php @@ -0,0 +1,75 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Exception\ValidationException; +use OCA\OpenRegister\Service\Object\DraftStatusPolicy; +use OCA\OpenRegister\Service\Object\SaveObject; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +/** + * Asserted from the caller, so the policy cannot be a guard with no call site. + * + * @covers \OCA\OpenRegister\Service\Object\SaveObject + * @uses \OCA\OpenRegister\Service\Object\DraftStatusPolicy + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Exception\ValidationException + */ +class SaveObjectDraftStatusTest extends TestCase { + + /** + * Call the private setSelfMetadata() on a SaveObject built without its many collaborators. + * + * @param array $selfData The @self payload. + */ + private function apply(ObjectEntity $entity, array $selfData): void { + $save = (new ReflectionClass(SaveObject::class))->newInstanceWithoutConstructor(); + $method = new \ReflectionMethod(SaveObject::class, 'setSelfMetadata'); + $method->invoke($save, $entity, $selfData, [], null); + }//end apply() + + protected function setUp(): void { + DraftStatusPolicy::resetPromotions(); + }//end setUp() + + /** + * A create asking for draft stores draft. + */ + public function testACreateAskingForDraftStoresDraft(): void { + $entity = new ObjectEntity(); + $entity->setUuid('u'); + + $this->apply(entity: $entity, selfData: ['status' => 'draft']); + + $this->assertSame('draft', $entity->getStatus()); + }//end testACreateAskingForDraftStoresDraft() + + /** + * An ordinary update of a draft keeps it a draft; leaving it without the submit is refused. + */ + public function testAnUpdateKeepsTheDraftAndCannotLeaveIt(): void { + $entity = new ObjectEntity(); + $entity->setId(3); + $entity->setUuid('u'); + $entity->setStatus('draft'); + + $this->apply(entity: $entity, selfData: []); + $this->assertSame('draft', $entity->getStatus()); + + $this->expectException(ValidationException::class); + $this->apply(entity: $entity, selfData: ['status' => 'active']); + }//end testAnUpdateKeepsTheDraftAndCannotLeaveIt() +}//end class diff --git a/tests/Unit/Service/ObjectServiceRefactoredMethodsTest.php b/tests/Unit/Service/ObjectServiceRefactoredMethodsTest.php index e78882e6d6..3674b6fb69 100644 --- a/tests/Unit/Service/ObjectServiceRefactoredMethodsTest.php +++ b/tests/Unit/Service/ObjectServiceRefactoredMethodsTest.php @@ -410,6 +410,36 @@ public function testValidateObjectIfRequiredSkipsWhenHardValidationDisabled(): v $this->assertTrue(true, 'No exception thrown when hard validation is disabled.'); } + /** + * Decision 180: a draft is validated even with hard validation off, with every required property excused. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function testADraftIsValidatedWithRequiredExcusedWhateverTheFlag(): void { + $object = ['@self' => ['status' => 'draft'], 'preferredDate' => '06-12345678']; + + $schema = new Schema(); + $schema->setId(1); + $schema->setHardValidation(false); + $schema->setRequired(['title', 'description']); + $schema->setProperties(['title' => ['type' => 'string'], 'description' => ['type' => 'string'], 'preferredDate' => ['type' => 'string', 'format' => 'date-time']]); + $this->setPrivateProperty('currentSchema', $schema); + + $invalid = (new \Opis\JsonSchema\Validator())->validate( + json_decode('{"preferredDate":"06-12345678"}'), + json_decode('{"type":"object","properties":{"preferredDate":{"type":"string","format":"date-time"}}}') + ); + $this->validateHandler->expects($this->once()) + ->method('validateObject') + ->with($this->anything(), $schema, $this->anything(), $this->anything(), ['title' => 'draft', 'description' => 'draft']) + ->willReturn($invalid); + $this->validateHandler->method('generateErrorMessage')->willReturn('preferredDate must match format date-time'); + + // Spec scenario: a type-invalid draft is refused. + $this->expectException(\OCA\OpenRegister\Exception\ValidationException::class); + $this->invokePrivateMethod('validateObjectIfRequired', [$object, null]); + } + /** * Test validateObjectIfRequired calls validator when hard validation is enabled. * From a619be54dc8cbe40eb150dd746dab5237a18a443 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 18:27:26 +0200 Subject: [PATCH 046/183] chore(forms): @spec on the purge job's run --- lib/BackgroundJob/FormUploadPurgeJob.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/BackgroundJob/FormUploadPurgeJob.php b/lib/BackgroundJob/FormUploadPurgeJob.php index edab565d84..480d3a6dc0 100644 --- a/lib/BackgroundJob/FormUploadPurgeJob.php +++ b/lib/BackgroundJob/FormUploadPurgeJob.php @@ -68,6 +68,8 @@ public function __construct( * @throws Throwable When a purge fails, after logging it. * * @SuppressWarnings(PHPMD.UnusedFormalParameter) TimedJob's signature. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire */ protected function run(mixed $argument): void { try { From ec817672f518092a4ca2b9ec2300e107fdf09c63 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 18:28:42 +0200 Subject: [PATCH 047/183] test(forms): e2e over HTTP for validate, submit, all or none, idempotency and uploads --- tests/e2e/ci/form-destination.spec.ts | 210 ++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 tests/e2e/ci/form-destination.spec.ts diff --git a/tests/e2e/ci/form-destination.spec.ts b/tests/e2e/ci/form-destination.spec.ts new file mode 100644 index 0000000000..b48ca3781d --- /dev/null +++ b/tests/e2e/ci/form-destination.spec.ts @@ -0,0 +1,210 @@ +import type { APIRequestContext } from '@playwright/test' + +/* + * SPDX-FileCopyrightText: 2026 Open Register Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * A FORM SUBMITS INTO ITS DESTINATION OBJECT (decision 179, ADR-117), end to + * end through the HTTP API a portal or a form designer uses. + * + * @e2e form-destination::a-required-property-with-no-source-is-reported + * @e2e form-destination::a-property-a-listener-fills-is-not-reported + * @e2e form-destination::a-form-with-one-finding-is-not-saved + * @e2e form-destination::a-schema-without-hard-validation-is-still-validated + * @e2e form-destination::a-repeated-idempotency-key-repeats-the-answer + * @e2e form-destination::a-refused-second-write-deletes-the-first + * @e2e form-and-journey-registry::a-partial-failure-leaves-nothing-behind + * @e2e form-destination::an-oversized-file-is-refused-at-upload + * @e2e form-destination::create-and-update-refuse-alike + * + * WHAT IS DELIBERATELY NOT CLAIMED HERE. The listener-computed confirmation, + * the schema-save unpublish and the flow task form need an owning app's + * listener or a flow; the unit tests prove those with a fake listener and say + * so. This file keeps no anchor for them rather than a misleading one. + * + * HERMETIC BY CONSTRUCTION. It creates its own register, schemas, form object + * and objects, and removes the register. It needs no `occ` and no docker. + */ +import { expect, request as pwRequest, test } from '@playwright/test' +import { resolveBaseUrl } from '../base-url.ts' + +const BASE = resolveBaseUrl() +const ADMIN = process.env.ADMIN_USER || process.env.OR_USER || 'admin' +const ADMIN_PASS = process.env.ADMIN_PASSWORD || process.env.OR_PASS || 'admin' +const RUN = Math.random().toString(36).slice(2, 10) +const API = '/index.php/apps/openregister/api' + +/** An API context, signed in or anonymous. */ +async function contextFor(user?: string, password?: string): Promise { + const headers: Record = { 'OCS-APIRequest': 'true', Accept: 'application/json' } + if (user !== undefined && password !== undefined) { + headers.Authorization = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}` + } + + return pwRequest.newContext({ baseURL: BASE, extraHTTPHeaders: headers }) +} + +/** The uuid an object create answers with, whichever shape it uses. */ +function uuidOf(body: Record): string { + const self = (body['@self'] ?? {}) as Record + + return String(self.id ?? body.id ?? body.uuid) +} + +test.describe.configure({ mode: 'serial' }) + +test.describe('a form submits into its destination object', () => { + let admin: APIRequestContext + let visitor: APIRequestContext + let registerId: string + let caseSchemaId: string + let orgSchemaId: string + let contactSchemaId: string + let formSchemaId: string + let caseFormId: string + let journeyFormId: string + + /** Create a schema in the test register and return its id. */ + async function schema(data: Record): Promise { + const res = await admin.post(`${API}/schemas`, { data: { description: 'e2e', ...data } }) + expect(res.ok(), `schema create failed: ${await res.text()}`).toBeTruthy() + + return String((await res.json()).id) + } + + /** Store a published form object and return its uuid. */ + async function form(body: Record): Promise { + const res = await admin.post(`${API}/objects/${registerId}/${formSchemaId}`, { data: { status: 'published', ...body } }) + expect(res.ok(), `form create failed: ${await res.text()}`).toBeTruthy() + + return uuidOf(await res.json()) + } + + test.beforeAll(async () => { + admin = await contextFor(ADMIN, ADMIN_PASS) + visitor = await contextFor() + + const reg = await admin.post(`${API}/registers`, { data: { title: `e2e form destination ${RUN}`, description: 'e2e' } }) + expect(reg.ok(), `register create failed: ${await reg.text()}`).toBeTruthy() + registerId = String((await reg.json()).id) + + const open = { read: ['public'], create: ['public'], update: ['authenticated'], delete: ['authenticated'] } + caseSchemaId = await schema({ + title: `e2e case ${RUN}`, + hardValidation: false, + required: ['title', 'caseType'], + authorization: open, + properties: { + title: { type: 'string', maxLength: 200 }, + caseType: { type: 'string' }, + identifier: { type: 'string', 'x-openregister': { serverSet: true, reference: true, confirmation: true } }, + bijlage: { type: 'file', maxSize: 1024 }, + }, + }) + orgSchemaId = await schema({ title: `e2e org ${RUN}`, required: ['name'], authorization: open, properties: { name: { type: 'string' } } }) + contactSchemaId = await schema({ + title: `e2e contact ${RUN}`, + required: ['email'], + authorization: open, + configuration: { unique: ['email'] }, + properties: { email: { type: 'string' }, organisation: { type: 'string' } }, + }) + formSchemaId = await schema({ + title: `e2e form ${RUN}`, + properties: { + status: { type: 'string' }, + audience: { type: 'string' }, + destination: { type: 'object' }, + mapping: { type: 'object' }, + writes: { type: 'array' }, + }, + }) + + caseFormId = await form({ + destination: { register: registerId, schema: caseSchemaId }, + mapping: { fields: [{ field: 'onderwerp', property: 'title' }, { field: 'bewijs', property: 'bijlage' }], fixed: { caseType: 'ct-1' } }, + }) + journeyFormId = await form({ + writes: [ + { as: 'org', register: registerId, schema: orgSchemaId, mapping: { fields: [{ field: 'naam', property: 'name' }] } }, + { as: 'contact', register: registerId, schema: contactSchemaId, mapping: { fields: [{ field: 'mail', property: 'email' }], fixed: { organisation: { $write: 'org' } } } }, + ], + }) + }) + + test.afterAll(async () => { + await admin.delete(`${API}/registers/${registerId}`) + }) + + test('validate names the required property no field fills, and not the one the server fills', async () => { + const res = await admin.post(`${API}/forms/validate`, { + data: { destination: { register: registerId, schema: caseSchemaId }, mapping: { fields: [{ field: 'a', property: 'title', type: 'text', maxLength: 100 }] } }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.accepted).toBe(false) + expect(body.findings.map((f: { property: string, code: string }) => `${f.property}:${f.code}`)).toEqual(['caseType:required-unmapped']) + + const fixed = await admin.post(`${API}/forms/validate`, { + data: { destination: { schema: caseSchemaId }, mapping: { fields: [{ field: 'a', property: 'title', type: 'text', maxLength: 100 }], fixed: { caseType: 'ct-1' } } }, + }) + expect((await fixed.json()).accepted).toBe(true) + }) + + test('a submit missing a required answer is refused with 422 and creates nothing, hard validation off', async () => { + const before = await admin.get(`${API}/objects/${registerId}/${caseSchemaId}?_limit=100`) + const countBefore = (await before.json()).total + const res = await visitor.post(`${API}/forms/${caseFormId}/submit`, { data: {} }) + expect(res.status()).toBe(422) + expect((await res.json()).findings[0]).toMatchObject({ property: 'title', code: 'required' }) + const after = await admin.get(`${API}/objects/${registerId}/${caseSchemaId}?_limit=100`) + expect((await after.json()).total).toBe(countBefore) + }) + + test('a repeated Idempotency-Key repeats the first answer and creates one object', async () => { + const key = `k-${RUN}` + const first = await visitor.post(`${API}/forms/${caseFormId}/submit`, { data: { onderwerp: 'Kapvergunning' }, headers: { 'Idempotency-Key': key } }) + expect(first.status()).toBe(201) + const second = await visitor.post(`${API}/forms/${caseFormId}/submit`, { data: { onderwerp: 'Kapvergunning' }, headers: { 'Idempotency-Key': key } }) + expect(second.status()).toBe(201) + expect((await second.json()).id).toBe((await first.json()).id) + }) + + test('a refused second write deletes the first', async () => { + const ok = await visitor.post(`${API}/forms/${journeyFormId}/submit`, { data: { naam: 'De Korst', mail: `info-${RUN}@dekorst.nl` } }) + expect(ok.status(), await ok.text()).toBe(201) + const orgsBefore = (await (await admin.get(`${API}/objects/${registerId}/${orgSchemaId}?_limit=100`)).json()).total + + const dup = await visitor.post(`${API}/forms/${journeyFormId}/submit`, { data: { naam: 'De Korst 2', mail: `info-${RUN}@dekorst.nl` } }) + expect(dup.status()).toBe(422) + expect((await dup.json()).findings[0].write).toBe('contact') + const orgsAfter = (await (await admin.get(`${API}/objects/${registerId}/${orgSchemaId}?_limit=100`)).json()).total + expect(orgsAfter).toBe(orgsBefore) + }) + + test('an oversized file is refused at upload and no token is issued', async () => { + const res = await visitor.post(`${API}/forms/${caseFormId}/uploads`, { + multipart: { property: 'bijlage', file: { name: 'groot.pdf', mimeType: 'application/pdf', buffer: Buffer.alloc(4096, 1) } }, + }) + expect(res.status()).toBe(422) + const body = await res.json() + expect(body.findings[0].code).toBe('file-too-large') + expect(body.token).toBeUndefined() + }) + + test('an unknown form is a 404, the same as an unpublished one', async () => { + const unknown = await visitor.post(`${API}/forms/00000000-0000-4000-8000-000000000000/submit`, { data: {} }) + expect(unknown.status()).toBe(404) + }) + + test('create and update refuse alike, per property', async () => { + const hard = await schema({ title: `e2e hard ${RUN}`, hardValidation: true, required: ['title'], properties: { title: { type: 'string' }, note: { type: 'string' } } }) + const created = await admin.post(`${API}/objects/${registerId}/${hard}`, { data: { note: 'x' } }) + const good = await admin.post(`${API}/objects/${registerId}/${hard}`, { data: { title: 't' } }) + const updated = await admin.put(`${API}/objects/${registerId}/${hard}/${uuidOf(await good.json())}`, { data: { note: 'y' } }) + + expect(created.status()).toBe(updated.status()) + expect(Array.isArray((await created.json()).errors)).toBe(true) + expect(Array.isArray((await updated.json()).errors)).toBe(true) + }) +}) From 2a1ca79a9b73dcaa89e066c9a7dc830415102e80 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 18:29:20 +0200 Subject: [PATCH 048/183] test(objects): e2e for drafts: saved, refused when type-invalid, owner-only, leaving draft --- tests/e2e/ci/form-destination.spec.ts | 46 ++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/e2e/ci/form-destination.spec.ts b/tests/e2e/ci/form-destination.spec.ts index b48ca3781d..e733a752f5 100644 --- a/tests/e2e/ci/form-destination.spec.ts +++ b/tests/e2e/ci/form-destination.spec.ts @@ -16,6 +16,10 @@ import type { APIRequestContext } from '@playwright/test' * @e2e form-and-journey-registry::a-partial-failure-leaves-nothing-behind * @e2e form-destination::an-oversized-file-is-refused-at-upload * @e2e form-destination::create-and-update-refuse-alike + * @e2e form-destination::a-draft-without-required-data-is-saved + * @e2e form-destination::a-type-invalid-draft-is-refused + * @e2e form-destination::leaving-draft-runs-full-validation-and-stamps-receipt + * @e2e form-destination::an-object-without-the-field-reads-as-today * * WHAT IS DELIBERATELY NOT CLAIMED HERE. The listener-computed confirmation, * the schema-save unpublish and the flow task form need an owning app's @@ -99,6 +103,7 @@ test.describe('a form submits into its destination object', () => { caseType: { type: 'string' }, identifier: { type: 'string', 'x-openregister': { serverSet: true, reference: true, confirmation: true } }, bijlage: { type: 'file', maxSize: 1024 }, + preferredDate: { type: 'string', format: 'date-time' }, }, }) orgSchemaId = await schema({ title: `e2e org ${RUN}`, required: ['name'], authorization: open, properties: { name: { type: 'string' } } }) @@ -122,7 +127,7 @@ test.describe('a form submits into its destination object', () => { caseFormId = await form({ destination: { register: registerId, schema: caseSchemaId }, - mapping: { fields: [{ field: 'onderwerp', property: 'title' }, { field: 'bewijs', property: 'bijlage' }], fixed: { caseType: 'ct-1' } }, + mapping: { fields: [{ field: 'onderwerp', property: 'title' }, { field: 'bewijs', property: 'bijlage' }, { field: 'datum', property: 'preferredDate' }], fixed: { caseType: 'ct-1' } }, }) journeyFormId = await form({ writes: [ @@ -207,4 +212,43 @@ test.describe('a form submits into its destination object', () => { expect(Array.isArray((await created.json()).errors)).toBe(true) expect(Array.isArray((await updated.json()).errors)).toBe(true) }) + test('a draft without required data is saved as a draft, and only its owner sees it', async () => { + const saved = await admin.post(`${API}/forms/${caseFormId}/draft`, { data: { datum: '2026-11-02T10:00:00+01:00' } }) + expect(saved.status(), await saved.text()).toBe(200) + const draft = await saved.json() + expect(draft.status).toBe('draft') + + const read = await admin.get(`${API}/objects/${registerId}/${caseSchemaId}/${draft.id}`) + expect((await read.json())['@self'].status).toBe('draft') + + const asVisitor = await visitor.get(`${API}/objects/${registerId}/${caseSchemaId}/${draft.id}`) + expect(asVisitor.status()).toBe(404) + }) + + test('a type-invalid draft is refused and nothing is stored', async () => { + const res = await admin.post(`${API}/forms/${caseFormId}/draft`, { data: { datum: '06-12345678' } }) + expect(res.status()).toBe(422) + expect((await res.json()).findings[0].property).toBe('preferredDate') + }) + + test('leaving draft runs full validation and stamps receipt', async () => { + const draft = await (await admin.post(`${API}/forms/${caseFormId}/draft`, { data: { datum: '2026-11-02T10:00:00+01:00' } })).json() + + const incomplete = await admin.post(`${API}/forms/${caseFormId}/submit`, { data: { _draft: draft.id } }) + expect(incomplete.status()).toBe(422) + + const done = await admin.post(`${API}/forms/${caseFormId}/submit`, { data: { _draft: draft.id, onderwerp: 'Kapvergunning' } }) + expect(done.status(), await done.text()).toBe(201) + const answer = await done.json() + expect(answer.id).toBe(draft.id) + expect(answer.receivedAt).toBeTruthy() + const read = await admin.get(`${API}/objects/${registerId}/${caseSchemaId}/${draft.id}`) + expect((await read.json())['@self'].status).toBe('active') + }) + + test('an object without the field reads as before', async () => { + const plain = await admin.post(`${API}/objects/${registerId}/${caseSchemaId}`, { data: { title: 'plain', caseType: 'ct-1' } }) + expect(plain.ok()).toBeTruthy() + expect((await plain.json())['@self'].status ?? null).toBeNull() + }) }) From b26e5551eada3f51ded1a3d051ed96626aa29185 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 18:34:48 +0200 Subject: [PATCH 049/183] refactor(forms): phpmd: payload pairs in their own method, named suppressions --- lib/Controller/FormsController.php | 3 + lib/Service/Form/FormDestinationValidator.php | 4 ++ lib/Service/Form/FormSubmitService.php | 65 ++++++++++++------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php index 3d87f706f0..756e785c44 100644 --- a/lib/Controller/FormsController.php +++ b/lib/Controller/FormsController.php @@ -81,6 +81,9 @@ class FormsController extends Controller { * @param IThrottler $throttler Counts guesses at form ids. * @param IL10N $l10n Translations. * @param LoggerInterface $logger Logs a throttler failure. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Three routes over four services, plus the + * request, session, throttler, translations and logger every public controller here takes. */ public function __construct( string $appName, diff --git a/lib/Service/Form/FormDestinationValidator.php b/lib/Service/Form/FormDestinationValidator.php index d302b747f3..ddb2bda716 100644 --- a/lib/Service/Form/FormDestinationValidator.php +++ b/lib/Service/Form/FormDestinationValidator.php @@ -47,6 +47,10 @@ /** * The one validator for a form's mapping against its destination. * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The complexity IS the finding list: + * every branch is one named reason a field cannot produce what its property holds. Split, + * the codes and the rules that raise them would sit in files that must agree. + * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-openregister-must-judge-a-forms-mapping-against-its-destination-schema */ class FormDestinationValidator { diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index b213d15482..9cdb042f8e 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -61,6 +61,11 @@ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The submit IS the meeting point of * mapping, validation, save, delete, uploads and idempotency; each collaborator is one * of those steps and none is reached any other way. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The complexity is the submit contract: + * map, validate all, write in order, compensate, answer. Split, the all-or-none rule would + * live in two files that must agree on what a plan is. + * @SuppressWarnings(PHPMD.StaticAccess) FormDestinationValidator::marker() and + * markedProperties() are pure reads of a schema property's `x-openregister` block. * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference */ @@ -199,10 +204,10 @@ public function submitAll( private function plan(array $writes, array $payload, string $scope): array { $plans = []; foreach (array_values($writes) as $index => $write) { - $named = trim((string)($write['as'] ?? '')) !== ''; - $as = (string)($write['as'] ?? ('write' . $index)); + $writeName = trim((string)($write['as'] ?? '')); + $named = $writeName !== ''; if ($named === false) { - $as = 'write' . $index; + $writeName = 'write' . $index; } $mapping = null; @@ -212,7 +217,7 @@ private function plan(array $writes, array $payload, string $scope): array { [$object, $files, $tokens] = $this->mapPayload(mapping: $mapping, payload: $payload, scope: $scope); $plans[] = [ - 'as' => $as, + 'as' => $writeName, 'named' => $named && count($writes) > 1, 'register' => $this->register(reference: $write['register'] ?? null), 'schema' => $this->schema(reference: $write['schema'] ?? null), @@ -246,28 +251,10 @@ private function plan(array $writes, array $payload, string $scope): array { * @throws FormSubmitRefusedException 422 for an unknown, expired or misdirected upload token. */ private function mapPayload(?array $mapping, array $payload, string $scope): array { - $pairs = []; - if ($mapping === null) { - foreach ($payload as $name => $value) { - $name = (string)$name; - if (str_starts_with($name, '_') === false && str_starts_with($name, '@') === false) { - $pairs[$name] = $value; - } - } - } - - foreach ((array)($mapping['fields'] ?? []) as $field) { - $name = (string)($field['field'] ?? ''); - $property = (string)($field['property'] ?? ''); - if ($property !== '' && array_key_exists($name, $payload) === true) { - $pairs[$property] = $payload[$name]; - } - } - $object = []; $files = []; $tokens = []; - foreach ($pairs as $property => $value) { + foreach ($this->pairsOf(mapping: $mapping, payload: $payload) as $property => $value) { $token = $this->tokenOf(value: $value); if ($token === null) { $object[$property] = $value; @@ -299,6 +286,38 @@ private function mapPayload(?array $mapping, array $payload, string $scope): arr return [$object, $files, $tokens]; }//end mapPayload() + /** + * Property => value: the mapped fields, or without a mapping the payload minus control and metadata keys. + * + * @param array|null $mapping The mapping. + * @param array $payload The payload. + * + * @return array The pairs. + */ + private function pairsOf(?array $mapping, array $payload): array { + $pairs = []; + if ($mapping === null) { + foreach ($payload as $name => $value) { + $name = (string)$name; + if (str_starts_with($name, '_') === false && str_starts_with($name, '@') === false) { + $pairs[$name] = $value; + } + } + + return $pairs; + } + + foreach ((array)($mapping['fields'] ?? []) as $field) { + $name = (string)($field['field'] ?? ''); + $property = (string)($field['property'] ?? ''); + if ($property !== '' && array_key_exists($name, $payload) === true) { + $pairs[$property] = $payload[$name]; + } + } + + return $pairs; + }//end pairsOf() + /** * The upload token a payload value carries, or null. * From 118b56a106927ad0ad1cac89d371fae9ce88e15a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 18:37:29 +0200 Subject: [PATCH 050/183] refactor(objects): phpmd: draft move and write-or-promote in their own methods --- lib/Service/Form/FormSubmitService.php | 57 +++++++++++++++++++----- lib/Service/Object/DraftStatusPolicy.php | 18 +++++++- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index 650ff0ab67..737a57e633 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -70,7 +70,8 @@ * map, validate all, write in order, compensate, answer. Split, the all-or-none rule would * live in two files that must agree on what a plan is. * @SuppressWarnings(PHPMD.StaticAccess) FormDestinationValidator::marker() and - * markedProperties() are pure reads of a schema property's `x-openregister` block. + * markedProperties() are pure reads of a schema property's `x-openregister` block; + * DraftStatusPolicy's promotion allowance is request-scoped state shared with the save path. * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference */ @@ -83,6 +84,20 @@ class FormSubmitService { */ public const PENDING_ID = '00000000-0000-4000-8000-000000000000'; + /** + * Validation mode: every rule. + * + * @var string + */ + private const MODE_FULL = 'full'; + + /** + * Validation mode for a draft: every rule except `required` (decision 180). + * + * @var string + */ + private const MODE_DRAFT = 'draft'; + /** * Constructor. * @@ -97,6 +112,9 @@ class FormSubmitService { * @param LoggerInterface $logger Records each compensation. * @param IEventDispatcher $events Announces a draft that left draft (receipt). * @param ITimeFactory $time The moment of receipt. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) One collaborator per step of the submit + * (resolve, validate, save, find, findings, keys, uploads, receipt) plus translations and logging. */ public function __construct( private readonly RegisterMapper $registers, @@ -188,7 +206,7 @@ public function saveDraft( $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId), $plans[0]['object']); } - $this->validateAll(plans: $plans, draft: true); + $this->validateAll(plans: $plans, mode: self::MODE_DRAFT); $created = $this->writeAll(plans: $plans, subject: $subject, status: ObjectEntity::STATUS_DRAFT, uuid: $draftId); return ['id' => (string)$created[0]['object']->getUuid(), 'status' => ObjectEntity::STATUS_DRAFT]; @@ -236,13 +254,7 @@ public function submitAll( } $plans = $this->plan(writes: $writes, payload: $payload, scope: $scope); - if ($draftId === null) { - $this->validateAll(plans: $plans); - $created = $this->writeAll(plans: $plans, subject: $subject); - $receivedAt = null; - } else { - [$created, $receivedAt] = $this->promoteDraft(plans: $plans, subject: $subject, draftId: $draftId); - } + [$created, $receivedAt] = $this->writeOrPromote(plans: $plans, subject: $subject, draftId: $draftId); $this->uploads->claim(tokens: array_merge(...array_map(static fn (array $plan): array => $plan['tokens'], $plans))); $answer = $this->answer(created: $created, receivedAt: $receivedAt); @@ -253,6 +265,27 @@ public function submitAll( return $answer; }//end submitAll() + /** + * Validate and write a fresh submit, or promote a draft. + * + * @param array> $plans The plans. + * @param IUser|null $subject The subject. + * @param string|null $draftId The draft to leave, or null. + * + * @return array{0: array, object: ObjectEntity}>, 1: DateTimeImmutable|null} Writes, receipt. + * + * @throws FormSubmitRefusedException 422, 403, 404 or 503. + */ + private function writeOrPromote(array $plans, ?IUser $subject, ?string $draftId): array { + if ($draftId !== null) { + return $this->promoteDraft(plans: $plans, subject: $subject, draftId: $draftId); + } + + $this->validateAll(plans: $plans); + + return [$this->writeAll(plans: $plans, subject: $subject), null]; + }//end writeOrPromote() + /** * Leave draft: full validation of the draft plus the payload, then one allowed promotion, then receipt. * @@ -463,13 +496,13 @@ private function tokenOf(mixed $value): ?string { * Validate every write with its destination's full validator before the first write. * * @param array> $plans The plans. - * @param bool $draft True for a draft: every `required` is excused, nothing else. + * @param string $mode MODE_FULL, or MODE_DRAFT: every `required` is excused, nothing else. * * @return void * * @throws FormSubmitRefusedException 422 with every finding of every write. */ - private function validateAll(array $plans, bool $draft = false): void { + private function validateAll(array $plans, string $mode = self::MODE_FULL): void { $findings = []; foreach ($plans as $plan) { $write = null; @@ -479,7 +512,7 @@ private function validateAll(array $plans, bool $draft = false): void { $object = $this->resolveWriteReferences(object: $plan['object'], ids: [], pending: true); $excused = $this->serverFilled(schema: $plan['schema'], object: $object); - if ($draft === true) { + if ($mode === self::MODE_DRAFT) { $excused = array_merge((new DraftStatusPolicy())->requiredExcusals(schema: $plan['schema']), $excused); } diff --git a/lib/Service/Object/DraftStatusPolicy.php b/lib/Service/Object/DraftStatusPolicy.php index 79cd437a0c..c868f675ea 100644 --- a/lib/Service/Object/DraftStatusPolicy.php +++ b/lib/Service/Object/DraftStatusPolicy.php @@ -97,6 +97,20 @@ public function resolveStatus(ObjectEntity $entity, array $selfData): ?string { return $current; } + return $this->resolveMove(entity: $entity, asked: $asked); + }//end resolveStatus() + + /** + * The status a requested move lands on, or a refusal. + * + * @param ObjectEntity $entity The object being saved. + * @param string $asked The status asked for, different from the stored one. + * + * @return string The status to store. + * + * @throws ValidationException When the move is not allowed. + */ + private function resolveMove(ObjectEntity $entity, string $asked): string { if ($asked === ObjectEntity::STATUS_DRAFT) { if ($entity->getId() === null) { return ObjectEntity::STATUS_DRAFT; @@ -105,7 +119,7 @@ public function resolveStatus(ObjectEntity $entity, array $selfData): ?string { throw new ValidationException(message: 'A saved object cannot go back to draft.'); } - if ($asked === ObjectEntity::STATUS_ACTIVE && $current === ObjectEntity::STATUS_DRAFT) { + if ($asked === ObjectEntity::STATUS_ACTIVE && $entity->isDraft() === true) { $uuid = (string)$entity->getUuid(); if (isset(self::$promotions[$uuid]) === true) { unset(self::$promotions[$uuid]); @@ -117,7 +131,7 @@ public function resolveStatus(ObjectEntity $entity, array $selfData): ?string { } throw new ValidationException(message: sprintf('Status "%s" is not one an object can be given here.', $asked)); - }//end resolveStatus() + }//end resolveMove() /** * Whether a write saves a draft: the payload asks for draft, or the stored object is one and the payload does not leave it. From fb3a0245dc1768e1d2fd25389df4f3236e6ee8b5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 19:01:46 +0200 Subject: [PATCH 051/183] fix(forms): a draft is written only by its owner, checked explicitly before the save --- lib/Controller/FormsController.php | 11 ++- lib/Service/Form/FormSubmitService.php | 74 ++++++++++++++++--- lib/Service/Object/DraftStatusPolicy.php | 2 + tests/Unit/Controller/FormsControllerTest.php | 14 ++++ .../Service/Form/FormSubmitServiceTest.php | 30 +++++++- 5 files changed, 114 insertions(+), 17 deletions(-) diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php index 68b3ae85d0..a79bd3c2b3 100644 --- a/lib/Controller/FormsController.php +++ b/lib/Controller/FormsController.php @@ -225,16 +225,23 @@ public function draft(string $formId): JSONResponse { } $write = $form['writes'][0]; + $destination = ['register' => ($write['register'] ?? null), 'schema' => ($write['schema'] ?? null)]; + $subject = $this->userSession->getUser(); + if ($draftId !== null && $this->submitter->isOwnerOfDraft($destination, $draftId, $subject) === false) { + // The same 404 an unknown id gets, so a draft id is not an oracle. + throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404); + } + $mapping = null; if (is_array($write['mapping'] ?? null) === true) { $mapping = $write['mapping']; } $draft = $this->submitter->saveDraft( - ['register' => ($write['register'] ?? null), 'schema' => ($write['schema'] ?? null)], + $destination, $mapping, $this->payload(), - $this->userSession->getUser(), + $subject, $draftId, $formId ); diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index 737a57e633..54b31dc8d4 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -203,7 +203,7 @@ public function saveDraft( $plans = $this->plan(writes: [array_merge($destination, ['mapping' => $mapping])], payload: $payload, scope: $scope); if ($draftId !== null) { - $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId), $plans[0]['object']); + $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId, subject: $subject), $plans[0]['object']); } $this->validateAll(plans: $plans, mode: self::MODE_DRAFT); @@ -303,7 +303,7 @@ private function promoteDraft(array $plans, ?IUser $subject, string $draftId): a throw new FormSubmitRefusedException(message: $message, status: 422); } - $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId), $plans[0]['object']); + $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId, subject: $subject), $plans[0]['object']); $this->validateAll(plans: $plans); DraftStatusPolicy::allowPromotion(uuid: $draftId); @@ -319,27 +319,77 @@ private function promoteDraft(array $plans, ?IUser $subject, string $draftId): a return [$created, $receivedAt]; }//end promoteDraft() + /** + * Whether a draft of this destination exists and belongs to the subject. + * + * The owner check is explicit, on top of the read RBAC that already keeps + * drafts to their owner: an administrator's session bypasses RBAC, and an + * administrator still may not write into somebody else's draft by id. + * + * @param array{register?: mixed, schema?: mixed} $destination The destination pair. + * @param string $draftId The draft's uuid. + * @param IUser|null $subject The subject. + * + * @return bool True when it is the subject's own draft. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function isOwnerOfDraft(array $destination, string $draftId, ?IUser $subject): bool { + try { + $register = $this->register(reference: $destination['register'] ?? null); + $schema = $this->schema(reference: $destination['schema'] ?? null); + } catch (FormSubmitRefusedException) { + return false; + } + + return $this->ownDraft(register: $register, schema: $schema, draftId: $draftId, subject: $subject) !== null; + }//end isOwnerOfDraft() + + /** + * The subject's own draft, or null. + * + * @param Register $register The register. + * @param Schema $schema The schema. + * @param string $draftId The draft's uuid. + * @param IUser|null $subject The subject. + * + * @return ObjectEntity|null The draft, when it is a draft and the subject owns it. + */ + private function ownDraft(Register $register, Schema $schema, string $draftId, ?IUser $subject): ?ObjectEntity { + if ($subject === null) { + return null; + } + + try { + $draft = $this->objects->find(id: $draftId, register: $register, schema: $schema, _rbac: false, _multitenancy: false); + } catch (Throwable) { + return null; + } + + if ($draft === null || $draft->isDraft() === false || $draft->getOwner() !== $subject->getUID()) { + return null; + } + + return $draft; + }//end ownDraft() + /** * The stored data of the subject's draft, or 404 when it is not theirs or not a draft. * - * Read under RBAC, so another person's draft is not found (drafts answer - * to their owner only, decision 180). + * Only the subject's own draft is found (decision 180); anything else + * answers the same 404, so a draft id is not an oracle. * * @param array $plan The write. * @param string $draftId The draft's uuid. + * @param IUser|null $subject The subject; only their own draft is found. * * @return array The draft's data, without its metadata. * * @throws FormSubmitRefusedException 404. */ - private function draftData(array $plan, string $draftId): array { - try { - $draft = $this->objects->find(id: $draftId, register: $plan['register'], schema: $plan['schema'], _rbac: true, _multitenancy: true); - } catch (Throwable) { - $draft = null; - } - - if ($draft === null || $draft->isDraft() === false) { + private function draftData(array $plan, string $draftId, ?IUser $subject): array { + $draft = $this->ownDraft(register: $plan['register'], schema: $plan['schema'], draftId: $draftId, subject: $subject); + if ($draft === null) { throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404); } diff --git a/lib/Service/Object/DraftStatusPolicy.php b/lib/Service/Object/DraftStatusPolicy.php index c868f675ea..2f44fb55a9 100644 --- a/lib/Service/Object/DraftStatusPolicy.php +++ b/lib/Service/Object/DraftStatusPolicy.php @@ -69,6 +69,8 @@ public static function allowPromotion(string $uuid): void { * Forget every allowance (tests, and a submit that was refused after allowing). * * @return void + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft */ public static function resetPromotions(): void { self::$promotions = []; diff --git a/tests/Unit/Controller/FormsControllerTest.php b/tests/Unit/Controller/FormsControllerTest.php index b3e03b9dad..8510e7e2cb 100644 --- a/tests/Unit/Controller/FormsControllerTest.php +++ b/tests/Unit/Controller/FormsControllerTest.php @@ -312,6 +312,7 @@ public function testADraftIsSavedAndSubmittedByItsId(): void { $this->session->method('getUser')->willReturn($user); $this->resolver->method('resolve')->willReturn($this->form()); $this->params = ['formId' => 'form-1', 'onderwerp' => 'Half af', '_draft' => 'd-1']; + $this->submitter->method('isOwnerOfDraft')->with(['register' => 'dossiq', 'schema' => 'case'], 'd-1', $user)->willReturn(true); $this->submitter->expects($this->once())->method('saveDraft') ->with(['register' => 'dossiq', 'schema' => 'case'], null, ['onderwerp' => 'Half af'], $user, 'd-1', 'form-1') ->willReturn(['id' => 'd-1', 'status' => 'draft']); @@ -326,6 +327,19 @@ public function testADraftIsSavedAndSubmittedByItsId(): void { $this->assertSame(201, $this->controller->submit(formId: 'form-1')->getStatus()); }//end testADraftIsSavedAndSubmittedByItsId() + /** + * Another person's draft id answers 404 and nothing is saved. + */ + public function testSomebodyElsesDraftIdIsA404(): void { + $this->session->method('getUser')->willReturn($this->createMock(IUser::class)); + $this->resolver->method('resolve')->willReturn($this->form()); + $this->params = ['formId' => 'form-1', '_draft' => 'not-mine']; + $this->submitter->method('isOwnerOfDraft')->willReturn(false); + $this->submitter->expects($this->never())->method('saveDraft'); + + $this->assertSame(404, $this->controller->draft(formId: 'form-1')->getStatus()); + }//end testSomebodyElsesDraftIdIsA404() + /** * The draft route is for signed-in people and is not public. */ diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php index 6c1b9c332d..d46eccc5a6 100644 --- a/tests/Unit/Service/Form/FormSubmitServiceTest.php +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -311,6 +311,7 @@ private function fakeSave( $entity->setUuid($uuid ?? ('uuid-draft-' . count($this->saves))); $entity->setObject($data); $entity->setStatus('draft'); + $entity->setOwner($currentUser?->getUID()); $entity->setCreated(new DateTime('2026-10-10T14:03:11+02:00')); $this->stored[$entity->getUuid()] = $entity; @@ -592,7 +593,7 @@ private function organisationThenContact(): array { * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft */ public function testADraftWithoutRequiredDataIsSaved(): void { - $user = $this->createMock(IUser::class); + $user = $this->user(uid: 'alice'); $draft = $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'Half af'], subject: $user); $this->assertSame('draft', $draft['status']); @@ -602,6 +603,29 @@ public function testADraftWithoutRequiredDataIsSaved(): void { $this->assertSame([], $this->dispatched); }//end testADraftWithoutRequiredDataIsSaved() + /** + * A signed-in user with a uid. + */ + private function user(string $uid): IUser { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + + return $user; + }//end user() + + /** + * Somebody else's draft is not found, even by its id: the same 404 as an unknown one. + */ + public function testSomebodyElsesDraftIsA404(): void { + $draft = $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'Van Alice'], subject: $this->user(uid: 'alice')); + + $this->assertTrue($this->service->isOwnerOfDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], draftId: $draft['id'], subject: $this->user(uid: 'alice'))); + $this->assertFalse($this->service->isOwnerOfDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], draftId: $draft['id'], subject: $this->user(uid: 'bob'))); + + $this->expectException(FormSubmitRefusedException::class); + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['caseType' => 'x'], subject: $this->user(uid: 'bob'), draftId: $draft['id']); + }//end testSomebodyElsesDraftIsA404() + /** * Spec scenario: a type-invalid draft is refused and nothing is stored. */ @@ -634,7 +658,7 @@ public function testAnAnonymousDraftIs401(): void { * Spec scenario: leaving draft runs full validation and stamps receipt. */ public function testLeavingDraftRunsFullValidationAndStampsReceipt(): void { - $user = $this->createMock(IUser::class); + $user = $this->user(uid: 'alice'); $draft = $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'Kapvergunning'], subject: $user); $answer = $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['caseType' => 'ct-1'], subject: $user, draftId: $draft['id']); @@ -654,7 +678,7 @@ public function testLeavingDraftRunsFullValidationAndStampsReceipt(): void { * A draft still missing required data cannot leave draft, and stays a draft. */ public function testAnIncompleteDraftCannotLeaveDraft(): void { - $user = $this->createMock(IUser::class); + $user = $this->user(uid: 'alice'); $draft = $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'Half af'], subject: $user); try { From 5cac08634853da43c7dd4f9d781e09b51c834828 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 19:16:52 +0200 Subject: [PATCH 052/183] fix(forms): psalm, phpstan and the lookup audit rule; one destination goes through submit() --- lib/Controller/FormsController.php | 46 +++++++++++++++---- lib/Controller/SchemasController.php | 3 ++ lib/Service/Form/FormDefinitionResolver.php | 2 +- lib/Service/Form/FormSubmitService.php | 3 +- tests/Unit/Controller/FormsControllerTest.php | 22 +++++++-- 5 files changed, 61 insertions(+), 15 deletions(-) diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php index 756e785c44..7dd1fb87ad 100644 --- a/lib/Controller/FormsController.php +++ b/lib/Controller/FormsController.php @@ -45,6 +45,7 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\IL10N; use OCP\IRequest; +use OCP\IUser; use OCP\IUserSession; use OCP\Security\Bruteforce\IThrottler; use Psr\Log\LoggerInterface; @@ -175,13 +176,7 @@ public function submit(string $formId): JSONResponse { $key = null; } - $answer = $this->submitter->submitAll( - $form['writes'], - $this->payload(), - $subject, - $key, - $formId - ); + $answer = $this->submitForm(form: $form, subject: $subject, key: $key); } catch (FormSubmitRefusedException $refused) { return new JSONResponse(data: $refused->toBody(), statusCode: $refused->getStatus()); } @@ -223,6 +218,38 @@ public function upload(string $formId): JSONResponse { return new JSONResponse(data: $issued, statusCode: 201); }//end upload() + /** + * One destination through FormSubmitService::submit(), a journey's writes through submitAll(). + * + * @param array{id: string, writes: array>, audience: string} $form The form. + * @param IUser|null $subject The subject. + * @param string|null $key The Idempotency-Key. + * + * @return array The answer. + * + * @throws FormSubmitRefusedException On any refusal. + */ + private function submitForm(array $form, ?IUser $subject, ?string $key): array { + if (count($form['writes']) !== 1) { + return $this->submitter->submitAll($form['writes'], $this->payload(), $subject, $key, $form['id']); + } + + $write = $form['writes'][0]; + $mapping = null; + if (is_array($write['mapping'] ?? null) === true) { + $mapping = $write['mapping']; + } + + return $this->submitter->submit( + ['register' => ($write['register'] ?? null), 'schema' => ($write['schema'] ?? null)], + $mapping, + $this->payload(), + $subject, + $key, + $form['id'] + ); + }//end submitForm() + /** * The stored form, registering a brute-force attempt when it does not resolve. * @@ -267,8 +294,9 @@ private function ruleFor(array $writes, string $property): array { continue; } - if ($property !== '' && is_array($properties[$property] ?? null) === true) { - return $properties[$property]; + $rule = ($properties[$property] ?? null); + if ($property !== '' && is_array($rule) === true) { + return $rule; } } diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php index 997237f86b..0e8ce8a7e5 100644 --- a/lib/Controller/SchemasController.php +++ b/lib/Controller/SchemasController.php @@ -1060,6 +1060,7 @@ public function create(): JSONResponse { * @return JSONResponse JSON response with updated schema or error * * @psalm-return JSONResponse<200, Schema, + * array>|JSONResponse<200, array, * array>|JSONResponse<400|403|404|409|500, array{error: string}, * array>|JSONResponse<422, array{error: string, * errors: array}, array> @@ -1155,6 +1156,7 @@ private function formDependentsCheck(): ?FormDependentsCheck { * @return JSONResponse JSON response with updated schema or error * * @psalm-return JSONResponse<200, Schema, + * array>|JSONResponse<200, array, * array>|JSONResponse<400|403|404|409|500, array{error: string}, * array>|JSONResponse<422, array{error: string, * errors: array}, array> @@ -1599,6 +1601,7 @@ public function discardDraft(int $id): JSONResponse { * @no-admin-idor-exempt Pure delegation to update(), which performs the checkSchemaManagePermission() guard; this method has no body of its own. * * @psalm-return JSONResponse<200, Schema, + * array>|JSONResponse<200, array, * array>|JSONResponse<400|403|404|409|500, array{error: string}, * array>|JSONResponse<422, array{error: string, * errors: array}, array> diff --git a/lib/Service/Form/FormDefinitionResolver.php b/lib/Service/Form/FormDefinitionResolver.php index a8d35ac7ec..2038887934 100644 --- a/lib/Service/Form/FormDefinitionResolver.php +++ b/lib/Service/Form/FormDefinitionResolver.php @@ -78,7 +78,7 @@ public function resolve(string $formId): array { // RBAC off to READ the form definition only: a public form is read // by a visitor with no account. Nothing of the body is returned; // what may be WRITTEN is decided by the destination's RBAC. - $entity = $this->objects->find(id: $formId, _rbac: false, _multitenancy: false); + $entity = $this->objects->find(id: $formId, _rbac: false, _multitenancy: false, _audit: false); } catch (Throwable) { $entity = null; } diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index 9cdb042f8e..9832874218 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -587,7 +587,8 @@ private function answer(array $created): array { register: $done['plan']['register'], schema: $schema, _rbac: false, - _multitenancy: false + _multitenancy: false, + _audit: false ) ?? $done['object']); $data = $entity->getObject(); $entry = [ diff --git a/tests/Unit/Controller/FormsControllerTest.php b/tests/Unit/Controller/FormsControllerTest.php index c766eef078..8f1c3ca1a1 100644 --- a/tests/Unit/Controller/FormsControllerTest.php +++ b/tests/Unit/Controller/FormsControllerTest.php @@ -157,8 +157,8 @@ public function testASubmitAnswers201(): void { $this->headers = ['Idempotency-Key' => 'k1']; $this->resolver->method('resolve')->with('form-1')->willReturn($this->form()); $answer = ['reference' => '2026-0412', 'id' => 'u', 'receivedAt' => null, 'confirmation' => [], 'objects' => []]; - $this->submitter->expects($this->once())->method('submitAll') - ->with($this->form()['writes'], ['onderwerp' => 'Kapvergunning'], null, 'k1', 'form-1') + $this->submitter->expects($this->once())->method('submit') + ->with(['register' => 'dossiq', 'schema' => 'case'], null, ['onderwerp' => 'Kapvergunning'], null, 'k1', 'form-1') ->willReturn($answer); $response = $this->controller->submit(formId: 'form-1'); @@ -167,6 +167,20 @@ public function testASubmitAnswers201(): void { $this->assertSame($answer, $response->getData()); }//end testASubmitAnswers201() + /** + * A journey form with several writes goes through submitAll(), all or none. + */ + public function testAJourneyFormGoesThroughSubmitAll(): void { + $writes = [['as' => 'org', 'register' => 'crm', 'schema' => 'organisation'], ['as' => 'contact', 'register' => 'crm', 'schema' => 'contact']]; + $this->params = ['formId' => 'form-2', 'naam' => 'De Korst']; + $this->resolver->method('resolve')->willReturn(['id' => 'form-2', 'writes' => $writes, 'audience' => 'public']); + $this->submitter->expects($this->never())->method('submit'); + $this->submitter->expects($this->once())->method('submitAll')->with($writes, ['naam' => 'De Korst'], null, null, 'form-2') + ->willReturn(['reference' => 'r', 'id' => 'i', 'receivedAt' => null, 'confirmation' => [], 'objects' => []]); + + $this->assertSame(201, $this->controller->submit(formId: 'form-2')->getStatus()); + }//end testAJourneyFormGoesThroughSubmitAll() + /** * A refusal answers with its own status and findings. */ @@ -174,7 +188,7 @@ public function testARefusalAnswersWithItsStatusAndFindings(): void { $this->params = ['formId' => 'form-1']; $this->resolver->method('resolve')->willReturn($this->form()); $findings = [['property' => 'caseType', 'code' => 'required', 'message' => 'm']]; - $this->submitter->method('submitAll')->willThrowException(new FormSubmitRefusedException(message: 'Not accepted', status: 422, findings: $findings)); + $this->submitter->method('submit')->willThrowException(new FormSubmitRefusedException(message: 'Not accepted', status: 422, findings: $findings)); $response = $this->controller->submit(formId: 'form-1'); @@ -213,7 +227,7 @@ public function testAFilledHoneypotStoresNothing(): void { public function testTheAudienceIsHonoured(): void { $this->params = ['formId' => 'form-1']; $this->resolver->method('resolve')->willReturn($this->form(audience: 'authenticated')); - $this->submitter->expects($this->once())->method('submitAll')->willReturn(['reference' => 'r', 'id' => 'i', 'receivedAt' => null, 'confirmation' => [], 'objects' => []]); + $this->submitter->expects($this->once())->method('submit')->willReturn(['reference' => 'r', 'id' => 'i', 'receivedAt' => null, 'confirmation' => [], 'objects' => []]); $this->assertSame(401, $this->controller->submit(formId: 'form-1')->getStatus()); From 4339dbe63f4730770ea0d4d0a4c83bfbd04b224e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 19:43:58 +0200 Subject: [PATCH 053/183] fix(objects): psalm on draftData; the aggregation fixture table carries _status --- lib/Service/Form/FormSubmitService.php | 8 ++++++-- .../Aggregation/AggregationRunnerReadPermissionTest.php | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index e481f7d9b4..ddf18ae233 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -393,8 +393,12 @@ private function draftData(array $plan, string $draftId, ?IUser $subject): array throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404); } - $data = $draft->getObject(); - unset($data['id'], $data['@self']); + $data = []; + foreach ($draft->getObject() as $property => $value) { + if ($property !== 'id' && $property !== '@self') { + $data[(string)$property] = $value; + } + } return $data; }//end draftData() diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerReadPermissionTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerReadPermissionTest.php index 44352eb402..a3ce491e4d 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerReadPermissionTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerReadPermissionTest.php @@ -111,7 +111,7 @@ protected function setUp(): void { $this->pdo->exec( 'CREATE TABLE "oc_' . self::TABLE . '" (' . '_uuid TEXT, _owner TEXT, _organisation TEXT, _authorization TEXT,' - . ' _deleted TEXT, _archived TEXT, learner_id TEXT, level TEXT)' + . ' _deleted TEXT, _archived TEXT, _status TEXT, learner_id TEXT, level TEXT)' ); $rows = [ ['u1', 'learner-a', 'low'], From f3a6342043416e9779e619c67dcc46a7e66467d8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 19:45:20 +0200 Subject: [PATCH 054/183] style(forms): prettier on the e2e spec --- tests/e2e/ci/form-destination.spec.ts | 196 ++++++++++++++++++++++---- 1 file changed, 165 insertions(+), 31 deletions(-) diff --git a/tests/e2e/ci/form-destination.spec.ts b/tests/e2e/ci/form-destination.spec.ts index b48ca3781d..09493cb415 100644 --- a/tests/e2e/ci/form-destination.spec.ts +++ b/tests/e2e/ci/form-destination.spec.ts @@ -35,8 +35,14 @@ const RUN = Math.random().toString(36).slice(2, 10) const API = '/index.php/apps/openregister/api' /** An API context, signed in or anonymous. */ -async function contextFor(user?: string, password?: string): Promise { - const headers: Record = { 'OCS-APIRequest': 'true', Accept: 'application/json' } +async function contextFor( + user?: string, + password?: string, +): Promise { + const headers: Record = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + } if (user !== undefined && password !== undefined) { headers.Authorization = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}` } @@ -66,7 +72,9 @@ test.describe('a form submits into its destination object', () => { /** Create a schema in the test register and return its id. */ async function schema(data: Record): Promise { - const res = await admin.post(`${API}/schemas`, { data: { description: 'e2e', ...data } }) + const res = await admin.post(`${API}/schemas`, { + data: { description: 'e2e', ...data }, + }) expect(res.ok(), `schema create failed: ${await res.text()}`).toBeTruthy() return String((await res.json()).id) @@ -74,7 +82,10 @@ test.describe('a form submits into its destination object', () => { /** Store a published form object and return its uuid. */ async function form(body: Record): Promise { - const res = await admin.post(`${API}/objects/${registerId}/${formSchemaId}`, { data: { status: 'published', ...body } }) + const res = await admin.post( + `${API}/objects/${registerId}/${formSchemaId}`, + { data: { status: 'published', ...body } }, + ) expect(res.ok(), `form create failed: ${await res.text()}`).toBeTruthy() return uuidOf(await res.json()) @@ -84,11 +95,18 @@ test.describe('a form submits into its destination object', () => { admin = await contextFor(ADMIN, ADMIN_PASS) visitor = await contextFor() - const reg = await admin.post(`${API}/registers`, { data: { title: `e2e form destination ${RUN}`, description: 'e2e' } }) + const reg = await admin.post(`${API}/registers`, { + data: { title: `e2e form destination ${RUN}`, description: 'e2e' }, + }) expect(reg.ok(), `register create failed: ${await reg.text()}`).toBeTruthy() registerId = String((await reg.json()).id) - const open = { read: ['public'], create: ['public'], update: ['authenticated'], delete: ['authenticated'] } + const open = { + read: ['public'], + create: ['public'], + update: ['authenticated'], + delete: ['authenticated'], + } caseSchemaId = await schema({ title: `e2e case ${RUN}`, hardValidation: false, @@ -97,17 +115,32 @@ test.describe('a form submits into its destination object', () => { properties: { title: { type: 'string', maxLength: 200 }, caseType: { type: 'string' }, - identifier: { type: 'string', 'x-openregister': { serverSet: true, reference: true, confirmation: true } }, + identifier: { + type: 'string', + 'x-openregister': { + serverSet: true, + reference: true, + confirmation: true, + }, + }, bijlage: { type: 'file', maxSize: 1024 }, }, }) - orgSchemaId = await schema({ title: `e2e org ${RUN}`, required: ['name'], authorization: open, properties: { name: { type: 'string' } } }) + orgSchemaId = await schema({ + title: `e2e org ${RUN}`, + required: ['name'], + authorization: open, + properties: { name: { type: 'string' } }, + }) contactSchemaId = await schema({ title: `e2e contact ${RUN}`, required: ['email'], authorization: open, configuration: { unique: ['email'] }, - properties: { email: { type: 'string' }, organisation: { type: 'string' } }, + properties: { + email: { type: 'string' }, + organisation: { type: 'string' }, + }, }) formSchemaId = await schema({ title: `e2e form ${RUN}`, @@ -122,12 +155,31 @@ test.describe('a form submits into its destination object', () => { caseFormId = await form({ destination: { register: registerId, schema: caseSchemaId }, - mapping: { fields: [{ field: 'onderwerp', property: 'title' }, { field: 'bewijs', property: 'bijlage' }], fixed: { caseType: 'ct-1' } }, + mapping: { + fields: [ + { field: 'onderwerp', property: 'title' }, + { field: 'bewijs', property: 'bijlage' }, + ], + fixed: { caseType: 'ct-1' }, + }, }) journeyFormId = await form({ writes: [ - { as: 'org', register: registerId, schema: orgSchemaId, mapping: { fields: [{ field: 'naam', property: 'name' }] } }, - { as: 'contact', register: registerId, schema: contactSchemaId, mapping: { fields: [{ field: 'mail', property: 'email' }], fixed: { organisation: { $write: 'org' } } } }, + { + as: 'org', + register: registerId, + schema: orgSchemaId, + mapping: { fields: [{ field: 'naam', property: 'name' }] }, + }, + { + as: 'contact', + register: registerId, + schema: contactSchemaId, + mapping: { + fields: [{ field: 'mail', property: 'email' }], + fixed: { organisation: { $write: 'org' } }, + }, + }, ], }) }) @@ -138,53 +190,120 @@ test.describe('a form submits into its destination object', () => { test('validate names the required property no field fills, and not the one the server fills', async () => { const res = await admin.post(`${API}/forms/validate`, { - data: { destination: { register: registerId, schema: caseSchemaId }, mapping: { fields: [{ field: 'a', property: 'title', type: 'text', maxLength: 100 }] } }, + data: { + destination: { register: registerId, schema: caseSchemaId }, + mapping: { + fields: [ + { + field: 'a', + property: 'title', + type: 'text', + maxLength: 100, + }, + ], + }, + }, }) expect(res.status()).toBe(200) const body = await res.json() expect(body.accepted).toBe(false) - expect(body.findings.map((f: { property: string, code: string }) => `${f.property}:${f.code}`)).toEqual(['caseType:required-unmapped']) + expect( + body.findings.map( + (f: { property: string; code: string }) => `${f.property}:${f.code}`, + ), + ).toEqual(['caseType:required-unmapped']) const fixed = await admin.post(`${API}/forms/validate`, { - data: { destination: { schema: caseSchemaId }, mapping: { fields: [{ field: 'a', property: 'title', type: 'text', maxLength: 100 }], fixed: { caseType: 'ct-1' } } }, + data: { + destination: { schema: caseSchemaId }, + mapping: { + fields: [ + { + field: 'a', + property: 'title', + type: 'text', + maxLength: 100, + }, + ], + fixed: { caseType: 'ct-1' }, + }, + }, }) expect((await fixed.json()).accepted).toBe(true) }) test('a submit missing a required answer is refused with 422 and creates nothing, hard validation off', async () => { - const before = await admin.get(`${API}/objects/${registerId}/${caseSchemaId}?_limit=100`) + const before = await admin.get( + `${API}/objects/${registerId}/${caseSchemaId}?_limit=100`, + ) const countBefore = (await before.json()).total - const res = await visitor.post(`${API}/forms/${caseFormId}/submit`, { data: {} }) + const res = await visitor.post(`${API}/forms/${caseFormId}/submit`, { + data: {}, + }) expect(res.status()).toBe(422) - expect((await res.json()).findings[0]).toMatchObject({ property: 'title', code: 'required' }) - const after = await admin.get(`${API}/objects/${registerId}/${caseSchemaId}?_limit=100`) + expect((await res.json()).findings[0]).toMatchObject({ + property: 'title', + code: 'required', + }) + const after = await admin.get( + `${API}/objects/${registerId}/${caseSchemaId}?_limit=100`, + ) expect((await after.json()).total).toBe(countBefore) }) test('a repeated Idempotency-Key repeats the first answer and creates one object', async () => { const key = `k-${RUN}` - const first = await visitor.post(`${API}/forms/${caseFormId}/submit`, { data: { onderwerp: 'Kapvergunning' }, headers: { 'Idempotency-Key': key } }) + const first = await visitor.post(`${API}/forms/${caseFormId}/submit`, { + data: { onderwerp: 'Kapvergunning' }, + headers: { 'Idempotency-Key': key }, + }) expect(first.status()).toBe(201) - const second = await visitor.post(`${API}/forms/${caseFormId}/submit`, { data: { onderwerp: 'Kapvergunning' }, headers: { 'Idempotency-Key': key } }) + const second = await visitor.post(`${API}/forms/${caseFormId}/submit`, { + data: { onderwerp: 'Kapvergunning' }, + headers: { 'Idempotency-Key': key }, + }) expect(second.status()).toBe(201) expect((await second.json()).id).toBe((await first.json()).id) }) test('a refused second write deletes the first', async () => { - const ok = await visitor.post(`${API}/forms/${journeyFormId}/submit`, { data: { naam: 'De Korst', mail: `info-${RUN}@dekorst.nl` } }) + const ok = await visitor.post(`${API}/forms/${journeyFormId}/submit`, { + data: { naam: 'De Korst', mail: `info-${RUN}@dekorst.nl` }, + }) expect(ok.status(), await ok.text()).toBe(201) - const orgsBefore = (await (await admin.get(`${API}/objects/${registerId}/${orgSchemaId}?_limit=100`)).json()).total + const orgsBefore = ( + await ( + await admin.get( + `${API}/objects/${registerId}/${orgSchemaId}?_limit=100`, + ) + ).json() + ).total - const dup = await visitor.post(`${API}/forms/${journeyFormId}/submit`, { data: { naam: 'De Korst 2', mail: `info-${RUN}@dekorst.nl` } }) + const dup = await visitor.post(`${API}/forms/${journeyFormId}/submit`, { + data: { naam: 'De Korst 2', mail: `info-${RUN}@dekorst.nl` }, + }) expect(dup.status()).toBe(422) expect((await dup.json()).findings[0].write).toBe('contact') - const orgsAfter = (await (await admin.get(`${API}/objects/${registerId}/${orgSchemaId}?_limit=100`)).json()).total + const orgsAfter = ( + await ( + await admin.get( + `${API}/objects/${registerId}/${orgSchemaId}?_limit=100`, + ) + ).json() + ).total expect(orgsAfter).toBe(orgsBefore) }) test('an oversized file is refused at upload and no token is issued', async () => { const res = await visitor.post(`${API}/forms/${caseFormId}/uploads`, { - multipart: { property: 'bijlage', file: { name: 'groot.pdf', mimeType: 'application/pdf', buffer: Buffer.alloc(4096, 1) } }, + multipart: { + property: 'bijlage', + file: { + name: 'groot.pdf', + mimeType: 'application/pdf', + buffer: Buffer.alloc(4096, 1), + }, + }, }) expect(res.status()).toBe(422) const body = await res.json() @@ -193,15 +312,30 @@ test.describe('a form submits into its destination object', () => { }) test('an unknown form is a 404, the same as an unpublished one', async () => { - const unknown = await visitor.post(`${API}/forms/00000000-0000-4000-8000-000000000000/submit`, { data: {} }) + const unknown = await visitor.post( + `${API}/forms/00000000-0000-4000-8000-000000000000/submit`, + { data: {} }, + ) expect(unknown.status()).toBe(404) }) test('create and update refuse alike, per property', async () => { - const hard = await schema({ title: `e2e hard ${RUN}`, hardValidation: true, required: ['title'], properties: { title: { type: 'string' }, note: { type: 'string' } } }) - const created = await admin.post(`${API}/objects/${registerId}/${hard}`, { data: { note: 'x' } }) - const good = await admin.post(`${API}/objects/${registerId}/${hard}`, { data: { title: 't' } }) - const updated = await admin.put(`${API}/objects/${registerId}/${hard}/${uuidOf(await good.json())}`, { data: { note: 'y' } }) + const hard = await schema({ + title: `e2e hard ${RUN}`, + hardValidation: true, + required: ['title'], + properties: { title: { type: 'string' }, note: { type: 'string' } }, + }) + const created = await admin.post(`${API}/objects/${registerId}/${hard}`, { + data: { note: 'x' }, + }) + const good = await admin.post(`${API}/objects/${registerId}/${hard}`, { + data: { title: 't' }, + }) + const updated = await admin.put( + `${API}/objects/${registerId}/${hard}/${uuidOf(await good.json())}`, + { data: { note: 'y' } }, + ) expect(created.status()).toBe(updated.status()) expect(Array.isArray((await created.json()).errors)).toBe(true) From ff7b169bd2478a6b6c5539a1a87b64a440976f4a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 19:45:42 +0200 Subject: [PATCH 055/183] fix(objects): the e2e spec without the merge markers a82083009e carried, prettier-formatted --- tests/e2e/ci/form-destination.spec.ts | 46 +++++++++++++++++++-------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/tests/e2e/ci/form-destination.spec.ts b/tests/e2e/ci/form-destination.spec.ts index 4875ef3e17..420db6432e 100644 --- a/tests/e2e/ci/form-destination.spec.ts +++ b/tests/e2e/ci/form-destination.spec.ts @@ -160,17 +160,14 @@ test.describe('a form submits into its destination object', () => { caseFormId = await form({ destination: { register: registerId, schema: caseSchemaId }, -<<<<<<< HEAD - mapping: { fields: [{ field: 'onderwerp', property: 'title' }, { field: 'bewijs', property: 'bijlage' }, { field: 'datum', property: 'preferredDate' }], fixed: { caseType: 'ct-1' } }, -======= mapping: { fields: [ { field: 'onderwerp', property: 'title' }, { field: 'bewijs', property: 'bijlage' }, + { field: 'datum', property: 'preferredDate' }, ], fixed: { caseType: 'ct-1' }, }, ->>>>>>> build/d179-form-destination-validator }) journeyFormId = await form({ writes: [ @@ -351,41 +348,62 @@ test.describe('a form submits into its destination object', () => { expect(Array.isArray((await updated.json()).errors)).toBe(true) }) test('a draft without required data is saved as a draft, and only its owner sees it', async () => { - const saved = await admin.post(`${API}/forms/${caseFormId}/draft`, { data: { datum: '2026-11-02T10:00:00+01:00' } }) + const saved = await admin.post(`${API}/forms/${caseFormId}/draft`, { + data: { datum: '2026-11-02T10:00:00+01:00' }, + }) expect(saved.status(), await saved.text()).toBe(200) const draft = await saved.json() expect(draft.status).toBe('draft') - const read = await admin.get(`${API}/objects/${registerId}/${caseSchemaId}/${draft.id}`) + const read = await admin.get( + `${API}/objects/${registerId}/${caseSchemaId}/${draft.id}`, + ) expect((await read.json())['@self'].status).toBe('draft') - const asVisitor = await visitor.get(`${API}/objects/${registerId}/${caseSchemaId}/${draft.id}`) + const asVisitor = await visitor.get( + `${API}/objects/${registerId}/${caseSchemaId}/${draft.id}`, + ) expect(asVisitor.status()).toBe(404) }) test('a type-invalid draft is refused and nothing is stored', async () => { - const res = await admin.post(`${API}/forms/${caseFormId}/draft`, { data: { datum: '06-12345678' } }) + const res = await admin.post(`${API}/forms/${caseFormId}/draft`, { + data: { datum: '06-12345678' }, + }) expect(res.status()).toBe(422) expect((await res.json()).findings[0].property).toBe('preferredDate') }) test('leaving draft runs full validation and stamps receipt', async () => { - const draft = await (await admin.post(`${API}/forms/${caseFormId}/draft`, { data: { datum: '2026-11-02T10:00:00+01:00' } })).json() - - const incomplete = await admin.post(`${API}/forms/${caseFormId}/submit`, { data: { _draft: draft.id } }) + const draft = await ( + await admin.post(`${API}/forms/${caseFormId}/draft`, { + data: { datum: '2026-11-02T10:00:00+01:00' }, + }) + ).json() + + const incomplete = await admin.post(`${API}/forms/${caseFormId}/submit`, { + data: { _draft: draft.id }, + }) expect(incomplete.status()).toBe(422) - const done = await admin.post(`${API}/forms/${caseFormId}/submit`, { data: { _draft: draft.id, onderwerp: 'Kapvergunning' } }) + const done = await admin.post(`${API}/forms/${caseFormId}/submit`, { + data: { _draft: draft.id, onderwerp: 'Kapvergunning' }, + }) expect(done.status(), await done.text()).toBe(201) const answer = await done.json() expect(answer.id).toBe(draft.id) expect(answer.receivedAt).toBeTruthy() - const read = await admin.get(`${API}/objects/${registerId}/${caseSchemaId}/${draft.id}`) + const read = await admin.get( + `${API}/objects/${registerId}/${caseSchemaId}/${draft.id}`, + ) expect((await read.json())['@self'].status).toBe('active') }) test('an object without the field reads as before', async () => { - const plain = await admin.post(`${API}/objects/${registerId}/${caseSchemaId}`, { data: { title: 'plain', caseType: 'ct-1' } }) + const plain = await admin.post( + `${API}/objects/${registerId}/${caseSchemaId}`, + { data: { title: 'plain', caseType: 'ct-1' } }, + ) expect(plain.ok()).toBeTruthy() expect((await plain.json())['@self'].status ?? null).toBeNull() }) From 73b661bbddff57d6f84dcf65e8d11d108e766b69 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 22:18:42 +0200 Subject: [PATCH 056/183] feat(calculations): graduated and bandRate operators sum a quantity over rate bands A quantity accrues over ordered rate bands, optionally capped (ADR-118 C16): the shape of a stepped daily penalty, tiered pricing or a bracket table, so it is configuration for each of them. The validator now treats a lit operand as data, so a literal rate schedule is no longer refused as malformed. --- .../CalculationAnnotationValidator.php | 6 + .../Calculation/CalculationEvaluator.php | 53 +++++ lib/Service/Calculation/RateSchedule.php | 208 ++++++++++++++++++ .../graduated-rate-calculation/proposal.md | 25 +++ .../specs/computed-fields/spec.md | 44 ++++ .../graduated-rate-calculation/tasks.md | 8 + .../Service/Calculation/GraduatedRateTest.php | 157 +++++++++++++ 7 files changed, 501 insertions(+) create mode 100644 lib/Service/Calculation/RateSchedule.php create mode 100644 openspec/changes/graduated-rate-calculation/proposal.md create mode 100644 openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md create mode 100644 openspec/changes/graduated-rate-calculation/tasks.md create mode 100644 tests/Unit/Service/Calculation/GraduatedRateTest.php diff --git a/lib/Service/Calculation/CalculationAnnotationValidator.php b/lib/Service/Calculation/CalculationAnnotationValidator.php index 41491046dc..38d9efc0bc 100644 --- a/lib/Service/Calculation/CalculationAnnotationValidator.php +++ b/lib/Service/Calculation/CalculationAnnotationValidator.php @@ -262,6 +262,12 @@ private function walk( return; } + // A literal is data, not an expression: a rate schedule or a list + // literal holds objects with several keys, which are not operators. + if ($op === 'lit') { + return; + } + $args = $expr[$op]; if ($op === 'prop') { $this->walkProp( diff --git a/lib/Service/Calculation/CalculationEvaluator.php b/lib/Service/Calculation/CalculationEvaluator.php index 6d66740e05..07db6ae203 100644 --- a/lib/Service/Calculation/CalculationEvaluator.php +++ b/lib/Service/Calculation/CalculationEvaluator.php @@ -298,6 +298,20 @@ class CalculationEvaluator { 'result' => 'integer', 'description' => 'Signed whole calendar months between the later and the earlier date.', ], + 'graduated' => [ + 'category' => 'arithmetic', + 'arity' => '2..3', + 'operands' => ['number', 'bands', 'number'], + 'result' => 'number', + 'description' => 'Amount a quantity accrues over rate bands ({upTo, rate}, the last may leave out upTo), capped by an optional third operand.', + ], + 'bandRate' => [ + 'category' => 'arithmetic', + 'arity' => '2', + 'operands' => ['number', 'bands'], + 'result' => 'number', + 'description' => 'The rate of the band a unit falls in, counting units from 1. Null when no band covers it.', + ], 'sha256' => [ 'category' => 'string', 'arity' => '1', @@ -311,11 +325,13 @@ class CalculationEvaluator { * Constructor. * * @param PlaceholderResolver $placeholders Shared placeholder resolver for literal-string interpolation. + * @param RateSchedule $rates The arithmetic behind graduated and bandRate. * * @return void */ public function __construct( private readonly PlaceholderResolver $placeholders, + private readonly RateSchedule $rates = new RateSchedule(), ) { }//end __construct() @@ -426,6 +442,8 @@ private function evaluateNode(array $object, mixed $expression): mixed { 'round' => $this->roundVal(object: $object, args: $args), 'year' => $this->yearOf(object: $object, args: $args), 'monthsElapsed' => $this->monthsElapsed(object: $object, args: $args), + 'graduated' => $this->rateSchedule(object: $object, args: $args, cumulative: true), + 'bandRate' => $this->rateSchedule(object: $object, args: $args, cumulative: false), 'sha256' => $this->sha256Of(object: $object, args: $args), default => throw new EvaluationException(sprintf('Unknown operator "%s".', $op)), };//end match @@ -1447,6 +1465,41 @@ private function monthsElapsed(array $object, mixed $args): ?int { return $this->calendarDiff(from: $earlier, to: $later, unit: 'months'); }//end monthsElapsed() + /** + * Graduated([quantity, bands, cap?]) and bandRate([unit, bands]) over a rate schedule. + * + * Null-safe like the other arithmetic operators: a quantity or a schedule + * that resolves to null yields null, so a record that has not started + * accruing yet has no amount rather than an invented zero. + * + * @param array $object The object's stored data. + * @param mixed $args `[quantity, bands]` or `[quantity, bands, cap]`. + * @param bool $cumulative True for graduated (the accrued amount), false for bandRate. + * + * @return int|float|null The amount or the rate, or null when an operand is null. + * + * @throws EvaluationException When an operand is missing or not numeric, or the schedule is malformed. + * + * @spec openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md + */ + private function rateSchedule(array $object, mixed $args, bool $cumulative): int|float|null { + if (is_array($args) === false || array_is_list($args) === false || count($args) < 2) { + throw new EvaluationException('graduated/bandRate requires (quantity, bands).'); + } + + $cap = null; + if ($cumulative === true && isset($args[2]) === true) { + $cap = $this->evaluateNode(object: $object, expression: $args[2]); + } + + return $this->rates->evaluate( + quantity: $this->evaluateNode(object: $object, expression: $args[0]), + bands: $this->evaluateNode(object: $object, expression: $args[1]), + cap: $cap, + cumulative: $cumulative + ); + }//end rateSchedule() + /** * Sha256(value): lowercase hex SHA-256 digest of the stringified operand. * diff --git a/lib/Service/Calculation/RateSchedule.php b/lib/Service/Calculation/RateSchedule.php new file mode 100644 index 0000000000..17b573cd2c --- /dev/null +++ b/lib/Service/Calculation/RateSchedule.php @@ -0,0 +1,208 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Calculation; + +/** + * A graduated rate schedule, evaluated without I/O. + * + * A schedule is a list of bands, `[{upTo: 14, rate: 23}, {upTo: 28, rate: 35}, + * {rate: 45}]`. Band one covers units 1 to 14, band two units 15 to 28, and a + * last band without `upTo` covers every unit after that. This is the shape of a + * daily penalty that steps up over time, of tiered pricing and of a tax + * bracket table, so it is configuration for any of them and code for none. + * + * Units past the last closed band are not charged when the schedule has no open + * band. A quantity of zero or less charges nothing. + * + * @spec openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md + */ +final class RateSchedule { + + /** + * Evaluate already-resolved operands of graduated or bandRate. + * + * Null-safe like the other arithmetic operators: a quantity or a schedule + * that resolves to null yields null, so a record that has not started + * accruing has no amount rather than an invented zero. + * + * @param mixed $quantity The quantity (graduated) or the unit (bandRate). + * @param mixed $bands The schedule. + * @param mixed $cap The cap, or null. Ignored by bandRate. + * @param bool $cumulative True for graduated, false for bandRate. + * + * @return int|float|null The amount or the rate, or null when an operand is null. + * + * @throws EvaluationException When an operand is not numeric or the schedule is malformed. + * + * @spec openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md + */ + public function evaluate(mixed $quantity, mixed $bands, mixed $cap, bool $cumulative): int|float|null { + if ($quantity === null || $bands === null) { + return null; + } + + if (is_numeric($quantity) === false || is_array($bands) === false) { + throw new EvaluationException('graduated/bandRate needs a numeric quantity and a list of bands.'); + } + + if ($cumulative === false) { + return $this->rateAt(unit: ($quantity + 0), bands: $bands); + } + + if ($cap !== null && is_numeric($cap) === false) { + throw new EvaluationException('The cap of graduated must be numeric.'); + } + + if ($cap !== null) { + $cap = ($cap + 0); + } + + return $this->accrued(quantity: ($quantity + 0), bands: $bands, cap: $cap); + }//end evaluate() + + /** + * The amount a quantity accrues over the schedule, capped when a cap is given. + * + * @param int|float $quantity How many units accrued (for example elapsed days). + * @param array $bands The schedule, see the class comment. + * @param int|float|null $cap The highest amount, or null for no cap. + * + * @return int|float The accrued amount. + * + * @throws EvaluationException When the schedule is malformed. + * + * @spec openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md + */ + public function accrued(int|float $quantity, array $bands, int|float|null $cap): int|float { + $total = 0; + $floor = 0; + foreach ($this->normalise(bands: $bands) as $band) { + if ($quantity <= $floor) { + break; + } + + $ceiling = $quantity; + if ($band['upTo'] !== null && $band['upTo'] < $quantity) { + $ceiling = $band['upTo']; + } + + $total += (($ceiling - $floor) * $band['rate']); + $floor = $ceiling; + } + + if ($cap !== null && $total > $cap) { + return $cap; + } + + return $total; + }//end accrued() + + /** + * The rate of the band a unit falls in. + * + * @param int|float $unit The unit, counted from 1 (for example day 15). + * @param array $bands The schedule, see the class comment. + * + * @return int|float|null The band's rate, or null when no band covers the unit. + * + * @throws EvaluationException When the schedule is malformed. + * + * @spec openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md + */ + public function rateAt(int|float $unit, array $bands): int|float|null { + if ($unit <= 0) { + return null; + } + + foreach ($this->normalise(bands: $bands) as $band) { + if ($band['upTo'] === null || $unit <= $band['upTo']) { + return $band['rate']; + } + } + + return null; + }//end rateAt() + + /** + * Check a schedule and reduce each band to its two numbers. + * + * @param array $bands The schedule as authored. + * + * @return array The checked bands. + * + * @throws EvaluationException When a band is not an object with a numeric rate, the bounds + * do not rise, or an open band is not the last one. + */ + private function normalise(array $bands): array { + if ($bands === [] || array_is_list($bands) === false) { + throw new EvaluationException('A rate schedule must be a non-empty list of bands.'); + } + + $checked = []; + $previous = 0; + $last = (count($bands) - 1); + foreach ($bands as $index => $band) { + $checked[] = $this->checkBand(band: $band, number: ($index + 1), previous: $previous, isLast: ($index === $last)); + $previous = ($checked[$index]['upTo'] ?? $previous); + } + + return $checked; + }//end normalise() + + /** + * Check one band against the band before it. + * + * @param mixed $band The band as authored. + * @param int $number The band's position, from 1, for the message. + * @param int|float $previous The upper bound of the band before it, 0 for the first. + * @param bool $isLast Whether this is the last band, the only one that may be open. + * + * @return array{upTo: int|float|null, rate: int|float} The checked band. + * + * @throws EvaluationException When the band is malformed. + */ + private function checkBand(mixed $band, int $number, int|float $previous, bool $isLast): array { + if (is_array($band) === false || is_numeric($band['rate'] ?? null) === false) { + throw new EvaluationException(sprintf('Band %d of a rate schedule needs a numeric rate.', $number)); + } + + $upTo = ($band['upTo'] ?? null); + if ($upTo === null) { + if ($isLast === false) { + throw new EvaluationException('Only the last band of a rate schedule may leave out upTo.'); + } + + return ['upTo' => null, 'rate' => ($band['rate'] + 0)]; + } + + if (is_numeric($upTo) === false || ($upTo + 0) <= $previous) { + throw new EvaluationException('The upTo bounds of a rate schedule must rise from band to band.'); + } + + return ['upTo' => ($upTo + 0), 'rate' => ($band['rate'] + 0)]; + }//end checkBand() +}//end class diff --git a/openspec/changes/graduated-rate-calculation/proposal.md b/openspec/changes/graduated-rate-calculation/proposal.md new file mode 100644 index 0000000000..55a33f61ba --- /dev/null +++ b/openspec/changes/graduated-rate-calculation/proposal.md @@ -0,0 +1,25 @@ +--- +kind: code +--- + +# Proposal: graduated-rate-calculation + +## Why + +ADR-118 (hydra, decisions 182, 184, 185) moves procedures out of app code and into configuration. Capability C16 (money calculation and payment) needs an amount that accrues over time on a rate that steps up, stops at a cap, and stays current without app code. dossiq computes its Awb 4:17 penalty in a hand-written daily loop today (`DwangsomCalculationService`). The same shape is tiered pricing, a tax bracket table, or a late fee that rises per week. + +OpenRegister's calculation engine already has the rest: materialised calculations, `diffDays`, `now` and the temporal sweep that rewrites a `now`-dependent calculation every day. It lacks one thing: summing a quantity over rate bands. + +## What changes + +- Two operators in `CalculationEvaluator`, backed by a pure `RateSchedule` class: + - `graduated: [quantity, bands, cap?]` returns the amount the quantity accrues over the bands, capped when a cap is given. + - `bandRate: [unit, bands]` returns the rate of the band a unit falls in, counting from 1. +- A band list is `[{upTo, rate}, ..., {rate}]`. Only the last band may leave out `upTo`. A malformed schedule is refused with an `EvaluationException`, never read as zero. +- `CalculationAnnotationValidator` treats a `lit` operand as data. Before this change, a literal holding objects (a rate schedule) was walked as if it were an expression and refused as `calculation-malformed-expr`. +- Both operators are published through `OperatorCatalogue` automatically, because the catalogue reads `CalculationEvaluator::OPERATORS`. + +## Out of scope + +- Interest over a period (clawback with statutory interest). That follows when subsidie moves (lane R4). +- The dossiq configuration that uses these operators. That ships in its own dossiq PR. diff --git a/openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md b/openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md new file mode 100644 index 0000000000..8120aac504 --- /dev/null +++ b/openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md @@ -0,0 +1,44 @@ +# Computed Fields Specification (delta) + +## ADDED Requirements + +### Requirement: A quantity accrues over graduated rate bands (REQ-GRC-01) + +The calculation engine MUST provide a `graduated` operator taking `[quantity, bands, cap?]`. `bands` MUST resolve to a list of `{upTo, rate}` objects with rising `upTo`, where only the last band MAY leave out `upTo` and then covers every later unit. The result MUST be the sum, per band, of the units of the quantity that fall in that band times the band's rate, and MUST NOT exceed `cap` when a cap is given. Units past the last closed band of a schedule without an open band MUST NOT be charged. A quantity of zero or less MUST yield zero. A quantity or schedule that resolves to null MUST yield null. + +#### Scenario: A stepped daily fee +- **GIVEN** bands `[{upTo: 3, rate: 2}, {upTo: 8, rate: 3}, {rate: 5}]` +- **WHEN** `graduated` is evaluated for quantity 9 +- **THEN** the result MUST be 26 (3 x 2 + 5 x 3 + 1 x 5) + +#### Scenario: The cap holds +- **GIVEN** the same bands and cap 40 +- **WHEN** `graduated` is evaluated for quantity 13 +- **THEN** the result MUST be 40 + +### Requirement: The rate of the band a unit falls in (REQ-GRC-02) + +The calculation engine MUST provide a `bandRate` operator taking `[unit, bands]` that returns the rate of the band whose range contains the unit, counting units from 1, and null when no band covers the unit (a unit of zero or less, or a unit past a closed schedule). + +#### Scenario: The boundary unit belongs to the lower band +- **GIVEN** bands `[{upTo: 3, rate: 2}, {upTo: 8, rate: 3}, {rate: 5}]` +- **WHEN** `bandRate` is evaluated for units 3, 4 and 9 +- **THEN** the results MUST be 2, 3 and 5 + +### Requirement: A malformed schedule is refused (REQ-GRC-03) + +An empty schedule, a schedule that is not a list, a band without a numeric rate, an open band before the last position, or `upTo` bounds that do not rise MUST raise an evaluation error. A malformed schedule MUST NOT be read as an amount of zero. + +#### Scenario: Falling bounds +- **GIVEN** bands `[{upTo: 5, rate: 1}, {upTo: 3, rate: 2}]` +- **WHEN** `graduated` is evaluated +- **THEN** evaluation MUST fail with an evaluation error + +### Requirement: A literal operand is data (REQ-GRC-04) + +The calculation annotation validator MUST NOT walk into the operand of `lit`, so a literal list of objects such as a rate schedule is accepted on import. + +#### Scenario: A schedule written in the expression +- **GIVEN** a calculation `graduated: [{prop: hours}, {lit: [{upTo: 3, rate: 2}, {rate: 5}]}, 40]` +- **WHEN** the schema is validated +- **THEN** the validator MUST return no errors diff --git a/openspec/changes/graduated-rate-calculation/tasks.md b/openspec/changes/graduated-rate-calculation/tasks.md new file mode 100644 index 0000000000..488ee62398 --- /dev/null +++ b/openspec/changes/graduated-rate-calculation/tasks.md @@ -0,0 +1,8 @@ +# Tasks: graduated-rate-calculation + + + +- [x] 1.1 `RateSchedule` sums a quantity over rate bands with an optional cap, and returns the rate of the band a unit falls in (REQ-GRC-01, REQ-GRC-02) +- [x] 1.2 `graduated` and `bandRate` are dispatched by `CalculationEvaluator` and listed in `OPERATORS`, null-safe like the other arithmetic operators (REQ-GRC-01, REQ-GRC-02) +- [x] 1.3 A malformed schedule is refused, not read as zero (REQ-GRC-03) +- [x] 1.4 `CalculationAnnotationValidator` does not walk into a `lit` operand (REQ-GRC-04) diff --git a/tests/Unit/Service/Calculation/GraduatedRateTest.php b/tests/Unit/Service/Calculation/GraduatedRateTest.php new file mode 100644 index 0000000000..a822f9b899 --- /dev/null +++ b/tests/Unit/Service/Calculation/GraduatedRateTest.php @@ -0,0 +1,157 @@ + 3, 'rate' => 2], + ['upTo' => 8, 'rate' => 3], + ['rate' => 5], + ]; + + private CalculationEvaluator $eval; + + protected function setUp(): void { + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn(null); + $this->eval = new CalculationEvaluator(new PlaceholderResolver($session)); + } + + private function fee(mixed $hours, mixed $bands = self::BANDS, mixed $cap = null): mixed { + $args = [['prop' => 'hours'], ['prop' => 'bands']]; + if ($cap !== null) { + $args[] = ['prop' => 'cap']; + } + + return $this->eval->evaluate(['hours' => $hours, 'bands' => $bands, 'cap' => $cap], ['graduated' => $args]); + } + + public function testEachBandChargesItsOwnRate(): void { + $this->assertSame(0, $this->fee(0)); + $this->assertSame(2, $this->fee(1)); + $this->assertSame(6, $this->fee(3)); + $this->assertSame(9, $this->fee(4)); + $this->assertSame(21, $this->fee(8)); + $this->assertSame(26, $this->fee(9)); + $this->assertSame(46, $this->fee(13)); + } + + public function testTheSumEqualsTheDayByDaySeries(): void { + $running = 0; + for ($unit = 1; $unit <= 40; $unit++) { + $running += $this->eval->evaluate(['u' => $unit, 'b' => self::BANDS], ['bandRate' => [['prop' => 'u'], ['prop' => 'b']]]); + $this->assertSame($running, $this->fee($unit), sprintf('unit %d', $unit)); + } + } + + public function testTheCapHoldsTheAmount(): void { + $this->assertSame(21, $this->fee(8, cap: 40)); + $this->assertSame(40, $this->fee(13, cap: 40)); + $this->assertSame(40, $this->fee(500, cap: 40)); + } + + public function testANegativeQuantityChargesNothing(): void { + $this->assertSame(0, $this->fee(-4)); + } + + public function testUnitsPastAClosedScheduleAreNotCharged(): void { + $closed = [['upTo' => 2, 'rate' => 10], ['upTo' => 4, 'rate' => 1]]; + $this->assertSame(22, $this->fee(4, $closed)); + $this->assertSame(22, $this->fee(10, $closed)); + $this->assertNull($this->eval->evaluate(['b' => $closed], ['bandRate' => [5, ['prop' => 'b']]])); + } + + public function testFractionalQuantitiesAndRates(): void { + $this->assertEqualsWithDelta(7.5, $this->fee(3.5), 0.0001); + $this->assertEqualsWithDelta(1.25, $this->fee(5, [['rate' => 0.25]]), 0.0001); + } + + public function testALiteralScheduleInTheExpression(): void { + $expr = ['graduated' => [['prop' => 'hours'], ['lit' => self::BANDS], 40]]; + $this->assertSame(26, $this->eval->evaluate(['hours' => 9], $expr)); + } + + public function testBandRateAtTheBoundaries(): void { + $rate = fn (int $u): mixed => $this->eval->evaluate([], ['bandRate' => [$u, ['lit' => self::BANDS]]]); + $this->assertNull($rate(0)); + $this->assertSame(2, $rate(1)); + $this->assertSame(2, $rate(3)); + $this->assertSame(3, $rate(4)); + $this->assertSame(3, $rate(8)); + $this->assertSame(5, $rate(9)); + $this->assertSame(5, $rate(1000)); + } + + public function testANullOperandYieldsNull(): void { + $this->assertNull($this->fee(null)); + $this->assertNull($this->fee(4, null)); + } + + /** + * @return array + */ + public static function malformedSchedules(): array { + return [ + 'empty' => [[]], + 'not a list' => [['a' => ['rate' => 1]]], + 'no rate' => [[['upTo' => 3]]], + 'open band first' => [[['rate' => 1], ['upTo' => 3, 'rate' => 2]]], + 'falling bounds' => [[['upTo' => 5, 'rate' => 1], ['upTo' => 3, 'rate' => 2]]], + 'zero bound' => [[['upTo' => 0, 'rate' => 1]]], + ]; + } + + /** + * @dataProvider malformedSchedules + */ + public function testAMalformedScheduleIsRefused(mixed $bands): void { + $this->expectException(EvaluationException::class); + $this->fee(4, $bands); + } + + public function testANonNumericQuantityIsRefused(): void { + $this->expectException(EvaluationException::class); + $this->fee('four'); + } + + public function testANonNumericCapIsRefused(): void { + $this->expectException(EvaluationException::class); + $this->fee(4, cap: 'lots'); + } + + public function testAMissingScheduleOperandIsRefused(): void { + $this->expectException(EvaluationException::class); + $this->eval->evaluate(['hours' => 2], ['graduated' => [['prop' => 'hours']]]); + } + + public function testTheValidatorAcceptsALiteralSchedule(): void { + $errors = (new CalculationAnnotationValidator())->validate([ + 'x-openregister-calculations' => [ + 'fee' => [ + 'type' => 'number', + 'expression' => ['graduated' => [['prop' => 'hours'], ['lit' => self::BANDS], 40]], + ], + ], + 'properties' => ['hours' => ['type' => 'integer']], + ]); + $this->assertSame([], $errors); + } +} From 869421d2b60e657057c73ec6257c778a84dfbb2f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 22:46:04 +0200 Subject: [PATCH 057/183] feat(tasks): a portal task may name the app action that completes it metadata.completeVia = {app, action} makes the portal seam refuse a direct completion with 409 complete-via-app, naming the action, after the party check and before anything is stored. Only the named app completes the task in-process (PortalTaskService::complete(viaApp: ...)). A portal reads the same key off the task row and opens the app's action instead of the upload form, so an app's own checks (a frozen checklist, a signature, a payment) cannot be skipped by completing the task through the seam. Change a-portal-task-names-the-app-that-completes-it. --- lib/Controller/PortalTaskController.php | 8 +++ lib/Exception/TaskCompleteViaAppException.php | 62 +++++++++++++++++++ lib/Service/Portal/PortalTaskService.php | 46 ++++++++++++++ .../proposal.md | 48 ++++++++++++++ .../specs/flow-portal-task/spec.md | 45 ++++++++++++++ .../tasks.md | 7 +++ .../Controller/PortalTaskControllerTest.php | 22 +++++++ .../TaskCompleteViaAppExceptionTest.php | 49 +++++++++++++++ .../Service/Portal/PortalTaskServiceTest.php | 57 +++++++++++++++++ 9 files changed, 344 insertions(+) create mode 100644 lib/Exception/TaskCompleteViaAppException.php create mode 100644 openspec/changes/a-portal-task-names-the-app-that-completes-it/proposal.md create mode 100644 openspec/changes/a-portal-task-names-the-app-that-completes-it/specs/flow-portal-task/spec.md create mode 100644 openspec/changes/a-portal-task-names-the-app-that-completes-it/tasks.md create mode 100644 tests/Unit/Exception/TaskCompleteViaAppExceptionTest.php diff --git a/lib/Controller/PortalTaskController.php b/lib/Controller/PortalTaskController.php index 664d316143..27d56aa461 100644 --- a/lib/Controller/PortalTaskController.php +++ b/lib/Controller/PortalTaskController.php @@ -47,6 +47,7 @@ use OCA\OpenRegister\Db\PortalTaskDelivery; use OCA\OpenRegister\Exception\PortalSubjectException; use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskCompleteViaAppException; use OCA\OpenRegister\Exception\TaskConflictException; use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\Portal\PortalSubject; @@ -290,6 +291,13 @@ private function asSubject(callable $action): JSONResponse { 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 (TaskCompleteViaAppException $elsewhere) { + // The task names the app action that completes it: say which, so + // the portal opens that action instead of this form. + return new JSONResponse( + ['error' => $elsewhere->getMessage(), 'code' => 'complete-via-app', 'completeVia' => $elsewhere->completeVia()], + Http::STATUS_CONFLICT + ); } catch (TaskConflictException $conflict) { return new JSONResponse(['error' => $conflict->getMessage(), 'code' => 'task-closed'], Http::STATUS_CONFLICT); } catch (Throwable $failure) { diff --git a/lib/Exception/TaskCompleteViaAppException.php b/lib/Exception/TaskCompleteViaAppException.php new file mode 100644 index 0000000000..dcf8871be4 --- /dev/null +++ b/lib/Exception/TaskCompleteViaAppException.php @@ -0,0 +1,62 @@ + + * 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/a-portal-task-names-the-app-that-completes-it/specs/flow-portal-task/spec.md#requirement-a-task-may-name-the-app-action-that-completes-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +/** + * A direct completion of a task that must be completed through an app action. + * + * @spec openspec/changes/a-portal-task-names-the-app-that-completes-it/specs/flow-portal-task/spec.md#requirement-a-task-may-name-the-app-action-that-completes-it + */ +class TaskCompleteViaAppException extends TaskConflictException { + + /** + * Constructor. + * + * @param string $app The app that completes the task. + * @param string $action The portal action of that app. + */ + public function __construct( + private readonly string $app, + private readonly string $action, + ) { + parent::__construct(message: sprintf('This task is completed through the %s action of %s.', $action, $app)); + }//end __construct() + + /** + * Where the completion goes instead. + * + * @return array{app: string, action: string} The app and its action. + * + * @spec openspec/changes/a-portal-task-names-the-app-that-completes-it/specs/flow-portal-task/spec.md#requirement-a-task-may-name-the-app-action-that-completes-it + */ + public function completeVia(): array { + return ['app' => $this->app, 'action' => $this->action]; + }//end completeVia() +}//end class diff --git a/lib/Service/Portal/PortalTaskService.php b/lib/Service/Portal/PortalTaskService.php index 09301206c9..efd78d62b4 100644 --- a/lib/Service/Portal/PortalTaskService.php +++ b/lib/Service/Portal/PortalTaskService.php @@ -40,6 +40,7 @@ use DateTime; use OCA\OpenRegister\Db\Task; use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Exception\TaskCompleteViaAppException; use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\FileService; use OCA\OpenRegister\Service\Task\TaskInboxService; @@ -74,6 +75,14 @@ class PortalTaskService { */ public const FILE_TAG_PREFIX = 'portal-task:'; + /** + * The task metadata key naming the app action that completes the task: + * `{app, action}`. With it set, only that app completes the task here. + * + * @var string + */ + public const COMPLETE_VIA = 'completeVia'; + /** * Constructor. * @@ -190,12 +199,17 @@ public function row(Task $task): array { * @param array> $files The uploads, each * {name, type, size, tmp_name|content}. * @param string $outcome The outcome to record; defaults to `submitted`. + * @param string $viaApp The app completing the task in-process, when it + * is the app the task's `completeVia` names; empty + * for the portal's own direct completion. * * @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 TaskCompleteViaAppException When the task names an app action that + * completes it and the caller is not that app (nothing stored). * @throws TaskValidationException When an upload constraint is violated (nothing stored). * @throws DoesNotExistException When no such task exists. * @@ -208,12 +222,22 @@ public function complete( ?string $comment = null, array $files = [], string $outcome = self::DEFAULT_OUTCOME, + string $viaApp = '', ): 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()); + // 1b. A task that names the app action completing it is completed by + // that app alone, so the app's own checks cannot be skipped by + // calling this seam directly. Only the matched party gets here, so + // the refusal tells nobody else that the task exists. + $via = $this->completeVia(task: $task); + if ($via !== null && $via['app'] !== $viaApp) { + throw new TaskCompleteViaAppException(app: $via['app'], action: $via['action']); + } + // 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); @@ -240,6 +264,28 @@ public function complete( ); }//end complete() + /** + * The app action a task names as the one that completes it, or null when + * it names none or names it incompletely (an app without an action, or + * the reverse, is ignored rather than locking the task). + * + * @param Task $task The task. + * + * @return array{app: string, action: string}|null The app and its action. + * + * @spec openspec/changes/a-portal-task-names-the-app-that-completes-it/specs/flow-portal-task/spec.md#requirement-a-task-may-name-the-app-action-that-completes-it + */ + public function completeVia(Task $task): ?array { + // A scalar casts to a list without `app` or `action`, so it reads as none. + $via = (array)(($task->getMetadata() ?? [])[self::COMPLETE_VIA] ?? []); + $route = ['app' => trim((string)($via['app'] ?? '')), 'action' => trim((string)($via['action'] ?? ''))]; + if (in_array('', $route, true) === true) { + return null; + } + + return $route; + }//end completeVia() + /** * Refuse a completion that violates the task's upload constraints, * naming the constraint. diff --git a/openspec/changes/a-portal-task-names-the-app-that-completes-it/proposal.md b/openspec/changes/a-portal-task-names-the-app-that-completes-it/proposal.md new file mode 100644 index 0000000000..41b70d5844 --- /dev/null +++ b/openspec/changes/a-portal-task-names-the-app-that-completes-it/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +depends_on: [flow-portal-task] +--- + +# Proposal: a-portal-task-names-the-app-that-completes-it + +## Summary + +A task may name the app action that completes it: `metadata.completeVia` = +`{app, action}`. The portal seam then refuses a direct completion of that +task with 409 `complete-via-app`, naming the app and the action, and only the +named app completes it in-process (`PortalTaskService::complete(viaApp: ...)`). +A portal reads the same key off the task row and opens that app's action +instead of the generic upload form. + +## Why + +Some tasks are more than "send a file and a comment". dossiq's checklist task +for an external inspector must be checked against the checklist frozen on the +task and completed with the rule's outcome; that check lives in dossiq. Before +this change the portal's "Mijn taken" completed every task straight through +this seam, so the party could skip the app's checks simply by using the +generic form, or by calling the seam directly. Hiding the form in the portal +would not close that: the seam would still accept the call. The refusal +belongs where the completion is recorded. + +The key is generic. Any app that completes its own tasks (a checklist, a +signature, a payment) sets it on the tasks it creates; nothing here names a +procedure (decision 182). + +## What changes + +- `PortalTaskService::COMPLETE_VIA` and `completeVia(Task)`: the whole + `{app, action}` or null; half of one is ignored rather than locking a task. +- `PortalTaskService::complete()` takes `viaApp`. After authorization (so a + stranger still reads absence and the denial is still audited), a task that + names an app is refused unless `viaApp` is that app. Nothing is stored. +- `TaskCompleteViaAppException` (a `TaskConflictException`) carries the route. +- `PortalTaskController::complete` answers 409 `complete-via-app` with + `completeVia`. + +## Out of scope + +- The internal task API: a staff user completing a task in Nextcloud is not + the portal seam, and the app owning the task decides what its own UI offers. +- The portal side (portaliq opens the action) and the app side (dossiq sets the + key on its checklist tasks) ship in their own repos. diff --git a/openspec/changes/a-portal-task-names-the-app-that-completes-it/specs/flow-portal-task/spec.md b/openspec/changes/a-portal-task-names-the-app-that-completes-it/specs/flow-portal-task/spec.md new file mode 100644 index 0000000000..e637645b3d --- /dev/null +++ b/openspec/changes/a-portal-task-names-the-app-that-completes-it/specs/flow-portal-task/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: A task may name the app action that completes it + +A task MAY carry `metadata.completeVia` with a non-empty `app` and a +non-empty `action`: the app whose own checks a completion must pass and the +portal action of that app that completes the task. A `completeVia` missing +either value SHALL be ignored. + +The portal seam SHALL refuse a direct completion of a task that names an app +action, after the party check and before anything is stored, with HTTP 409, +code `complete-via-app`, and `completeVia` naming the app and the action. A +subject who is not the task's party SHALL still read the task as absent. + +The task service's portal completion SHALL complete such a task only when the +in-process caller names the same app (`viaApp`). The task row SHALL carry the +key unchanged in `metadata`, so a portal can open the named action instead of +its generic form. + +#### Scenario: The party is sent to the app action + +- GIVEN an open external task for `party:bsn-1` with `metadata.completeVia` = `{app: dossiq, action: completeChecklistTask}` +- WHEN subject `bsn-1` posts a completion to the portal seam +- THEN the answer is 409 with code `complete-via-app` and `completeVia` = `{app: dossiq, action: completeChecklistTask}` +- AND no file is stored and the task stays open + +#### Scenario: The named app completes the task + +- GIVEN the same task +- WHEN dossiq completes it in-process for subject `bsn-1` with `viaApp` = `dossiq` +- THEN the task is completed + +#### Scenario: Another app or a stranger cannot + +- GIVEN the same task +- WHEN another app completes it with `viaApp` = `pipelinq` +- THEN the completion is refused with `complete-via-app` +- WHEN subject `bsn-2` completes it +- THEN the completion is refused as an access denial, read as an absent task + +#### Scenario: Half a completeVia locks nothing + +- GIVEN a task whose `metadata.completeVia` names an app but no action +- WHEN its party completes it through the portal seam +- THEN the completion proceeds as for any portal task diff --git a/openspec/changes/a-portal-task-names-the-app-that-completes-it/tasks.md b/openspec/changes/a-portal-task-names-the-app-that-completes-it/tasks.md new file mode 100644 index 0000000000..666a4b9428 --- /dev/null +++ b/openspec/changes/a-portal-task-names-the-app-that-completes-it/tasks.md @@ -0,0 +1,7 @@ +# Tasks: a-portal-task-names-the-app-that-completes-it + +- [x] 1.1 `PortalTaskService::COMPLETE_VIA` + `completeVia(Task)`: whole `{app, action}` or null (`tests/Unit/Service/Portal/PortalTaskServiceTest.php::testOnlyAWholeCompleteViaCounts`) +- [x] 1.2 `PortalTaskService::complete(viaApp:)` refuses a task naming another app after authorization, before storing (`testATaskNamingItsAppIsCompletedByThatAppOnly`) +- [x] 1.3 `TaskCompleteViaAppException` carries the route (`tests/Unit/Exception/TaskCompleteViaAppExceptionTest.php`) +- [x] 1.4 `PortalTaskController::complete` answers 409 `complete-via-app` with `completeVia` (`tests/Unit/Controller/PortalTaskControllerTest.php::testACompletionPastTheNamedAppNamesTheAction`) +- [ ] 2.1 Live: a dossiq checklist task is refused at the seam and completes through dossiq's portal action (live pass, decision 139) diff --git a/tests/Unit/Controller/PortalTaskControllerTest.php b/tests/Unit/Controller/PortalTaskControllerTest.php index 6eb515045b..ce49c42520 100644 --- a/tests/Unit/Controller/PortalTaskControllerTest.php +++ b/tests/Unit/Controller/PortalTaskControllerTest.php @@ -30,6 +30,7 @@ use OCA\OpenRegister\Db\Task; use OCA\OpenRegister\Exception\PortalSubjectException; use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskCompleteViaAppException; use OCA\OpenRegister\Exception\TaskConflictException; use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\Portal\PortalSubject; @@ -54,6 +55,7 @@ * @covers \OCA\OpenRegister\Exception\PortalSubjectException * @covers \OCA\OpenRegister\Db\PortalTaskDelivery * @uses \OCA\OpenRegister\Db\Task + * @uses \OCA\OpenRegister\Exception\TaskCompleteViaAppException * @uses \OCA\OpenRegister\Service\Portal\PortalSubject */ class PortalTaskControllerTest extends TestCase { @@ -281,6 +283,26 @@ public function testCompletionRefusalsTranslateUniformly(): void { } }//end testCompletionRefusalsTranslateUniformly() + /** + * A task completed through an app action answers 409 `complete-via-app` + * and names the app and the action, so the portal can open it. + * + * @return void + */ + public function testACompletionPastTheNamedAppNamesTheAction(): void { + $this->subjectResolves(); + $this->request->method('getUploadedFile')->willReturn(null); + $portal = $this->createMock(PortalTaskService::class); + $portal->method('complete')->willThrowException(new TaskCompleteViaAppException(app: 'dossiq', action: 'completeChecklistTask')); + $this->portal = $portal; + + $response = $this->controller()->complete(uuid: 't-1'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame('complete-via-app', $response->getData()['code']); + $this->assertSame(['app' => 'dossiq', 'action' => 'completeChecklistTask'], $response->getData()['completeVia']); + }//end testACompletionPastTheNamedAppNamesTheAction() + /** * The delivery routes: 401 without a session, 403 for a non-administrator, * the rows for an administrator; settlement passes through and 404s an diff --git a/tests/Unit/Exception/TaskCompleteViaAppExceptionTest.php b/tests/Unit/Exception/TaskCompleteViaAppExceptionTest.php new file mode 100644 index 0000000000..7c7a76757d --- /dev/null +++ b/tests/Unit/Exception/TaskCompleteViaAppExceptionTest.php @@ -0,0 +1,49 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Tests + * @package OCA\OpenRegister\Tests\Unit\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/a-portal-task-names-the-app-that-completes-it/specs/flow-portal-task/spec.md#requirement-a-task-may-name-the-app-action-that-completes-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Exception; + +use OCA\OpenRegister\Exception\TaskCompleteViaAppException; +use OCA\OpenRegister\Exception\TaskConflictException; +use PHPUnit\Framework\TestCase; + +/** + * Tests for {@see TaskCompleteViaAppException}. + * + * @covers \OCA\OpenRegister\Exception\TaskCompleteViaAppException + */ +class TaskCompleteViaAppExceptionTest extends TestCase { + + /** + * It is a conflict, names the action in its message, and hands the route back. + * + * @return void + */ + public function testItNamesTheAppAndTheAction(): void { + $refusal = new TaskCompleteViaAppException(app: 'dossiq', action: 'completeChecklistTask'); + + $this->assertInstanceOf(TaskConflictException::class, $refusal); + $this->assertStringContainsString('completeChecklistTask', $refusal->getMessage()); + $this->assertStringContainsString('dossiq', $refusal->getMessage()); + $this->assertSame(['app' => 'dossiq', 'action' => 'completeChecklistTask'], $refusal->completeVia()); + }//end testItNamesTheAppAndTheAction() +}//end class diff --git a/tests/Unit/Service/Portal/PortalTaskServiceTest.php b/tests/Unit/Service/Portal/PortalTaskServiceTest.php index 153a99af77..3c7a330076 100644 --- a/tests/Unit/Service/Portal/PortalTaskServiceTest.php +++ b/tests/Unit/Service/Portal/PortalTaskServiceTest.php @@ -27,6 +27,7 @@ use OCA\OpenRegister\Db\Task; use OCA\OpenRegister\Db\TaskMapper; use OCA\OpenRegister\Exception\TaskAccessDeniedException; +use OCA\OpenRegister\Exception\TaskCompleteViaAppException; use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\FileService; use OCA\OpenRegister\Service\Portal\PortalSubject; @@ -50,6 +51,7 @@ * @covers \OCA\OpenRegister\Service\Task\TaskAuthorizationService * @covers \OCA\OpenRegister\Service\Task\TaskInboxService * @covers \OCA\OpenRegister\Db\Task + * @covers \OCA\OpenRegister\Exception\TaskCompleteViaAppException * @uses \OCA\OpenRegister\Db\PortalTaskDelivery * @uses \OCA\OpenRegister\Db\ObjectEntity * @uses \OCA\OpenRegister\Service\Task\TaskTemporalProjection @@ -452,4 +454,59 @@ public function testShowReadsAForeignOrInternalTaskAsAbsent(): void { $this->expectException(DoesNotExistException::class); $this->service->show(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1'); }//end testShowReadsAForeignOrInternalTaskAsAbsent() + /** + * A task naming the app action that completes it: the matched party is + * refused here, naming the action, before anything is stored; the named + * app completes it in-process; a stranger is still denied first. + * + * @return void + */ + public function testATaskNamingItsAppIsCompletedByThatAppOnly(): void { + $task = $this->task(['required' => false]); + $task->setMetadata(array_merge($task->getMetadata(), [PortalTaskService::COMPLETE_VIA => ['app' => 'dossiq', 'action' => 'completeChecklistTask']])); + $this->openForRuns($task); + $this->files->expects($this->never())->method('addFile'); + $this->tasks->expects($this->once())->method('complete')->willReturn($task); + + try { + $this->service->complete(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1', files: [['name' => 'x.pdf', 'type' => 'application/pdf', 'size' => 1, 'content' => 'x']]); + $this->fail('The seam completed a task its app completes.'); + } catch (TaskCompleteViaAppException $elsewhere) { + $this->assertSame(['app' => 'dossiq', 'action' => 'completeChecklistTask'], $elsewhere->completeVia()); + } + + try { + $this->service->complete(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1', viaApp: 'pipelinq'); + $this->fail('Another app completed the task.'); + } catch (TaskCompleteViaAppException) { + $this->addToAssertionCount(1); + } + + try { + $this->service->complete(subject: new PortalSubject(subjectRef: 'bsn-2'), uuid: 't-1'); + $this->fail('A stranger learned the task exists.'); + } catch (TaskAccessDeniedException) { + $this->addToAssertionCount(1); + } + + $this->service->complete(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1', viaApp: 'dossiq'); + }//end testATaskNamingItsAppIsCompletedByThatAppOnly() + + /** + * Only a whole `{app, action}` locks a task; half of one is ignored. + * + * @return void + */ + public function testOnlyAWholeCompleteViaCounts(): void { + $task = $this->task(); + $this->assertNull($this->service->completeVia(task: $task)); + + foreach ([['app' => 'dossiq'], ['action' => 'x'], ['app' => ' ', 'action' => 'x'], 'dossiq'] as $half) { + $task->setMetadata([PortalTaskService::COMPLETE_VIA => $half]); + $this->assertNull($this->service->completeVia(task: $task), json_encode($half)); + } + + $task->setMetadata([PortalTaskService::COMPLETE_VIA => ['app' => ' dossiq ', 'action' => 'completeChecklistTask']]); + $this->assertSame(['app' => 'dossiq', 'action' => 'completeChecklistTask'], $this->service->completeVia(task: $task)); + }//end testOnlyAWholeCompleteViaCounts() }//end class From b2091d901023c15716b2fa13d49d13e6c7137d1f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 23:06:52 +0200 Subject: [PATCH 058/183] feat(referential-integrity): onDelete RESTRICT for a reference by key, failing closed A property can cite rows of another schema by a key property (referenceSchema + referenceKey, e.g. a code) and refuse their delete with onDelete RESTRICT. An unreadable citation blocks the delete as unverifiable; a citing table that does not exist yet counts as no citation. The property validator accepts the two keys and refuses referenceKey with any other action. uuid references are unchanged. Generic reference guard, capability C25 of hydra's procedures-are-configuration (decision 182). --- .../Object/ReferentialIntegrityService.php | 162 +++++++++++ .../Schemas/PropertyValidatorHandler.php | 15 +- .../.openspec.yaml | 2 + .../proposal.md | 25 ++ .../screens.md | 3 + .../specs/referential-integrity/spec.md | 24 ++ .../tasks.md | 8 + openspec/specs/referential-integrity/spec.md | 20 ++ .../ReferentialIntegrityKeyReferenceTest.php | 271 ++++++++++++++++++ .../PropertyValidatorReferenceKeyTest.php | 74 +++++ 10 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/archive/2026-10-10-reference-guard-by-key/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-10-reference-guard-by-key/proposal.md create mode 100644 openspec/changes/archive/2026-10-10-reference-guard-by-key/screens.md create mode 100644 openspec/changes/archive/2026-10-10-reference-guard-by-key/specs/referential-integrity/spec.md create mode 100644 openspec/changes/archive/2026-10-10-reference-guard-by-key/tasks.md create mode 100644 tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php create mode 100644 tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php diff --git a/lib/Service/Object/ReferentialIntegrityService.php b/lib/Service/Object/ReferentialIntegrityService.php index 121c9ec1bb..40d50434aa 100644 --- a/lib/Service/Object/ReferentialIntegrityService.php +++ b/lib/Service/Object/ReferentialIntegrityService.php @@ -59,6 +59,7 @@ * @SuppressWarnings(PHPMD.ExcessiveClassLength) * @SuppressWarnings(PHPMD.StaticAccess) * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.TooManyMethods) One integrity algorithm: relation index, graph walk, uuid and key lookups, actions. */ class ReferentialIntegrityService { /** @@ -733,6 +734,19 @@ private function indexRelationsForSchema(\OCA\OpenRegister\Db\Schema $schema, ar continue; } + $referenceKey = $this->extractReferenceKey(property: $property); + if ($referenceKey !== null && $onDelete !== 'RESTRICT') { + // A reference by key (a code, not a uuid) only refuses. CASCADE, + // SET_NULL and SET_DEFAULT rewrite or delete by uuid, so on a key + // they would act on the wrong value; the relation is left out and + // the schema author told why, rather than half-applied. + $this->logger->warning( + message: '[ReferentialIntegrity] referenceKey is only honoured with onDelete RESTRICT; relation ignored', + context: ['schemaId' => $schemaId, 'property' => $propertyName, 'onDelete' => $onDelete] + ); + continue; + } + $isArray = isset($property['type']) && $property['type'] === 'array'; if (isset($this->relationIndex[$targetSchemaId]) === false) { @@ -744,6 +758,7 @@ private function indexRelationsForSchema(\OCA\OpenRegister\Db\Schema $schema, ar 'property' => $propertyName, 'onDelete' => $onDelete, 'isArray' => $isArray, + 'referenceKey' => $referenceKey, ]; }//end foreach @@ -788,9 +803,38 @@ private function extractTargetRef(array $property): ?string { return (string)$property['items']['$ref']; } + // A reference that is not an object relation (a code citing a row by + // key) names its target with `referenceSchema`, so the `$ref` relation + // machinery (rendering, _extend, uuid validation) is not engaged. + if (isset($property['referenceSchema']) === true) { + return (string)$property['referenceSchema']; + } + + if (isset($property['items']['referenceSchema']) === true) { + return (string)$property['items']['referenceSchema']; + } + return null; }//end extractTargetRef() + /** + * The target property a reference cites by, when it is not the uuid. + * + * @param array $property The property configuration array. + * + * @return string|null The target's key property, or null for a uuid reference. + * + * @spec openspec/specs/referential-integrity/spec.md + */ + private function extractReferenceKey(array $property): ?string { + $key = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null)); + if (is_string($key) === false || trim($key) === '' || trim($key) === 'uuid') { + return null; + } + + return trim($key); + }//end extractReferenceKey() + /** * Resolve a schema $ref string to a schema ID. * @@ -883,6 +927,14 @@ private function walkDeletionGraph( $dependents = $this->relationIndex[$schemaId]; foreach ($dependents as $dep) { + if (($dep['referenceKey'] ?? null) !== null) { + $blockers = array_merge( + $blockers, + $this->keyReferenceBlockers(object: $object, dep: $dep, chain: $chain) + ); + continue; + } + // Find actual objects of the dependent schema that reference this object's UUID. $referencingObjects = $this->findReferencingObjects( sourceSchemaId: $dep['sourceSchemaId'], @@ -1019,6 +1071,116 @@ private function walkDeletionGraph( ); }//end walkDeletionGraph() + /** + * The RESTRICT blockers for a reference that cites this object by a key property. + * + * FAIL CLOSED. A citation that cannot be checked blocks the delete with + * reason `unverifiable`: deleting a row that may still be cited is the one + * outcome a RESTRICT rules out. Only a citing table that does not exist + * yet answers "nothing cites it", because then nothing can. + * + * @param ObjectEntity $object The object being deleted. + * @param array $dep The relation index entry (with `referenceKey`). + * @param array $chain The chain so far. + * + * @return array> The blockers, possibly none. + * + * @spec openspec/specs/referential-integrity/spec.md + */ + private function keyReferenceBlockers(ObjectEntity $object, array $dep, array $chain): array { + $uuid = (string)$object->getUuid(); + $value = (($object->getObject() ?? [])[$dep['referenceKey']] ?? null); + if (is_scalar($value) === false || trim((string)$value) === '') { + // Nothing to be cited by. + return []; + } + + $referencing = $this->findReferencingByKey( + sourceSchemaId: $dep['sourceSchemaId'], + propertyName: $dep['property'], + value: trim((string)$value), + isArray: $dep['isArray'] + ); + + $step = "{$uuid} → {$dep['property']}={$value} (RESTRICT by {$dep['referenceKey']})"; + if ($referencing === null) { + return [ + [ + 'objectUuid' => '', + 'schema' => $dep['sourceSchemaId'], + 'property' => $dep['property'], + 'action' => 'RESTRICT', + 'reason' => 'unverifiable', + 'chain' => array_merge($chain, [$step]), + ], + ]; + } + + $blockers = []; + foreach ($referencing as $refObj) { + if ($refObj->getDeleted() !== null && empty($refObj->getDeleted()) === false) { + continue; + } + + $blockers[] = [ + 'objectUuid' => $refObj->getUuid(), + 'schema' => $dep['sourceSchemaId'], + 'property' => $dep['property'], + 'action' => 'RESTRICT', + 'chain' => array_merge($chain, [$step]), + ]; + } + + return $blockers; + }//end keyReferenceBlockers() + + /** + * Rows of a schema whose property holds a key value, or null when that cannot be read. + * + * @param string $sourceSchemaId The citing schema's id. + * @param string $propertyName The citing property. + * @param string $value The cited key value. + * @param bool $isArray Whether the property is an array. + * + * @return ObjectEntity[]|null The citing rows, or null when unverifiable. + * + * @spec openspec/specs/referential-integrity/spec.md + */ + private function findReferencingByKey(string $sourceSchemaId, string $propertyName, string $value, bool $isArray): ?array { + $this->ensureSchemaDetail(); + + $register = $this->schemaRegisterMap[$sourceSchemaId] ?? null; + $schema = $this->schemaCache[$sourceSchemaId] ?? null; + if ($register === null || $schema === null) { + // The broad uuid fallback cannot find a key, so this cannot be checked. + $this->logger->warning( + message: '[ReferentialIntegrity] key reference cannot be checked: citing schema has no register', + context: ['schemaId' => $sourceSchemaId, 'property' => $propertyName] + ); + return null; + } + + try { + return $this->findReferencingInMagicTable( + register: $register, + schema: $schema, + propertyName: $propertyName, + targetUuid: $value, + isArray: $isArray + ); + } catch (\Exception $e) { + if ($this->db->tableExists('openregister_table_' . $register->getId() . '_' . $schema->getId()) === false) { + return []; + } + + $this->logger->warning( + message: '[ReferentialIntegrity] key reference could not be checked, so the delete is refused', + context: ['schemaId' => $sourceSchemaId, 'property' => $propertyName, 'error' => $e->getMessage()] + ); + return null; + } + }//end findReferencingByKey() + /** * Find objects of a specific schema that reference a given UUID in a specific property. * diff --git a/lib/Service/Schemas/PropertyValidatorHandler.php b/lib/Service/Schemas/PropertyValidatorHandler.php index 432bbd6615..e2631d5f28 100644 --- a/lib/Service/Schemas/PropertyValidatorHandler.php +++ b/lib/Service/Schemas/PropertyValidatorHandler.php @@ -494,6 +494,8 @@ class PropertyValidatorHandler { 'calculation' => ['value' => 'object', 'description' => 'Derive the value from other properties instead of asking for it.'], 'computed' => ['value' => 'string', 'description' => 'A Twig expression that derives the value.'], 'onDelete' => ['value' => 'string', 'description' => 'What happens to this object when the one it points at is deleted.'], + 'referenceSchema' => ['value' => 'string', 'description' => 'The schema whose rows this value cites by a key, not a uuid.'], + 'referenceKey' => ['value' => 'string', 'description' => 'The cited schema\'s property this value matches, e.g. code.'], 'cascade' => ['value' => 'boolean', 'description' => 'Save the referenced object along with this one.'], 'cascadeDelete' => ['value' => 'boolean', 'description' => 'Delete the referenced object along with this one.'], 'inversedBy' => ['value' => 'string', 'description' => 'The property on the other side of the relation.'], @@ -953,9 +955,18 @@ public function validateProperty(array $property, string $path = ''): bool { if (($property['onDelete'] ?? null) !== null) { // OnDelete is only valid on relation properties (those with $ref). $hasRef = isset($property['$ref']) === true - || (isset($property['items']['$ref']) === true); + || (isset($property['items']['$ref']) === true) + || isset($property['referenceSchema']) === true + || (isset($property['items']['referenceSchema']) === true); if ($hasRef === false) { - throw new Exception("'onDelete' at '$path' is only valid on relation properties with '\$ref'"); + throw new Exception("'onDelete' at '$path' is only valid on relation properties with '\$ref' or 'referenceSchema'"); + } + + // A reference by key only refuses: the other actions rewrite or + // delete by uuid and would act on the wrong value. + $referenceKey = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null)); + if ($referenceKey !== null && strtoupper((string)$property['onDelete']) !== 'RESTRICT') { + throw new Exception("'referenceKey' at '$path' is only valid with onDelete RESTRICT"); } $validActions = ['CASCADE', 'RESTRICT', 'SET_NULL', 'SET_DEFAULT', 'NO_ACTION']; diff --git a/openspec/changes/archive/2026-10-10-reference-guard-by-key/.openspec.yaml b/openspec/changes/archive/2026-10-10-reference-guard-by-key/.openspec.yaml new file mode 100644 index 0000000000..53f62d7060 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-reference-guard-by-key/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/archive/2026-10-10-reference-guard-by-key/proposal.md b/openspec/changes/archive/2026-10-10-reference-guard-by-key/proposal.md new file mode 100644 index 0000000000..1af8905b54 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-reference-guard-by-key/proposal.md @@ -0,0 +1,25 @@ +# Proposal: reference guard by key + +## Why + +Hydra decision 182 (procedures are configuration) names capability C25, reference guard: "refuses +the delete of a row that another row cites", configured as a schema property. OpenRegister's +`onDelete: RESTRICT` already refuses a delete for a `$ref` relation, but only when the citation is +the cited row's uuid, and a citation it cannot read lets the delete through. Apps therefore keep +their own delete listeners for rows cited by a code, for example dossiq's +`WooRefusalGroundDeleteGuard`, which reads every assessment and decision to look for a ground code. + +## What changes + +- `referenceSchema` and `referenceKey` on a property (or its `items`): a reference by key, honoured + with `onDelete: RESTRICT`, searched in the citing magic table by the key's value. +- Fail closed for key references: an unreadable citation blocks with `reason: unverifiable`; a + citing table that does not exist yet counts as no citation. +- Requirement 16 in `referential-integrity`. + +uuid references behave exactly as before. + +## Impact + +`ReferentialIntegrityService` and `PropertyValidatorHandler` (two vocabulary keys). No migration, no API change; the `DELETION_BLOCKED` response +gains an optional `reason` on a blocker. diff --git a/openspec/changes/archive/2026-10-10-reference-guard-by-key/screens.md b/openspec/changes/archive/2026-10-10-reference-guard-by-key/screens.md new file mode 100644 index 0000000000..c7e7f9c4bb --- /dev/null +++ b/openspec/changes/archive/2026-10-10-reference-guard-by-key/screens.md @@ -0,0 +1,3 @@ +# Screens + +- No screen: a refused delete answers with the existing DELETION_BLOCKED response wherever the delete was made. diff --git a/openspec/changes/archive/2026-10-10-reference-guard-by-key/specs/referential-integrity/spec.md b/openspec/changes/archive/2026-10-10-reference-guard-by-key/specs/referential-integrity/spec.md new file mode 100644 index 0000000000..0f199e8c97 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-reference-guard-by-key/specs/referential-integrity/spec.md @@ -0,0 +1,24 @@ +# Delta: referential-integrity + +## ADDED Requirements + +### Requirement 16: A reference that cites a row by a key property MUST be able to refuse its delete +A schema property whose values cite rows of another schema by a key property (a code, not a uuid) MUST be able to declare `referenceSchema` (the cited schema, on the property or on `items`), `referenceKey` (the cited schema's property, e.g. `code`) and `onDelete: RESTRICT`. Deleting a cited row MUST then be refused with the citing rows as blockers, exactly as for a uuid reference. `referenceSchema` MUST NOT engage the `$ref` relation behaviour (rendering, `_extend`, uuid validation). A key reference MUST be honoured only with `RESTRICT`: saving a schema that declares `referenceKey` with any other action MUST be refused, and such a relation that reaches the relation index anyway MUST be left out with a warning. `onDelete` MUST be accepted on a property with `referenceSchema` as it is on one with `$ref`. The check MUST fail closed: when the citing rows cannot be read, the delete MUST be refused with a blocker whose `reason` is `unverifiable`; only a citing table that does not exist yet MUST count as "nothing cites it". A row without a value for the key MUST be deletable. This is the generic reference guard (capability C25 of hydra's `procedures-are-configuration`), so an app needs no delete listener of its own. + +#### Scenario: A cited code refuses the delete +- **GIVEN** schema `note` with property `labels` = `{type: array, items: {type: string}, referenceSchema: label, referenceKey: code, onDelete: RESTRICT}` +- **AND** a note whose `labels` holds `A1` +- **WHEN** the `label` row with `code` `A1` is deleted +- **THEN** the delete MUST be refused with `DELETION_BLOCKED`, naming the note as blocker +- **AND** the citing table MUST be searched for `A1`, not for the label's uuid + +#### Scenario: An unreadable citation refuses the delete +- **GIVEN** the same schemas, and the citing table exists but cannot be read +- **WHEN** the `label` row is deleted +- **THEN** the delete MUST be refused with a blocker whose `reason` is `unverifiable` + +#### Scenario: A key reference does not cascade +- **GIVEN** a key reference declared with `onDelete: CASCADE` +- **WHEN** the relation index is built +- **THEN** the relation MUST be left out and a warning logged + diff --git a/openspec/changes/archive/2026-10-10-reference-guard-by-key/tasks.md b/openspec/changes/archive/2026-10-10-reference-guard-by-key/tasks.md new file mode 100644 index 0000000000..9e235fb4b0 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-reference-guard-by-key/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] T01 Index `referenceSchema` / `referenceKey`; leave out a key reference with an action other than RESTRICT, with a warning +- [x] T02 Walk: search the citing table for the key's value; skip a row without a key value +- [x] T03 Fail closed: unverifiable blocker; a missing citing table counts as no citation +- [x] T04 Unit tests on a neutral `label` / `note` fixture +- [x] T05 Property validator: `referenceSchema` and `referenceKey` in the vocabulary, onDelete accepted with `referenceSchema`, `referenceKey` refused with an action other than RESTRICT +- [x] T06 Spec requirement 16 diff --git a/openspec/specs/referential-integrity/spec.md b/openspec/specs/referential-integrity/spec.md index 33b88723cb..bd4076669f 100644 --- a/openspec/specs/referential-integrity/spec.md +++ b/openspec/specs/referential-integrity/spec.md @@ -414,6 +414,26 @@ Referential integrity operations MUST bypass RBAC and multi-tenancy filters when - **THEN** `filterByRbac()` MUST check schema authorization for each result - **AND** only the 3 accessible orders MUST be returned +### Requirement 16: A reference that cites a row by a key property MUST be able to refuse its delete +A schema property whose values cite rows of another schema by a key property (a code, not a uuid) MUST be able to declare `referenceSchema` (the cited schema, on the property or on `items`), `referenceKey` (the cited schema's property, e.g. `code`) and `onDelete: RESTRICT`. Deleting a cited row MUST then be refused with the citing rows as blockers, exactly as for a uuid reference. `referenceSchema` MUST NOT engage the `$ref` relation behaviour (rendering, `_extend`, uuid validation). A key reference MUST be honoured only with `RESTRICT`: saving a schema that declares `referenceKey` with any other action MUST be refused, and such a relation that reaches the relation index anyway MUST be left out with a warning. `onDelete` MUST be accepted on a property with `referenceSchema` as it is on one with `$ref`. The check MUST fail closed: when the citing rows cannot be read, the delete MUST be refused with a blocker whose `reason` is `unverifiable`; only a citing table that does not exist yet MUST count as "nothing cites it". A row without a value for the key MUST be deletable. This is the generic reference guard (capability C25 of hydra's `procedures-are-configuration`), so an app needs no delete listener of its own. + +#### Scenario: A cited code refuses the delete +- **GIVEN** schema `note` with property `labels` = `{type: array, items: {type: string}, referenceSchema: label, referenceKey: code, onDelete: RESTRICT}` +- **AND** a note whose `labels` holds `A1` +- **WHEN** the `label` row with `code` `A1` is deleted +- **THEN** the delete MUST be refused with `DELETION_BLOCKED`, naming the note as blocker +- **AND** the citing table MUST be searched for `A1`, not for the label's uuid + +#### Scenario: An unreadable citation refuses the delete +- **GIVEN** the same schemas, and the citing table exists but cannot be read +- **WHEN** the `label` row is deleted +- **THEN** the delete MUST be refused with a blocker whose `reason` is `unverifiable` + +#### Scenario: A key reference does not cascade +- **GIVEN** a key reference declared with `onDelete: CASCADE` +- **WHEN** the relation index is built +- **THEN** the relation MUST be left out and a warning logged + ## Current Implementation Status **Substantially implemented.** Core referential integrity logic exists: diff --git a/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php b/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php new file mode 100644 index 0000000000..4cc1fc408a --- /dev/null +++ b/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php @@ -0,0 +1,271 @@ + + * @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/specs/referential-integrity/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Archival\ArchivalRetentionGuard; +use OCA\OpenRegister\Service\Object\ReferentialIntegrityService; +use OCP\DB\IPreparedStatement; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IDBConnection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * A ground row (`code`) cited by code from an assessment's array property. + * + * The fixture is neutral: a `label` schema whose rows carry a `code`, and a + * `note` schema whose `labels` property lists codes. + * + * @covers \OCA\OpenRegister\Service\Object\ReferentialIntegrityService + */ +class ReferentialIntegrityKeyReferenceTest extends TestCase { + + /** + * The database connection mock. + * + * @var IDBConnection&MockObject + */ + private IDBConnection $db; + + /** + * The schema mapper mock. + * + * @var SchemaMapper&MockObject + */ + private SchemaMapper $schemaMapper; + + /** + * The service under test. + * + * @var ReferentialIntegrityService + */ + private ReferentialIntegrityService $service; + + /** + * Set up. + * + * @return void + */ + protected function setUp(): void { + $this->db = $this->createMock(IDBConnection::class); + $this->schemaMapper = $this->createMock(SchemaMapper::class); + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($this->createMock(ICache::class)); + + $this->service = new ReferentialIntegrityService( + schemaMapper: $this->schemaMapper, + registerMapper: $this->createMock(RegisterMapper::class), + objectEntityMapper: $this->createMock(MagicMapper::class), + auditTrailMapper: $this->createMock(AuditTrailMapper::class), + logger: $this->createMock(LoggerInterface::class), + db: $this->db, + cacheFactory: $cacheFactory, + archivalGuard: $this->createMock(ArchivalRetentionGuard::class), + ); + }//end setUp() + + /** + * Index the two fixture schemas, the citing one with the given property. + * + * @param array $labelsProperty The citing property's definition. + * @param bool $withRegister Whether the citing schema lives in a register. + * + * @return void + */ + private function schemas(array $labelsProperty, bool $withRegister = true): void { + $label = new Schema(); + $label->setId(1); + $label->setSlug('label'); + $label->setUuid('uuid-schema-1'); + $label->setProperties(['code' => ['type' => 'string']]); + + $note = new Schema(); + $note->setId(2); + $note->setSlug('note'); + $note->setUuid('uuid-schema-2'); + $note->setProperties(['labels' => $labelsProperty]); + $note->setRequired([]); + + $this->schemaMapper->method('findAll')->willReturn([$label, $note]); + + $register = new Register(); + $register->setId(7); + + $this->setProperty(name: 'schemaCache', value: ['1' => $label, '2' => $note]); + $this->setProperty(name: 'schemaRegisterMap', value: $withRegister === true ? ['2' => $register] : []); + }//end schemas() + + /** + * Set a private property on the service. + * + * @param string $name The property. + * @param mixed $value The value. + * + * @return void + */ + private function setProperty(string $name, mixed $value): void { + $property = new \ReflectionProperty(ReferentialIntegrityService::class, $name); + $property->setValue($this->service, $value); + }//end setProperty() + + /** + * A label row with a code. + * + * @param string $code The code. + * + * @return ObjectEntity The row. + */ + private function label(string $code): ObjectEntity { + $entity = new ObjectEntity(); + $entity->setUuid('label-uuid'); + $entity->setSchema('1'); + $entity->setObject(['code' => $code]); + + return $entity; + }//end label() + + /** + * The citing-table query answers with these rows, and records the value it was asked for. + * + * @param array> $rows The rows. + * @param array $params Receives the bound parameters. + * + * @return void + */ + private function citingRows(array $rows, array &$params): void { + $statement = $this->createMock(IPreparedStatement::class); + $statement->method('execute')->willReturnCallback( + function (?array $bound = null) use (&$params) { + $params = (array)$bound; + return $this->createMock(\OCP\DB\IResult::class); + } + ); + $statement->method('fetchAll')->willReturn($rows); + $this->db->method('prepare')->willReturn($statement); + }//end citingRows() + + /** + * A row cited by its code may not be deleted; the citing row is named. + * + * @return void + */ + public function testCitedByKeyIsRestricted(): void { + $this->schemas(labelsProperty: ['type' => 'array', 'items' => ['type' => 'string'], 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT']); + $params = []; + $this->citingRows(rows: [['_uuid' => 'note-1', '_register' => '7', '_schema' => '2', '_deleted' => null, '_prop' => '["A1"]']], params: $params); + + $analysis = $this->service->canDelete($this->label(code: 'A1')); + + self::assertFalse($analysis->deletable); + self::assertSame('note-1', $analysis->blockers[0]['objectUuid']); + self::assertSame('RESTRICT', $analysis->blockers[0]['action']); + self::assertSame(['A1'], $params, 'the citing table must be searched for the CODE, not the uuid'); + }//end testCitedByKeyIsRestricted() + + /** + * A row nothing cites by its code may be deleted. + * + * @return void + */ + public function testUncitedKeyIsDeletable(): void { + $this->schemas(labelsProperty: ['type' => 'array', 'items' => ['type' => 'string', 'referenceSchema' => 'label', 'referenceKey' => 'code'], 'onDelete' => 'RESTRICT']); + $params = []; + $this->citingRows(rows: [], params: $params); + + self::assertTrue($this->service->canDelete($this->label(code: 'B2'))->deletable); + }//end testUncitedKeyIsDeletable() + + /** + * A citation that cannot be read refuses the delete as unverifiable. + * + * @return void + */ + public function testUnreadableCitationFailsClosed(): void { + $this->schemas(labelsProperty: ['type' => 'array', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT']); + $this->db->method('prepare')->willThrowException(new RuntimeException('connection lost')); + $this->db->method('tableExists')->willReturn(true); + + $analysis = $this->service->canDelete($this->label(code: 'A1')); + + self::assertFalse($analysis->deletable); + self::assertSame('unverifiable', $analysis->blockers[0]['reason']); + }//end testUnreadableCitationFailsClosed() + + /** + * A citing table that does not exist yet means nothing cites the row. + * + * @return void + */ + public function testMissingCitingTableMeansNoCitation(): void { + $this->schemas(labelsProperty: ['type' => 'array', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT']); + $this->db->method('prepare')->willThrowException(new RuntimeException('no such table')); + $this->db->method('tableExists')->willReturn(false); + + self::assertTrue($this->service->canDelete($this->label(code: 'A1'))->deletable); + }//end testMissingCitingTableMeansNoCitation() + + /** + * A citing schema outside any register cannot be checked, so the delete is refused. + * + * @return void + */ + public function testCitingSchemaWithoutRegisterFailsClosed(): void { + $this->schemas(labelsProperty: ['type' => 'array', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT'], withRegister: false); + + $analysis = $this->service->canDelete($this->label(code: 'A1')); + + self::assertSame('unverifiable', $analysis->blockers[0]['reason']); + }//end testCitingSchemaWithoutRegisterFailsClosed() + + /** + * A row without a value for the key cannot be cited by it. + * + * @return void + */ + public function testRowWithoutKeyIsDeletable(): void { + $this->schemas(labelsProperty: ['type' => 'array', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT']); + $this->db->expects(self::never())->method('prepare'); + + self::assertTrue($this->service->canDelete($this->label(code: ''))->deletable); + }//end testRowWithoutKeyIsDeletable() + + /** + * A key reference with an action other than RESTRICT is not indexed at all. + * + * @return void + */ + public function testKeyReferenceOnlyRestricts(): void { + $this->schemas(labelsProperty: ['type' => 'array', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'CASCADE']); + + self::assertFalse($this->service->hasIncomingOnDeleteReferences('1')); + }//end testKeyReferenceOnlyRestricts() +}//end class diff --git a/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php b/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php new file mode 100644 index 0000000000..acbf38c926 --- /dev/null +++ b/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php @@ -0,0 +1,74 @@ + + * @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/specs/referential-integrity/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Schemas; + +use Exception; +use OCA\OpenRegister\Service\Schemas\PropertyValidatorHandler; +use PHPUnit\Framework\TestCase; + +/** + * `referenceSchema` + `referenceKey` + `onDelete: RESTRICT` is a valid property; other actions are not. + * + * @covers \OCA\OpenRegister\Service\Schemas\PropertyValidatorHandler + */ +class PropertyValidatorReferenceKeyTest extends TestCase { + + /** + * A key reference that restricts is accepted, on the property or on its items. + * + * @return void + */ + public function testRestrictingKeyReferenceIsValid(): void { + $validator = new PropertyValidatorHandler(); + + self::assertTrue($validator->validateProperty(property: ['type' => 'string', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT'])); + self::assertTrue( + $validator->validateProperty( + property: ['type' => 'array', 'items' => ['type' => 'string', 'referenceSchema' => 'label', 'referenceKey' => 'code'], 'onDelete' => 'restrict'] + ) + ); + }//end testRestrictingKeyReferenceIsValid() + + /** + * A key reference with an action that rewrites by uuid is refused. + * + * @return void + */ + public function testCascadingKeyReferenceIsRefused(): void { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'referenceKey' at '' is only valid with onDelete RESTRICT"); + + (new PropertyValidatorHandler())->validateProperty(property: ['type' => 'string', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'CASCADE']); + }//end testCascadingKeyReferenceIsRefused() + + /** + * onDelete without a `$ref` or `referenceSchema` is still refused. + * + * @return void + */ + public function testOnDeleteWithoutTargetIsRefused(): void { + $this->expectException(Exception::class); + $this->expectExceptionMessage('only valid on relation properties'); + + (new PropertyValidatorHandler())->validateProperty(property: ['type' => 'string', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT']); + }//end testOnDeleteWithoutTargetIsRefused() +}//end class From 7e1782dab3d1c0d2634433ee4e8af757807e6ddc Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 10 Oct 2026 23:29:25 +0200 Subject: [PATCH 059/183] feat(dmn): named decision tables are stored once and flows reference them by key Decision 186: a reusable decision table lives in OpenRegister as a generic capability (ADR-118 C15). A core decision-tables register, refused at save when the evaluator cannot run a table or its key is taken; DecisionTableStore resolves a table by key or uuid and evaluates it for apps; the decision-table flow step takes tableRef as an alternative to an inline table. --- appinfo/info.xml | 4 +- lib/AppInfo/Application.php | 3 + .../DecisionTableValidationListener.php | 187 ++++++++++++++++++ lib/Repair/ImportDecisionTableRegister.php | 142 +++++++++++++ lib/Service/Dmn/DecisionTableStore.php | 186 +++++++++++++++++ lib/Service/Flow/Nodes/DecisionTableNode.php | 106 ++++++++-- lib/Settings/decision_table_register.json | 108 ++++++++++ .../stored-decision-tables/proposal.md | 21 ++ .../specs/flow-decision-tables/spec.md | 44 +++++ .../changes/stored-decision-tables/tasks.md | 10 + .../DecisionTableValidationListenerTest.php | 80 ++++++++ .../Service/Dmn/DecisionTableStoreTest.php | 123 ++++++++++++ .../DecisionTableNodeStoredTableTest.php | 104 ++++++++++ 13 files changed, 1099 insertions(+), 19 deletions(-) create mode 100644 lib/Listener/DecisionTableValidationListener.php create mode 100644 lib/Repair/ImportDecisionTableRegister.php create mode 100644 lib/Service/Dmn/DecisionTableStore.php create mode 100644 lib/Settings/decision_table_register.json create mode 100644 openspec/changes/stored-decision-tables/proposal.md create mode 100644 openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md create mode 100644 openspec/changes/stored-decision-tables/tasks.md create mode 100644 tests/Unit/Listener/DecisionTableValidationListenerTest.php create mode 100644 tests/Unit/Service/Dmn/DecisionTableStoreTest.php create mode 100644 tests/Unit/Service/Flow/Nodes/DecisionTableNodeStoredTableTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 611e320354..1b0cf10adf 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.1.35-unstable.20261009130000 + 2.1.35-unstable.20261010220000 EUPL-1.2 Conduction OpenRegister @@ -273,6 +273,7 @@ Vrij en open source onder de EUPL-licentie. and a reversal that has nothing to read. --> OCA\OpenRegister\Repair\ImportMergeOperationRegister OCA\OpenRegister\Repair\ImportDismissedPairRegister + OCA\OpenRegister\Repair\ImportDecisionTableRegister OCA\OpenRegister\Repair\ImportMergeOperationRegister OCA\OpenRegister\Repair\ImportDismissedPairRegister + OCA\OpenRegister\Repair\ImportDecisionTableRegister + +- [x] 1.1 Ship the `decision-tables` register and its import repair step (REQ-SDT-01) +- [x] 1.2 Refuse a stored table the evaluator cannot run, and a duplicate key, at save (REQ-SDT-02) +- [x] 1.3 `DecisionTableStore` resolves by key or uuid and refuses an unknown reference (REQ-SDT-03) +- [x] 1.4 The decision-table step accepts `tableRef`, exactly one of `table` and `tableRef` (REQ-SDT-04) +- [ ] 1.5 Live: import the register on a running instance, store a table, run a flow step by `tableRef` (owed to the live pass) +- [ ] 1.6 Point the decision-table editor at the register (follow-up) diff --git a/tests/Unit/Listener/DecisionTableValidationListenerTest.php b/tests/Unit/Listener/DecisionTableValidationListenerTest.php new file mode 100644 index 0000000000..c7753ade59 --- /dev/null +++ b/tests/Unit/Listener/DecisionTableValidationListenerTest.php @@ -0,0 +1,80 @@ +setSlug($slug); + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturn($schema); + + $objects = $this->createMock(ObjectService::class); + $objects->method('searchObjectsBySlug')->willReturn($sameKey); + + return new DecisionTableValidationListener($schemas, new DecisionTableValidator(), $objects); + } + + private function row(array $data, string $uuid = 'u-1'): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid($uuid); + $object->setSchema('7'); + $object->setObject($data); + return $object; + } + + public function testAnExecutableTableIsSaved(): void { + $event = new ObjectCreatingEvent($this->row(DecisionTableStoreTest::shippingTable())); + $this->listener('decision-table')->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } + + public function testATableTheEngineCannotRunIsRefused(): void { + $table = DecisionTableStoreTest::shippingTable(); + $table['rules'][0]['inputEntries'] = []; + $event = new ObjectCreatingEvent($this->row($table)); + $this->listener('decision-table')->handle($event); + $this->assertTrue($event->isPropagationStopped()); + $this->assertSame(DecisionTableValidationListener::ERROR_CODE, $event->getErrors()['code']); + } + + public function testASecondTableUnderTheSameKeyIsRefused(): void { + $other = $this->row(DecisionTableStoreTest::shippingTable(), 'u-2'); + $event = new ObjectCreatingEvent($this->row(DecisionTableStoreTest::shippingTable())); + $this->listener('decision-table', [$other])->handle($event); + $this->assertTrue($event->isPropagationStopped()); + $this->assertStringContainsString('already used by decision table u-2', $event->getErrors()['message']); + } + + public function testUpdatingATableKeepsItsOwnKey(): void { + $self = $this->row(DecisionTableStoreTest::shippingTable()); + $event = new ObjectUpdatingEvent($this->row(DecisionTableStoreTest::shippingTable()), $self); + $this->listener('decision-table', [$self])->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } + + public function testOtherSchemasAreLeftAlone(): void { + $event = new ObjectCreatingEvent($this->row(['anything' => true])); + $this->listener('invoice')->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } +} diff --git a/tests/Unit/Service/Dmn/DecisionTableStoreTest.php b/tests/Unit/Service/Dmn/DecisionTableStoreTest.php new file mode 100644 index 0000000000..764137ec8d --- /dev/null +++ b/tests/Unit/Service/Dmn/DecisionTableStoreTest.php @@ -0,0 +1,123 @@ + + */ + public static function shippingTable(string $key = 'parcel-carrier', string $carrier = 'freight'): array { + return [ + 'key' => $key, + 'name' => 'Parcel carrier', + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'weight', 'type' => 'number']], + 'outputs' => [['name' => 'carrier', 'type' => 'string']], + 'rules' => [ + ['id' => 'light', 'inputEntries' => ['< 2'], 'outputEntries' => ['letterbox']], + ['id' => 'heavy', 'inputEntries' => ['>= 2'], 'outputEntries' => [$carrier]], + ], + ]; + }//end shippingTable() + + /** + * A store over the given rows. + * + * @param array}> $rows Uuid and data per row. + * @param bool $unreadable Whether the register read fails. + * + * @return DecisionTableStore + */ + private function store(array $rows, bool $unreadable = false): DecisionTableStore { + $objects = $this->createMock(ObjectService::class); + $method = $objects->method('searchObjectsBySlug')->with(DecisionTableStore::REGISTER, DecisionTableStore::SCHEMA); + if ($unreadable === true) { + $method->willThrowException(new RuntimeException('register missing')); + } else { + $entities = []; + foreach ($rows as [$uuid, $data]) { + $entity = new ObjectEntity(); + $entity->setUuid($uuid); + $entity->setObject($data); + $entities[] = $entity; + } + + $method->willReturn($entities); + } + + return new DecisionTableStore($objects, new DecisionTableValidator(), new DecisionTableEvaluator()); + }//end store() + + public function testATableIsFoundByKey(): void { + $store = $this->store([['u-1', self::shippingTable()]]); + $this->assertSame('Parcel carrier', $store->find('parcel-carrier')['name']); + } + + public function testATableIsFoundByUuid(): void { + $store = $this->store([['u-1', self::shippingTable()]]); + $this->assertSame('parcel-carrier', $store->find('u-1')['key']); + } + + public function testAKeyWinsOverAUuidThatHappensToMatch(): void { + $store = $this->store([['parcel-carrier', self::shippingTable('other', 'van')], ['u-2', self::shippingTable()]]); + $this->assertSame('freight', $store->evaluate('parcel-carrier', ['weight' => 5])['outputs']['carrier']); + } + + public function testEvaluateRunsTheStoredTable(): void { + $store = $this->store([['u-1', self::shippingTable()]]); + $result = $store->evaluate('parcel-carrier', ['weight' => 1.5]); + $this->assertSame(['carrier' => 'letterbox'], $result['outputs']); + $this->assertSame(['light'], $result['matchedRuleIds']); + $this->assertSame('FIRST', $result['hitPolicy']); + $this->assertSame('parcel-carrier', $result['tableKey']); + } + + public function testAnUnknownReferenceIsRefusedNotAnsweredEmpty(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('No decision table has the key or id "nope"'); + $this->store([['u-1', self::shippingTable()]])->find('nope'); + } + + public function testAnEmptyReferenceIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->store([])->find(' '); + } + + public function testAStoredTableTheEngineCannotRunIsRefusedOnRead(): void { + $broken = self::shippingTable(); + $broken['hitPolicy'] = 'OUTPUT ORDER'; + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('cannot be run'); + $this->store([['u-1', $broken]])->find('parcel-carrier'); + } + + public function testAnUnreadableRegisterIsAFaultNotANoMatch(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('could not be read'); + $this->store([], unreadable: true)->find('parcel-carrier'); + } +} diff --git a/tests/Unit/Service/Flow/Nodes/DecisionTableNodeStoredTableTest.php b/tests/Unit/Service/Flow/Nodes/DecisionTableNodeStoredTableTest.php new file mode 100644 index 0000000000..186ff2c424 --- /dev/null +++ b/tests/Unit/Service/Flow/Nodes/DecisionTableNodeStoredTableTest.php @@ -0,0 +1,104 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static fn (string $text, array $params = []): string => vsprintf($text, $params) + ); + $urls = $this->createMock(IURLGenerator::class); + + $entity = new ObjectEntity(); + $entity->setUuid('u-1'); + $entity->setObject(DecisionTableStoreTest::shippingTable()); + $objects = $this->createMock(ObjectService::class); + $objects->method('searchObjectsBySlug')->willReturn([$entity]); + + $store = null; + if ($withStore === true) { + $store = new DecisionTableStore($objects, new DecisionTableValidator(), new DecisionTableEvaluator()); + } + + return new DecisionTableNode( + engine: new DecisionTableEvaluator(), + validator: new DecisionTableValidator(), + l10n: $l10n, + urls: $urls, + store: $store, + ); + } + + public function testAStepDecidesWithAStoredTable(): void { + $out = $this->node()->execute( + items: [FlowItems::item(json: ['parcel' => ['kg' => 7]])], + config: [ + 'tableRef' => 'parcel-carrier', + 'inputMapping' => ['weight' => 'parcel.kg'], + 'resultKey' => 'decision', + ], + context: [] + ); + + $json = $out[0][FlowItems::JSON]; + $this->assertSame('freight', $json['carrier']); + $this->assertSame(['heavy'], $json['decision']['matchedRuleIds']); + $this->assertSame('parcel-carrier', $json['decision']['tableKey']); + } + + public function testAStoredTableIsAcceptedAtSave(): void { + $this->node()->validateConfig(['tableRef' => 'u-1', 'outputMapping' => ['carrier' => 'shipping.carrier']]); + $this->addToAssertionCount(1); + } + + public function testAnUnknownReferenceIsRefusedAtSave(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('No decision table has the key or id "missing"'); + $this->node()->validateConfig(['tableRef' => 'missing']); + } + + public function testAMappingTheStoredTableDoesNotDeclareIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->node()->validateConfig(['tableRef' => 'parcel-carrier', 'inputMapping' => ['volume' => 'parcel.m3']]); + } + + public function testBothAnInlineAndAStoredTableAreRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('not both'); + $this->node()->validateConfig(['tableRef' => 'parcel-carrier', 'table' => DecisionTableStoreTest::shippingTable()]); + } + + public function testNeitherIsRefused(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('needs a table'); + $this->node()->validateConfig([]); + } + + public function testWithoutAStoreAReferenceIsRefusedNotIgnored(): void { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('not available'); + $this->node(withStore: false)->validateConfig(['tableRef' => 'parcel-carrier']); + } +} From acd187287f0773675413cf9d599cb51f979c501b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:32:08 +0200 Subject: [PATCH 060/183] fix(ci): clear development's red after #4573 (prettier on the folder-browser e2e, @spec on three responder methods) --- lib/Service/File/ObjectFolderResponder.php | 6 + tests/e2e/ci/object-folder-browser.spec.ts | 175 ++++++++++++++++----- 2 files changed, 143 insertions(+), 38 deletions(-) diff --git a/lib/Service/File/ObjectFolderResponder.php b/lib/Service/File/ObjectFolderResponder.php index 61451a525a..c89f5744d1 100644 --- a/lib/Service/File/ObjectFolderResponder.php +++ b/lib/Service/File/ObjectFolderResponder.php @@ -59,6 +59,8 @@ public function __construct( /** * Answer a failed call. * + * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule + * * @param Throwable $e The failure. * * @return JSONResponse The error response. @@ -101,6 +103,8 @@ public function failure(Throwable $e): JSONResponse { /** * Answer an upload request that carried no file. * + * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule + * * @return JSONResponse 400. */ public function noUpload(): JSONResponse { @@ -110,6 +114,8 @@ public function noUpload(): JSONResponse { /** * Answer an upload batch. * + * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule + * * @param array $outcome What was stored and refused, from ObjectFolderUploads::uploadAll(). * @param int $total How many files were sent. * diff --git a/tests/e2e/ci/object-folder-browser.spec.ts b/tests/e2e/ci/object-folder-browser.spec.ts index b9dfd452c2..0a241733f6 100644 --- a/tests/e2e/ci/object-folder-browser.spec.ts +++ b/tests/e2e/ci/object-folder-browser.spec.ts @@ -44,7 +44,10 @@ const RUN = Math.random().toString(36).slice(2, 10) const API = '/index.php/apps/openregister/api' /** Build an API context authenticated as one user. */ -async function contextFor(user: string, password: string): Promise { +async function contextFor( + user: string, + password: string, +): Promise { return pwRequest.newContext({ baseURL: BASE, extraHTTPHeaders: { @@ -55,7 +58,7 @@ async function contextFor(user: string, password: string): Promise { delete: [`user:${OWNER}`], }) - const folderUrl = (uuid: string, suffix = '') => `${API}/objects/${registerId}/${schemaId}/${uuid}/folder${suffix}` + const folderUrl = (uuid: string, suffix = '') => + `${API}/objects/${registerId}/${schemaId}/${uuid}/folder${suffix}` /** Upload one text file into a folder of an object. */ - async function upload(ctx: APIRequestContext, uuid: string, path: string, name: string, body: string) { + async function upload( + ctx: APIRequestContext, + uuid: string, + path: string, + name: string, + body: string, + ) { return ctx.post(folderUrl(uuid, '/upload'), { multipart: { path, - 'files[]': { name, mimeType: 'text/plain', buffer: Buffer.from(body) }, + 'files[]': { + name, + mimeType: 'text/plain', + buffer: Buffer.from(body), + }, }, }) } @@ -98,7 +112,9 @@ test.describe('an object folder through the object rule', () => { /** Create an object as the owner. */ async function createObject(key: string): Promise { - const res = await owner.post(`${API}/objects/${registerId}/${schemaId}`, { data: { key } }) + const res = await owner.post(`${API}/objects/${registerId}/${schemaId}`, { + data: { key }, + }) expect(res.ok(), `object create failed: ${await res.text()}`).toBeTruthy() const body = await res.json() return String(body['@self']?.id ?? body.id ?? body.uuid) @@ -109,12 +125,20 @@ test.describe('an object folder through the object rule', () => { owner = await contextFor(OWNER, PASS) other = await contextFor(OTHER, PASS) - for (const [ctx, uid] of [[owner, OWNER], [other, OTHER]] as const) { + for (const [ctx, uid] of [ + [owner, OWNER], + [other, OTHER], + ] as const) { const res = await ctx.get(`${API}/registers`) - expect(res.status(), `seeded account '${uid}' cannot authenticate; did seed.sh run?`).toBeLessThan(400) + expect( + res.status(), + `seeded account '${uid}' cannot authenticate; did seed.sh run?`, + ).toBeLessThan(400) } - const reg = await admin.post(`${API}/registers`, { data: { title: `e2e object folder ${RUN}`, description: 'e2e' } }) + const reg = await admin.post(`${API}/registers`, { + data: { title: `e2e object folder ${RUN}`, description: 'e2e' }, + }) expect(reg.ok(), `register create failed: ${await reg.text()}`).toBeTruthy() registerId = String((await reg.json()).id) @@ -122,7 +146,9 @@ test.describe('an object folder through the object rule', () => { data: { title: `e2e object folder schema ${RUN}`, description: 'e2e', - properties: { key: { type: 'string', title: 'Key', maxLength: 255 } }, + properties: { + key: { type: 'string', title: 'Key', maxLength: 255 }, + }, authorization: authorization([`user:${OWNER}`, `user:${OTHER}`]), }, }) @@ -133,7 +159,10 @@ test.describe('an object folder through the object rule', () => { caseB = await createObject('case-b') const stored = await upload(owner, caseB, '', 'van-b.txt', 'case b') - expect(stored.status(), `upload to case B failed: ${await stored.text()}`).toBe(201) + expect( + stored.status(), + `upload to case B failed: ${await stored.text()}`, + ).toBe(201) caseBFileId = (await stored.json()).stored[0].id }) @@ -150,17 +179,29 @@ test.describe('an object folder through the object rule', () => { }) test('an updater adds a subfolder and a file in it, and downloads it', async () => { - const made = await owner.post(folderUrl(caseA), { data: { name: 'Bijlagen', path: '' } }) + const made = await owner.post(folderUrl(caseA), { + data: { name: 'Bijlagen', path: '' }, + }) expect(made.status(), await made.text()).toBe(201) folderId = (await made.json()).id - expect((await upload(owner, caseA, '', 'besluit.txt', 'besluit')).status()).toBe(201) - const stored = await upload(owner, caseA, 'Bijlagen', 'brief.txt', 'hallo bea') + expect( + (await upload(owner, caseA, '', 'besluit.txt', 'besluit')).status(), + ).toBe(201) + const stored = await upload( + owner, + caseA, + 'Bijlagen', + 'brief.txt', + 'hallo bea', + ) expect(stored.status(), await stored.text()).toBe(201) const file = (await stored.json()).stored[0] as Entry expect(file.path).toBe('Bijlagen/brief.txt') - const download = await owner.get(`${API}/objects/${registerId}/${schemaId}/${caseA}/files/${file.id}`) + const download = await owner.get( + `${API}/objects/${registerId}/${schemaId}/${caseA}/files/${file.id}`, + ) expect(download.status()).toBe(200) expect(await download.text()).toBe('hallo bea') }) @@ -170,7 +211,9 @@ test.describe('an object folder through the object rule', () => { expect(root.status(), await root.text()).toBe(200) const rootBody = await root.json() expect(rootBody.canChange).toBe(false) - expect((rootBody.entries as Entry[]).map((e) => `${e.type}:${e.name}`).sort()).toEqual(['file:besluit.txt', 'folder:Bijlagen']) + expect( + (rootBody.entries as Entry[]).map((e) => `${e.type}:${e.name}`).sort(), + ).toEqual(['file:besluit.txt', 'folder:Bijlagen']) const sub = await list(other, caseA, 'Bijlagen') expect(sub.status()).toBe(200) @@ -178,26 +221,52 @@ test.describe('an object folder through the object rule', () => { expect(subEntries.map((e) => e.path)).toEqual(['Bijlagen/brief.txt']) // The reader downloads through the object's file endpoint, as herself. - const download = await other.get(`${API}/objects/${registerId}/${schemaId}/${caseA}/files/${subEntries[0].id}`) + const download = await other.get( + `${API}/objects/${registerId}/${schemaId}/${caseA}/files/${subEntries[0].id}`, + ) expect(download.status()).toBe(200) expect(await download.text()).toBe('hallo bea') // And may change nothing. - expect((await other.post(folderUrl(caseA), { data: { name: 'Nieuw' } })).status()).toBe(403) - expect((await upload(other, caseA, 'Bijlagen', 'nee.txt', 'x')).status()).toBe(403) - expect((await other.put(folderUrl(caseA, `/${folderId}`), { data: { name: 'Anders' } })).status()).toBe(403) - expect((await other.delete(folderUrl(caseA, `/${folderId}`))).status()).toBe(403) + expect( + ( + await other.post(folderUrl(caseA), { data: { name: 'Nieuw' } }) + ).status(), + ).toBe(403) + expect( + (await upload(other, caseA, 'Bijlagen', 'nee.txt', 'x')).status(), + ).toBe(403) + expect( + ( + await other.put(folderUrl(caseA, `/${folderId}`), { + data: { name: 'Anders' }, + }) + ).status(), + ).toBe(403) + expect((await other.delete(folderUrl(caseA, `/${folderId}`))).status()).toBe( + 403, + ) const after = await list(owner, caseA) - expect(((await after.json()).entries as Entry[]).map((e) => e.name).sort()).toEqual(['Bijlagen', 'besluit.txt']) + expect( + ((await after.json()).entries as Entry[]).map((e) => e.name).sort(), + ).toEqual(['Bijlagen', 'besluit.txt']) }) test('no share is created for the reader', async () => { - const shares = await other.get('/ocs/v2.php/apps/files_sharing/api/v1/shares', { - params: { shared_with_me: 'true', format: 'json' }, - }) + const shares = await other.get( + '/ocs/v2.php/apps/files_sharing/api/v1/shares', + { + params: { shared_with_me: 'true', format: 'json' }, + }, + ) expect(shares.status()).toBe(200) - const data = ((await shares.json()).ocs?.data ?? []) as Array<{ path?: string, file_target?: string }> - const mirrors = data.filter((share) => String(share.path ?? share.file_target ?? '').includes(caseA)) + const data = ((await shares.json()).ocs?.data ?? []) as Array<{ + path?: string + file_target?: string + }> + const mirrors = data.filter((share) => + String(share.path ?? share.file_target ?? '').includes(caseA), + ) expect(mirrors, 'a share on the case folder reached the reader').toEqual([]) }) @@ -207,30 +276,58 @@ test.describe('an object folder through the object rule', () => { expect(res.status(), `path ${path}`).toBe(400) } expect((await list(other, caseA, 'besluit.txt')).status()).toBe(404) - expect((await upload(owner, caseA, '../' + caseB, 'x.txt', 'x')).status()).toBe(400) + expect( + (await upload(owner, caseA, '../' + caseB, 'x.txt', 'x')).status(), + ).toBe(400) }) test('a taken name is refused', async () => { - expect((await owner.post(folderUrl(caseA), { data: { name: 'Bijlagen' } })).status()).toBe(409) - expect((await upload(owner, caseA, 'Bijlagen', 'brief.txt', 'overschreven')).status()).toBe(409) + expect( + ( + await owner.post(folderUrl(caseA), { data: { name: 'Bijlagen' } }) + ).status(), + ).toBe(409) + expect( + ( + await upload(owner, caseA, 'Bijlagen', 'brief.txt', 'overschreven') + ).status(), + ).toBe(409) }) test('a node of another object is not reachable', async () => { - expect((await owner.put(folderUrl(caseA, `/${caseBFileId}`), { data: { name: 'gestolen.txt' } })).status()).toBe(404) - expect((await owner.delete(folderUrl(caseA, `/${caseBFileId}`))).status()).toBe(404) + expect( + ( + await owner.put(folderUrl(caseA, `/${caseBFileId}`), { + data: { name: 'gestolen.txt' }, + }) + ).status(), + ).toBe(404) + expect( + (await owner.delete(folderUrl(caseA, `/${caseBFileId}`))).status(), + ).toBe(404) const b = await list(owner, caseB) - expect(((await b.json()).entries as Entry[]).map((e) => e.name)).toEqual(['van-b.txt']) + expect(((await b.json()).entries as Entry[]).map((e) => e.name)).toEqual([ + 'van-b.txt', + ]) }) test('an updater renames and deletes a subfolder', async () => { - const renamed = await owner.put(folderUrl(caseA, `/${folderId}`), { data: { name: 'Stukken' } }) + const renamed = await owner.put(folderUrl(caseA, `/${folderId}`), { + data: { name: 'Stukken' }, + }) expect(renamed.status(), await renamed.text()).toBe(200) - let names = ((await (await list(owner, caseA)).json()).entries as Entry[]).map((e) => e.name).sort() + let names = ((await (await list(owner, caseA)).json()).entries as Entry[]) + .map((e) => e.name) + .sort() expect(names).toEqual(['Stukken', 'besluit.txt']) expect((await list(owner, caseA, 'Stukken')).status()).toBe(200) - expect((await owner.delete(folderUrl(caseA, `/${folderId}`))).status()).toBe(200) - names = ((await (await list(owner, caseA)).json()).entries as Entry[]).map((e) => e.name) + expect((await owner.delete(folderUrl(caseA, `/${folderId}`))).status()).toBe( + 200, + ) + names = ((await (await list(owner, caseA)).json()).entries as Entry[]).map( + (e) => e.name, + ) expect(names).toEqual(['besluit.txt']) }) @@ -238,7 +335,9 @@ test.describe('an object folder through the object rule', () => { const sch = await admin.put(`${API}/schemas/${schemaId}`, { data: { title: `e2e object folder schema ${RUN}`, - properties: { key: { type: 'string', title: 'Key', maxLength: 255 } }, + properties: { + key: { type: 'string', title: 'Key', maxLength: 255 }, + }, authorization: authorization([`user:${OWNER}`]), }, }) From 359f5c3981545251f290c4337bebc9844c763339 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:35:53 +0200 Subject: [PATCH 061/183] fix(ci): catalogue keys for the decision table register's schema strings --- l10n/en.js | 12 ++++++++++++ l10n/nl.js | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/l10n/en.js b/l10n/en.js index 9628bec18e..2d703d4e82 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.", "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.", + "Decision Table": "Decision Table", "Disconnect": "Disconnect", "Expired": "Expired", "Expires {date}": "Expires {date}", + "Hit policy": "Hit policy", + "How the matching rules become one answer.": "How the matching rules become one answer.", + "Input columns, each with a `name` and an optional `type`.": "Input columns, each with a `name` and an optional `type`.", + "Inputs": "Inputs", + "Name shown to the people who author and read the table.": "Name shown to the people who author and read the table.", "No accounts are connected yet.": "No accounts are connected yet.", "Not connected yet": "Not connected yet", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.", + "Outputs": "Outputs", "Reconnect": "Reconnect", "Relink needed": "Relink needed", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.", @@ -26,6 +36,8 @@ OC.L10N.register( "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)", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.", + "What the table decides and where its rules come from.": "What the table decides and where its rules come from.", "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.", diff --git a/l10n/nl.js b/l10n/nl.js index f998a767e5..cae5ccdf74 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App die deze tabel meelevert of beheert, bijvoorbeeld `dossiq`. Leeg voor een tabel die een beheerder zelf heeft gemaakt.", "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.", + "Decision Table": "Beslistabel", "Disconnect": "Ontkoppelen", "Expired": "Verlopen", "Expires {date}": "Verloopt {date}", + "Hit policy": "Hitbeleid", + "How the matching rules become one answer.": "Hoe de passende regels samen één antwoord worden.", + "Input columns, each with a `name` and an optional `type`.": "Invoerkolommen, elk met een `name` en een optioneel `type`.", + "Inputs": "Invoer", + "Name shown to the people who author and read the table.": "Naam zoals de mensen die de tabel opstellen en lezen hem zien.", "No accounts are connected yet.": "Er zijn nog geen accounts gekoppeld.", "Not connected yet": "Nog niet gekoppeld", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Uitvoerkolommen, elk met een `name` en, bij PRIORITY, een geordende lijst `values`.", + "Outputs": "Uitvoer", "Reconnect": "Opnieuw koppelen", "Relink needed": "Opnieuw koppelen nodig", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Regels, elk met een `id`, `inputEntries` (één unaire test per invoer) en `outputEntries` (één waarde per uitvoer).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Elke kaart heeft een eigen knop Laden. Kies \"Geen\" op een productieomgeving.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Vaste verwijzing die een flowstap of een app voor deze tabel gebruikt, bijvoorbeeld `enforcement-intervention`. Uniek binnen het register.", + "What the table decides and where its rules come from.": "Wat de tabel beslist en waar de regels vandaan komen.", "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.", From 653ad8e94cae2599a69dc99d68f0ae2452303700 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:38:01 +0200 Subject: [PATCH 062/183] fix(ci): McpTool's tenth field trips ExcessiveParameterList; suppress with the reason --- lib/Mcp/Attribute/McpTool.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Mcp/Attribute/McpTool.php b/lib/Mcp/Attribute/McpTool.php index 2371f32a19..9d3de5d154 100644 --- a/lib/Mcp/Attribute/McpTool.php +++ b/lib/Mcp/Attribute/McpTool.php @@ -117,6 +117,10 @@ final class McpTool { * strings and values scalars, validated by the scanner at scan time. * A mark is a claim: a consumer MUST NOT treat one as a grant. * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) An attribute is a flat declaration: + * every field is one named argument at the call site, and grouping them in a value + * object would break every `#[McpTool(...)]` already written. + * * @spec openspec/specs/ai-mcp/spec.md * (Requirement: REQ-ATTR-001 — The #[McpTool] service-method attribute) * @spec openspec/specs/ai-mcp/spec.md From 94211c62ff9c25c5597fbe3fe818db5f32190b62 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:40:52 +0200 Subject: [PATCH 063/183] fix(ci): translate the decision table register's schema strings into every locale --- l10n/be.js | 12 ++++++++++++ l10n/bg.js | 12 ++++++++++++ l10n/bs.js | 12 ++++++++++++ l10n/ca.js | 12 ++++++++++++ l10n/cs.js | 12 ++++++++++++ l10n/da.js | 12 ++++++++++++ l10n/de.js | 12 ++++++++++++ l10n/el.js | 12 ++++++++++++ l10n/es.js | 12 ++++++++++++ l10n/et.js | 12 ++++++++++++ l10n/fi.js | 12 ++++++++++++ l10n/fr.js | 12 ++++++++++++ l10n/ga.js | 12 ++++++++++++ l10n/hr.js | 12 ++++++++++++ l10n/hu.js | 12 ++++++++++++ l10n/is.js | 12 ++++++++++++ l10n/it.js | 12 ++++++++++++ l10n/lb.js | 12 ++++++++++++ l10n/lt.js | 12 ++++++++++++ l10n/lv.js | 12 ++++++++++++ l10n/mk.js | 12 ++++++++++++ l10n/mt.js | 12 ++++++++++++ l10n/nb.js | 12 ++++++++++++ l10n/pl.js | 12 ++++++++++++ l10n/pt.js | 12 ++++++++++++ l10n/rm.js | 12 ++++++++++++ l10n/ro.js | 12 ++++++++++++ l10n/ru.js | 12 ++++++++++++ l10n/sk.js | 12 ++++++++++++ l10n/sl.js | 12 ++++++++++++ l10n/sq.js | 12 ++++++++++++ l10n/sr.js | 12 ++++++++++++ l10n/sv.js | 12 ++++++++++++ l10n/tr.js | 12 ++++++++++++ l10n/uk.js | 12 ++++++++++++ 35 files changed, 420 insertions(+) diff --git a/l10n/be.js b/l10n/be.js index 244c0c06ae..256511b962 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Праграма, якая пастаўляе або валодае гэтай табліцай, напрыклад `dossiq`. Пуста для табліцы, створанай адміністратарам.", "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.": "Не ўдалося пачаць падлучэнне. Праверце пастаўшчыка і паспрабуйце зноў.", + "Decision Table": "Табліца рашэнняў", "Disconnect": "Адлучыць", "Expired": "Пратэрмінавана", "Expires {date}": "Сканчаецца {date}", + "Hit policy": "Палітыка супадзення", + "How the matching rules become one answer.": "Як адпаведныя правілы становяцца адным адказам.", + "Input columns, each with a `name` and an optional `type`.": "Слупкі ўводу, кожны з `name` і неабавязковым `type`.", + "Inputs": "Увод", + "Name shown to the people who author and read the table.": "Назва, якую бачаць людзі, што складаюць і чытаюць табліцу.", "No accounts are connected yet.": "Пакуль не падлучаны ніводны ўліковы запіс.", "Not connected yet": "Яшчэ не падлучана", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Слупкі вываду, кожны з `name` і, для PRIORITY, упарадкаваным спісам `values`.", + "Outputs": "Вывад", "Reconnect": "Падлучыць зноў", "Relink needed": "Патрэбна паўторная прывязка", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Правілы, кожнае з `id`, `inputEntries` (адна ўнарная праверка на ўвод) і `outputEntries` (адно значэнне на вывад).", "Server address": "Адрас сервера", "Load example data?": "Загрузіць прыклады даных?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Прыклады даных запаўняюць спісы, старонкі падрабязнасцей і панэлі, каб вы адразу ўбачылі праграму ў працы. Кожная картка мае ўласную кнопку \"Загрузіць\". На працоўнай усталёўцы абярыце \"Няма\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.": "Прыклады значэнняў для кожнай схемы, якую пастаўляе гэтая праграма, згенераваныя з саміх схем. Яны паказваюць спісы, старонкі падрабязнасцей і панэлі ў працы, а не расказваюць гісторыю. Бяспечна паўтараць і потым выдаліць.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Стабільная спасылка, якую крок патоку або праграма выкарыстоўвае для гэтай табліцы, напрыклад `enforcement-intervention`. Унікальная ў межах рэестра.", + "What the table decides and where its rules come from.": "Што вырашае табліца і адкуль бяруцца яе правілы.", "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.": "Кожны абвешчаны ланцужок адкрывае ўпарадкаваную паслядоўнасць задач пры спробе пераходу. Узгадняльнікі вырашаюць у сваёй скрыні задач, а не тут.", diff --git a/l10n/bg.js b/l10n/bg.js index 030e865daf..7ff16ac216 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Приложението, което доставя или притежава тази таблица, например `dossiq`. Празно за таблица, създадена от администратор.", "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.": "Свързването не можа да започне. Проверете доставчика и опитайте отново.", + "Decision Table": "Таблица за решения", "Disconnect": "Разкачване", "Expired": "Изтекъл", "Expires {date}": "Изтича на {date}", + "Hit policy": "Политика на съвпадение", + "How the matching rules become one answer.": "Как съвпадащите правила се превръщат в един отговор.", + "Input columns, each with a `name` and an optional `type`.": "Входни колони, всяка с `name` и по избор `type`.", + "Inputs": "Входове", + "Name shown to the people who author and read the table.": "Име, което виждат хората, които съставят и четат таблицата.", "No accounts are connected yet.": "Още няма свързани акаунти.", "Not connected yet": "Още не е свързан", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Изходни колони, всяка с `name` и, за PRIORITY, подреден списък `values`.", + "Outputs": "Изходи", "Reconnect": "Свързване отново", "Relink needed": "Нужно е ново свързване", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Правила, всяко с `id`, `inputEntries` (по един унарен тест за вход) и `outputEntries` (по една стойност за изход).", "Server address": "Адрес на сървъра", "Load example data?": "Да се заредят ли примерни данни?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Примерните данни попълват списъците, страниците с подробности и таблата, за да видите приложението веднага в действие. Всяка карта има собствен бутон \"Зареждане\". Изберете \"Няма\" при производствена инсталация.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.": "Примерни стойности за всяка схема, която приложението предоставя, генерирани от самите схеми. Показват списъците, страниците с подробности и таблата в действие, вместо да разказват история. Безопасно за повторно изпълнение и може да се изтрие после.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Стабилна препратка, която стъпка от поток или приложение използва за тази таблица, например `enforcement-intervention`. Уникална в регистъра.", + "What the table decides and where its rules come from.": "Какво решава таблицата и откъде идват правилата ѝ.", "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.": "Всяка декларирана верига отваря подредена последователност от задачи при опит за прехода. Одобряващите решават в своята кутия със задачи, не тук.", diff --git a/l10n/bs.js b/l10n/bs.js index 08ef27fe92..2f37aaf388 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacija koja isporučuje ili posjeduje ovu tabelu, na primjer `dossiq`. Prazno za tabelu koju je napisao administrator.", "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.", + "Decision Table": "Tabela odluka", "Disconnect": "Odspoji", "Expired": "Isteklo", "Expires {date}": "Ističe {date}", + "Hit policy": "Pravilo podudaranja", + "How the matching rules become one answer.": "Kako podudarna pravila postaju jedan odgovor.", + "Input columns, each with a `name` and an optional `type`.": "Ulazne kolone, svaka sa `name` i neobaveznim `type`.", + "Inputs": "Ulazi", + "Name shown to the people who author and read the table.": "Naziv koji vide ljudi koji pišu i čitaju tabelu.", "No accounts are connected yet.": "Još nije povezan nijedan nalog.", "Not connected yet": "Još nije povezano", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Izlazne kolone, svaka sa `name` i, za PRIORITY, uređenom listom `values`.", + "Outputs": "Izlazi", "Reconnect": "Poveži ponovo", "Relink needed": "Potrebno je ponovno povezivanje", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Pravila, svako sa `id`, `inputEntries` (jedan unarni test po ulazu) i `outputEntries` (jedna vrijednost po izlazu).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Primjeri podataka popunjavaju liste, stranice s detaljima i kontrolne table, pa aplikaciju odmah vidite kako radi. Svaka kartica ima svoje dugme \"Učitaj\". Odaberite \"Ništa\" na produkcijskoj instalaciji.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabilna referenca koju korak toka ili aplikacija koristi za ovu tabelu, na primjer `enforcement-intervention`. Jedinstvena unutar registra.", + "What the table decides and where its rules come from.": "Šta tabela odlučuje i odakle dolaze njena pravila.", "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.", diff --git a/l10n/ca.js b/l10n/ca.js index ebcbbef711..4e59a0b1c7 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplicació que subministra o posseeix aquesta taula, per exemple `dossiq`. Buit per a una taula creada per un administrador.", "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.", + "Decision Table": "Taula de decisions", "Disconnect": "Desconnecta", "Expired": "Caducat", "Expires {date}": "Caduca el {date}", + "Hit policy": "Política de coincidència", + "How the matching rules become one answer.": "Com les regles que coincideixen esdevenen una única resposta.", + "Input columns, each with a `name` and an optional `type`.": "Columnes d'entrada, cadascuna amb un `name` i un `type` opcional.", + "Inputs": "Entrades", + "Name shown to the people who author and read the table.": "Nom que veuen les persones que redacten i llegeixen la taula.", "No accounts are connected yet.": "Encara no hi ha cap compte connectat.", "Not connected yet": "Encara no connectat", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Columnes de sortida, cadascuna amb un `name` i, per a PRIORITY, una llista ordenada `values`.", + "Outputs": "Sortides", "Reconnect": "Torna a connectar", "Relink needed": "Cal tornar a enllaçar", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Regles, cadascuna amb un `id`, `inputEntries` (una prova unària per entrada) i `outputEntries` (un valor per sortida).", "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. Each card has its own Load button. 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. Cada targeta té el seu propi botó \"Carrega\". Trieu \"Cap\" en una instal·lació de producció.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Referència estable que un pas de flux o una aplicació fa servir per a aquesta taula, per exemple `enforcement-intervention`. Única dins del registre.", + "What the table decides and where its rules come from.": "Què decideix la taula i d'on vénen les seves regles.", "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í.", diff --git a/l10n/cs.js b/l10n/cs.js index 077a77bb99..9acca0aee1 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikace, která tuto tabulku dodává nebo vlastní, například `dossiq`. Prázdné u tabulky, kterou vytvořil správce.", "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.", + "Decision Table": "Rozhodovací tabulka", "Disconnect": "Odpojit", "Expired": "Vypršelo", "Expires {date}": "Vyprší {date}", + "Hit policy": "Pravidlo shody", + "How the matching rules become one answer.": "Jak se odpovídající pravidla stanou jednou odpovědí.", + "Input columns, each with a `name` and an optional `type`.": "Vstupní sloupce, každý s `name` a volitelným `type`.", + "Inputs": "Vstupy", + "Name shown to the people who author and read the table.": "Název, který vidí lidé, kteří tabulku sestavují a čtou.", "No accounts are connected yet.": "Zatím nejsou připojeny žádné účty.", "Not connected yet": "Zatím nepřipojeno", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Výstupní sloupce, každý s `name` a u PRIORITY s uspořádaným seznamem `values`.", + "Outputs": "Výstupy", "Reconnect": "Připojit znovu", "Relink needed": "Je potřeba znovu propojit", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Pravidla, každé s `id`, `inputEntries` (jeden unární test na vstup) a `outputEntries` (jedna hodnota na výstup).", "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. Each card has its own Load button. 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. Každá karta má vlastní tlačítko \"Načíst\". Na produkční instalaci zvolte \"Žádná\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabilní odkaz, který krok toku nebo aplikace používá pro tuto tabulku, například `enforcement-intervention`. Jedinečný v rámci registru.", + "What the table decides and where its rules come from.": "O čem tabulka rozhoduje a odkud pocházejí její pravidla.", "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.", diff --git a/l10n/da.js b/l10n/da.js index 97b8af964b..618662c712 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Appen, der leverer eller ejer denne tabel, for eksempel `dossiq`. Tom for en tabel, en administrator har oprettet.", "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.", + "Decision Table": "Beslutningstabel", "Disconnect": "Afbryd", "Expired": "Udløbet", "Expires {date}": "Udløber {date}", + "Hit policy": "Træfpolitik", + "How the matching rules become one answer.": "Hvordan de matchende regler bliver til ét svar.", + "Input columns, each with a `name` and an optional `type`.": "Inputkolonner, hver med et `name` og en valgfri `type`.", + "Inputs": "Input", + "Name shown to the people who author and read the table.": "Navn, som de der opretter og læser tabellen, ser.", "No accounts are connected yet.": "Der er endnu ikke forbundet nogen konti.", "Not connected yet": "Endnu ikke forbundet", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Outputkolonner, hver med et `name` og for PRIORITY en ordnet `values`-liste.", + "Outputs": "Output", "Reconnect": "Forbind igen", "Relink needed": "Ny tilknytning er nødvendig", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Regler, hver med et `id`, `inputEntries` (én unær test pr. input) og `outputEntries` (én værdi pr. output).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Eksempeldata fylder lister, detaljesider og dashboards, så du med det samme ser appen virke. Hvert kort har sin egen \"Indlæs\"-knap. Vælg \"Ingen\" på en produktionsinstallation.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabil reference, som et flowtrin eller en app bruger til denne tabel, for eksempel `enforcement-intervention`. Entydig i registret.", + "What the table decides and where its rules come from.": "Hvad tabellen afgør, og hvor dens regler kommer fra.", "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.", diff --git a/l10n/de.js b/l10n/de.js index 92f468f9ce..3afc0e9c9a 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App, die diese Tabelle mitliefert oder besitzt, zum Beispiel `dossiq`. Leer bei einer Tabelle, die ein Administrator angelegt hat.", "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.", + "Decision Table": "Entscheidungstabelle", "Disconnect": "Trennen", "Expired": "Abgelaufen", "Expires {date}": "Läuft am {date} ab", + "Hit policy": "Trefferrichtlinie", + "How the matching rules become one answer.": "Wie aus den passenden Regeln eine einzige Antwort wird.", + "Input columns, each with a `name` and an optional `type`.": "Eingabespalten, jede mit einem `name` und einem optionalen `type`.", + "Inputs": "Eingaben", + "Name shown to the people who author and read the table.": "Name, den die Personen sehen, die die Tabelle erstellen und lesen.", "No accounts are connected yet.": "Es sind noch keine Konten verbunden.", "Not connected yet": "Noch nicht verbunden", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Ausgabespalten, jede mit einem `name` und, bei PRIORITY, einer geordneten `values`-Liste.", + "Outputs": "Ausgaben", "Reconnect": "Erneut verbinden", "Relink needed": "Neuverknüpfung nötig", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Regeln, jede mit einer `id`, `inputEntries` (ein unärer Test pro Eingabe) und `outputEntries` (ein Wert pro Ausgabe).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Beispieldaten füllen die Listen, Detailseiten und Dashboards, damit Sie die App sofort in Betrieb sehen. Jede Karte hat eine eigene Schaltfläche \"Laden\". Wählen Sie \"Keine\" bei einer Produktivinstallation.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabile Referenz, die ein Flow-Schritt oder eine App für diese Tabelle verwendet, zum Beispiel `enforcement-intervention`. Eindeutig innerhalb des Registers.", + "What the table decides and where its rules come from.": "Was die Tabelle entscheidet und woher ihre Regeln stammen.", "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.", diff --git a/l10n/el.js b/l10n/el.js index 737869f625..487f23fb6c 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Η εφαρμογή που παρέχει ή κατέχει αυτόν τον πίνακα, για παράδειγμα `dossiq`. Κενό για πίνακα που δημιούργησε διαχειριστής.", "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.": "Δεν ήταν δυνατή η έναρξη της σύνδεσης. Ελέγξτε τον πάροχο και δοκιμάστε ξανά.", + "Decision Table": "Πίνακας αποφάσεων", "Disconnect": "Αποσύνδεση", "Expired": "Έληξε", "Expires {date}": "Λήγει στις {date}", + "Hit policy": "Πολιτική αντιστοίχισης", + "How the matching rules become one answer.": "Πώς οι κανόνες που ταιριάζουν γίνονται μία απάντηση.", + "Input columns, each with a `name` and an optional `type`.": "Στήλες εισόδου, καθεμία με `name` και προαιρετικό `type`.", + "Inputs": "Είσοδοι", + "Name shown to the people who author and read the table.": "Όνομα που βλέπουν όσοι συντάσσουν και διαβάζουν τον πίνακα.", "No accounts are connected yet.": "Δεν έχει συνδεθεί ακόμη κανένας λογαριασμός.", "Not connected yet": "Δεν έχει συνδεθεί ακόμη", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Στήλες εξόδου, καθεμία με `name` και, για PRIORITY, ταξινομημένη λίστα `values`.", + "Outputs": "Έξοδοι", "Reconnect": "Επανασύνδεση", "Relink needed": "Απαιτείται νέα σύνδεση", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Κανόνες, καθένας με `id`, `inputEntries` (ένας μοναδιαίος έλεγχος ανά είσοδο) και `outputEntries` (μία τιμή ανά έξοδο).", "Server address": "Διεύθυνση διακομιστή", "Load example data?": "Να φορτωθούν δεδομένα παραδείγματος;", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Τα δεδομένα παραδείγματος γεμίζουν τις λίστες, τις σελίδες λεπτομερειών και τους πίνακες, ώστε να δείτε αμέσως την εφαρμογή να λειτουργεί. Κάθε κάρτα έχει το δικό της κουμπί \"Φόρτωση\". Επιλέξτε \"Κανένα\" σε εγκατάσταση παραγωγής.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.": "Τιμές παραδείγματος για κάθε σχήμα που παρέχει αυτή η εφαρμογή, παραγμένες από τα ίδια τα σχήματα. Δείχνουν τις λίστες, τις σελίδες λεπτομερειών και τους πίνακες σε λειτουργία αντί να αφηγούνται μια ιστορία. Ασφαλής επανάληψη και διαγραφή μετά.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Σταθερή αναφορά που χρησιμοποιεί ένα βήμα ροής ή μια εφαρμογή για αυτόν τον πίνακα, για παράδειγμα `enforcement-intervention`. Μοναδική μέσα στο μητρώο.", + "What the table decides and where its rules come from.": "Τι αποφασίζει ο πίνακας και από πού προέρχονται οι κανόνες του.", "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.": "Κάθε δηλωμένη αλυσίδα ανοίγει μια διατεταγμένη ακολουθία εργασιών όταν επιχειρείται η μετάβαση. Οι εγκρίνοντες αποφασίζουν στα εισερχόμενα εργασιών τους, όχι εδώ.", diff --git a/l10n/es.js b/l10n/es.js index c21e5e4777..2e7772956b 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplicación que suministra o posee esta tabla, por ejemplo `dossiq`. Vacío para una tabla creada por un administrador.", "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.", + "Decision Table": "Tabla de decisiones", "Disconnect": "Desconectar", "Expired": "Caducado", "Expires {date}": "Caduca el {date}", + "Hit policy": "Política de coincidencia", + "How the matching rules become one answer.": "Cómo las reglas que coinciden se convierten en una sola respuesta.", + "Input columns, each with a `name` and an optional `type`.": "Columnas de entrada, cada una con un `name` y un `type` opcional.", + "Inputs": "Entradas", + "Name shown to the people who author and read the table.": "Nombre que ven las personas que redactan y leen la tabla.", "No accounts are connected yet.": "Aún no hay cuentas conectadas.", "Not connected yet": "Aún no conectado", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Columnas de salida, cada una con un `name` y, para PRIORITY, una lista ordenada `values`.", + "Outputs": "Salidas", "Reconnect": "Reconectar", "Relink needed": "Hace falta volver a enlazar", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Reglas, cada una con un `id`, `inputEntries` (una prueba unaria por entrada) y `outputEntries` (un valor por salida).", "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. Each card has its own Load button. 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. Cada tarjeta tiene su propio botón \"Cargar\". Elige \"Ninguno\" en una instalación de producción.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Referencia estable que un paso de flujo o una aplicación usa para esta tabla, por ejemplo `enforcement-intervention`. Única dentro del registro.", + "What the table decides and where its rules come from.": "Qué decide la tabla y de dónde vienen sus reglas.", "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í.", diff --git a/l10n/et.js b/l10n/et.js index ef9e53910a..6a4f5da2c9 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Rakendus, mis selle tabeli kaasa annab või omab, näiteks `dossiq`. Tühi, kui tabeli lõi administraator.", "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.", + "Decision Table": "Otsustustabel", "Disconnect": "Katkesta ühendus", "Expired": "Aegunud", "Expires {date}": "Aegub {date}", + "Hit policy": "Vaste poliitika", + "How the matching rules become one answer.": "Kuidas sobivad reeglid muutuvad üheks vastuseks.", + "Input columns, each with a `name` and an optional `type`.": "Sisendveerud, igaühel `name` ja valikuline `type`.", + "Inputs": "Sisendid", + "Name shown to the people who author and read the table.": "Nimi, mida näevad tabeli koostajad ja lugejad.", "No accounts are connected yet.": "Ühtegi kontot pole veel ühendatud.", "Not connected yet": "Pole veel ühendatud", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Väljundveerud, igaühel `name` ja PRIORITY puhul järjestatud `values` loend.", + "Outputs": "Väljundid", "Reconnect": "Ühenda uuesti", "Relink needed": "Vaja on uut sidumist", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Reeglid, igaühel `id`, `inputEntries` (üks unaarne test sisendi kohta) ja `outputEntries` (üks väärtus väljundi kohta).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Näidisandmed täidavad loendid, detailvaated ja töölauad, nii et näete rakendust kohe töötamas. Igal kaardil on oma nupp \"Laadi\". Tootmispaigalduses valige \"Puudub\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Püsiv viide, mida voo samm või rakendus selle tabeli jaoks kasutab, näiteks `enforcement-intervention`. Registris kordumatu.", + "What the table decides and where its rules come from.": "Mida tabel otsustab ja kust selle reeglid pärinevad.", "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.", diff --git a/l10n/fi.js b/l10n/fi.js index e8437be211..7f46bbcdd8 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Sovellus, joka toimittaa tai omistaa tämän taulukon, esimerkiksi `dossiq`. Tyhjä, jos ylläpitäjä on laatinut taulukon.", "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.", + "Decision Table": "Päätöstaulukko", "Disconnect": "Katkaise yhteys", "Expired": "Vanhentunut", "Expires {date}": "Vanhenee {date}", + "Hit policy": "Osumakäytäntö", + "How the matching rules become one answer.": "Miten täsmäävistä säännöistä tulee yksi vastaus.", + "Input columns, each with a `name` and an optional `type`.": "Syöttösarakkeet, joilla kullakin on `name` ja valinnainen `type`.", + "Inputs": "Syötteet", + "Name shown to the people who author and read the table.": "Nimi, jonka taulukon laatijat ja lukijat näkevät.", "No accounts are connected yet.": "Yhtään tiliä ei ole vielä yhdistetty.", "Not connected yet": "Ei vielä yhdistetty", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Tulossarakkeet, joilla kullakin on `name` ja PRIORITY-tapauksessa järjestetty `values`-luettelo.", + "Outputs": "Tulosteet", "Reconnect": "Yhdistä uudelleen", "Relink needed": "Uudelleenlinkitys tarvitaan", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Säännöt, joilla kullakin on `id`, `inputEntries` (yksi unaarinen testi syötettä kohti) ja `outputEntries` (yksi arvo tulostetta kohti).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Esimerkkitiedot täyttävät luettelot, tietosivut ja koontinäytöt, joten näet sovelluksen heti toiminnassa. Jokaisessa kortissa on oma \"Lataa\"-painike. Valitse tuotantoasennuksessa \"Ei mitään\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Pysyvä viittaus, jota työnkulun vaihe tai sovellus käyttää tälle taulukolle, esimerkiksi `enforcement-intervention`. Yksilöllinen rekisterissä.", + "What the table decides and where its rules come from.": "Mistä taulukko päättää ja mistä sen säännöt tulevat.", "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ä.", diff --git a/l10n/fr.js b/l10n/fr.js index ad90f4aefa..b862e2090a 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Application qui fournit ou possède cette table, par exemple `dossiq`. Vide pour une table créée par un administrateur.", "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.", + "Decision Table": "Table de décision", "Disconnect": "Déconnecter", "Expired": "Expiré", "Expires {date}": "Expire le {date}", + "Hit policy": "Politique de correspondance", + "How the matching rules become one answer.": "Comment les règles correspondantes deviennent une seule réponse.", + "Input columns, each with a `name` and an optional `type`.": "Colonnes d'entrée, chacune avec un `name` et un `type` facultatif.", + "Inputs": "Entrées", + "Name shown to the people who author and read the table.": "Nom affiché aux personnes qui rédigent et lisent la table.", "No accounts are connected yet.": "Aucun compte n’est encore connecté.", "Not connected yet": "Pas encore connecté", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Colonnes de sortie, chacune avec un `name` et, pour PRIORITY, une liste ordonnée `values`.", + "Outputs": "Sorties", "Reconnect": "Reconnecter", "Relink needed": "Nouvelle liaison requise", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Règles, chacune avec un `id`, `inputEntries` (un test unaire par entrée) et `outputEntries` (une valeur par sortie).", "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. Each card has its own Load button. 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. Chaque carte a son propre bouton \"Charger\". Choisissez \"Aucune\" sur une installation de production.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Référence stable qu'une étape de flux ou une application utilise pour cette table, par exemple `enforcement-intervention`. Unique dans le registre.", + "What the table decides and where its rules come from.": "Ce que la table décide et d'où viennent ses règles.", "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.", diff --git a/l10n/ga.js b/l10n/ga.js index 16ef1d03f6..ac87b6e5c1 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "An aip a sholáthraíonn nó a bhfuil úinéireacht aici ar an tábla seo, mar shampla `dossiq`. Folamh i gcás tábla a chruthaigh riarthóir.", "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.", + "Decision Table": "Tábla cinntí", "Disconnect": "Dícheangail", "Expired": "As dáta", "Expires {date}": "Rachaidh sé in éag {date}", + "Hit policy": "Polasaí meaitseála", + "How the matching rules become one answer.": "Conas a thagann freagra amháin as na rialacha comhoiriúnacha.", + "Input columns, each with a `name` and an optional `type`.": "Colúin ionchuir, gach ceann acu le `name` agus `type` roghnach.", + "Inputs": "Ionchuir", + "Name shown to the people who author and read the table.": "Ainm a fheiceann na daoine a scríobhann agus a léann an tábla.", "No accounts are connected yet.": "Níl aon chuntas ceangailte fós.", "Not connected yet": "Gan cheangal fós", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Colúin aschuir, gach ceann acu le `name` agus, le haghaidh PRIORITY, liosta ordaithe `values`.", + "Outputs": "Aschuir", "Reconnect": "Athcheangail", "Relink needed": "Teastaíonn athnascadh", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Rialacha, gach ceann acu le `id`, `inputEntries` (tástáil aonártha amháin in aghaidh gach ionchuir) agus `outputEntries` (luach amháin in aghaidh gach aschuir).", "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. Each card has its own Load button. 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. Tá a cnaipe \"Luchtaigh\" féin ag gach cárta. Roghnaigh \"Ceann ar bith\" ar shuiteáil táirgthe.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Tagairt sheasmhach a úsáideann céim sreafa nó aip don tábla seo, mar shampla `enforcement-intervention`. Uathúil laistigh den chlár.", + "What the table decides and where its rules come from.": "Cad a chinneann an tábla agus cárb as a dtagann a rialacha.", "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.", diff --git a/l10n/hr.js b/l10n/hr.js index b41528a5aa..00440b6779 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacija koja isporučuje ili posjeduje ovu tablicu, na primjer `dossiq`. Prazno za tablicu koju je izradio administrator.", "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.", + "Decision Table": "Tablica odluka", "Disconnect": "Odspoji", "Expired": "Isteklo", "Expires {date}": "Ističe {date}", + "Hit policy": "Pravilo podudaranja", + "How the matching rules become one answer.": "Kako podudarna pravila postaju jedan odgovor.", + "Input columns, each with a `name` and an optional `type`.": "Ulazni stupci, svaki s `name` i neobaveznim `type`.", + "Inputs": "Ulazi", + "Name shown to the people who author and read the table.": "Naziv koji vide osobe koje sastavljaju i čitaju tablicu.", "No accounts are connected yet.": "Još nije povezan nijedan račun.", "Not connected yet": "Još nije povezano", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Izlazni stupci, svaki s `name` i, za PRIORITY, uređenim popisom `values`.", + "Outputs": "Izlazi", "Reconnect": "Poveži ponovno", "Relink needed": "Potrebno je ponovno povezivanje", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Pravila, svako s `id`, `inputEntries` (jedan unarni test po ulazu) i `outputEntries` (jedna vrijednost po izlazu).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Primjeri podataka popunjavaju popise, stranice s detaljima i nadzorne ploče, pa aplikaciju odmah vidite kako radi. Svaka kartica ima vlastiti gumb \"Učitaj\". Odaberite \"Nema\" na produkcijskoj instalaciji.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabilna referenca koju korak toka ili aplikacija koristi za ovu tablicu, na primjer `enforcement-intervention`. Jedinstvena unutar registra.", + "What the table decides and where its rules come from.": "Što tablica odlučuje i odakle dolaze njezina pravila.", "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.", diff --git a/l10n/hu.js b/l10n/hu.js index 9e009a31a2..d92cab4862 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Az alkalmazás, amely ezt a táblázatot szállítja vagy birtokolja, például `dossiq`. Üres, ha a táblázatot egy rendszergazda hozta létre.", "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.", + "Decision Table": "Döntési táblázat", "Disconnect": "Leválasztás", "Expired": "Lejárt", "Expires {date}": "Lejár: {date}", + "Hit policy": "Találati szabályzat", + "How the matching rules become one answer.": "Hogyan lesz az egyező szabályokból egyetlen válasz.", + "Input columns, each with a `name` and an optional `type`.": "Bemeneti oszlopok, mindegyik egy `name` mezővel és egy opcionális `type` mezővel.", + "Inputs": "Bemenetek", + "Name shown to the people who author and read the table.": "A név, amelyet a táblázatot szerkesztők és olvasók látnak.", "No accounts are connected yet.": "Még nincs csatlakoztatott fiók.", "Not connected yet": "Még nincs csatlakoztatva", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Kimeneti oszlopok, mindegyik egy `name` mezővel és PRIORITY esetén egy rendezett `values` listával.", + "Outputs": "Kimenetek", "Reconnect": "Újracsatlakoztatás", "Relink needed": "Újbóli összekapcsolás szükséges", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Szabályok, mindegyik egy `id` mezővel, `inputEntries` (bemenetenként egy unáris teszt) és `outputEntries` (kimenetenként egy érték) mezőkkel.", "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. Each card has its own Load button. 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. Minden kártyának saját \"Betöltés\" gombja van. Éles telepítésen válassza a \"Nincs\" lehetőséget.", @@ -23,6 +33,8 @@ OC.L10N.register( "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ő.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Állandó hivatkozás, amelyet egy folyamatlépés vagy alkalmazás használ ehhez a táblázathoz, például `enforcement-intervention`. A nyilvántartáson belül egyedi.", + "What the table decides and where its rules come from.": "Miről dönt a táblázat, és honnan származnak a szabályai.", "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.", diff --git a/l10n/is.js b/l10n/is.js index 314be37a81..0b78a9c19a 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Forritið sem útvegar eða á þessa töflu, til dæmis `dossiq`. Autt fyrir töflu sem stjórnandi bjó til.", "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.", + "Decision Table": "Ákvörðunartafla", "Disconnect": "Aftengja", "Expired": "Útrunnið", "Expires {date}": "Rennur út {date}", + "Hit policy": "Samsvörunarregla", + "How the matching rules become one answer.": "Hvernig samsvarandi reglur verða að einu svari.", + "Input columns, each with a `name` and an optional `type`.": "Inntaksdálkar, hver með `name` og valkvæðu `type`.", + "Inputs": "Inntök", + "Name shown to the people who author and read the table.": "Heiti sem fólk sem semur og les töfluna sér.", "No accounts are connected yet.": "Engir reikningar eru tengdir enn.", "Not connected yet": "Ekki tengt enn", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Úttaksdálkar, hver með `name` og, fyrir PRIORITY, raðaðan `values` lista.", + "Outputs": "Úttök", "Reconnect": "Tengja aftur", "Relink needed": "Endurtenging nauðsynleg", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Reglur, hver með `id`, `inputEntries` (eitt einundarpróf á hvert inntak) og `outputEntries` (eitt gildi á hvert úttak).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Sýnigögn fylla listana, upplýsingasíðurnar og mælaborðin, svo þú sérð forritið virka strax. Hvert spjald hefur sinn eigin \"Hlaða\"-hnapp. Veldu \"Engin\" á framleiðsluuppsetningu.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stöðug tilvísun sem flæðisskref eða forrit notar fyrir þessa töflu, til dæmis `enforcement-intervention`. Einkvæm innan skrárinnar.", + "What the table decides and where its rules come from.": "Hvað taflan ákveður og hvaðan reglur hennar koma.", "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.", diff --git a/l10n/it.js b/l10n/it.js index 1e2517a583..afa551d345 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App che fornisce o possiede questa tabella, per esempio `dossiq`. Vuoto per una tabella creata da un amministratore.", "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.", + "Decision Table": "Tabella decisionale", "Disconnect": "Scollega", "Expired": "Scaduto", "Expires {date}": "Scade il {date}", + "Hit policy": "Criterio di corrispondenza", + "How the matching rules become one answer.": "Come le regole corrispondenti diventano un'unica risposta.", + "Input columns, each with a `name` and an optional `type`.": "Colonne di input, ciascuna con un `name` e un `type` facoltativo.", + "Inputs": "Input", + "Name shown to the people who author and read the table.": "Nome mostrato alle persone che scrivono e leggono la tabella.", "No accounts are connected yet.": "Nessun account è ancora collegato.", "Not connected yet": "Non ancora collegato", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Colonne di output, ciascuna con un `name` e, per PRIORITY, un elenco ordinato `values`.", + "Outputs": "Output", "Reconnect": "Ricollega", "Relink needed": "Serve un nuovo collegamento", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Regole, ciascuna con un `id`, `inputEntries` (un test unario per input) e `outputEntries` (un valore per output).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "I dati di esempio riempiono elenchi, pagine di dettaglio e dashboard, così vedi subito l’app in funzione. Ogni scheda ha il proprio pulsante \"Carica\". Scegli \"Nessuno\" su un’installazione di produzione.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Riferimento stabile che un passo del flusso o un'app usa per questa tabella, per esempio `enforcement-intervention`. Univoco nel registro.", + "What the table decides and where its rules come from.": "Che cosa decide la tabella e da dove vengono le sue regole.", "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.", diff --git a/l10n/lb.js b/l10n/lb.js index 74ec09946c..608d6df9c0 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "App, déi dës Tabell matbréngt oder besëtzt, zum Beispill `dossiq`. Eidel bei enger Tabell, déi en Administrateur ugeluecht huet.", "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.", + "Decision Table": "Entscheedungstabell", "Disconnect": "Trennen", "Expired": "Ofgelaf", "Expires {date}": "Leeft de(n) {date} of", + "Hit policy": "Treffer-Richtlinn", + "How the matching rules become one answer.": "Wéi aus de passende Reegele jee eng Äntwert gëtt.", + "Input columns, each with a `name` and an optional `type`.": "Aganskolonnen, all mat engem `name` an engem optionalen `type`.", + "Inputs": "Agäng", + "Name shown to the people who author and read the table.": "Numm, deen d'Leit gesinn, déi d'Tabell erstellen a liesen.", "No accounts are connected yet.": "Et ass nach kee Kont verbonnen.", "Not connected yet": "Nach net verbonnen", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Ausgangskolonnen, all mat engem `name` a bei PRIORITY enger gerangenger `values`-Lëscht.", + "Outputs": "Ausgäng", "Reconnect": "Nei verbannen", "Relink needed": "Nei Verbindung néideg", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Reegelen, all mat enger `id`, `inputEntries` (een unären Test pro Gang) an `outputEntries` (een Wäert pro Ausgang).", "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. Each card has its own Load button. 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. All Kaart huet hiren eegene Knäppchen \"Lueden\". Wielt \"Keng\" bei enger Produktiounsinstallatioun.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabil Referenz, déi e Flow-Schrëtt oder eng App fir dës Tabell benotzt, zum Beispill `enforcement-intervention`. Eenzegaarteg am Register.", + "What the table decides and where its rules come from.": "Wat d'Tabell entscheet a vu wou hir Reegele kommen.", "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.", diff --git a/l10n/lt.js b/l10n/lt.js index 4724cb07cd..eac6de8dd8 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Programa, kuri pateikia šią lentelę arba ją valdo, pavyzdžiui, `dossiq`. Tuščia, jei lentelę sukūrė administratorius.", "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ą.", + "Decision Table": "Sprendimų lentelė", "Disconnect": "Atjungti", "Expired": "Nebegalioja", "Expires {date}": "Galioja iki {date}", + "Hit policy": "Atitikimo politika", + "How the matching rules become one answer.": "Kaip atitinkančios taisyklės tampa vienu atsakymu.", + "Input columns, each with a `name` and an optional `type`.": "Įvesties stulpeliai, kiekvienas su `name` ir pasirenkamu `type`.", + "Inputs": "Įvestys", + "Name shown to the people who author and read the table.": "Pavadinimas, kurį mato lentelę rengiantys ir skaitantys žmonės.", "No accounts are connected yet.": "Kol kas neprijungta nė viena paskyra.", "Not connected yet": "Dar neprijungta", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Išvesties stulpeliai, kiekvienas su `name` ir, esant PRIORITY, sutvarkytu `values` sąrašu.", + "Outputs": "Išvestys", "Reconnect": "Prijungti iš naujo", "Relink needed": "Reikia susieti iš naujo", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Taisyklės, kiekviena su `id`, `inputEntries` (vienas unarinis testas kiekvienai įvesčiai) ir `outputEntries` (viena reikšmė kiekvienai išvesčiai).", "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. Each card has its own Load button. 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ą. Kiekviena kortelė turi savo mygtuką \"Įkelti\". Gamybinėje diegtyje pasirinkite \"Nėra\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Pastovi nuoroda, kurią šiai lentelei naudoja srauto žingsnis ar programa, pavyzdžiui, `enforcement-intervention`. Unikali registre.", + "What the table decides and where its rules come from.": "Ką lentelė sprendžia ir iš kur atsiranda jos taisyklės.", "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.", diff --git a/l10n/lv.js b/l10n/lv.js index 1c0c15dcf6..ebf6e8b533 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Lietotne, kas piegādā vai pārvalda šo tabulu, piemēram, `dossiq`. Tukšs, ja tabulu izveidoja administrators.", "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.", + "Decision Table": "Lēmumu tabula", "Disconnect": "Atvienot", "Expired": "Beidzies", "Expires {date}": "Beidzas {date}", + "Hit policy": "Atbilstības politika", + "How the matching rules become one answer.": "Kā atbilstošie noteikumi kļūst par vienu atbildi.", + "Input columns, each with a `name` and an optional `type`.": "Ievades kolonnas, katrai ir `name` un neobligāts `type`.", + "Inputs": "Ievades", + "Name shown to the people who author and read the table.": "Nosaukums, ko redz cilvēki, kas tabulu sastāda un lasa.", "No accounts are connected yet.": "Vēl nav pievienots neviens konts.", "Not connected yet": "Vēl nav pievienots", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Izvades kolonnas, katrai ir `name` un PRIORITY gadījumā sakārtots `values` saraksts.", + "Outputs": "Izvades", "Reconnect": "Pievienot atkārtoti", "Relink needed": "Nepieciešama atkārtota sasaiste", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Noteikumi, katram ir `id`, `inputEntries` (viens unārs tests katrai ievadei) un `outputEntries` (viena vērtība katrai izvadei).", "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. Each card has its own Load button. 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ā. Katrai kartītei ir sava poga \"Ielādēt\". Ražošanas instalācijā izvēlieties \"Nav\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabila atsauce, ko šai tabulai izmanto plūsmas solis vai lietotne, piemēram, `enforcement-intervention`. Unikāla reģistrā.", + "What the table decides and where its rules come from.": "Ko tabula nosaka un no kurienes nāk tās noteikumi.", "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.", diff --git a/l10n/mk.js b/l10n/mk.js index 5e1e5ff3d6..4f4ccbffa6 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Апликацијата што ја испорачува или ја поседува оваа табела, на пример `dossiq`. Празно за табела што ја создал администратор.", "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.": "Поврзувањето не можеше да започне. Провери го давателот и обиди се повторно.", + "Decision Table": "Табела на одлуки", "Disconnect": "Исклучи", "Expired": "Истечено", "Expires {date}": "Истекува {date}", + "Hit policy": "Правило за совпаѓање", + "How the matching rules become one answer.": "Како правилата што се совпаѓаат стануваат еден одговор.", + "Input columns, each with a `name` and an optional `type`.": "Влезни колони, секоја со `name` и по избор `type`.", + "Inputs": "Влезови", + "Name shown to the people who author and read the table.": "Име што го гледаат луѓето што ја составуваат и ја читаат табелата.", "No accounts are connected yet.": "Сè уште нема поврзани сметки.", "Not connected yet": "Сè уште не е поврзано", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Излезни колони, секоја со `name` и, за PRIORITY, подредена листа `values`.", + "Outputs": "Излези", "Reconnect": "Поврзи повторно", "Relink needed": "Потребно е ново поврзување", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Правила, секое со `id`, `inputEntries` (еден унарен тест по влез) и `outputEntries` (една вредност по излез).", "Server address": "Адреса на серверот", "Load example data?": "Да се вчитаат примероци од податоци?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Примероците од податоци ги полнат списоците, страниците со детали и таблите, за да ја видите апликацијата како работи веднаш. Секоја картичка има свое копче \"Вчитај\". Изберете \"Ништо\" на продукциска инсталација.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.": "Примерни вредности за секоја шема што ја носи оваа апликација, генерирани од самите шеми. Ги покажуваат списоците, страниците со детали и таблите во работа наместо да раскажуваат приказна. Безбедно за повторување и бришење потоа.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Стабилна референца што чекор од тек или апликација ја користи за оваа табела, на пример `enforcement-intervention`. Уникатна во регистарот.", + "What the table decides and where its rules come from.": "Што одлучува табелата и од каде доаѓаат нејзините правила.", "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.": "Секој деклариран синџир отвора подредена низа задачи кога преминот ќе се обиде. Одобрувачите одлучуваат во своето сандаче со задачи, не овде.", diff --git a/l10n/mt.js b/l10n/mt.js index 73c22dfb33..b8cb1b0bd9 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "L-applikazzjoni li tipprovdi jew li tippossjedi din it-tabella, pereżempju `dossiq`. Vojt għal tabella maħluqa minn amministratur.", "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.", + "Decision Table": "Tabella tad-deċiżjonijiet", "Disconnect": "Skonnettja", "Expired": "Skada", "Expires {date}": "Jiskadi fi {date}", + "Hit policy": "Politika tat-tqabbil", + "How the matching rules become one answer.": "Kif ir-regoli li jaqblu jsiru tweġiba waħda.", + "Input columns, each with a `name` and an optional `type`.": "Kolonni tal-input, kull waħda b'`name` u `type` fakultattiv.", + "Inputs": "Dħul", + "Name shown to the people who author and read the table.": "L-isem li jaraw in-nies li jikteb u jaqraw it-tabella.", "No accounts are connected yet.": "Għad m’hemm ebda kont imqabbad.", "Not connected yet": "Għadu mhux imqabbad", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Kolonni tal-output, kull waħda b'`name` u, għal PRIORITY, lista ordnata `values`.", + "Outputs": "Ħruġ", "Reconnect": "Erġa’ qabbad", "Relink needed": "Hemm bżonn rabta ġdida", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Regoli, kull waħda b'`id`, `inputEntries` (test unarju wieħed għal kull input) u `outputEntries` (valur wieħed għal kull output).", "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. Each card has its own Load button. 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. Kull karta għandha l-buttuna \"Għabbi\" tagħha. Agħżel \"Xejn\" fuq installazzjoni tal-produzzjoni.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Referenza stabbli li pass tal-fluss jew applikazzjoni juża għal din it-tabella, pereżempju `enforcement-intervention`. Unika fir-reġistru.", + "What the table decides and where its rules come from.": "X'tiddeċiedi t-tabella u minn fejn ġejjin ir-regoli tagħha.", "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.", diff --git a/l10n/nb.js b/l10n/nb.js index 4c4dc591ef..61b01fa469 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Appen som leverer eller eier denne tabellen, for eksempel `dossiq`. Tom for en tabell en administrator har laget.", "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.", + "Decision Table": "Beslutningstabell", "Disconnect": "Koble fra", "Expired": "Utløpt", "Expires {date}": "Utløper {date}", + "Hit policy": "Treffregel", + "How the matching rules become one answer.": "Hvordan de matchende reglene blir til ett svar.", + "Input columns, each with a `name` and an optional `type`.": "Inndatakolonner, hver med et `name` og en valgfri `type`.", + "Inputs": "Inndata", + "Name shown to the people who author and read the table.": "Navnet som de som lager og leser tabellen, ser.", "No accounts are connected yet.": "Ingen kontoer er tilkoblet ennå.", "Not connected yet": "Ikke tilkoblet ennå", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Utdatakolonner, hver med et `name` og, for PRIORITY, en ordnet `values`-liste.", + "Outputs": "Utdata", "Reconnect": "Koble til på nytt", "Relink needed": "Ny tilkobling kreves", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Regler, hver med en `id`, `inputEntries` (én unær test per inndata) og `outputEntries` (én verdi per utdata).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Eksempeldata fyller listene, detaljsidene og dashbordene, så du ser appen virke med en gang. Hvert kort har sin egen \"Last inn\"-knapp. Velg \"Ingen\" på en produksjonsinstallasjon.", @@ -23,6 +33,8 @@ OC.L10N.register( "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å.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabil referanse som et flytsteg eller en app bruker for denne tabellen, for eksempel `enforcement-intervention`. Unik i registeret.", + "What the table decides and where its rules come from.": "Hva tabellen avgjør, og hvor reglene kommer fra.", "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.", diff --git a/l10n/pl.js b/l10n/pl.js index 46ac55f54e..d8104a8020 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacja, która dostarcza tę tabelę lub jest jej właścicielem, na przykład `dossiq`. Puste dla tabeli utworzonej przez administratora.", "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.", + "Decision Table": "Tabela decyzyjna", "Disconnect": "Odłącz", "Expired": "Wygasło", "Expires {date}": "Wygasa {date}", + "Hit policy": "Reguła dopasowania", + "How the matching rules become one answer.": "Jak pasujące reguły stają się jedną odpowiedzią.", + "Input columns, each with a `name` and an optional `type`.": "Kolumny wejściowe, każda z `name` i opcjonalnym `type`.", + "Inputs": "Wejścia", + "Name shown to the people who author and read the table.": "Nazwa widoczna dla osób, które tworzą i czytają tabelę.", "No accounts are connected yet.": "Nie połączono jeszcze żadnego konta.", "Not connected yet": "Jeszcze niepołączone", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Kolumny wyjściowe, każda z `name` i, dla PRIORITY, uporządkowaną listą `values`.", + "Outputs": "Wyjścia", "Reconnect": "Połącz ponownie", "Relink needed": "Wymagane ponowne połączenie", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Reguły, każda z `id`, `inputEntries` (jeden test unarny na wejście) i `outputEntries` (jedna wartość na wyjście).", "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. Each card has its own Load button. 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ę. Każda karta ma własny przycisk \"Wczytaj\". Na instalacji produkcyjnej wybierz \"Brak\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stałe odwołanie, którego krok przepływu lub aplikacja używa dla tej tabeli, na przykład `enforcement-intervention`. Unikalne w rejestrze.", + "What the table decides and where its rules come from.": "O czym decyduje tabela i skąd pochodzą jej reguły.", "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.", diff --git a/l10n/pt.js b/l10n/pt.js index c7a32e19f7..e64f92d48f 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplicação que fornece ou possui esta tabela, por exemplo `dossiq`. Vazio para uma tabela criada por um administrador.", "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.", + "Decision Table": "Tabela de decisão", "Disconnect": "Desligar", "Expired": "Expirado", "Expires {date}": "Expira a {date}", + "Hit policy": "Política de correspondência", + "How the matching rules become one answer.": "Como as regras correspondentes se tornam uma única resposta.", + "Input columns, each with a `name` and an optional `type`.": "Colunas de entrada, cada uma com um `name` e um `type` opcional.", + "Inputs": "Entradas", + "Name shown to the people who author and read the table.": "Nome mostrado às pessoas que redigem e leem a tabela.", "No accounts are connected yet.": "Ainda não há contas ligadas.", "Not connected yet": "Ainda não ligado", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Colunas de saída, cada uma com um `name` e, para PRIORITY, uma lista ordenada `values`.", + "Outputs": "Saídas", "Reconnect": "Ligar de novo", "Relink needed": "É preciso voltar a ligar", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Regras, cada uma com um `id`, `inputEntries` (um teste unário por entrada) e `outputEntries` (um valor por saída).", "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. Each card has its own Load button. 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. Cada cartão tem o seu próprio botão \"Carregar\". Escolha \"Nenhum\" numa instalação de produção.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Referência estável que um passo de fluxo ou uma aplicação usa para esta tabela, por exemplo `enforcement-intervention`. Única no registo.", + "What the table decides and where its rules come from.": "O que a tabela decide e de onde vêm as suas regras.", "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.", diff --git a/l10n/rm.js b/l10n/rm.js index 8bb8d88fa0..f3970fc5ce 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "L'app che furnescha u posseda questa tabella, per exempel `dossiq`. Vid per ina tabella creada d'in administratur.", "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.", + "Decision Table": "Tabella da decisiuns", "Disconnect": "Discolliar", "Expired": "Scrudà", "Expires {date}": "Scroda ils {date}", + "Hit policy": "Politica da correspundenza", + "How the matching rules become one answer.": "Co che las reglas correspundentas daventan ina sulet resposta.", + "Input columns, each with a `name` and an optional `type`.": "Colonnas d'endataziun, mintgina cun in `name` ed in `type` opziunal.", + "Inputs": "Endaziuns", + "Name shown to the people who author and read the table.": "Num che las persunas che scrivan e legian la tabella vesan.", "No accounts are connected yet.": "Anc nagin conto n’è collià.", "Not connected yet": "Anc betg collià", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Colonnas d'surtida, mintgina cun in `name` e, per PRIORITY, ina glista urdinada `values`.", + "Outputs": "Surtidas", "Reconnect": "Colliar da nov", "Relink needed": "Ina nova colliaziun è necessaria", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Reglas, mintgina cun in `id`, `inputEntries` (in test unar per endataziun) ed `outputEntries` (ina valur per surtida).", "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. Each card has its own Load button. 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. Mintga carta ha ses agen buttun \"Chargiar\". Tscherna \"Nagina\" sin ina installaziun da producziun.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Referenza stabila che in pass dal flux u ina app duvra per questa tabella, per exempel `enforcement-intervention`. Unica aint il register.", + "What the table decides and where its rules come from.": "Tge che la tabella decida e danunder che ses reglas derivan.", "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.", diff --git a/l10n/ro.js b/l10n/ro.js index 4793c0c230..81262cf00a 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplicația care furnizează sau deține acest tabel, de exemplu `dossiq`. Gol pentru un tabel creat de un administrator.", "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.", + "Decision Table": "Tabel de decizie", "Disconnect": "Deconectează", "Expired": "Expirat", "Expires {date}": "Expiră la {date}", + "Hit policy": "Politică de potrivire", + "How the matching rules become one answer.": "Cum devin regulile potrivite un singur răspuns.", + "Input columns, each with a `name` and an optional `type`.": "Coloane de intrare, fiecare cu un `name` și un `type` opțional.", + "Inputs": "Intrări", + "Name shown to the people who author and read the table.": "Numele văzut de persoanele care redactează și citesc tabelul.", "No accounts are connected yet.": "Încă nu este conectat niciun cont.", "Not connected yet": "Încă neconectat", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Coloane de ieșire, fiecare cu un `name` și, pentru PRIORITY, o listă ordonată `values`.", + "Outputs": "Ieșiri", "Reconnect": "Reconectează", "Relink needed": "Este necesară o nouă legătură", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Reguli, fiecare cu un `id`, `inputEntries` (un test unar pe intrare) și `outputEntries` (o valoare pe ieșire).", "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. Each card has its own Load button. 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. Fiecare card are propriul buton \"Încarcă\". Alegeți \"Niciunul\" pe o instalare de producție.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Referință stabilă pe care un pas de flux sau o aplicație o folosește pentru acest tabel, de exemplu `enforcement-intervention`. Unică în registru.", + "What the table decides and where its rules come from.": "Ce decide tabelul și de unde provin regulile lui.", "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.", diff --git a/l10n/ru.js b/l10n/ru.js index 403a8a23d2..3252d6440e 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Приложение, которое поставляет эту таблицу или владеет ею, например `dossiq`. Пусто для таблицы, созданной администратором.", "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.": "Не удалось начать подключение. Проверьте поставщика и попробуйте снова.", + "Decision Table": "Таблица решений", "Disconnect": "Отключить", "Expired": "Истёк", "Expires {date}": "Истекает {date}", + "Hit policy": "Политика совпадения", + "How the matching rules become one answer.": "Как подходящие правила превращаются в один ответ.", + "Input columns, each with a `name` and an optional `type`.": "Столбцы ввода, каждый с `name` и необязательным `type`.", + "Inputs": "Входы", + "Name shown to the people who author and read the table.": "Название, которое видят люди, составляющие и читающие таблицу.", "No accounts are connected yet.": "Пока не подключён ни один аккаунт.", "Not connected yet": "Ещё не подключён", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Столбцы вывода, каждый с `name` и, для PRIORITY, упорядоченным списком `values`.", + "Outputs": "Выходы", "Reconnect": "Подключить заново", "Relink needed": "Нужна повторная привязка", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Правила, каждое с `id`, `inputEntries` (одна унарная проверка на вход) и `outputEntries` (одно значение на выход).", "Server address": "Адрес сервера", "Load example data?": "Загрузить примеры данных?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Примеры данных заполняют списки, страницы подробностей и панели, чтобы вы сразу увидели приложение в работе. У каждой карточки есть своя кнопка \"Загрузить\". На рабочей установке выберите \"Нет\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.": "Примеры значений для каждой схемы, которую поставляет это приложение, созданные из самих схем. Они показывают списки, страницы подробностей и панели в работе, а не рассказывают историю. Безопасно повторять и потом удалить.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Стабильная ссылка, которую шаг потока или приложение использует для этой таблицы, например `enforcement-intervention`. Уникальна в реестре.", + "What the table decides and where its rules come from.": "Что решает таблица и откуда берутся её правила.", "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.": "Каждая объявленная цепочка открывает упорядоченную последовательность задач при попытке перехода. Согласующие решают в своём ящике задач, а не здесь.", diff --git a/l10n/sk.js b/l10n/sk.js index d7ad3e9daf..c182dd95e1 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikácia, ktorá túto tabuľku dodáva alebo vlastní, napríklad `dossiq`. Prázdne pri tabuľke, ktorú vytvoril správca.", "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.", + "Decision Table": "Rozhodovacia tabuľka", "Disconnect": "Odpojiť", "Expired": "Vypršalo", "Expires {date}": "Vyprší {date}", + "Hit policy": "Pravidlo zhody", + "How the matching rules become one answer.": "Ako sa zhodné pravidlá stanú jednou odpoveďou.", + "Input columns, each with a `name` and an optional `type`.": "Vstupné stĺpce, každý s `name` a voliteľným `type`.", + "Inputs": "Vstupy", + "Name shown to the people who author and read the table.": "Názov, ktorý vidia ľudia, ktorí tabuľku zostavujú a čítajú.", "No accounts are connected yet.": "Zatiaľ nie sú pripojené žiadne účty.", "Not connected yet": "Zatiaľ nepripojené", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Výstupné stĺpce, každý s `name` a pri PRIORITY s usporiadaným zoznamom `values`.", + "Outputs": "Výstupy", "Reconnect": "Pripojiť znova", "Relink needed": "Je potrebné znova prepojiť", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Pravidlá, každé s `id`, `inputEntries` (jeden unárny test na vstup) a `outputEntries` (jedna hodnota na výstup).", "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. Each card has its own Load button. 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. Každá karta má vlastné tlačidlo \"Načítať\". Na produkčnej inštalácii zvoľte \"Žiadne\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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ť.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabilný odkaz, ktorý krok toku alebo aplikácia používa pre túto tabuľku, napríklad `enforcement-intervention`. Jedinečný v rámci registra.", + "What the table decides and where its rules come from.": "O čom tabuľka rozhoduje a odkiaľ pochádzajú jej pravidlá.", "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.", diff --git a/l10n/sl.js b/l10n/sl.js index c8c7b779af..1ee4c56f95 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacija, ki to tabelo dobavlja ali jo ima v lasti, na primer `dossiq`. Prazno za tabelo, ki jo je ustvaril skrbnik.", "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.", + "Decision Table": "Odločitvena tabela", "Disconnect": "Odklopi", "Expired": "Poteklo", "Expires {date}": "Poteče {date}", + "Hit policy": "Pravilo ujemanja", + "How the matching rules become one answer.": "Kako ujemajoča se pravila postanejo en odgovor.", + "Input columns, each with a `name` and an optional `type`.": "Vhodni stolpci, vsak z `name` in neobveznim `type`.", + "Inputs": "Vhodi", + "Name shown to the people who author and read the table.": "Ime, ki ga vidijo osebe, ki tabelo pišejo in berejo.", "No accounts are connected yet.": "Noben račun še ni povezan.", "Not connected yet": "Še ni povezano", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Izhodni stolpci, vsak z `name` in pri PRIORITY z urejenim seznamom `values`.", + "Outputs": "Izhodi", "Reconnect": "Poveži znova", "Relink needed": "Potrebna je nova povezava", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Pravila, vsako z `id`, `inputEntries` (en unarni test na vhod) in `outputEntries` (ena vrednost na izhod).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Vzorčni podatki napolnijo sezname, strani s podrobnostmi in nadzorne plošče, tako da aplikacijo takoj vidite delovati. Vsaka kartica ima svoj gumb \"Naloži\". Pri produkcijski namestitvi izberite \"Brez\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stalna referenca, ki jo korak toka ali aplikacija uporablja za to tabelo, na primer `enforcement-intervention`. Enolična v registru.", + "What the table decides and where its rules come from.": "O čem tabela odloča in od kod izhajajo njena pravila.", "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.", diff --git a/l10n/sq.js b/l10n/sq.js index 8f96f6d640..06e2976b34 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Aplikacioni që e ofron ose e zotëron këtë tabelë, për shembull `dossiq`. Bosh për një tabelë të krijuar nga një administrator.", "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.", + "Decision Table": "Tabelë vendimesh", "Disconnect": "Shkëput", "Expired": "Skaduar", "Expires {date}": "Skadon më {date}", + "Hit policy": "Politika e përputhjes", + "How the matching rules become one answer.": "Si rregullat që përputhen bëhen një përgjigje e vetme.", + "Input columns, each with a `name` and an optional `type`.": "Kolona hyrjeje, secila me një `name` dhe një `type` opsional.", + "Inputs": "Hyrjet", + "Name shown to the people who author and read the table.": "Emri që e shohin njerëzit që e hartojnë dhe e lexojnë tabelën.", "No accounts are connected yet.": "Ende nuk është lidhur asnjë llogari.", "Not connected yet": "Ende e palidhur", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Kolona daljeje, secila me një `name` dhe, për PRIORITY, një listë të renditur `values`.", + "Outputs": "Daljet", "Reconnect": "Rilidh", "Relink needed": "Nevojitet rilidhje", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Rregulla, secili me një `id`, `inputEntries` (një provë njëkëshe për çdo hyrje) dhe `outputEntries` (një vlerë për çdo dalje).", "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. Each card has its own Load button. 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ë. Çdo kartë ka butonin e vet \"Ngarko\". Zgjidhni \"Asnjë\" në një instalim prodhimi.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Referencë e qëndrueshme që një hap rrjedhe ose një aplikacion e përdor për këtë tabelë, për shembull `enforcement-intervention`. Unike brenda regjistrit.", + "What the table decides and where its rules come from.": "Çfarë vendos tabela dhe nga vijnë rregullat e saj.", "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.", diff --git a/l10n/sr.js b/l10n/sr.js index 8ba461eeab..63ce0cf32f 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Апликација која испоручује или поседује ову табелу, на пример `dossiq`. Празно за табелу коју је направио администратор.", "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.": "Није било могуће покренути повезивање. Провери пружаоца и покушај поново.", + "Decision Table": "Табела одлука", "Disconnect": "Одспоји", "Expired": "Истекло", "Expires {date}": "Истиче {date}", + "Hit policy": "Правило подударања", + "How the matching rules become one answer.": "Како подударна правила постају један одговор.", + "Input columns, each with a `name` and an optional `type`.": "Улазне колоне, свака са `name` и необавезним `type`.", + "Inputs": "Улази", + "Name shown to the people who author and read the table.": "Назив који виде људи који пишу и читају табелу.", "No accounts are connected yet.": "Још није повезан ниједан налог.", "Not connected yet": "Још није повезано", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Излазне колоне, свака са `name` и, за PRIORITY, уређеном листом `values`.", + "Outputs": "Излази", "Reconnect": "Повежи поново", "Relink needed": "Потребно је поновно повезивање", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Правила, свако са `id`, `inputEntries` (један унарни тест по улазу) и `outputEntries` (једна вредност по излазу).", "Server address": "Адреса сервера", "Load example data?": "Учитати примере података?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Примери података попуњавају листе, странице са детаљима и командне табле, па апликацију одмах видите како ради. Свака картица има своје дугме \"Учитај\". Изаберите \"Ништа\" на продукцијској инсталацији.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.": "Примери вредности за сваку шему коју ова апликација доноси, генерисани из самих шема. Показују листе, странице са детаљима и командне табле у раду уместо да причају причу. Безбедно за понављање и брисање после.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Стабилна референца коју корак тока или апликација користи за ову табелу, на пример `enforcement-intervention`. Јединствена унутар регистра.", + "What the table decides and where its rules come from.": "Шта табела одлучује и одакле долазе њена правила.", "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.", diff --git a/l10n/sv.js b/l10n/sv.js index 73b8f11303..bb438df30d 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Appen som levererar eller äger den här tabellen, till exempel `dossiq`. Tom för en tabell som en administratör har skapat.", "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.", + "Decision Table": "Beslutstabell", "Disconnect": "Koppla från", "Expired": "Utgången", "Expires {date}": "Går ut {date}", + "Hit policy": "Träffpolicy", + "How the matching rules become one answer.": "Hur de matchande reglerna blir ett svar.", + "Input columns, each with a `name` and an optional `type`.": "Indatakolumner, var och en med ett `name` och en valfri `type`.", + "Inputs": "Indata", + "Name shown to the people who author and read the table.": "Namn som de som skriver och läser tabellen ser.", "No accounts are connected yet.": "Inga konton är anslutna ännu.", "Not connected yet": "Inte ansluten ännu", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Utdatakolumner, var och en med ett `name` och, för PRIORITY, en ordnad `values`-lista.", + "Outputs": "Utdata", "Reconnect": "Anslut igen", "Relink needed": "Ny länkning behövs", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Regler, var och en med ett `id`, `inputEntries` (ett unärt test per indata) och `outputEntries` (ett värde per utdata).", "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. Each card has its own Load button. Pick \"None\" on a production install.": "Exempeldata fyller listor, detaljsidor och instrumentpaneler, så att du direkt ser appen fungera. Varje kort har en egen \"Läs in\"-knapp. Välj \"Inga\" vid en produktionsinstallation.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Stabil referens som ett flödessteg eller en app använder för den här tabellen, till exempel `enforcement-intervention`. Unik i registret.", + "What the table decides and where its rules come from.": "Vad tabellen avgör och var reglerna kommer ifrån.", "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.", diff --git a/l10n/tr.js b/l10n/tr.js index 7d41d2bb07..e64ed2634c 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Bu tabloyu sağlayan veya sahiplenen uygulama, örneğin `dossiq`. Bir yönetici tarafından oluşturulan tablolar için boştur.", "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.", + "Decision Table": "Karar tablosu", "Disconnect": "Bağlantıyı kes", "Expired": "Süresi doldu", "Expires {date}": "{date} tarihinde doluyor", + "Hit policy": "Eşleşme ilkesi", + "How the matching rules become one answer.": "Eşleşen kuralların nasıl tek bir yanıta dönüştüğü.", + "Input columns, each with a `name` and an optional `type`.": "Giriş sütunları; her birinde bir `name` ve isteğe bağlı bir `type` bulunur.", + "Inputs": "Girişler", + "Name shown to the people who author and read the table.": "Tabloyu hazırlayan ve okuyan kişilerin gördüğü ad.", "No accounts are connected yet.": "Henüz bağlı hesap yok.", "Not connected yet": "Henüz bağlı değil", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Çıkış sütunları; her birinde bir `name` ve PRIORITY için sıralı bir `values` listesi bulunur.", + "Outputs": "Çıkışlar", "Reconnect": "Yeniden bağla", "Relink needed": "Yeniden bağlama gerekiyor", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Kurallar; her birinde bir `id`, `inputEntries` (giriş başına bir tekli sınama) ve `outputEntries` (çıkış başına bir değer) bulunur.", "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. Each card has its own Load button. 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. Her kartın kendi \"Yükle\" düğmesi vardır. Üretim kurulumunda \"Hiçbiri\" seçin.", @@ -23,6 +33,8 @@ OC.L10N.register( "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.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Bir akış adımının veya uygulamanın bu tablo için kullandığı kalıcı başvuru, örneğin `enforcement-intervention`. Kayıt içinde benzersizdir.", + "What the table decides and where its rules come from.": "Tablonun neye karar verdiği ve kurallarının nereden geldiği.", "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.", diff --git a/l10n/uk.js b/l10n/uk.js index 9d65f408c9..6bfcbf2e21 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -3,19 +3,29 @@ OC.L10N.register( { "3": "3", "30": "30", + "App that ships or owns this table, for example `dossiq`. Empty for a table an administrator authored.": "Застосунок, що постачає цю таблицю або володіє нею, наприклад `dossiq`. Порожньо для таблиці, створеної адміністратором.", "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.": "Не вдалося почати підключення. Перевірте постачальника та спробуйте ще раз.", + "Decision Table": "Таблиця рішень", "Disconnect": "Відʼєднати", "Expired": "Прострочено", "Expires {date}": "Спливає {date}", + "Hit policy": "Політика збігу", + "How the matching rules become one answer.": "Як відповідні правила перетворюються на одну відповідь.", + "Input columns, each with a `name` and an optional `type`.": "Стовпці введення, кожен з `name` і необов'язковим `type`.", + "Inputs": "Входи", + "Name shown to the people who author and read the table.": "Назва, яку бачать люди, що складають і читають таблицю.", "No accounts are connected yet.": "Ще не підключено жодного облікового запису.", "Not connected yet": "Ще не підключено", + "Output columns, each with a `name` and, for PRIORITY, an ordered `values` list.": "Стовпці виведення, кожен з `name` і, для PRIORITY, впорядкованим списком `values`.", + "Outputs": "Виходи", "Reconnect": "Підключити знову", "Relink needed": "Потрібне повторне звʼязування", + "Rules, each with an `id`, `inputEntries` (one unary test per input) and `outputEntries` (one value per output).": "Правила, кожне з `id`, `inputEntries` (одна унарна перевірка на вхід) і `outputEntries` (одне значення на вихід).", "Server address": "Адреса сервера", "Load example data?": "Завантажити приклади даних?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Приклади даних заповнюють списки, сторінки деталей і панелі, тож ви одразу побачите застосунок у роботі. Кожна картка має власну кнопку \"Завантажити\". На робочій інсталяції оберіть \"Немає\".", @@ -23,6 +33,8 @@ OC.L10N.register( "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.": "Приклади значень для кожної схеми, яку постачає цей застосунок, згенеровані з самих схем. Вони показують списки, сторінки деталей і панелі в роботі, а не розповідають історію. Безпечно повторювати і потім видалити.", + "Stable reference a flow step or an app uses for this table, for example `enforcement-intervention`. Unique within the register.": "Стабільне посилання, яке крок потоку або застосунок використовує для цієї таблиці, наприклад `enforcement-intervention`. Унікальне в реєстрі.", + "What the table decides and where its rules come from.": "Що вирішує таблиця і звідки беруться її правила.", "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.": "Кожен оголошений ланцюжок відкриває впорядковану послідовність завдань під час спроби переходу. Погоджувачі вирішують у своїй скриньці завдань, а не тут.", From ce3a58e1546b5d36e90a9567e5ebb6807ed1cddb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:44:45 +0200 Subject: [PATCH 064/183] fix(ci): mark the decision table schema as holding no demo data (gate 101) --- lib/Settings/decision_table_register.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Settings/decision_table_register.json b/lib/Settings/decision_table_register.json index dcfd94f832..4b70d14316 100644 --- a/lib/Settings/decision_table_register.json +++ b/lib/Settings/decision_table_register.json @@ -29,6 +29,7 @@ "schemas": { "decision-table": { "slug": "decision-table", + "x-openregister-demo-data": "Decision tables are the rules an administrator or an app ships, not sample content: a generated table would be evaluated by real flows.", "title": "Decision Table", "version": "1.0.0", "published": "2026-01-01T00:00:00+00:00", From b388178a14acaa0c1c731e6be57f6ca3b3f8fb9b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:45:57 +0200 Subject: [PATCH 065/183] fix(ci): put @spec after @return so phpcs accepts the docblock order --- lib/Service/File/ObjectFolderResponder.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/Service/File/ObjectFolderResponder.php b/lib/Service/File/ObjectFolderResponder.php index c89f5744d1..a156fc5d97 100644 --- a/lib/Service/File/ObjectFolderResponder.php +++ b/lib/Service/File/ObjectFolderResponder.php @@ -59,11 +59,11 @@ public function __construct( /** * Answer a failed call. * - * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule - * * @param Throwable $e The failure. * * @return JSONResponse The error response. + * + * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule */ public function failure(Throwable $e): JSONResponse { if ($e instanceof ObjectFileAccessDeniedException === true) { @@ -103,9 +103,9 @@ public function failure(Throwable $e): JSONResponse { /** * Answer an upload request that carried no file. * - * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule - * * @return JSONResponse 400. + * + * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule */ public function noUpload(): JSONResponse { return new JSONResponse(data: ['error' => $this->l10n->t('No file was uploaded')], statusCode: 400); @@ -114,8 +114,6 @@ public function noUpload(): JSONResponse { /** * Answer an upload batch. * - * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule - * * @param array $outcome What was stored and refused, from ObjectFolderUploads::uploadAll(). * @param int $total How many files were sent. * @@ -123,6 +121,8 @@ public function noUpload(): JSONResponse { * @phpstan-param array{stored: list>, rejected: list} $outcome * * @return JSONResponse 201 when all were stored, 207 when some were, 409 when every file was a taken name, 400 otherwise. + * + * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule */ public function uploaded(array $outcome, int $total): JSONResponse { $conflicts = 0; From 7779ea63aca227effd99940a8a7532ed64bf8f42 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:49:59 +0200 Subject: [PATCH 066/183] fix(ci): declare the classes the new tests execute (@uses) so they record coverage --- .../Object/ReferentialIntegrityKeyReferenceTest.php | 4 ++++ .../Service/Schemas/PropertyValidatorReferenceKeyTest.php | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php b/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php index 4cc1fc408a..f18b3c3ce4 100644 --- a/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php +++ b/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php @@ -46,6 +46,10 @@ * `note` schema whose `labels` property lists codes. * * @covers \OCA\OpenRegister\Service\Object\ReferentialIntegrityService + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Db\Register + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Dto\DeletionAnalysis */ class ReferentialIntegrityKeyReferenceTest extends TestCase { diff --git a/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php b/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php index acbf38c926..735a609cb6 100644 --- a/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php +++ b/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php @@ -29,6 +29,13 @@ * `referenceSchema` + `referenceKey` + `onDelete: RESTRICT` is a valid property; other actions are not. * * @covers \OCA\OpenRegister\Service\Schemas\PropertyValidatorHandler + * @uses \OCA\OpenRegister\Service\Schemas\CodedChoiceDeclaration + * @uses \OCA\OpenRegister\Service\Schemas\GeneratedIdentifierDeclaration + * @uses \OCA\OpenRegister\Service\Schemas\PropertySourceDeclaration + * @uses \OCA\OpenRegister\Service\Schemas\ReferenceFilterDeclaration + * @uses \OCA\OpenRegister\Service\Schemas\RepeatingGroupDeclarationValidator + * @uses \OCA\OpenRegister\Service\Schemas\ScopedPropertyDeclaration + * @uses \OCA\OpenRegister\Service\Vocabulary\CodedPropertyDeclarationFactory */ class PropertyValidatorReferenceKeyTest extends TestCase { From 1a70c1c76d1c153e245a2ce96d8c097f14aa1867 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:50:30 +0200 Subject: [PATCH 067/183] fix(ci): declare the class CasePlanServiceTest executes (@uses) --- tests/Unit/Service/Case/CasePlanServiceTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Service/Case/CasePlanServiceTest.php b/tests/Unit/Service/Case/CasePlanServiceTest.php index 178d19711c..d1902ff470 100644 --- a/tests/Unit/Service/Case/CasePlanServiceTest.php +++ b/tests/Unit/Service/Case/CasePlanServiceTest.php @@ -65,6 +65,7 @@ * @uses \OCA\OpenRegister\Service\Case\CaseSentryEvaluator * @uses \OCA\OpenRegister\Service\Flow\EventCatalogService * @uses \OCA\OpenRegister\Service\Flow\FlowExpression + * @uses \OCA\OpenRegister\Service\Case\CasePlanEnsurer */ class CasePlanServiceTest extends TestCase { From 27a05241e3b1a3195445d2d038445e1e20edfc30 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:50:32 +0200 Subject: [PATCH 068/183] fix(ci): declare the classes the new tests execute (@uses) so they record coverage --- tests/Unit/Service/Form/FormSubmitServiceTest.php | 2 ++ tests/Unit/Service/Task/TaskFormReaderTest.php | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php index d131f4800b..707cc07563 100644 --- a/tests/Unit/Service/Form/FormSubmitServiceTest.php +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -55,6 +55,8 @@ * @uses \OCA\OpenRegister\Db\Schema * @uses \OCA\OpenRegister\Db\Register * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Exception\CustomValidationException + * @uses \OCA\OpenRegister\Exception\NotAuthorizedException */ class FormSubmitServiceTest extends TestCase { diff --git a/tests/Unit/Service/Task/TaskFormReaderTest.php b/tests/Unit/Service/Task/TaskFormReaderTest.php index 0c31fc098d..b16911d329 100644 --- a/tests/Unit/Service/Task/TaskFormReaderTest.php +++ b/tests/Unit/Service/Task/TaskFormReaderTest.php @@ -32,6 +32,7 @@ * @covers \OCA\OpenRegister\Service\Task\TaskFormReader * @covers \OCA\OpenRegister\Service\Task\TaskForm * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Service\Form\FormDestinationValidator */ class TaskFormReaderTest extends TestCase { From 874efca6e09001354aa3c0ad30ae3f9fc5a636f1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:50:33 +0200 Subject: [PATCH 069/183] fix(ci): declare the classes the new tests execute (@uses) so they record coverage --- tests/Unit/Service/Task/TaskBlockedByCreateTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Unit/Service/Task/TaskBlockedByCreateTest.php b/tests/Unit/Service/Task/TaskBlockedByCreateTest.php index 63a02d05cc..f43f3c910a 100644 --- a/tests/Unit/Service/Task/TaskBlockedByCreateTest.php +++ b/tests/Unit/Service/Task/TaskBlockedByCreateTest.php @@ -44,6 +44,8 @@ * @uses \OCA\OpenRegister\Service\Task\TaskBuilder * @uses \OCA\OpenRegister\Service\Task\TaskPriority * @uses \OCA\OpenRegister\Service\Task\TaskState + * @uses \OCA\OpenRegister\Db\TaskAudit + * @uses \OCA\OpenRegister\Service\Task\TaskBlockerGuard */ class TaskBlockedByCreateTest extends TestCase { From 6051cb19d068c0ed31333aa952251ce0cc046624 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:50:52 +0200 Subject: [PATCH 070/183] fix(ci): declare the classes the new tests execute (@uses) so they record coverage --- tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php | 6 ++++++ tests/Unit/Service/Form/FormSubmitServiceTest.php | 2 ++ 2 files changed, 8 insertions(+) diff --git a/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php b/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php index a5dced671a..8dc85d0b98 100644 --- a/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php +++ b/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php @@ -33,6 +33,12 @@ * * @covers \OCA\OpenRegister\Db\MagicMapper\MagicRbacHandler * @uses \OCA\OpenRegister\Service\Object\DraftStatusPolicy + * @uses \OCA\OpenRegister\Db\MagicMapper\RbacResolvers + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Service\Object\PermissionHandler + * @uses \OCA\OpenRegister\Service\Rbac\DenyEnforcementMode + * @uses \OCA\OpenRegister\Service\Rbac\DenyResolver + * @uses \OCA\OpenRegister\Service\Rbac\ObjectScopeResolver */ class MagicRbacDraftVisibilityTest extends TestCase { diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php index 52bbaa23fb..8bf2cd2e82 100644 --- a/tests/Unit/Service/Form/FormSubmitServiceTest.php +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -60,6 +60,8 @@ * @uses \OCA\OpenRegister\Db\ObjectEntity * @uses \OCA\OpenRegister\Exception\CustomValidationException * @uses \OCA\OpenRegister\Exception\NotAuthorizedException + * @uses \OCA\OpenRegister\Event\ObjectActivatedEvent + * @uses \OCA\OpenRegister\Service\Object\DraftStatusPolicy */ class FormSubmitServiceTest extends TestCase { From 12d6351f3b7e9d3054974220ab63dcfdc05f68e4 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:52:42 +0200 Subject: [PATCH 071/183] fix(ci): cover the listener's fail-open branches and the refusal reason (coverage guard) --- .../Listener/FrozenNodeWriteListenerTest.php | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/Unit/Listener/FrozenNodeWriteListenerTest.php b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php index 3f90b1198a..364a4d31be 100644 --- a/tests/Unit/Listener/FrozenNodeWriteListenerTest.php +++ b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php @@ -185,6 +185,103 @@ public function testANodeOutsideTheRegisterTreeIsIgnored(): void { $this->addToAssertionCount(1); }//end testANodeOutsideTheRegisterTreeIsIgnored() + public function testANodeWhosePathCannotBeReadIsNotRefused(): void { + $node = $this->createMock(File::class); + $node->method('getPath')->willThrowException(new \RuntimeException('gone')); + $this->objects->expects($this->never())->method('findAcrossAllSources'); + + $this->listener()->handle(new BeforeNodeWrittenEvent($node)); + $this->addToAssertionCount(1); + }//end testANodeWhosePathCannotBeReadIsNotRefused() + + public function testAFileDirectlyUnderFilesHasNoOwnerObject(): void { + $files = $this->folder('files', 2, '/openregister/files', null); + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('loose.pdf'); + $file->method('getPath')->willReturn('/openregister/files/loose.pdf'); + $file->method('getParent')->willReturn($files); + $this->objects->expects($this->never())->method('findAcrossAllSources'); + + $this->listener()->handle(new BeforeNodeWrittenEvent($file)); + $this->addToAssertionCount(1); + }//end testAFileDirectlyUnderFilesHasNoOwnerObject() + + public function testAFileWhoseParentCannotBeReadHasNoOwnerObject(): void { + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('orphan.pdf'); + $file->method('getPath')->willReturn('/openregister/files/x/orphan.pdf'); + $file->method('getParent')->willThrowException(new \RuntimeException('no parent')); + $this->objects->expects($this->never())->method('findAcrossAllSources'); + + $this->listener()->handle(new BeforeNodeWrittenEvent($file)); + $this->addToAssertionCount(1); + }//end testAFileWhoseParentCannotBeReadHasNoOwnerObject() + + public function testAFolderNamedLikeAnObjectWhoseIdCannotBeReadIsNotTheOwner(): void { + $objectFolder = $this->createMock(Folder::class); + $objectFolder->method('getName')->willReturn(self::UUID); + $objectFolder->method('getId')->willThrowException(new \RuntimeException('no id')); + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('a.pdf'); + $file->method('getPath')->willReturn('/openregister/files/r/' . self::UUID . '/a.pdf'); + $file->method('getParent')->willReturn($objectFolder); + $this->objects->expects($this->never())->method('findAcrossAllSources'); + + $this->listener()->handle(new BeforeNodeWrittenEvent($file)); + $this->addToAssertionCount(1); + }//end testAFolderNamedLikeAnObjectWhoseIdCannotBeReadIsNotTheOwner() + + public function testTheOwnerSearchStopsAfterTheDepthLimit(): void { + // A chain deeper than the limit of eight levels, none of them named like a uuid. + $node = $this->folder('deepest', 100, '/openregister/files/a', null); + for ($level = 0; $level < 10; $level++) { + $parent = $this->createMock(Folder::class); + $parent->method('getName')->willReturn('level' . $level); + $parent->method('getParent')->willReturn($node); + $node = $parent; + } + + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('deep.pdf'); + $file->method('getPath')->willReturn('/openregister/files/a/b/deep.pdf'); + $file->method('getParent')->willReturn($node); + $this->objects->expects($this->never())->method('findAcrossAllSources'); + + $this->listener()->handle(new BeforeNodeWrittenEvent($file)); + $this->addToAssertionCount(1); + }//end testTheOwnerSearchStopsAfterTheDepthLimit() + + public function testAnAnswerWithoutAnObjectIsNotRefused(): void { + $this->objects->method('findAcrossAllSources')->willReturn(['object' => null]); + + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileInObjectFolder())); + $this->addToAssertionCount(1); + }//end testAnAnswerWithoutAnObjectIsNotRefused() + + public function testTheLogLineSurvivesAFileWhoseIdCannotBeRead(): void { + $files = $this->folder('files', 2, '/openregister/files', null); + $objectFolder = $this->folder(self::UUID, self::FOLDER_ID, '/openregister/files/' . self::UUID, $files); + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('new.pdf'); + $file->method('getId')->willThrowException(new \RuntimeException('not stored yet')); + $file->method('getPath')->willReturn('/openregister/files/' . self::UUID . '/new.pdf'); + $file->method('getParent')->willReturn($objectFolder); + $this->objects->method('findAcrossAllSources')->willThrowException(new DbException('connection lost')); + $this->logger->expects($this->once())->method('warning')->with( + $this->anything(), + $this->callback(static fn (array $context): bool => $context['node'] === null) + ); + + $this->expectException(HintException::class); + $this->listener()->handle(new BeforeNodeWrittenEvent($file)); + }//end testTheLogLineSurvivesAFileWhoseIdCannotBeRead() + + public function testTheRefusalCarriesTheReasonFromTheMarker(): void { + $refusal = \OCA\OpenRegister\Exception\ObjectStateWriteException::frozen($this->frozenObject()); + + $this->assertSame('geleverd', $refusal->getReason()); + }//end testTheRefusalCarriesTheReasonFromTheMarker() + public function testAnOtherEventIsIgnored(): void { $this->objects->expects($this->never())->method('findAcrossAllSources'); From 6f458092871a2b71e9ffa6e6aafb906a6a2a9f41 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 08:58:18 +0200 Subject: [PATCH 072/183] feat(records): a user saves and shares a named record template records-saved-templates tasks 1.1 and 1.2 (backend). RecordTemplate entity and RecordTemplateMapper over a new openregister_record_templates table (migration Version1Date20261011090000; the values column is template_values because VALUES is an SQL keyword). /api/record-templates index, show, create, update, patch and destroy beside /api/views. Sharing reads like a saved view (owner, isPublic, sharedWith through ViewShareResolver). Use needs create rights on the template's schema, so a public or group-shared template is not offered to a read-only member and a direct read answers 404. Only the owner or an administrator changes or deletes one. Input checks live in RecordTemplateInput. --- appinfo/info.xml | 2 +- appinfo/routes.php | 7 + lib/Controller/RecordTemplatesController.php | 247 +++++++++++++ lib/Db/RecordTemplate.php | 270 ++++++++++++++ lib/Db/RecordTemplateMapper.php | 184 ++++++++++ lib/Exception/RecordTemplateRefused.php | 65 ++++ lib/Migration/Version1Date20261011090000.php | 93 +++++ .../RecordTemplate/RecordTemplateInput.php | 177 +++++++++ .../RecordTemplate/RecordTemplateService.php | 342 ++++++++++++++++++ .../RecordTemplatesControllerTest.php | 161 +++++++++ tests/Unit/Db/RecordTemplateTest.php | 100 +++++ .../Version1Date20261011090000Test.php | 86 +++++ .../RecordTemplateInputTest.php | 102 ++++++ .../RecordTemplateServiceTest.php | 336 +++++++++++++++++ 14 files changed, 2171 insertions(+), 1 deletion(-) create mode 100644 lib/Controller/RecordTemplatesController.php create mode 100644 lib/Db/RecordTemplate.php create mode 100644 lib/Db/RecordTemplateMapper.php create mode 100644 lib/Exception/RecordTemplateRefused.php create mode 100644 lib/Migration/Version1Date20261011090000.php create mode 100644 lib/Service/RecordTemplate/RecordTemplateInput.php create mode 100644 lib/Service/RecordTemplate/RecordTemplateService.php create mode 100644 tests/Unit/Controller/RecordTemplatesControllerTest.php create mode 100644 tests/Unit/Db/RecordTemplateTest.php create mode 100644 tests/Unit/Migration/Version1Date20261011090000Test.php create mode 100644 tests/Unit/Service/RecordTemplate/RecordTemplateInputTest.php create mode 100644 tests/Unit/Service/RecordTemplate/RecordTemplateServiceTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 611e320354..17958090bd 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.1.35-unstable.20261009130000 + 2.1.35-unstable.20261011090000 EUPL-1.2 Conduction OpenRegister diff --git a/appinfo/routes.php b/appinfo/routes.php index 417625e13e..5276286ee9 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1837,6 +1837,13 @@ ['name' => 'views#update', 'url' => '/api/views/{id}', 'verb' => 'PUT', 'requirements' => ['id' => '[^/]+']], ['name' => 'views#patch', 'url' => '/api/views/{id}', 'verb' => 'PATCH', 'requirements' => ['id' => '[^/]+']], ['name' => 'views#destroy', 'url' => '/api/views/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+']], + // Record templates (records-saved-templates D-4): named values for a new record, shared like a view. + ['name' => 'recordTemplates#index', 'url' => '/api/record-templates', 'verb' => 'GET'], + ['name' => 'recordTemplates#show', 'url' => '/api/record-templates/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], + ['name' => 'recordTemplates#create', 'url' => '/api/record-templates', 'verb' => 'POST'], + ['name' => 'recordTemplates#update', 'url' => '/api/record-templates/{id}', 'verb' => 'PUT', 'requirements' => ['id' => '[^/]+']], + ['name' => 'recordTemplates#patch', 'url' => '/api/record-templates/{id}', 'verb' => 'PATCH', 'requirements' => ['id' => '[^/]+']], + ['name' => 'recordTemplates#destroy', 'url' => '/api/record-templates/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+']], // Read-only presentation data — drag-to-move goes through the existing // guarded object PATCH/PUT (/api/objects/{register}/{schema}/{id}), never // a bespoke endpoint here (REQ-VIEW-KANBAN-03). diff --git a/lib/Controller/RecordTemplatesController.php b/lib/Controller/RecordTemplatesController.php new file mode 100644 index 0000000000..b1a83381c2 --- /dev/null +++ b/lib/Controller/RecordTemplatesController.php @@ -0,0 +1,247 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\Exception\RecordTemplateRefused; +use OCA\OpenRegister\Service\RecordTemplate\RecordTemplateService; +use OCA\OpenRegister\Service\Rbac\ViewerReach; +use OCA\OpenRegister\Service\Rbac\ViewerReachResolver; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Index, show, create, update, patch and destroy for record templates. + * + * Every per-template decision (may use, may change) is RecordTemplateService's; + * this controller only names the caller and maps a refusal to its status. + * + * @psalm-suppress UnusedClass Reached through appinfo/routes.php. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ +class RecordTemplatesController extends Controller { + + /** + * Constructor. + * + * @param string $appName The app name. + * @param IRequest $request The request. + * @param RecordTemplateService $templates Decides and stores. + * @param ViewerReachResolver $viewers Names the caller and their groups. + * @param LoggerInterface $logger Logger. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function __construct( + string $appName, + IRequest $request, + private readonly RecordTemplateService $templates, + private readonly ViewerReachResolver $viewers, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * The templates the caller may use, optionally for one register and schema. + * + * @return JSONResponse `{results, total}`, 401 without a session. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function index(): JSONResponse { + return $this->answer( + action: fn (ViewerReach $reach): array => $this->listed( + templates: $this->templates->listFor( + reach: $reach, + register: $this->filter(name: 'register'), + schema: $this->filter(name: 'schema') + ) + ) + ); + }//end index() + + /** + * One template the caller may use. + * + * @param string $id The template uuid. + * + * @return JSONResponse The template, 404 when absent or not usable. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function show(string $id): JSONResponse { + return $this->answer(action: fn (ViewerReach $reach): array => $this->templates->get(uuid: $id, reach: $reach)->jsonSerialize()); + }//end show() + + /** + * Store a template owned by the caller. + * + * @return JSONResponse 201 with the template; 400, 403 as refused. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + #[NoAdminRequired] + public function create(): JSONResponse { + return $this->answer( + action: fn (ViewerReach $reach): array => $this->templates->create(data: $this->request->getParams(), reach: $reach)->jsonSerialize(), + success: 201 + ); + }//end create() + + /** + * Replace a template the caller owns (administrators: any). + * + * @param string $id The template uuid. + * + * @return JSONResponse The template; 400, 403, 404 as refused. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + #[NoAdminRequired] + public function update(string $id): JSONResponse { + return $this->answer( + action: fn (ViewerReach $reach): array => $this->templates->change( + uuid: $id, + data: $this->request->getParams(), + reach: $reach, + partial: false + )->jsonSerialize() + ); + }//end update() + + /** + * Change the fields sent on a template the caller owns (administrators: any). + * + * @param string $id The template uuid. + * + * @return JSONResponse The template; 400, 403, 404 as refused. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + #[NoAdminRequired] + public function patch(string $id): JSONResponse { + return $this->answer( + action: fn (ViewerReach $reach): array => $this->templates->change( + uuid: $id, + data: $this->request->getParams(), + reach: $reach, + partial: true + )->jsonSerialize() + ); + }//end patch() + + /** + * Delete a template the caller owns (administrators: any). + * + * @param string $id The template uuid. + * + * @return JSONResponse 204; 403, 404 as refused. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + #[NoAdminRequired] + public function destroy(string $id): JSONResponse { + return $this->answer( + action: function (ViewerReach $reach) use ($id): array { + $this->templates->delete(uuid: $id, reach: $reach); + return []; + }, + success: 204 + ); + }//end destroy() + + /** + * Run an action for the signed-in caller and map its outcome to a response. + * + * @param callable(ViewerReach): array $action The action. + * @param int $success The status on success. + * + * @return JSONResponse The response. + */ + private function answer(callable $action, int $success=200): JSONResponse { + $userId = $this->viewers->currentUid(); + if ($userId === '') { + return new JSONResponse(data: ['error' => 'User not authenticated'], statusCode: 401); + } + + try { + return new JSONResponse(data: $action($this->viewers->reachOf(userId: $userId)), statusCode: $success); + } catch (RecordTemplateRefused $e) { + $body = ['error' => $e->getMessage()]; + if ($e->getFindings() !== []) { + $body['findings'] = $e->getFindings(); + } + + return new JSONResponse(data: $body, statusCode: $e->getCode()); + } catch (Throwable $e) { + $this->logger->error('[RecordTemplatesController] Record template request failed', ['exception' => $e->getMessage()]); + + return new JSONResponse(data: ['error' => 'The record template request failed.'], statusCode: 500); + } + }//end answer() + + /** + * The templates as the index answers them. + * + * @param array $templates The templates. + * + * @return array{results: array>, total: int} The body. + */ + private function listed(array $templates): array { + return [ + 'results' => array_map(static fn ($template): array => $template->jsonSerialize(), $templates), + 'total' => count($templates), + ]; + }//end listed() + + /** + * One optional query filter. + * + * @param string $name The parameter name. + * + * @return string|null The value, or null when absent or empty. + */ + private function filter(string $name): ?string { + $value = trim((string)$this->request->getParam(key: $name, default: '')); + if ($value === '') { + return null; + } + + return $value; + }//end filter() +}//end class diff --git a/lib/Db/RecordTemplate.php b/lib/Db/RecordTemplate.php new file mode 100644 index 0000000000..786e3273cc --- /dev/null +++ b/lib/Db/RecordTemplate.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 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * A record template: values for a new record of one schema. + * + * The values are data, not a half-saved object (design D-2): a record started + * from a template is saved through the normal save path, so validation, + * defaults and RBAC apply there once. + * + * @method string|null getUuid() + * @method void setUuid(?string $uuid) + * @method string|null getName() + * @method void setName(?string $name) + * @method string|null getDescription() + * @method void setDescription(?string $description) + * @method string|null getRegister() + * @method void setRegister(?string $register) + * @method string|null getSchema() + * @method void setSchema(?string $schema) + * @method string|null getOwner() + * @method void setOwner(?string $owner) + * @method bool getIsPublic() + * @method void setIsPublic(bool $isPublic) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * @method DateTime|null getUpdated() + * @method void setUpdated(?DateTime $updated) + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ +class RecordTemplate extends Entity implements JsonSerializable { + + /** + * The template's own uuid. + * + * @var string|null + */ + protected ?string $uuid = null; + + /** + * The name a user picks it by. + * + * @var string|null + */ + protected ?string $name = null; + + /** + * What the template is for. + * + * @var string|null + */ + protected ?string $description = null; + + /** + * The register id the template's schema lives in. + * + * @var string|null + */ + protected ?string $register = null; + + /** + * The schema id a record started from it is made in. + * + * @var string|null + */ + protected ?string $schema = null; + + /** + * The values a new record starts with, keyed by property name. + * + * Stored as `template_values`: VALUES is an SQL keyword. + * + * @var array|null + */ + protected ?array $templateValues = []; + + /** + * The user who saved it. + * + * @var string|null + */ + protected ?string $owner = null; + + /** + * Whether everyone who may create in the schema may use it. + * + * @var boolean + */ + protected bool $isPublic = false; + + /** + * The groups it is shared with, as `{group, mode}` like a saved view. + * + * @var array|null + */ + protected ?array $sharedWith = []; + + /** + * When it was saved. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * When it last changed. + * + * @var DateTime|null + */ + protected ?DateTime $updated = null; + + /** + * The access the CALLER holds on it, for this request only. + * + * Transient: it belongs to the pair (template, caller), not to the template. + * + * @var string|null + */ + private ?string $access = null; + + /** + * Declare the column types. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function __construct() { + $this->addType(fieldName: 'uuid', type: 'string'); + $this->addType(fieldName: 'name', type: 'string'); + $this->addType(fieldName: 'description', type: 'string'); + $this->addType(fieldName: 'register', type: 'string'); + $this->addType(fieldName: 'schema', type: 'string'); + $this->addType(fieldName: 'templateValues', type: 'json'); + $this->addType(fieldName: 'owner', type: 'string'); + $this->addType(fieldName: 'isPublic', type: 'boolean'); + $this->addType(fieldName: 'sharedWith', type: 'json'); + $this->addType(fieldName: 'created', type: 'datetime'); + $this->addType(fieldName: 'updated', type: 'datetime'); + }//end __construct() + + /** + * The values a new record starts with. + * + * @return array The values, empty when none are kept. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function getValues(): array { + return ($this->templateValues ?? []); + }//end getValues() + + /** + * Set the values a new record starts with. + * + * @param array $values The values, keyed by property name. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function setValues(array $values): void { + $this->templateValues = $values; + $this->markFieldUpdated(attribute: 'templateValues'); + }//end setValues() + + /** + * The groups it is shared with. + * + * @return array The shares, empty when shared with nobody. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function getSharedWith(): array { + return ($this->sharedWith ?? []); + }//end getSharedWith() + + /** + * Set the groups it is shared with. + * + * @param array $sharedWith The shares. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function setSharedWith(array $sharedWith): void { + $this->sharedWith = $sharedWith; + $this->markFieldUpdated(attribute: 'sharedWith'); + }//end setSharedWith() + + /** + * The access the caller holds, when somebody resolved it. + * + * @return string|null One of owner, write, read, or null when unresolved. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function getAccess(): ?string { + return $this->access; + }//end getAccess() + + /** + * Record the access a caller holds, for this request only. + * + * @param string|null $access The resolved access. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function setAccess(?string $access): void { + $this->access = $access; + }//end setAccess() + + /** + * The template as the API answers it. + * + * @return array The template, with `@self.access` for the caller. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'name' => $this->name, + 'description' => $this->description, + 'register' => $this->register, + 'schema' => $this->schema, + 'values' => $this->getValues(), + 'owner' => $this->owner, + 'isPublic' => $this->isPublic, + 'sharedWith' => $this->getSharedWith(), + // Absent rather than guessed when nobody resolved it, as on a view. + '@self' => ['access' => $this->access], + 'created' => $this->created?->format(format: 'c'), + 'updated' => $this->updated?->format(format: 'c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/RecordTemplateMapper.php b/lib/Db/RecordTemplateMapper.php new file mode 100644 index 0000000000..5117310619 --- /dev/null +++ b/lib/Db/RecordTemplateMapper.php @@ -0,0 +1,184 @@ + + * @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/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use InvalidArgumentException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Symfony\Component\Uid\Uuid; + +/** + * Stores record templates in `openregister_record_templates`. + * + * The mapper answers rows; WHO may use one is decided by + * RecordTemplateService, the same split a saved view has between its mapper + * and ViewShareResolver. + * + * @method RecordTemplate findEntity(IQueryBuilder $query) + * @method list findEntities(IQueryBuilder $query) + * + * @template-extends QBMapper + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ +class RecordTemplateMapper extends QBMapper { + + /** + * The table. + */ + public const TABLE = 'openregister_record_templates'; + + /** + * Constructor. + * + * @param IDBConnection $db Database connection. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: self::TABLE, entityClass: RecordTemplate::class); + }//end __construct() + + /** + * One template by its uuid. + * + * @param string $uuid The template uuid. + * + * @return RecordTemplate The template. + * + * @throws DoesNotExistException When no template has that uuid. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function findByUuid(string $uuid): RecordTemplate { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid, IQueryBuilder::PARAM_STR))); + + return $this->findEntity(query: $qb); + }//end findByUuid() + + /** + * The templates a caller might reach: their own, the public ones and every + * shared one, optionally for one register and schema. + * + * The SQL narrows; the share itself is judged in PHP by the caller's groups, + * as for saved views, because a template list is tens of rows, and one + * reading of `sharedWith` is safer than four JSON dialects agreeing on an + * authorization question. + * + * @param string $userId The caller. + * @param string|null $register Only templates for this register id. + * @param string|null $schema Only templates for this schema id. + * + * @return RecordTemplate[] The candidates, newest first. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function findCandidates(string $userId, ?string $register=null, ?string $schema=null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->orX( + $qb->expr()->eq('owner', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('is_public', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)), + $qb->expr()->isNotNull('shared_with') + ) + ) + ->orderBy('created', 'DESC'); + + if ($register !== null && $register !== '') { + $qb->andWhere($qb->expr()->eq('register', $qb->createNamedParameter($register, IQueryBuilder::PARAM_STR))); + } + + if ($schema !== null && $schema !== '') { + $qb->andWhere($qb->expr()->eq('schema', $qb->createNamedParameter($schema, IQueryBuilder::PARAM_STR))); + } + + return $this->findEntities(query: $qb); + }//end findCandidates() + + /** + * Store a new template with its uuid and timestamps. + * + * @param Entity $entity The template. + * + * @return RecordTemplate The stored template. + * + * @SuppressWarnings(PHPMD.StaticAccess) Uuid::v4 is the standard Symfony UID pattern. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function insert(Entity $entity): RecordTemplate { + $entity = $this->template(entity: $entity); + if (empty($entity->getUuid()) === true) { + $entity->setUuid((string)Uuid::v4()); + } + + $entity->setCreated(new DateTime()); + $entity->setUpdated(new DateTime()); + + return parent::insert(entity: $entity); + }//end insert() + + /** + * Store a changed template with a new `updated`. + * + * @param Entity $entity The template. + * + * @return RecordTemplate The stored template. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function update(Entity $entity): RecordTemplate { + $entity = $this->template(entity: $entity); + $entity->setUpdated(new DateTime()); + + return parent::update(entity: $entity); + }//end update() + + /** + * The entity as a template, or a refusal for anything else. + * + * @param Entity $entity The entity handed in. + * + * @return RecordTemplate The template. + * + * @throws InvalidArgumentException When it is not a record template. + */ + private function template(Entity $entity): RecordTemplate { + if ($entity instanceof RecordTemplate === false) { + throw new InvalidArgumentException('RecordTemplateMapper stores record templates only.'); + } + + return $entity; + }//end template() +}//end class diff --git a/lib/Exception/RecordTemplateRefused.php b/lib/Exception/RecordTemplateRefused.php new file mode 100644 index 0000000000..71c866840f --- /dev/null +++ b/lib/Exception/RecordTemplateRefused.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 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://www.OpenRegister.app + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * Raised when a record template request is refused. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ +class RecordTemplateRefused extends RuntimeException { + + /** + * Constructor. + * + * @param int $status The HTTP status: 400, 403 or 404. + * @param string $message The sentence the caller reads. + * @param array $findings What was wrong with the input, for a 400. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function __construct( + int $status, + string $message, + private readonly array $findings=[], + ) { + parent::__construct(message: $message, code: $status); + }//end __construct() + + /** + * What was wrong with the input. + * + * @return array The findings, empty unless a 400. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function getFindings(): array { + return $this->findings; + }//end getFindings() +}//end class diff --git a/lib/Migration/Version1Date20261011090000.php b/lib/Migration/Version1Date20261011090000.php new file mode 100644 index 0000000000..dd1e0f5091 --- /dev/null +++ b/lib/Migration/Version1Date20261011090000.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Record templates (records-saved-templates D-1): a new table + * `openregister_record_templates` beside the saved views, with the same three + * sharing columns (owner, is_public, shared_with) plus the register, the + * schema, a name, a description and the values a new record starts with. + * The values column is `template_values` because VALUES is an SQL keyword. + * + * @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/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Creates `openregister_record_templates`. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ +class Version1Date20261011090000 extends SimpleMigrationStep { + + /** + * The table. + */ + public const TABLE = 'openregister_record_templates'; + + /** + * Create the table when it is absent. + * + * Booleans are nullable because Nextcloud refuses a NOT NULL boolean. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the schema wrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The schema. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The signature is Nextcloud's. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + + $schema = $schemaClosure(); + if ($schema->hasTable(self::TABLE) === true) { + return $schema; + } + + $table = $schema->createTable(self::TABLE); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20]); + $table->addColumn('uuid', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('description', Types::TEXT, ['notnull' => false]); + $table->addColumn('register', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('schema', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('template_values', Types::TEXT, ['notnull' => false]); + $table->addColumn('owner', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('is_public', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + $table->addColumn('shared_with', Types::TEXT, ['notnull' => false]); + $table->addColumn('created', Types::DATETIME, ['notnull' => false]); + $table->addColumn('updated', Types::DATETIME, ['notnull' => false]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['uuid'], 'or_rectpl_uuid'); + $table->addIndex(['register', 'schema'], 'or_rectpl_reg_schema'); + $table->addIndex(['owner'], 'or_rectpl_owner'); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Service/RecordTemplate/RecordTemplateInput.php b/lib/Service/RecordTemplate/RecordTemplateInput.php new file mode 100644 index 0000000000..4523b452d5 --- /dev/null +++ b/lib/Service/RecordTemplate/RecordTemplateInput.php @@ -0,0 +1,177 @@ + + * @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/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\RecordTemplate; + +use OCA\OpenRegister\Db\RecordTemplate; +use OCA\OpenRegister\Exception\RecordTemplateRefused; + +/** + * Checks and applies the writable fields of a record template. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ +class RecordTemplateInput { + + /** + * The longest name a template carries. + */ + public const NAME_MAX = 255; + + /** + * The fields a caller may set. + */ + public const WRITABLE = ['name', 'description', 'register', 'schema', 'values', 'isPublic', 'sharedWith']; + + /** + * The fields a full write must carry. + */ + public const REQUIRED = ['name', 'register', 'schema']; + + /** + * The writable fields of a request, checked. + * + * @param array $data The request body. + * @param bool $partial True when absent fields stay as they are (PATCH). + * @param callable(mixed): array $shareFindings Checks `sharedWith`. + * + * @return array The fields to set, text fields trimmed. + * + * @throws RecordTemplateRefused 400 naming every finding. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) PUT and PATCH differ only in this. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function validated(array $data, bool $partial, callable $shareFindings): array { + $fields = array_intersect_key($data, array_flip(self::WRITABLE)); + $findings = []; + + foreach (self::REQUIRED as $required) { + if ($partial === true && array_key_exists($required, $fields) === false) { + continue; + } + + $text = $this->text(value: ($fields[$required] ?? null)); + if ($text === '') { + $findings[] = ['code' => $required . '.required', 'message' => $required . ' is required.']; + continue; + } + + $fields[$required] = $text; + } + + $findings = array_merge($findings, $this->shapeFindings(fields: $fields)); + if (array_key_exists('sharedWith', $fields) === true) { + $findings = array_merge($findings, $shareFindings($fields['sharedWith'])); + } + + if ($findings !== []) { + throw new RecordTemplateRefused(status: 400, message: 'The record template is not valid.', findings: $findings); + } + + return $fields; + }//end validated() + + /** + * Set the checked fields on the template. + * + * @param RecordTemplate $template The template. + * @param array $fields The checked fields. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function apply(RecordTemplate $template, array $fields): void { + $setters = [ + 'name' => fn (mixed $value) => $template->setName((string)$value), + 'register' => fn (mixed $value) => $template->setRegister((string)$value), + 'schema' => fn (mixed $value) => $template->setSchema((string)$value), + 'description' => fn (mixed $value) => $template->setDescription($this->text(value: $value)), + 'values' => fn (mixed $value) => $template->setValues((array)$value), + 'isPublic' => fn (mixed $value) => $template->setIsPublic((bool)$value), + 'sharedWith' => fn (mixed $value) => $template->setSharedWith((array)($value ?? [])), + ]; + + foreach ($setters as $field => $set) { + if (array_key_exists($field, $fields) === true) { + $set($fields[$field]); + } + } + }//end apply() + + /** + * What is wrong with the name length, the values and the public flag. + * + * @param array $fields The fields sent. + * + * @return array The findings. + */ + private function shapeFindings(array $fields): array { + $findings = []; + if (mb_strlen($this->text(value: ($fields['name'] ?? null))) > self::NAME_MAX) { + $findings[] = ['code' => 'name.too-long', 'message' => 'name is longer than ' . self::NAME_MAX . ' characters.']; + } + + if (array_key_exists('values', $fields) === true && $this->isObject(value: $fields['values']) === false) { + $findings[] = ['code' => 'values.not-an-object', 'message' => 'values must be an object keyed by property name.']; + } + + if (array_key_exists('isPublic', $fields) === true && is_bool($fields['isPublic']) === false) { + $findings[] = ['code' => 'isPublic.not-a-boolean', 'message' => 'isPublic must be true or false.']; + } + + return $findings; + }//end shapeFindings() + + /** + * Whether a value is a JSON object (an empty one included), not a list. + * + * @param mixed $value The value. + * + * @return bool True for an object keyed by name. + */ + private function isObject(mixed $value): bool { + return is_array($value) === true && ($value === [] || array_is_list($value) === false); + }//end isObject() + + /** + * A scalar as trimmed text, anything else as ''. + * + * @param mixed $value The value. + * + * @return string The text. + */ + private function text(mixed $value): string { + if (is_string($value) === false && is_int($value) === false) { + return ''; + } + + return trim((string)$value); + }//end text() +}//end class diff --git a/lib/Service/RecordTemplate/RecordTemplateService.php b/lib/Service/RecordTemplate/RecordTemplateService.php new file mode 100644 index 0000000000..5e0774f895 --- /dev/null +++ b/lib/Service/RecordTemplate/RecordTemplateService.php @@ -0,0 +1,342 @@ + + * @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/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\RecordTemplate; + +use OCA\OpenRegister\Db\RecordTemplate; +use OCA\OpenRegister\Db\RecordTemplateMapper; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\RecordTemplateRefused; +use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCA\OpenRegister\Service\Rbac\ViewerReach; +use OCA\OpenRegister\Service\Rbac\ViewerReachResolver; +use OCA\OpenRegister\Service\Rbac\ViewShareResolver; +use OCP\AppFramework\Db\DoesNotExistException; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Lists, stores, changes and deletes record templates for one caller. + * + * USE NEEDS CREATE RIGHTS. A template is a way to start a record, so a caller + * who may not create in its schema is not offered it, even when it is public + * or shared with their group: the list leaves it out and a direct read answers + * 404, never "it exists but you may not". CHANGE NEEDS OWNERSHIP: only the + * owner or an administrator changes or deletes one (design D-4); a group share + * is a way in to USE it, not to edit it. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Thirteen: the store, the schema read, the + * create-rights check, the share rules and their refusals; each is one question this class asks. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ +class RecordTemplateService { + + /** + * Reads the shares the way a saved view does. + * + * @var ViewShareResolver + */ + private readonly ViewShareResolver $shares; + + /** + * Checks and applies the writable fields. + * + * @var RecordTemplateInput + */ + private readonly RecordTemplateInput $input; + + /** + * Create rights per caller and schema, for this request. + * + * @var array + */ + private array $mayCreate = []; + + /** + * Constructor. + * + * @param RecordTemplateMapper $mapper The template store. + * @param SchemaMapper $schemas Reads the template's schema. + * @param PermissionHandler $permissions Answers whether a caller may create in a schema. + * @param ViewerReachResolver $viewers Validates shares against existing groups. + * @param LoggerInterface $logger Logger. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function __construct( + private readonly RecordTemplateMapper $mapper, + private readonly SchemaMapper $schemas, + private readonly PermissionHandler $permissions, + private readonly ViewerReachResolver $viewers, + private readonly LoggerInterface $logger, + ) { + $this->shares = new ViewShareResolver(); + $this->input = new RecordTemplateInput(); + }//end __construct() + + /** + * The templates the caller may use, each with the access they hold on it. + * + * @param ViewerReach $reach The caller. + * @param string|null $register Only templates for this register id. + * @param string|null $schema Only templates for this schema id. + * + * @return RecordTemplate[] The usable templates. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function listFor(ViewerReach $reach, ?string $register=null, ?string $schema=null): array { + $usable = []; + foreach ($this->mapper->findCandidates(userId: $reach->userId, register: $register, schema: $schema) as $template) { + if ($this->resolveUse(template: $template, reach: $reach) === true) { + $usable[] = $template; + } + } + + return $usable; + }//end listFor() + + /** + * One template the caller may use. + * + * @param string $uuid The template uuid. + * @param ViewerReach $reach The caller. + * + * @return RecordTemplate The template, with the caller's access. + * + * @throws RecordTemplateRefused 404 when it is absent or the caller may not use it. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function get(string $uuid, ViewerReach $reach): RecordTemplate { + $template = $this->find(uuid: $uuid); + if ($this->resolveUse(template: $template, reach: $reach) === false) { + throw new RecordTemplateRefused(status: 404, message: 'No such record template.'); + } + + return $template; + }//end get() + + /** + * Store a new template owned by the caller. + * + * @param array $data `{name, description?, register, schema, values?, isPublic?, sharedWith?}`. + * @param ViewerReach $reach The caller. + * + * @return RecordTemplate The stored template. + * + * @throws RecordTemplateRefused 400 when the input is unusable, 403 when the caller may not create in the schema. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function create(array $data, ViewerReach $reach): RecordTemplate { + $fields = $this->input->validated(data: $data, partial: false, shareFindings: $this->shareFindings(...)); + $this->assertMayCreate(schema: (string)$fields['schema'], reach: $reach); + + $template = new RecordTemplate(); + $this->input->apply(template: $template, fields: $fields); + $template->setOwner($reach->userId); + + $stored = $this->mapper->insert(entity: $template); + $stored->setAccess(ViewShareResolver::ACCESS_OWNER); + + return $stored; + }//end create() + + /** + * Change a template the caller owns, or any template for an administrator. + * + * @param string $uuid The template uuid. + * @param array $data The fields to set. + * @param ViewerReach $reach The caller. + * @param bool $partial True for PATCH (only the fields sent), false for PUT. + * + * @return RecordTemplate The stored template. + * + * @throws RecordTemplateRefused 404, 403 or 400. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) PUT and PATCH differ only in this. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function change(string $uuid, array $data, ViewerReach $reach, bool $partial): RecordTemplate { + $template = $this->administered(uuid: $uuid, reach: $reach); + $fields = $this->input->validated(data: $data, partial: $partial, shareFindings: $this->shareFindings(...)); + if (isset($fields['schema']) === true && $fields['schema'] !== $template->getSchema()) { + $this->assertMayCreate(schema: (string)$fields['schema'], reach: $reach); + } + + $this->input->apply(template: $template, fields: $fields); + $stored = $this->mapper->update(entity: $template); + $this->resolveUse(template: $stored, reach: $reach); + + return $stored; + }//end change() + + /** + * Delete a template the caller owns, or any template for an administrator. + * + * @param string $uuid The template uuid. + * @param ViewerReach $reach The caller. + * + * @return void + * + * @throws RecordTemplateRefused 404 or 403. + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function delete(string $uuid, ViewerReach $reach): void { + $this->mapper->delete(entity: $this->administered(uuid: $uuid, reach: $reach)); + }//end delete() + + /** + * Whether the caller may use the template; sets their access when they may. + * + * @param RecordTemplate $template The template. + * @param ViewerReach $reach The caller. + * + * @return bool True when they may use it. + */ + private function resolveUse(RecordTemplate $template, ViewerReach $reach): bool { + $access = $this->shares->accessFor(view: $template->jsonSerialize(), userId: $reach->userId, userGroups: $reach->groups); + if ($access === null && $reach->isAdmin === true) { + $access = ViewShareResolver::ACCESS_WRITE; + } + + if ($access === null || $this->mayCreateIn(schema: (string)$template->getSchema(), reach: $reach) === false) { + return false; + } + + $template->setAccess($access); + + return true; + }//end resolveUse() + + /** + * The template, when the caller may change it. + * + * @param string $uuid The template uuid. + * @param ViewerReach $reach The caller. + * + * @return RecordTemplate The template. + * + * @throws RecordTemplateRefused 404 when they may not even use it, 403 when they may use but not change it. + */ + private function administered(string $uuid, ViewerReach $reach): RecordTemplate { + $template = $this->get(uuid: $uuid, reach: $reach); + if ($this->shares->mayAdminister(view: $template->jsonSerialize(), userId: $reach->userId, isAdmin: $reach->isAdmin) === false) { + throw new RecordTemplateRefused(status: 403, message: 'Only the owner or an administrator may change this record template.'); + } + + return $template; + }//end administered() + + /** + * The stored template. + * + * @param string $uuid The template uuid. + * + * @return RecordTemplate The template. + * + * @throws RecordTemplateRefused 404 when it is absent. + */ + private function find(string $uuid): RecordTemplate { + try { + return $this->mapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException $e) { + throw new RecordTemplateRefused(status: 404, message: 'No such record template.'); + } + }//end find() + + /** + * Refuse a caller who may not create in the schema. + * + * @param string $schema The schema id. + * @param ViewerReach $reach The caller. + * + * @return void + * + * @throws RecordTemplateRefused 403 when they may not create there. + */ + private function assertMayCreate(string $schema, ViewerReach $reach): void { + if ($this->mayCreateIn(schema: $schema, reach: $reach) === false) { + throw new RecordTemplateRefused(status: 403, message: 'You may not create records in this schema, so you may not keep a template for it.'); + } + }//end assertMayCreate() + + /** + * Whether the caller may create records in the schema. + * + * A schema that cannot be read is no right to create: the template is then + * left out, never offered on a guess. + * + * @param string $schema The schema id. + * @param ViewerReach $reach The caller. + * + * @return bool True when they may. + */ + private function mayCreateIn(string $schema, ViewerReach $reach): bool { + if ($schema === '') { + return false; + } + + $key = $reach->userId . '|' . $schema; + if (array_key_exists($key, $this->mayCreate) === false) { + $verdict = false; + try { + $verdict = $this->permissions->hasPermission( + schema: $this->schemas->find(id: $schema, _rbac: false), + action: 'create', + userId: $reach->userId + ); + } catch (DoesNotExistException $e) { + $verdict = false; + } catch (Throwable $e) { + $this->logger->warning( + '[RecordTemplateService] Could not read create rights; the template is not offered.', + ['schema' => $schema, 'exception' => $e->getMessage()] + ); + } + + $this->mayCreate[$key] = $verdict; + } + + return $this->mayCreate[$key]; + }//end mayCreateIn() + + /** + * What is wrong with a share list, judged against the groups that exist. + * + * @param mixed $sharedWith The declared shares. + * + * @return array The findings. + */ + private function shareFindings(mixed $sharedWith): array { + return $this->viewers->shareFindings(sharedWith: $sharedWith); + }//end shareFindings() +}//end class diff --git a/tests/Unit/Controller/RecordTemplatesControllerTest.php b/tests/Unit/Controller/RecordTemplatesControllerTest.php new file mode 100644 index 0000000000..55d00f3aa0 --- /dev/null +++ b/tests/Unit/Controller/RecordTemplatesControllerTest.php @@ -0,0 +1,161 @@ + + * @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/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use OCA\OpenRegister\Controller\RecordTemplatesController; +use OCA\OpenRegister\Db\RecordTemplate; +use OCA\OpenRegister\Exception\RecordTemplateRefused; +use OCA\OpenRegister\Service\Rbac\ViewerReach; +use OCA\OpenRegister\Service\Rbac\ViewerReachResolver; +use OCA\OpenRegister\Service\RecordTemplate\RecordTemplateService; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * @covers \OCA\OpenRegister\Controller\RecordTemplatesController + */ +class RecordTemplatesControllerTest extends TestCase { + + /** + * The service double. + * + * @var RecordTemplateService&MockObject + */ + private RecordTemplateService&MockObject $templates; + + /** + * Set up the service double. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->templates = $this->createMock(RecordTemplateService::class); + }//end setUp() + + /** + * Without a session every route answers 401 and the service is not asked. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testWithoutASessionIs401(): void { + $this->templates->expects($this->never())->method('listFor'); + + self::assertSame(401, $this->controller(userId: '')->index()->getStatus()); + }//end testWithoutASessionIs401() + + /** + * The index passes the register and schema filters and answers `{results, total}`. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#scenario-a-caseworker-saves-a-melding-as-a-template + */ + public function testTheIndexFiltersBySchema(): void { + $template = new RecordTemplate(); + $template->setName('Melding wateroverlast'); + $this->templates->expects($this->once())->method('listFor') + ->with($this->isInstanceOf(ViewerReach::class), null, 'meldingen') + ->willReturn([$template]); + + $response = $this->controller(userId: 'bert', params: ['schema' => 'meldingen'])->index(); + + self::assertSame(200, $response->getStatus()); + self::assertSame(1, $response->getData()['total']); + self::assertSame('Melding wateroverlast', $response->getData()['results'][0]['name']); + }//end testTheIndexFiltersBySchema() + + /** + * A create answers 201; a refusal answers its own status with its findings. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testRefusalsKeepTheirStatus(): void { + $this->templates->method('create')->willReturnOnConsecutiveCalls( + new RecordTemplate(), + $this->throwException(new RecordTemplateRefused(status: 400, message: 'Not valid', findings: [['code' => 'name.required', 'message' => 'name is required.']])) + ); + $this->templates->method('change')->willThrowException(new RecordTemplateRefused(status: 403, message: 'Not yours')); + $this->templates->method('get')->willThrowException(new RecordTemplateRefused(status: 404, message: 'No such record template.')); + + $controller = $this->controller(userId: 'anna'); + self::assertSame(201, $controller->create()->getStatus()); + $refused = $controller->create(); + self::assertSame(400, $refused->getStatus()); + self::assertSame('name.required', $refused->getData()['findings'][0]['code']); + self::assertSame(403, $controller->patch(id: 'tpl-1')->getStatus()); + self::assertSame(403, $controller->update(id: 'tpl-1')->getStatus()); + self::assertSame(404, $controller->show(id: 'tpl-1')->getStatus()); + }//end testRefusalsKeepTheirStatus() + + /** + * A delete answers 204, and an unexpected failure 500 without its message. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testDeleteAnd500(): void { + $this->templates->expects($this->once())->method('delete')->with('tpl-1', $this->isInstanceOf(ViewerReach::class)); + $this->templates->method('listFor')->willThrowException(new RuntimeException('database password in here')); + + $controller = $this->controller(userId: 'anna'); + self::assertSame(204, $controller->destroy(id: 'tpl-1')->getStatus()); + $failed = $controller->index(); + self::assertSame(500, $failed->getStatus()); + self::assertStringNotContainsString('password', json_encode($failed->getData())); + }//end testDeleteAnd500() + + /** + * The controller for a caller. + * + * @param string $userId The caller, '' for none. + * @param array $params The request parameters. + * + * @return RecordTemplatesController The controller. + */ + private function controller(string $userId, array $params=[]): RecordTemplatesController { + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn($params); + $request->method('getParam')->willReturnCallback(static fn (string $key, mixed $default=null): mixed => ($params[$key] ?? $default)); + + $viewers = $this->createMock(ViewerReachResolver::class); + $viewers->method('currentUid')->willReturn($userId); + $viewers->method('reachOf')->willReturnCallback(static fn (string $userId): ViewerReach => new ViewerReach(userId: $userId, groups: [], isAdmin: false)); + + return new RecordTemplatesController( + appName: 'openregister', + request: $request, + templates: $this->templates, + viewers: $viewers, + logger: new NullLogger() + ); + }//end controller() +}//end class diff --git a/tests/Unit/Db/RecordTemplateTest.php b/tests/Unit/Db/RecordTemplateTest.php new file mode 100644 index 0000000000..454e9e8f8e --- /dev/null +++ b/tests/Unit/Db/RecordTemplateTest.php @@ -0,0 +1,100 @@ + + * @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/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\RecordTemplate; +use OCA\OpenRegister\Db\RecordTemplateMapper; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Db\RecordTemplate + * @covers \OCA\OpenRegister\Db\RecordTemplateMapper + */ +class RecordTemplateTest extends TestCase { + + /** + * The row's `template_values` and `shared_with` come back as the API's `values` and `sharedWith`. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testARowReadsBackAsTheApiShape(): void { + $template = RecordTemplate::fromRow( + [ + 'id' => 7, + 'uuid' => 'tpl-7', + 'name' => 'Melding wateroverlast', + 'description' => null, + 'register' => 'zaken', + 'schema' => 'meldingen', + 'template_values' => '{"categorie":"water"}', + 'owner' => 'anna', + 'is_public' => false, + 'shared_with' => '[{"group":"kcc","mode":"read"}]', + 'created' => null, + 'updated' => null, + ] + ); + + $json = $template->jsonSerialize(); + self::assertSame(['categorie' => 'water'], $json['values']); + self::assertSame([['group' => 'kcc', 'mode' => 'read']], $json['sharedWith']); + self::assertSame('meldingen', $json['schema']); + self::assertNull($json['@self']['access'], 'No access is guessed before somebody resolves it.'); + + $template->setAccess('owner'); + self::assertSame('owner', $template->jsonSerialize()['@self']['access']); + }//end testARowReadsBackAsTheApiShape() + + /** + * Values and shares that were never set answer empty lists, not null. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testUnsetValuesAndSharesAreEmpty(): void { + $template = new RecordTemplate(); + $template->setValues([]); + $template->setSharedWith([]); + + self::assertSame([], $template->getValues()); + self::assertSame([], $template->getSharedWith()); + self::assertArrayHasKey('templateValues', $template->getUpdatedFields()); + }//end testUnsetValuesAndSharesAreEmpty() + + /** + * The mapper stores in its own table. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testTheMapperOwnsItsTable(): void { + $mapper = new RecordTemplateMapper(db: $this->createMock(IDBConnection::class)); + + self::assertSame('openregister_record_templates', $mapper->getTableName()); + }//end testTheMapperOwnsItsTable() +}//end class diff --git a/tests/Unit/Migration/Version1Date20261011090000Test.php b/tests/Unit/Migration/Version1Date20261011090000Test.php new file mode 100644 index 0000000000..9d775d73e8 --- /dev/null +++ b/tests/Unit/Migration/Version1Date20261011090000Test.php @@ -0,0 +1,86 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Migration; + +use OCA\OpenRegister\Migration\Version1Date20261011090000; +use OCA\OpenRegister\Tests\Support\SchemaTableMockTrait; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; + +/** + * The record template table is created once, with the sharing columns of a + * saved view and index names Oracle accepts. + * + * @covers \OCA\OpenRegister\Migration\Version1Date20261011090000 + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ +class Version1Date20261011090000Test extends TestCase { + use SchemaTableMockTrait; + + /** + * A schema without the table gets it, with every column the entity maps. + * + * @return void + */ + public function testItCreatesTheTable(): void { + $columns = []; + $indexes = []; + $table = $this->createTableMock(); + $table->method('addColumn')->willReturnCallback( + function (string $name) use (&$columns) { + $columns[] = $name; + return $this->createColumnMock(); + } + ); + $table->method('addIndex')->willReturnCallback( + function (array $cols, string $name) use (&$indexes, $table) { + $indexes[] = $name; + return $table; + } + ); + $table->method('addUniqueIndex')->willReturnCallback( + function (array $cols, string $name) use (&$indexes, $table) { + $indexes[] = $name; + return $table; + } + ); + + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->willReturn(false); + $schema->expects($this->once())->method('createTable')->with('openregister_record_templates')->willReturn($table); + + $step = new Version1Date20261011090000(); + self::assertSame($schema, $step->changeSchema($this->createMock(IOutput::class), static fn () => $schema, [])); + + self::assertSame( + ['id', 'uuid', 'name', 'description', 'register', 'schema', 'template_values', 'owner', 'is_public', 'shared_with', 'created', 'updated'], + $columns + ); + foreach ($indexes as $name) { + self::assertLessThanOrEqual(30, strlen($name), $name . ' is too long for Oracle.'); + } + }//end testItCreatesTheTable() + + /** + * A second run finds the table and changes nothing. + * + * @return void + */ + public function testASecondRunChangesNothing(): void { + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->willReturn(true); + $schema->expects($this->never())->method('createTable'); + + $step = new Version1Date20261011090000(); + self::assertSame($schema, $step->changeSchema($this->createMock(IOutput::class), static fn () => $schema, [])); + }//end testASecondRunChangesNothing() +}//end class diff --git a/tests/Unit/Service/RecordTemplate/RecordTemplateInputTest.php b/tests/Unit/Service/RecordTemplate/RecordTemplateInputTest.php new file mode 100644 index 0000000000..c249b1e1e7 --- /dev/null +++ b/tests/Unit/Service/RecordTemplate/RecordTemplateInputTest.php @@ -0,0 +1,102 @@ + + * @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/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\RecordTemplate; + +use OCA\OpenRegister\Db\RecordTemplate; +use OCA\OpenRegister\Exception\RecordTemplateRefused; +use OCA\OpenRegister\Service\RecordTemplate\RecordTemplateInput; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\RecordTemplate\RecordTemplateInput + */ +class RecordTemplateInputTest extends TestCase { + + /** + * A full write needs a name, a register and a schema; a PATCH only checks what it sends. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testAFullWriteNeedsItsThreeFieldsAndAPatchDoesNot(): void { + $input = new RecordTemplateInput(); + $none = static fn (mixed $shares): array => []; + + try { + $input->validated(data: ['description' => 'x'], partial: false, shareFindings: $none); + self::fail('A full write without its required fields must be refused.'); + } catch (RecordTemplateRefused $e) { + self::assertSame(['name.required', 'register.required', 'schema.required'], array_column($e->getFindings(), 'code')); + } + + self::assertSame(['description' => 'x'], $input->validated(data: ['description' => 'x', 'owner' => 'me'], partial: true, shareFindings: $none)); + }//end testAFullWriteNeedsItsThreeFieldsAndAPatchDoesNot() + + /** + * A list is not values, an empty object is, a long name and a non-boolean flag are refused, and share findings are kept. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testShapeFindings(): void { + $input = new RecordTemplateInput(); + $share = static fn (mixed $shares): array => [['code' => 'share.unknown-group', 'message' => 'no']]; + + try { + $input->validated( + data: ['name' => str_repeat('a', 256), 'register' => 1, 'schema' => 's', 'values' => [1, 2], 'isPublic' => 1, 'sharedWith' => [['group' => 'x']]], + partial: false, + shareFindings: $share + ); + self::fail('Bad shapes must be refused.'); + } catch (RecordTemplateRefused $e) { + self::assertSame(400, $e->getCode()); + self::assertSame(['name.too-long', 'values.not-an-object', 'isPublic.not-a-boolean', 'share.unknown-group'], array_column($e->getFindings(), 'code')); + } + + $ok = $input->validated(data: ['name' => ' Kort ', 'register' => 1, 'schema' => 's', 'values' => []], partial: false, shareFindings: static fn (mixed $s): array => []); + self::assertSame(['name' => 'Kort', 'register' => '1', 'schema' => 's', 'values' => []], $ok); + }//end testShapeFindings() + + /** + * Only the fields sent land on the entity. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testOnlyTheFieldsSentLand(): void { + $template = new RecordTemplate(); + $template->setName('Oud'); + $template->setValues(['a' => 1]); + + (new RecordTemplateInput())->apply(template: $template, fields: ['description' => ' Nieuw ', 'isPublic' => true]); + + self::assertSame('Oud', $template->getName()); + self::assertSame(['a' => 1], $template->getValues()); + self::assertSame('Nieuw', $template->getDescription()); + self::assertTrue($template->getIsPublic()); + }//end testOnlyTheFieldsSentLand() +}//end class diff --git a/tests/Unit/Service/RecordTemplate/RecordTemplateServiceTest.php b/tests/Unit/Service/RecordTemplate/RecordTemplateServiceTest.php new file mode 100644 index 0000000000..9309ff7880 --- /dev/null +++ b/tests/Unit/Service/RecordTemplate/RecordTemplateServiceTest.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 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\RecordTemplate; + +use OCA\OpenRegister\Db\RecordTemplate; +use OCA\OpenRegister\Db\RecordTemplateMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\RecordTemplateRefused; +use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCA\OpenRegister\Service\Rbac\ViewerReach; +use OCA\OpenRegister\Service\Rbac\ViewerReachResolver; +use OCA\OpenRegister\Service\RecordTemplate\RecordTemplateService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; +use OCP\IGroupManager; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\RecordTemplate\RecordTemplateService + * @covers \OCA\OpenRegister\Db\RecordTemplate + * @covers \OCA\OpenRegister\Exception\RecordTemplateRefused + */ +class RecordTemplateServiceTest extends TestCase { + + /** + * The stored templates, by uuid. + * + * @var array + */ + private array $rows = []; + + /** + * Who may create in which schema: "user|schema" => bool. + * + * @var array + */ + private array $creators = []; + + /** + * A kcc member with create rights on meldingen sees the template shared with kcc. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#scenario-a-caseworker-saves-a-melding-as-a-template + */ + public function testAGroupMemberWhoMayCreateSeesTheSharedTemplate(): void { + $this->creators = ['anna|meldingen' => true, 'bert|meldingen' => true]; + $service = $this->service(); + $service->create(data: $this->melding(), reach: $this->reach(userId: 'anna', groups: ['kcc'])); + + $listed = $service->listFor(reach: $this->reach(userId: 'bert', groups: ['kcc']), schema: 'meldingen'); + + self::assertCount(1, $listed); + self::assertSame('Melding wateroverlast', $listed[0]->getName()); + self::assertSame('read', $listed[0]->getAccess()); + self::assertSame(['categorie' => 'water', 'prioriteit' => 'hoog'], $listed[0]->jsonSerialize()['values']); + }//end testAGroupMemberWhoMayCreateSeesTheSharedTemplate() + + /** + * A kcc member with read-only access to meldingen is not offered it, and cannot read it directly. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#scenario-a-user-without-create-rights-is-not-offered-the-template + */ + public function testAMemberWithoutCreateRightsIsNotOfferedTheTemplate(): void { + $this->creators = ['anna|meldingen' => true, 'carla|meldingen' => false]; + $service = $this->service(); + $stored = $service->create(data: $this->melding(['isPublic' => true]), reach: $this->reach(userId: 'anna', groups: ['kcc'])); + $carla = $this->reach(userId: 'carla', groups: ['kcc']); + + self::assertSame([], $service->listFor(reach: $carla, schema: 'meldingen')); + $this->assertRefused(status: 404, action: fn () => $service->get(uuid: (string)$stored->getUuid(), reach: $carla)); + }//end testAMemberWithoutCreateRightsIsNotOfferedTheTemplate() + + /** + * A private template is the owner's only; outside the group it is not listed. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testAPrivateTemplateIsTheOwnersOnly(): void { + $this->creators = ['anna|meldingen' => true, 'bert|meldingen' => true]; + $service = $this->service(); + $service->create(data: $this->melding(['sharedWith' => []]), reach: $this->reach(userId: 'anna')); + + self::assertCount(1, $service->listFor(reach: $this->reach(userId: 'anna'))); + self::assertSame('owner', $service->listFor(reach: $this->reach(userId: 'anna'))[0]->getAccess()); + self::assertSame([], $service->listFor(reach: $this->reach(userId: 'bert', groups: ['kcc']))); + }//end testAPrivateTemplateIsTheOwnersOnly() + + /** + * A group member may use it but not change or delete it; the owner and an administrator may. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testOnlyTheOwnerOrAnAdministratorChangesIt(): void { + $this->creators = ['anna|meldingen' => true, 'bert|meldingen' => true, 'admin|meldingen' => true]; + $service = $this->service(); + $uuid = (string)$service->create(data: $this->melding(['sharedWith' => [['group' => 'kcc', 'mode' => 'write']]]), reach: $this->reach(userId: 'anna'))->getUuid(); + $bert = $this->reach(userId: 'bert', groups: ['kcc']); + + $this->assertRefused(status: 403, action: fn () => $service->change(uuid: $uuid, data: ['name' => 'Van Bert'], reach: $bert, partial: true)); + $this->assertRefused(status: 403, action: fn () => $service->delete(uuid: $uuid, reach: $bert)); + self::assertSame('Melding wateroverlast', $this->rows[$uuid]->getName()); + + $renamed = $service->change(uuid: $uuid, data: ['name' => 'Wateroverlast kelder'], reach: $this->reach(userId: 'anna'), partial: true); + self::assertSame('Wateroverlast kelder', $renamed->getName()); + self::assertSame(['categorie' => 'water', 'prioriteit' => 'hoog'], $renamed->getValues(), 'A PATCH keeps the fields it does not send.'); + + $service->delete(uuid: $uuid, reach: $this->reach(userId: 'admin', isAdmin: true)); + self::assertArrayNotHasKey($uuid, $this->rows); + }//end testOnlyTheOwnerOrAnAdministratorChangesIt() + + /** + * A template for a schema the caller may not create in is not stored. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testNoTemplateForASchemaTheCallerMayNotCreateIn(): void { + $this->creators = ['carla|meldingen' => false]; + $service = $this->service(); + + $this->assertRefused(status: 403, action: fn () => $service->create(data: $this->melding(), reach: $this->reach(userId: 'carla'))); + self::assertSame([], $this->rows); + }//end testNoTemplateForASchemaTheCallerMayNotCreateIn() + + /** + * Unusable input is refused with every finding, and nothing is stored. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testUnusableInputIsRefusedWithItsFindings(): void { + $this->creators = ['anna|meldingen' => true]; + $service = $this->service(); + + try { + $service->create( + data: ['name' => '', 'register' => 'zaken', 'schema' => 'meldingen', 'values' => ['a', 'b'], 'isPublic' => 'yes', 'sharedWith' => [['group' => 'ghosts', 'mode' => 'read']]], + reach: $this->reach(userId: 'anna') + ); + self::fail('Unusable input must be refused.'); + } catch (RecordTemplateRefused $e) { + self::assertSame(400, $e->getCode()); + $codes = array_column($e->getFindings(), 'code'); + self::assertContains('name.required', $codes); + self::assertContains('values.not-an-object', $codes); + self::assertContains('isPublic.not-a-boolean', $codes); + self::assertCount(4, $codes, 'The unknown group is the fourth finding: ' . implode(', ', $codes)); + } + + self::assertSame([], $this->rows); + }//end testUnusableInputIsRefusedWithItsFindings() + + /** + * A missing template answers 404, the same as one the caller may not use. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#requirement-a-user-can-save-and-share-a-named-record-template + */ + public function testAMissingTemplateIs404(): void { + $service = $this->service(); + + $this->assertRefused(status: 404, action: fn () => $service->get(uuid: 'nope', reach: $this->reach(userId: 'anna'))); + }//end testAMissingTemplateIs404() + + /** + * A schema whose rights cannot be read offers nothing. + * + * @return void + * + * @spec openspec/changes/records-saved-templates/specs/record-templates/spec.md#scenario-a-user-without-create-rights-is-not-offered-the-template + */ + public function testAnUnreadableSchemaOffersNothing(): void { + $this->creators = ['anna|meldingen' => true]; + $service = $this->service(); + $service->create(data: $this->melding(['schema' => 'meldingen', 'isPublic' => true]), reach: $this->reach(userId: 'anna')); + $this->rows[array_key_first($this->rows)]->setSchema('gone'); + + self::assertSame([], $this->service()->listFor(reach: $this->reach(userId: 'anna'))); + }//end testAnUnreadableSchemaOffersNothing() + + /** + * A refusal with the expected status. + * + * @param int $status The expected status. + * @param callable $action The action. + * + * @return void + */ + private function assertRefused(int $status, callable $action): void { + try { + $action(); + self::fail('Expected a refusal with ' . $status . '.'); + } catch (RecordTemplateRefused $e) { + self::assertSame($status, $e->getCode(), $e->getMessage()); + } + }//end assertRefused() + + /** + * The template the spec names, shared with kcc for reading. + * + * @param array $override Fields to change. + * + * @return array The request body. + */ + private function melding(array $override=[]): array { + return $override + [ + 'name' => 'Melding wateroverlast', + 'description' => 'Standaard bij water in de kelder', + 'register' => 'zaken', + 'schema' => 'meldingen', + 'values' => ['categorie' => 'water', 'prioriteit' => 'hoog'], + 'sharedWith' => [['group' => 'kcc', 'mode' => 'read']], + ]; + }//end melding() + + /** + * A caller. + * + * @param string $userId The uid. + * @param array $groups Their groups. + * @param bool $isAdmin Whether they administer the instance. + * + * @return ViewerReach The reach. + */ + private function reach(string $userId, array $groups=[], bool $isAdmin=false): ViewerReach { + return new ViewerReach(userId: $userId, groups: $groups, isAdmin: $isAdmin); + }//end reach() + + /** + * The service over an in-memory store and a create-rights table. + * + * @return RecordTemplateService The service. + */ + private function service(): RecordTemplateService { + $mapper = $this->createMock(RecordTemplateMapper::class); + $mapper->method('insert')->willReturnCallback(function (Entity $template): RecordTemplate { + $template->setUuid('tpl-' . (count($this->rows) + 1)); + $this->rows[(string)$template->getUuid()] = $template; + return $template; + }); + $mapper->method('update')->willReturnCallback(fn (Entity $template): Entity => $template); + $mapper->method('delete')->willReturnCallback(function (Entity $template): Entity { + unset($this->rows[(string)$template->getUuid()]); + return $template; + }); + $mapper->method('findByUuid')->willReturnCallback(function (string $uuid): RecordTemplate { + if (isset($this->rows[$uuid]) === false) { + throw new DoesNotExistException('none'); + } + + return $this->rows[$uuid]; + }); + $mapper->method('findCandidates')->willReturnCallback( + fn (string $userId, ?string $register=null, ?string $schema=null): array => array_values( + array_filter( + $this->rows, + static fn (RecordTemplate $row): bool => ($schema === null || $row->getSchema() === $schema) + ) + ) + ); + + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturnCallback(function (string|int $id): Schema { + if ($id === 'gone') { + throw new DoesNotExistException('gone'); + } + + $schema = new Schema(); + $schema->setSlug((string)$id); + return $schema; + }); + + $permissions = $this->createMock(PermissionHandler::class); + $permissions->method('hasPermission')->willReturnCallback( + fn (Schema $schema, string $action, ?string $userId=null): bool => ($action === 'create' && ($this->creators[$userId . '|' . $schema->getSlug()] ?? false) === true) + ); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('groupExists')->willReturnCallback(static fn (string $gid): bool => $gid === 'kcc'); + $viewers = new ViewerReachResolver( + userSession: $this->createMock(IUserSession::class), + groupManager: $groups, + logger: new NullLogger() + ); + + return new RecordTemplateService( + mapper: $mapper, + schemas: $schemas, + permissions: $permissions, + viewers: $viewers, + logger: new NullLogger() + ); + }//end service() +}//end class From 148261ebc6cdda313fa65633f841672c4299a966 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 09:08:54 +0200 Subject: [PATCH 073/183] chore(openspec): records-saved-templates 1.1 and 1.2 built, live checks open --- openspec/changes/records-saved-templates/tasks.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/records-saved-templates/tasks.md b/openspec/changes/records-saved-templates/tasks.md index bb00526054..46aef8395c 100644 --- a/openspec/changes/records-saved-templates/tasks.md +++ b/openspec/changes/records-saved-templates/tasks.md @@ -3,7 +3,9 @@ ## 1. Store - [ ] 1.1 `RecordTemplate` entity, mapper with reach listing, migration for `openregister_record_templates`. Verify: mapper tests for owner, group-shared and public templates on PostgreSQL and MariaDB. + - built (11 Oct, build/dep-dossiq-record-templates): `lib/Db/RecordTemplate.php`, `lib/Db/RecordTemplateMapper.php` (`findCandidates`: own, public or shared rows, by register/schema), `lib/Migration/Version1Date20261011090000.php` (`template_values`, as VALUES is an SQL keyword). The reach decision (owner, group share, public, plus create rights) is in `lib/Service/RecordTemplate/RecordTemplateService.php`, unit-tested in `tests/Unit/Service/RecordTemplate/RecordTemplateServiceTest.php`; entity, mapper table and migration in `tests/Unit/Db/RecordTemplateTest.php` and `tests/Unit/Migration/Version1Date20261011090000Test.php`. Open: the DB-backed mapper run on PostgreSQL and MariaDB (CI matrix / live pass, decision 139). - [ ] 1.2 `/api/record-templates` controller and routes beside `/api/views`, with owner-or-admin update and delete, and use limited to users who may create in the schema. Verify: `tests/Api/RecordTemplatesTest` including a user without create rights who does not see the template. + - built (11 Oct): `lib/Controller/RecordTemplatesController.php`, six routes after `views#destroy`; `tests/Unit/Controller/RecordTemplatesControllerTest.php` (401, filters, 201/204, 400 with findings, 403, 404, 500 without the message); the read-only member case through the service in `RecordTemplateServiceTest::testAMemberWithoutCreateRightsIsNotOfferedTheTemplate`. Open: `tests/Api/RecordTemplatesTest` against an instance (live pass, decision 139). ## 2. Interface From ab3692eef02890cdcd5fe926e6fd2120aea2a92b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 09:51:07 +0200 Subject: [PATCH 074/183] feat(decisions): a decision is its own object, and a schema declares who owns it Decision 197: a reviewer's decision is not a column on the entity relation but its own object (openregister_decisions: verdict, decided by, decided at, note, supersedes), which the relation links by decision_uuid. x-openregister-review joins the annotation vocabulary and is refused at save when its ownership is neither open nor decider. --- lib/Controller/SchemasController.php | 22 +++ lib/Db/Decision.php | 156 +++++++++++++++++ lib/Db/DecisionMapper.php | 135 +++++++++++++++ lib/Db/EntityRelation.php | 17 +- lib/Db/Schema.php | 7 + lib/Db/SchemaMapper.php | 27 +++ lib/Migration/Version1Date20261011140000.php | 99 +++++++++++ .../Decision/ReviewAnnotationValidator.php | 118 +++++++++++++ .../Decision/ReviewDeclarationException.php | 61 +++++++ lib/Service/Decision/ReviewPolicy.php | 159 ++++++++++++++++++ tests/Unit/Db/EntityRelationTest.php | 16 +- tests/Unit/Db/SchemaReviewAnnotationTest.php | 106 ++++++++++++ .../Version1Date20261011140000Test.php | 114 +++++++++++++ .../Service/Decision/ReviewPolicyTest.php | 67 ++++++++ 14 files changed, 1102 insertions(+), 2 deletions(-) create mode 100644 lib/Db/Decision.php create mode 100644 lib/Db/DecisionMapper.php create mode 100644 lib/Migration/Version1Date20261011140000.php create mode 100644 lib/Service/Decision/ReviewAnnotationValidator.php create mode 100644 lib/Service/Decision/ReviewDeclarationException.php create mode 100644 lib/Service/Decision/ReviewPolicy.php create mode 100644 tests/Unit/Db/SchemaReviewAnnotationTest.php create mode 100644 tests/Unit/Migration/Version1Date20261011140000Test.php create mode 100644 tests/Unit/Service/Decision/ReviewPolicyTest.php diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php index 0e8ce8a7e5..b49609331a 100644 --- a/lib/Controller/SchemasController.php +++ b/lib/Controller/SchemasController.php @@ -47,6 +47,7 @@ use OCA\OpenRegister\Service\Form\FormDependentsCheck; use OCA\OpenRegister\Service\Hinge\ListPresentationResolver; use OCA\OpenRegister\Service\BulkJob\ReversibilityDeclarationException; +use OCA\OpenRegister\Service\Decision\ReviewDeclarationException; use OCA\OpenRegister\Service\Relation\RelationDeclarationException; use OCA\OpenRegister\Service\Rules\DependentValueDeclarationException; use OCA\OpenRegister\Service\JsonLd\JsonLdContextService; @@ -948,6 +949,13 @@ public function create(): JSONResponse { data: ['error' => $e->getMessage(), 'errors' => $e->getErrors()], statusCode: 422 ); + } catch (ReviewDeclarationException $e) { + // A review declaration that cannot be honoured is refused with its + // reasons, above the generic catch so it does not become a 500. + return new JSONResponse( + data: ['error' => $e->getMessage(), 'errors' => $e->getErrors()], + statusCode: 422 + ); } catch (RelationDeclarationException $e) { // A relation declaration is the caller's input and a person is waiting // on the answer, so the refusal names the property rather than being @@ -1366,6 +1374,13 @@ private function applyUpdate(int $id, array $data): JSONResponse { data: ['error' => $e->getMessage(), 'errors' => $e->getErrors()], statusCode: 422 ); + } catch (ReviewDeclarationException $e) { + // A review declaration that cannot be honoured is refused with its + // reasons, above the generic catch so it does not become a 500. + return new JSONResponse( + data: ['error' => $e->getMessage(), 'errors' => $e->getErrors()], + statusCode: 422 + ); } catch (RelationDeclarationException $e) { // A relation declaration is the caller's input and a person is waiting // on the answer, so the refusal names the property rather than being @@ -2037,6 +2052,13 @@ public function upload(?int $id = null): JSONResponse { data: ['error' => $e->getMessage(), 'errors' => $e->getErrors()], statusCode: 422 ); + } catch (ReviewDeclarationException $e) { + // A review declaration that cannot be honoured is refused with its + // reasons, above the generic catch so it does not become a 500. + return new JSONResponse( + data: ['error' => $e->getMessage(), 'errors' => $e->getErrors()], + statusCode: 422 + ); } catch (RelationDeclarationException $e) { // A relation declaration is the caller's input and a person is waiting // on the answer, so the refusal names the property rather than being diff --git a/lib/Db/Decision.php b/lib/Db/Decision.php new file mode 100644 index 0000000000..5231f0ffe5 --- /dev/null +++ b/lib/Db/Decision.php @@ -0,0 +1,156 @@ + + * @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/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * One decision on one subject. + * + * A decision is never edited. Changing a verdict records a new decision that + * names the one it supersedes, and the subject links to the new one. So the + * person who decided is a fact, not a field someone else can overwrite. + * + * @method string|null getUuid() + * @method void setUuid(?string $uuid) + * @method string|null getSubjectType() + * @method void setSubjectType(?string $subjectType) + * @method string|null getSubjectId() + * @method void setSubjectId(?string $subjectId) + * @method string|null getVerdict() + * @method void setVerdict(?string $verdict) + * @method string|null getDecidedBy() + * @method void setDecidedBy(?string $decidedBy) + * @method DateTime|null getDecidedAt() + * @method void setDecidedAt(?DateTime $decidedAt) + * @method string|null getNote() + * @method void setNote(?string $note) + * @method string|null getSupersedes() + * @method void setSupersedes(?string $supersedes) + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ +class Decision extends Entity implements JsonSerializable { + + /** + * The decision's own uuid, by which subjects link to it. + * + * @var string|null + */ + protected ?string $uuid = null; + + /** + * What kind of thing was decided on, for example `entity-relation` or `object`. + * + * @var string|null + */ + protected ?string $subjectType = null; + + /** + * The id or uuid of the thing decided on, within its type. + * + * @var string|null + */ + protected ?string $subjectId = null; + + /** + * The verdict, in the subject type's own words (`redact`, `release`, ...). + * + * @var string|null + */ + protected ?string $verdict = null; + + /** + * The user who decided. + * + * @var string|null + */ + protected ?string $decidedBy = null; + + /** + * When it was decided. + * + * @var DateTime|null + */ + protected ?DateTime $decidedAt = null; + + /** + * The decider's note on the decision. + * + * @var string|null + */ + protected ?string $note = null; + + /** + * The uuid of the decision this one replaces, if any. + * + * @var string|null + */ + protected ?string $supersedes = null; + + /** + * Declare the column types. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function __construct() { + $this->addType(fieldName: 'uuid', type: 'string'); + $this->addType(fieldName: 'subjectType', type: 'string'); + $this->addType(fieldName: 'subjectId', type: 'string'); + $this->addType(fieldName: 'verdict', type: 'string'); + $this->addType(fieldName: 'decidedBy', type: 'string'); + $this->addType(fieldName: 'decidedAt', type: 'datetime'); + $this->addType(fieldName: 'note', type: 'string'); + $this->addType(fieldName: 'supersedes', type: 'string'); + }//end __construct() + + /** + * The decision as the API answers it. + * + * @return array{uuid: string|null, subjectType: string|null, subjectId: string|null, + * verdict: string|null, decidedBy: string|null, decidedAt: string|null, + * note: string|null, supersedes: string|null} + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function jsonSerialize(): array { + return [ + 'uuid' => $this->uuid, + 'subjectType' => $this->subjectType, + 'subjectId' => $this->subjectId, + 'verdict' => $this->verdict, + 'decidedBy' => $this->decidedBy, + 'decidedAt' => $this->decidedAt?->format(DateTime::ATOM), + 'note' => $this->note, + 'supersedes' => $this->supersedes, + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/DecisionMapper.php b/lib/Db/DecisionMapper.php new file mode 100644 index 0000000000..2c3aea8c35 --- /dev/null +++ b/lib/Db/DecisionMapper.php @@ -0,0 +1,135 @@ + + * @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/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use InvalidArgumentException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Symfony\Component\Uid\Uuid; + +/** + * Stores and finds decisions. Who may supersede one is decided by + * DecisionOwnership, not here. + * + * @method Decision findEntity(IQueryBuilder $query) + * @method list findEntities(IQueryBuilder $query) + * + * @template-extends QBMapper + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ +class DecisionMapper extends QBMapper { + + /** + * The table. + */ + public const TABLE = 'openregister_decisions'; + + /** + * Constructor. + * + * @param IDBConnection $db Database connection. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: self::TABLE, entityClass: Decision::class); + }//end __construct() + + /** + * One decision by its uuid. + * + * @param string $uuid The decision uuid. + * + * @return Decision The decision. + * + * @throws DoesNotExistException When no decision has that uuid. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function findByUuid(string $uuid): Decision { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid, IQueryBuilder::PARAM_STR))); + + return $this->findEntity(query: $qb); + }//end findByUuid() + + /** + * Every decision taken on one subject, newest first. + * + * @param string $subjectType The subject type. + * @param string $subjectId The subject id. + * + * @return Decision[] The decisions. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function findForSubject(string $subjectType, string $subjectId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('subject_type', $qb->createNamedParameter($subjectType, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('subject_id', $qb->createNamedParameter($subjectId, IQueryBuilder::PARAM_STR))) + ->orderBy('decided_at', 'DESC') + ->addOrderBy('id', 'DESC'); + + return $this->findEntities(query: $qb); + }//end findForSubject() + + /** + * Store a new decision with its uuid and, when unset, the moment of deciding. + * + * @param Entity $entity The decision. + * + * @return Decision The stored decision. + * + * @SuppressWarnings(PHPMD.StaticAccess) Uuid::v4 is the standard Symfony UID pattern. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function insert(Entity $entity): Decision { + if ($entity instanceof Decision === false) { + throw new InvalidArgumentException('DecisionMapper stores decisions only.'); + } + + if (empty($entity->getUuid()) === true) { + $entity->setUuid((string)Uuid::v4()); + } + + if ($entity->getDecidedAt() === null) { + $entity->setDecidedAt(new DateTime()); + } + + return parent::insert(entity: $entity); + }//end insert() +}//end class diff --git a/lib/Db/EntityRelation.php b/lib/Db/EntityRelation.php index f15e42ea02..f9ee25db4a 100644 --- a/lib/Db/EntityRelation.php +++ b/lib/Db/EntityRelation.php @@ -65,6 +65,8 @@ * @method void setBases(?array $bases) * @method bool getSkipAnonymization() * @method void setSkipAnonymization(bool $skipAnonymization) + * @method string|null getDecisionUuid() + * @method void setDecisionUuid(?string $decisionUuid) * @method DateTime getCreatedAt() * @method void setCreatedAt(DateTime $createdAt) * @@ -220,6 +222,16 @@ class EntityRelation extends Entity implements JsonSerializable { */ protected bool $skipAnonymization = false; + /** + * The uuid of this occurrence's current decision, or null while undecided. + * + * The decision is its own object (`openregister_decisions`): who decided, + * when, the verdict and a note. The relation only links it (decision 197). + * + * @var string|null + */ + protected ?string $decisionUuid = null; + /** * Created at timestamp. * @@ -249,6 +261,7 @@ public function __construct() { $this->addType(fieldName: 'anonymizedValue', type: 'string'); $this->addType(fieldName: 'bases', type: 'json'); $this->addType(fieldName: 'skipAnonymization', type: 'boolean'); + $this->addType(fieldName: 'decisionUuid', type: 'string'); $this->addType(fieldName: 'createdAt', type: 'datetime'); }//end __construct() @@ -264,7 +277,8 @@ public function __construct() { * confidence: float, detectionMethod: null|string, * context: null|string, anonymized: bool, * anonymizedValue: null|string, bases: array|null, - * skipAnonymization: bool, createdAt: null|string} + * skipAnonymization: bool, decision: null|string, + * createdAt: null|string} */ public function jsonSerialize(): array { return [ @@ -287,6 +301,7 @@ public function jsonSerialize(): array { 'anonymizedValue' => $this->anonymizedValue, 'bases' => $this->bases, 'skipAnonymization' => $this->skipAnonymization, + 'decision' => $this->decisionUuid, 'createdAt' => $this->createdAt?->format(DateTime::ATOM), ]; }//end jsonSerialize() diff --git a/lib/Db/Schema.php b/lib/Db/Schema.php index def40ef664..c494699e86 100644 --- a/lib/Db/Schema.php +++ b/lib/Db/Schema.php @@ -3463,6 +3463,13 @@ private function validateAllowedTagsValue(mixed $value): void { // could never opt an object into a subscription — same // or#460/#462-class trap as every entry above. 'x-openregister-registry', + // Who owns a decision on this schema's records (open, or the decider + // plus supervisors and administrators) and whether one reviewer's + // decisions are hidden from another (reviewer-owns-their-decisions). + // Read by ReviewPolicyResolver, refused at save by + // ReviewAnnotationValidator. Absent from this list it would be dropped, + // and a four-eyes review its author had just switched on would stay open. + 'x-openregister-review', // A property that shows a referenced record's own field, live. Read by // LensResolver at render time; the value is never stored. Absent from // this list, setConfiguration() would drop the block and every lens diff --git a/lib/Db/SchemaMapper.php b/lib/Db/SchemaMapper.php index c2b9e088b1..e0be5bfded 100644 --- a/lib/Db/SchemaMapper.php +++ b/lib/Db/SchemaMapper.php @@ -42,6 +42,8 @@ use OCA\OpenRegister\Service\Calculation\CalculationDeclarationException; use OCA\OpenRegister\Service\Calculation\PropertyCalculations; use OCA\OpenRegister\Service\Consent\ConsentAnnotationValidator; +use OCA\OpenRegister\Service\Decision\ReviewAnnotationValidator; +use OCA\OpenRegister\Service\Decision\ReviewDeclarationException; use OCA\OpenRegister\Service\Consent\ConsentDeclarationException; use OCA\OpenRegister\Service\ExternalLink\ExternalLinkAnnotationValidator; use OCA\OpenRegister\Service\ExternalLink\ExternalLinkResolver; @@ -1224,6 +1226,7 @@ private function cleanObject(Schema $schema): void { $this->validateDepartmentMatrix(schema: $schema); $this->validateRevealAudit(schema: $schema); $this->validateReversibilityDeclaration(schema: $schema); + $this->validateReviewDeclaration(schema: $schema); $this->logDroppedAnnotationKeys(schema: $schema); }//end cleanObject() @@ -1296,6 +1299,30 @@ private function validateReversibilityDeclaration(Schema $schema): void { throw new ReversibilityDeclarationException(errors: $errors); }//end validateReversibilityDeclaration() + /** + * Refuse an `x-openregister-review` declaration the ownership rule cannot honour. + * + * Refused rather than logged: a mistyped ownership read as open would leave + * a four-eyes review open while its author believes it is closed. + * + * @param Schema $schema Schema to validate. + * + * @return void + * + * @throws ReviewDeclarationException When the declaration is unsound. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + private function validateReviewDeclaration(Schema $schema): void { + $errors = (new ReviewAnnotationValidator())->validate(configuration: ($schema->getConfiguration() ?? [])); + + if ($errors === []) { + return; + } + + throw new ReviewDeclarationException(errors: $errors); + }//end validateReviewDeclaration() + /** * R07: surface dropped `x-openregister-*` keys via the structured * logger. Schema::validateConfigurationArray accumulates unknown diff --git a/lib/Migration/Version1Date20261011140000.php b/lib/Migration/Version1Date20261011140000.php new file mode 100644 index 0000000000..fd5bdd409d --- /dev/null +++ b/lib/Migration/Version1Date20261011140000.php @@ -0,0 +1,99 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Decisions as their own objects (reviewer-owns-their-decisions, decision 197): + * a table `openregister_decisions` holding a verdict on a subject, who decided + * it, when and a note, and a `decision_uuid` column on + * `openregister_entity_relations` by which a relation links its current + * decision. + * + * @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/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + +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_decisions` and links entity relations to it. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ +class Version1Date20261011140000 extends SimpleMigrationStep { + + /** + * The decision table. + */ + public const TABLE = 'openregister_decisions'; + + /** + * The relation table that gains the link. + */ + public const RELATIONS = 'openregister_entity_relations'; + + /** + * Create the table and the link column when they are absent. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure Returns the schema wrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The schema. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) The signature is Nextcloud's. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + + $schema = $schemaClosure(); + if ($schema->hasTable(self::TABLE) === false) { + $table = $schema->createTable(self::TABLE); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20]); + $table->addColumn('uuid', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('subject_type', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('subject_id', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('verdict', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('decided_by', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('decided_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('note', Types::TEXT, ['notnull' => false]); + $table->addColumn('supersedes', Types::STRING, ['notnull' => false, 'length' => 255]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['uuid'], 'or_decision_uuid'); + $table->addIndex(['subject_type', 'subject_id'], 'or_decision_subject'); + $table->addIndex(['decided_by'], 'or_decision_decided_by'); + } + + if ($schema->hasTable(self::RELATIONS) === true) { + $relations = $schema->getTable(self::RELATIONS); + if ($relations->hasColumn('decision_uuid') === false) { + $relations->addColumn('decision_uuid', Types::STRING, ['notnull' => false, 'length' => 255]); + $relations->addIndex(['decision_uuid'], 'or_entrel_decision'); + } + } + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Service/Decision/ReviewAnnotationValidator.php b/lib/Service/Decision/ReviewAnnotationValidator.php new file mode 100644 index 0000000000..c848ddc1d8 --- /dev/null +++ b/lib/Service/Decision/ReviewAnnotationValidator.php @@ -0,0 +1,118 @@ + + * @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/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Decision; + +/** + * Shape checks for `x-openregister-review`. + * + * Whether each supervisor group exists is not checked here: an app's repair + * step may create its groups after its schemas are imported, and a group that + * does not exist grants nobody anything, so it cannot open the review. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +final class ReviewAnnotationValidator { + + /** + * The refusals for a schema configuration; empty when it is sound or absent. + * + * @param array $configuration The schema configuration. + * + * @return array The refusals. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function validate(array $configuration): array { + if (array_key_exists(ReviewPolicy::ANNOTATION, $configuration) === false) { + return []; + } + + $declaration = $configuration[ReviewPolicy::ANNOTATION]; + if (is_array($declaration) === false) { + return [self::refusal(code: 'review-shape', message: ReviewPolicy::ANNOTATION.' must be an object with ownership, supervisors and hideOthers.')]; + } + + $errors = []; + foreach (array_keys($declaration) as $key) { + if (in_array($key, ReviewPolicy::KEYS, true) === false) { + $errors[] = self::refusal(code: 'review-unknown-key', message: 'Unknown key "'.$key.'" in '.ReviewPolicy::ANNOTATION.'.'); + } + } + + $ownership = ($declaration['ownership'] ?? ReviewPolicy::OWNERSHIP_OPEN); + if (in_array($ownership, [ReviewPolicy::OWNERSHIP_OPEN, ReviewPolicy::OWNERSHIP_DECIDER], true) === false) { + $errors[] = self::refusal( + code: 'review-ownership', + message: 'Ownership "'.(is_scalar($ownership) === true ? (string)$ownership : gettype($ownership)).'" is not open or decider.' + ); + } + + if (array_key_exists('supervisors', $declaration) === true && self::isListOfStrings(value: $declaration['supervisors']) === false) { + $errors[] = self::refusal(code: 'review-supervisors', message: 'Supervisors must be a list of group ids.'); + } + + if (array_key_exists('hideOthers', $declaration) === true && is_bool($declaration['hideOthers']) === false) { + $errors[] = self::refusal(code: 'review-hide-others', message: 'hideOthers must be true or false.'); + } + + return $errors; + }//end validate() + + /** + * Whether a value is a list of non-empty strings. + * + * @param mixed $value The value. + * + * @return bool True for a list of group ids. + */ + private static function isListOfStrings(mixed $value): bool { + if (is_array($value) === false || array_is_list($value) === false) { + return false; + } + + foreach ($value as $item) { + if (is_string($item) === false || trim($item) === '') { + return false; + } + } + + return true; + }//end isListOfStrings() + + /** + * One refusal. + * + * @param string $code The refusal code. + * @param string $message What is wrong. + * + * @return array{code: string, message: string} The refusal. + */ + private static function refusal(string $code, string $message): array { + return ['code' => $code, 'message' => $message]; + }//end refusal() +}//end class diff --git a/lib/Service/Decision/ReviewDeclarationException.php b/lib/Service/Decision/ReviewDeclarationException.php new file mode 100644 index 0000000000..b7ea628126 --- /dev/null +++ b/lib/Service/Decision/ReviewDeclarationException.php @@ -0,0 +1,61 @@ + + * @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/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Decision; + +use Exception; + +/** + * Raised when an `x-openregister-review` declaration is refused at schema save. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +final class ReviewDeclarationException extends Exception { + /** + * Constructor. + * + * @param array $errors The refusals. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function __construct(private readonly array $errors) { + $messages = array_map( + static fn (array $error): string => (string)($error['message'] ?? ''), + $errors + ); + + parent::__construct(message: 'Invalid review declaration: '.implode(' ', $messages)); + }//end __construct() + + /** + * The individual refusals. + * + * @return array The refusals. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function getErrors(): array { + return $this->errors; + }//end getErrors() +}//end class diff --git a/lib/Service/Decision/ReviewPolicy.php b/lib/Service/Decision/ReviewPolicy.php new file mode 100644 index 0000000000..79bb6567c0 --- /dev/null +++ b/lib/Service/Decision/ReviewPolicy.php @@ -0,0 +1,159 @@ + + * @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/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Decision; + +/** + * The review policy of one schema, or of the instance for files without one. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +final class ReviewPolicy { + + /** + * The schema configuration key. + */ + public const ANNOTATION = 'x-openregister-review'; + + /** + * Anyone who may write the subject may change its decision. + */ + public const OWNERSHIP_OPEN = 'open'; + + /** + * Only the decider, a supervisor or an administrator may change it. + */ + public const OWNERSHIP_DECIDER = 'decider'; + + /** + * The keys a declaration may hold. + */ + public const KEYS = ['ownership', 'supervisors', 'hideOthers']; + + /** + * Constructor. + * + * @param bool $deciderOwned Whether ownership is `decider`. + * @param array $supervisors Group ids whose members may change any decision. + * @param bool $hideOthers Whether another reviewer's decisions are hidden. + */ + private function __construct( + private readonly bool $deciderOwned, + private readonly array $supervisors, + private readonly bool $hideOthers, + ) { + }//end __construct() + + /** + * Read a declaration. Nothing declared is today's open behaviour. + * + * An ownership other than `open` or `decider` fails closed to `decider`: + * the validator refuses it at save, and a value that got past it must not + * quietly open a four-eyes review. + * + * @param mixed $declaration The `x-openregister-review` value, or null. + * + * @return self The policy. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public static function fromDeclaration(mixed $declaration): self { + if (is_array($declaration) === false) { + return new self(deciderOwned: false, supervisors: [], hideOthers: false); + } + + $ownership = ($declaration['ownership'] ?? self::OWNERSHIP_OPEN); + + $supervisors = []; + foreach ((array)($declaration['supervisors'] ?? []) as $group) { + if (is_string($group) === true && trim($group) !== '') { + $supervisors[trim($group)] = true; + } + } + + return new self( + deciderOwned: $ownership !== self::OWNERSHIP_OPEN, + supervisors: array_keys($supervisors), + hideOthers: (($declaration['hideOthers'] ?? false) === true) + ); + }//end fromDeclaration() + + /** + * Whether a decision belongs to the person who took it. + * + * @return bool True for `decider` ownership. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function isDeciderOwned(): bool { + return $this->deciderOwned; + }//end isDeciderOwned() + + /** + * The supervisor groups. + * + * @return array Group ids. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function getSupervisors(): array { + return $this->supervisors; + }//end getSupervisors() + + /** + * Whether another reviewer's decisions are hidden. + * + * @return bool True when hiding is on. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-schema-can-hide-one-reviewers-decisions-from-another-req-rod-002 + */ + public function hidesOthers(): bool { + return $this->hideOthers; + }//end hidesOthers() + + /** + * The policy as a declaration. + * + * @return array{ownership: string, supervisors: array, hideOthers: bool} + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function toArray(): array { + $ownership = self::OWNERSHIP_OPEN; + if ($this->deciderOwned === true) { + $ownership = self::OWNERSHIP_DECIDER; + } + + return [ + 'ownership' => $ownership, + 'supervisors' => $this->supervisors, + 'hideOthers' => $this->hideOthers, + ]; + }//end toArray() +}//end class diff --git a/tests/Unit/Db/EntityRelationTest.php b/tests/Unit/Db/EntityRelationTest.php index d7682826e5..60089e8b60 100644 --- a/tests/Unit/Db/EntityRelationTest.php +++ b/tests/Unit/Db/EntityRelationTest.php @@ -48,6 +48,7 @@ public function testConstructorRegistersFieldTypes(): void { $this->assertSame('string', $fieldTypes['anonymizedValue']); $this->assertSame('json', $fieldTypes['bases']); $this->assertSame('boolean', $fieldTypes['skipAnonymization']); + $this->assertSame('string', $fieldTypes['decisionUuid']); $this->assertSame('datetime', $fieldTypes['createdAt']); } @@ -149,7 +150,7 @@ public function testJsonSerialize(): void { 'id', 'entityId', 'chunkId', 'role', 'fileId', 'objectId', 'emailId', 'positionStart', 'positionEnd', 'confidence', 'detectionMethod', 'context', 'anonymized', 'anonymizedValue', - 'bases', 'skipAnonymization', 'createdAt', + 'bases', 'skipAnonymization', 'decision', 'createdAt', ]; foreach ($expectedKeys as $key) { $this->assertArrayHasKey($key, $json); @@ -175,4 +176,17 @@ public function testJsonSerializeNullCreatedAt(): void { $json = $this->relation->jsonSerialize(); $this->assertNull($json['createdAt']); } + + /** + * A relation links its current decision by uuid and serialises the link as `decision`. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function testARelationLinksItsDecision(): void { + $this->assertNull($this->relation->jsonSerialize()['decision']); + + $this->relation->setDecisionUuid('5f0c6b2e-0000-4000-8000-000000000001'); + + $this->assertSame('5f0c6b2e-0000-4000-8000-000000000001', $this->relation->jsonSerialize()['decision']); + } } diff --git a/tests/Unit/Db/SchemaReviewAnnotationTest.php b/tests/Unit/Db/SchemaReviewAnnotationTest.php new file mode 100644 index 0000000000..db7a1c05fa --- /dev/null +++ b/tests/Unit/Db/SchemaReviewAnnotationTest.php @@ -0,0 +1,106 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\Decision\ReviewAnnotationValidator; +use OCA\OpenRegister\Service\Decision\ReviewDeclarationException; +use OCA\OpenRegister\Service\Decision\ReviewPolicy; +use PHPUnit\Framework\TestCase; + +/** + * `x-openregister-review` survives a schema save and is refused when it says + * something the ownership rule cannot honour (REQ-ROD-001, task 1.1). + * + * @covers \OCA\OpenRegister\Service\Decision\ReviewAnnotationValidator + * @covers \OCA\OpenRegister\Service\Decision\ReviewDeclarationException + * @uses \OCA\OpenRegister\Service\Decision\ReviewPolicy + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +class SchemaReviewAnnotationTest extends TestCase { + + /** + * The key round-trips through the schema configuration instead of being dropped. + * + * @return void + */ + public function testTheAnnotationSurvivesASave(): void { + $review = ['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => true]; + $schema = new Schema(); + $schema->setConfiguration([ReviewPolicy::ANNOTATION => $review]); + + self::assertSame($review, $schema->getConfiguration()[ReviewPolicy::ANNOTATION] ?? null); + }//end testTheAnnotationSurvivesASave() + + /** + * An ownership that is neither `open` nor `decider` is refused, not read as open. + * + * @return void + */ + public function testAnUnknownOwnershipIsRefused(): void { + $errors = (new ReviewAnnotationValidator())->validate( + configuration: [ReviewPolicy::ANNOTATION => ['ownership' => 'decidr']] + ); + + self::assertCount(1, $errors); + self::assertSame('review-ownership', $errors[0]['code']); + self::assertStringContainsString('decidr', $errors[0]['message']); + }//end testAnUnknownOwnershipIsRefused() + + /** + * Supervisors must be a list of group ids and hideOthers a boolean. + * + * @return void + */ + public function testSupervisorsAndHidingAreChecked(): void { + $errors = (new ReviewAnnotationValidator())->validate( + configuration: [ReviewPolicy::ANNOTATION => ['ownership' => 'decider', 'supervisors' => 'woo-coordinator', 'hideOthers' => 'yes']] + ); + + self::assertSame(['review-supervisors', 'review-hide-others'], array_column($errors, 'code')); + }//end testSupervisorsAndHidingAreChecked() + + /** + * A sound declaration, an absent one and an unknown key each answer as expected. + * + * @return void + */ + public function testASoundDeclarationPassesAndAnUnknownKeyIsRefused(): void { + $validator = new ReviewAnnotationValidator(); + + self::assertSame([], $validator->validate(configuration: [])); + self::assertSame( + [], + $validator->validate(configuration: [ReviewPolicy::ANNOTATION => ['ownership' => 'open', 'supervisors' => [], 'hideOthers' => false]]) + ); + self::assertSame( + ['review-shape'], + array_column($validator->validate(configuration: [ReviewPolicy::ANNOTATION => 'decider']), 'code') + ); + self::assertSame( + ['review-unknown-key'], + array_column($validator->validate(configuration: [ReviewPolicy::ANNOTATION => ['ownership' => 'decider', 'hide' => true]]), 'code') + ); + }//end testASoundDeclarationPassesAndAnUnknownKeyIsRefused() + + /** + * The exception carries every refusal and names them in its message. + * + * @return void + */ + public function testTheExceptionNamesEachRefusal(): void { + $errors = [['code' => 'review-ownership', 'message' => 'Ownership "x" is not open or decider.']]; + $exception = new ReviewDeclarationException(errors: $errors); + + self::assertSame($errors, $exception->getErrors()); + self::assertStringContainsString('Ownership "x"', $exception->getMessage()); + }//end testTheExceptionNamesEachRefusal() +}//end class diff --git a/tests/Unit/Migration/Version1Date20261011140000Test.php b/tests/Unit/Migration/Version1Date20261011140000Test.php new file mode 100644 index 0000000000..5f834b092f --- /dev/null +++ b/tests/Unit/Migration/Version1Date20261011140000Test.php @@ -0,0 +1,114 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Migration; + +use OCA\OpenRegister\Migration\Version1Date20261011140000; +use OCA\OpenRegister\Tests\Support\SchemaTableMockTrait; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; + +/** + * The decision table is created once, and an entity relation gains the column + * that links it to its current decision. + * + * @covers \OCA\OpenRegister\Migration\Version1Date20261011140000 + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ +class Version1Date20261011140000Test extends TestCase { + use SchemaTableMockTrait; + + /** + * A schema without the table gets it, and the relation table gets the link. + * + * @return void + */ + public function testItCreatesTheTableAndTheLink(): void { + $columns = []; + $indexes = []; + $table = $this->createTableMock(); + $table->method('addColumn')->willReturnCallback( + function (string $name) use (&$columns) { + $columns[] = $name; + return $this->createColumnMock(); + } + ); + $table->method('addIndex')->willReturnCallback( + function (array $cols, string $name) use (&$indexes, $table) { + $indexes[] = $name; + return $table; + } + ); + $table->method('addUniqueIndex')->willReturnCallback( + function (array $cols, string $name) use (&$indexes, $table) { + $indexes[] = $name; + return $table; + } + ); + + $relationColumns = []; + $relations = $this->createTableMock(); + $relations->method('hasColumn')->willReturn(false); + $relations->method('addColumn')->willReturnCallback( + function (string $name) use (&$relationColumns) { + $relationColumns[] = $name; + return $this->createColumnMock(); + } + ); + $relations->method('addIndex')->willReturnCallback( + function (array $cols, string $name) use (&$indexes, $relations) { + $indexes[] = $name; + return $relations; + } + ); + + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->willReturnMap( + [ + ['openregister_decisions', false], + ['openregister_entity_relations', true], + ] + ); + $schema->expects($this->once())->method('createTable')->with('openregister_decisions')->willReturn($table); + $schema->method('getTable')->with('openregister_entity_relations')->willReturn($relations); + + $step = new Version1Date20261011140000(); + self::assertSame($schema, $step->changeSchema($this->createMock(IOutput::class), static fn () => $schema, [])); + + self::assertSame( + ['id', 'uuid', 'subject_type', 'subject_id', 'verdict', 'decided_by', 'decided_at', 'note', 'supersedes'], + $columns + ); + self::assertSame(['decision_uuid'], $relationColumns); + foreach ($indexes as $name) { + self::assertLessThanOrEqual(30, strlen($name), $name . ' is too long for Oracle.'); + } + }//end testItCreatesTheTableAndTheLink() + + /** + * A second run finds both and changes nothing. + * + * @return void + */ + public function testASecondRunChangesNothing(): void { + $relations = $this->createTableMock(); + $relations->method('hasColumn')->willReturn(true); + $relations->expects($this->never())->method('addColumn'); + + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('hasTable')->willReturn(true); + $schema->expects($this->never())->method('createTable'); + $schema->method('getTable')->willReturn($relations); + + $step = new Version1Date20261011140000(); + self::assertSame($schema, $step->changeSchema($this->createMock(IOutput::class), static fn () => $schema, [])); + }//end testASecondRunChangesNothing() +}//end class diff --git a/tests/Unit/Service/Decision/ReviewPolicyTest.php b/tests/Unit/Service/Decision/ReviewPolicyTest.php new file mode 100644 index 0000000000..45246b3d63 --- /dev/null +++ b/tests/Unit/Service/Decision/ReviewPolicyTest.php @@ -0,0 +1,67 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Decision; + +use OCA\OpenRegister\Service\Decision\ReviewPolicy; +use PHPUnit\Framework\TestCase; + +/** + * A review policy read from a declaration falls back to today's behaviour + * only when nothing is declared. + * + * @covers \OCA\OpenRegister\Service\Decision\ReviewPolicy + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +class ReviewPolicyTest extends TestCase { + + /** + * Nothing declared is open, no supervisors, nothing hidden. + * + * @return void + */ + public function testNothingDeclaredIsOpen(): void { + $policy = ReviewPolicy::fromDeclaration(declaration: null); + + self::assertFalse($policy->isDeciderOwned()); + self::assertSame([], $policy->getSupervisors()); + self::assertFalse($policy->hidesOthers()); + self::assertSame(['ownership' => 'open', 'supervisors' => [], 'hideOthers' => false], $policy->toArray()); + }//end testNothingDeclaredIsOpen() + + /** + * A declaration is read as declared, with blank and duplicate groups dropped. + * + * @return void + */ + public function testADeclarationIsReadAsDeclared(): void { + $policy = ReviewPolicy::fromDeclaration( + declaration: ['ownership' => 'decider', 'supervisors' => ['woo-coordinator', '', 'woo-coordinator', 7], 'hideOthers' => true] + ); + + self::assertTrue($policy->isDeciderOwned()); + self::assertSame(['woo-coordinator'], $policy->getSupervisors()); + self::assertTrue($policy->hidesOthers()); + }//end testADeclarationIsReadAsDeclared() + + /** + * An ownership nobody can honour fails closed: read as decider, never as open. + * + * The validator refuses it at save; this covers a value stored before the + * validator existed, or written around it. + * + * @return void + */ + public function testAnUnreadableOwnershipFailsClosed(): void { + self::assertTrue(ReviewPolicy::fromDeclaration(declaration: ['ownership' => 'decidr'])->isDeciderOwned()); + self::assertFalse(ReviewPolicy::fromDeclaration(declaration: ['ownership' => 'open'])->isDeciderOwned()); + self::assertFalse(ReviewPolicy::fromDeclaration(declaration: [])->isDeciderOwned()); + }//end testAnUnreadableOwnershipFailsClosed() +}//end class From 10a2d83d6cfde5521cef2bb9b1262beef835391c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 09:59:04 +0200 Subject: [PATCH 075/183] feat(decisions): only the decider, a supervisor or an administrator changes a decision PATCH /api/entity-relations/{id} now records each decision as a decision object linked from the relation. Under decider ownership a change by anyone else answers 403 decision-owned with who decided and when. The app that owns the subject can widen or narrow who supervises through DecisionChangeRequestedEvent. The instance default for files without a schema lives in the file settings. --- appinfo/info.xml | 2 +- lib/Controller/EntityRelationsController.php | 25 +- lib/Db/EntityRelationMapper.php | 20 ++ lib/Event/DecisionChangeRequestedEvent.php | 169 +++++++++ .../Decision/DecisionOwnedException.php | 57 +++ lib/Service/Decision/DecisionOwnership.php | 153 ++++++++ lib/Service/Decision/DecisionService.php | 136 ++++++++ .../Decision/RelationDecisionService.php | 215 ++++++++++++ lib/Service/Decision/ReviewPolicy.php | 5 + lib/Service/Decision/ReviewPolicyResolver.php | 165 +++++++++ lib/Service/Settings/FileSettingsHandler.php | 31 ++ .../Unit/Controller/DecisionOwnershipTest.php | 326 ++++++++++++++++++ .../EntityRelationsControllerTest.php | 56 ++- tests/Unit/Db/DecisionMapperTest.php | 156 +++++++++ ...lationMapperUpdateDecisionMetadataTest.php | 15 + .../Decision/DecisionOwnershipTest.php | 199 +++++++++++ .../Service/Decision/DecisionServiceTest.php | 118 +++++++ .../Decision/ReviewPolicyResolverTest.php | 141 ++++++++ .../Settings/FileSettingsHandlerTest.php | 18 + 19 files changed, 1997 insertions(+), 10 deletions(-) create mode 100644 lib/Event/DecisionChangeRequestedEvent.php create mode 100644 lib/Service/Decision/DecisionOwnedException.php create mode 100644 lib/Service/Decision/DecisionOwnership.php create mode 100644 lib/Service/Decision/DecisionService.php create mode 100644 lib/Service/Decision/RelationDecisionService.php create mode 100644 lib/Service/Decision/ReviewPolicyResolver.php create mode 100644 tests/Unit/Controller/DecisionOwnershipTest.php create mode 100644 tests/Unit/Db/DecisionMapperTest.php create mode 100644 tests/Unit/Service/Decision/DecisionOwnershipTest.php create mode 100644 tests/Unit/Service/Decision/DecisionServiceTest.php create mode 100644 tests/Unit/Service/Decision/ReviewPolicyResolverTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 40892ca9fc..3be85683ef 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.1.35-unstable.20261010120000 + 2.1.35-unstable.20261011140000 EUPL-1.2 Conduction OpenRegister diff --git a/lib/Controller/EntityRelationsController.php b/lib/Controller/EntityRelationsController.php index 7faa033736..acccc210ec 100644 --- a/lib/Controller/EntityRelationsController.php +++ b/lib/Controller/EntityRelationsController.php @@ -47,6 +47,8 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Service\Decision\DecisionOwnedException; +use OCA\OpenRegister\Service\Decision\RelationDecisionService; use OCA\OpenRegister\Service\Object\PermissionHandler; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -76,6 +78,7 @@ class EntityRelationsController extends Controller { * @param RegisterMapper $registerMapper Register loader (for object lookup disambiguation). * @param EmailLinkMapper $emailLinkMapper EmailLink loader (for email-bound authz indirection). * @param LoggerInterface $logger Structured log sink. + * @param RelationDecisionService $relationDecisions Takes the decision as its own object, under the ownership rule. */ public function __construct( string $appName, @@ -89,6 +92,7 @@ public function __construct( private readonly RegisterMapper $registerMapper, private readonly EmailLinkMapper $emailLinkMapper, private readonly LoggerInterface $logger, + private readonly RelationDecisionService $relationDecisions, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() @@ -96,8 +100,12 @@ public function __construct( /** * PATCH /api/entity-relations/{id}. * - * Body: { bases?: ?array, skipAnonymization?: bool } — any other - * key triggers HTTP 400. + * Body: { bases?: ?array, skipAnonymization?: bool, + * decisionNote?: ?string } — any other key triggers HTTP 400. + * + * The decision is recorded as its own object (decision 197) that the + * relation links; under `decider` ownership a decision someone else took + * answers 403 `decision-owned` naming who decided and when (REQ-ROD-001). * * @param int $id Relation row id. * @@ -162,11 +170,9 @@ public function update(int $id): JSONResponse { unset($body['id'], $body['_route']); try { - $relation = $this->entityRelationMapper->updateDecisionMetadata( - relation: $relation, - fields: $body, - actingUser: $user - ); + $decided = $this->relationDecisions->decide(relation: $relation, body: $body, actor: $user); + } catch (DecisionOwnedException $e) { + return new JSONResponse(data: $e->getRefusal(), statusCode: Http::STATUS_FORBIDDEN); } catch (CustomValidationException $e) { return new JSONResponse( data: ['error' => $e->getMessage(), 'details' => $e->getErrors()], @@ -188,8 +194,11 @@ public function update(int $id): JSONResponse { ); }//end try + $data = $decided['relation']->jsonSerialize(); + $data['decisionRecord'] = $decided['decision']?->jsonSerialize(); + return new JSONResponse( - data: $relation->jsonSerialize(), + data: $data, statusCode: Http::STATUS_OK ); }//end update() diff --git a/lib/Db/EntityRelationMapper.php b/lib/Db/EntityRelationMapper.php index 33f64281ad..c6b37cbd16 100644 --- a/lib/Db/EntityRelationMapper.php +++ b/lib/Db/EntityRelationMapper.php @@ -940,4 +940,24 @@ private function emitDecisionMetadataAuditEntry( $this->auditTrailMapper->insert($auditTrail); }//end emitDecisionMetadataAuditEntry() + + /** + * Link a relation to its current decision object. + * + * The decision itself (who, when, verdict, note) is its own object in + * `openregister_decisions` (decision 197); the relation only points at it. + * The audit trail of the verdict stays with `updateDecisionMetadata()`. + * + * @param EntityRelation $relation The relation. + * @param string $decisionUuid The decision's uuid. + * + * @return EntityRelation The stored relation. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function linkDecision(EntityRelation $relation, string $decisionUuid): EntityRelation { + $relation->setDecisionUuid($decisionUuid); + + return $this->update(entity: $relation); + }//end linkDecision() }//end class diff --git a/lib/Event/DecisionChangeRequestedEvent.php b/lib/Event/DecisionChangeRequestedEvent.php new file mode 100644 index 0000000000..a2b2aa7210 --- /dev/null +++ b/lib/Event/DecisionChangeRequestedEvent.php @@ -0,0 +1,169 @@ + + * @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/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Service\Decision\ReviewPolicy; +use OCP\EventDispatcher\Event; + +/** + * A vetoable question: may this user change this decision? + * + * OpenRegister answers first from the declared policy (a member of a + * supervisor group may). A listener may then allow or refuse. It is never + * dispatched for the decider themselves or for an administrator, who always + * may, and never under `open` ownership. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ +class DecisionChangeRequestedEvent extends Event { + + /** + * Why a listener refused, when it did. + * + * @var string|null + */ + private ?string $reason = null; + + /** + * Constructor. + * + * @param Decision $decision The decision someone wants to change. + * @param string $actorId The user who wants to change it. + * @param ReviewPolicy $policy The policy that applies. + * @param array $subject What the decision is on: subjectType, + * subjectId and, when known, register, + * schema, objectUuid and fileId. + * @param bool $allowed OpenRegister's own answer from the policy. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + public function __construct( + private readonly Decision $decision, + private readonly string $actorId, + private readonly ReviewPolicy $policy, + private readonly array $subject, + private bool $allowed, + ) { + parent::__construct(); + }//end __construct() + + /** + * The decision someone wants to change. + * + * @return Decision The decision. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + public function getDecision(): Decision { + return $this->decision; + }//end getDecision() + + /** + * The user who wants to change it. + * + * @return string The user id. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + public function getActorId(): string { + return $this->actorId; + }//end getActorId() + + /** + * The policy that applies. + * + * @return ReviewPolicy The policy. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + public function getPolicy(): ReviewPolicy { + return $this->policy; + }//end getPolicy() + + /** + * What the decision is on. + * + * @return array subjectType, subjectId and, when known, + * register, schema, objectUuid and fileId. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + public function getSubject(): array { + return $this->subject; + }//end getSubject() + + /** + * Let the change through. + * + * @return void + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + public function allow(): void { + $this->allowed = true; + $this->reason = null; + }//end allow() + + /** + * Refuse the change. + * + * @param string $reason Why, for the log; the caller is told only who decided. + * + * @return void + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + public function refuse(string $reason): void { + $this->allowed = false; + $this->reason = $reason; + }//end refuse() + + /** + * The answer after every listener has had its say. + * + * @return bool True when the change may go through. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + public function isAllowed(): bool { + return $this->allowed; + }//end isAllowed() + + /** + * Why a listener refused. + * + * @return string|null The reason, or null. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 + */ + public function getReason(): ?string { + return $this->reason; + }//end getReason() +}//end class diff --git a/lib/Service/Decision/DecisionOwnedException.php b/lib/Service/Decision/DecisionOwnedException.php new file mode 100644 index 0000000000..78d57554ab --- /dev/null +++ b/lib/Service/Decision/DecisionOwnedException.php @@ -0,0 +1,57 @@ + + * @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/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Decision; + +use Exception; + +/** + * Raised when someone other than the decider, a supervisor or an + * administrator tries to change a decider-owned decision. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +final class DecisionOwnedException extends Exception { + /** + * Constructor. + * + * @param array $refusal The refusal body: error, decision, decidedBy, decidedAt. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function __construct(private readonly array $refusal) { + parent::__construct(message: 'This decision belongs to '.(string)($refusal['decidedBy'] ?? 'another reviewer').'.'); + }//end __construct() + + /** + * The refusal body the API answers with. + * + * @return array error, decision, decidedBy, decidedAt. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function getRefusal(): array { + return $this->refusal; + }//end getRefusal() +}//end class diff --git a/lib/Service/Decision/DecisionOwnership.php b/lib/Service/Decision/DecisionOwnership.php new file mode 100644 index 0000000000..28f236db71 --- /dev/null +++ b/lib/Service/Decision/DecisionOwnership.php @@ -0,0 +1,153 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Decision; + +use DateTimeInterface; +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Event\DecisionChangeRequestedEvent; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IGroupManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The ownership rule on one decision. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +class DecisionOwnership { + + /** + * Constructor. + * + * @param IGroupManager $groupManager Administrator and supervisor membership. + * @param IEventDispatcher $eventDispatcher Asks the app that owns the subject. + * @param LoggerInterface $logger Records refusals by a listener. + */ + public function __construct( + private readonly IGroupManager $groupManager, + private readonly IEventDispatcher $eventDispatcher, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Why a user may not change a decision, or null when they may. + * + * The refusal names who decided and when, never the verdict or the note, + * so a refused reviewer learns nothing a hidden review would keep from them. + * + * @param Decision $decision The current decision. + * @param string $actorId The user who wants to change it. + * @param ReviewPolicy $policy The policy that applies. + * @param array $subject What the decision is on, for listeners. + * + * @return array{error: string, decision: string|null, decidedBy: string|null, decidedAt: string|null}|null + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function refusal(Decision $decision, string $actorId, ReviewPolicy $policy, array $subject=[]): ?array { + if ($policy->isDeciderOwned() === false + || $decision->getDecidedBy() === $actorId + || $this->groupManager->isAdmin($actorId) === true + ) { + return null; + } + + if ($this->mayByEvent(decision: $decision, actorId: $actorId, policy: $policy, subject: $subject) === true) { + return null; + } + + return [ + 'error' => 'decision-owned', + 'decision' => $decision->getUuid(), + 'decidedBy' => $decision->getDecidedBy(), + 'decidedAt' => $decision->getDecidedAt()?->format(DateTimeInterface::ATOM), + ]; + }//end refusal() + + /** + * Whether a supervisor group, then the app that owns the subject, lets the user through. + * + * A listener that throws refuses: the question fails closed. + * + * @param Decision $decision The current decision. + * @param string $actorId The user. + * @param ReviewPolicy $policy The policy. + * @param array $subject What the decision is on. + * + * @return bool True when the change may go through. + */ + private function mayByEvent(Decision $decision, string $actorId, ReviewPolicy $policy, array $subject): bool { + $event = new DecisionChangeRequestedEvent( + decision: $decision, + actorId: $actorId, + policy: $policy, + subject: $subject, + allowed: $this->isSupervisor(actorId: $actorId, policy: $policy) + ); + + try { + $this->eventDispatcher->dispatchTyped($event); + } catch (Throwable $error) { + $this->logger->warning( + message: '[DecisionOwnership] A listener failed; the change is refused.', + context: ['decision' => $decision->getUuid(), 'actor' => $actorId, 'error' => $error->getMessage()] + ); + return false; + } + + if ($event->isAllowed() === false && $event->getReason() !== null) { + $this->logger->info( + message: '[DecisionOwnership] Refused by the app that owns the subject.', + context: ['decision' => $decision->getUuid(), 'actor' => $actorId, 'reason' => $event->getReason()] + ); + } + + return $event->isAllowed(); + }//end mayByEvent() + + /** + * Whether the user is in one of the policy's supervisor groups. + * + * @param string $actorId The user. + * @param ReviewPolicy $policy The policy. + * + * @return bool True for a supervisor. + */ + private function isSupervisor(string $actorId, ReviewPolicy $policy): bool { + foreach ($policy->getSupervisors() as $group) { + if ($this->groupManager->isInGroup($actorId, $group) === true) { + return true; + } + } + + return false; + }//end isSupervisor() +}//end class diff --git a/lib/Service/Decision/DecisionService.php b/lib/Service/Decision/DecisionService.php new file mode 100644 index 0000000000..0eb801424a --- /dev/null +++ b/lib/Service/Decision/DecisionService.php @@ -0,0 +1,136 @@ + + * @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/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Decision; + +use InvalidArgumentException; +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Db\DecisionMapper; +use OCP\AppFramework\Db\DoesNotExistException; + +/** + * Records a decision, finds one, and lists a subject's decisions. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ +class DecisionService { + + /** + * The longest note kept, in characters. + */ + public const MAX_NOTE = 4000; + + /** + * Constructor. + * + * @param DecisionMapper $decisionMapper Stores decisions. + */ + public function __construct( + private readonly DecisionMapper $decisionMapper, + ) { + }//end __construct() + + /** + * Record a new decision on a subject. + * + * @param string $subjectType What kind of thing was decided on. + * @param string $subjectId Its id within that kind. + * @param string $verdict The verdict. + * @param string $decidedBy The user who decided. + * @param string|null $note The decider's note. + * @param string|null $supersedes The uuid of the decision this one replaces. + * + * @return Decision The stored decision, with its uuid and moment. + * + * @throws InvalidArgumentException When a required value is empty or the note is too long. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function record( + string $subjectType, + string $subjectId, + string $verdict, + string $decidedBy, + ?string $note=null, + ?string $supersedes=null, + ): Decision { + foreach (['subjectType' => $subjectType, 'subjectId' => $subjectId, 'verdict' => $verdict, 'decidedBy' => $decidedBy] as $field => $value) { + if (trim($value) === '') { + throw new InvalidArgumentException('A decision needs '.$field.'.'); + } + } + + if ($note !== null && mb_strlen($note) > self::MAX_NOTE) { + throw new InvalidArgumentException('A decision note is at most '.self::MAX_NOTE.' characters.'); + } + + $decision = new Decision(); + $decision->setSubjectType($subjectType); + $decision->setSubjectId($subjectId); + $decision->setVerdict($verdict); + $decision->setDecidedBy($decidedBy); + $decision->setNote($note); + $decision->setSupersedes($supersedes); + + return $this->decisionMapper->insert(entity: $decision); + }//end record() + + /** + * One decision by uuid, or null when the uuid is empty or names none. + * + * @param string|null $uuid The decision uuid. + * + * @return Decision|null The decision. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function find(?string $uuid): ?Decision { + if ($uuid === null || $uuid === '') { + return null; + } + + try { + return $this->decisionMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return null; + } + }//end find() + + /** + * Every decision taken on a subject, newest first. + * + * @param string $subjectType The subject type. + * @param string $subjectId The subject id. + * + * @return Decision[] The decisions. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function history(string $subjectType, string $subjectId): array { + return $this->decisionMapper->findForSubject(subjectType: $subjectType, subjectId: $subjectId); + }//end history() +}//end class diff --git a/lib/Service/Decision/RelationDecisionService.php b/lib/Service/Decision/RelationDecisionService.php new file mode 100644 index 0000000000..177b6aab7a --- /dev/null +++ b/lib/Service/Decision/RelationDecisionService.php @@ -0,0 +1,215 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Decision; + +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Db\EntityRelation; +use OCA\OpenRegister\Db\EntityRelationMapper; +use OCA\OpenRegister\Exception\CustomValidationException; +use OCP\IDBConnection; +use OCP\IUser; +use Throwable; + +/** + * Applies a decision to a relation and records it as a decision object. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +class RelationDecisionService { + + /** + * The occurrence stays readable. + */ + public const VERDICT_RELEASE = 'release'; + + /** + * The occurrence is redacted. + */ + public const VERDICT_REDACT = 'redact'; + + /** + * The body key that carries the decider's note. + */ + public const NOTE_FIELD = 'decisionNote'; + + /** + * Constructor. + * + * @param EntityRelationMapper $relationMapper The relation store and its audited decision write. + * @param DecisionService $decisions Records decision objects. + * @param DecisionOwnership $ownership The ownership rule. + * @param ReviewPolicyResolver $policies The relation's policy. + * @param IDBConnection $db One transaction for the write and its decision. + */ + public function __construct( + private readonly EntityRelationMapper $relationMapper, + private readonly DecisionService $decisions, + private readonly DecisionOwnership $ownership, + private readonly ReviewPolicyResolver $policies, + private readonly IDBConnection $db, + ) { + }//end __construct() + + /** + * Apply a reviewer's decision to a relation. + * + * The subject write check runs before this (the caller's job). Here: the + * relation's current decision is read; under `decider` ownership someone + * else is refused; the change is written through the audited mapper; and + * when anything changed a new decision object is recorded, superseding + * the old one, and linked from the relation, all in one transaction. + * + * @param EntityRelation $relation The relation. + * @param array $body bases, skipAnonymization and decisionNote. + * @param IUser $actor The reviewer. + * + * @return array{relation: EntityRelation, decision: Decision|null} The relation and its current decision. + * + * @throws DecisionOwnedException When the decision belongs to someone else. + * @throws CustomValidationException When the body is malformed. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function decide(EntityRelation $relation, array $body, IUser $actor): array { + $hasNote = array_key_exists(self::NOTE_FIELD, $body); + $note = $this->noteFrom(body: $body); + unset($body[self::NOTE_FIELD]); + + $actorId = $actor->getUID(); + $current = $this->decisions->find(uuid: $relation->getDecisionUuid()); + if ($current !== null) { + $resolved = $this->policies->forRelation(relation: $relation); + $refusal = $this->ownership->refusal(decision: $current, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']); + if ($refusal !== null) { + throw new DecisionOwnedException(refusal: $refusal); + } + } + + $before = [$relation->getBases(), $relation->getSkipAnonymization()]; + + $this->db->beginTransaction(); + try { + $updated = $this->relationMapper->updateDecisionMetadata(relation: $relation, fields: $body, actingUser: $actor); + + if ($this->isNewDecision(current: $current, before: $before, updated: $updated, body: $body, note: $hasNote === true ? [$note] : []) === false) { + $this->db->commit(); + return ['relation' => $updated, 'decision' => $current]; + } + + if ($hasNote === false && $current !== null && $current->getDecidedBy() === $actorId) { + // The same reviewer changing their verdict keeps their note. + $note = $current->getNote(); + } + + $decision = $this->decisions->record( + subjectType: ReviewPolicyResolver::SUBJECT_TYPE, + subjectId: (string)$updated->getId(), + verdict: $this->verdictOf(relation: $updated), + decidedBy: $actorId, + note: $note, + supersedes: $current?->getUuid() + ); + $updated = $this->relationMapper->linkDecision(relation: $updated, decisionUuid: (string)$decision->getUuid()); + $this->db->commit(); + } catch (Throwable $error) { + $this->db->rollBack(); + throw $error; + }//end try + + return ['relation' => $updated, 'decision' => $decision]; + }//end decide() + + /** + * Whether this write is a decision worth recording. + * + * On an undecided relation every write is one, including a reviewer + * confirming the default: that is a decision too. On a decided relation + * only a change of verdict, grounds or note is; repeating the same + * decision records nothing and does not take it over. + * + * @param Decision|null $current The current decision. + * @param array{0: mixed, 1: mixed} $before Bases and skipAnonymization before the write. + * @param EntityRelation $updated The relation after the write. + * @param array $body The decision fields written. + * @param array $note The note given, as a one-item list, or empty when none was given. + * + * @return bool True when a new decision is recorded. + */ + private function isNewDecision(?Decision $current, array $before, EntityRelation $updated, array $body, array $note): bool { + if ($current === null) { + return $body !== [] || $note !== []; + } + + if ($before !== [$updated->getBases(), $updated->getSkipAnonymization()]) { + return true; + } + + return $note !== [] && $note[0] !== $current->getNote(); + }//end isNewDecision() + + /** + * The verdict a relation's state expresses. + * + * @param EntityRelation $relation The relation. + * + * @return string `release` when it is skipped from the anonymise pass, else `redact`. + */ + private function verdictOf(EntityRelation $relation): string { + if ($relation->getSkipAnonymization() === true) { + return self::VERDICT_RELEASE; + } + + return self::VERDICT_REDACT; + }//end verdictOf() + + /** + * The note from a body: a string, or null to clear it. + * + * @param array $body The body. + * + * @return string|null The note, trimmed; null when absent, empty or null. + * + * @throws CustomValidationException When the note is not a string. + */ + private function noteFrom(array $body): ?string { + $note = ($body[self::NOTE_FIELD] ?? null); + if ($note !== null && is_string($note) === false) { + throw new CustomValidationException( + message: 'Invalid decisionNote shape: must be a string or null', + errors: ['field' => self::NOTE_FIELD, 'reason' => 'must_be_string_or_null'] + ); + } + + if ($note === null || trim($note) === '') { + return null; + } + + return trim($note); + }//end noteFrom() +}//end class diff --git a/lib/Service/Decision/ReviewPolicy.php b/lib/Service/Decision/ReviewPolicy.php index 79bb6567c0..d141be67c3 100644 --- a/lib/Service/Decision/ReviewPolicy.php +++ b/lib/Service/Decision/ReviewPolicy.php @@ -41,6 +41,11 @@ final class ReviewPolicy { */ public const ANNOTATION = 'x-openregister-review'; + /** + * The instance default's key in the file settings, for files that belong to no schema. + */ + public const SETTINGS_KEY = 'anonymisationReview'; + /** * Anyone who may write the subject may change its decision. */ diff --git a/lib/Service/Decision/ReviewPolicyResolver.php b/lib/Service/Decision/ReviewPolicyResolver.php new file mode 100644 index 0000000000..1ded70c481 --- /dev/null +++ b/lib/Service/Decision/ReviewPolicyResolver.php @@ -0,0 +1,165 @@ + + * @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/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Decision; + +use OCA\OpenRegister\Db\EntityRelation; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\File\FolderManagementHandler; +use OCA\OpenRegister\Service\Settings\FileSettingsHandler; +use OCP\Files\IRootFolder; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Resolves a relation's policy and describes its subject for listeners. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +class ReviewPolicyResolver { + + /** + * The subject type of a decision on an entity relation. + */ + public const SUBJECT_TYPE = 'entity-relation'; + + /** + * Constructor. + * + * @param SchemaMapper $schemaMapper Reads the schema's declaration. + * @param FileSettingsHandler $fileSettings Holds the instance default. + * @param IRootFolder $rootFolder Finds a relation's file. + * @param FolderManagementHandler $folders Finds the object a file belongs to. + * @param LoggerInterface $logger Records a policy that could not be read. + */ + public function __construct( + private readonly SchemaMapper $schemaMapper, + private readonly FileSettingsHandler $fileSettings, + private readonly IRootFolder $rootFolder, + private readonly FolderManagementHandler $folders, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * The policy for a relation and a description of what it is on. + * + * @param EntityRelation $relation The relation. + * + * @return array{policy: ReviewPolicy, subject: array} + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function forRelation(EntityRelation $relation): array { + $subject = $this->subjectOf(relation: $relation); + $schema = (string)($subject['schema'] ?? ''); + + if ($schema === '') { + return ['policy' => $this->instanceDefault(), 'subject' => $subject]; + } + + try { + $configuration = ($this->schemaMapper->find(id: $schema, _rbac: false, _multitenancy: false)->getConfiguration() ?? []); + } catch (Throwable $error) { + // Fail closed: a policy that cannot be read is decider-owned with + // no supervisors, so only the decider and an administrator may. + $this->logger->warning( + message: '[ReviewPolicyResolver] The schema could not be read; the decision stays with its decider.', + context: ['schema' => $schema, 'error' => $error->getMessage()] + ); + return [ + 'policy' => ReviewPolicy::fromDeclaration(declaration: ['ownership' => ReviewPolicy::OWNERSHIP_DECIDER]), + 'subject' => $subject, + ]; + } + + if (array_key_exists(ReviewPolicy::ANNOTATION, $configuration) === false) { + return ['policy' => $this->instanceDefault(), 'subject' => $subject]; + } + + return ['policy' => ReviewPolicy::fromDeclaration(declaration: $configuration[ReviewPolicy::ANNOTATION]), 'subject' => $subject]; + }//end forRelation() + + /** + * What the relation is on: its own object, or the object its file belongs to. + * + * @param EntityRelation $relation The relation. + * + * @return array subjectType, subjectId, fileId, objectId, + * register, schema and objectUuid. + */ + private function subjectOf(EntityRelation $relation): array { + $subject = [ + 'subjectType' => self::SUBJECT_TYPE, + 'subjectId' => (string)$relation->getId(), + 'fileId' => $relation->getFileId(), + 'objectId' => $relation->getObjectId(), + 'register' => $relation->getRegisterId(), + 'schema' => $relation->getSchemaId(), + 'objectUuid' => $relation->getObjectUuid(), + ]; + + $fileId = (int)($relation->getFileId() ?? 0); + if ((string)$subject['schema'] !== '' || $fileId <= 0) { + return $subject; + } + + try { + $node = $this->rootFolder->getFirstNodeById($fileId); + $object = null; + if ($node !== null) { + $object = $this->folders->getObjectForFile(file: $node); + } + } catch (Throwable $error) { + $this->logger->info( + message: '[ReviewPolicyResolver] The file\'s object could not be found; the instance default applies.', + context: ['fileId' => $fileId, 'error' => $error->getMessage()] + ); + return $subject; + } + + if ($object !== null) { + $subject['register'] = $object->getRegister(); + $subject['schema'] = $object->getSchema(); + $subject['objectUuid'] = $object->getUuid(); + } + + return $subject; + }//end subjectOf() + + /** + * The instance default, for files that belong to no schema. Unset is open. + * + * @return ReviewPolicy The policy. + */ + private function instanceDefault(): ReviewPolicy { + $settings = $this->fileSettings->getFileSettingsOnly(); + + return ReviewPolicy::fromDeclaration(declaration: ($settings[ReviewPolicy::SETTINGS_KEY] ?? null)); + }//end instanceDefault() +}//end class diff --git a/lib/Service/Settings/FileSettingsHandler.php b/lib/Service/Settings/FileSettingsHandler.php index 4b90254831..c7c6ef230a 100644 --- a/lib/Service/Settings/FileSettingsHandler.php +++ b/lib/Service/Settings/FileSettingsHandler.php @@ -23,6 +23,9 @@ namespace OCA\OpenRegister\Service\Settings; use Exception; +use InvalidArgumentException; +use OCA\OpenRegister\Service\Decision\ReviewAnnotationValidator; +use OCA\OpenRegister\Service\Decision\ReviewPolicy; use OCP\IAppConfig; use RuntimeException; @@ -251,6 +254,9 @@ public function updateFileSettingsOnly(array $fileData): array { 'entityRecognitionEnabled' => $fileData['entityRecognitionEnabled'] ?? false, 'entityRecognitionMethod' => $fileData['entityRecognitionMethod'] ?? 'auto', // Auto (unconfigured marker), regex, presidio, openanonymiser, llm, hybrid. + // Who owns a redaction decision on a file that belongs to no + // schema (reviewer-owns-their-decisions); null is open. + ReviewPolicy::SETTINGS_KEY => $this->reviewDefault(fileData: $fileData), ]; $auditKeys = ['fileManagement', OwnSettingsChangeRecorder::PLAIN_PREFIX . self::OPENANONYMISER_PASSWORD_KEY]; @@ -269,6 +275,31 @@ public function updateFileSettingsOnly(array $fileData): array { }//end try }//end updateFileSettingsOnly() + /** + * The instance review default from a settings save, refused when unsound. + * + * @param array $fileData The settings being saved. + * + * @return array|null The declaration, or null when none is given. + * + * @throws InvalidArgumentException When the declaration names an ownership nobody can honour. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + private function reviewDefault(array $fileData): ?array { + $declaration = ($fileData[ReviewPolicy::SETTINGS_KEY] ?? null); + if ($declaration === null) { + return null; + } + + $errors = (new ReviewAnnotationValidator())->validate(configuration: [ReviewPolicy::ANNOTATION => $declaration]); + if ($errors !== []) { + throw new InvalidArgumentException(implode(' ', array_column($errors, 'message'))); + } + + return ReviewPolicy::fromDeclaration(declaration: $declaration)->toArray(); + }//end reviewDefault() + /** * The stored OpenAnonymiser password, for the outgoing request only. * diff --git a/tests/Unit/Controller/DecisionOwnershipTest.php b/tests/Unit/Controller/DecisionOwnershipTest.php new file mode 100644 index 0000000000..520d185b84 --- /dev/null +++ b/tests/Unit/Controller/DecisionOwnershipTest.php @@ -0,0 +1,326 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use DateTime; +use OCA\OpenRegister\Controller\EntityRelationsController; +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Db\DecisionMapper; +use OCA\OpenRegister\Db\EmailLinkMapper; +use OCA\OpenRegister\Db\EntityRelation; +use OCA\OpenRegister\Db\EntityRelationMapper; +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\Service\Decision\DecisionOwnership; +use OCA\OpenRegister\Service\Decision\DecisionService; +use OCA\OpenRegister\Service\Decision\RelationDecisionService; +use OCA\OpenRegister\Service\Decision\ReviewPolicyResolver; +use OCA\OpenRegister\Service\File\FolderManagementHandler; +use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCA\OpenRegister\Service\Settings\FileSettingsHandler; +use OCP\AppFramework\Http; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IRootFolder; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Through `PATCH /api/entity-relations/{id}`: a reviewer's decision is its own + * object, and under `decider` ownership only the decider, a supervisor or an + * administrator changes it (REQ-ROD-001, decision 197). + * + * Real controller, real RelationDecisionService, DecisionOwnership, + * DecisionService and ReviewPolicyResolver; the stores are in-memory. + * + * @covers \OCA\OpenRegister\Controller\EntityRelationsController + * @covers \OCA\OpenRegister\Service\Decision\RelationDecisionService + * @covers \OCA\OpenRegister\Service\Decision\DecisionOwnedException + * @uses \OCA\OpenRegister\Service\Decision\DecisionOwnership + * @uses \OCA\OpenRegister\Service\Decision\DecisionService + * @uses \OCA\OpenRegister\Service\Decision\ReviewPolicy + * @uses \OCA\OpenRegister\Service\Decision\ReviewPolicyResolver + * @uses \OCA\OpenRegister\Event\DecisionChangeRequestedEvent + * @uses \OCA\OpenRegister\Service\Settings\FileSettingsHandler + * @uses \OCA\OpenRegister\Db\Decision + * @uses \OCA\OpenRegister\Db\EntityRelation + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +class DecisionOwnershipTest extends TestCase { + + /** + * The relation under review, on an object of schema 12. + * + * @var EntityRelation + */ + private EntityRelation $relation; + + /** + * Decisions stored, by uuid. + * + * @var array + */ + private array $decisions = []; + + /** + * Schema 12's configuration. + * + * @var array + */ + private array $schemaConfiguration = [ + 'x-openregister-review' => ['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => false], + ]; + + /** + * Group memberships by user. + * + * @var array> + */ + private array $groups = ['coord' => ['woo-coordinator'], 'root' => ['admin']]; + + /** + * Set up a relation every reviewer may write. + * + * @return void + */ + protected function setUp(): void { + $this->relation = new EntityRelation(); + $this->relation->setId(7); + $this->relation->setObjectId(5); + $this->relation->setRegisterId('3'); + $this->relation->setSchemaId('12'); + }//end setUp() + + /** + * The controller as `$uid` sees it. + * + * @param string $uid The signed-in user. + * + * @return EntityRelationsController The controller. + */ + private function controllerAs(string $uid, array $body): EntityRelationsController { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + + $request = $this->createMock(IRequest::class); + $request->method('getHeader')->willReturn('application/json'); + $request->method('getParams')->willReturn($body + ['id' => 7, '_route' => 'entityRelations#update']); + + $relations = $this->createMock(EntityRelationMapper::class); + $relations->method('find')->willReturnCallback(fn (): EntityRelation => $this->relation); + $relations->method('updateDecisionMetadata')->willReturnCallback( + static function (EntityRelation $relation, array $fields): EntityRelation { + if (array_key_exists('bases', $fields) === true) { + $relation->setBases($fields['bases']); + } + + if (array_key_exists('skipAnonymization', $fields) === true) { + $relation->setSkipAnonymization($fields['skipAnonymization']); + } + + return $relation; + } + ); + $relations->method('linkDecision')->willReturnCallback( + static function (EntityRelation $relation, string $decisionUuid): EntityRelation { + $relation->setDecisionUuid($decisionUuid); + return $relation; + } + ); + + // Every reviewer may write the relation's object: the subject check passes. + $schema = new Schema(); + $schema->setConfiguration($this->schemaConfiguration); + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturn($schema); + $magic = $this->createMock(MagicMapper::class); + $magic->method('find')->willReturn(new ObjectEntity()); + $permissions = $this->createMock(PermissionHandler::class); + $permissions->method('hasPermission')->willReturn(true); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturnCallback(fn (string $id): bool => in_array('admin', ($this->groups[$id] ?? []), true)); + $groups->method('isInGroup')->willReturnCallback(fn (string $id, string $gid): bool => in_array($gid, ($this->groups[$id] ?? []), true)); + + $decisionMapper = $this->createMock(DecisionMapper::class); + $decisionMapper->method('findByUuid')->willReturnCallback(fn (string $uuid): Decision => $this->decisions[$uuid]); + $decisionMapper->method('insert')->willReturnCallback( + function (Decision $decision): Decision { + $decision->setUuid('d-'.(count($this->decisions) + 1)); + $decision->setDecidedAt(new DateTime('2026-10-11T10:00:00+00:00')); + $this->decisions[(string)$decision->getUuid()] = $decision; + return $decision; + } + ); + + $logger = $this->createMock(LoggerInterface::class); + $decide = new RelationDecisionService( + relationMapper: $relations, + decisions: new DecisionService(decisionMapper: $decisionMapper), + ownership: new DecisionOwnership(groupManager: $groups, eventDispatcher: $this->createMock(IEventDispatcher::class), logger: $logger), + policies: new ReviewPolicyResolver( + schemaMapper: $schemas, + fileSettings: new FileSettingsHandler(appConfig: $this->createMock(IAppConfig::class)), + rootFolder: $this->createMock(IRootFolder::class), + folders: $this->createMock(FolderManagementHandler::class), + logger: $logger + ), + db: $this->createMock(IDBConnection::class) + ); + + return new EntityRelationsController( + 'openregister', + $request, + $relations, + $session, + $this->createMock(IRootFolder::class), + $permissions, + $magic, + $schemas, + $this->createMock(RegisterMapper::class), + $this->createMock(EmailLinkMapper::class), + $logger, + $decide + ); + }//end controllerAs() + + /** + * j.devries redacts the name under 5.1.2e. + * + * @return void + */ + private function firstReviewerDecides(): void { + $response = $this->controllerAs(uid: 'j.devries', body: ['bases' => ['5.1.2e'], 'skipAnonymization' => false, 'decisionNote' => 'Private person.'])->update(7); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + }//end firstReviewerDecides() + + /** + * A second reviewer is refused, told who decided and when, and nothing changes. + * + * @return void + */ + public function testASecondReviewerIsRefused(): void { + $this->firstReviewerDecides(); + + $response = $this->controllerAs(uid: 'p.bakker', body: ['skipAnonymization' => true])->update(7); + + self::assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + self::assertSame( + ['error' => 'decision-owned', 'decision' => 'd-1', 'decidedBy' => 'j.devries', 'decidedAt' => '2026-10-11T10:00:00+00:00'], + $response->getData() + ); + self::assertFalse($this->relation->getSkipAnonymization()); + self::assertSame('d-1', $this->relation->getDecisionUuid()); + self::assertCount(1, $this->decisions); + }//end testASecondReviewerIsRefused() + + /** + * A coordinator's change is a new decision, by the coordinator, superseding the first. + * + * @return void + */ + public function testASupervisorMayChangeAnotherReviewersDecision(): void { + $this->firstReviewerDecides(); + + $response = $this->controllerAs(uid: 'coord', body: ['skipAnonymization' => true])->update(7); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame('d-2', $this->relation->getDecisionUuid()); + $decision = $this->decisions['d-2']; + self::assertSame('coord', $decision->getDecidedBy()); + self::assertSame('release', $decision->getVerdict()); + self::assertSame('d-1', $decision->getSupersedes()); + self::assertNull($decision->getNote(), 'The first reviewer\'s note does not pass to the coordinator.'); + self::assertSame('j.devries', $this->decisions['d-1']->getDecidedBy(), 'The first decision is kept as it was.'); + self::assertSame('d-2', $response->getData()['decisionRecord']['uuid'] ?? null); + }//end testASupervisorMayChangeAnotherReviewersDecision() + + /** + * An administrator may change it. + * + * @return void + */ + public function testAnAdministratorMay(): void { + $this->firstReviewerDecides(); + + $response = $this->controllerAs(uid: 'root', body: ['bases' => ['5.1.2a']])->update(7); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame('root', $this->decisions['d-2']->getDecidedBy()); + }//end testAnAdministratorMay() + + /** + * An undecided relation is decided by whoever may write it, as today. + * + * @return void + */ + public function testAnUndecidedRelationIsOpen(): void { + $response = $this->controllerAs(uid: 'p.bakker', body: ['skipAnonymization' => false])->update(7); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame('d-1', $this->relation->getDecisionUuid(), 'Confirming the default is a decision too.'); + self::assertSame('p.bakker', $this->decisions['d-1']->getDecidedBy()); + self::assertSame('redact', $this->decisions['d-1']->getVerdict()); + self::assertSame('d-1', $response->getData()['decision']); + }//end testAnUndecidedRelationIsOpen() + + /** + * Under open ownership a second reviewer may change the first's decision. + * + * @return void + */ + public function testOpenOwnershipKeepsTodaysBehaviour(): void { + $this->schemaConfiguration = []; + $this->firstReviewerDecides(); + + $response = $this->controllerAs(uid: 'p.bakker', body: ['skipAnonymization' => true])->update(7); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertTrue($this->relation->getSkipAnonymization()); + self::assertSame('p.bakker', $this->decisions['d-2']->getDecidedBy()); + }//end testOpenOwnershipKeepsTodaysBehaviour() + + /** + * The note is the decider's: kept on their own change, refused to another, malformed refused. + * + * @return void + */ + public function testTheNoteFollowsTheDecision(): void { + $this->firstReviewerDecides(); + self::assertSame('Private person.', $this->decisions['d-1']->getNote()); + + // Another reviewer may not even change the note. + $refused = $this->controllerAs(uid: 'p.bakker', body: ['decisionNote' => 'I disagree.'])->update(7); + self::assertSame(Http::STATUS_FORBIDDEN, $refused->getStatus()); + + // The decider changing their verdict keeps their note. + $this->controllerAs(uid: 'j.devries', body: ['bases' => ['5.1.2e', '5.1.2i']])->update(7); + self::assertSame('Private person.', $this->decisions['d-2']->getNote()); + + // Repeating the same decision records nothing new. + $this->controllerAs(uid: 'j.devries', body: ['bases' => ['5.1.2e', '5.1.2i']])->update(7); + self::assertCount(2, $this->decisions); + + // A note that is not text is refused with 400. + $malformed = $this->controllerAs(uid: 'j.devries', body: ['decisionNote' => ['x']])->update(7); + self::assertSame(Http::STATUS_BAD_REQUEST, $malformed->getStatus()); + }//end testTheNoteFollowsTheDecision() +}//end class diff --git a/tests/Unit/Controller/EntityRelationsControllerTest.php b/tests/Unit/Controller/EntityRelationsControllerTest.php index 0345013141..4f0b820dd1 100644 --- a/tests/Unit/Controller/EntityRelationsControllerTest.php +++ b/tests/Unit/Controller/EntityRelationsControllerTest.php @@ -27,7 +27,19 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Db\DecisionMapper; +use OCA\OpenRegister\Service\Decision\DecisionOwnership; +use OCA\OpenRegister\Service\Decision\DecisionService; +use OCA\OpenRegister\Service\Decision\RelationDecisionService; +use OCA\OpenRegister\Service\Decision\ReviewPolicyResolver; +use OCA\OpenRegister\Service\File\FolderManagementHandler; use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCA\OpenRegister\Service\Settings\FileSettingsHandler; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\Files\File; @@ -79,6 +91,8 @@ class EntityRelationsControllerTest extends TestCase { private IUser&MockObject $user; + private RelationDecisionService $relationDecisions; + protected function setUp(): void { $this->request = $this->createMock(IRequest::class); $this->mapper = $this->createMock(EntityRelationMapper::class); @@ -93,6 +107,39 @@ protected function setUp(): void { $this->user = $this->createMock(IUser::class); $this->user->method('getUID')->willReturn('alice'); + + // The real decision path over the mocked relation mapper: an undecided + // relation records its first decision and links it. + $decisionMapper = $this->createMock(DecisionMapper::class); + $decisionMapper->method('insert')->willReturnCallback( + static function (Decision $decision): Decision { + $decision->setUuid('d-1'); + return $decision; + } + ); + $this->mapper->method('linkDecision')->willReturnCallback( + static function (EntityRelation $relation, string $decisionUuid): EntityRelation { + $relation->setDecisionUuid($decisionUuid); + return $relation; + } + ); + $this->relationDecisions = new RelationDecisionService( + relationMapper: $this->mapper, + decisions: new DecisionService(decisionMapper: $decisionMapper), + ownership: new DecisionOwnership( + groupManager: $this->createMock(IGroupManager::class), + eventDispatcher: $this->createMock(IEventDispatcher::class), + logger: $this->logger + ), + policies: new ReviewPolicyResolver( + schemaMapper: $this->schemaMapper, + fileSettings: new FileSettingsHandler(appConfig: $this->createMock(IAppConfig::class)), + rootFolder: $this->rootFolder, + folders: $this->createMock(FolderManagementHandler::class), + logger: $this->logger + ), + db: $this->createMock(IDBConnection::class) + ); }//end setUp() private function makeController(): EntityRelationsController { @@ -107,7 +154,8 @@ private function makeController(): EntityRelationsController { $this->schemaMapper, $this->registerMapper, $this->emailLinkMapper, - $this->logger + $this->logger, + $this->relationDecisions ); }//end makeController() @@ -206,6 +254,7 @@ public function testReturns200OnSuccessfulPatch(): void { $relation->setFileId(7); $updatedRelation = new EntityRelation(); + $updatedRelation->setId(42); $updatedRelation->setFileId(7); $updatedRelation->setBases(['uuid-a']); @@ -244,6 +293,10 @@ public function testReturns200OnSuccessfulPatch(): void { $this->assertSame(Http::STATUS_OK, $response->getStatus()); $body = $response->getData(); $this->assertSame(['uuid-a'], $body['bases']); + // The decision is its own object, linked from the relation (decision 197). + $this->assertSame('d-1', $body['decision']); + $this->assertSame('alice', $body['decisionRecord']['decidedBy']); + $this->assertSame('redact', $body['decisionRecord']['verdict']); }//end testReturns200OnSuccessfulPatch() public function testReturns403WhenObjectBoundButHasPermissionDeniesUpdate(): void { @@ -285,6 +338,7 @@ public function testReturns200WhenObjectBoundAndHasPermissionGrantsUpdate(): voi $relation->setSchemaId('schema-uuid'); $updatedRelation = new EntityRelation(); + $updatedRelation->setId(42); $updatedRelation->setObjectId(99); $updatedRelation->setBases(['uuid-a']); diff --git a/tests/Unit/Db/DecisionMapperTest.php b/tests/Unit/Db/DecisionMapperTest.php new file mode 100644 index 0000000000..9cd2287268 --- /dev/null +++ b/tests/Unit/Db/DecisionMapperTest.php @@ -0,0 +1,156 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use InvalidArgumentException; +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Db\DecisionMapper; +use OCA\OpenRegister\Db\EntityRelation; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; + +/** + * Decisions are stored with a uuid and a moment, and found by uuid or subject. + * + * @covers \OCA\OpenRegister\Db\DecisionMapper + * @covers \OCA\OpenRegister\Db\Decision + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ +class DecisionMapperTest extends TestCase { + + /** + * The columns written by the last insert. + * + * @var array + */ + private array $written = []; + + /** + * The conditions of the last select. + * + * @var array + */ + private array $conditions = []; + + /** + * A mapper over a query builder that records what it is asked. + * + * @return DecisionMapper The mapper. + */ + private function mapper(): DecisionMapper { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturnCallback( + function (string $column, mixed $value): string { + $this->conditions[] = $column.'='.(string)$value; + return $column; + } + ); + + $qb = $this->createMock(IQueryBuilder::class); + foreach (['select', 'from', 'where', 'andWhere', 'orderBy', 'addOrderBy', 'insert'] as $fluent) { + $qb->method($fluent)->willReturnSelf(); + } + + $qb->method('expr')->willReturn($expr); + $qb->method('createNamedParameter')->willReturnCallback(static fn (mixed $value): mixed => $value); + $qb->method('setValue')->willReturnCallback( + function (string $column, mixed $value) use ($qb): IQueryBuilder { + $this->written[$column] = $value; + return $qb; + } + ); + $qb->method('executeStatement')->willReturn(1); + $qb->method('getLastInsertId')->willReturn(5); + + $db = $this->createMock(IDBConnection::class); + $db->method('getQueryBuilder')->willReturn($qb); + + return new class($db) extends DecisionMapper { + /** + * The rows a select answers. + * + * @var array + */ + public array $rows = []; + + /** + * Answer the first row instead of reading a database. + * + * @param IQueryBuilder $query The query. + * + * @return Decision The row. + */ + protected function findEntity(IQueryBuilder $query): Decision { + return $this->rows[0]; + } + + /** + * Answer every row instead of reading a database. + * + * @param IQueryBuilder $query The query. + * + * @return array The rows. + */ + protected function findEntities(IQueryBuilder $query): array { + return $this->rows; + } + }; + }//end mapper() + + /** + * An inserted decision gets a uuid and the moment it was taken. + * + * @return void + */ + public function testAnInsertedDecisionGetsAUuidAndAMoment(): void { + $decision = new Decision(); + $decision->setSubjectType('entity-relation'); + $decision->setSubjectId('7'); + $decision->setVerdict('redact'); + $decision->setDecidedBy('j.devries'); + + $stored = $this->mapper()->insert($decision); + + self::assertMatchesRegularExpression('/^[0-9a-f-]{36}$/', (string)$stored->getUuid()); + self::assertNotNull($stored->getDecidedAt()); + self::assertSame(5, $stored->getId()); + self::assertSame('j.devries', $this->written['decided_by'] ?? null); + self::assertSame('entity-relation', $this->written['subject_type'] ?? null); + self::assertSame($stored->getUuid(), $stored->jsonSerialize()['uuid']); + }//end testAnInsertedDecisionGetsAUuidAndAMoment() + + /** + * Only decisions are stored. + * + * @return void + */ + public function testOnlyDecisionsAreStored(): void { + $this->expectException(InvalidArgumentException::class); + $this->mapper()->insert(new EntityRelation()); + }//end testOnlyDecisionsAreStored() + + /** + * Finding filters on the uuid, or on the subject's type and id. + * + * @return void + */ + public function testItFindsByUuidAndBySubject(): void { + $row = new Decision(); + $mapper = $this->mapper(); + $mapper->rows = [$row]; + + self::assertSame($row, $mapper->findByUuid(uuid: 'd-1')); + self::assertSame([$row], $mapper->findForSubject(subjectType: 'entity-relation', subjectId: '7')); + self::assertSame(['uuid=d-1', 'subject_type=entity-relation', 'subject_id=7'], $this->conditions); + }//end testItFindsByUuidAndBySubject() +}//end class diff --git a/tests/Unit/Db/EntityRelationMapperUpdateDecisionMetadataTest.php b/tests/Unit/Db/EntityRelationMapperUpdateDecisionMetadataTest.php index 05404d71ba..d11f5fab3e 100644 --- a/tests/Unit/Db/EntityRelationMapperUpdateDecisionMetadataTest.php +++ b/tests/Unit/Db/EntityRelationMapperUpdateDecisionMetadataTest.php @@ -500,4 +500,19 @@ public function testDispatchFailureDoesNotMaskUpdate(): void { $this->assertTrue($result->getSkipAnonymization()); }//end testDispatchFailureDoesNotMaskUpdate() + + /** + * Linking a decision stores its uuid on the relation. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ + public function testLinkDecisionStoresTheUuid(): void { + $relation = new EntityRelation(); + $mapper = $this->mapperWithMockedUpdate(); + $mapper->expects($this->once())->method('update')->with($this->identicalTo($relation)); + + $stored = $mapper->linkDecision(relation: $relation, decisionUuid: 'd-1'); + + $this->assertSame('d-1', $stored->getDecisionUuid()); + }//end testLinkDecisionStoresTheUuid() }//end class diff --git a/tests/Unit/Service/Decision/DecisionOwnershipTest.php b/tests/Unit/Service/Decision/DecisionOwnershipTest.php new file mode 100644 index 0000000000..891cbd00f9 --- /dev/null +++ b/tests/Unit/Service/Decision/DecisionOwnershipTest.php @@ -0,0 +1,199 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Decision; + +use DateTime; +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Event\DecisionChangeRequestedEvent; +use OCA\OpenRegister\Service\Decision\DecisionOwnership; +use OCA\OpenRegister\Service\Decision\ReviewPolicy; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IGroupManager; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * The ownership rule on a decision object (REQ-ROD-001, REQ-DEC-002). + * + * @covers \OCA\OpenRegister\Service\Decision\DecisionOwnership + * @covers \OCA\OpenRegister\Event\DecisionChangeRequestedEvent + * @uses \OCA\OpenRegister\Service\Decision\ReviewPolicy + * @uses \OCA\OpenRegister\Db\Decision + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +class DecisionOwnershipTest extends TestCase { + + /** + * Group memberships by user. + * + * @var array> + */ + private array $groups = ['coord' => ['woo-coordinator'], 'root' => ['admin']]; + + /** + * What a listener does with the question, if anything. + * + * @var (callable(DecisionChangeRequestedEvent): void)|null + */ + private $listener = null; + + /** + * The events dispatched. + * + * @var array + */ + private array $dispatched = []; + + /** + * The rule under test, over a group manager and dispatcher that read the fields above. + * + * @return DecisionOwnership The rule. + */ + private function ownership(): DecisionOwnership { + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturnCallback(fn (string $uid): bool => in_array('admin', ($this->groups[$uid] ?? []), true)); + $groups->method('isInGroup')->willReturnCallback(fn (string $uid, string $gid): bool => in_array($gid, ($this->groups[$uid] ?? []), true)); + + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + function (DecisionChangeRequestedEvent $event): void { + $this->dispatched[] = $event; + if ($this->listener !== null) { + ($this->listener)($event); + } + } + ); + + return new DecisionOwnership(groupManager: $groups, eventDispatcher: $dispatcher, logger: $this->createMock(LoggerInterface::class)); + }//end ownership() + + /** + * A decision by j.devries. + * + * @return Decision The decision. + */ + private function decision(): Decision { + $decision = new Decision(); + $decision->setUuid('d-1'); + $decision->setVerdict('redact'); + $decision->setDecidedBy('j.devries'); + $decision->setDecidedAt(new DateTime('2026-10-11T09:30:00+00:00')); + + return $decision; + }//end decision() + + /** + * The decider-owned policy with the coordinator group. + * + * @return ReviewPolicy The policy. + */ + private function decider(): ReviewPolicy { + return ReviewPolicy::fromDeclaration(declaration: ['ownership' => 'decider', 'supervisors' => ['woo-coordinator']]); + }//end decider() + + /** + * A second reviewer is refused, told who decided and when, not the verdict. + * + * @return void + */ + public function testASecondReviewerIsRefused(): void { + $refusal = $this->ownership()->refusal(decision: $this->decision(), actorId: 'p.bakker', policy: $this->decider(), subject: ['subjectType' => 'entity-relation', 'subjectId' => '7']); + + self::assertSame( + ['error' => 'decision-owned', 'decision' => 'd-1', 'decidedBy' => 'j.devries', 'decidedAt' => '2026-10-11T09:30:00+00:00'], + $refusal + ); + self::assertCount(1, $this->dispatched); + self::assertSame(['subjectType' => 'entity-relation', 'subjectId' => '7'], $this->dispatched[0]->getSubject()); + }//end testASecondReviewerIsRefused() + + /** + * The decider, a supervisor and an administrator may; open ownership lets anyone. + * + * @return void + */ + public function testTheDeciderASupervisorAnAdministratorAndOpenOwnershipMay(): void { + $ownership = $this->ownership(); + + self::assertNull($ownership->refusal(decision: $this->decision(), actorId: 'j.devries', policy: $this->decider())); + self::assertNull($ownership->refusal(decision: $this->decision(), actorId: 'coord', policy: $this->decider())); + self::assertNull($ownership->refusal(decision: $this->decision(), actorId: 'root', policy: $this->decider())); + self::assertNull($ownership->refusal(decision: $this->decision(), actorId: 'p.bakker', policy: ReviewPolicy::fromDeclaration(declaration: null))); + + // The supervisor's answer went through the event; the other three never asked. + self::assertCount(1, $this->dispatched); + self::assertTrue($this->dispatched[0]->isAllowed()); + }//end testTheDeciderASupervisorAnAdministratorAndOpenOwnershipMay() + + /** + * The app that owns the subject may allow someone the policy does not name. + * + * @return void + */ + public function testAListenerMayAllow(): void { + $this->listener = static function (DecisionChangeRequestedEvent $event): void { + if ($event->getActorId() === 'case-supervisor') { + $event->allow(); + } + }; + + self::assertNull($this->ownership()->refusal(decision: $this->decision(), actorId: 'case-supervisor', policy: $this->decider())); + }//end testAListenerMayAllow() + + /** + * The app that owns the subject may refuse a supervisor the policy names. + * + * @return void + */ + public function testAListenerMayRefuse(): void { + $this->listener = static function (DecisionChangeRequestedEvent $event): void { + $event->refuse(reason: 'not this case'); + }; + + $refusal = $this->ownership()->refusal(decision: $this->decision(), actorId: 'coord', policy: $this->decider()); + + self::assertSame('decision-owned', $refusal['error'] ?? null); + self::assertSame('not this case', $this->dispatched[0]->getReason()); + }//end testAListenerMayRefuse() + + /** + * A listener that fails refuses: the question fails closed. + * + * @return void + */ + public function testAFailingListenerRefuses(): void { + $this->listener = static function (): void { + throw new RuntimeException('listener down'); + }; + + $refusal = $this->ownership()->refusal(decision: $this->decision(), actorId: 'coord', policy: $this->decider()); + + self::assertSame('decision-owned', $refusal['error'] ?? null); + }//end testAFailingListenerRefuses() + + /** + * An allow after a refusal clears the reason. + * + * @return void + */ + public function testAnAllowClearsTheReason(): void { + $event = new DecisionChangeRequestedEvent(decision: $this->decision(), actorId: 'x', policy: $this->decider(), subject: [], allowed: false); + $event->refuse(reason: 'first'); + $event->allow(); + + self::assertTrue($event->isAllowed()); + self::assertNull($event->getReason()); + self::assertSame('x', $event->getActorId()); + self::assertSame('d-1', $event->getDecision()->getUuid()); + self::assertTrue($event->getPolicy()->isDeciderOwned()); + }//end testAnAllowClearsTheReason() +}//end class diff --git a/tests/Unit/Service/Decision/DecisionServiceTest.php b/tests/Unit/Service/Decision/DecisionServiceTest.php new file mode 100644 index 0000000000..b2a2beeb6a --- /dev/null +++ b/tests/Unit/Service/Decision/DecisionServiceTest.php @@ -0,0 +1,118 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Decision; + +use InvalidArgumentException; +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Db\DecisionMapper; +use OCA\OpenRegister\Service\Decision\DecisionService; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\TestCase; + +/** + * A decision is recorded as its own object and read back by uuid or subject (REQ-DEC-001). + * + * @covers \OCA\OpenRegister\Service\Decision\DecisionService + * @uses \OCA\OpenRegister\Db\Decision + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + */ +class DecisionServiceTest extends TestCase { + + /** + * Recording stores who, what, on what, the note and what it supersedes. + * + * @return void + */ + public function testItRecordsADecision(): void { + $mapper = $this->createMock(DecisionMapper::class); + $mapper->expects($this->once())->method('insert')->willReturnCallback( + static function (Decision $decision): Decision { + $decision->setUuid('d-2'); + return $decision; + } + ); + + $decision = (new DecisionService(decisionMapper: $mapper))->record( + subjectType: 'entity-relation', + subjectId: '7', + verdict: 'release', + decidedBy: 'coord', + note: 'Publicly known name.', + supersedes: 'd-1' + ); + + self::assertSame( + [ + 'uuid' => 'd-2', + 'subjectType' => 'entity-relation', + 'subjectId' => '7', + 'verdict' => 'release', + 'decidedBy' => 'coord', + 'decidedAt' => null, + 'note' => 'Publicly known name.', + 'supersedes' => 'd-1', + ], + $decision->jsonSerialize() + ); + }//end testItRecordsADecision() + + /** + * A decision without a decider or a verdict, or with a note past the limit, is refused. + * + * @return void + */ + public function testAnIncompleteDecisionIsRefused(): void { + $mapper = $this->createMock(DecisionMapper::class); + $mapper->expects($this->never())->method('insert'); + $service = new DecisionService(decisionMapper: $mapper); + + foreach ([['verdict', ''], ['decidedBy', ' ']] as [$field, $value]) { + $args = ['subjectType' => 'entity-relation', 'subjectId' => '7', 'verdict' => 'redact', 'decidedBy' => 'j.devries']; + $args[$field] = $value; + try { + $service->record(...$args); + self::fail('A decision without '.$field.' was stored.'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString($field, $e->getMessage()); + } + } + + $this->expectException(InvalidArgumentException::class); + $service->record(subjectType: 'entity-relation', subjectId: '7', verdict: 'redact', decidedBy: 'j.devries', note: str_repeat('x', DecisionService::MAX_NOTE + 1)); + }//end testAnIncompleteDecisionIsRefused() + + /** + * Finding answers the decision, or null for an empty or unknown uuid. + * + * @return void + */ + public function testItFindsByUuidAndListsASubject(): void { + $found = new Decision(); + $mapper = $this->createMock(DecisionMapper::class); + $mapper->method('findByUuid')->willReturnCallback( + static function (string $uuid) use ($found): Decision { + if ($uuid === 'd-1') { + return $found; + } + + throw new DoesNotExistException('none'); + } + ); + $mapper->expects($this->once())->method('findForSubject')->with('entity-relation', '7')->willReturn([$found]); + $service = new DecisionService(decisionMapper: $mapper); + + self::assertSame($found, $service->find(uuid: 'd-1')); + self::assertNull($service->find(uuid: 'gone')); + self::assertNull($service->find(uuid: null)); + self::assertNull($service->find(uuid: '')); + self::assertSame([$found], $service->history(subjectType: 'entity-relation', subjectId: '7')); + }//end testItFindsByUuidAndListsASubject() +}//end class diff --git a/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php b/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php new file mode 100644 index 0000000000..5937206ee0 --- /dev/null +++ b/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php @@ -0,0 +1,141 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Decision; + +use OCA\OpenRegister\Db\EntityRelation; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Decision\ReviewPolicyResolver; +use OCA\OpenRegister\Service\File\FolderManagementHandler; +use OCA\OpenRegister\Service\Settings\FileSettingsHandler; +use OCP\Files\File; +use OCP\Files\IRootFolder; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * A relation's policy comes from its schema, through its object or its file's + * object, else from the instance default (REQ-ROD-001). + * + * @covers \OCA\OpenRegister\Service\Decision\ReviewPolicyResolver + * @uses \OCA\OpenRegister\Service\Decision\ReviewPolicy + * @uses \OCA\OpenRegister\Service\Settings\FileSettingsHandler + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ +class ReviewPolicyResolverTest extends TestCase { + + /** + * A resolver over a schema store, stored file settings, and a file that belongs to object `doc-1`. + * + * @param array|null $schemaConfiguration The schema's configuration, or null when it cannot be read. + * @param array $fileSettings The stored file settings. + * + * @return ReviewPolicyResolver The resolver. + */ + private function resolver(?array $schemaConfiguration, array $fileSettings=[]): ReviewPolicyResolver { + $schemas = $this->createMock(SchemaMapper::class); + if ($schemaConfiguration === null) { + $schemas->method('find')->willThrowException(new RuntimeException('gone')); + } else { + $schema = new Schema(); + $schema->setConfiguration($schemaConfiguration); + $schemas->method('find')->with('12')->willReturn($schema); + } + + $appConfig = $this->createMock(IAppConfig::class); + $stored = ''; + if ($fileSettings !== []) { + $stored = (string)json_encode($fileSettings); + } + + $appConfig->method('getValueString')->willReturnCallback( + static fn (string $app, string $key, string $default=''): string => ($key === 'fileManagement' ? $stored : $default) + ); + + $file = $this->createMock(File::class); + $root = $this->createMock(IRootFolder::class); + $root->method('getFirstNodeById')->willReturnCallback(static fn (int $id) => ($id === 99 ? $file : null)); + + $object = new ObjectEntity(); + $object->setRegister('3'); + $object->setSchema('12'); + $object->setUuid('doc-1'); + $folders = $this->createMock(FolderManagementHandler::class); + $folders->method('getObjectForFile')->willReturn($object); + + return new ReviewPolicyResolver( + schemaMapper: $schemas, + fileSettings: new FileSettingsHandler(appConfig: $appConfig), + rootFolder: $root, + folders: $folders, + logger: $this->createMock(LoggerInterface::class) + ); + }//end resolver() + + /** + * A relation on a file reads the policy of the schema of the object the file belongs to. + * + * @return void + */ + public function testAFileReadsTheSchemaOfItsObject(): void { + $relation = new EntityRelation(); + $relation->setId(7); + $relation->setFileId(99); + + $resolved = $this->resolver(schemaConfiguration: ['x-openregister-review' => ['ownership' => 'decider', 'supervisors' => ['woo-coordinator']]]) + ->forRelation(relation: $relation); + + self::assertSame(['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => false], $resolved['policy']->toArray()); + self::assertSame('entity-relation', $resolved['subject']['subjectType']); + self::assertSame('7', $resolved['subject']['subjectId']); + self::assertSame('12', $resolved['subject']['schema']); + self::assertSame('doc-1', $resolved['subject']['objectUuid']); + self::assertSame('3', $resolved['subject']['register']); + }//end testAFileReadsTheSchemaOfItsObject() + + /** + * A schema without the declaration, or a file without an object, falls back to the instance default. + * + * @return void + */ + public function testWithoutADeclarationTheInstanceDefaultApplies(): void { + $relation = new EntityRelation(); + $relation->setObjectId(5); + $relation->setSchemaId('12'); + + $resolver = $this->resolver(schemaConfiguration: [], fileSettings: ['anonymisationReview' => ['ownership' => 'decider', 'hideOthers' => true]]); + self::assertTrue($resolver->forRelation(relation: $relation)['policy']->hidesOthers()); + + $orphan = new EntityRelation(); + $orphan->setFileId(50); + self::assertTrue($resolver->forRelation(relation: $orphan)['policy']->isDeciderOwned()); + + self::assertFalse($this->resolver(schemaConfiguration: [])->forRelation(relation: $orphan)['policy']->isDeciderOwned()); + }//end testWithoutADeclarationTheInstanceDefaultApplies() + + /** + * A schema that cannot be read leaves the decision with its decider. + * + * @return void + */ + public function testAnUnreadableSchemaFailsClosed(): void { + $relation = new EntityRelation(); + $relation->setSchemaId('12'); + + $policy = $this->resolver(schemaConfiguration: null)->forRelation(relation: $relation)['policy']; + + self::assertTrue($policy->isDeciderOwned()); + self::assertSame([], $policy->getSupervisors()); + }//end testAnUnreadableSchemaFailsClosed() +}//end class diff --git a/tests/Unit/Service/Settings/FileSettingsHandlerTest.php b/tests/Unit/Service/Settings/FileSettingsHandlerTest.php index 997bed65df..553a807daa 100644 --- a/tests/Unit/Service/Settings/FileSettingsHandlerTest.php +++ b/tests/Unit/Service/Settings/FileSettingsHandlerTest.php @@ -346,4 +346,22 @@ public function testUpdateFileSettingsStoresCorrectJson(): void { $this->assertSame(777, $decoded['chunkSize']); $this->assertArrayHasKey('enabledFileTypes', $decoded); } + + /** + * The instance review default is kept, normalised, and an unsound one is refused. + * + * @return void + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + public function testTheReviewDefaultIsKeptAndChecked(): void { + $result = $this->handler->updateFileSettingsOnly(['anonymisationReview' => ['ownership' => 'decider', 'supervisors' => ['woo-coordinator']]]); + $this->assertSame(['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => false], $result['anonymisationReview']); + + $this->assertNull($this->handler->updateFileSettingsOnly([])['anonymisationReview']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('decidr'); + $this->handler->updateFileSettingsOnly(['anonymisationReview' => ['ownership' => 'decidr']]); + } } From 7fb399ef871851f6c2066e022a1a489067a4947f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 10:04:06 +0200 Subject: [PATCH 076/183] chore(openspec): reviewer-owns-their-decisions follows decision 197 The decision is its own object (new spec: decisions, REQ-DEC-001/002); the relation links it. Proposal explains why decidiq's Decision is not reused. Tasks 1.1, 1.2 and 1.4 built; the validator is split for complexity. --- .../Decision/RelationDecisionService.php | 7 ++- .../Decision/ReviewAnnotationValidator.php | 35 +++++++++++-- lib/Service/Decision/ReviewPolicyResolver.php | 4 ++ .../reviewer-owns-their-decisions/proposal.md | 28 ++++++++--- .../reviewer-owns-their-decisions/screens.md | 3 ++ .../specs/decisions/spec.md | 50 +++++++++++++++++++ .../specs/entity-relation-grondslagen/spec.md | 9 ++-- .../reviewer-owns-their-decisions/tasks.md | 12 +++-- 8 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 openspec/changes/reviewer-owns-their-decisions/screens.md create mode 100644 openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md diff --git a/lib/Service/Decision/RelationDecisionService.php b/lib/Service/Decision/RelationDecisionService.php index 177b6aab7a..3e132599a0 100644 --- a/lib/Service/Decision/RelationDecisionService.php +++ b/lib/Service/Decision/RelationDecisionService.php @@ -113,11 +113,16 @@ public function decide(EntityRelation $relation, array $body, IUser $actor): arr $before = [$relation->getBases(), $relation->getSkipAnonymization()]; + $given = []; + if ($hasNote === true) { + $given = [$note]; + } + $this->db->beginTransaction(); try { $updated = $this->relationMapper->updateDecisionMetadata(relation: $relation, fields: $body, actingUser: $actor); - if ($this->isNewDecision(current: $current, before: $before, updated: $updated, body: $body, note: $hasNote === true ? [$note] : []) === false) { + if ($this->isNewDecision(current: $current, before: $before, updated: $updated, body: $body, note: $given) === false) { $this->db->commit(); return ['relation' => $updated, 'decision' => $current]; } diff --git a/lib/Service/Decision/ReviewAnnotationValidator.php b/lib/Service/Decision/ReviewAnnotationValidator.php index c848ddc1d8..ae83ac173f 100644 --- a/lib/Service/Decision/ReviewAnnotationValidator.php +++ b/lib/Service/Decision/ReviewAnnotationValidator.php @@ -57,6 +57,17 @@ public function validate(array $configuration): array { return [self::refusal(code: 'review-shape', message: ReviewPolicy::ANNOTATION.' must be an object with ownership, supervisors and hideOthers.')]; } + return array_merge(self::unknownKeys(declaration: $declaration), self::badValues(declaration: $declaration)); + }//end validate() + + /** + * A refusal for every key the declaration may not hold. + * + * @param array $declaration The declaration. + * + * @return array The refusals. + */ + private static function unknownKeys(array $declaration): array { $errors = []; foreach (array_keys($declaration) as $key) { if (in_array($key, ReviewPolicy::KEYS, true) === false) { @@ -64,12 +75,26 @@ public function validate(array $configuration): array { } } + return $errors; + }//end unknownKeys() + + /** + * A refusal for every value the ownership rule cannot honour. + * + * @param array $declaration The declaration. + * + * @return array The refusals. + */ + private static function badValues(array $declaration): array { + $errors = []; $ownership = ($declaration['ownership'] ?? ReviewPolicy::OWNERSHIP_OPEN); if (in_array($ownership, [ReviewPolicy::OWNERSHIP_OPEN, ReviewPolicy::OWNERSHIP_DECIDER], true) === false) { - $errors[] = self::refusal( - code: 'review-ownership', - message: 'Ownership "'.(is_scalar($ownership) === true ? (string)$ownership : gettype($ownership)).'" is not open or decider.' - ); + $named = gettype($ownership); + if (is_scalar($ownership) === true) { + $named = (string)$ownership; + } + + $errors[] = self::refusal(code: 'review-ownership', message: 'Ownership "'.$named.'" is not open or decider.'); } if (array_key_exists('supervisors', $declaration) === true && self::isListOfStrings(value: $declaration['supervisors']) === false) { @@ -81,7 +106,7 @@ public function validate(array $configuration): array { } return $errors; - }//end validate() + }//end badValues() /** * Whether a value is a list of non-empty strings. diff --git a/lib/Service/Decision/ReviewPolicyResolver.php b/lib/Service/Decision/ReviewPolicyResolver.php index 1ded70c481..5fb5b2ee37 100644 --- a/lib/Service/Decision/ReviewPolicyResolver.php +++ b/lib/Service/Decision/ReviewPolicyResolver.php @@ -73,6 +73,8 @@ public function __construct( * * @return array{policy: ReviewPolicy, subject: array} * + * @SuppressWarnings(PHPMD.StaticAccess) ReviewPolicy::fromDeclaration is its named constructor. + * * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 */ public function forRelation(EntityRelation $relation): array { @@ -156,6 +158,8 @@ private function subjectOf(EntityRelation $relation): array { * The instance default, for files that belong to no schema. Unset is open. * * @return ReviewPolicy The policy. + * + * @SuppressWarnings(PHPMD.StaticAccess) ReviewPolicy::fromDeclaration is its named constructor. */ private function instanceDefault(): ReviewPolicy { $settings = $this->fileSettings->getFileSettingsOnly(); diff --git a/openspec/changes/reviewer-owns-their-decisions/proposal.md b/openspec/changes/reviewer-owns-their-decisions/proposal.md index 4db8840a1b..d0a794228e 100644 --- a/openspec/changes/reviewer-owns-their-decisions/proposal.md +++ b/openspec/changes/reviewer-owns-their-decisions/proposal.md @@ -1,6 +1,6 @@ --- kind: code -depends_on: [redaction-release-safeguards, access-owner-and-condition-scopes] +depends_on: [access-owner-and-condition-scopes] --- # Proposal: reviewer-owns-their-decisions @@ -11,7 +11,7 @@ A reviewer edits only their own redaction decisions and comments, and can be bar - Rows: 12.28 (not statutory). The grant on `wooAssessment` is not in this change: it belongs to whichever app ships that schema (D1 moved the Woo request to dossiq), and is open as a follow-up. - Wave 1, size M. -- Dependencies: `openregister/redaction-release-safeguards` (https://github.com/ConductionNL/openregister/issues/4392) for `decidedBy` and `decidedAt`, and `openregister/access-owner-and-condition-scopes` (open, 0 of 5 tasks, no issue yet) for `@creator`. +- Dependencies: `openregister/access-owner-and-condition-scopes` (open, 0 of 5 tasks, no issue yet) for `@creator` (section 3 only). The former dependency on `redaction-release-safeguards` for `decidedBy`/`decidedAt` is gone: decision 197 puts them on the decision object. - Decision: D1 decides who ships the assessment schema; no decision bears on this change directly. - Build rules: openspec/woo-build-rules.md @@ -23,12 +23,26 @@ A four-eyes review is only four eyes if the second reviewer cannot quietly overw ## What changes -- Decision ownership. A relation decided by a person (`decidedBy`, added by `redaction-release-safeguards`) can then be changed only by that person, by a member of a declared supervisor group, or by an administrator. An undecided relation can be decided by anyone who may write its subject, as today. A refused change answers 403 naming who decided and when. -- The decision note. `EntityRelation` gains `decisionNote`, a reviewer's comment on the decision, owned the same way as the decision. -- Hiding. When hiding is on, every endpoint that returns entity relations returns another reviewer's decided relation as `decision: "hidden"`, without `decidedBy`, `decisionNote` or `bases`, unless the caller is that reviewer, a supervisor or an administrator. The relation itself stays listed, so the reviewer knows there is something to decide. -- Configuration. Per schema through `x-openregister-review: {"ownership": "decider", "supervisors": [""], "hideOthers": true}`, and an instance default in the file settings (`anonymisation.review.ownership` `open` or `decider`, `anonymisation.review.supervisors`, `anonymisation.review.hideOthers`) for files that belong to no schema. The default stays `open`, today's behaviour. +Decision 197 (Ruben, 11 Oct 2026): "A decision is its own object that can be linked by other objects." The earlier draft put `decidedBy`, `decidedAt` and `decisionNote` on the entity relation. That is replaced. + +- The decision object. A new OpenRegister table `openregister_decisions` holds one decision per row: `uuid`, `subjectType` and `subjectId` (what was decided on), `verdict`, `decidedBy`, `decidedAt`, `note` and `supersedes`. A decision is never edited. A change records a new decision that names the one it supersedes, so who decided is a fact, not a field someone else can overwrite. `DecisionService` records and reads them for any subject type, so other apps can link decisions from their own objects. +- The link. An entity relation gains `decision_uuid` and serialises it as `decision`. `PATCH /api/entity-relations/{id}` records a decision object (`entity-relation`, the relation id, `redact` or `release`, the caller, and `decisionNote` from the body) and links it, in one transaction with the audited write of `bases` and `skipAnonymization`. The response carries the relation plus `decisionRecord`. +- Decision ownership. A relation whose current decision was taken by someone else can then be changed only by that person, by a member of a declared supervisor group, or by an administrator. An undecided relation can be decided by anyone who may write its subject, as today. A refused change answers 403 `{error: "decision-owned", decision, decidedBy, decidedAt}`, without the verdict or the note. +- The app that owns the subject decides who supervises. Before someone other than the decider changes a decider-owned decision, OpenRegister dispatches `DecisionChangeRequestedEvent` with the decision, the caller, the policy and the subject (register, schema, object uuid, file id). A listener may allow or refuse; a listener that fails refuses. This is how dossiq's case reviewer access answers per case, without OpenRegister knowing about cases. +- Hiding. When hiding is on, every endpoint that returns entity relations returns another reviewer's decided relation as `decision: "hidden"`, without the decision record or `bases`, unless the caller is that reviewer, a supervisor or an administrator. The relation itself stays listed, so the reviewer knows there is something to decide. +- Configuration. Per schema through `x-openregister-review: {"ownership": "decider", "supervisors": [""], "hideOthers": true}`, refused at save when the ownership is neither `open` nor `decider`. A relation on a file reads the schema of the object the file belongs to. For files that belong to no schema, the instance default sits in the file settings as `anonymisationReview` with the same shape. The default stays `open`, today's behaviour. - The assessment record. A Woo assessment is an OpenRegister object, so its half is an authorization block the owning app ships: `update: ["@creator", ""]` and, to hide, `read: ["@creator", ""]`, enforced by `access-owner-and-condition-scopes` (open, 0 of 5). This change adds a test that such a block on a fixture `wooAssessment`-shaped schema refuses a second reviewer's edit, so the owning app can rely on it. +### Why OpenRegister's own decision object, not decidiq's Decision + +decidiq's `Decision` already carries `subjectRegister`, `subjectSchema` and `subjectId`, so reuse was checked first. It does not fit a reviewer verdict on a redaction: + +- OpenRegister cannot depend on decidiq. Redaction review runs on any OpenRegister instance, and a duck-typed call to an app that is not installed would turn ownership into a silent no-op. +- The subject is an entity relation, a row in `openregister_entity_relations` with an integer id, not an object with a register, schema and uuid. decidiq's back-reference cannot name it. +- decidiq's Decision is a governance decision: `title`, `text` and `decisionType` are required, it has a voting lifecycle, and creating one notifies `decidesk-members` (`decisionRecorded`). A document review takes hundreds of verdicts per document; each would become a governance decision and a notification. + +decidiq's Decision stays the object for governance decisions. A later change can let decidiq link an OpenRegister decision when a governance decision rests on a review. + ## What does not change - Who may write the relation's subject at all: `actorCanWriteRelationSubject()` stays the first check. @@ -36,7 +50,7 @@ A four-eyes review is only four eyes if the second reviewer cannot quietly overw ## Dependencies and absent apps -- `redaction-release-safeguards` (wave 1) adds `decidedBy` and `decidedAt`; this change orders after it in the same wave. +- `redaction-release-safeguards` (wave 1) was to add `decidedBy` and `decidedAt` to the relation. Decision 197 moves both onto the decision object, so this change no longer waits on it for them. - `access-owner-and-condition-scopes` (open, 0 of 5) supplies `@creator` for the assessment half. - The plan names "the wooAssessment grant change in opencatalogi/woo-request-scoped-access". That change does not exist on opencatalogi `development` (checked 2026-10-05), and decision D1 moved the Woo request to dossiq. The grant change therefore belongs to whichever app ships the `wooAssessment` schema; the PR body names the authorization block above for that lane. No app is called at runtime, so there is no absent-app path. diff --git a/openspec/changes/reviewer-owns-their-decisions/screens.md b/openspec/changes/reviewer-owns-their-decisions/screens.md new file mode 100644 index 0000000000..13649f2740 --- /dev/null +++ b/openspec/changes/reviewer-owns-their-decisions/screens.md @@ -0,0 +1,3 @@ +# Screens + +- No screen: the decision object, the ownership rule and the review declaration are API and storage inside OpenRegister; the reviewer-facing states ("decided by another reviewer") appear on the consuming app's review screen, which carries its own board (dossiq DqBeoordelingAfgeschermd, design backlog). diff --git a/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md b/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md new file mode 100644 index 0000000000..e3f58cf773 --- /dev/null +++ b/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md @@ -0,0 +1,50 @@ +--- +status: proposed +--- + +# decisions + +## ADDED Requirements + +### Requirement: A decision is its own object (REQ-DEC-001) + +OpenRegister SHALL store a decision as its own object in `openregister_decisions`, with `uuid`, `subjectType`, `subjectId`, `verdict`, `decidedBy`, `decidedAt`, `note` and `supersedes`. A decision SHALL NOT be edited after it is stored: changing a verdict, its grounds or its note SHALL record a new decision whose `supersedes` names the previous one, and the subject SHALL link the new one. A subject links its current decision by uuid; an entity relation does so through `decision_uuid`, serialised as `decision`. Every decision on a subject SHALL be readable in order through `DecisionService::history()`. A decision without a subject, a verdict or a decider, or with a note longer than 4,000 characters, SHALL be refused. + +#### Scenario: a reviewer's redaction is recorded as a decision object + + +- **GIVEN** an undecided entity relation 7 +- **WHEN** `j.devries` sends `PATCH /api/entity-relations/7` with `bases: ["5.1.2e"]` and `decisionNote: "Private person."` +- **THEN** a decision is stored with subject `entity-relation` 7, verdict `redact`, decided by `j.devries`, the moment and the note, the relation's `decision` names its uuid, and the response carries it as `decisionRecord` + +#### Scenario: a change supersedes, it does not overwrite + + +- **GIVEN** that decision +- **WHEN** a coordinator changes the relation to release +- **THEN** a second decision is stored, decided by the coordinator, verdict `release`, superseding the first, and the first is unchanged + +#### Scenario: repeating a decision records nothing + + +- **GIVEN** a decided relation +- **WHEN** its decider sends the same bases again +- **THEN** no new decision is recorded + +### Requirement: The app that owns the subject may answer who supervises (REQ-DEC-002) + +Before anyone other than the decider or an administrator changes a decider-owned decision, OpenRegister SHALL dispatch `DecisionChangeRequestedEvent` carrying the decision, the caller, the policy, the subject (subject type and id and, when known, register, schema, object uuid and file id) and OpenRegister's own answer from the policy's supervisor groups. A listener MAY allow or refuse. A listener that throws SHALL refuse. The event SHALL NOT be dispatched under `open` ownership, for the decider, or for an administrator. + +#### Scenario: a case's reviewer access answers for its case + + +- **GIVEN** a decider-owned decision on a document of a case whose case type names `woo-coordinator` as supervisor group, and an app listening for the event +- **WHEN** a member of that group, who is in no group the schema names, changes the decision +- **THEN** the listener allows it and the change is stored + +#### Scenario: a failing listener refuses + + +- **GIVEN** a listener that throws +- **WHEN** a supervisor changes another reviewer's decision +- **THEN** the change is refused with `decision-owned` diff --git a/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md b/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md index 395ae26dc8..56045dca13 100644 --- a/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md +++ b/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md @@ -8,9 +8,10 @@ status: proposed ### Requirement: A reviewer's decision is changed only by that reviewer or a supervisor (REQ-ROD-001) -When ownership is `decider` for the relation's schema (from `x-openregister-review`) or, for a relation with no schema, from the instance default, `PATCH /api/entity-relations/{id}` on a relation whose `decidedBy` is set SHALL be refused with 403 `{error: "decision-owned", decidedBy, decidedAt}` unless the caller is `decidedBy`, a member of a configured supervisor group, or an administrator. A relation with `decidedBy` null SHALL be decided by any caller who passes the existing subject write check. `decisionNote` SHALL follow the same rule. When ownership is `open` behaviour SHALL be as today. +When ownership is `decider` for the relation's schema (from `x-openregister-review`, read from the relation's own schema or, for a relation on a file, from the schema of the object the file belongs to) or, for a relation with no schema, from the instance default (`anonymisationReview` in the file settings), `PATCH /api/entity-relations/{id}` on a relation whose current decision object (REQ-DEC-001) was taken by someone else SHALL be refused with 403 `{error: "decision-owned", decision, decidedBy, decidedAt}` unless the caller is the decider, a member of a configured supervisor group, an administrator, or allowed by the app that owns the subject (REQ-DEC-002). The refusal SHALL NOT carry the verdict or the note. A relation without a decision SHALL be decided by any caller who passes the existing subject write check. The decision note (`decisionNote` in the body, stored as the decision's `note`) SHALL follow the same rule. When ownership is `open` behaviour SHALL be as today. A declaration whose ownership is neither `open` nor `decider`, whose supervisors are not a list of group ids, or whose `hideOthers` is not a boolean SHALL be refused at schema save with 422; an ownership that is unreadable at run time SHALL be read as `decider`. #### Scenario: a second reviewer cannot overwrite the first + - **GIVEN** a schema with ownership `decider` and supervisors `woo-coordinator`, and a name `j.devries` decided to redact under `5.1.2e` - **WHEN** reviewer `p.bakker`, who may write the document, changes it to release - **THEN** the change is refused, naming `j.devries` and the moment of the decision, and the relation is unchanged @@ -20,18 +21,18 @@ When ownership is `decider` for the relation's schema (from `x-openregister-revi - **GIVEN** the same decision - **WHEN** a member of `woo-coordinator` changes it -- **THEN** the change is stored with the coordinator as `decidedBy` and the audit row names both +- **THEN** a new decision is stored with the coordinator as `decidedBy`, superseding the first, and the audit row of the relation records the change #### Scenario: an undecided occurrence is open to any reviewer - **GIVEN** a relation with no decision - **WHEN** `p.bakker` decides it -- **THEN** the decision is stored with `p.bakker` as `decidedBy` +- **THEN** a decision object is stored with `p.bakker` as `decidedBy` and linked from the relation ### Requirement: A schema can hide one reviewer's decisions from another (REQ-ROD-002) -When `hideOthers` is on, every endpoint that returns entity relations (`gdprEntities#index`, `gdprEntities#show`, the file entity listings and the review leaf's reads) SHALL return a relation decided by someone other than the caller with `decision: "hidden"` and without `decidedBy`, `decidedAt`, `decisionNote` and `bases`, unless the caller is a supervisor or an administrator. The relation SHALL still be listed with its type and position. +When `hideOthers` is on, every endpoint that returns entity relations (`gdprEntities#index`, `gdprEntities#show`, the file entity listings and the review leaf's reads) SHALL return a relation decided by someone other than the caller with `decision: "hidden"`, without its decision record (`decidedBy`, `decidedAt`, `note`) and without `bases`, unless the caller is a supervisor or an administrator. The relation SHALL still be listed with its type and position. #### Scenario: a blind second review - **GIVEN** `hideOthers` on and ten occurrences, four decided by `j.devries` diff --git a/openspec/changes/reviewer-owns-their-decisions/tasks.md b/openspec/changes/reviewer-owns-their-decisions/tasks.md index 2dd722a6ed..fd09bb5b46 100644 --- a/openspec/changes/reviewer-owns-their-decisions/tasks.md +++ b/openspec/changes/reviewer-owns-their-decisions/tasks.md @@ -2,13 +2,17 @@ ## 1. Ownership (REQ-ROD-001) -- [ ] 1.1 Accept `x-openregister-review` in `Schema::ANNOTATION_VOCABULARY` (validated: `ownership` `open` or `decider`, `supervisors` a list of existing group ids, `hideOthers` boolean) and the instance defaults in `FileSettingsHandler`. Verify: `tests/Unit/Db/SchemaReviewAnnotationTest.php::testAnUnknownOwnershipIsRefused` (fails today: the key is dropped). -- [ ] 1.2 Migration adding `decision_note` to `openregister_entity_relations`; the ownership rule in `EntityRelationsController::update()` after `actorCanWriteRelationSubject()`. Verify: `tests/Unit/Controller/DecisionOwnershipTest.php::testASecondReviewerIsRefused`, `testASupervisorMayChangeAnotherReviewersDecision`, `testAnAdministratorMay`, `testAnUndecidedRelationIsOpen`, `testOpenOwnershipKeepsTodaysBehaviour`, `testTheNoteFollowsTheDecision`, with real `EntityRelation` entities and `environmentAwareDouble` for `IGroupManager`. -- [ ] 1.3 Through the route: a Newman sequence in `tests/newman/` with two reviewer users, one deciding and the other getting 403 on the same relation. +- [x] 1.1 Accept `x-openregister-review` in `Schema::ANNOTATION_VOCABULARY` (validated: `ownership` `open` or `decider`, `supervisors` a list of group ids, `hideOthers` boolean) and the instance default in `FileSettingsHandler`. Verify: `tests/Unit/Db/SchemaReviewAnnotationTest.php::testAnUnknownOwnershipIsRefused` (fails today: the key is dropped). + - done: `lib/Service/Decision/ReviewAnnotationValidator.php` + `ReviewDeclarationException` (422 from SchemasController create/update/upload), hooked in `SchemaMapper::cleanObject()`; `lib/Service/Decision/ReviewPolicy.php` (unreadable ownership fails closed to decider); instance default `anonymisationReview` in `FileSettingsHandler::updateFileSettingsOnly()`. Tests: `SchemaReviewAnnotationTest` (5, red before the change: 5 errors), `ReviewPolicyTest` (3), `FileSettingsHandlerTest::testTheReviewDefaultIsKeptAndChecked`. Group existence is not checked at save: an app's repair step may create its groups after its schemas, and a missing group grants nobody anything. +- [x] 1.2 Decision 197: the decision is its own object, not columns on the relation. Migration `Version1Date20261011140000` creates `openregister_decisions` and adds `decision_uuid` to `openregister_entity_relations`; the ownership rule runs in `EntityRelationsController::update()` after `actorCanWriteRelationSubject()`. Verify: `tests/Unit/Controller/DecisionOwnershipTest.php::testASecondReviewerIsRefused`, `testASupervisorMayChangeAnotherReviewersDecision`, `testAnAdministratorMay`, `testAnUndecidedRelationIsOpen`, `testOpenOwnershipKeepsTodaysBehaviour`, `testTheNoteFollowsTheDecision`, with real `EntityRelation` entities. + - done: `lib/Db/Decision.php`, `lib/Db/DecisionMapper.php`, `lib/Service/Decision/{DecisionService,DecisionOwnership,ReviewPolicyResolver,RelationDecisionService,DecisionOwnedException}.php`, `EntityRelationMapper::linkDecision()`, `EntityRelation::$decisionUuid` (serialised `decision`). Tests: the six named (real controller, real services, in-memory stores), `Service/Decision/{DecisionOwnershipTest,DecisionServiceTest,ReviewPolicyResolverTest}`, `Db/DecisionMapperTest`, `Migration/Version1Date20261011140000Test`, `EntityRelationMapperUpdateDecisionMetadataTest::testLinkDecisionStoresTheUuid`, `EntityRelationsControllerTest` (now through the decision path). +- [x] 1.4 The app that owns the subject answers who supervises (REQ-DEC-002): `lib/Event/DecisionChangeRequestedEvent.php`, dispatched by `DecisionOwnership` with the subject (register, schema, object uuid, file id; a relation on a file resolves its object through `FolderManagementHandler::getObjectForFile`). A failing listener refuses. + - done: `Service/Decision/DecisionOwnershipTest::testAListenerMayAllow`, `testAListenerMayRefuse`, `testAFailingListenerRefuses`. Consumer: dossiq woo-request-scoped-access 5.2 (case reviewer access). +- [ ] 1.3 Through the route: a Newman sequence in `tests/newman/` with two reviewer users, one deciding and the other getting 403 `decision-owned` on the same relation, and the decision object read back. (live pass, decision 139) ## 2. Hiding (REQ-ROD-002) -- [ ] 2.1 A `DecisionVisibility` filter applied in every controller that serialises `EntityRelation`. Verify: `tests/Unit/Service/Anonymisation/HiddenDecisionTest.php::testAnotherReviewersDecisionIsHidden`, `testTheSupervisorSeesIt`, `testEveryRelationEndpointMasks` (reflection over controllers returning relations, fails on one without the filter). +- [ ] 2.1 A `DecisionVisibility` filter applied in every controller that serialises `EntityRelation`; a hidden relation carries `decision: "hidden"` and no decision record. (not built in the first PR: next stretch) Verify: `tests/Unit/Service/Anonymisation/HiddenDecisionTest.php::testAnotherReviewersDecisionIsHidden`, `testTheSupervisorSeesIt`, `testEveryRelationEndpointMasks` (reflection over controllers returning relations, fails on one without the filter). - [ ] 2.2 The review UI in OpenRegister shows "decided by another reviewer" without the decision. Verify: `tests/e2e/ci/blind-second-review.spec.ts` seeds four decisions by one user, signs in as another and sees ten detections, four marked decided without their ground. ## 3. Assessment records (REQ-ROD-003) From 21076e847908a59217c5651d5f387743bc41f393 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 10:12:25 +0200 Subject: [PATCH 077/183] feat(objects): a draft may be owned by a scope value such as portal: (decision 195) Only a caller asserting that owner finds, reads, saves or sends the draft; anyone else, a signed-in user included, gets the same 404 as an unknown id. New: DraftOwner (the owner value and the one comparison), DraftFinder (an owner's draft by id or newest in a destination). FormSubmitService gains readDraft/findDraft and an optional owner on saveDraft/submit/submitAll/ isOwnerOfDraft. ObjectService/SaveObject take a service-level _owner for a new object. --- lib/Service/Form/FormSubmitService.php | 193 +++++++++++---- lib/Service/Object/DraftFinder.php | 221 ++++++++++++++++++ lib/Service/Object/DraftOwner.php | 178 ++++++++++++++ lib/Service/Object/SaveObject.php | 29 ++- lib/Service/ObjectService.php | 9 +- .../specs/form-destination/spec.md | 18 ++ .../form-destination-validator/tasks.md | 3 + .../Service/Form/FormSubmitServiceTest.php | 116 ++++++++- tests/Unit/Service/Object/DraftFinderTest.php | 169 ++++++++++++++ tests/Unit/Service/Object/DraftOwnerTest.php | 122 ++++++++++ .../Object/SaveObjectSystemOwnerTest.php | 19 ++ 11 files changed, 1019 insertions(+), 58 deletions(-) create mode 100644 lib/Service/Object/DraftFinder.php create mode 100644 lib/Service/Object/DraftOwner.php create mode 100644 tests/Unit/Service/Object/DraftFinderTest.php create mode 100644 tests/Unit/Service/Object/DraftOwnerTest.php diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index ddf18ae233..000aac47b6 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -50,6 +50,8 @@ use OCA\OpenRegister\Exception\CustomValidationException; use OCA\OpenRegister\Exception\FormSubmitRefusedException; use OCA\OpenRegister\Exception\NotAuthorizedException; +use OCA\OpenRegister\Service\Object\DraftFinder; +use OCA\OpenRegister\Service\Object\DraftOwner; use OCA\OpenRegister\Service\Object\DraftStatusPolicy; use OCA\OpenRegister\Service\Object\ValidateObject; use OCA\OpenRegister\Service\ObjectService; @@ -98,6 +100,13 @@ class FormSubmitService { */ private const MODE_DRAFT = 'draft'; + /** + * Finds an owner's drafts. + * + * @var DraftFinder + */ + private readonly DraftFinder $drafts; + /** * Constructor. * @@ -129,6 +138,7 @@ public function __construct( private readonly IEventDispatcher $events, private readonly ITimeFactory $time, ) { + $this->drafts = new DraftFinder(objects: $objects); }//end __construct() @@ -142,6 +152,7 @@ public function __construct( * @param string|null $idempotencyKey The Idempotency-Key, when the caller sent one. * @param string $scope The form id the key and upload tokens belong to. * @param string|null $draftId The subject's draft of this destination, to leave draft. + * @param DraftOwner|null $owner The draft's asserted owner; null takes the subject (decision 195). * * @return array The answer: reference, id, receivedAt, confirmation, objects. * @@ -157,6 +168,7 @@ public function submit( ?string $idempotencyKey = null, string $scope = '', ?string $draftId = null, + ?DraftOwner $owner = null, ): array { return $this->submitAll( writes: [array_merge($destination, ['mapping' => $mapping])], @@ -164,7 +176,8 @@ public function submit( subject: $subject, idempotencyKey: $idempotencyKey, scope: $scope, - draftId: $draftId + draftId: $draftId, + owner: $owner ); }//end submit() @@ -175,6 +188,9 @@ public function submit( * holds, so a wrong type, format or enum value is refused. A draft is the * signed-in subject's own; an anonymous visitor gets 401 (resuming an * anonymous draft needs a resume token, question Q-openregister-F2). + * A caller may assert a scope-value owner instead, such as + * `portal:` for a portal resident without an account + * (decision 195); only that owner then reads, saves or sends it. * * @param array{register?: mixed, schema?: mixed} $destination The resolved destination pair. * @param array|null $mapping The form's mapping. @@ -182,6 +198,7 @@ public function submit( * @param IUser|null $subject The signed-in subject. * @param string|null $draftId The draft to update; null starts one. * @param string $scope The form id. + * @param DraftOwner|null $owner The asserted owner; null takes the subject (decision 195). * * @return array{id: string, status: string} The draft. * @@ -196,18 +213,20 @@ public function saveDraft( ?IUser $subject, ?string $draftId = null, string $scope = '', + ?DraftOwner $owner = null, ): array { - if ($subject === null) { + $owner = $this->ownerOf(subject: $subject, owner: $owner); + if ($owner === null) { throw new FormSubmitRefusedException(message: $this->l10n->t('Please sign in to save this form and carry on later.'), status: 401); } $plans = $this->plan(writes: [array_merge($destination, ['mapping' => $mapping])], payload: $payload, scope: $scope); if ($draftId !== null) { - $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId, subject: $subject), $plans[0]['object']); + $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId, owner: $owner), $plans[0]['object']); } $this->validateAll(plans: $plans, mode: self::MODE_DRAFT); - $created = $this->writeAll(plans: $plans, subject: $subject, status: ObjectEntity::STATUS_DRAFT, uuid: $draftId); + $created = $this->writeAll(plans: $plans, subject: $owner->user(), status: ObjectEntity::STATUS_DRAFT, uuid: $draftId, owner: $owner); return ['id' => (string)$created[0]['object']->getUuid(), 'status' => ObjectEntity::STATUS_DRAFT]; }//end saveDraft() @@ -226,6 +245,7 @@ public function saveDraft( * @param string|null $idempotencyKey The Idempotency-Key, when sent. * @param string $scope The form id the key and tokens belong to. * @param string|null $draftId The subject's draft to leave draft (one write only). + * @param DraftOwner|null $owner The draft's asserted owner; null takes the subject (decision 195). * * @return array The answer: reference, id, receivedAt, confirmation, objects. * @@ -240,6 +260,7 @@ public function submitAll( ?string $idempotencyKey = null, string $scope = '', ?string $draftId = null, + ?DraftOwner $owner = null, ): array { $key = trim((string)$idempotencyKey); $keyScope = $this->keyScope(scope: $scope, writes: $writes); @@ -254,7 +275,7 @@ public function submitAll( } $plans = $this->plan(writes: $writes, payload: $payload, scope: $scope); - [$created, $receivedAt] = $this->writeOrPromote(plans: $plans, subject: $subject, draftId: $draftId); + [$created, $receivedAt] = $this->writeOrPromote(plans: $plans, subject: $subject, draftId: $draftId, owner: $owner); $this->uploads->claim(tokens: array_merge(...array_map(static fn (array $plan): array => $plan['tokens'], $plans))); $answer = $this->answer(created: $created, receivedAt: $receivedAt); @@ -271,14 +292,15 @@ public function submitAll( * @param array> $plans The plans. * @param IUser|null $subject The subject. * @param string|null $draftId The draft to leave, or null. + * @param DraftOwner|null $owner The draft's asserted owner, or null for the subject. * * @return array{0: array, object: ObjectEntity}>, 1: DateTimeImmutable|null} Writes, receipt. * * @throws FormSubmitRefusedException 422, 403, 404 or 503. */ - private function writeOrPromote(array $plans, ?IUser $subject, ?string $draftId): array { + private function writeOrPromote(array $plans, ?IUser $subject, ?string $draftId, ?DraftOwner $owner = null): array { if ($draftId !== null) { - return $this->promoteDraft(plans: $plans, subject: $subject, draftId: $draftId); + return $this->promoteDraft(plans: $plans, owner: $this->ownerOf(subject: $subject, owner: $owner), draftId: $draftId); } $this->validateAll(plans: $plans); @@ -290,25 +312,25 @@ private function writeOrPromote(array $plans, ?IUser $subject, ?string $draftId) * Leave draft: full validation of the draft plus the payload, then one allowed promotion, then receipt. * * @param array> $plans The plans (exactly one). - * @param IUser|null $subject The subject. + * @param DraftOwner|null $owner The draft's owner; null finds no draft. * @param string $draftId The draft. * * @return array{0: array, object: ObjectEntity}>, 1: DateTimeImmutable} The write and the moment of receipt. * * @throws FormSubmitRefusedException 404 unknown draft, 422 incomplete or several writes, 403, 503. */ - private function promoteDraft(array $plans, ?IUser $subject, string $draftId): array { + private function promoteDraft(array $plans, ?DraftOwner $owner, string $draftId): array { if (count($plans) !== 1) { $message = $this->l10n->t('A draft is one object; a form that writes several cannot leave draft in one step.'); throw new FormSubmitRefusedException(message: $message, status: 422); } - $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId, subject: $subject), $plans[0]['object']); + $plans[0]['object'] = array_merge($this->draftData(plan: $plans[0], draftId: $draftId, owner: $owner), $plans[0]['object']); $this->validateAll(plans: $plans); DraftStatusPolicy::allowPromotion(uuid: $draftId); try { - $created = $this->writeAll(plans: $plans, subject: $subject, status: ObjectEntity::STATUS_ACTIVE, uuid: $draftId); + $created = $this->writeAll(plans: $plans, subject: $owner?->user(), status: ObjectEntity::STATUS_ACTIVE, uuid: $draftId, owner: $owner); } finally { DraftStatusPolicy::resetPromotions(); } @@ -329,12 +351,13 @@ private function promoteDraft(array $plans, ?IUser $subject, string $draftId): a * @param array{register?: mixed, schema?: mixed} $destination The destination pair. * @param string $draftId The draft's uuid. * @param IUser|null $subject The subject. + * @param DraftOwner|null $owner The asserted owner; null takes the subject (decision 195). * * @return bool True when it is the subject's own draft. * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft */ - public function isOwnerOfDraft(array $destination, string $draftId, ?IUser $subject): bool { + public function isOwnerOfDraft(array $destination, string $draftId, ?IUser $subject, ?DraftOwner $owner = null): bool { try { $register = $this->register(reference: $destination['register'] ?? null); $schema = $this->schema(reference: $destination['schema'] ?? null); @@ -342,66 +365,119 @@ public function isOwnerOfDraft(array $destination, string $draftId, ?IUser $subj return false; } - return $this->ownDraft(register: $register, schema: $schema, draftId: $draftId, subject: $subject) !== null; + $owner = $this->ownerOf(subject: $subject, owner: $owner); + + return $this->drafts->own(register: $register, schema: $schema, draftId: $draftId, owner: $owner) !== null; }//end isOwnerOfDraft() /** - * The subject's own draft, or null. + * The stored data of the subject's draft, or 404 when it is not theirs or not a draft. * - * @param Register $register The register. - * @param Schema $schema The schema. - * @param string $draftId The draft's uuid. - * @param IUser|null $subject The subject. + * Only the subject's own draft is found (decision 180); anything else + * answers the same 404, so a draft id is not an oracle. * - * @return ObjectEntity|null The draft, when it is a draft and the subject owns it. + * @param array $plan The write. + * @param string $draftId The draft's uuid. + * @param DraftOwner|null $owner The owner; only their own draft is found. + * + * @return array The draft's data, without its metadata. + * + * @throws FormSubmitRefusedException 404. */ - private function ownDraft(Register $register, Schema $schema, string $draftId, ?IUser $subject): ?ObjectEntity { - if ($subject === null) { - return null; + private function draftData(array $plan, string $draftId, ?DraftOwner $owner): array { + $draft = $this->drafts->own(register: $plan['register'], schema: $plan['schema'], draftId: $draftId, owner: $owner); + if ($draft === null) { + throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404); } - try { - $draft = $this->objects->find(id: $draftId, register: $register, schema: $schema, _rbac: false, _multitenancy: false, _audit: false); - } catch (Throwable) { - return null; + return DraftFinder::dataOf(object: $draft); + }//end draftData() + + /** + * The draft's owner: the asserted one, else the signed-in subject, else nobody. + * + * @param IUser|null $subject The signed-in subject. + * @param DraftOwner|null $owner The asserted owner. + * + * @return DraftOwner|null The owner, or null when there is none. + */ + private function ownerOf(?IUser $subject, ?DraftOwner $owner): ?DraftOwner { + if ($owner !== null) { + return $owner; } - if ($draft === null || $draft->isDraft() === false || $draft->getOwner() !== $subject->getUID()) { - return null; + if ($subject !== null) { + return DraftOwner::ofUser(user: $subject); } - return $draft; - }//end ownDraft() + return null; + }//end ownerOf() /** - * The stored data of the subject's draft, or 404 when it is not theirs or not a draft. + * Read the owner's draft by id: its data, for resuming a form. * - * Only the subject's own draft is found (decision 180); anything else - * answers the same 404, so a draft id is not an oracle. + * Anything but the owner's own draft answers the same 404 an unknown id + * gets (decisions 180 and 195), so a draft id is not an oracle. * - * @param array $plan The write. - * @param string $draftId The draft's uuid. - * @param IUser|null $subject The subject; only their own draft is found. + * @param array{register?: mixed, schema?: mixed} $destination The destination pair. + * @param string $draftId The draft's uuid. + * @param IUser|null $subject The signed-in subject. + * @param DraftOwner|null $owner The asserted owner; null takes the subject. * - * @return array The draft's data, without its metadata. + * @return array{id: string, status: string, data: array, updatedAt: string} The draft. * - * @throws FormSubmitRefusedException 404. + * @throws FormSubmitRefusedException 404 unknown or not the owner's draft, 404 unknown destination. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value */ - private function draftData(array $plan, string $draftId, ?IUser $subject): array { - $draft = $this->ownDraft(register: $plan['register'], schema: $plan['schema'], draftId: $draftId, subject: $subject); + public function readDraft(array $destination, string $draftId, ?IUser $subject, ?DraftOwner $owner = null): array { + $register = $this->register(reference: $destination['register'] ?? null); + $schema = $this->schema(reference: $destination['schema'] ?? null); + $owner = $this->ownerOf(subject: $subject, owner: $owner); + $draft = $this->drafts->own(register: $register, schema: $schema, draftId: $draftId, owner: $owner); if ($draft === null) { throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404); } - $data = []; - foreach ($draft->getObject() as $property => $value) { - if ($property !== 'id' && $property !== '@self') { - $data[(string)$property] = $value; - } + return DraftFinder::answer(draft: $draft); + }//end readDraft() + + /** + * Find the owner's newest draft in a destination, optionally one whose data holds given values. + * + * For resuming by account: the caller knows who and which form, not the + * draft id. `$match` narrows to drafts whose data holds each given value + * exactly, such as the fixed values a form writes (a case type). + * + * @param array{register?: mixed, schema?: mixed} $destination The destination pair. + * @param IUser|null $subject The signed-in subject. + * @param DraftOwner|null $owner The asserted owner; null takes the subject. + * @param array $match Values the draft's data must hold. + * + * @return array{id: string, status: string, data: array, updatedAt: string}|null The draft, or null. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function findDraft(array $destination, ?IUser $subject, ?DraftOwner $owner = null, array $match = []): ?array { + $owner = $this->ownerOf(subject: $subject, owner: $owner); + if ($owner === null) { + return null; } - return $data; - }//end draftData() + try { + $register = $this->register(reference: $destination['register'] ?? null); + $schema = $this->schema(reference: $destination['schema'] ?? null); + } catch (FormSubmitRefusedException) { + return null; + } + + $newest = $this->drafts->newest(register: $register, schema: $schema, owner: $owner, match: $match); + if ($newest === null) { + return null; + } + + return DraftFinder::answer(draft: $newest); + }//end findDraft() /** * Resolve each write's destination and map the payload onto it. @@ -628,12 +704,28 @@ private function serverFilled(Schema $schema, array $object): array { * @param IUser|null $subject The subject. * @param string|null $status The lifecycle status to store (`draft`, `active`), or none. * @param string|null $uuid The object to update (a draft), for a single write. + * @param DraftOwner|null $owner A draft's owner; a scope value is stamped on create, and + * authorises the update of its own draft (checked before). * * @return array, object: ObjectEntity}> The created objects, in order. * * @throws FormSubmitRefusedException 422, 403 or 503 after compensation. */ - private function writeAll(array $plans, ?IUser $subject, ?string $status = null, ?string $uuid = null): array { + private function writeAll(array $plans, ?IUser $subject, ?string $status = null, ?string $uuid = null, ?DraftOwner $owner = null): array { + // A scope-value owner (decision 195) has no Nextcloud user for RBAC to + // judge. Starting its draft is a create under the same RBAC as an + // anonymous submit; updating or sending its own draft was authorised by + // ownDraft() before this write, so the user RBAC is not asked again. + $scoped = $owner !== null && $owner->isScoped() === true; + $explicitOwner = null; + $rbac = true; + if ($scoped === true) { + $rbac = $uuid === null; + if ($uuid === null) { + $explicitOwner = $owner->value(); + } + } + $created = []; $ids = []; foreach ($plans as $plan) { @@ -653,11 +745,12 @@ private function writeAll(array $plans, ?IUser $subject, ?string $status = null, register: $plan['register'], schema: $plan['schema'], uuid: $uuid, - _rbac: true, + _rbac: $rbac, _multitenancy: true, uploadedFiles: $files, currentUser: $subject, - _unowned: $subject === null + _unowned: $subject === null && $scoped === false, + _owner: $explicitOwner ); } catch (Throwable $exception) { $this->compensate(created: $created, failedAt: $plan['as'], reason: $exception); diff --git a/lib/Service/Object/DraftFinder.php b/lib/Service/Object/DraftFinder.php new file mode 100644 index 0000000000..a172c29116 --- /dev/null +++ b/lib/Service/Object/DraftFinder.php @@ -0,0 +1,221 @@ +` has no + * Nextcloud user for RBAC to judge, and an administrator's session bypasses + * RBAC, so the owner comparison here is the check, for everyone alike. + * Anything else is "not found", which callers answer with one 404. + * + * 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/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Object; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\ObjectService; +use Throwable; + +/** + * One owner's drafts, by id or by destination. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ +class DraftFinder { + + /** + * How many of one owner's objects in a destination a search reads. + * + * @var integer + */ + public const SEARCH_LIMIT = 50; + + /** + * Constructor. + * + * @param ObjectService $objects Reads objects. + */ + public function __construct( + private readonly ObjectService $objects, + ) { + + }//end __construct() + + /** + * The owner's draft with this id, or null when it is unknown, not a draft, or someone else's. + * + * @param Register $register The destination register. + * @param Schema $schema The destination schema. + * @param string $draftId The draft's uuid. + * @param DraftOwner|null $owner The asserted owner; null finds nothing. + * + * @return ObjectEntity|null The draft. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function own(Register $register, Schema $schema, string $draftId, ?DraftOwner $owner): ?ObjectEntity { + if ($owner === null) { + return null; + } + + try { + $draft = $this->objects->find(id: $draftId, register: $register, schema: $schema, _rbac: false, _multitenancy: false, _audit: false); + } catch (Throwable) { + return null; + } + + if ($draft === null || $owner->owns(object: $draft) === false) { + return null; + } + + return $draft; + }//end own() + + /** + * The owner's most recently changed draft in a destination whose data holds each `$match` value exactly. + * + * @param Register $register The destination register. + * @param Schema $schema The destination schema. + * @param DraftOwner $owner The asserted owner. + * @param array $match Values the draft's data must hold, such as a form's fixed values. + * + * @return ObjectEntity|null The draft, or null. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function newest(Register $register, Schema $schema, DraftOwner $owner, array $match = []): ?ObjectEntity { + try { + $found = $this->objects->searchObjects( + query: [ + '@self' => ['register' => (string)$register->getId(), 'schema' => (string)$schema->getId(), 'owner' => $owner->value()], + '_limit' => self::SEARCH_LIMIT, + ], + _rbac: false, + _multitenancy: false + ); + } catch (Throwable) { + return null; + } + + if (is_array($found) === false) { + return null; + } + + $newest = null; + foreach ($found as $candidate) { + if ($this->qualifies(candidate: $candidate, owner: $owner, match: $match) === false) { + continue; + } + + if ($newest === null || self::changedAt(object: $candidate) > self::changedAt(object: $newest)) { + $newest = $candidate; + } + } + + return $newest; + }//end newest() + + /** + * An object's data without its metadata. + * + * @param ObjectEntity $object The object. + * + * @return array The data. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public static function dataOf(ObjectEntity $object): array { + $data = []; + foreach ($object->getObject() as $property => $value) { + if ($property !== 'id' && $property !== '@self') { + $data[(string)$property] = $value; + } + } + + return $data; + }//end dataOf() + + /** + * A draft as callers get it: id, status, data and when it last changed (for a caller's own retention). + * + * @param ObjectEntity $draft The draft. + * + * @return array{id: string, status: string, data: array, updatedAt: string} The draft. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public static function answer(ObjectEntity $draft): array { + $updatedAt = ''; + if (self::changedAt(object: $draft) > 0) { + $updatedAt = ($draft->getUpdated() ?? $draft->getCreated())->format(DATE_ATOM); + } + + return [ + 'id' => (string)$draft->getUuid(), + 'status' => ObjectEntity::STATUS_DRAFT, + 'data' => self::dataOf(object: $draft), + 'updatedAt' => $updatedAt, + ]; + }//end answer() + + /** + * Whether a search hit is the owner's draft and holds every match value. + * + * @param mixed $candidate The search hit. + * @param DraftOwner $owner The owner. + * @param array $match Property values. + * + * @return bool True when it qualifies. + * + * @psalm-assert-if-true ObjectEntity $candidate + */ + private function qualifies(mixed $candidate, DraftOwner $owner, array $match): bool { + if ($candidate instanceof ObjectEntity === false || $owner->owns(object: $candidate) === false) { + return false; + } + + $data = $candidate->getObject(); + foreach ($match as $property => $value) { + if (array_key_exists((string)$property, $data) === false || $data[(string)$property] !== $value) { + return false; + } + } + + return true; + }//end qualifies() + + /** + * The object's last change as a timestamp. + * + * @param ObjectEntity $object The object. + * + * @return int Seconds since the epoch; 0 when unknown. + */ + private static function changedAt(ObjectEntity $object): int { + $moment = $object->getUpdated() ?? $object->getCreated(); + if ($moment === null) { + return 0; + } + + return $moment->getTimestamp(); + }//end changedAt() +}//end class diff --git a/lib/Service/Object/DraftOwner.php b/lib/Service/Object/DraftOwner.php new file mode 100644 index 0000000000..98f6aaddf3 --- /dev/null +++ b/lib/Service/Object/DraftOwner.php @@ -0,0 +1,178 @@ +` (decision 195). + * + * Ruben, 11 October 2026: OpenRegister accepts a scope-value draft owner such + * as `portal:`, and only that subject reads or sends the draft. A + * resident signed in at a portal has no Nextcloud account, so a user id cannot + * own their draft. The value is stored in the object's ordinary `_owner` + * column. A Nextcloud user id cannot hold a colon, so `:` + * never names a user and no user's owner admit in the read RBAC matches it. + * + * The caller asserts the owner. For a portal that is the app that verified + * the resident's session in-process; OpenRegister then only ever compares the + * asserted value with the stored one. Nothing here names an app or a + * procedure (decision 182): `portal` is just the scope portals use. + * + * 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/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Object; + +use InvalidArgumentException; +use OCA\OpenRegister\Db\ObjectEntity; +use OCP\IUser; + +/** + * A draft owner value, and the one comparison that decides whether it owns a draft. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ +final class DraftOwner { + + /** + * The scope a portal's signed-in subjects own drafts under. + * + * @var string + */ + public const PORTAL_SCOPE = 'portal'; + + /** + * The width of the `_owner` column; a longer value would be cut and then match nothing. + * + * @var integer + */ + public const MAX_LENGTH = 64; + + /** + * Constructor. + * + * @param string $value The stored owner value. + * @param IUser|null $user The user, when the owner is one. + */ + private function __construct( + private readonly string $value, + private readonly ?IUser $user, + ) { + + }//end __construct() + + /** + * A Nextcloud user as owner: the uid, as every object has had until now. + * + * @param IUser $user The user. + * + * @return self The owner. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public static function ofUser(IUser $user): self { + return new self(value: $user->getUID(), user: $user); + }//end ofUser() + + /** + * A scope value as owner: `:`. + * + * @param string $scope Lower case letters, digits and hyphens, starting with a letter. + * @param string $reference The subject within the scope; trimmed, no control characters. + * + * @return self The owner. + * + * @throws InvalidArgumentException When either part is malformed or the value does not fit the owner column. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public static function scoped(string $scope, string $reference): self { + if (preg_match('/^[a-z][a-z0-9-]*$/', $scope) !== 1) { + throw new InvalidArgumentException('A draft owner scope is lower case letters, digits and hyphens.'); + } + + $reference = trim($reference); + if ($reference === '' || preg_match('/[\x00-\x1F\x7F]/', $reference) === 1) { + throw new InvalidArgumentException('A draft owner reference is a non-empty value without control characters.'); + } + + $value = $scope . ':' . $reference; + if (strlen($value) > self::MAX_LENGTH) { + throw new InvalidArgumentException(sprintf('A draft owner value is at most %d characters.', self::MAX_LENGTH)); + } + + return new self(value: $value, user: null); + }//end scoped() + + /** + * A portal's signed-in subject as owner: `portal:`. + * + * @param string $subjectRef The subject reference the portal verified. + * + * @return self The owner. + * + * @throws InvalidArgumentException When the reference is empty or too long. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public static function portalSubject(string $subjectRef): self { + return self::scoped(scope: self::PORTAL_SCOPE, reference: $subjectRef); + }//end portalSubject() + + /** + * The value stored in, and compared with, the object's owner. + * + * @return string The value. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function value(): string { + return $this->value; + }//end value() + + /** + * The user, when the owner is one. + * + * @return IUser|null The user, or null for a scope value. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function user(): ?IUser { + return $this->user; + }//end user() + + /** + * Whether the owner is a scope value rather than a user. + * + * @return bool True for a scope value. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function isScoped(): bool { + return $this->user === null; + }//end isScoped() + + /** + * Whether this owner owns the object as a draft: it is a draft and its owner is exactly this value. + * + * @param ObjectEntity $object The stored object. + * + * @return bool True when it is this owner's draft. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function owns(ObjectEntity $object): bool { + return $object->isDraft() === true && $object->getOwner() === $this->value; + }//end owns() +}//end class diff --git a/lib/Service/Object/SaveObject.php b/lib/Service/Object/SaveObject.php index 6fe327566d..3857866874 100644 --- a/lib/Service/Object/SaveObject.php +++ b/lib/Service/Object/SaveObject.php @@ -3072,6 +3072,7 @@ private function sanitizeEmptyStringsForObjectProperties(array $data, Schema $sc * @param bool $failIfExists Insert-only: throw ObjectExistsException rather than update when taken (default: false). * @param bool $_unowned Stamp the system identity even when a session exists. * @param bool $_dedupOverride Save through a blocking duplicate match, when the caller is entitled to. + * @param string|null $_owner An explicit owner for a new object, such as a draft's scope value (decision 195). * * @return ObjectEntity The saved object entity. * @@ -3101,6 +3102,7 @@ public function saveObject( bool $failIfExists = false, bool $_unowned = false, bool $_dedupOverride = false, + ?string $_owner = null, ): ObjectEntity { // Extract UUID and @self metadata from data. [$uuid, $selfData, $data] = $this->extractUuidAndSelfData( @@ -3426,7 +3428,8 @@ public function saveObject( _multitenancy: $_multitenancy, currentUser: $currentUser, failIfExists: $failIfExists, - _unowned: $_unowned + _unowned: $_unowned, + _owner: $_owner ); // An override is only on record once the object it overrode for @@ -3870,6 +3873,7 @@ private function handleObjectUpdate( * @param IUser|null $currentUser Explicit acting user for `@self.folder` access checks (forwarded to setSelfMetadata) * @param bool $failIfExists Insert-only: refuse instead of updating when the identifier is taken. * @param bool $_unowned Stamp the system identity even when a session exists. + * @param string|null $_owner An explicit owner, such as a draft's scope value (decision 195). * * @return ObjectEntity Created object * @@ -3894,6 +3898,7 @@ private function handleObjectCreation( ?IUser $currentUser = null, bool $failIfExists = false, bool $_unowned = false, + ?string $_owner = null, ): ObjectEntity { // Create a new object entity. $objectEntity = new ObjectEntity(); @@ -3919,7 +3924,8 @@ private function handleObjectCreation( selfData: $selfData, _multitenancy: $_multitenancy, currentUser: $currentUser, - _unowned: $_unowned + _unowned: $_unowned, + _owner: $_owner ); // Apply archival metadata from schema archive configuration. @@ -4217,6 +4223,7 @@ private function clearImageMetadataIfFileProperty( * @param bool $_multitenancy Whether to apply multitenancy filtering. * @param IUser|null $currentUser Explicit acting user for `@self.folder` access checks (forwarded to setSelfMetadata). * @param bool $_unowned Stamp the system identity even when a session exists. + * @param string|null $_owner An explicit owner, such as a draft's scope value (decision 195). * * @return ObjectEntity The prepared object entity. * @@ -4236,6 +4243,7 @@ private function prepareObjectForCreation( bool $_multitenancy, ?IUser $currentUser = null, bool $_unowned = false, + ?string $_owner = null, ): ObjectEntity { // Set @self metadata properties. $this->setSelfMetadata(objectEntity: $objectEntity, selfData: $selfData, data: $data, currentUser: $currentUser); @@ -4291,7 +4299,7 @@ private function prepareObjectForCreation( // Set owner from the active user session, or fall back to the system // identifier when no session is active. See applyOwnerAttribution() // for the system-context contract (openregister#1617). - $this->applyOwnerAttribution(objectEntity: $objectEntity, _unowned: $_unowned); + $this->applyOwnerAttribution(objectEntity: $objectEntity, _unowned: $_unowned, _owner: $_owner); // Set organisation from active organisation if not already set. // setSelfMetadata() (called above) only accepted a client-supplied @self.organisation @@ -4350,12 +4358,25 @@ private function prepareObjectForCreation( * from its side, because this method overrides a caller-set owner whenever * there is a session. * + * EXPLICITLY OWNED. `$_owner` is the other service-level argument: a caller + * that asserted who owns a new object, such as a portal subject's draft + * owned by `portal:` (decision 195, see DraftOwner), names + * it here. No controller forwards it either. + * * @param ObjectEntity $objectEntity Entity being prepared for creation. * @param bool $_unowned Stamp the system identity even when a session exists. + * @param string|null $_owner An explicit owner; wins over the session and the system identity. * * @return void + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value */ - private function applyOwnerAttribution(ObjectEntity $objectEntity, bool $_unowned = false): void { + private function applyOwnerAttribution(ObjectEntity $objectEntity, bool $_unowned = false, ?string $_owner = null): void { + if ($_owner !== null && $_owner !== '') { + $objectEntity->setOwner($_owner); + return; + } + // DELIBERATELY UNOWNED. The system identifier rather than an empty // string: rows persisted with an empty `_owner` are invisible to the // REST list path's RBAC filter even for admins (openregister#1617), so diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 8bb8147378..d1172a9015 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -1693,6 +1693,9 @@ public function getLogs(string $uuid, array $filters=[], bool $_rbac=true, bool * @param bool $_dedupOverride Save through a blocking duplicate match, when the caller is * entitled to. Read from the RAW request by ObjectsController, * because the body filter there strips `_`-prefixed keys. + * @param string|null $_owner An explicit owner for a NEW object (a draft's scope value such as + * `portal:`, decision 195). Service-level only: no + * controller forwards it. An update keeps the stored owner. * * @return ObjectEntity The saved and rendered object * @@ -1721,7 +1724,8 @@ public function saveObject( ?IUser $currentUser=null, bool $failIfExists=false, bool $_unowned=false, - bool $_dedupOverride=false + bool $_dedupOverride=false, + ?string $_owner=null ): ObjectEntity { // A SAVE SCOPES ITSELF; IT DOES NOT SCOPE THE NEXT CALLER. // @@ -1950,7 +1954,8 @@ public function saveObject( currentUser: $currentUser, failIfExists: $failIfExists, _unowned: $_unowned, - _dedupOverride: $_dedupOverride + _dedupOverride: $_dedupOverride, + _owner: $_owner ); // Invalidate contact matching cache for objects with email properties. diff --git a/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md index fb1cdcb132..33aba07796 100644 --- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md +++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md @@ -114,6 +114,24 @@ Object metadata SHALL carry a stored `@self.status` with at least the values `dr - **WHEN** it is read - **THEN** its status is deduced from its dates exactly as before +### Requirement: A draft MUST be able to be owned by a scope value + +A draft's owner SHALL be either a Nextcloud user or a scope value `:`, such as `portal:` for a resident signed in at a portal who has no Nextcloud account (decision 195). A scope is lower case letters, digits and hyphens; the whole value SHALL fit the owner column (64 characters). A Nextcloud user id cannot hold a colon, so a scope value never names a user. A draft owned by a scope value SHALL be found, read, saved and sent only by a caller asserting that same owner; every other caller, a signed-in user included, SHALL get the same 404 an unknown draft id gets. The draft clause in the read RBAC keeps such a draft out of every list, as it keeps any draft to its owner. Generic by construction (decision 182): the owner value carries no app or procedure logic. + +#### Scenario: A portal subject saves, reads and sends its own draft +- **GIVEN** a caller asserting owner `portal:subject-1` +- **WHEN** it saves a draft, reads it back by id, finds it by destination, and sends it +- **THEN** the draft is stored with owner `portal:subject-1` and status `draft`, each read answers its data, and the send moves it to `active` + +#### Scenario: Anyone else gets 404 +- **GIVEN** a draft owned by `portal:subject-1` +- **WHEN** a caller asserting `portal:subject-2`, or a signed-in user, reads, saves or sends it by id +- **THEN** each answers 404 and the draft is unchanged + +#### Scenario: A malformed scope value is refused +- **WHEN** an owner is built from an empty reference, a scope with upper case or a colon, or a value longer than 64 characters +- **THEN** it is refused before anything is read or written + ### Requirement: The form destination check MUST refuse from the first release Form save, journey save and schema save SHALL refuse on a finding from the first release that ships the validator. There SHALL be no report-only mode (decision 181). diff --git a/openspec/changes/form-destination-validator/tasks.md b/openspec/changes/form-destination-validator/tasks.md index ec0e3740d3..9dc321bc30 100644 --- a/openspec/changes/form-destination-validator/tasks.md +++ b/openspec/changes/form-destination-validator/tasks.md @@ -23,6 +23,9 @@ - Spec ref: specs/form-destination/spec.md, "An object MUST be able to carry the explicit lifecycle status `draft`" - Test: unit tests for each scenario, including the legacy read control - [x] 2b.2 Draft validation skips `required` only; the draft-to-active move goes through `FormSubmitService` with full validation and receipt effects +- [x] 2b.4 A draft may be owned by a scope value `:` such as `portal:` (decision 195); only a caller asserting that owner finds, reads, saves or sends it, anyone else gets 404 + - Spec ref: specs/form-destination/spec.md, "A draft MUST be able to be owned by a scope value" + - Test: DraftOwnerTest, FormSubmitServiceTest (portal owner cases), SaveObjectSystemOwnerTest (explicit owner) - [ ] 2b.3 Flagged to Ruben, not built here: staff list default for drafts, draft expiry, anonymous drafts (Q-openregister-F2) ## 3. Uploads diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php index 8bf2cd2e82..13abe7cf11 100644 --- a/tests/Unit/Service/Form/FormSubmitServiceTest.php +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -28,6 +28,7 @@ use OCA\OpenRegister\Service\Form\FormPayloadFindings; use OCA\OpenRegister\Service\Form\FormSubmitService; use OCA\OpenRegister\Service\Form\FormUploadStore; +use OCA\OpenRegister\Service\Object\DraftOwner; use OCA\OpenRegister\Service\Object\ValidateObject; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData; @@ -61,6 +62,8 @@ * @uses \OCA\OpenRegister\Exception\CustomValidationException * @uses \OCA\OpenRegister\Exception\NotAuthorizedException * @uses \OCA\OpenRegister\Event\ObjectActivatedEvent + * @uses \OCA\OpenRegister\Service\Object\DraftFinder + * @uses \OCA\OpenRegister\Service\Object\DraftOwner * @uses \OCA\OpenRegister\Service\Object\DraftStatusPolicy */ class FormSubmitServiceTest extends TestCase { @@ -111,6 +114,13 @@ class FormSubmitServiceTest extends TestCase { */ private array $temporary = []; + /** + * Searches the service ran, with their RBAC flags. + * + * @var array> + */ + private array $searches = []; + /** * Events the service dispatched. * @@ -171,6 +181,14 @@ function (string|int $id): Schema { $this->objects = $this->createMock(ObjectService::class); $this->objects->method('saveObject')->willReturnCallback($this->fakeSave(...)); $this->objects->method('find')->willReturnCallback(fn (string|int $id): ?ObjectEntity => ($this->stored[(string)$id] ?? null)); + $this->objects->method('searchObjects')->willReturnCallback( + function (array $query = [], bool $_rbac = true, bool $_multitenancy = true): array { + $this->searches[] = ['query' => $query, 'rbac' => $_rbac, 'multitenancy' => $_multitenancy]; + $owner = ($query['@self']['owner'] ?? null); + + return array_values(array_filter($this->stored, static fn (ObjectEntity $entity): bool => $entity->getOwner() === $owner)); + } + ); $this->objects->method('deleteObject')->willReturnCallback( function (string $uuid): bool { $this->deleted[] = $uuid; @@ -299,9 +317,11 @@ private function fakeSave( ?IUser $currentUser = null, bool $failIfExists = false, bool $_unowned = false, + bool $_dedupOverride = false, + ?string $_owner = null, ): ObjectEntity { $this->assertInstanceOf(Schema::class, $schema); - $this->saves[] = ['object' => $object, 'schema' => $schema->getSlug(), 'rbac' => $_rbac, 'user' => $currentUser, 'unowned' => $_unowned, 'files' => $uploadedFiles, 'uuid' => $uuid]; + $this->saves[] = ['object' => $object, 'schema' => $schema->getSlug(), 'rbac' => $_rbac, 'user' => $currentUser, 'unowned' => $_unowned, 'files' => $uploadedFiles, 'uuid' => $uuid, 'owner' => $_owner]; if (isset($this->refuseSave[$schema->getSlug()]) === true) { throw $this->refuseSave[$schema->getSlug()]; } @@ -315,7 +335,8 @@ private function fakeSave( $entity->setUuid($uuid ?? ('uuid-draft-' . count($this->saves))); $entity->setObject($data); $entity->setStatus('draft'); - $entity->setOwner($currentUser?->getUID()); + // An update keeps the stored owner, as SaveObject's update path does. + $entity->setOwner(($this->stored[(string)$uuid] ?? null)?->getOwner() ?? $_owner ?? $currentUser?->getUID()); $entity->setCreated(new DateTime('2026-10-10T14:03:11+02:00')); $this->stored[$entity->getUuid()] = $entity; @@ -333,6 +354,7 @@ private function fakeSave( $entity->setUuid($uuid ?? ('uuid-' . $schema->getSlug() . '-' . count($this->saves))); $entity->setObject($data); $entity->setStatus($status); + $entity->setOwner(($this->stored[(string)$uuid] ?? null)?->getOwner() ?? $_owner ?? $currentUser?->getUID()); $entity->setCreated(new DateTime('2026-10-10T14:03:11+02:00')); $this->stored[$entity->getUuid()] = $entity; @@ -713,4 +735,94 @@ public function testAnUnknownDraftIsA404(): void { } } }//end testAnUnknownDraftIsA404() + + /** + * Spec scenario: a portal subject saves, reads, finds and sends its own draft (decision 195). + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function testAPortalSubjectSavesReadsFindsAndSendsItsOwnDraft(): void { + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $owner = DraftOwner::portalSubject(subjectRef: 'subject-1'); + + $draft = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Half af'], subject: null, owner: $owner); + + $this->assertSame('draft', $draft['status']); + $this->assertSame('portal:subject-1', $this->saves[0]['owner']); + $this->assertNull($this->saves[0]['user']); + $this->assertFalse($this->saves[0]['unowned']); + $this->assertTrue($this->saves[0]['rbac'], 'Starting a draft is a create, under the same RBAC as an anonymous submit.'); + $this->assertSame('portal:subject-1', $this->stored[$draft['id']]->getOwner()); + + $read = $this->service->readDraft(destination: $destination, draftId: $draft['id'], subject: null, owner: $owner); + $this->assertSame(['id' => $draft['id'], 'status' => 'draft', 'data' => ['title' => 'Half af'], 'updatedAt' => '2026-10-10T14:03:11+02:00'], $read); + + $found = $this->service->findDraft(destination: $destination, subject: null, owner: $owner, match: ['title' => 'Half af']); + $this->assertSame($draft['id'], $found['id'] ?? null); + $this->assertSame('portal:subject-1', $this->searches[0]['query']['@self']['owner']); + $this->assertNull($this->service->findDraft(destination: $destination, subject: null, owner: $owner, match: ['title' => 'Anders'])); + + $this->service->saveDraft(destination: $destination, mapping: null, payload: ['description' => 'Meer'], subject: null, draftId: $draft['id'], owner: $owner); + $this->assertFalse($this->saves[1]['rbac'], 'Updating an own scoped draft is authorised by the owner check, not by a user RBAC no subject has.'); + $this->assertSame(['title' => 'Half af', 'description' => 'Meer'], $this->service->readDraft(destination: $destination, draftId: $draft['id'], subject: null, owner: $owner)['data']); + + $answer = $this->service->submit(destination: $destination, mapping: null, payload: ['caseType' => 'ct-1'], subject: null, draftId: $draft['id'], owner: $owner); + $this->assertSame('active', $this->saves[2]['object']['@self']['status']); + $this->assertSame($draft['id'], $this->saves[2]['uuid']); + $this->assertSame('2026-0412', $answer['reference']); + $this->assertFalse($this->stored[$draft['id']]->isDraft()); + }//end testAPortalSubjectSavesReadsFindsAndSendsItsOwnDraft() + + /** + * Spec scenario: anyone else gets 404, whether another portal subject or a signed-in user. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function testAnyoneElseGets404OnAPortalDraft(): void { + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $draft = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Van subject 1'], subject: null, owner: DraftOwner::portalSubject(subjectRef: 'subject-1')); + $others = [ + 'another subject' => [null, DraftOwner::portalSubject(subjectRef: 'subject-2')], + 'a signed-in user' => [$this->user(uid: 'alice'), null], + 'a user named like the subject' => [$this->user(uid: 'subject-1'), null], + 'nobody' => [null, null], + ]; + + foreach ($others as $who => [$subject, $owner]) { + $this->assertFalse($this->service->isOwnerOfDraft(destination: $destination, draftId: $draft['id'], subject: $subject, owner: $owner), $who); + $this->assertNull($this->service->findDraft(destination: $destination, subject: $subject, owner: $owner), $who); + $calls = [ + 'read' => fn () => $this->service->readDraft(destination: $destination, draftId: $draft['id'], subject: $subject, owner: $owner), + 'save' => fn () => $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Overschreven'], subject: $subject, draftId: $draft['id'], owner: $owner), + 'send' => fn () => $this->service->submit(destination: $destination, mapping: null, payload: ['caseType' => 'x'], subject: $subject, draftId: $draft['id'], owner: $owner), + ]; + foreach ($calls as $action => $call) { + try { + $call(); + $this->fail($who . ' could ' . $action . ' the draft.'); + } catch (FormSubmitRefusedException $refused) { + // Nobody at all may not save a draft (401); everyone else meets the same 404 an unknown id gets. + $expected = ($subject === null && $owner === null && $action === 'save') ? 401 : 404; + $this->assertSame($expected, $refused->getStatus(), $who . ' ' . $action); + } + } + } + + $this->assertCount(1, $this->saves); + $this->assertSame('Van subject 1', $this->stored[$draft['id']]->getObject()['title']); + $this->assertTrue($this->stored[$draft['id']]->isDraft()); + }//end testAnyoneElseGets404OnAPortalDraft() + + /** + * A user's own draft reads back through readDraft as before; the owner defaults to the subject. + */ + public function testAUsersDraftReadsBackThroughTheSubject(): void { + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $alice = $this->user(uid: 'alice'); + $draft = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Van Alice'], subject: $alice); + + $this->assertNull($this->saves[0]['owner'], 'A user draft is owned through the session user, as before.'); + $this->assertSame('Van Alice', $this->service->readDraft(destination: $destination, draftId: $draft['id'], subject: $alice)['data']['title']); + $this->assertSame($draft['id'], $this->service->findDraft(destination: $destination, subject: $alice)['id'] ?? null); + }//end testAUsersDraftReadsBackThroughTheSubject() }//end class diff --git a/tests/Unit/Service/Object/DraftFinderTest.php b/tests/Unit/Service/Object/DraftFinderTest.php new file mode 100644 index 0000000000..becf9e5def --- /dev/null +++ b/tests/Unit/Service/Object/DraftFinderTest.php @@ -0,0 +1,169 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use DateTime; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\Object\DraftFinder; +use OCA\OpenRegister\Service\Object\DraftOwner; +use OCA\OpenRegister\Service\ObjectService; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * Decision 195: only the asserted owner finds its draft. + * + * @covers \OCA\OpenRegister\Service\Object\DraftFinder + * @uses \OCA\OpenRegister\Service\Object\DraftOwner + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Db\Register + * @uses \OCA\OpenRegister\Db\Schema + */ +class DraftFinderTest extends TestCase { + + /** + * Stored objects by uuid. + * + * @var array + */ + private array $stored = []; + + /** + * Searches run: query and flags. + * + * @var array> + */ + private array $searches = []; + + private ObjectService $objects; + + private DraftFinder $finder; + + private Register $register; + + private Schema $schema; + + protected function setUp(): void { + $this->objects = $this->createMock(ObjectService::class); + $this->objects->method('find')->willReturnCallback( + function (string|int $id, ...$rest): ?ObjectEntity { + if ($id === 'boom') { + throw new RuntimeException('database gone'); + } + + return ($this->stored[(string)$id] ?? null); + } + ); + $this->objects->method('searchObjects')->willReturnCallback( + function (array $query = [], bool $_rbac = true, bool $_multitenancy = true): array { + $this->searches[] = ['query' => $query, 'rbac' => $_rbac, 'multitenancy' => $_multitenancy]; + $owner = ($query['@self']['owner'] ?? null); + $hits = array_values(array_filter($this->stored, static fn (ObjectEntity $entity): bool => $entity->getOwner() === $owner)); + $hits[] = ['not' => 'an entity']; + + return $hits; + } + ); + $this->finder = new DraftFinder(objects: $this->objects); + $this->register = new Register(); + $this->register->setId(10); + $this->schema = new Schema(); + $this->schema->setId(1); + }//end setUp() + + /** + * Store an object. + * + * @param array $data The data. + */ + private function store(string $uuid, string $owner, ?string $status, array $data, string $updated): ObjectEntity { + $entity = new ObjectEntity(); + $entity->setUuid($uuid); + $entity->setOwner($owner); + $entity->setStatus($status); + $entity->setObject($data); + $entity->setUpdated(new DateTime($updated)); + $this->stored[$uuid] = $entity; + + return $entity; + }//end store() + + /** + * By id: the owner's draft is found; another owner, a non-draft, an unknown id, a failing read or nobody find nothing. + */ + public function testOwnFindsOnlyTheOwnersDraft(): void { + $draft = $this->store(uuid: 'd1', owner: 'portal:subject-1', status: 'draft', data: ['title' => 'A'], updated: '2026-10-10'); + $this->store(uuid: 'sent', owner: 'portal:subject-1', status: 'active', data: [], updated: '2026-10-10'); + $owner = DraftOwner::portalSubject(subjectRef: 'subject-1'); + + $this->assertTrue($this->finder->own(register: $this->register, schema: $this->schema, draftId: 'd1', owner: $owner) === $draft); + $this->assertNull($this->finder->own(register: $this->register, schema: $this->schema, draftId: 'd1', owner: DraftOwner::portalSubject(subjectRef: 'subject-2'))); + $this->assertNull($this->finder->own(register: $this->register, schema: $this->schema, draftId: 'sent', owner: $owner)); + $this->assertNull($this->finder->own(register: $this->register, schema: $this->schema, draftId: 'nope', owner: $owner)); + $this->assertNull($this->finder->own(register: $this->register, schema: $this->schema, draftId: 'boom', owner: $owner)); + $this->assertNull($this->finder->own(register: $this->register, schema: $this->schema, draftId: 'd1', owner: null)); + }//end testOwnFindsOnlyTheOwnersDraft() + + /** + * In a destination: the owner's newest matching draft, searched by owner with RBAC off and checked again. + */ + public function testNewestPicksTheOwnersNewestMatchingDraft(): void { + $this->store(uuid: 'old', owner: 'portal:subject-1', status: 'draft', data: ['caseType' => 'ct-1'], updated: '2026-10-01'); + $this->store(uuid: 'new', owner: 'portal:subject-1', status: 'draft', data: ['caseType' => 'ct-1'], updated: '2026-10-09'); + $this->store(uuid: 'other-type', owner: 'portal:subject-1', status: 'draft', data: ['caseType' => 'ct-2'], updated: '2026-10-10'); + $this->store(uuid: 'sent', owner: 'portal:subject-1', status: 'active', data: ['caseType' => 'ct-1'], updated: '2026-10-11'); + $this->store(uuid: 'theirs', owner: 'portal:subject-2', status: 'draft', data: ['caseType' => 'ct-1'], updated: '2026-10-11'); + $owner = DraftOwner::portalSubject(subjectRef: 'subject-1'); + + $newest = $this->finder->newest(register: $this->register, schema: $this->schema, owner: $owner, match: ['caseType' => 'ct-1']); + + $this->assertSame('new', $newest?->getUuid()); + $this->assertSame(['register' => '10', 'schema' => '1', 'owner' => 'portal:subject-1'], $this->searches[0]['query']['@self']); + $this->assertSame(DraftFinder::SEARCH_LIMIT, $this->searches[0]['query']['_limit']); + $this->assertFalse($this->searches[0]['rbac']); + $this->assertFalse($this->searches[0]['multitenancy']); + $this->assertSame('other-type', $this->finder->newest(register: $this->register, schema: $this->schema, owner: $owner)?->getUuid()); + $this->assertNull($this->finder->newest(register: $this->register, schema: $this->schema, owner: $owner, match: ['caseType' => 'ct-9'])); + $this->assertNull($this->finder->newest(register: $this->register, schema: $this->schema, owner: DraftOwner::portalSubject(subjectRef: 'subject-3'))); + }//end testNewestPicksTheOwnersNewestMatchingDraft() + + /** + * A failing or counting search finds nothing rather than failing the caller. + */ + public function testAFailingOrCountingSearchFindsNothing(): void { + $objects = $this->createMock(ObjectService::class); + $objects->method('searchObjects')->willReturnOnConsecutiveCalls(3, $this->throwException(new RuntimeException('gone'))); + $finder = new DraftFinder(objects: $objects); + $owner = DraftOwner::portalSubject(subjectRef: 'subject-1'); + + $this->assertNull($finder->newest(register: $this->register, schema: $this->schema, owner: $owner)); + $this->assertNull($finder->newest(register: $this->register, schema: $this->schema, owner: $owner)); + }//end testAFailingOrCountingSearchFindsNothing() + + /** + * The data without its metadata keys. + */ + public function testDataOfLeavesOutTheMetadata(): void { + $entity = $this->store(uuid: 'd1', owner: 'portal:subject-1', status: 'draft', data: ['title' => 'A', '@self' => ['x' => 1]], updated: '2026-10-10'); + + $this->assertSame(['title' => 'A'], DraftFinder::dataOf(object: $entity)); + $this->assertSame( + ['id' => 'd1', 'status' => 'draft', 'data' => ['title' => 'A'], 'updatedAt' => (new DateTime('2026-10-10'))->format(DATE_ATOM)], + DraftFinder::answer(draft: $entity) + ); + $this->assertSame('', DraftFinder::answer(draft: new ObjectEntity())['updatedAt']); + }//end testDataOfLeavesOutTheMetadata() +}//end class diff --git a/tests/Unit/Service/Object/DraftOwnerTest.php b/tests/Unit/Service/Object/DraftOwnerTest.php new file mode 100644 index 0000000000..9602f7ec65 --- /dev/null +++ b/tests/Unit/Service/Object/DraftOwnerTest.php @@ -0,0 +1,122 @@ +`. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use InvalidArgumentException; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Object\DraftOwner; +use OCP\IUser; +use PHPUnit\Framework\TestCase; + +/** + * Decision 195: a draft may be owned by a scope value; only that owner finds it. + * + * @covers \OCA\OpenRegister\Service\Object\DraftOwner + * @uses \OCA\OpenRegister\Db\ObjectEntity + */ +class DraftOwnerTest extends TestCase { + + /** + * A user with a uid. + */ + private function user(string $uid): IUser { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + + return $user; + }//end user() + + /** + * A stored object with an owner and a status. + */ + private function object(?string $owner, ?string $status): ObjectEntity { + $entity = new ObjectEntity(); + $entity->setUuid('uuid-1'); + $entity->setOwner($owner); + $entity->setStatus($status); + + return $entity; + }//end object() + + /** + * A user owner is the uid, carries the user, and is not scoped. + */ + public function testAUserOwnerIsTheUid(): void { + $alice = $this->user(uid: 'alice'); + $owner = DraftOwner::ofUser(user: $alice); + + $this->assertSame('alice', $owner->value()); + $this->assertTrue($owner->user() === $alice); + $this->assertFalse($owner->isScoped()); + }//end testAUserOwnerIsTheUid() + + /** + * A portal subject owner is `portal:`, scoped, without a user. + */ + public function testAPortalSubjectIsAScopeValue(): void { + $owner = DraftOwner::portalSubject(subjectRef: ' subject-1 '); + + $this->assertSame('portal:subject-1', $owner->value()); + $this->assertNull($owner->user()); + $this->assertTrue($owner->isScoped()); + $this->assertSame('ledger:x', DraftOwner::scoped(scope: 'ledger', reference: 'x')->value()); + }//end testAPortalSubjectIsAScopeValue() + + /** + * Only the same owner owns a draft; a non-draft is nobody's draft. + */ + public function testOnlyTheSameOwnerOwnsTheDraft(): void { + $owner = DraftOwner::portalSubject(subjectRef: 'subject-1'); + + $this->assertTrue($owner->owns(object: $this->object(owner: 'portal:subject-1', status: 'draft'))); + $this->assertFalse($owner->owns(object: $this->object(owner: 'portal:subject-2', status: 'draft'))); + $this->assertFalse($owner->owns(object: $this->object(owner: 'subject-1', status: 'draft'))); + $this->assertFalse($owner->owns(object: $this->object(owner: 'portal:subject-1', status: 'active'))); + $this->assertFalse($owner->owns(object: $this->object(owner: 'portal:subject-1', status: null))); + $this->assertFalse(DraftOwner::ofUser(user: $this->user(uid: 'portal:subject-1'))->isScoped()); + }//end testOnlyTheSameOwnerOwnsTheDraft() + + /** + * Malformed scope values are refused before anything is read or written. + * + * @dataProvider malformed + */ + public function testAMalformedScopeValueIsRefused(string $scope, string $reference): void { + $this->expectException(InvalidArgumentException::class); + DraftOwner::scoped(scope: $scope, reference: $reference); + }//end testAMalformedScopeValueIsRefused() + + /** + * Scope and reference pairs that are not an owner. + * + * @return array + */ + public static function malformed(): array { + return [ + 'empty reference' => ['portal', ' '], + 'empty scope' => ['', 'subject-1'], + 'upper case scope' => ['Portal', 'subject-1'], + 'colon in scope' => ['por:tal', 'subject-1'], + 'control character' => ['portal', "sub\nject"], + 'longer than the owner column' => ['portal', str_repeat('a', 58)], + ]; + }//end malformed() + + /** + * The longest reference that still fits the 64-character owner column. + */ + public function testTheLongestFittingReferenceIsAccepted(): void { + $this->assertSame(64, strlen(DraftOwner::portalSubject(subjectRef: str_repeat('a', 57))->value())); + }//end testTheLongestFittingReferenceIsAccepted() +}//end class diff --git a/tests/Unit/Service/Object/SaveObjectSystemOwnerTest.php b/tests/Unit/Service/Object/SaveObjectSystemOwnerTest.php index 0a08404184..3b9aa1a2e0 100644 --- a/tests/Unit/Service/Object/SaveObjectSystemOwnerTest.php +++ b/tests/Unit/Service/Object/SaveObjectSystemOwnerTest.php @@ -218,4 +218,23 @@ public function testSessionlessWriteOverwritesEmptyStringOwner(): void { $this->assertSame('__system__', $entity->getOwner()); }//end testSessionlessWriteOverwritesEmptyStringOwner() + + /** + * An explicit owner from the service caller wins over the session and the system identity (decision 195). + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function testAnExplicitOwnerWinsOverTheSession(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('admin'); + $this->userSession->method('getUser')->willReturn($user); + $this->organisationService->expects($this->never())->method('getSystemUserId'); + + $entity = new ObjectEntity(); + $method = new ReflectionMethod(SaveObject::class, 'applyOwnerAttribution'); + $method->setAccessible(true); + $method->invoke($this->handler, $entity, false, 'portal:subject-1'); + + $this->assertSame('portal:subject-1', $entity->getOwner()); + }//end testAnExplicitOwnerWinsOverTheSession() }//end class From 6a89b48bd1df077efe23bf448f8c954131e7cd80 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 10:24:59 +0200 Subject: [PATCH 078/183] fix(dmn): validate only rows in the decision-tables register; test the register import The listener matched a row by schema slug alone, so another app's own decision-table schema (buildiq's, until it moves onto this register) would have had its rows refused. It now also requires the decision-tables register. ImportDecisionTableRegister gains its unit tests (CI's coverage guard read the added statements as untested). --- .../DecisionTableValidationListener.php | 34 +++- .../DecisionTableValidationListenerTest.php | 54 +++++- .../ImportDecisionTableRegisterTest.php | 168 ++++++++++++++++++ 3 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Repair/ImportDecisionTableRegisterTest.php diff --git a/lib/Listener/DecisionTableValidationListener.php b/lib/Listener/DecisionTableValidationListener.php index 536c4c9949..84f78844c6 100644 --- a/lib/Listener/DecisionTableValidationListener.php +++ b/lib/Listener/DecisionTableValidationListener.php @@ -26,6 +26,7 @@ namespace OCA\OpenRegister\Listener; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Event\ObjectCreatingEvent; use OCA\OpenRegister\Event\ObjectUpdatingEvent; @@ -62,11 +63,13 @@ class DecisionTableValidationListener implements IEventListener { * @param SchemaMapper $schemas Tells a decision-table row from any other. * @param DecisionTableValidator $validator The validator the inline step uses. * @param ObjectService $objects Finds a table already carrying the key. + * @param RegisterMapper $registers Tells the decision-tables register from another app's. */ public function __construct( private readonly SchemaMapper $schemas, private readonly DecisionTableValidator $validator, private readonly ObjectService $objects, + private readonly RegisterMapper $registers, ) { }//end __construct() @@ -146,9 +149,38 @@ private function isDecisionTable(ObjectEntity $object): bool { return false; } - return $schema->getSlug() === DecisionTableStore::SCHEMA; + if ($schema->getSlug() !== DecisionTableStore::SCHEMA) { + return false; + } + + // The slug alone is not the table: another app may still hold a schema + // of the same slug in its own register (buildiq did until decision + // 196), and its rows are not OpenRegister's tables to validate. + return $this->inDecisionTableRegister(object: $object); }//end isDecisionTable() + /** + * Whether the object lives in the decision-tables register. + * + * @param ObjectEntity $object The object being saved. + * + * @return bool True when its register is `decision-tables`. + */ + private function inDecisionTableRegister(ObjectEntity $object): bool { + $reference = $object->getRegister(); + if ($reference === null || $reference === '') { + return false; + } + + try { + $register = $this->registers->find($reference, _rbac: false, _multitenancy: false); + } catch (Throwable $unresolved) { + return false; + } + + return $register->getSlug() === DecisionTableStore::REGISTER; + }//end inDecisionTableRegister() + /** * The uuid of another table already carrying the key, if any. * diff --git a/tests/Unit/Listener/DecisionTableValidationListenerTest.php b/tests/Unit/Listener/DecisionTableValidationListenerTest.php index c7753ade59..3b73f7585d 100644 --- a/tests/Unit/Listener/DecisionTableValidationListenerTest.php +++ b/tests/Unit/Listener/DecisionTableValidationListenerTest.php @@ -5,6 +5,8 @@ namespace OCA\OpenRegister\Tests\Unit\Listener; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Event\ObjectCreatingEvent; @@ -13,6 +15,7 @@ use OCA\OpenRegister\Service\Dmn\DecisionTableValidator; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Tests\Unit\Service\Dmn\DecisionTableStoreTest; +use OCP\AppFramework\Db\DoesNotExistException; use PHPUnit\Framework\TestCase; /** @@ -22,7 +25,7 @@ */ class DecisionTableValidationListenerTest extends TestCase { - private function listener(string $slug, array $sameKey = []): DecisionTableValidationListener { + private function listener(string $slug, array $sameKey = [], ?string $registerSlug = 'decision-tables'): DecisionTableValidationListener { $schema = new Schema(); $schema->setSlug($slug); $schemas = $this->createMock(SchemaMapper::class); @@ -31,13 +34,23 @@ private function listener(string $slug, array $sameKey = []): DecisionTableValid $objects = $this->createMock(ObjectService::class); $objects->method('searchObjectsBySlug')->willReturn($sameKey); - return new DecisionTableValidationListener($schemas, new DecisionTableValidator(), $objects); + $registers = $this->createMock(RegisterMapper::class); + if ($registerSlug === null) { + $registers->method('find')->willThrowException(new DoesNotExistException('no register')); + } else { + $register = new Register(); + $register->setSlug($registerSlug); + $registers->method('find')->willReturn($register); + } + + return new DecisionTableValidationListener($schemas, new DecisionTableValidator(), $objects, $registers); } private function row(array $data, string $uuid = 'u-1'): ObjectEntity { $object = new ObjectEntity(); $object->setUuid($uuid); $object->setSchema('7'); + $object->setRegister('3'); $object->setObject($data); return $object; } @@ -77,4 +90,41 @@ public function testOtherSchemasAreLeftAlone(): void { $this->listener('invoice')->handle($event); $this->assertFalse($event->isPropagationStopped()); } + + /** + * A schema of the same slug in another app's register is not OpenRegister's + * table: its rows are left alone (buildiq's own `decision-table` until it moved). + * + * @return void + */ + public function testTheSameSlugInAnotherRegisterIsLeftAlone(): void { + $event = new ObjectCreatingEvent($this->row(['ruleSetId' => 'loan', 'hitPolicy' => 'first'])); + $this->listener('decision-table', [], 'buildiq')->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } + + /** + * A row whose register cannot be resolved is left alone. + * + * @return void + */ + public function testAnUnresolvableRegisterIsLeftAlone(): void { + $event = new ObjectCreatingEvent($this->row(['anything' => true])); + $this->listener('decision-table', [], null)->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } + + /** + * A row without a schema is left alone. + * + * @return void + */ + public function testARowWithoutASchemaIsLeftAlone(): void { + $object = new ObjectEntity(); + $object->setUuid('u-9'); + $object->setObject(['anything' => true]); + $event = new ObjectCreatingEvent($object); + $this->listener('decision-table')->handle($event); + $this->assertFalse($event->isPropagationStopped()); + } } diff --git a/tests/Unit/Repair/ImportDecisionTableRegisterTest.php b/tests/Unit/Repair/ImportDecisionTableRegisterTest.php new file mode 100644 index 0000000000..b469164c5b --- /dev/null +++ b/tests/Unit/Repair/ImportDecisionTableRegisterTest.php @@ -0,0 +1,168 @@ + + * @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 + * + * @spec openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Repair; + +use OCA\OpenRegister\Repair\ImportDecisionTableRegister; +use OCA\OpenRegister\Service\ConfigurationService; +use OCP\App\IAppManager; +use OCP\Migration\IOutput; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Tests for the decision-table register import. + */ +class ImportDecisionTableRegisterTest extends TestCase { + + /** + * @var ConfigurationService&MockObject + */ + private ConfigurationService&MockObject $configurationService; + + /** + * @var IAppManager&MockObject + */ + private IAppManager&MockObject $appManager; + + /** + * @var IOutput&MockObject + */ + private IOutput&MockObject $output; + + /** + * Build the step with mocked collaborators. + * + * @return void + */ + protected function setUp(): void { + $this->configurationService = $this->createMock(ConfigurationService::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->output = $this->createMock(IOutput::class); + + }//end setUp() + + /** + * The step, with the app living at a path. + * + * @param string $appPath Where the app is installed. + * + * @return ImportDecisionTableRegister + */ + private function step(string $appPath): ImportDecisionTableRegister { + $this->appManager->method('getAppPath')->with('openregister')->willReturn($appPath); + + return new ImportDecisionTableRegister( + configurationService: $this->configurationService, + appManager: $this->appManager, + logger: $this->createMock(LoggerInterface::class) + ); + + }//end step() + + /** + * The shipped descriptor is imported as the decision-tables register with + * its `decision-table` schema, under the step's own configuration id. + * + * @return void + */ + public function testTheShippedDescriptorIsImported(): void { + $this->configurationService->expects($this->once()) + ->method('importFromApp') + ->with( + $this->equalTo('openregister.decision-tables'), + $this->callback( + static function (array $data): bool { + return isset($data['components']['registers']['decision-tables']) === true + && ($data['components']['schemas']['decision-table']['slug'] ?? null) === 'decision-table'; + } + ), + $this->equalTo('1.0.0'), + $this->equalTo(false) + ); + $this->output->expects($this->once())->method('info'); + + $step = $this->step(appPath: (string)realpath(__DIR__ . '/../../..')); + $step->run($this->output); + + $this->assertStringContainsString('decision-table', $step->getName()); + + }//end testTheShippedDescriptorIsImported() + + /** + * A missing descriptor is a warning, not an import. + * + * @return void + */ + public function testAMissingDescriptorWarnsAndImportsNothing(): void { + $this->configurationService->expects($this->never())->method('importFromApp'); + $this->output->expects($this->once())->method('warning') + ->with($this->stringContains('not found')); + + $this->step(appPath: sys_get_temp_dir() . '/no-such-openregister-' . getmypid())->run($this->output); + + }//end testAMissingDescriptorWarnsAndImportsNothing() + + /** + * A descriptor that is not JSON is a warning, not an import. + * + * @return void + */ + public function testADescriptorThatIsNotJsonWarns(): void { + $root = sys_get_temp_dir() . '/or-dt-' . getmypid() . '-' . bin2hex(random_bytes(4)); + mkdir($root . '/lib/Settings', 0777, true); + file_put_contents($root . '/lib/Settings/decision_table_register.json', '{not json'); + + $this->configurationService->expects($this->never())->method('importFromApp'); + $this->output->expects($this->once())->method('warning') + ->with($this->stringContains('not valid JSON')); + + try { + $this->step(appPath: $root)->run($this->output); + } finally { + unlink($root . '/lib/Settings/decision_table_register.json'); + rmdir($root . '/lib/Settings'); + rmdir($root . '/lib'); + rmdir($root); + } + + }//end testADescriptorThatIsNotJsonWarns() + + /** + * A failing import is caught and reported, never thrown. + * + * @return void + */ + public function testAFailingImportIsReportedNotThrown(): void { + $this->configurationService->method('importFromApp')->willThrowException(new RuntimeException('database gone')); + $this->output->expects($this->once())->method('warning') + ->with($this->stringContains('database gone')); + + $this->step(appPath: (string)realpath(__DIR__ . '/../../..'))->run($this->output); + + }//end testAFailingImportIsReportedNotThrown() +}//end class From 60abb4e6a42f83eccab1a1446ed371f38212f8f3 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 10:25:15 +0200 Subject: [PATCH 079/183] chore(decisions): the entity relation PATCH names its requirement --- lib/Controller/EntityRelationsController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Controller/EntityRelationsController.php b/lib/Controller/EntityRelationsController.php index acccc210ec..0581e530b0 100644 --- a/lib/Controller/EntityRelationsController.php +++ b/lib/Controller/EntityRelationsController.php @@ -112,6 +112,8 @@ public function __construct( * @return JSONResponse Updated relation (200) or a structured error. * * @NoAdminRequired + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 */ public function update(int $id): JSONResponse { $user = $this->userSession->getUser(); From 5752019ac7254f8ead71b3524ad00e5f684f82fa Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 18:58:18 +0200 Subject: [PATCH 080/183] fix(schemas): accept the hideOnCreate form role and publish inputControl's accepted values (#4585) * fix(schemas): accept hideOnCreate as a form role in x-openregister-extends-form nextcloud-vue 2.79.0 reads hideOnCreate to leave a field off the create form. The extends-form validator refused it as an unknown vocabulary key, so dossiq's register import rejected the whole case schema (PARTIAL IMPORT) and upgraded instances kept the old one. A form role is accepted, forwards no key and never lands on a property built from a form. The extending-forms read publishes the form roles and source aliases. * feat(schemas): publish the accepted values of inputControl and matchType The save path refuses any inputControl or matchType outside a closed list, but the vocabulary only said 'string'. dossiq shipped inputControl: textarea and the import refused its whole aanvullingsverzoek schema. The vocabulary now carries allowedValues on those modifier rows, taken from the one list the validator enforces, so an app can pin its shipped schemas against it. * refactor(schemas): keep the form-role change inside the phpmd complexity budget The closed modifier lists become a constant, so the vocabulary reads them without static access, and the form-role skips fold into existing branches. --- .../PropertyVocabularyController.php | 3 + .../Schemas/ExtendingFormDeclaration.php | 25 +++++- .../Schemas/PropertyValidatorHandler.php | 21 +++-- lib/Service/Schemas/PropertyVocabulary.php | 15 +++- .../changes/extends-form-form-roles/design.md | 26 ++++++ .../extends-form-form-roles/proposal.md | 46 ++++++++++ .../specs/runtime-schema-api/spec.md | 46 ++++++++++ .../changes/extends-form-form-roles/tasks.md | 7 ++ .../PropertyVocabularyControllerTest.php | 3 + .../Schemas/ExtendingFormDeclarationTest.php | 83 +++++++++++++++++++ .../Schemas/PropertyVocabularyTest.php | 32 +++++++ 11 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/extends-form-form-roles/design.md create mode 100644 openspec/changes/extends-form-form-roles/proposal.md create mode 100644 openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md create mode 100644 openspec/changes/extends-form-form-roles/tasks.md diff --git a/lib/Controller/PropertyVocabularyController.php b/lib/Controller/PropertyVocabularyController.php index 34e8f6efa0..ea6adf0572 100644 --- a/lib/Controller/PropertyVocabularyController.php +++ b/lib/Controller/PropertyVocabularyController.php @@ -98,6 +98,7 @@ public function index(): JSONResponse { * @NoCSRFRequired * * @spec openspec/changes/property-vocabulary-published/specs/runtime-schema-api/spec.md + * @spec openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md */ public function extendingForms(): JSONResponse { $rows = []; @@ -125,6 +126,8 @@ public function extendingForms(): JSONResponse { 'declarations' => $rows, 'annotation' => ExtendingFormDeclaration::ANNOTATION, 'vocabularyKeys' => $this->vocabulary->keys(), + 'sourceAliases' => ExtendingFormDeclaration::SOURCE_ALIASES, + 'formRoles' => ExtendingFormDeclaration::FORM_ROLES, 'counts' => ['declarations' => count($rows)], ] ); diff --git a/lib/Service/Schemas/ExtendingFormDeclaration.php b/lib/Service/Schemas/ExtendingFormDeclaration.php index d6a9af792f..b14619521e 100644 --- a/lib/Service/Schemas/ExtendingFormDeclaration.php +++ b/lib/Service/Schemas/ExtendingFormDeclaration.php @@ -79,6 +79,22 @@ final class ExtendingFormDeclaration { */ public const SOURCE_ALIASES = ['definition' => 'description']; + /** + * Roles the consumer reads to steer the form, not to fill a property key. + * + * `@conduction/nextcloud-vue` 2.79.0 added `hideOnCreate` to the + * `DEFAULT_MAP` of `propertiesFromDefinitions`: a definition whose mapped + * field is `true` gets no field on a create form, because the app fills + * that answer in itself. It forwards no vocabulary key, so it is neither + * counted as forwarded nor written onto a property built from a form. + * Refusing it by name refused dossiq's whole case schema at import. + * + * @var array + */ + public const FORM_ROLES = [ + 'hideOnCreate' => 'The create form leaves the field out, because the app fills it in itself. Edit forms still show it.', + ]; + /** * Wire the vocabulary the declaration is checked against. * @@ -132,6 +148,7 @@ public function fromSchema(array $configuration, array $properties): array { * @return array The refusals, empty when valid. * * @spec openspec/changes/property-vocabulary-published/specs/runtime-schema-api/spec.md + * @spec openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md * * @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per rule, each naming its own refusal. * @SuppressWarnings(PHPMD.NPathComplexity) One branch per rule, each naming its own refusal. @@ -171,7 +188,9 @@ public function validate(mixed $annotation, string $path = self::ANNOTATION): ar continue; } - if (array_key_exists($role, self::SOURCE_ALIASES) === true) { + // A source alias or a form role is read by the consumer without + // being a property key of its own. + if (array_key_exists($role, (self::SOURCE_ALIASES + self::FORM_ROLES)) === true) { continue; } @@ -284,10 +303,12 @@ public function describe(mixed $annotation, string $path = self::ANNOTATION): ar * @return array The property definition. * * @spec openspec/changes/property-vocabulary-published/specs/runtime-schema-api/spec.md + * @spec openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md */ public function toProperty(mixed $annotation, array $formValues): array { $property = []; - foreach ($this->pairs(annotation: $annotation) as $role => $field) { + // A form role steers the form and fills no property key. + foreach (array_diff_key($this->pairs(annotation: $annotation), self::FORM_ROLES) as $role => $field) { $key = (string)$role; if (array_key_exists($key, self::SOURCE_ALIASES) === true) { $key = self::SOURCE_ALIASES[$key]; diff --git a/lib/Service/Schemas/PropertyValidatorHandler.php b/lib/Service/Schemas/PropertyValidatorHandler.php index e2631d5f28..6294abee11 100644 --- a/lib/Service/Schemas/PropertyValidatorHandler.php +++ b/lib/Service/Schemas/PropertyValidatorHandler.php @@ -460,6 +460,20 @@ class PropertyValidatorHandler { ], ]; + /** + * The modifiers that take one value from a closed list, and that list. + * + * The save path refuses any other value, so the list is published with the + * vocabulary. An app that ships `inputControl: textarea` learns at build + * time what it otherwise learns from a schema refused at import. + * + * @var array> + */ + public const CLOSED_MODIFIER_VALUES = [ + 'matchType' => PropertySearchProfile::MATCH_TYPES, + 'inputControl' => PropertySearchProfile::INPUT_CONTROLS, + ]; + /** * The keys that change how a property behaves rather than what it accepts. * @@ -1005,12 +1019,7 @@ public function validateProperty(array $property, string $path = ''): bool { * @spec openspec/changes/search-quality-operators-and-facets/specs/zoeken-filteren/spec.md */ private function validateSearchProfile(array $property, string $path): void { - $declarations = [ - 'matchType' => PropertySearchProfile::MATCH_TYPES, - 'inputControl' => PropertySearchProfile::INPUT_CONTROLS, - ]; - - foreach ($declarations as $key => $allowed) { + foreach (self::CLOSED_MODIFIER_VALUES as $key => $allowed) { $declared = ($property[$key] ?? null); if ($declared === null) { continue; diff --git a/lib/Service/Schemas/PropertyVocabulary.php b/lib/Service/Schemas/PropertyVocabulary.php index 56942cdf27..28c45d2079 100644 --- a/lib/Service/Schemas/PropertyVocabulary.php +++ b/lib/Service/Schemas/PropertyVocabulary.php @@ -162,19 +162,30 @@ public function constraints(): array { /** * Every modifier key, valid on any type. * - * @return array The modifier rows. + * A modifier the save path checks against a closed list also carries that + * list as `allowedValues`. + * + * @return array}> The modifier rows. * * @spec openspec/changes/property-vocabulary-published/specs/runtime-schema-api/spec.md + * @spec openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md */ public function modifiers(): array { $rows = []; + $closed = PropertyValidatorHandler::CLOSED_MODIFIER_VALUES; foreach (PropertyValidatorHandler::MODIFIERS as $key => $descriptor) { - $rows[] = [ + $row = [ 'key' => (string)$key, 'value' => $descriptor['value'], 'enforced' => true, 'description' => $descriptor['description'], ]; + if (array_key_exists((string)$key, $closed) === true) { + $row['allowedValues'] = $closed[(string)$key]; + } + + $rows[] = $row; } return $rows; diff --git a/openspec/changes/extends-form-form-roles/design.md b/openspec/changes/extends-form-form-roles/design.md new file mode 100644 index 0000000000..46dbd4e492 --- /dev/null +++ b/openspec/changes/extends-form-form-roles/design.md @@ -0,0 +1,26 @@ +# Design: extends-form-form-roles + +`ExtendingFormDeclaration::FORM_ROLES` maps each form role to a sentence that +says what it steers. `validate()` accepts a role that is a vocabulary key, a +source alias or a form role, and refuses anything else as before. +`forwards()` already skips a role the vocabulary does not hold, so a form role +is not counted. `toProperty()` skips form roles explicitly, so a submitted +`hideOnCreate` value never becomes a property key the property validator would +then refuse. + +`GET /api/schemas/extending-forms` adds `formRoles` and `sourceAliases`. They +are static and per platform, not per schema, so they sit beside +`vocabularyKeys`. + +The list is taken from the consumer: `DEFAULT_MAP` in +`src/utils/dynamicProperties.js` of `@conduction/nextcloud-vue` 2.79.0 reads +`title`, `description`, `type`, `enum`, `required`, `default` and +`hideOnCreate`, and `mapped()` is also called for `definition`, `format` and +`maxLength`. All of those but `definition` and `hideOnCreate` are vocabulary +keys; `definition` is the existing alias, `hideOnCreate` is the new form role. +A unit test pins that every role the consumer reads is accepted. + +`PropertyValidatorHandler::CLOSED_MODIFIER_VALUES` is the one list: the save +path's `validateSearchProfile()` iterates it, and `PropertyVocabulary::modifiers()` +copies it onto the matching rows as `allowedValues`. A unit test asks the +validator that every published value saves and that `textarea` is refused. diff --git a/openspec/changes/extends-form-form-roles/proposal.md b/openspec/changes/extends-form-form-roles/proposal.md new file mode 100644 index 0000000000..700190beda --- /dev/null +++ b/openspec/changes/extends-form-form-roles/proposal.md @@ -0,0 +1,46 @@ +--- +kind: code +depends_on: [property-vocabulary-published] +--- + +# Proposal: extends-form-form-roles + +## Summary + +`x-openregister-extends-form` refuses every map role that is not a property +vocabulary key. `@conduction/nextcloud-vue` 2.79.0 added a role to its +`DEFAULT_MAP` that is not a property key: `hideOnCreate`, which tells the +create form to leave a field out because the app fills it in itself. dossiq +declared it (dossiq #3716), and every register import since refuses the +whole `case` schema with `Invalid x-openregister-extends-form declaration: +'hideOnCreate'`. An upgraded instance keeps its old case schema and logs +`PARTIAL IMPORT`. + +## Why + +The validator exists so a form cannot claim to forward a key nobody defines. +`hideOnCreate` forwards nothing: it steers the form. Refusing it by name is +the same breakage the `definition` alias was added to prevent, one release +later. + +## What changes + +- A second list beside `SOURCE_ALIASES`: form roles, the roles the shipped + consumer reads to steer the form rather than to fill a property key. Today + that is `hideOnCreate`. +- A declaration mapping a form role is accepted. A form role forwards no key, + is not counted as forwarded and never lands in a property built from a form + submission. +- The extending-forms read publishes the form roles and the source aliases, so + an app can check its declaration against what this layer accepts. +- The property vocabulary publishes `allowedValues` for the modifiers whose + value the save path checks against a closed list (`inputControl`, + `matchType`). The same import refused dossiq's `aanvullingsverzoek` for + `inputControl: textarea`; `textarea` is a form widget, not a list filter + control, so dossiq drops it. Publishing the list lets dossiq pin its shipped + schemas against it at build time. + +## Out of scope + +Any other role. A role is added here when the shipped consumer reads it, not +before. diff --git a/openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md b/openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md new file mode 100644 index 0000000000..875ea5db30 --- /dev/null +++ b/openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: A form role the consumer reads is accepted and forwards nothing (REQ-PVP-004) + +An `x-openregister-extends-form` declaration SHALL accept a map role that the +shipped consumer reads to steer the form rather than to fill a property key. +Such a form role SHALL NOT count as a forwarded vocabulary key and SHALL NOT +become a key of a property built from a form submission. The accepted form +roles SHALL be published on the extending-forms read. Today the one form role +is `hideOnCreate`. + +#### Scenario: a declaration that maps hideOnCreate is accepted + +- **GIVEN** an app whose form declaration maps `hideOnCreate` to its own field +- **WHEN** a schema carrying that declaration is imported +- **THEN** the schema is saved and the declaration reports no errors +- @e2e exclude {validator, covered by unit tests and the dossiq register import} + +#### Scenario: a form role forwards no key + +- **GIVEN** a declaration mapping `hideOnCreate` +- **WHEN** its forwarded keys are read and a form submission is turned into a property +- **THEN** `hideOnCreate` is not among the forwarded keys and not on the property +- @e2e exclude {pure function, covered by unit tests} + +#### Scenario: a role nobody defines is still refused + +- **GIVEN** a declaration mapping a role that is neither a vocabulary key, a source alias nor a form role +- **WHEN** the declaration is validated +- **THEN** it is refused naming the role +- @e2e exclude {validator, covered by unit tests} + +### Requirement: A closed-list modifier publishes its accepted values (REQ-PVP-005) + +A modifier whose value the save path checks against a closed list SHALL +publish that list as `allowedValues` on its row of the property vocabulary. +The published list SHALL be the list the save path enforces, so an app can +check its shipped schemas before an import refuses them. Today that is +`inputControl` and `matchType`. + +#### Scenario: inputControl publishes the controls the save path accepts + +- **GIVEN** the published property vocabulary +- **WHEN** the `inputControl` modifier row is read +- **THEN** it carries `allowedValues`, every listed value saves, and `textarea` is not listed and is refused +- @e2e exclude {published read, covered by unit tests and the dossiq vocabulary guard} diff --git a/openspec/changes/extends-form-form-roles/tasks.md b/openspec/changes/extends-form-form-roles/tasks.md new file mode 100644 index 0000000000..b1901f2e38 --- /dev/null +++ b/openspec/changes/extends-form-form-roles/tasks.md @@ -0,0 +1,7 @@ +# Tasks: extends-form-form-roles + +- [x] 1. Add `FORM_ROLES` with `hideOnCreate` to `ExtendingFormDeclaration` and accept it in `validate()`. +- [x] 2. Keep form roles out of `forwards()` and `toProperty()`. +- [x] 3. Publish `formRoles` and `sourceAliases` on the extending-forms read. +- [x] 4. Unit tests: the role is accepted, forwards nothing, never reaches a property, and every role the 2.79.0 consumer reads is accepted. +- [x] 5. Publish `allowedValues` for `inputControl` and `matchType` from the list the save path enforces, with a test that asks the validator. diff --git a/tests/Unit/Controller/PropertyVocabularyControllerTest.php b/tests/Unit/Controller/PropertyVocabularyControllerTest.php index fdd12e3c9e..111a12b869 100644 --- a/tests/Unit/Controller/PropertyVocabularyControllerTest.php +++ b/tests/Unit/Controller/PropertyVocabularyControllerTest.php @@ -166,5 +166,8 @@ public function testAnInstanceWithNoDeclarationsAnswersAnEmptyList(): void { $this->assertSame([], $body['declarations']); $this->assertSame(0, $body['counts']['declarations']); $this->assertSame($this->vocabulary->keys(), $body['vocabularyKeys']); + $this->assertSame(ExtendingFormDeclaration::FORM_ROLES, $body['formRoles']); + $this->assertArrayHasKey('hideOnCreate', $body['formRoles']); + $this->assertSame(ExtendingFormDeclaration::SOURCE_ALIASES, $body['sourceAliases']); } }//end class diff --git a/tests/Unit/Service/Schemas/ExtendingFormDeclarationTest.php b/tests/Unit/Service/Schemas/ExtendingFormDeclarationTest.php index dfd2918c73..6516a5eaea 100644 --- a/tests/Unit/Service/Schemas/ExtendingFormDeclarationTest.php +++ b/tests/Unit/Service/Schemas/ExtendingFormDeclarationTest.php @@ -256,6 +256,89 @@ public function testTheDefinitionAliasSuppliesADescription(): void { ); } + /** + * `hideOnCreate` is accepted, forwards no key and never reaches a property. + * + * `@conduction/nextcloud-vue` 2.79.0 reads it to leave a field off the + * create form. Refusing it by name refused dossiq's whole case schema at + * register import, so an upgraded instance kept its old case schema. + * + * @spec openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md + * + * @return void + */ + public function testTheHideOnCreateFormRoleIsAcceptedAndForwardsNothing(): void { + $declaration = $this->shippedForm(); + $declaration['map']['hideOnCreate'] = 'hideOnCreate'; + + $this->assertSame(expected: [], actual: $this->declarations->validate(annotation: $declaration)); + $this->assertNotContains(needle: 'hideOnCreate', haystack: $this->declarations->forwards(annotation: $declaration)); + $this->assertSame(expected: 6, actual: count($this->declarations->forwards(annotation: $declaration))); + + $property = $this->declarations->toProperty( + annotation: $declaration, + formValues: ['propertyType' => 'string', 'hideOnCreate' => true] + ); + $this->assertSame(expected: ['type' => 'string'], actual: $property); + $this->assertTrue(condition: (new PropertyValidatorHandler())->validateProperty(property: $property, path: '/x')); + } + + /** + * Every role the shipped consumer reads is accepted. + * + * Taken from `@conduction/nextcloud-vue` 2.79.0 + * `src/utils/dynamicProperties.js`: the `DEFAULT_MAP` keys plus every role + * `mapped()` is called with. A role the consumer reads that this layer + * refuses breaks a working form at import, which is what this pins. + * + * @spec openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md + * + * @return void + */ + public function testEveryRoleTheShippedConsumerReadsIsAccepted(): void { + $roles = [ + 'title', + 'description', + 'definition', + 'type', + 'format', + 'maxLength', + 'enum', + 'required', + 'default', + 'hideOnCreate', + ]; + $map = []; + foreach ($roles as $role) { + $map[$role] = $role . 'Field'; + } + + $errors = $this->declarations->validate( + annotation: ['definitions' => 'caseTypeFieldDefinition', 'map' => $map] + ); + + $this->assertSame(expected: [], actual: $errors); + } + + /** + * A role that is neither a key, an alias nor a form role is still refused. + * + * @spec openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md + * + * @return void + */ + public function testARoleNobodyDefinesIsStillRefusedBesideAFormRole(): void { + $errors = $this->declarations->validate( + annotation: [ + 'definitions' => 'caseTypeFieldDefinition', + 'map' => ['hideOnCreate' => 'hideOnCreate', 'hideOnEdit' => 'hideOnEdit'], + ] + ); + + $this->assertCount(expectedCount: 1, haystack: $errors); + $this->assertSame(expected: 'hideOnEdit', actual: $errors[0]['key']); + } + /** * Both places the annotation may sit are found. * diff --git a/tests/Unit/Service/Schemas/PropertyVocabularyTest.php b/tests/Unit/Service/Schemas/PropertyVocabularyTest.php index 77ee7fcb37..c373f34697 100644 --- a/tests/Unit/Service/Schemas/PropertyVocabularyTest.php +++ b/tests/Unit/Service/Schemas/PropertyVocabularyTest.php @@ -538,4 +538,36 @@ public function testPassthroughKeysSayTheyAreNotEnforced(): void { $this->assertNotSame(expected: '', actual: $row['description']); } } + + /** + * A closed-list modifier publishes the list the save path enforces. + * + * Asked of the validator, not read off the table: every published value + * saves, and a value outside the list is refused. `textarea` is the value + * dossiq shipped that refused its whole aanvullingsverzoek schema. + * + * @spec openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md + * + * @return void + */ + public function testAClosedListModifierPublishesTheListTheSavePathEnforces(): void { + $validator = new PropertyValidatorHandler(); + $modifiers = array_column($this->vocabulary->modifiers(), null, 'key'); + + foreach (['inputControl', 'matchType'] as $key) { + $this->assertArrayHasKey(key: 'allowedValues', array: $modifiers[$key], message: "'{$key}' does not publish its list"); + $this->assertNotSame(expected: [], actual: $modifiers[$key]['allowedValues']); + foreach ($modifiers[$key]['allowedValues'] as $value) { + $this->assertTrue( + condition: $validator->validateProperty(property: ['type' => 'string', $key => $value], path: '/x'), + message: "published {$key} '{$value}' is refused by the save path" + ); + } + } + + $this->assertNotContains(needle: 'textarea', haystack: $modifiers['inputControl']['allowedValues']); + $this->assertArrayNotHasKey(key: 'allowedValues', array: $modifiers['conceptScheme']); + $this->expectExceptionMessage(message: 'textarea'); + $validator->validateProperty(property: ['type' => 'string', 'inputControl' => 'textarea'], path: '/x'); + } }//end class From 575105fb1af44a11ff967783d103f45989b4640c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 20:32:36 +0200 Subject: [PATCH 081/183] fix(rbac): a roles block that resolves to nothing grants nothing (#4589) * fix(rbac): a roles block that resolves to nothing grants nothing expandRoles() returned an empty block when the register declares no role definitions or every role name is unknown, and an empty block reads as no authorization, which is open to every signed in account. Found live on 2026-10-11: dossiq's case schema, roles only, was readable by a user in no group. The block now comes back with the four canonical verbs granted to nobody, the same fail-safe reading stripMcpScope() already applies. * test(rbac): an unknown-roles-only block resolves closed --- lib/Service/Object/PermissionHandler.php | 42 +- .../design.md | 22 ++ .../proposal.md | 39 ++ .../specs/rbac-scopes/spec.md | 26 ++ .../tasks.md | 9 + .../PermissionHandlerHandlingTeamRuleTest.php | 372 ++++++++++++++++++ .../Object/PermissionHandlerRbacTest.php | 9 +- .../PermissionHandlerRoleHierarchyTest.php | 30 +- 8 files changed, 540 insertions(+), 9 deletions(-) create mode 100644 openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/design.md create mode 100644 openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/proposal.md create mode 100644 openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md create mode 100644 openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/tasks.md create mode 100644 tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php index b4fc3375ce..afff4b12e4 100644 --- a/lib/Service/Object/PermissionHandler.php +++ b/lib/Service/Object/PermissionHandler.php @@ -3540,7 +3540,7 @@ public function expandRoles(array $authorization, Schema $schema): array { 'schemaId' => $schema->getId(), ] ); - return $authorization; + return $this->grantNothingWhenEmpty(authorization: $authorization); } // Build the lookup map with role-hierarchy support. A role @@ -3591,9 +3591,47 @@ public function expandRoles(array $authorization, Schema $schema): array { } }//end foreach - return $authorization; + return $this->grantNothingWhenEmpty(authorization: $authorization); }//end expandRoles() + /** + * A roles block that resolved to nothing grants nothing. + * + * 🔴 THE SAME DIRECTION AS {@see stripMcpScope()}, FOR THE SAME REASON. An + * author who writes `roles` has opted the schema into authorization. When + * none of those roles can be expanded (the register declares no role + * definitions, or every name is unknown) the block used to come back as + * `[]`, and `[]` is "no authorization configured", which is default-OPEN: + * every signed in account could read, change and delete. Found live on + * 2026-10-11, where a case schema declaring `roles` only, written so a + * notification dispatcher could find who holds a role, left every case of + * the register readable by a user in no group at all. + * + * Between a fail-safe and a fail-open reading of an ambiguous block the + * block gets the fail-safe one: the canonical verbs are listed with nobody + * in them. The block is non-empty, so an unlisted verb is refused as well, + * and the administrator and owner bypasses still apply. A denial is loud + * and fixed by declaring the roles; a grant is silent. + * + * @param array $authorization The block after expansion. + * + * @return array The block, or one granting nobody when it is empty. + * + * @spec openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md + */ + private function grantNothingWhenEmpty(array $authorization): array { + if ($authorization !== []) { + return $authorization; + } + + return [ + 'read' => [], + 'create' => [], + 'update' => [], + 'delete' => [], + ]; + }//end grantNothingWhenEmpty() + /** * Flatten a role hierarchy into a `name => actions` map. * diff --git a/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/design.md b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/design.md new file mode 100644 index 0000000000..e3b9d136ba --- /dev/null +++ b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/design.md @@ -0,0 +1,22 @@ +# Design: roles-block-that-resolves-to-nothing-grants-nothing + +## D-1. Closed, not absent + +The expansion's empty result meant two different things: "the author wrote no +rules" and "the author wrote rules nobody can resolve". Only the first is the +default-open case. The second now comes back as the four canonical verbs with +nobody in them, which every evaluator already reads as a refusal: the single +object path (`hasGroupPermission()`, `empty($authorization[$action])`) and the +list path (`MagicRbacHandler::applyRbacFilters()`, the `1 = 0` deny). + +## D-2. Why the four canonical verbs and not a marker key + +A marker key would be a key nobody else knows, and an unknown key is the kind +of thing a later strip or merge drops in silence. Four empty lists are the +grammar's own "grant to nobody", already used by `stripMcpScope()`. + +## D-3. Object level follows + +An object's own `_authorization` holding unresolvable roles also comes back +closed, and replaces the schema's verbs for that object. That narrows rather +than widens, which is the direction an ambiguous rule should fail in. diff --git a/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/proposal.md b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/proposal.md new file mode 100644 index 0000000000..f08c5f2ca6 --- /dev/null +++ b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/proposal.md @@ -0,0 +1,39 @@ +# Proposal: roles-block-that-resolves-to-nothing-grants-nothing + +## Why + +Found live on 2026-10-11 on the dossiq review instance. dossiq's `case` schema +declared an `authorization` block holding `roles` only, written so the +notification dispatcher and the transition guard can find who holds a role. The +dossiq register declares no role definitions, so `PermissionHandler::expandRoles()` +logged "register has no role definitions", dropped the `roles` key and returned +`[]`. `hasGroupPermission()` reads `[]` as "no authorization configured", which is +default-open: a user in no group at all could read, change and delete every case. + +It is the same mistake `stripMcpScope()` already names and fixed for a block +holding only `mcp` entries: an author who writes rules has opted the schema into +authorization, and reading an unresolvable block as absent turns a restriction +into a grant, silently. + +## What changes + +- `expandRoles()` answers a block that grants nobody (`read`, `create`, + `update` and `delete` each `[]`) when the expansion leaves nothing: the + register declares no role definitions, or every role name is unknown. The + administrator and owner bypasses still apply. +- A block that keeps any other key after expansion is unchanged, because it is + already non-empty and so already refuses an unlisted verb. + +## Merge order + +dossiq first (or together): ConductionNL/dossiq `case-access-by-handling-team` +gives the case schema its own read and update rules. Merged alone, this change +closes dossiq's case schema to everybody but administrators and owners. +Other fleet apps whose schemas carry `roles` only are listed in the PR body. + +## Impact + +- `lib/Service/Object/PermissionHandler.php` +- `tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php` (the two + tests that asserted the open reading) +- `tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php` (new) diff --git a/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md new file mode 100644 index 0000000000..77b93a0955 --- /dev/null +++ b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: A roles block that resolves to nothing grants nothing + +When an authorization block declares `roles` and none of them can be expanded, +because the parent register declares no role definitions or every role name is +unknown, the expanded block SHALL grant no verb to anybody: `read`, `create`, +`update` and `delete` SHALL each be an empty list. It SHALL NOT be read as an +absent block, which is open to every signed in account. The administrator and +object owner bypasses SHALL still apply, and a block that keeps any other rule +after expansion SHALL be evaluated as written. + +#### Scenario: A schema with roles only and a register without role definitions +@e2e exclude An engine verdict with no surface of its own; asserted through hasPermission() in tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php. +- **GIVEN** a schema whose authorization is `{"roles": {"behandelaar": ["behandelaars"]}}` +- **AND** a parent register that declares no role definitions +- **WHEN** a signed in user in no group asks to read, update or delete an object of it +- **THEN** each SHALL be refused +- **AND** an administrator SHALL still be allowed + +#### Scenario: A schema whose role names are all unknown +@e2e exclude Same engine verdict; asserted in tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php. +- **GIVEN** a register declaring the role `viewer` +- **AND** a schema whose authorization assigns only the unknown role `ghost` +- **WHEN** the block is expanded +- **THEN** every canonical verb SHALL be an empty list diff --git a/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/tasks.md b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/tasks.md new file mode 100644 index 0000000000..70f0beba25 --- /dev/null +++ b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/tasks.md @@ -0,0 +1,9 @@ +# Tasks: roles-block-that-resolves-to-nothing-grants-nothing + +- [x] 1.1 `PermissionHandler::expandRoles()` grants nothing when the expansion is empty. + - `tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php` +- [x] 1.2 Assert through `hasPermission()` and `ObjectFileAccess::mayUpdate()` that a + roles-only block closes the schema, and that a handling team rule keyed on a + field list and an assignee rule admit exactly their members. + - `tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php` +- [x] 1.3 `openspec validate roles-block-that-resolves-to-nothing-grants-nothing --strict`. diff --git a/tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php b/tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php new file mode 100644 index 0000000000..17f5dda8f8 --- /dev/null +++ b/tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php @@ -0,0 +1,372 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://www.OpenRegister.app + * + * @spec openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md + */ + +declare(strict_types=1); + +namespace Unit\Service\Object; + +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\ConditionMatcher; +use OCA\OpenRegister\Service\File\ObjectFileAccess; +use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\OperatorEvaluator; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\NullLogger; + +/** + * @covers \OCA\OpenRegister\Service\Object\PermissionHandler + * @covers \OCA\OpenRegister\Service\File\ObjectFileAccess + * + * @spec openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md + */ +class PermissionHandlerHandlingTeamRuleTest extends TestCase { + + /** + * Group membership per user id. + * + * @var array> + */ + private const GROUPS = [ + 'outsider' => [], + 'bea' => ['vergunningen'], + 'piet' => [], + 'admin' => ['admin'], + ]; + + /** + * The assignee rule. + * + * @var array + */ + private const ASSIGNEE = ['group' => 'authenticated', 'match' => ['assignee' => '$userId']]; + + /** + * The team rule. + * + * @var array + */ + private const TEAM = ['group' => 'vergunningen', 'match' => ['caseType' => ['$in' => ['ct-permit']]]]; + + /** + * Who is signed in. + * + * @var string + */ + private string $current = 'outsider'; + + /** + * The handler under test. + * + * @var PermissionHandler + */ + private PermissionHandler $handler; + + /** + * The schema mapper, answering the schema under test. + * + * @var SchemaMapper + */ + private SchemaMapper $schemaMapper; + + /** + * Build a handler with the real matcher and a register without role definitions. + * + * @return void + */ + protected function setUp(): void { + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturnCallback(fn (): IUser => $this->user(uid: $this->current)); + + $userManager = $this->createMock(IUserManager::class); + $userManager->method('get')->willReturnCallback(fn (string $uid): IUser => $this->user(uid: $uid)); + + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('getUserGroupIds')->willReturnCallback( + static fn (IUser $user): array => self::GROUPS[$user->getUID()] ?? [] + ); + + $register = new Register(); + $register->setId(20); + $register->setAuthorization(null); + $register->setConfiguration([]); + + $registerMapper = $this->createMock(RegisterMapper::class); + $registerMapper->method('getFirstRegisterWithSchema')->willReturn(20); + $registerMapper->method('find')->willReturn($register); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + static function (string $class) use ($registerMapper) { + if ($class === RegisterMapper::class) { + return $registerMapper; + } + + throw new \RuntimeException('Not available: ' . $class); + } + ); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool')->willReturnCallback( + static fn (string $app, string $key, bool $default = false): bool => $default + ); + + $matcher = new ConditionMatcher( + $userSession, + $this->createMock(ContainerInterface::class), + new OperatorEvaluator(new NullLogger()), + new NullLogger(), + $groupManager + ); + + $this->schemaMapper = $this->createMock(SchemaMapper::class); + + $this->handler = new PermissionHandler( + $userSession, + $userManager, + $groupManager, + $this->schemaMapper, + $this->createMock(MagicMapper::class), + $matcher, + $appConfig, + new NullLogger(), + $container + ); + }//end setUp() + + /** + * An account. + * + * @param string $uid The user id. + * + * @return IUser The account. + */ + private function user(string $uid): IUser { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + + return $user; + }//end user() + + /** + * A schema with the given block. + * + * @param array $authorization The block. + * + * @return Schema The schema. + */ + private function schema(array $authorization): Schema { + $schema = new Schema(); + $schema->setId(35); + $schema->setTitle('Case'); + $schema->setAuthorization($authorization); + + return $schema; + }//end schema() + + /** + * The block a leaf app ships: roles for routing, plus the team and assignee rules. + * + * @return Schema The schema. + */ + private function caseSchema(): Schema { + return $this->schema( + [ + 'roles' => ['behandelaar' => ['behandelaars']], + 'read' => [self::ASSIGNEE, 'dossiq-background-service', self::TEAM], + 'create' => ['dossiq-medewerkers', 'dossiq-background-service'], + 'update' => [self::ASSIGNEE, 'dossiq-background-service', self::TEAM], + 'delete' => ['admin'], + ] + ); + }//end caseSchema() + + /** + * An object owned by admin. + * + * @param string $caseType The type field. + * @param string $assignee The assignee field. + * + * @return ObjectEntity The object. + */ + private function object(string $caseType, string $assignee): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid('obj-' . $caseType . '-' . $assignee); + $object->setSchema('35'); + $object->setOwner('admin'); + $object->setObject(['caseType' => $caseType, 'assignee' => $assignee]); + + return $object; + }//end object() + + /** + * Ask as one user. + * + * @param string $uid The user. + * @param Schema $schema The schema. + * @param string $action The verb. + * @param ObjectEntity $object The object. + * + * @return bool The verdict. + */ + private function may(string $uid, Schema $schema, string $action, ObjectEntity $object): bool { + $this->current = $uid; + $this->handler->clearPermissionCache(); + + return $this->handler->hasPermission( + schema: $schema, + action: $action, + objectOwner: $object->getOwner(), + object: $object + ); + }//end may() + + /** + * A user in no group and not assigned is refused every verb. + * + * @return void + */ + public function testAUserInNoGroupIsRefused(): void { + $case = $this->object(caseType: 'ct-permit', assignee: 'piet'); + + foreach (['read', 'update', 'delete'] as $verb) { + $this->assertFalse($this->may(uid: 'outsider', schema: $this->caseSchema(), action: $verb, object: $case), $verb); + } + }//end testAUserInNoGroupIsRefused() + + /** + * A team member reads and updates the records of the types the team handles, and deletes none. + * + * @return void + */ + public function testATeamMemberReadsAndUpdatesItsTypesOnly(): void { + $permit = $this->object(caseType: 'ct-permit', assignee: 'piet'); + $other = $this->object(caseType: 'ct-other', assignee: 'piet'); + $schema = $this->caseSchema(); + + $this->assertTrue($this->may(uid: 'bea', schema: $schema, action: 'read', object: $permit)); + $this->assertTrue($this->may(uid: 'bea', schema: $schema, action: 'update', object: $permit)); + $this->assertFalse($this->may(uid: 'bea', schema: $schema, action: 'delete', object: $permit)); + $this->assertFalse($this->may(uid: 'bea', schema: $schema, action: 'read', object: $other)); + }//end testATeamMemberReadsAndUpdatesItsTypesOnly() + + /** + * The assignee reads their record outside every team, and no other. + * + * @return void + */ + public function testTheAssigneeReadsTheirOwnRecord(): void { + $schema = $this->caseSchema(); + + $this->assertTrue($this->may(uid: 'piet', schema: $schema, action: 'read', object: $this->object(caseType: 'ct-other', assignee: 'piet'))); + $this->assertFalse($this->may(uid: 'piet', schema: $schema, action: 'read', object: $this->object(caseType: 'ct-other', assignee: 'bea'))); + }//end testTheAssigneeReadsTheirOwnRecord() + + /** + * An administrator keeps every verb. + * + * @return void + */ + public function testAnAdministratorKeepsEverything(): void { + $case = $this->object(caseType: 'ct-other', assignee: 'piet'); + + foreach (['read', 'update', 'delete'] as $verb) { + $this->assertTrue($this->may(uid: 'admin', schema: $this->caseSchema(), action: $verb, object: $case), $verb); + } + }//end testAnAdministratorKeepsEverything() + + /** + * The folder answers `canChange` from the same rule. + * + * @return void + */ + public function testTheFolderAnswersCanChangeFromTheSameRule(): void { + $schema = $this->caseSchema(); + $this->schemaMapper->method('find')->willReturn($schema); + $access = new ObjectFileAccess( + $this->createMock(ObjectService::class), + $this->handler, + $this->schemaMapper + ); + $case = $this->object(caseType: 'ct-permit', assignee: 'piet'); + + $this->current = 'outsider'; + $this->handler->clearPermissionCache(); + $this->assertFalse($access->mayUpdate(object: $case)); + + $this->current = 'bea'; + $this->handler->clearPermissionCache(); + $this->assertTrue($access->mayUpdate(object: $case)); + }//end testTheFolderAnswersCanChangeFromTheSameRule() + + /** + * A roles-only block on a register without role definitions closes the schema. + * + * The live defect: this block used to expand to nothing and read as open. + * + * @return void + */ + public function testARolesOnlyBlockThatResolvesToNothingIsClosed(): void { + $schema = $this->schema(['roles' => ['behandelaar' => ['behandelaars']]]); + $case = $this->object(caseType: 'ct-permit', assignee: 'piet'); + + foreach (['read', 'create', 'update', 'delete'] as $verb) { + $this->assertFalse($this->may(uid: 'outsider', schema: $schema, action: $verb, object: $case), $verb); + } + + $this->assertTrue($this->may(uid: 'admin', schema: $schema, action: 'read', object: $case)); + }//end testARolesOnlyBlockThatResolvesToNothingIsClosed() + + /** + * The folder of such a schema does not offer a change. + * + * @return void + */ + public function testARolesOnlyBlockGivesNoCanChange(): void { + $schema = $this->schema(['roles' => ['behandelaar' => ['behandelaars']]]); + $this->schemaMapper->method('find')->willReturn($schema); + $access = new ObjectFileAccess($this->createMock(ObjectService::class), $this->handler, $this->schemaMapper); + + $this->current = 'outsider'; + $this->handler->clearPermissionCache(); + $this->assertFalse($access->mayUpdate(object: $this->object(caseType: 'ct-permit', assignee: 'piet'))); + }//end testARolesOnlyBlockGivesNoCanChange() +}//end class diff --git a/tests/Unit/Service/Object/PermissionHandlerRbacTest.php b/tests/Unit/Service/Object/PermissionHandlerRbacTest.php index 56e10d041f..2ef5981f1b 100644 --- a/tests/Unit/Service/Object/PermissionHandlerRbacTest.php +++ b/tests/Unit/Service/Object/PermissionHandlerRbacTest.php @@ -303,10 +303,13 @@ public function testUnknownRoleNameIsIgnored(): void { $this->logger->expects($this->atLeastOnce()) ->method('warning'); - // With empty authorization (only unknown roles), all actions should be permitted - // because the effective authorization ends up empty after role expansion. + // Only unknown roles: the block grants nobody anything. It used to come + // back empty, and an empty block is "no authorization", which permitted + // every action to every signed in account (roles-block-that-resolves-to- + // nothing-grants-nothing). It now names the canonical verbs with nobody. $result = $this->handler->resolveAuthorization($schema); - $this->assertEmpty($result); + $this->assertSame(['read' => [], 'create' => [], 'update' => [], 'delete' => []], $result); + $this->assertFalse($this->handler->hasPermission($schema, 'read')); } // === Manage Action Tests === diff --git a/tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php b/tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php index a5b518201e..2f439caf90 100644 --- a/tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php +++ b/tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php @@ -347,15 +347,17 @@ public function testUnknownRoleAssignmentLoggedAndSkipped(): void { schema: $schema ); - // viewer wasn't assigned and ghost is unknown; nothing got - // added to the authorization map. - $this->assertArrayNotHasKey('read', $expanded); + // viewer wasn't assigned and ghost is unknown, so the block granted + // nobody anything. It comes back closed rather than empty: an empty + // block is "no authorization", which is open to every signed in account. + $this->assertSame(['read' => [], 'create' => [], 'update' => [], 'delete' => []], $expanded); $this->assertArrayNotHasKey('roles', $expanded); }//end testUnknownRoleAssignmentLoggedAndSkipped() /** * Register without role definitions but a schema that references - * roles logs a warning and returns the (rolesless) authorization. + * roles logs a warning and returns a block that grants nobody, never the + * empty block that reads as default-open. * * @return void */ @@ -375,5 +377,25 @@ public function testRegisterWithoutRoleDefinitionsLoggedAndSkipped(): void { ); $this->assertArrayNotHasKey('roles', $expanded); + $this->assertSame(['read' => [], 'create' => [], 'update' => [], 'delete' => []], $expanded); }//end testRegisterWithoutRoleDefinitionsLoggedAndSkipped() + + /** + * Direct rules beside an unresolvable roles block are kept as written. + * + * The block is already non-empty, so it already refuses an unlisted verb; + * nothing is added to it. + * + * @return void + */ + public function testDirectRulesBesideUnresolvableRolesAreKept(): void { + $schema = $this->makeSchemaWithRegisterRoles([]); + + $expanded = $this->handler->expandRoles( + authorization: ['roles' => ['viewer' => ['public']], 'read' => ['behandelaars']], + schema: $schema + ); + + $this->assertSame(['read' => ['behandelaars']], $expanded); + }//end testDirectRulesBesideUnresolvableRolesAreKept() }//end class From f05db036e3f100af3c5fccfe7c49a03afbb994a1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 20:34:04 +0200 Subject: [PATCH 082/183] fix(settings): personal settings move into the user settings dialog (#4587) * fix(settings): personal settings move into the user settings dialog Ruben, 11 Oct 2026: no user configuration pages. OpenRegister no longer adds a form to Nextcloud's personal settings: leaves info.xml, with OpenRegisterPersonal, templates/settings/personal.php, src/personalSettings.js, PersonalRoot.vue and the webpack entry. Its three sections (browser notifications, the credential wallet, connected accounts) are sections of the in-app user settings dialog (src/views/settings/UserSettings.vue in CnAppRoot's #user-settings slot). Filling the slot drops the library's fallback, so its notifications pane and walkthrough replay are drawn again from the app. The OAuth2 connect flow returned to /settings/user/additional when no return URL was given, and never reopened anything. The fallback is now the app page, and the callback appends #user-settings, which App.vue answers by opening the dialog on Connected accounts. * fix(settings): the user settings dialog follows board OrPersoonlijkeInstellingen Notices first (the library pane, then browser notifications), then connected accounts, then credentials. --- appinfo/info.xml | 1 - lib/Controller/CredentialOauth2Controller.php | 14 +- lib/Service/Credential/OAuth2Endpoints.php | 6 +- lib/Settings/OpenRegisterPersonal.php | 86 ------------ src/App.vue | 77 +++++++++++ .../BrowserNotificationsSection.vue | 28 ++-- .../OAuth2ConnectionsSection.spec.js | 2 +- .../userSettings/OAuth2ConnectionsSection.vue | 28 ++-- src/components/userSettings/PersonalRoot.vue | 55 -------- src/personalSettings.js | 33 ----- src/views/settings/UserSettings.vue | 122 ++++++++++++++++++ templates/settings/personal.php | 11 -- .../CredentialOauth2ControllerTest.php | 10 +- tests/e2e/credential-oauth2-connect.spec.ts | 4 +- webpack.config.js | 4 - 15 files changed, 258 insertions(+), 223 deletions(-) delete mode 100644 lib/Settings/OpenRegisterPersonal.php delete mode 100644 src/components/userSettings/PersonalRoot.vue delete mode 100644 src/personalSettings.js create mode 100644 src/views/settings/UserSettings.vue delete mode 100644 templates/settings/personal.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 40892ca9fc..434ab18243 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -522,7 +522,6 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Settings\OpenRegisterAdmin OCA\OpenRegister\Settings\IntegrationsAdminSettings OCA\OpenRegister\Sections\OpenRegisterAdmin - OCA\OpenRegister\Settings\OpenRegisterPersonal + + diff --git a/src/personalSettings.js b/src/personalSettings.js deleted file mode 100644 index 87522b4503..0000000000 --- a/src/personalSettings.js +++ /dev/null @@ -1,33 +0,0 @@ -import { translate, translatePlural } from '@nextcloud/l10n' -import { createApp } from 'vue' -import PersonalRoot from './components/userSettings/PersonalRoot.vue' -import { registerLibraryTranslations } from './services/libraryTranslations.js' - -// Register nextcloud-vue's own catalogue before anything renders, so the -// library's labels follow the reader's language on this page too. -registerLibraryTranslations() - -// Personal-settings bundle. Mounts the per-user browser Web Push opt-in toggle -// (openregister-web-push-engine; it drives window.OCA.OpenRegister.WebPush, which -// the always-loaded push client installs on every page) AND the credential-broker -// wallet. -// -// The broker had shipped with no UI at all: CnCredentials existed in the library -// and the REST surface existed on the server, but nothing mounted the component, -// so the only way to create a credential was to hand-craft a POST. That is a large -// part of why every app in the fleet kept custody of its own secrets — the -// sanctioned path was not reachable from a browser. -// -// CnCredentials talks straight to /apps/openregister/api/credentials over axios and -// holds no store, so this entry needs no Pinia. - -const app = createApp(PersonalRoot) - -app.mixin({ - methods: { - t: translate, - n: translatePlural, - }, -}) - -app.mount('#openregister-personal-settings') diff --git a/src/views/settings/UserSettings.vue b/src/views/settings/UserSettings.vue new file mode 100644 index 0000000000..0b447da879 --- /dev/null +++ b/src/views/settings/UserSettings.vue @@ -0,0 +1,122 @@ + + + + + + diff --git a/templates/settings/personal.php b/templates/settings/personal.php deleted file mode 100644 index ecbf42a5d9..0000000000 --- a/templates/settings/personal.php +++ /dev/null @@ -1,11 +0,0 @@ - - -
diff --git a/tests/Unit/Controller/CredentialOauth2ControllerTest.php b/tests/Unit/Controller/CredentialOauth2ControllerTest.php index 47ceacfcd9..1141afbcb2 100644 --- a/tests/Unit/Controller/CredentialOauth2ControllerTest.php +++ b/tests/Unit/Controller/CredentialOauth2ControllerTest.php @@ -172,7 +172,7 @@ public function testASuccessfulCallbackRedirectsToTheReturnUrlDeclaredAtStart(): $response = $controller->callback(); $this->assertInstanceOf(RedirectResponse::class, $response); - $this->assertStringEndsWith('?connected=ok', $response->getRedirectURL()); + $this->assertStringEndsWith('?connected=ok#user-settings', $response->getRedirectURL()); $this->assertSame(1, $this->completions); } @@ -187,12 +187,12 @@ public function testAFailedExchangeRedirectsWithAFailureMarkerAndQuotesNoProvide $response = $controller->callback(); $this->assertInstanceOf(RedirectResponse::class, $response); - $this->assertStringEndsWith('?connected=failed', $response->getRedirectURL()); + $this->assertStringEndsWith('?connected=failed#user-settings', $response->getRedirectURL()); $this->assertStringNotContainsString('invalid_client', $response->getRedirectURL()); $this->assertStringNotContainsString('CLIENT_ID_HERE', $response->getRedirectURL()); } - public function testAnOffInstanceReturnUrlFallsBackToPersonalSettings(): void { + public function testAnOffInstanceReturnUrlFallsBackToTheAppPage(): void { $controller = $this->makeController( params: ['state' => 'STATE_VALUE_HERE', 'code' => 'AUTH_CODE_HERE'], unverifiedClaims: ['cb' => self::OWN_CALLBACK], @@ -202,7 +202,7 @@ public function testAnOffInstanceReturnUrlFallsBackToPersonalSettings(): void { $response = $controller->callback(); $this->assertInstanceOf(RedirectResponse::class, $response); - $this->assertStringStartsWith('https://home.example/settings/user/additional', $response->getRedirectURL()); + $this->assertStringStartsWith('https://home.example/apps/openregister/', $response->getRedirectURL()); } public function testTheClientMetadataIdentifiesItselfByItsOwnUrl(): void { @@ -554,7 +554,7 @@ static function (string $route): string { return 'https://home.example/apps/openregister/oauth2/client-metadata.json'; } - return 'https://home.example/settings/user/additional'; + return 'https://home.example/apps/openregister/'; } ); $urlGenerator->method('getAbsoluteURL')->willReturnCallback( diff --git a/tests/e2e/credential-oauth2-connect.spec.ts b/tests/e2e/credential-oauth2-connect.spec.ts index 11c8def624..84baa627d5 100644 --- a/tests/e2e/credential-oauth2-connect.spec.ts +++ b/tests/e2e/credential-oauth2-connect.spec.ts @@ -41,7 +41,9 @@ const AUTH = { username: ADMIN, password: PASSWORD } const METADATA_PATH = '/apps/openregister/oauth2/client-metadata.json' const CALLBACK_PATH = '/apps/openregister/oauth2/callback' const PROVIDERS_PATH = '/apps/openregister/api/credentials/providers' -const SETTINGS_PATH = '/settings/user/additional' +// The user settings dialog; App.vue opens it on this hash (no form in +// Nextcloud's personal settings since 11 Oct 2026). +const SETTINGS_PATH = '/apps/openregister/#user-settings' test.describe('OAuth2 client metadata', () => { test('is public and identifies itself by its own URL', async ({ diff --git a/webpack.config.js b/webpack.config.js index 375b46bebf..9080ebbc14 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -278,10 +278,6 @@ webpackConfig.entry = { import: path.join(__dirname, 'src', 'settings.js'), filename: appId + '-settings.js', }, - personalSettings: { - import: path.join(__dirname, 'src', 'personalSettings.js'), - filename: appId + '-personalSettings.js', - }, filesSidebar: { import: path.join(__dirname, 'src', 'files-sidebar.js'), filename: appId + '-filesSidebar.js', From 90a3c0091c4d521e44d1860639eef79af1d19e5d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 23:04:49 +0200 Subject: [PATCH 083/183] feat(objects): the owner of a draft can discard it (decision 195) DraftDiscard removes only the asserted owner's object in status draft, permanently; a sent object, someone else's draft, an unknown id or an unknown destination answer not found and remove nothing. portaliq's DELETE /portal/api/drafts calls it. Task 2b.3 records decision 201. --- lib/Service/Object/DraftDiscard.php | 135 +++++++++++++ .../specs/form-destination/spec.md | 14 ++ .../form-destination-validator/tasks.md | 5 +- .../Unit/Service/Object/DraftDiscardTest.php | 177 ++++++++++++++++++ 4 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 lib/Service/Object/DraftDiscard.php create mode 100644 tests/Unit/Service/Object/DraftDiscardTest.php diff --git a/lib/Service/Object/DraftDiscard.php b/lib/Service/Object/DraftDiscard.php new file mode 100644 index 0000000000..f8035a1cd6 --- /dev/null +++ b/lib/Service/Object/DraftDiscard.php @@ -0,0 +1,135 @@ + + * 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/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-discarded-by-its-owner + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Object; + +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\ObjectService; +use Throwable; + +/** + * Removes one owner's draft from a destination. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-discarded-by-its-owner + */ +class DraftDiscard { + + /** + * Constructor. + * + * @param ObjectService $objects Reads and deletes objects. + * @param RegisterMapper $registers Resolves the destination register. + * @param SchemaMapper $schemas Resolves the destination schema. + */ + public function __construct( + private readonly ObjectService $objects, + private readonly RegisterMapper $registers, + private readonly SchemaMapper $schemas, + ) { + + }//end __construct() + + /** + * Discard the owner's draft with this id. + * + * @param array{register?: mixed, schema?: mixed} $destination The destination pair (id, uuid, slug or entity). + * @param string $draftId The draft's uuid. + * @param DraftOwner $owner The asserted owner. + * + * @return bool True when the draft was removed; false when it is unknown, not a draft, + * someone else's, its destination does not exist, or the delete failed. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-discarded-by-its-owner + */ + public function discard(array $destination, string $draftId, DraftOwner $owner): bool { + if ($draftId === '') { + return false; + } + + try { + $register = $this->register(reference: $destination['register'] ?? null); + $schema = $this->schema(reference: $destination['schema'] ?? null); + } catch (Throwable) { + return false; + } + + $draft = (new DraftFinder(objects: $this->objects))->own(register: $register, schema: $schema, draftId: $draftId, owner: $owner); + if ($draft === null) { + return false; + } + + try { + // RBAC off: a scope-value owner has no Nextcloud user to judge; + // DraftFinder::own() above was the owner check. + return $this->objects->deleteObject( + uuid: (string)$draft->getUuid(), + register: $register, + schema: $schema, + _rbac: false, + _multitenancy: false, + permanent: true + ); + } catch (Throwable) { + return false; + } + }//end discard() + + /** + * A destination's register. + * + * @param mixed $reference Id, uuid, slug or entity. + * + * @return Register The register. + */ + private function register(mixed $reference): Register { + if ($reference instanceof Register) { + return $reference; + } + + return $this->registers->find((string)$reference, _rbac: false, _multitenancy: false); + }//end register() + + /** + * A destination's schema. + * + * @param mixed $reference Id, uuid, slug or entity. + * + * @return Schema The schema. + */ + private function schema(mixed $reference): Schema { + if ($reference instanceof Schema) { + return $reference; + } + + return $this->schemas->find((string)$reference, _multitenancy: false); + }//end schema() +}//end class diff --git a/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md index 33aba07796..647bbaab54 100644 --- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md +++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md @@ -132,6 +132,20 @@ A draft's owner SHALL be either a Nextcloud user or a scope value `::` such as `portal:` (decision 195); only a caller asserting that owner finds, reads, saves or sends it, anyone else gets 404 - Spec ref: specs/form-destination/spec.md, "A draft MUST be able to be owned by a scope value" - Test: DraftOwnerTest, FormSubmitServiceTest (portal owner cases), SaveObjectSystemOwnerTest (explicit owner) -- [ ] 2b.3 Flagged to Ruben, not built here: staff list default for drafts, draft expiry, anonymous drafts (Q-openregister-F2) +- [x] 2b.5 The owner may discard its draft; only its own object in `draft` is removed, permanently, anything else is "not found" + - Spec ref: specs/form-destination/spec.md, "A draft MUST be able to be discarded by its owner" + - Test: DraftDiscardTest +- [ ] 2b.3 Answered by Ruben (decision 201), not built here: a draft expires after the owning app's retention (30 days by default), enforced by that app on read from `updatedAt`; staff work lists hide drafts by default with a filter to show them (today the read RBAC keeps every draft to its owner); a draft gets its case number only when it leaves draft. Anonymous drafts stay open (Q-openregister-F2) ## 3. Uploads diff --git a/tests/Unit/Service/Object/DraftDiscardTest.php b/tests/Unit/Service/Object/DraftDiscardTest.php new file mode 100644 index 0000000000..a68f520e00 --- /dev/null +++ b/tests/Unit/Service/Object/DraftDiscardTest.php @@ -0,0 +1,177 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-discarded-by-its-owner + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Object\DraftDiscard; +use OCA\OpenRegister\Service\Object\DraftOwner; +use OCA\OpenRegister\Service\ObjectService; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * Decision 195: a draft is discarded by its asserted owner only. + * + * @covers \OCA\OpenRegister\Service\Object\DraftDiscard + * @uses \OCA\OpenRegister\Service\Object\DraftFinder + * @uses \OCA\OpenRegister\Service\Object\DraftOwner + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Db\Register + * @uses \OCA\OpenRegister\Db\Schema + */ +class DraftDiscardTest extends TestCase { + + /** + * Stored objects by uuid. + * + * @var array + */ + private array $stored = []; + + /** + * Deletes run: uuid and flags. + * + * @var array> + */ + private array $deletes = []; + + private DraftDiscard $discard; + + private Register $register; + + private Schema $schema; + + protected function setUp(): void { + $this->register = new Register(); + $this->register->setId(10); + $this->schema = new Schema(); + $this->schema->setId(1); + + $objects = $this->createMock(ObjectService::class); + $objects->method('find')->willReturnCallback( + fn (string|int $id, ...$rest): ?ObjectEntity => ($this->stored[(string)$id] ?? null) + ); + $objects->method('deleteObject')->willReturnCallback( + function (string $uuid, $register = null, $schema = null, bool $_rbac = true, bool $_multitenancy = true, bool $_retentionSweep = false, $currentUser = null, bool $permanent = false): bool { + if ($uuid === 'stuck') { + throw new RuntimeException('database gone'); + } + + $this->deletes[] = ['uuid' => $uuid, 'rbac' => $_rbac, 'multitenancy' => $_multitenancy, 'permanent' => $permanent, 'register' => $register, 'schema' => $schema]; + unset($this->stored[$uuid]); + + return true; + } + ); + + $registers = $this->createMock(RegisterMapper::class); + $registers->method('find')->willReturnCallback( + function (string|int $id): Register { + if ((string)$id !== 'portal') { + throw new RuntimeException('no register'); + } + + return $this->register; + } + ); + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturnCallback( + function (string|int $id): Schema { + if ((string)$id !== 'case') { + throw new RuntimeException('no schema'); + } + + return $this->schema; + } + ); + + $this->discard = new DraftDiscard(objects: $objects, registers: $registers, schemas: $schemas); + }//end setUp() + + /** + * Store an object. + */ + private function store(string $uuid, string $owner, ?string $status): void { + $entity = new ObjectEntity(); + $entity->setUuid($uuid); + $entity->setOwner($owner); + $entity->setStatus($status); + $entity->setObject(['title' => 'A']); + $this->stored[$uuid] = $entity; + }//end store() + + /** + * The owner's draft is removed permanently, with RBAC off because the owner check ran first. + */ + public function testDiscardsTheOwnersDraft(): void { + $this->store(uuid: 'd1', owner: 'portal:subject-1', status: 'draft'); + + $discarded = $this->discard->discard( + destination: ['register' => 'portal', 'schema' => 'case'], + draftId: 'd1', + owner: DraftOwner::portalSubject(subjectRef: 'subject-1') + ); + + $this->assertTrue($discarded); + $this->assertCount(1, $this->deletes); + $this->assertSame('d1', $this->deletes[0]['uuid']); + $this->assertFalse($this->deletes[0]['rbac']); + $this->assertFalse($this->deletes[0]['multitenancy']); + $this->assertTrue($this->deletes[0]['permanent']); + $this->assertSame($this->register, $this->deletes[0]['register']); + $this->assertSame($this->schema, $this->deletes[0]['schema']); + }//end testDiscardsTheOwnersDraft() + + /** + * Entities passed as the destination are used as they are. + */ + public function testAcceptsEntitiesAsTheDestination(): void { + $this->store(uuid: 'd1', owner: 'portal:subject-1', status: 'draft'); + + $this->assertTrue( + $this->discard->discard( + destination: ['register' => $this->register, 'schema' => $this->schema], + draftId: 'd1', + owner: DraftOwner::portalSubject(subjectRef: 'subject-1') + ) + ); + }//end testAcceptsEntitiesAsTheDestination() + + /** + * Someone else's draft, a sent object, an unknown id, an empty id, an unknown destination + * and a failing delete remove nothing and answer false. + */ + public function testRefusesEverythingThatIsNotTheOwnersDraft(): void { + $this->store(uuid: 'theirs', owner: 'portal:subject-2', status: 'draft'); + $this->store(uuid: 'sent', owner: 'portal:subject-1', status: 'active'); + $this->store(uuid: 'stuck', owner: 'portal:subject-1', status: 'draft'); + $owner = DraftOwner::portalSubject(subjectRef: 'subject-1'); + $destination = ['register' => 'portal', 'schema' => 'case']; + + $this->assertFalse($this->discard->discard(destination: $destination, draftId: 'theirs', owner: $owner)); + $this->assertFalse($this->discard->discard(destination: $destination, draftId: 'sent', owner: $owner)); + $this->assertFalse($this->discard->discard(destination: $destination, draftId: 'nope', owner: $owner)); + $this->assertFalse($this->discard->discard(destination: $destination, draftId: '', owner: $owner)); + $this->assertFalse($this->discard->discard(destination: ['register' => 'gone', 'schema' => 'case'], draftId: 'sent', owner: $owner)); + $this->assertFalse($this->discard->discard(destination: ['register' => 'portal', 'schema' => 'gone'], draftId: 'sent', owner: $owner)); + $this->assertFalse($this->discard->discard(destination: $destination, draftId: 'stuck', owner: $owner)); + $this->assertSame([], $this->deletes); + $this->assertArrayHasKey('theirs', $this->stored); + $this->assertArrayHasKey('sent', $this->stored); + }//end testRefusesEverythingThatIsNotTheOwnersDraft() +}//end class From 23574aee5cdadcc83a4e228f1ad344301b68dfa1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 23:06:07 +0200 Subject: [PATCH 084/183] fix(ci): cover the plan-item timer, node and expiry paths the coverage guard flagged --- .../Service/Case/CasePlanItemTimersTest.php | 4 ++ .../Unit/Service/Case/CasePlanServiceTest.php | 4 ++ .../Service/Case/Nodes/CaseFlowNodesTest.php | 42 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/tests/Unit/Service/Case/CasePlanItemTimersTest.php b/tests/Unit/Service/Case/CasePlanItemTimersTest.php index b4306a3101..8df6af8a82 100644 --- a/tests/Unit/Service/Case/CasePlanItemTimersTest.php +++ b/tests/Unit/Service/Case/CasePlanItemTimersTest.php @@ -215,5 +215,9 @@ public function testOnlyAnEnforcingExpiryOnACaseItemTerminates(): void { $task = clone $timer; $task->setSubjectType('task'); $this->assertNull($this->service()->itemToTerminate(event: $fire($task, FlowTimerFiredEvent::KIND_EXPIRY))); + + $blank = clone $timer; + $blank->setSubjectUuid(' '); + $this->assertNull($this->service()->itemToTerminate(event: $fire($blank, FlowTimerFiredEvent::KIND_EXPIRY)), 'A timer naming no item terminates nothing.'); }//end testOnlyAnEnforcingExpiryOnACaseItemTerminates() }//end class diff --git a/tests/Unit/Service/Case/CasePlanServiceTest.php b/tests/Unit/Service/Case/CasePlanServiceTest.php index 55d6f7bc11..2a7f514fc8 100644 --- a/tests/Unit/Service/Case/CasePlanServiceTest.php +++ b/tests/Unit/Service/Case/CasePlanServiceTest.php @@ -640,5 +640,9 @@ public function testAnExpiredTimerTerminatesItsItem(): void { $this->assertFalse($this->service()->onTimerExpired(itemUuid: 'item-3', timerUuid: 'timer-9'), 'A terminal item is left alone.'); $this->assertSame(CaseItem::STATE_COMPLETED, $this->items->findByUuid(uuid: 'item-3')->getState()); $this->assertFalse($this->service()->onTimerExpired(itemUuid: 'item-404', timerUuid: 'timer-9'), 'A vanished item is not an error.'); + + $this->items->seed([CaseFixtures::row(id: 4, key: 'late', type: CaseItem::TYPE_MILESTONE, state: CaseItem::STATE_ENABLED, parentId: 1)]); + $this->assertFalse($this->service()->onTimerExpired(itemUuid: 'item-4', timerUuid: 'timer-9'), 'A term that cannot end its item is logged.'); + $this->assertSame(CaseItem::STATE_ENABLED, $this->items->findByUuid(uuid: 'item-4')->getState()); }//end testAnExpiredTimerTerminatesItsItem() }//end class diff --git a/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php b/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php index c7abef1b2e..d6ff82f75d 100644 --- a/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php +++ b/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php @@ -279,4 +279,46 @@ public function testAdvanceMovesTheItemAndRefusalsFailTheStep(): void { $this->expectException(UnexpectedValueException::class); $node->validateConfig(config: ['item' => 'besluit-genomen', 'to' => 'done']); }//end testAdvanceMovesTheItemAndRefusalsFailTheStep() + + /** + * An empty batch passes straight through both nodes; an advance with no + * item key is refused at save time. + * + * @return void + */ + public function testEmptyBatchesPassThroughAndAnAdvanceNeedsAnItemKey(): void { + $open = new CaseOpenNode(...$this->collaborators()); + $advance = new CaseAdvanceNode(...$this->collaborators()); + $this->assertSame([], $open->execute(items: [], config: ['definition' => self::definition()], context: [])); + $this->assertSame([], $advance->execute(items: [], config: [], context: [])); + + $this->expectException(UnexpectedValueException::class); + $advance->validateConfig(config: ['item' => ' ', 'to' => CaseItem::STATE_COMPLETED]); + }//end testEmptyBatchesPassThroughAndAnAdvanceNeedsAnItemKey() + + /** + * The object of the case comes from the item, or from a configured + * template; an item with neither fails the step; a malformed payload or + * `@self` is read as empty. + * + * @return void + */ + public function testTheCaseObjectComesFromTheItemOrAConfiguredTemplate(): void { + $collaborators = $this->collaborators(); + $node = new CaseOpenNode(...$collaborators); + $context = [FlowRunService::RUN_AS_CONTEXT_KEY => 'alice']; + + $items = [[FlowItems::JSON => ['ref' => CaseFixtures::OBJECT, '@self' => 'not-an-array']]]; + $out = $node->execute(items: $items, config: ['definition' => self::definition(), 'uuid' => '{{ref}}'], context: $context); + $this->assertSame(['opened' => true, 'reason' => 'opened'], $out[0][FlowItems::JSON]['case']); + $rows = $this->items->findByObject(objectUuid: CaseFixtures::OBJECT); + $this->assertNull($rows[0]->getRegisterId(), 'A malformed @self carries no register.'); + + try { + $node->execute(items: [[FlowItems::JSON => 'not-an-array']], config: ['definition' => self::definition()], context: $context); + $this->fail('an item with no object cannot open a case'); + } catch (RuntimeException $refusal) { + $this->assertStringContainsString('which object', $refusal->getMessage()); + } + }//end testTheCaseObjectComesFromTheItemOrAConfiguredTemplate() }//end class From 857328b505e2b6d3413b2834653b225b80a189bb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 23:06:19 +0200 Subject: [PATCH 085/183] fix(ci): move the app version so the added migration runs on upgrade --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 40892ca9fc..7335af788f 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.1.35-unstable.20261010120000 + 2.1.35-unstable.20261011150000 EUPL-1.2 Conduction OpenRegister From 4ba2c11cba1dc527f067f6252b8bd18d8f73c8e1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 23:12:43 +0200 Subject: [PATCH 086/183] fix(ci): cover the blocker guard, mapper, release listener and export-run list the coverage guard flagged --- tests/Unit/Db/ExportRunMapperTest.php | 62 +++++++++++++++++++ tests/Unit/Db/FluentQueryBuilderTrait.php | 2 +- tests/Unit/Db/TaskBlockedByTest.php | 34 ++++++++++ .../TaskBlockerReleaseListenerTest.php | 15 +++++ .../ScheduledReportServiceProfileRunTest.php | 31 ++++++++++ .../Service/Task/TaskBlockedByCreateTest.php | 2 +- 6 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Db/ExportRunMapperTest.php diff --git a/tests/Unit/Db/ExportRunMapperTest.php b/tests/Unit/Db/ExportRunMapperTest.php new file mode 100644 index 0000000000..59e719fb95 --- /dev/null +++ b/tests/Unit/Db/ExportRunMapperTest.php @@ -0,0 +1,62 @@ + + * @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 + * + * @spec openspec/changes/an-export-is-a-file-with-a-life/specs/data-import-export/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use DateTime; +use OCA\OpenRegister\Db\ExportRunMapper; +use PHPUnit\Framework\TestCase; + +/** + * Walks findForActor() against a recording query builder. + * + * @covers \OCA\OpenRegister\Db\ExportRunMapper + */ +class ExportRunMapperTest extends TestCase { + use FluentQueryBuilderTrait; + + /** + * An actor, a period and a known key each add a predicate; an unknown key is dropped. + * + * @return void + */ + public function testActorPeriodAndKnownKeysNarrowTheListAndUnknownKeysAreDropped(): void { + $mapper = new ExportRunMapper(db: $this->connectionWith()); + + $runs = $mapper->findForActor( + actor: 'anna', + filters: [ + 'from' => new DateTime('2026-01-01'), + 'until' => new DateTime('2026-02-01'), + 'status' => 'ready', + 'bogus' => 'x', + 'source' => '', + ] + ); + + $this->assertSame([], $runs); + $this->assertTrue($this->saw('expr.eq', 'actor')); + $this->assertTrue($this->saw('expr.gte', 'produced_at')); + $this->assertTrue($this->saw('expr.lte', 'produced_at')); + $this->assertTrue($this->saw('expr.eq', 'status')); + $this->assertFalse($this->saw('expr.eq', 'bogus')); + $this->assertFalse($this->saw('expr.eq', 'source')); + }//end testActorPeriodAndKnownKeysNarrowTheListAndUnknownKeysAreDropped() +}//end class diff --git a/tests/Unit/Db/FluentQueryBuilderTrait.php b/tests/Unit/Db/FluentQueryBuilderTrait.php index 0d30c7f639..3ddb32cab1 100644 --- a/tests/Unit/Db/FluentQueryBuilderTrait.php +++ b/tests/Unit/Db/FluentQueryBuilderTrait.php @@ -141,7 +141,7 @@ public function __toString(): string { } }; $expr = $this->createMock(originalClassName: IExpressionBuilder::class); - foreach (['eq', 'neq', 'lt', 'gt', 'in', 'notIn', 'like', 'isNull', 'isNotNull'] as $method) { + foreach (['eq', 'neq', 'lt', 'gt', 'gte', 'lte', 'in', 'notIn', 'like', 'isNull', 'isNotNull'] as $method) { $expr->method($method)->willReturnCallback( function (mixed ...$args) use ($method): string { $this->calls[] = ['expr.' . $method, ($args[0] ?? null)]; diff --git a/tests/Unit/Db/TaskBlockedByTest.php b/tests/Unit/Db/TaskBlockedByTest.php index d84274f6d1..666b09bd1c 100644 --- a/tests/Unit/Db/TaskBlockedByTest.php +++ b/tests/Unit/Db/TaskBlockedByTest.php @@ -221,4 +221,38 @@ public function testASingleReadDerivesTheFlagToo(): void { $this->assertTrue($inbox->enrich(task: $waiting)['blocked']); }//end testASingleReadDerivesTheFlagToo() + + /** + * The dependants of a closing task are the open rows naming it. + * + * @return void + */ + public function testFindOpenBlockedByAsksForTheOpenDependants(): void { + $mapper = new TaskBlockerMapper(db: $this->connectionWith()); + + $this->assertSame([], $mapper->findOpenBlockedBy(blockerUuid: 'task-visit')); + $this->assertTrue($this->saw('expr.eq', 'blocked_by')); + $this->assertTrue($this->saw('expr.eq', 'is_terminal')); + }//end testFindOpenBlockedByAsksForTheOpenDependants() + + /** + * A blocker lookup that fails leaves the inbox readable: nothing is marked blocked. + * + * @return void + */ + public function testABlockerLookupThatFailsMarksNothingBlocked(): void { + $waiting = new Task(); + $waiting->setUuid('task-advice'); + $waiting->setBlockedBy('task-visit'); + $tasks = $this->createMock(TaskMapper::class); + $tasks->method('findInbox')->willReturn([$waiting]); + $tasks->method('countInbox')->willReturn(1); + $blockers = $this->createMock(TaskBlockerMapper::class); + $blockers->method('openBlockerUuids')->willThrowException(new \RuntimeException('database went away')); + + $inbox = new TaskInboxService(tasks: $tasks, temporal: new TaskTemporalProjection(), logger: new NullLogger(), objects: null, blockers: $blockers); + $rows = $inbox->inbox(criteria: new TaskInboxCriteria(uid: 'anna', objectUuid: 'case-1'))['results']; + + $this->assertFalse($rows[0]['blocked']); + }//end testABlockerLookupThatFailsMarksNothingBlocked() }//end class diff --git a/tests/Unit/Listener/TaskBlockerReleaseListenerTest.php b/tests/Unit/Listener/TaskBlockerReleaseListenerTest.php index 98f5a22722..4906f731c9 100644 --- a/tests/Unit/Listener/TaskBlockerReleaseListenerTest.php +++ b/tests/Unit/Listener/TaskBlockerReleaseListenerTest.php @@ -142,4 +142,19 @@ public function testAnotherEventIsIgnored(): void { $listener = new TaskBlockerReleaseListener(tasks: $tasks, service: $this->createMock(TaskService::class), logger: new NullLogger()); $listener->handle(event: new Event()); }//end testAnotherEventIsIgnored() + + /** + * A failing dependants lookup is logged and releases nothing. + * + * @return void + */ + public function testAFailingLookupReleasesNothing(): void { + $tasks = $this->createMock(TaskMapper::class); + $tasks->method('findOpenBlockedBy')->willThrowException(new RuntimeException('database went away')); + $service = $this->createMock(TaskService::class); + $service->expects($this->never())->method('record'); + + $listener = new TaskBlockerReleaseListener(tasks: $tasks, service: $service, logger: new NullLogger()); + $listener->handle(event: new TaskTerminalEvent(task: $this->task(uuid: 'task-visit'))); + }//end testAFailingLookupReleasesNothing() }//end class diff --git a/tests/Unit/Service/ScheduledReportServiceProfileRunTest.php b/tests/Unit/Service/ScheduledReportServiceProfileRunTest.php index 20a632c993..f6e599a775 100644 --- a/tests/Unit/Service/ScheduledReportServiceProfileRunTest.php +++ b/tests/Unit/Service/ScheduledReportServiceProfileRunTest.php @@ -268,4 +268,35 @@ public function testAProfileTheRunnerCannotLoadFailsLoudly(): void { self::assertSame('failed', $report->getLastStatus()); self::assertStringContainsString('export profile 42', (string)$report->getLastError()); }//end testAProfileTheRunnerCannotLoadFailsLoudly() + + /** + * A profile that read a moment ago but cannot now falls back to the + * default expiry rather than keeping the copy forever. + * + * @return void + */ + public function testAProfileThatCannotBeReadAgainFallsBackToTheDefaultExpiry(): void { + $this->mockOwnerFolder(); + + $profile = new ExportProfile(); + $profile->setName('Maandelijkse aanlevering'); + $profile->setFormat('json'); + $profiles = $this->createMock(ExportProfileService::class); + $profiles->method('find')->willReturnOnConsecutiveCalls($profile, $this->throwException(new \RuntimeException('gone'))); + $profiles->method('run')->willReturn(['bytes' => '{}', 'rowCount' => 3, 'metadata' => [], 'filename' => 'x.json']); + + $seen = 'not called'; + $runs = $this->createMock(ExportRunRecorder::class); + $runs->expects(self::once())->method('record')->willReturnCallback( + function (...$args) use (&$seen) { + $seen = (array_key_exists(10, $args) === true) ? $args[10] : 'absent'; + + return new \OCA\OpenRegister\Db\ExportRun(); + } + ); + + $this->service($profiles, $runs)->runOne(report: $this->report()); + + self::assertSame(ExportRunRecorder::DEFAULT_RETENTION_SECONDS, $seen); + }//end testAProfileThatCannotBeReadAgainFallsBackToTheDefaultExpiry() }//end class diff --git a/tests/Unit/Service/Task/TaskBlockedByCreateTest.php b/tests/Unit/Service/Task/TaskBlockedByCreateTest.php index f43f3c910a..707f976355 100644 --- a/tests/Unit/Service/Task/TaskBlockedByCreateTest.php +++ b/tests/Unit/Service/Task/TaskBlockedByCreateTest.php @@ -45,7 +45,7 @@ * @uses \OCA\OpenRegister\Service\Task\TaskPriority * @uses \OCA\OpenRegister\Service\Task\TaskState * @uses \OCA\OpenRegister\Db\TaskAudit - * @uses \OCA\OpenRegister\Service\Task\TaskBlockerGuard + * @covers \OCA\OpenRegister\Service\Task\TaskBlockerGuard */ class TaskBlockedByCreateTest extends TestCase { From 7eb0ee95c04c9b49b5f66fbac5e02835bbb546ca Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 23:20:18 +0200 Subject: [PATCH 087/183] feat(decisions): another reviewer's decision is hidden under hideOthers DecisionVisibility masks a relation decided by someone else when the subject's x-openregister-review declares hideOthers, unless the caller supervises or administers: decision becomes "hidden", bases, skipAnonymization and an embedded decision record are dropped. Applied in GdprEntitiesController::show and the entity relation PATCH answer; HiddenDecisionTest scans the controllers so a new relation endpoint cannot skip it (REQ-ROD-002, task 2.1). --- lib/Controller/EntityRelationsController.php | 6 + lib/Controller/GdprEntitiesController.php | 13 +- lib/Service/Decision/DecisionVisibility.php | 174 +++++++++++++++ .../reviewer-owns-their-decisions/tasks.md | 2 +- tests/Service/ControllersIntegrationTest2.php | 3 +- .../Unit/Controller/DecisionOwnershipTest.php | 14 +- .../EntityRelationsControllerTest.php | 18 +- .../Controller/GdprEntitiesControllerTest.php | 28 ++- .../Anonymisation/HiddenDecisionTest.php | 205 ++++++++++++++++++ 9 files changed, 454 insertions(+), 9 deletions(-) create mode 100644 lib/Service/Decision/DecisionVisibility.php create mode 100644 tests/Unit/Service/Anonymisation/HiddenDecisionTest.php diff --git a/lib/Controller/EntityRelationsController.php b/lib/Controller/EntityRelationsController.php index 0581e530b0..a33fc61926 100644 --- a/lib/Controller/EntityRelationsController.php +++ b/lib/Controller/EntityRelationsController.php @@ -48,6 +48,7 @@ use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Exception\CustomValidationException; use OCA\OpenRegister\Service\Decision\DecisionOwnedException; +use OCA\OpenRegister\Service\Decision\DecisionVisibility; use OCA\OpenRegister\Service\Decision\RelationDecisionService; use OCA\OpenRegister\Service\Object\PermissionHandler; use OCP\AppFramework\Controller; @@ -79,6 +80,7 @@ class EntityRelationsController extends Controller { * @param EmailLinkMapper $emailLinkMapper EmailLink loader (for email-bound authz indirection). * @param LoggerInterface $logger Structured log sink. * @param RelationDecisionService $relationDecisions Takes the decision as its own object, under the ownership rule. + * @param DecisionVisibility $decisionVisibility Masks a decision the caller may not see (REQ-ROD-002). */ public function __construct( string $appName, @@ -93,6 +95,7 @@ public function __construct( private readonly EmailLinkMapper $emailLinkMapper, private readonly LoggerInterface $logger, private readonly RelationDecisionService $relationDecisions, + private readonly DecisionVisibility $decisionVisibility, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() @@ -198,6 +201,9 @@ public function update(int $id): JSONResponse { $data = $decided['relation']->jsonSerialize(); $data['decisionRecord'] = $decided['decision']?->jsonSerialize(); + // The caller just took this decision, so it is theirs to see; masked + // all the same so no relation leaves OpenRegister unfiltered. + $data = $this->decisionVisibility->present(relation: $decided['relation'], userId: $user->getUID(), serialised: $data); return new JSONResponse( data: $data, diff --git a/lib/Controller/GdprEntitiesController.php b/lib/Controller/GdprEntitiesController.php index 0ab73ffd72..ba81ea2efa 100644 --- a/lib/Controller/GdprEntitiesController.php +++ b/lib/Controller/GdprEntitiesController.php @@ -29,6 +29,7 @@ use DateTime; use OCA\OpenRegister\Db\EntityRelationMapper; use OCA\OpenRegister\Db\GdprEntityMapper; +use OCA\OpenRegister\Service\Decision\DecisionVisibility; use OCA\OpenRegister\Service\OrganisationService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -58,6 +59,7 @@ * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI: the tenth is the REQ-ROD-002 decision filter. */ class GdprEntitiesController extends Controller { /** @@ -72,6 +74,7 @@ class GdprEntitiesController extends Controller { * @param IUserSession $userSession Current user (admin gate) * @param IGroupManager $groupManager Group manager (admin gate) * @param OrganisationService $organisationService Org-scoping helper + * @param DecisionVisibility $decisionVisibility Masks another reviewer's hidden decision */ public function __construct( string $appName, @@ -83,6 +86,7 @@ public function __construct( private readonly IUserSession $userSession, private readonly IGroupManager $groupManager, private readonly OrganisationService $organisationService, + private readonly DecisionVisibility $decisionVisibility, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() @@ -239,7 +243,8 @@ public function index(): JSONResponse { */ public function show(int $id): JSONResponse { try { - if ($this->userSession->getUser() === null) { + $user = $this->userSession->getUser(); + if ($user === null) { return $this->unauthorized(); } @@ -268,7 +273,11 @@ public function show(int $id): JSONResponse { data: [ 'success' => true, 'data' => $entity->jsonSerialize(), - 'relations' => array_map(fn ($r) => $r->jsonSerialize(), $relations), + // REQ-ROD-002: another reviewer's decision is hidden under hideOthers. + 'relations' => $this->decisionVisibility->presentAll( + relations: $relations, + userId: $user->getUID() + ), ] ); } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { diff --git a/lib/Service/Decision/DecisionVisibility.php b/lib/Service/Decision/DecisionVisibility.php new file mode 100644 index 0000000000..c7045901e2 --- /dev/null +++ b/lib/Service/Decision/DecisionVisibility.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 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-schema-can-hide-one-reviewers-decisions-from-another-req-rod-002 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Decision; + +use OCA\OpenRegister\Db\EntityRelation; +use OCP\IGroupManager; + +/** + * The serialised relation a caller may see. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-schema-can-hide-one-reviewers-decisions-from-another-req-rod-002 + */ +class DecisionVisibility { + + /** + * What a masked relation carries in place of its decision. + */ + public const HIDDEN = 'hidden'; + + /** + * The serialised fields that carry the decision's ground or verdict. + * + * `bases` is the ground; `skipAnonymization` is the release-or-redact + * verdict itself, so leaving it would tell the second reviewer what the + * first one decided. `decisionRecord` is the embedded decision. + */ + public const MASKED_FIELDS = ['bases', 'skipAnonymization', 'decisionRecord']; + + /** + * Constructor. + * + * @param ReviewPolicyResolver $resolver The policy of the relation's subject. + * @param DecisionService $decisions The current decision. + * @param IGroupManager $groupManager Administrators and supervisors. + */ + public function __construct( + private readonly ReviewPolicyResolver $resolver, + private readonly DecisionService $decisions, + private readonly IGroupManager $groupManager, + ) { + }//end __construct() + + /** + * The relation as the caller may see it. + * + * @param EntityRelation $relation The relation. + * @param string $userId The caller ('' for nobody signed in). + * @param array $serialised The serialisation to mask; the relation's own when omitted. + * + * @return array + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-schema-can-hide-one-reviewers-decisions-from-another-req-rod-002 + */ + public function present(EntityRelation $relation, string $userId, ?array $serialised=null): array { + $data = ($serialised ?? $relation->jsonSerialize()); + if ($this->isHidden(relation: $relation, userId: $userId) === false) { + return $data; + } + + $data['decision'] = self::HIDDEN; + foreach (self::MASKED_FIELDS as $field) { + if (array_key_exists($field, $data) === true) { + $data[$field] = null; + } + } + + return $data; + }//end present() + + /** + * Every relation as the caller may see it. + * + * @param EntityRelation[] $relations The relations. + * @param string $userId The caller. + * + * @return list> + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-schema-can-hide-one-reviewers-decisions-from-another-req-rod-002 + */ + public function presentAll(array $relations, string $userId): array { + $presented = []; + foreach ($relations as $relation) { + $presented[] = $this->present(relation: $relation, userId: $userId); + } + + return $presented; + }//end presentAll() + + /** + * Whether the caller may not see this relation's decision. + * + * Hidden when the relation carries a decision, its policy hides others, + * someone else took it, and the caller neither supervises nor administers. + * A decision whose record is gone is hidden too: its taker is unknown. + * + * @param EntityRelation $relation The relation. + * @param string $userId The caller. + * + * @return bool + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-schema-can-hide-one-reviewers-decisions-from-another-req-rod-002 + */ + public function isHidden(EntityRelation $relation, string $userId): bool { + $decisionUuid = (string)($relation->getDecisionUuid() ?? ''); + if ($decisionUuid === '') { + return false; + } + + $policy = $this->resolver->forRelation(relation: $relation)['policy']; + if ($policy->hidesOthers() === false) { + return false; + } + + $decision = $this->decisions->find(uuid: $decisionUuid); + if ($decision !== null && $userId !== '' && $decision->getDecidedBy() === $userId) { + return false; + } + + return $this->supervises(userId: $userId, policy: $policy) === false; + }//end isHidden() + + /** + * Whether the caller is an administrator or in one of the policy's supervisor groups. + * + * @param string $userId The caller. + * @param ReviewPolicy $policy The policy. + * + * @return bool + */ + private function supervises(string $userId, ReviewPolicy $policy): bool { + if ($userId === '') { + return false; + } + + if ($this->groupManager->isAdmin($userId) === true) { + return true; + } + + foreach ($policy->getSupervisors() as $group) { + if ($this->groupManager->isInGroup($userId, $group) === true) { + return true; + } + } + + return false; + }//end supervises() +}//end class diff --git a/openspec/changes/reviewer-owns-their-decisions/tasks.md b/openspec/changes/reviewer-owns-their-decisions/tasks.md index fd09bb5b46..1669800b67 100644 --- a/openspec/changes/reviewer-owns-their-decisions/tasks.md +++ b/openspec/changes/reviewer-owns-their-decisions/tasks.md @@ -12,7 +12,7 @@ ## 2. Hiding (REQ-ROD-002) -- [ ] 2.1 A `DecisionVisibility` filter applied in every controller that serialises `EntityRelation`; a hidden relation carries `decision: "hidden"` and no decision record. (not built in the first PR: next stretch) Verify: `tests/Unit/Service/Anonymisation/HiddenDecisionTest.php::testAnotherReviewersDecisionIsHidden`, `testTheSupervisorSeesIt`, `testEveryRelationEndpointMasks` (reflection over controllers returning relations, fails on one without the filter). +- [x] 2.1 A `DecisionVisibility` filter applied in every controller that serialises `EntityRelation`; a hidden relation carries `decision: "hidden"` and no decision record. Done: `lib/Service/Decision/DecisionVisibility.php` (masks `bases`, `skipAnonymization` and an embedded `decisionRecord`, since `skipAnonymization` is the verdict itself), applied in `GdprEntitiesController::show` and the `EntityRelationsController::update` answer. Verify: `tests/Unit/Service/Anonymisation/HiddenDecisionTest.php::testAnotherReviewersDecisionIsHidden`, `testTheSupervisorSeesIt`, `testEveryRelationEndpointMasks` (reflection over controllers returning relations, fails on one without the filter). - [ ] 2.2 The review UI in OpenRegister shows "decided by another reviewer" without the decision. Verify: `tests/e2e/ci/blind-second-review.spec.ts` seeds four decisions by one user, signs in as another and sees ten detections, four marked decided without their ground. ## 3. Assessment records (REQ-ROD-003) diff --git a/tests/Service/ControllersIntegrationTest2.php b/tests/Service/ControllersIntegrationTest2.php index e0d41480fc..8d73834c68 100644 --- a/tests/Service/ControllersIntegrationTest2.php +++ b/tests/Service/ControllersIntegrationTest2.php @@ -1825,7 +1825,8 @@ private function buildGdprEntitiesController(): GdprEntitiesController { $this->logger, \OC::$server->get(\OCP\IUserSession::class), \OC::$server->get(\OCP\IGroupManager::class), - \OC::$server->get(OrganisationService::class) + \OC::$server->get(OrganisationService::class), + \OC::$server->get(\OCA\OpenRegister\Service\Decision\DecisionVisibility::class) ); }//end buildGdprEntitiesController() diff --git a/tests/Unit/Controller/DecisionOwnershipTest.php b/tests/Unit/Controller/DecisionOwnershipTest.php index 520d185b84..4e77f73b65 100644 --- a/tests/Unit/Controller/DecisionOwnershipTest.php +++ b/tests/Unit/Controller/DecisionOwnershipTest.php @@ -23,6 +23,7 @@ use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\Decision\DecisionOwnership; use OCA\OpenRegister\Service\Decision\DecisionService; +use OCA\OpenRegister\Service\Decision\DecisionVisibility; use OCA\OpenRegister\Service\Decision\RelationDecisionService; use OCA\OpenRegister\Service\Decision\ReviewPolicyResolver; use OCA\OpenRegister\Service\File\FolderManagementHandler; @@ -198,7 +199,18 @@ function (Decision $decision): Decision { $this->createMock(RegisterMapper::class), $this->createMock(EmailLinkMapper::class), $logger, - $decide + $decide, + new DecisionVisibility( + resolver: new ReviewPolicyResolver( + schemaMapper: $schemas, + fileSettings: new FileSettingsHandler(appConfig: $this->createMock(IAppConfig::class)), + rootFolder: $this->createMock(IRootFolder::class), + folders: $this->createMock(FolderManagementHandler::class), + logger: $logger + ), + decisions: new DecisionService(decisionMapper: $decisionMapper), + groupManager: $groups + ) ); }//end controllerAs() diff --git a/tests/Unit/Controller/EntityRelationsControllerTest.php b/tests/Unit/Controller/EntityRelationsControllerTest.php index 4f0b820dd1..67d04d38d3 100644 --- a/tests/Unit/Controller/EntityRelationsControllerTest.php +++ b/tests/Unit/Controller/EntityRelationsControllerTest.php @@ -31,6 +31,7 @@ use OCA\OpenRegister\Db\DecisionMapper; use OCA\OpenRegister\Service\Decision\DecisionOwnership; use OCA\OpenRegister\Service\Decision\DecisionService; +use OCA\OpenRegister\Service\Decision\DecisionVisibility; use OCA\OpenRegister\Service\Decision\RelationDecisionService; use OCA\OpenRegister\Service\Decision\ReviewPolicyResolver; use OCA\OpenRegister\Service\File\FolderManagementHandler; @@ -155,10 +156,25 @@ private function makeController(): EntityRelationsController { $this->registerMapper, $this->emailLinkMapper, $this->logger, - $this->relationDecisions + $this->relationDecisions, + $this->visibleAsIs() ); }//end makeController() + /** + * A visibility filter that hides nothing: these tests are about the PATCH, HiddenDecisionTest about the mask. + * + * @return DecisionVisibility + */ + private function visibleAsIs(): DecisionVisibility { + $visibility = $this->createMock(DecisionVisibility::class); + $visibility->method('present')->willReturnCallback( + static fn (EntityRelation $relation, string $userId, ?array $serialised = null): array => ($serialised ?? $relation->jsonSerialize()) + ); + + return $visibility; + }//end visibleAsIs() + public function testReturns401WhenNotAuthenticated(): void { $this->userSession->method('getUser')->willReturn(null); diff --git a/tests/Unit/Controller/GdprEntitiesControllerTest.php b/tests/Unit/Controller/GdprEntitiesControllerTest.php index 215f17a7ad..947d6fa7dd 100644 --- a/tests/Unit/Controller/GdprEntitiesControllerTest.php +++ b/tests/Unit/Controller/GdprEntitiesControllerTest.php @@ -5,9 +5,11 @@ namespace Unit\Controller; use OCA\OpenRegister\Controller\GdprEntitiesController; +use OCA\OpenRegister\Db\EntityRelation; use OCA\OpenRegister\Db\EntityRelationMapper; use OCA\OpenRegister\Db\GdprEntity; use OCA\OpenRegister\Db\GdprEntityMapper; +use OCA\OpenRegister\Service\Decision\DecisionVisibility; use OCA\OpenRegister\Service\OrganisationService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -65,7 +67,8 @@ protected function setUp(): void { $this->logger, $this->userSession, $this->groupManager, - $this->organisationService + $this->organisationService, + $this->visibleAsIs() ); } @@ -935,7 +938,8 @@ private function nonAdminController(array $orgUuids): GdprEntitiesController { $this->logger, $userSession, $groupManager, - $organisationService + $organisationService, + $this->visibleAsIs() ); } @@ -952,7 +956,8 @@ public function testIndexUnauthenticatedReturns401(): void { $this->logger, $userSession, $this->groupManager, - $this->organisationService + $this->organisationService, + $this->visibleAsIs() ); $result = $controller->index(); @@ -1019,4 +1024,21 @@ public function testDestroyNonAdminCrossTenantReturns404(): void { $this->assertEquals(Http::STATUS_NOT_FOUND, $result->getStatus()); } + + /** + * A visibility filter that hides nothing: HiddenDecisionTest covers the mask itself. + * + * @return DecisionVisibility + */ + private function visibleAsIs(): DecisionVisibility { + $visibility = $this->createMock(DecisionVisibility::class); + $visibility->method('presentAll')->willReturnCallback( + static fn (array $relations, string $userId): array => array_map( + static fn (EntityRelation $relation): array => $relation->jsonSerialize(), + array_values($relations) + ) + ); + + return $visibility; + } } diff --git a/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php b/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php new file mode 100644 index 0000000000..703edf7ddd --- /dev/null +++ b/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php @@ -0,0 +1,205 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-schema-can-hide-one-reviewers-decisions-from-another-req-rod-002 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Anonymisation; + +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Db\EntityRelation; +use OCA\OpenRegister\Service\Decision\DecisionService; +use OCA\OpenRegister\Service\Decision\DecisionVisibility; +use OCA\OpenRegister\Service\Decision\ReviewPolicy; +use OCA\OpenRegister\Service\Decision\ReviewPolicyResolver; +use OCP\IGroupManager; +use PHPUnit\Framework\TestCase; + +/** + * A blind second review: another reviewer's decision is listed but not shown. + * + * @covers \OCA\OpenRegister\Service\Decision\DecisionVisibility + */ +class HiddenDecisionTest extends TestCase { + + private const DECISION = 'dec00000-0000-4000-8000-000000000001'; + + /** + * The filter over a policy, one decision by j.devries, and group membership. + * + * @param array|null $declaration The schema's x-openregister-review. + * + * @return DecisionVisibility + */ + private function visibility(?array $declaration=['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => true]): DecisionVisibility { + $resolver = $this->createMock(ReviewPolicyResolver::class); + $resolver->method('forRelation')->willReturn( + ['policy' => ReviewPolicy::fromDeclaration(declaration: $declaration), 'subject' => []] + ); + + $decision = new Decision(); + $decision->setUuid(self::DECISION); + $decision->setDecidedBy('j.devries'); + $decisions = $this->createMock(DecisionService::class); + $decisions->method('find')->willReturnCallback( + static fn (?string $uuid): ?Decision => $uuid === self::DECISION ? $decision : null + ); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturnCallback(static fn (string $uid): bool => $uid === 'admin'); + $groups->method('isInGroup')->willReturnCallback( + static fn (string $uid, string $group): bool => $uid === 'coordinator' && $group === 'woo-coordinator' + ); + + return new DecisionVisibility(resolver: $resolver, decisions: $decisions, groupManager: $groups); + }//end visibility() + + /** + * A decided relation: redacted on 5.1.2e by j.devries's decision. + * + * @param string|null $decisionUuid The decision it links. + * + * @return EntityRelation + */ + private function relation(?string $decisionUuid=self::DECISION): EntityRelation { + $relation = new EntityRelation(); + $relation->setId(12); + $relation->setEntityId(3); + $relation->setPositionStart(10); + $relation->setPositionEnd(20); + $relation->setBases(['5.1.2e']); + $relation->setSkipAnonymization(false); + $relation->setDecisionUuid($decisionUuid); + + return $relation; + }//end relation() + + /** + * p.bakker sees the relation, its type and position, but neither the decision, its ground nor its verdict. + * + * @return void + */ + public function testAnotherReviewersDecisionIsHidden(): void { + $data = $this->visibility()->present(relation: $this->relation(), userId: 'p.bakker'); + + self::assertSame(DecisionVisibility::HIDDEN, $data['decision']); + self::assertNull($data['bases']); + self::assertNull($data['skipAnonymization']); + self::assertSame(12, $data['id']); + self::assertSame(10, $data['positionStart']); + self::assertSame(20, $data['positionEnd']); + }//end testAnotherReviewersDecisionIsHidden() + + /** + * A supervisor, an administrator and the decider see the decision. + * + * @return void + */ + public function testTheSupervisorSeesIt(): void { + foreach (['coordinator', 'admin', 'j.devries'] as $userId) { + $data = $this->visibility()->present(relation: $this->relation(), userId: $userId); + + self::assertSame(self::DECISION, $data['decision'], $userId); + self::assertSame(['5.1.2e'], $data['bases'], $userId); + self::assertFalse($data['skipAnonymization'], $userId); + } + }//end testTheSupervisorSeesIt() + + /** + * Nothing hides without hideOthers, an undecided relation hides nothing, and nobody signed in sees nothing. + * + * @return void + */ + public function testOnlyADecidedRelationUnderHideOthersIsMasked(): void { + $open = $this->visibility(declaration: ['ownership' => 'decider', 'supervisors' => [], 'hideOthers' => false]); + self::assertSame(self::DECISION, $open->present(relation: $this->relation(), userId: 'p.bakker')['decision']); + + self::assertNull($this->visibility()->present(relation: $this->relation(decisionUuid: null), userId: 'p.bakker')['decision']); + self::assertSame(DecisionVisibility::HIDDEN, $this->visibility()->present(relation: $this->relation(), userId: '')['decision']); + }//end testOnlyADecidedRelationUnderHideOthersIsMasked() + + /** + * A decision whose record is gone is hidden: who took it is unknown. + * + * @return void + */ + public function testADecisionWithoutItsRecordIsHidden(): void { + $data = $this->visibility()->present(relation: $this->relation(decisionUuid: 'dec-gone'), userId: 'p.bakker'); + + self::assertSame(DecisionVisibility::HIDDEN, $data['decision']); + }//end testADecisionWithoutItsRecordIsHidden() + + /** + * The embedded decision record of a PATCH answer is masked with the rest. + * + * @return void + */ + public function testAnEmbeddedDecisionRecordIsMasked(): void { + $relation = $this->relation(); + $serialised = $relation->jsonSerialize(); + $serialised['decisionRecord'] = ['decidedBy' => 'j.devries', 'note' => 'name of a resident']; + + $data = $this->visibility()->present(relation: $relation, userId: 'p.bakker', serialised: $serialised); + + self::assertNull($data['decisionRecord']); + }//end testAnEmbeddedDecisionRecordIsMasked() + + /** + * presentAll masks each relation on its own. + * + * @return void + */ + public function testPresentAllMasksEachRelation(): void { + $data = $this->visibility()->presentAll(relations: [$this->relation(), $this->relation(decisionUuid: null)], userId: 'p.bakker'); + + self::assertSame([DecisionVisibility::HIDDEN, null], array_column($data, 'decision')); + }//end testPresentAllMasksEachRelation() + + /** + * Every controller that serialises an entity relation goes through the filter. + * + * A controller that injects the relation mapper and serialises what it + * reads (`$r->jsonSerialize()`, `$relation->jsonSerialize()`, + * `$decided['relation']->jsonSerialize()`) must also name DecisionVisibility. + * The two known endpoints must be found, so a scan that matches nothing fails. + * + * @return void + */ + public function testEveryRelationEndpointMasks(): void { + $serialisers = []; + $unfiltered = []; + foreach (glob(__DIR__ . '/../../../../lib/Controller/{,*/}*Controller.php', GLOB_BRACE) as $path) { + $source = (string)file_get_contents($path); + if (str_contains($source, 'EntityRelationMapper') === false + || preg_match('/(\$r|\$relation\w*|\[\'relation\'\])->jsonSerialize\(\)|decisionVisibility->present/', $source) !== 1 + ) { + continue; + } + + $serialisers[] = basename($path); + if (str_contains($source, 'DecisionVisibility') === false) { + $unfiltered[] = basename($path); + } + } + + self::assertContains('GdprEntitiesController.php', $serialisers, 'the scan must find the known relation endpoint, or it proves nothing'); + self::assertContains('EntityRelationsController.php', $serialisers, 'the scan must find the known relation endpoint, or it proves nothing'); + self::assertSame([], $unfiltered, 'these controllers serialise entity relations without DecisionVisibility'); + }//end testEveryRelationEndpointMasks() +}//end class From c40c3ababec0d939650a3f50c77ae475527a3a11 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 23:24:40 +0200 Subject: [PATCH 088/183] fix(decisions): under hideOthers a refused change names neither who decided nor when The 403 decision-owned refusal carried decidedBy and decidedAt even when the schema hides another reviewer's decisions, which REQ-ROD-002 forbids. Under hideOthers it now says only that the decision belongs to another reviewer. Assisted-by: Claude Code --- .../Decision/DecisionOwnedException.php | 5 +++-- lib/Service/Decision/DecisionOwnership.php | 19 +++++++++++++------ .../specs/entity-relation-grondslagen/spec.md | 9 ++++++++- .../Decision/DecisionOwnershipTest.php | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/lib/Service/Decision/DecisionOwnedException.php b/lib/Service/Decision/DecisionOwnedException.php index 78d57554ab..52a7f496f7 100644 --- a/lib/Service/Decision/DecisionOwnedException.php +++ b/lib/Service/Decision/DecisionOwnedException.php @@ -36,7 +36,8 @@ final class DecisionOwnedException extends Exception { /** * Constructor. * - * @param array $refusal The refusal body: error, decision, decidedBy, decidedAt. + * @param array $refusal The refusal body: error, decision and, unless + * hideOthers is on, decidedBy and decidedAt. * * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 */ @@ -47,7 +48,7 @@ public function __construct(private readonly array $refusal) { /** * The refusal body the API answers with. * - * @return array error, decision, decidedBy, decidedAt. + * @return array error, decision and, unless hideOthers is on, decidedBy and decidedAt. * * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 */ diff --git a/lib/Service/Decision/DecisionOwnership.php b/lib/Service/Decision/DecisionOwnership.php index 28f236db71..7fefee6def 100644 --- a/lib/Service/Decision/DecisionOwnership.php +++ b/lib/Service/Decision/DecisionOwnership.php @@ -60,15 +60,16 @@ public function __construct( /** * Why a user may not change a decision, or null when they may. * - * The refusal names who decided and when, never the verdict or the note, - * so a refused reviewer learns nothing a hidden review would keep from them. + * The refusal names who decided and when, never the verdict or the note. + * Under `hideOthers` it names neither who decided nor when, only that the + * decision belongs to another reviewer (REQ-ROD-002). * * @param Decision $decision The current decision. * @param string $actorId The user who wants to change it. * @param ReviewPolicy $policy The policy that applies. * @param array $subject What the decision is on, for listeners. * - * @return array{error: string, decision: string|null, decidedBy: string|null, decidedAt: string|null}|null + * @return array{error: string, decision: string|null, decidedBy?: string|null, decidedAt?: string|null}|null * * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 */ @@ -84,12 +85,18 @@ public function refusal(Decision $decision, string $actorId, ReviewPolicy $polic return null; } - return [ + $refusal = [ 'error' => 'decision-owned', 'decision' => $decision->getUuid(), - 'decidedBy' => $decision->getDecidedBy(), - 'decidedAt' => $decision->getDecidedAt()?->format(DateTimeInterface::ATOM), ]; + if ($policy->hidesOthers() === true) { + return $refusal; + } + + $refusal['decidedBy'] = $decision->getDecidedBy(); + $refusal['decidedAt'] = $decision->getDecidedAt()?->format(DateTimeInterface::ATOM); + + return $refusal; }//end refusal() /** diff --git a/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md b/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md index 56045dca13..ee1b6279b5 100644 --- a/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md +++ b/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md @@ -8,7 +8,7 @@ status: proposed ### Requirement: A reviewer's decision is changed only by that reviewer or a supervisor (REQ-ROD-001) -When ownership is `decider` for the relation's schema (from `x-openregister-review`, read from the relation's own schema or, for a relation on a file, from the schema of the object the file belongs to) or, for a relation with no schema, from the instance default (`anonymisationReview` in the file settings), `PATCH /api/entity-relations/{id}` on a relation whose current decision object (REQ-DEC-001) was taken by someone else SHALL be refused with 403 `{error: "decision-owned", decision, decidedBy, decidedAt}` unless the caller is the decider, a member of a configured supervisor group, an administrator, or allowed by the app that owns the subject (REQ-DEC-002). The refusal SHALL NOT carry the verdict or the note. A relation without a decision SHALL be decided by any caller who passes the existing subject write check. The decision note (`decisionNote` in the body, stored as the decision's `note`) SHALL follow the same rule. When ownership is `open` behaviour SHALL be as today. A declaration whose ownership is neither `open` nor `decider`, whose supervisors are not a list of group ids, or whose `hideOthers` is not a boolean SHALL be refused at schema save with 422; an ownership that is unreadable at run time SHALL be read as `decider`. +When ownership is `decider` for the relation's schema (from `x-openregister-review`, read from the relation's own schema or, for a relation on a file, from the schema of the object the file belongs to) or, for a relation with no schema, from the instance default (`anonymisationReview` in the file settings), `PATCH /api/entity-relations/{id}` on a relation whose current decision object (REQ-DEC-001) was taken by someone else SHALL be refused with 403 `{error: "decision-owned", decision, decidedBy, decidedAt}` unless the caller is the decider, a member of a configured supervisor group, an administrator, or allowed by the app that owns the subject (REQ-DEC-002). The refusal SHALL NOT carry the verdict or the note, and under `hideOthers` (REQ-ROD-002) it SHALL carry neither `decidedBy` nor `decidedAt`: it says only that the decision belongs to another reviewer. A relation without a decision SHALL be decided by any caller who passes the existing subject write check. The decision note (`decisionNote` in the body, stored as the decision's `note`) SHALL follow the same rule. When ownership is `open` behaviour SHALL be as today. A declaration whose ownership is neither `open` nor `decider`, whose supervisors are not a list of group ids, or whose `hideOthers` is not a boolean SHALL be refused at schema save with 422; an ownership that is unreadable at run time SHALL be read as `decider`. #### Scenario: a second reviewer cannot overwrite the first @@ -46,6 +46,13 @@ When `hideOthers` is on, every endpoint that returns entity relations (`gdprEnti - **WHEN** a reviewer reads relations through any endpoint that returns them - **THEN** no response carries another reviewer's `decidedBy`, `decisionNote` or `bases` +#### Scenario: a refused change under hideOthers names no one + + +- **GIVEN** ownership `decider` with `hideOthers` on, and `j.devries` decided an occurrence +- **WHEN** `p.bakker` tries to change that decision +- **THEN** the answer is 403 `{error: "decision-owned", decision}` without `decidedBy` or `decidedAt` + ### Requirement: An assessment record can be limited to its author and a supervisor (REQ-ROD-003) An object schema whose authorization block holds `update: ["@creator", ""]` SHALL refuse an update by a user who is neither the object's creator nor a member of the group, and `read: ["@creator", ""]` SHALL keep the object out of that user's lists and reads, as `access-owner-and-condition-scopes` specifies. diff --git a/tests/Unit/Service/Decision/DecisionOwnershipTest.php b/tests/Unit/Service/Decision/DecisionOwnershipTest.php index 891cbd00f9..193cd6788c 100644 --- a/tests/Unit/Service/Decision/DecisionOwnershipTest.php +++ b/tests/Unit/Service/Decision/DecisionOwnershipTest.php @@ -12,6 +12,7 @@ use DateTime; use OCA\OpenRegister\Db\Decision; use OCA\OpenRegister\Event\DecisionChangeRequestedEvent; +use OCA\OpenRegister\Service\Decision\DecisionOwnedException; use OCA\OpenRegister\Service\Decision\DecisionOwnership; use OCA\OpenRegister\Service\Decision\ReviewPolicy; use OCP\EventDispatcher\IEventDispatcher; @@ -116,6 +117,24 @@ public function testASecondReviewerIsRefused(): void { self::assertSame(['subjectType' => 'entity-relation', 'subjectId' => '7'], $this->dispatched[0]->getSubject()); }//end testASecondReviewerIsRefused() + /** + * Under hideOthers the refusal names neither who decided nor when (REQ-ROD-002). + * + * @return void + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-schema-can-hide-one-reviewers-decisions-from-another-req-rod-002 + */ + public function testUnderHideOthersTheRefusalNamesNoDecider(): void { + $policy = ReviewPolicy::fromDeclaration(declaration: ['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => true]); + $refusal = $this->ownership()->refusal(decision: $this->decision(), actorId: 'p.bakker', policy: $policy); + + self::assertSame(['error' => 'decision-owned', 'decision' => 'd-1'], $refusal); + + $exception = new DecisionOwnedException(refusal: (array)$refusal); + self::assertStringNotContainsString('j.devries', $exception->getMessage()); + self::assertStringNotContainsString('j.devries', (string)json_encode($exception->getRefusal())); + }//end testUnderHideOthersTheRefusalNamesNoDecider() + /** * The decider, a supervisor and an administrator may; open ownership lets anyone. * From 9f66c244180cde477229914f14d07196fab16c2d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 11 Oct 2026 23:27:49 +0200 Subject: [PATCH 089/183] feat(decisions): the app that owns the subject may show a hidden decision DecisionVisibility only knew the schema's supervisor groups, so a case-type supervisor outside them never saw a hidden decision. DecisionVisibility now asks DecisionVisibilityRequestedEvent before hiding a decision from a signed-in caller; a listener may allow, and a listener that throws keeps it hidden (REQ-DEC-003). A controller-level test pins the hideOthers refusal. Assisted-by: Claude Code --- .../DecisionVisibilityRequestedEvent.php | 143 ++++++++++++++++++ lib/Service/Decision/DecisionVisibility.php | 65 ++++++-- .../specs/decisions/spec.md | 18 +++ .../reviewer-owns-their-decisions/tasks.md | 1 + .../Unit/Controller/DecisionOwnershipTest.php | 22 ++- .../Anonymisation/HiddenDecisionTest.php | 109 ++++++++++++- 6 files changed, 344 insertions(+), 14 deletions(-) create mode 100644 lib/Event/DecisionVisibilityRequestedEvent.php diff --git a/lib/Event/DecisionVisibilityRequestedEvent.php b/lib/Event/DecisionVisibilityRequestedEvent.php new file mode 100644 index 0000000000..a971e8c282 --- /dev/null +++ b/lib/Event/DecisionVisibilityRequestedEvent.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 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Event; + +use OCA\OpenRegister\Db\Decision; +use OCA\OpenRegister\Service\Decision\ReviewPolicy; +use OCP\EventDispatcher\Event; + +/** + * A question with one answer: may this user see this hidden decision? + * + * Dispatched only when the decision would otherwise be hidden: the policy + * hides others, someone else took the decision, and the caller is signed in + * but neither an administrator nor in a supervisor group the policy names. + * A listener may allow; nothing else can make a decision visible. A + * listener that throws leaves the decision hidden. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ +class DecisionVisibilityRequestedEvent extends Event { + + /** + * Whether a listener let the caller see the decision. + * + * @var boolean + */ + private bool $allowed = false; + + /** + * Constructor. + * + * @param Decision|null $decision The hidden decision, or null when its record is gone. + * @param string $actorId The user who reads it. + * @param ReviewPolicy $policy The policy that applies. + * @param array $subject What the decision is on: subjectType, + * subjectId and, when known, register, + * schema, objectUuid and fileId. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function __construct( + private readonly ?Decision $decision, + private readonly string $actorId, + private readonly ReviewPolicy $policy, + private readonly array $subject, + ) { + parent::__construct(); + }//end __construct() + + /** + * The hidden decision. + * + * @return Decision|null The decision, or null when its record is gone. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function getDecision(): ?Decision { + return $this->decision; + }//end getDecision() + + /** + * The user who reads it. + * + * @return string The user id. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function getActorId(): string { + return $this->actorId; + }//end getActorId() + + /** + * The policy that applies. + * + * @return ReviewPolicy The policy. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function getPolicy(): ReviewPolicy { + return $this->policy; + }//end getPolicy() + + /** + * What the decision is on. + * + * @return array subjectType, subjectId and, when known, + * register, schema, objectUuid and fileId. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function getSubject(): array { + return $this->subject; + }//end getSubject() + + /** + * Let the caller see the decision. + * + * @return void + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function allow(): void { + $this->allowed = true; + }//end allow() + + /** + * The answer after every listener has had its say. + * + * @return bool True when the caller may see the decision. + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function isAllowed(): bool { + return $this->allowed; + }//end isAllowed() +}//end class diff --git a/lib/Service/Decision/DecisionVisibility.php b/lib/Service/Decision/DecisionVisibility.php index c7045901e2..2f3043dc52 100644 --- a/lib/Service/Decision/DecisionVisibility.php +++ b/lib/Service/Decision/DecisionVisibility.php @@ -29,8 +29,13 @@ namespace OCA\OpenRegister\Service\Decision; +use OCA\OpenRegister\Db\Decision; use OCA\OpenRegister\Db\EntityRelation; +use OCA\OpenRegister\Event\DecisionVisibilityRequestedEvent; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IGroupManager; +use Psr\Log\LoggerInterface; +use Throwable; /** * The serialised relation a caller may see. @@ -56,14 +61,18 @@ class DecisionVisibility { /** * Constructor. * - * @param ReviewPolicyResolver $resolver The policy of the relation's subject. - * @param DecisionService $decisions The current decision. - * @param IGroupManager $groupManager Administrators and supervisors. + * @param ReviewPolicyResolver $resolver The policy of the relation's subject. + * @param DecisionService $decisions The current decision. + * @param IGroupManager $groupManager Administrators and supervisors. + * @param IEventDispatcher $eventDispatcher Asks the app that owns the subject (REQ-DEC-003). + * @param LoggerInterface $logger Records a listener that failed. */ public function __construct( private readonly ReviewPolicyResolver $resolver, private readonly DecisionService $decisions, private readonly IGroupManager $groupManager, + private readonly IEventDispatcher $eventDispatcher, + private readonly LoggerInterface $logger, ) { }//end __construct() @@ -117,7 +126,8 @@ public function presentAll(array $relations, string $userId): array { * Whether the caller may not see this relation's decision. * * Hidden when the relation carries a decision, its policy hides others, - * someone else took it, and the caller neither supervises nor administers. + * someone else took it, the caller neither supervises nor administers, + * and the app that owns the subject does not show it to them (REQ-DEC-003). * A decision whose record is gone is hidden too: its taker is unknown. * * @param EntityRelation $relation The relation. @@ -133,19 +143,56 @@ public function isHidden(EntityRelation $relation, string $userId): bool { return false; } - $policy = $this->resolver->forRelation(relation: $relation)['policy']; + $resolved = $this->resolver->forRelation(relation: $relation); + $policy = $resolved['policy']; if ($policy->hidesOthers() === false) { return false; } + if ($userId === '') { + return true; + } + $decision = $this->decisions->find(uuid: $decisionUuid); - if ($decision !== null && $userId !== '' && $decision->getDecidedBy() === $userId) { + if ($decision !== null && $decision->getDecidedBy() === $userId) { + return false; + } + + if ($this->supervises(userId: $userId, policy: $policy) === true) { return false; } - return $this->supervises(userId: $userId, policy: $policy) === false; + return $this->shownByEvent(decision: $decision, userId: $userId, policy: $policy, subject: $resolved['subject']) === false; }//end isHidden() + /** + * Whether the app that owns the subject shows the decision to the caller. + * + * A listener that throws keeps the decision hidden: the question fails closed. + * + * @param Decision|null $decision The hidden decision. + * @param string $userId The caller. + * @param ReviewPolicy $policy The policy. + * @param array $subject What the decision is on. + * + * @return bool True when a listener allowed it. + */ + private function shownByEvent(?Decision $decision, string $userId, ReviewPolicy $policy, array $subject): bool { + $event = new DecisionVisibilityRequestedEvent(decision: $decision, actorId: $userId, policy: $policy, subject: $subject); + + try { + $this->eventDispatcher->dispatchTyped($event); + } catch (Throwable $error) { + $this->logger->warning( + message: '[DecisionVisibility] A listener failed; the decision stays hidden.', + context: ['decision' => $decision?->getUuid(), 'actor' => $userId, 'error' => $error->getMessage()] + ); + return false; + } + + return $event->isAllowed(); + }//end shownByEvent() + /** * Whether the caller is an administrator or in one of the policy's supervisor groups. * @@ -155,10 +202,6 @@ public function isHidden(EntityRelation $relation, string $userId): bool { * @return bool */ private function supervises(string $userId, ReviewPolicy $policy): bool { - if ($userId === '') { - return false; - } - if ($this->groupManager->isAdmin($userId) === true) { return true; } diff --git a/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md b/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md index e3f58cf773..cb45ee9c41 100644 --- a/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md +++ b/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md @@ -48,3 +48,21 @@ Before anyone other than the decider or an administrator changes a decider-owned - **GIVEN** a listener that throws - **WHEN** a supervisor changes another reviewer's decision - **THEN** the change is refused with `decision-owned` + +### Requirement: The app that owns the subject may show a hidden decision (REQ-DEC-003) + +Before a decision is hidden from a caller under `hideOthers` (REQ-ROD-002), OpenRegister SHALL dispatch `DecisionVisibilityRequestedEvent` carrying the decision (null when its record is gone), the caller, the policy and the subject (as in REQ-DEC-002). A listener MAY allow, and the decision is then shown to that caller. Nothing else SHALL make a hidden decision visible: without an allow it stays hidden, and a listener that throws SHALL leave it hidden. The event SHALL NOT be dispatched when nothing would be hidden (no decision, `hideOthers` off, the caller is the decider, an administrator or in a supervisor group of the policy) or when nobody is signed in. + +#### Scenario: a case-type supervisor sees hidden decisions on their cases + + +- **GIVEN** `hideOthers` on and a decision by `j.devries` on a document of a case whose case type names `woo-coordinator` as supervisor group, which the schema does not name +- **WHEN** a member of `woo-coordinator` reads the document's detections +- **THEN** the app that owns the case allows it and the decision, its ground and its verdict are shown + +#### Scenario: a failing listener keeps the decision hidden + + +- **GIVEN** a listener that throws +- **WHEN** a reviewer reads another reviewer's decision under `hideOthers` +- **THEN** the relation shows `decision: "hidden"` without its ground or verdict diff --git a/openspec/changes/reviewer-owns-their-decisions/tasks.md b/openspec/changes/reviewer-owns-their-decisions/tasks.md index 1669800b67..64096c2732 100644 --- a/openspec/changes/reviewer-owns-their-decisions/tasks.md +++ b/openspec/changes/reviewer-owns-their-decisions/tasks.md @@ -13,6 +13,7 @@ ## 2. Hiding (REQ-ROD-002) - [x] 2.1 A `DecisionVisibility` filter applied in every controller that serialises `EntityRelation`; a hidden relation carries `decision: "hidden"` and no decision record. Done: `lib/Service/Decision/DecisionVisibility.php` (masks `bases`, `skipAnonymization` and an embedded `decisionRecord`, since `skipAnonymization` is the verdict itself), applied in `GdprEntitiesController::show` and the `EntityRelationsController::update` answer. Verify: `tests/Unit/Service/Anonymisation/HiddenDecisionTest.php::testAnotherReviewersDecisionIsHidden`, `testTheSupervisorSeesIt`, `testEveryRelationEndpointMasks` (reflection over controllers returning relations, fails on one without the filter). +- [x] 2.3 The app that owns the subject may show a hidden decision (REQ-DEC-003): `lib/Event/DecisionVisibilityRequestedEvent.php` (allow only), dispatched by `DecisionVisibility::isHidden()` only when the decision would otherwise be hidden from a signed-in caller; a failing listener keeps it hidden. Under `hideOthers` the 403 `decision-owned` refusal names neither `decidedBy` nor `decidedAt`. Verify: `HiddenDecisionTest::testTheOwningAppMayShowAHiddenDecision`, `testAFailingListenerKeepsItHidden`, `testTheQuestionIsAskedOnlyWhenHidden`; `Service/Decision/DecisionOwnershipTest::testUnderHideOthersTheRefusalNamesNoDecider` and the controller-level test of the same name. Consumer: dossiq `CaseDecisionAccess` (case-type supervisors). - [ ] 2.2 The review UI in OpenRegister shows "decided by another reviewer" without the decision. Verify: `tests/e2e/ci/blind-second-review.spec.ts` seeds four decisions by one user, signs in as another and sees ten detections, four marked decided without their ground. ## 3. Assessment records (REQ-ROD-003) diff --git a/tests/Unit/Controller/DecisionOwnershipTest.php b/tests/Unit/Controller/DecisionOwnershipTest.php index 4e77f73b65..f98caf9e62 100644 --- a/tests/Unit/Controller/DecisionOwnershipTest.php +++ b/tests/Unit/Controller/DecisionOwnershipTest.php @@ -209,7 +209,9 @@ function (Decision $decision): Decision { logger: $logger ), decisions: new DecisionService(decisionMapper: $decisionMapper), - groupManager: $groups + groupManager: $groups, + eventDispatcher: $this->createMock(IEventDispatcher::class), + logger: $logger ) ); }//end controllerAs() @@ -244,6 +246,24 @@ public function testASecondReviewerIsRefused(): void { self::assertCount(1, $this->decisions); }//end testASecondReviewerIsRefused() + /** + * Under hideOthers the PATCH refusal names neither who decided nor when (REQ-ROD-002). + * + * @return void + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-schema-can-hide-one-reviewers-decisions-from-another-req-rod-002 + */ + public function testUnderHideOthersTheRefusalNamesNoDecider(): void { + $this->schemaConfiguration['x-openregister-review']['hideOthers'] = true; + $this->firstReviewerDecides(); + + $response = $this->controllerAs(uid: 'p.bakker', body: ['skipAnonymization' => true])->update(7); + + self::assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + self::assertSame(['error' => 'decision-owned', 'decision' => 'd-1'], $response->getData()); + self::assertStringNotContainsString('j.devries', (string)json_encode($response->getData())); + }//end testUnderHideOthersTheRefusalNamesNoDecider() + /** * A coordinator's change is a new decision, by the coordinator, superseding the first. * diff --git a/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php b/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php index 703edf7ddd..53fee34e37 100644 --- a/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php +++ b/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php @@ -24,12 +24,16 @@ use OCA\OpenRegister\Db\Decision; use OCA\OpenRegister\Db\EntityRelation; +use OCA\OpenRegister\Event\DecisionVisibilityRequestedEvent; use OCA\OpenRegister\Service\Decision\DecisionService; use OCA\OpenRegister\Service\Decision\DecisionVisibility; use OCA\OpenRegister\Service\Decision\ReviewPolicy; use OCA\OpenRegister\Service\Decision\ReviewPolicyResolver; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IGroupManager; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; /** * A blind second review: another reviewer's decision is listed but not shown. @@ -40,6 +44,20 @@ class HiddenDecisionTest extends TestCase { private const DECISION = 'dec00000-0000-4000-8000-000000000001'; + /** + * What a listener does with the question, if anything. + * + * @var (callable(DecisionVisibilityRequestedEvent): void)|null + */ + private $listener = null; + + /** + * The events dispatched. + * + * @var array + */ + private array $dispatched = []; + /** * The filter over a policy, one decision by j.devries, and group membership. * @@ -50,7 +68,7 @@ class HiddenDecisionTest extends TestCase { private function visibility(?array $declaration=['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => true]): DecisionVisibility { $resolver = $this->createMock(ReviewPolicyResolver::class); $resolver->method('forRelation')->willReturn( - ['policy' => ReviewPolicy::fromDeclaration(declaration: $declaration), 'subject' => []] + ['policy' => ReviewPolicy::fromDeclaration(declaration: $declaration), 'subject' => ['subjectType' => 'entity-relation', 'subjectId' => '12', 'objectUuid' => 'doc-1']] ); $decision = new Decision(); @@ -67,7 +85,23 @@ private function visibility(?array $declaration=['ownership' => 'decider', 'supe static fn (string $uid, string $group): bool => $uid === 'coordinator' && $group === 'woo-coordinator' ); - return new DecisionVisibility(resolver: $resolver, decisions: $decisions, groupManager: $groups); + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + function (DecisionVisibilityRequestedEvent $event): void { + $this->dispatched[] = $event; + if ($this->listener !== null) { + ($this->listener)($event); + } + } + ); + + return new DecisionVisibility( + resolver: $resolver, + decisions: $decisions, + groupManager: $groups, + eventDispatcher: $dispatcher, + logger: $this->createMock(LoggerInterface::class) + ); }//end visibility() /** @@ -171,6 +205,77 @@ public function testPresentAllMasksEachRelation(): void { self::assertSame([DecisionVisibility::HIDDEN, null], array_column($data, 'decision')); }//end testPresentAllMasksEachRelation() + /** + * The app that owns the subject may show a hidden decision, e.g. to a case-type supervisor (REQ-DEC-003). + * + * @return void + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function testTheOwningAppMayShowAHiddenDecision(): void { + $this->listener = static function (DecisionVisibilityRequestedEvent $event): void { + if ($event->getActorId() === 'case-supervisor') { + $event->allow(); + } + }; + + $data = $this->visibility()->present(relation: $this->relation(), userId: 'case-supervisor'); + self::assertSame(self::DECISION, $data['decision']); + self::assertSame(['5.1.2e'], $data['bases']); + + self::assertCount(1, $this->dispatched); + $event = $this->dispatched[0]; + self::assertSame(self::DECISION, $event->getDecision()?->getUuid()); + self::assertSame('doc-1', $event->getSubject()['objectUuid'] ?? null); + self::assertTrue($event->getPolicy()->hidesOthers()); + + self::assertSame(DecisionVisibility::HIDDEN, $this->visibility()->present(relation: $this->relation(), userId: 'p.bakker')['decision']); + }//end testTheOwningAppMayShowAHiddenDecision() + + /** + * A listener that throws leaves the decision hidden. + * + * @return void + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function testAFailingListenerKeepsItHidden(): void { + $this->listener = static function (DecisionVisibilityRequestedEvent $event): void { + $event->allow(); + throw new RuntimeException('case lookup failed'); + }; + + $data = $this->visibility()->present(relation: $this->relation(), userId: 'case-supervisor'); + + self::assertSame(DecisionVisibility::HIDDEN, $data['decision']); + self::assertNull($data['bases']); + }//end testAFailingListenerKeepsItHidden() + + /** + * The question is asked only when the decision would otherwise be hidden from someone signed in. + * + * @return void + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-show-a-hidden-decision-req-dec-003 + */ + public function testTheQuestionIsAskedOnlyWhenHidden(): void { + $this->listener = static function (DecisionVisibilityRequestedEvent $event): void { + $event->allow(); + }; + + foreach (['coordinator', 'admin', 'j.devries'] as $userId) { + $this->visibility()->present(relation: $this->relation(), userId: $userId); + } + + $this->visibility()->present(relation: $this->relation(decisionUuid: null), userId: 'p.bakker'); + $this->visibility(declaration: ['ownership' => 'decider', 'hideOthers' => false])->present(relation: $this->relation(), userId: 'p.bakker'); + + $anonymous = $this->visibility()->present(relation: $this->relation(), userId: ''); + self::assertSame(DecisionVisibility::HIDDEN, $anonymous['decision']); + + self::assertSame([], $this->dispatched); + }//end testTheQuestionIsAskedOnlyWhenHidden() + /** * Every controller that serialises an entity relation goes through the filter. * From daed30c35c8cab76df4169ec93a6390e5187922c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 07:39:35 +0200 Subject: [PATCH 090/183] chore(train): bump info.xml version so every new migration and repair step runs --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 37a8332bdb..561f878e8f 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.1.35-unstable.20261011150000 + 2.1.35-unstable.20261012120000 EUPL-1.2 Conduction OpenRegister From 2eb8d3f41b03743dddf664ba700d676a6946fbbe Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 07:48:52 +0200 Subject: [PATCH 091/183] fix(forms): a portal subject's first draft save no longer answers 403 where the public may not create The owner assertion authorises every write of a scope-owned draft, its start included (decision 195). A fresh submit without a draft stays under RBAC, and no controller can assert an owner. --- lib/Controller/FormsController.php | 2 +- lib/Service/Form/FormSubmitService.php | 23 +++--- tests/Unit/Controller/FormsControllerTest.php | 79 +++++++++++++++++++ .../Service/Form/FormSubmitServiceTest.php | 78 +++++++++++++++++- 4 files changed, 167 insertions(+), 15 deletions(-) diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php index 235ad19b11..33b28f4f7d 100644 --- a/lib/Controller/FormsController.php +++ b/lib/Controller/FormsController.php @@ -381,7 +381,7 @@ private function payload(): array { $payload = []; foreach ($this->request->getParams() as $name => $value) { $name = (string)$name; - if ($name === 'formId' || str_starts_with($name, '_') === true) { + if ($name === 'formId' || str_starts_with($name, '_') === true || str_starts_with($name, '@') === true) { continue; } diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index 000aac47b6..7b624879c4 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -705,7 +705,7 @@ private function serverFilled(Schema $schema, array $object): array { * @param string|null $status The lifecycle status to store (`draft`, `active`), or none. * @param string|null $uuid The object to update (a draft), for a single write. * @param DraftOwner|null $owner A draft's owner; a scope value is stamped on create, and - * authorises the update of its own draft (checked before). + * authorises every write of its own draft. * * @return array, object: ObjectEntity}> The created objects, in order. * @@ -713,17 +713,18 @@ private function serverFilled(Schema $schema, array $object): array { */ private function writeAll(array $plans, ?IUser $subject, ?string $status = null, ?string $uuid = null, ?DraftOwner $owner = null): array { // A scope-value owner (decision 195) has no Nextcloud user for RBAC to - // judge. Starting its draft is a create under the same RBAC as an - // anonymous submit; updating or sending its own draft was authorised by - // ownDraft() before this write, so the user RBAC is not asked again. + // judge, so the owner assertion is the authorisation on every write of + // its draft. Starting it does not ask the destination's public create + // rule: the in-process caller vouched for the subject, exactly as it + // does when that subject's app creates the object outright. Updating + // or sending it was authorised by DraftFinder::own() before this write. + // Only saveDraft() and promoteDraft() hand an owner in, so a scoped + // write is always a draft or the one promotion of an owned draft; a + // fresh submit never reaches this branch and stays under RBAC. $scoped = $owner !== null && $owner->isScoped() === true; $explicitOwner = null; - $rbac = true; - if ($scoped === true) { - $rbac = $uuid === null; - if ($uuid === null) { - $explicitOwner = $owner->value(); - } + if ($scoped === true && $uuid === null) { + $explicitOwner = $owner->value(); } $created = []; @@ -745,7 +746,7 @@ private function writeAll(array $plans, ?IUser $subject, ?string $status = null, register: $plan['register'], schema: $plan['schema'], uuid: $uuid, - _rbac: $rbac, + _rbac: $scoped === false, _multitenancy: true, uploadedFiles: $files, currentUser: $subject, diff --git a/tests/Unit/Controller/FormsControllerTest.php b/tests/Unit/Controller/FormsControllerTest.php index dce57bd361..c50b68685d 100644 --- a/tests/Unit/Controller/FormsControllerTest.php +++ b/tests/Unit/Controller/FormsControllerTest.php @@ -22,6 +22,7 @@ use OCA\OpenRegister\Service\Form\FormSubmitService; use OCA\OpenRegister\Service\Form\FormUploadStore; use OCA\OpenRegister\Service\Hardening\ThrottledSurfaces; +use OCA\OpenRegister\Service\Object\DraftOwner; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\BruteForceProtection; @@ -341,6 +342,84 @@ public function testADraftIsSavedAndSubmittedByItsId(): void { $this->assertSame(201, $this->controller->submit(formId: 'form-1')->getStatus()); }//end testADraftIsSavedAndSubmittedByItsId() + /** + * A request cannot name a draft's owner: only an in-process caller may (decision 195). + * + * The controller hands the service the session user and nothing else, so + * `_owner`, `owner` and `@self.owner` in a request never become the owner + * assertion that authorises a draft without the public create rule. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function testARequestCannotNameADraftsOwner(): void { + $user = $this->createMock(IUser::class); + $this->session->method('getUser')->willReturn($user); + $this->resolver->method('resolve')->willReturn($this->form()); + $this->params = ['formId' => 'form-1', 'onderwerp' => 'Half af', '_owner' => 'portal:subject-1', '_draft' => 'd-1', '@self' => ['owner' => 'portal:subject-1']]; + $calls = []; + $record = static function (string $method, array $answer) use (&$calls): callable { + return static function (mixed ...$arguments) use (&$calls, $method, $answer): array { + $calls[$method] = $arguments; + + return $answer; + }; + }; + $this->submitter->method('isOwnerOfDraft')->willReturnCallback( + static function (mixed ...$arguments) use (&$calls): bool { + $calls['isOwnerOfDraft'] = $arguments; + + return true; + } + ); + $this->submitter->method('saveDraft')->willReturnCallback($record('saveDraft', ['id' => 'd-1', 'status' => 'draft'])); + $this->submitter->method('submit')->willReturnCallback( + $record('submit', ['reference' => 'r', 'id' => 'd-1', 'receivedAt' => 'now', 'confirmation' => [], 'objects' => []]) + ); + + $this->assertSame(200, $this->controller->draft(formId: 'form-1')->getStatus()); + $this->assertSame(201, $this->controller->submit(formId: 'form-1')->getStatus()); + + $this->assertSame(['isOwnerOfDraft', 'saveDraft', 'submit'], array_keys($calls)); + foreach ($calls as $method => $arguments) { + foreach ($arguments as $argument) { + $this->assertNotInstanceOf(DraftOwner::class, $argument, $method . ' was handed an owner assertion from a request.'); + } + + $this->assertNotContains('portal:subject-1', $arguments, $method); + } + + $this->assertSame(['onderwerp' => 'Half af'], $calls['saveDraft'][2], 'Metadata and control keys stay out of the payload.'); + }//end testARequestCannotNameADraftsOwner() + + /** + * No controller builds an owner assertion or forwards an explicit owner to a save. + * + * `DraftOwner` and the save's `_owner` argument are service-level: an app + * that knows its subject in process asserts the owner. A controller that + * did would let a request authorise its own draft. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function testNoControllerAssertsADraftOwner(): void { + $root = dirname(__DIR__, 3) . '/lib/Controller'; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + $read = 0; + $offenders = []; + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $read++; + if (preg_match('/\bDraftOwner\b|\b_owner\s*:/', (string)file_get_contents($file->getPathname())) === 1) { + $offenders[] = substr($file->getPathname(), (strlen($root) + 1)); + } + } + + $this->assertGreaterThan(50, $read, 'The sweep read the controllers; an empty sweep proves nothing.'); + $this->assertSame([], $offenders); + }//end testNoControllerAssertsADraftOwner() + /** * Another person's draft id answers 404 and nothing is saved. */ diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php index 13abe7cf11..d960a7b585 100644 --- a/tests/Unit/Service/Form/FormSubmitServiceTest.php +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -109,6 +109,13 @@ class FormSubmitServiceTest extends TestCase { */ private array $refuseSave = []; + /** + * Whether the destination refuses an anonymous create under RBAC. + * + * @var bool + */ + private bool $publicCreateRefused = false; + /** * @var array */ @@ -326,6 +333,12 @@ private function fakeSave( throw $this->refuseSave[$schema->getSlug()]; } + // A destination whose schema does not let the public create: the save + // refuses an anonymous create under RBAC, as SaveObject does. + if ($this->publicCreateRefused === true && $_rbac === true && $currentUser === null && $uuid === null) { + throw new NotAuthorizedException(message: 'The public may not create this object.'); + } + $data = (array)$object; $status = ($data['@self']['status'] ?? null); unset($data['@self']); @@ -657,7 +670,7 @@ public function testSomebodyElsesDraftIsA404(): void { */ public function testATypeInvalidDraftIsRefused(): void { try { - $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => ['not', 'a', 'string']], subject: $this->createMock(IUser::class)); + $this->service->saveDraft(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => ['not', 'a', 'string']], subject: $this->user(uid: 'alice')); $this->fail('A type-invalid draft was saved.'); } catch (FormSubmitRefusedException $refused) { $this->assertSame(422, $refused->getStatus()); @@ -728,7 +741,7 @@ public function testAnUnknownDraftIsA404(): void { foreach (['uuid-case-1', 'nope'] as $id) { try { - $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], subject: $this->createMock(IUser::class), draftId: $id); + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], subject: $this->user(uid: 'alice'), draftId: $id); $this->fail('Draft ' . $id . ' was accepted.'); } catch (FormSubmitRefusedException $refused) { $this->assertSame(404, $refused->getStatus()); @@ -751,7 +764,7 @@ public function testAPortalSubjectSavesReadsFindsAndSendsItsOwnDraft(): void { $this->assertSame('portal:subject-1', $this->saves[0]['owner']); $this->assertNull($this->saves[0]['user']); $this->assertFalse($this->saves[0]['unowned']); - $this->assertTrue($this->saves[0]['rbac'], 'Starting a draft is a create, under the same RBAC as an anonymous submit.'); + $this->assertFalse($this->saves[0]['rbac'], 'Starting a scoped draft is authorised by the owner assertion, not by the public create rule.'); $this->assertSame('portal:subject-1', $this->stored[$draft['id']]->getOwner()); $read = $this->service->readDraft(destination: $destination, draftId: $draft['id'], subject: null, owner: $owner); @@ -773,6 +786,65 @@ public function testAPortalSubjectSavesReadsFindsAndSendsItsOwnDraft(): void { $this->assertFalse($this->stored[$draft['id']]->isDraft()); }//end testAPortalSubjectSavesReadsFindsAndSendsItsOwnDraft() + /** + * Spec scenario: a scope-owned draft starts where the public may not create. + * + * The owner assertion authorises the draft, as it authorises its update and + * its send; the destination schema does not have to allow public create. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function testAScopeOwnedDraftStartsWhereThePublicMayNotCreate(): void { + $this->publicCreateRefused = true; + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $owner = DraftOwner::portalSubject(subjectRef: 'subject-1'); + + $draft = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Half af'], subject: null, owner: $owner); + + $this->assertSame('draft', $draft['status']); + $this->assertSame('portal:subject-1', $this->stored[$draft['id']]->getOwner()); + $this->assertFalse($this->saves[0]['rbac']); + $this->assertSame('draft', $this->saves[0]['object']['@self']['status'], 'The owner assertion authorises a draft, never an object that skips draft.'); + + $answer = $this->service->submit(destination: $destination, mapping: null, payload: ['caseType' => 'ct-1'], subject: null, draftId: $draft['id'], owner: $owner); + $this->assertSame($draft['id'], $answer['id']); + $this->assertFalse($this->stored[$draft['id']]->isDraft()); + }//end testAScopeOwnedDraftStartsWhereThePublicMayNotCreate() + + /** + * The control: without an owner assertion the same destination still refuses. + * + * A fresh anonymous submit stays under the public create rule (403), an + * owner asserted on a fresh submit changes nothing (it names a draft's + * owner, and there is no draft), and nobody at all saves no draft (401). + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function testWithoutAnOwnedDraftThePublicCreateRuleStillDecides(): void { + $this->publicCreateRefused = true; + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $calls = [ + 'anonymous submit' => [403, fn () => $this->service->submit(destination: $destination, mapping: null, payload: ['title' => 'a', 'caseType' => 'b'])], + 'fresh submit naming an owner' => [403, fn () => $this->service->submit(destination: $destination, mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], owner: DraftOwner::portalSubject(subjectRef: 'subject-1'))], + 'draft of nobody' => [401, fn () => $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'a'], subject: null)], + ]; + + foreach ($calls as $what => [$status, $call]) { + try { + $call(); + $this->fail($what . ' was accepted.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame($status, $refused->getStatus(), $what); + } + } + + $this->assertSame([], $this->stored); + foreach ($this->saves as $save) { + $this->assertTrue($save['rbac'], 'Every create without an owned draft asks the RBAC.'); + $this->assertNull($save['owner']); + } + }//end testWithoutAnOwnedDraftThePublicCreateRuleStillDecides() + /** * Spec scenario: anyone else gets 404, whether another portal subject or a signed-in user. * From c716f9f210c9ea9b7690e21e330c67eb2f58b487 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 07:48:53 +0200 Subject: [PATCH 092/183] feat(objects): lists and searches hide drafts unless asked, the caller's own stay in view (decision 201) _drafts follows _archived: absent is the work list, true is drafts alone, any is both. A @self[status] filter decides for itself. DraftFinder asks for drafts. --- lib/Db/MagicMapper/MagicSearchHandler.php | 42 ++++- lib/Service/Object/DraftFinder.php | 3 + lib/Service/Object/DraftListLens.php | 157 ++++++++++++++++ .../specs/form-destination/spec.md | 34 +++- .../form-destination-validator/tasks.md | 9 +- .../MagicSearchHandlerDraftLensTest.php | 172 ++++++++++++++++++ tests/Unit/Service/Object/DraftFinderTest.php | 1 + .../Unit/Service/Object/DraftListLensTest.php | 121 ++++++++++++ 8 files changed, 535 insertions(+), 4 deletions(-) create mode 100644 lib/Service/Object/DraftListLens.php create mode 100644 tests/Unit/Db/MagicMapper/MagicSearchHandlerDraftLensTest.php create mode 100644 tests/Unit/Service/Object/DraftListLensTest.php diff --git a/lib/Db/MagicMapper/MagicSearchHandler.php b/lib/Db/MagicMapper/MagicSearchHandler.php index 419b8868ed..fbeea86bda 100644 --- a/lib/Db/MagicMapper/MagicSearchHandler.php +++ b/lib/Db/MagicMapper/MagicSearchHandler.php @@ -54,6 +54,7 @@ use OCA\OpenRegister\Exception\UnknownMetadataFieldException; use OCA\OpenRegister\Service\DateTimeNormalizer; use OCA\OpenRegister\Service\LanguageService; +use OCA\OpenRegister\Service\Object\DraftListLens; use OCA\OpenRegister\Service\Object\SchemaTypeConverter; use OCA\OpenRegister\Service\Search\PropertySearchProfile; use OCA\OpenRegister\Service\Search\SearchTermNode; @@ -508,7 +509,8 @@ public function buildFilteredQuery(array $query, Schema $schema, string $tableNa $this->applyBasicFilters( qb: $queryBuilder, includeDeleted: $includeDeleted, - archivedMode: $this->resolveArchivedMode(query: $query) + archivedMode: $this->resolveArchivedMode(query: $query), + query: $query ); // Apply multi-tenancy and RBAC access control filters. @@ -907,6 +909,20 @@ private function lifecycleConditionsSql(array $query, bool $includeDeleted): arr $conditions[] = '_archived IS NOT NULL'; } + // Decision 201: drafts stay out of a list unless asked for, on this + // path as on applyBasicFilters(), with the caller's own kept in view. + $userId = $this->rbacHandler->getCurrentUserId(); + $owner = null; + if ($userId !== null && $userId !== '') { + $owner = (string)$this->db->getQueryBuilder()->getConnection()->quote($userId); + } + + $lens = new DraftListLens(); + $drafts = $lens->sql(mode: $lens->modeFor(query: $query), columnPrefix: '', owner: $owner); + if ($drafts !== null) { + $conditions[] = $drafts; + } + return $conditions; }//end lifecycleConditionsSql() @@ -2154,6 +2170,7 @@ private function getReservedParams(): array { '_count', '_includeDeleted', '_archived', + DraftListLens::PARAMETER, '_relations_contains', '_multitenancy_explicit', '_fuzzy', @@ -2173,10 +2190,13 @@ private function getReservedParams(): array { * @param IQueryBuilder $qb Query builder to modify * @param bool $includeDeleted Whether to include deleted objects * @param string $archivedMode Which archive lens to apply, one of the ARCHIVED_* constants + * @param array $query The search query, for the draft lens (`_drafts`, decision 201) * * @return void + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-list-must-hide-drafts-unless-asked */ - private function applyBasicFilters(IQueryBuilder $qb, bool $includeDeleted, string $archivedMode = self::ARCHIVED_EXCLUDE): void { + private function applyBasicFilters(IQueryBuilder $qb, bool $includeDeleted, string $archivedMode = self::ARCHIVED_EXCLUDE, array $query = []): void { // Handle deleted filter. if ($includeDeleted === false) { $qb->andWhere($qb->expr()->isNull('t._deleted')); @@ -2194,6 +2214,24 @@ private function applyBasicFilters(IQueryBuilder $qb, bool $includeDeleted, stri $qb->andWhere($qb->expr()->isNotNull('t._archived')); } + // Decision 201: drafts stay out of a list unless asked for, whoever + // asks. The read RBAC keeps a draft to its owner, but an administrator + // and an app's service listing with RBAC off pass that by, and their + // work lists are the ones the decision is about. The caller's own + // drafts stay in view. Raw SQL through createFunction(), spelled once + // in DraftListLens for this path and lifecycleConditionsSql(). + $userId = $this->rbacHandler->getCurrentUserId(); + $owner = null; + if ($userId !== null && $userId !== '') { + $owner = (string)$qb->createNamedParameter($userId); + } + + $lens = new DraftListLens(); + $drafts = $lens->sql(mode: $lens->modeFor(query: $query), columnPrefix: 't.', owner: $owner); + if ($drafts !== null) { + $qb->andWhere($qb->createFunction($drafts)); + } + }//end applyBasicFilters() /** diff --git a/lib/Service/Object/DraftFinder.php b/lib/Service/Object/DraftFinder.php index a172c29116..a8055c6256 100644 --- a/lib/Service/Object/DraftFinder.php +++ b/lib/Service/Object/DraftFinder.php @@ -108,6 +108,9 @@ public function newest(Register $register, Schema $schema, DraftOwner $owner, ar query: [ '@self' => ['register' => (string)$register->getId(), 'schema' => (string)$schema->getId(), 'owner' => $owner->value()], '_limit' => self::SEARCH_LIMIT, + // A list hides drafts unless asked (decision 201), and this + // owner has no session for the lens to recognise as "own". + DraftListLens::PARAMETER => 'true', ], _rbac: false, _multitenancy: false diff --git a/lib/Service/Object/DraftListLens.php b/lib/Service/Object/DraftListLens.php new file mode 100644 index 0000000000..49fea7a079 --- /dev/null +++ b/lib/Service/Object/DraftListLens.php @@ -0,0 +1,157 @@ + + * 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/form-destination-validator/specs/form-destination/spec.md#requirement-a-list-must-hide-drafts-unless-asked + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Object; + +use OCA\OpenRegister\Db\ObjectEntity; + +/** + * Which drafts a list shows, and the SQL clause that says so. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-list-must-hide-drafts-unless-asked + */ +class DraftListLens { + + /** + * The query parameter a caller sends. + * + * @var string + */ + public const PARAMETER = '_drafts'; + + /** + * The work list: no drafts but the caller's own. The default. + * + * @var string + */ + public const EXCLUDE = 'exclude'; + + /** + * Drafts and nothing else. + * + * @var string + */ + public const ONLY = 'only'; + + /** + * Drafts beside the rest. The literal is the value callers send (`_drafts=any`). + * + * @var string + */ + public const ANY = 'any'; + + /** + * The lens a query asks for. + * + * An unrecognised value reads as the work list, because a typo must never + * widen what a list shows. Without the parameter, a filter on the stored + * status (`@self[status]=draft`) is the caller saying which statuses it + * wants, so the default steps aside and that filter decides. + * + * @param array $query The search query. + * + * @return string One of EXCLUDE, ONLY and ANY. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-list-must-hide-drafts-unless-asked + */ + public function modeFor(array $query): string { + if (array_key_exists(self::PARAMETER, $query) === false) { + if ($this->filtersOnStatus(query: $query) === true) { + return self::ANY; + } + + return self::EXCLUDE; + } + + $raw = $query[self::PARAMETER]; + if (is_string($raw) === true && strtolower(trim($raw)) === self::ANY) { + return self::ANY; + } + + if (is_scalar($raw) === true && filter_var($raw, FILTER_VALIDATE_BOOLEAN) === true) { + return self::ONLY; + } + + return self::EXCLUDE; + }//end modeFor() + + /** + * Whether the query carries a filter on the stored status. + * + * @param array $query The search query. + * + * @return bool True for a non-empty `@self.status` filter. + */ + private function filtersOnStatus(array $query): bool { + $self = ($query['@self'] ?? null); + if (is_array($self) === false) { + return false; + } + + $status = ($self['status'] ?? null); + + return $status !== null && $status !== '' && $status !== []; + }//end filtersOnStatus() + + /** + * The clause for a lens, or null when it puts no condition on the list. + * + * @param string $mode One of EXCLUDE, ONLY and ANY. + * @param string $columnPrefix The table alias with its dot, or empty for an unaliased UNION member. + * @param string|null $owner The caller, already quoted or bound by the emitter; null for nobody. + * + * @return string|null The predicate, without a leading AND. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-list-must-hide-drafts-unless-asked + */ + public function sql(string $mode, string $columnPrefix, ?string $owner): ?string { + if ($mode === self::ANY) { + return null; + } + + if ($mode === self::ONLY) { + return $columnPrefix . "_status = '" . ObjectEntity::STATUS_DRAFT . "'"; + } + + $notADraft = (new DraftStatusPolicy())->notADraftSql(columnPrefix: $columnPrefix); + if ($owner === null || $owner === '') { + return $notADraft; + } + + return '(' . $notADraft . ' OR ' . $columnPrefix . '_owner = ' . $owner . ')'; + }//end sql() +}//end class diff --git a/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md index 647bbaab54..4aee842f33 100644 --- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md +++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md @@ -116,7 +116,7 @@ Object metadata SHALL carry a stored `@self.status` with at least the values `dr ### Requirement: A draft MUST be able to be owned by a scope value -A draft's owner SHALL be either a Nextcloud user or a scope value `:`, such as `portal:` for a resident signed in at a portal who has no Nextcloud account (decision 195). A scope is lower case letters, digits and hyphens; the whole value SHALL fit the owner column (64 characters). A Nextcloud user id cannot hold a colon, so a scope value never names a user. A draft owned by a scope value SHALL be found, read, saved and sent only by a caller asserting that same owner; every other caller, a signed-in user included, SHALL get the same 404 an unknown draft id gets. The draft clause in the read RBAC keeps such a draft out of every list, as it keeps any draft to its owner. Generic by construction (decision 182): the owner value carries no app or procedure logic. +A draft's owner SHALL be either a Nextcloud user or a scope value `:`, such as `portal:` for a resident signed in at a portal who has no Nextcloud account (decision 195). A scope is lower case letters, digits and hyphens; the whole value SHALL fit the owner column (64 characters). A Nextcloud user id cannot hold a colon, so a scope value never names a user. A draft owned by a scope value SHALL be found, read, saved and sent only by a caller asserting that same owner; every other caller, a signed-in user included, SHALL get the same 404 an unknown draft id gets. The owner assertion SHALL be the authorisation for every write of that draft, its start included: starting it SHALL NOT need the destination schema to allow public create, because the in-process caller vouches for its subject, as it does when it creates the object outright. Only a draft is authorised this way; a fresh submit without a draft SHALL stay under the destination's RBAC. An owner assertion SHALL be reachable in process only: no controller builds one or forwards an explicit owner to a save, so a request cannot authorise its own draft. The draft clause in the read RBAC and the draft lens on lists keep such a draft out of every list, as they keep any draft to its owner. Generic by construction (decision 182): the owner value carries no app or procedure logic. #### Scenario: A portal subject saves, reads and sends its own draft - **GIVEN** a caller asserting owner `portal:subject-1` @@ -128,6 +128,16 @@ A draft's owner SHALL be either a Nextcloud user or a scope value `: + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db\MagicMapper + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-list-must-hide-drafts-unless-asked + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db\MagicMapper; + +use Doctrine\DBAL\Platforms\MySQLPlatform; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; +use OCA\OpenRegister\Db\MagicMapper\MagicRbacHandler; +use OCA\OpenRegister\Db\MagicMapper\MagicSearchHandler; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\DateTimeNormalizer; +use OCA\OpenRegister\Service\Object\SchemaTypeConverter; +use OCA\OpenRegister\Service\Query\RelatedRowQueryApplier; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\QueryBuilder\IQueryFunction; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionClass; + +/** + * Decision 201: a list or search hides drafts unless asked, on the union path and the single-table path alike. + * + * @covers \OCA\OpenRegister\Db\MagicMapper\MagicSearchHandler + * @uses \OCA\OpenRegister\Service\Object\DraftListLens + * @uses \OCA\OpenRegister\Service\Object\DraftStatusPolicy + * @uses \OCA\OpenRegister\Db\Schema + */ +final class MagicSearchHandlerDraftLensTest extends TestCase { + + /** + * Raw SQL the single-table path put on its query builder. + * + * @var array + */ + private array $functions = []; + + /** + * A handler over a fake database, enough to build a WHERE clause; RBAC and tenancy waved through. + * + * @param string|null $userId The signed-in caller, or null for nobody. + * + * @return MagicSearchHandler The handler. + */ + private function handler(?string $userId): MagicSearchHandler { + $connection = $this->createMock(IDBConnection::class); + $connection->method('quote')->willReturnCallback(static fn (mixed $value): string => "'" . $value . "'"); + + $expression = $this->createMock(IExpressionBuilder::class); + $expression->method('isNull')->willReturnCallback(static fn (string $column): string => $column . ' IS NULL'); + $queryBuilder = $this->createMock(IQueryBuilder::class); + $queryBuilder->method('getConnection')->willReturn($connection); + $queryBuilder->method('expr')->willReturn($expression); + $queryBuilder->method('createNamedParameter')->willReturnCallback(static fn (mixed $value): string => ':bound_' . $value); + $queryBuilder->method('createFunction')->willReturnCallback( + function (string $sql): IQueryFunction { + $this->functions[] = $sql; + + return $this->createMock(IQueryFunction::class); + } + ); + + $db = $this->createMock(IDBConnection::class); + $db->method('getQueryBuilder')->willReturn($queryBuilder); + $db->method('getDatabasePlatform')->willReturn(new MySQLPlatform()); + + $rbac = $this->createMock(MagicRbacHandler::class); + $rbac->method('buildRbacConditionsSql')->willReturn(['bypass' => true, 'conditions' => []]); + $rbac->method('getCurrentUserId')->willReturn($userId); + + $organisation = $this->createMock(MagicOrganizationHandler::class); + $organisation->method('resolveOrganizationScope')->willReturn(['mode' => MagicOrganizationHandler::SCOPE_ALL, 'uuids' => []]); + + return new MagicSearchHandler( + $db, + $this->createMock(LoggerInterface::class), + $rbac, + $organisation, + $this->createMock(SchemaTypeConverter::class), + $this->createMock(DateTimeNormalizer::class), + relatedRows: $this->createMock(RelatedRowQueryApplier::class) + ); + }//end handler() + + /** + * Spec scenario: a work list leaves drafts out, with RBAC off, which is how an app's service lists. + */ + public function testTheUnionPathHidesDraftsByDefault(): void { + $conditions = $this->handler(userId: null)->buildWhereConditionsSql(query: ['_rbac' => false], schema: new Schema()); + + $this->assertContains("(_status IS NULL OR _status <> 'draft')", $conditions); + }//end testTheUnionPathHidesDraftsByDefault() + + /** + * Spec scenario: the owner still sees their own drafts in the same list. + */ + public function testTheUnionPathKeepsTheCallersOwnDrafts(): void { + $conditions = $this->handler(userId: 'alice')->buildWhereConditionsSql(query: [], schema: new Schema()); + + $this->assertContains("((_status IS NULL OR _status <> 'draft') OR _owner = 'alice')", $conditions); + }//end testTheUnionPathKeepsTheCallersOwnDrafts() + + /** + * Spec scenario: the filter shows them; `_drafts=true` alone, `_drafts=any` beside the rest. + */ + public function testTheUnionPathShowsDraftsWhenAsked(): void { + $only = $this->handler(userId: 'alice')->buildWhereConditionsSql(query: ['_drafts' => 'true'], schema: new Schema()); + $this->assertContains("_status = 'draft'", $only); + + // The control: a path that always emitted a clause would pass the tests above. + foreach ([['_drafts' => 'any'], ['@self' => ['status' => 'draft']]] as $query) { + $any = $this->handler(userId: 'alice')->buildWhereConditionsSql(query: $query, schema: new Schema()); + $this->assertSame([], array_values(array_filter($any, static fn (mixed $condition): bool => is_string($condition) === true && str_contains($condition, "'draft')") === true))); + $this->assertNotContains("_status = 'draft'", $any); + } + }//end testTheUnionPathShowsDraftsWhenAsked() + + /** + * The single-table path carries the same lens, with the owner bound and the table alias on each column. + * + * Two paths build one WHERE by different means; a clause on one of them + * only is a list that shows drafts or hides them depending on how many + * schemas the caller asked for. + */ + public function testTheSingleTablePathCarriesTheSameLens(): void { + $apply = (new ReflectionClass(MagicSearchHandler::class))->getMethod('applyBasicFilters'); + $apply->setAccessible(true); + $calls = [ + "((t._status IS NULL OR t._status <> 'draft') OR t._owner = :bound_alice)" => ['alice', []], + "(t._status IS NULL OR t._status <> 'draft')" => [null, []], + "t._status = 'draft'" => ['alice', ['_drafts' => true]], + ]; + foreach ($calls as $expected => [$userId, $query]) { + $this->functions = []; + $handler = $this->handler(userId: $userId); + $queryBuilder = (new ReflectionClass(MagicSearchHandler::class))->getProperty('db')->getValue($handler)->getQueryBuilder(); + $apply->invoke($handler, $queryBuilder, false, MagicSearchHandler::ARCHIVED_EXCLUDE, $query); + + $this->assertSame([$expected], $this->functions); + } + + $this->functions = []; + $handler = $this->handler(userId: 'alice'); + $queryBuilder = (new ReflectionClass(MagicSearchHandler::class))->getProperty('db')->getValue($handler)->getQueryBuilder(); + $apply->invoke($handler, $queryBuilder, false, MagicSearchHandler::ARCHIVED_EXCLUDE, ['_drafts' => 'any']); + $this->assertSame([], $this->functions); + }//end testTheSingleTablePathCarriesTheSameLens() + + /** + * `_drafts` is reserved, so it is never read as a filter on a property called "drafts" (which answers `1 = 0`). + */ + public function testDraftsIsAReservedParameter(): void { + $method = (new ReflectionClass(MagicSearchHandler::class))->getMethod('getReservedParams'); + $method->setAccessible(true); + + $this->assertContains('_drafts', $method->invoke($this->handler(userId: null))); + }//end testDraftsIsAReservedParameter() +}//end class diff --git a/tests/Unit/Service/Object/DraftFinderTest.php b/tests/Unit/Service/Object/DraftFinderTest.php index becf9e5def..d4d3d8acd8 100644 --- a/tests/Unit/Service/Object/DraftFinderTest.php +++ b/tests/Unit/Service/Object/DraftFinderTest.php @@ -133,6 +133,7 @@ public function testNewestPicksTheOwnersNewestMatchingDraft(): void { $this->assertSame('new', $newest?->getUuid()); $this->assertSame(['register' => '10', 'schema' => '1', 'owner' => 'portal:subject-1'], $this->searches[0]['query']['@self']); $this->assertSame(DraftFinder::SEARCH_LIMIT, $this->searches[0]['query']['_limit']); + $this->assertSame('true', $this->searches[0]['query']['_drafts'], 'A list hides drafts unless asked (decision 201); the finder asks for drafts alone.'); $this->assertFalse($this->searches[0]['rbac']); $this->assertFalse($this->searches[0]['multitenancy']); $this->assertSame('other-type', $this->finder->newest(register: $this->register, schema: $this->schema, owner: $owner)?->getUuid()); diff --git a/tests/Unit/Service/Object/DraftListLensTest.php b/tests/Unit/Service/Object/DraftListLensTest.php new file mode 100644 index 0000000000..d63f95416a --- /dev/null +++ b/tests/Unit/Service/Object/DraftListLensTest.php @@ -0,0 +1,121 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-list-must-hide-drafts-unless-asked + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use OCA\OpenRegister\Service\Object\DraftListLens; +use PHPUnit\Framework\TestCase; + +/** + * Decision 201: a work list hides drafts by default, with a filter to show them. + * + * @covers \OCA\OpenRegister\Service\Object\DraftListLens + * @uses \OCA\OpenRegister\Service\Object\DraftStatusPolicy + */ +class DraftListLensTest extends TestCase { + + /** + * Nobody asked: drafts are left out. A caller who forgets the parameter gets the work list. + */ + public function testNoParameterHidesDrafts(): void { + $this->assertSame(DraftListLens::EXCLUDE, (new DraftListLens())->modeFor(query: ['_limit' => 20])); + }//end testNoParameterHidesDrafts() + + /** + * is drafts alone, is drafts beside the rest, in any casing. + * + * @param mixed $value What arrives over the wire. + * @param string $expected The lens. + * + * @dataProvider askedFor + */ + public function testTheParameterPicksTheLens(mixed $value, string $expected): void { + $this->assertSame($expected, (new DraftListLens())->modeFor(query: ['_drafts' => $value])); + }//end testTheParameterPicksTheLens() + + /** + * The spellings a query string produces. + * + * An unknown value falls back to hiding: a typo must never widen a list. + * + * @return array The cases. + */ + public static function askedFor(): array { + return [ + 'the string true' => ['true', DraftListLens::ONLY], + 'the boolean' => [true, DraftListLens::ONLY], + 'the digit' => ['1', DraftListLens::ONLY], + 'any' => ['any', DraftListLens::ANY], + 'ANY' => [' ANY ', DraftListLens::ANY], + 'explicit false' => ['false', DraftListLens::EXCLUDE], + 'a typo' => ['al', DraftListLens::EXCLUDE], + 'the empty string' => ['', DraftListLens::EXCLUDE], + 'null' => [null, DraftListLens::EXCLUDE], + 'an array' => [['any'], DraftListLens::EXCLUDE], + ]; + }//end askedFor() + + /** + * A caller that filters on the stored status has said which statuses it wants; the default steps aside. + * + * Without this, `@self[status]=draft` would answer the caller's own drafts + * only, and say nothing about the rest it was allowed to see. + */ + public function testAnExplicitStatusFilterDecides(): void { + $lens = new DraftListLens(); + + $this->assertSame(DraftListLens::ANY, $lens->modeFor(query: ['@self' => ['status' => 'draft']])); + $this->assertSame(DraftListLens::ANY, $lens->modeFor(query: ['@self' => ['status' => ['in' => ['draft', 'active']]]])); + $this->assertSame(DraftListLens::EXCLUDE, $lens->modeFor(query: ['@self' => ['owner' => 'alice']]), 'Another metadata filter is not a status filter.'); + $this->assertSame(DraftListLens::EXCLUDE, $lens->modeFor(query: ['@self' => ['status' => '']]), 'An empty filter asks for nothing.'); + $this->assertSame(DraftListLens::ONLY, $lens->modeFor(query: ['_drafts' => 'true', '@self' => ['status' => 'active']]), 'The parameter, when sent, wins.'); + }//end testAnExplicitStatusFilterDecides() + + /** + * The default clause keeps rows that are not drafts, legacy rows without a status included. + */ + public function testHidingKeepsEverythingThatIsNotADraft(): void { + $this->assertSame( + "(t._status IS NULL OR t._status <> 'draft')", + (new DraftListLens())->sql(mode: DraftListLens::EXCLUDE, columnPrefix: 't.', owner: null) + ); + }//end testHidingKeepsEverythingThatIsNotADraft() + + /** + * The owner still sees their own drafts: the clause admits the caller's rows, drafts or not. + */ + public function testTheOwnerKeepsTheirOwnDrafts(): void { + $this->assertSame( + "((_status IS NULL OR _status <> 'draft') OR _owner = 'alice')", + (new DraftListLens())->sql(mode: DraftListLens::EXCLUDE, columnPrefix: '', owner: "'alice'") + ); + $this->assertSame( + "((t._status IS NULL OR t._status <> 'draft') OR t._owner = :dcValue1)", + (new DraftListLens())->sql(mode: DraftListLens::EXCLUDE, columnPrefix: 't.', owner: ':dcValue1'), + 'The owner is whatever the emitter quoted or bound; the lens never quotes.' + ); + }//end testTheOwnerKeepsTheirOwnDrafts() + + /** + * Drafts alone, and no clause at all when both were asked for. + * + * The second is the control: a lens that always emitted a clause would + * pass the two tests above and still hide drafts from a caller who asked. + */ + public function testOnlyAndAny(): void { + $lens = new DraftListLens(); + + $this->assertSame("t._status = 'draft'", $lens->sql(mode: DraftListLens::ONLY, columnPrefix: 't.', owner: "'alice'")); + $this->assertNull($lens->sql(mode: DraftListLens::ANY, columnPrefix: 't.', owner: "'alice'")); + }//end testOnlyAndAny() +}//end class From a9279e538a9f77599fbdeaa4b4830f9140d2d000 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 07:49:13 +0200 Subject: [PATCH 093/183] style: prettier on App.vue, BrowserNotificationsSection.vue, UserSettings.vue (Frontend Check format) --- src/App.vue | 8 ++++++-- .../userSettings/BrowserNotificationsSection.vue | 6 +++++- src/views/settings/UserSettings.vue | 4 +--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/App.vue b/src/App.vue index 8d42c8e048..f29f0e648a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -58,8 +58,12 @@ + @continueWalkthrough=" + appRoot() && appRoot().continueWalkthroughFromSettings() + " + @restartWalkthrough=" + appRoot() && appRoot().restartWalkthroughFromSettings() + " /> + + diff --git a/src/components/migrationPack/PackPreviewPanel.vue b/src/components/migrationPack/PackPreviewPanel.vue new file mode 100644 index 0000000000..13a35801eb --- /dev/null +++ b/src/components/migrationPack/PackPreviewPanel.vue @@ -0,0 +1,327 @@ + + + + + + diff --git a/src/components/migrationPack/TransformDialog.vue b/src/components/migrationPack/TransformDialog.vue new file mode 100644 index 0000000000..bfd33055d8 --- /dev/null +++ b/src/components/migrationPack/TransformDialog.vue @@ -0,0 +1,542 @@ + + + + + + diff --git a/src/icons.js b/src/icons.js index ec5b217b33..a50e2ceea9 100644 --- a/src/icons.js +++ b/src/icons.js @@ -47,6 +47,7 @@ import ShieldAccountOutline from 'vue-material-design-icons/ShieldAccountOutline import ShieldLockOutline from 'vue-material-design-icons/ShieldLockOutline.vue' import SitemapOutline from 'vue-material-design-icons/SitemapOutline.vue' import StoreOutline from 'vue-material-design-icons/StoreOutline.vue' +import SwapHorizontal from 'vue-material-design-icons/SwapHorizontal.vue' import TextBoxOutline from 'vue-material-design-icons/TextBoxOutline.vue' import TrayFull from 'vue-material-design-icons/TrayFull.vue' import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue' @@ -88,6 +89,7 @@ export default { ShieldLockOutline, SitemapOutline, StoreOutline, + SwapHorizontal, TextBoxOutline, TrayFull, ViewDashboardOutline, diff --git a/src/manifest.json b/src/manifest.json index 127525c13d..af8f7fffe8 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -339,6 +339,24 @@ "component": "IntegrationsView", "_note": "Standalone integration-registry surface for the per-leaf screenshot harness; bypasses object-detail so sub-resource plugins do not race the render." }, + { + "id": "migration-packs", + "route": "/migration-packs", + "type": "custom", + "title": "Migration packs", + "component": "MigrationPacksIndex", + "permission": "admin", + "_note": "The column mappings an import runs (migration-pack-editor-ui). A migration pack is an OpenRegister foundation entity served by its own controller (/api/migration-packs), not a register-stored object, so the built-in index renderer (useObjectStore by register+schema slug) cannot resolve it, and no named entity source exists for it. `permission` is documentation only: OpenRegister passes no permissions list to CnAppRoot, so the page itself shows read-only for a non-admin and the menu entry is gated on runtime.user.isAdmin." + }, + { + "id": "migration-pack-detail", + "route": "/migration-packs/:id", + "type": "custom", + "title": "Migration pack", + "component": "MigrationPackDetail", + "permission": "admin", + "_note": "The editor of one migration pack (migration-pack-editor-ui): an ordered field-mapping table with per-row transform dialogs, an Advanced (JSON) view and a sample-row test over /api/migration-packs/preview. Not a register object, so the built-in detail renderer has nothing to load, and its nested fieldMappings with transforms are not expressible as a schema form." + }, { "id": "connections", "route": "/settings/connections", @@ -570,6 +588,17 @@ "visibleIf": { "appInstalled": "integriq" } + }, + { + "id": "MigrationPacks", + "label": "Migration packs", + "icon": "SwapHorizontal", + "route": "migration-packs", + "order": 97, + "permission": "admin", + "visibleIf": { + "user.isAdmin": true + } } ] }, diff --git a/src/menu-layout.json b/src/menu-layout.json index 0bf6bb658e..2860abab13 100644 --- a/src/menu-layout.json +++ b/src/menu-layout.json @@ -33,6 +33,7 @@ "Endpoints", "Webhooks", "Connections", + "MigrationPacks", "Configurations" ] } diff --git a/src/navigation/MainMenu.vue b/src/navigation/MainMenu.vue index 5ee31c75f8..1f2f2b5b5b 100644 --- a/src/navigation/MainMenu.vue +++ b/src/navigation/MainMenu.vue @@ -1,5 +1,5 @@ @@ -208,7 +180,6 @@ import { translate as t } from '@nextcloud/l10n' import { NcAppContent, NcButton, - NcDialog, NcEmptyContent, NcLoadingIcon, NcNoteCard, @@ -218,6 +189,7 @@ import Eye from 'vue-material-design-icons/Eye.vue' import Pencil from 'vue-material-design-icons/Pencil.vue' import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue' import Upload from 'vue-material-design-icons/Upload.vue' +import DeleteMigrationPackDialog from '../../dialogs/migrationPack/DeleteMigrationPackDialog.vue' import { canEditPacks, deletePack, @@ -230,11 +202,11 @@ import { export default { name: 'MigrationPacksIndex', components: { + DeleteMigrationPackDialog, Download, Eye, NcAppContent, NcButton, - NcDialog, NcEmptyContent, NcLoadingIcon, NcNoteCard, From 7661a904dfb2f82f77ff8493910f3278a5b0ae84 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 20:17:54 +0200 Subject: [PATCH 114/183] fix(l10n): add the migration-pack manifest titles to nl.json "Migration pack" and "Migration packs" are the manifest's page titles and menu label. nl.js already carried them; nl.json, which the manifest l10n check reads, did not. Co-Authored-By: Claude Opus 5.5 (1M context) --- l10n/nl.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/l10n/nl.json b/l10n/nl.json index 18ba853c3c..5219614011 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -3442,7 +3442,9 @@ "Sample rows must be a list of objects": "Voorbeeldrijen moeten een lijst van objecten zijn", "A preview takes at most %s sample rows": "Een voorvertoning neemt maximaal %s voorbeeldrijen aan", "The pack definition is invalid": "De pakketdefinitie is ongeldig", - "Failed to preview migration pack": "De voorvertoning van het migratiepakket is mislukt" + "Failed to preview migration pack": "De voorvertoning van het migratiepakket is mislukt", + "Migration pack": "Migratiepakket", + "Migration packs": "Migratiepakketten" }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { From 6233a1b4a0eb815db8eece38e0c302c3eb040cc6 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 20:17:54 +0200 Subject: [PATCH 115/183] test(e2e): drive the migration pack list and editor in the browser A CI spec for the migration-packs and migration-pack-detail pages: an administrator sees the list with a pack the spec created and opens it in the editor, the field-mapping table shows the pack's rows in order, and the seeded non-admin reads the pack by URL with no Save action. The pack is created through the admin API and deleted in afterAll. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/ci/migration-pack-pages.spec.ts | 281 ++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 tests/e2e/ci/migration-pack-pages.spec.ts diff --git a/tests/e2e/ci/migration-pack-pages.spec.ts b/tests/e2e/ci/migration-pack-pages.spec.ts new file mode 100644 index 0000000000..9631ed1eda --- /dev/null +++ b/tests/e2e/ci/migration-pack-pages.spec.ts @@ -0,0 +1,281 @@ +import type { APIRequestContext, Page } from '@playwright/test' + +/* + * SPDX-FileCopyrightText: 2026 Open Register Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * THE MIGRATION PACK PAGES, driven through the browser. + * + * Two manifest pages: `migration-packs` (MigrationPacksIndex, the list) and + * `migration-pack-detail` (MigrationPackDetail, the editor). The Jest specs in + * src/views/migrationPack/ drive their options objects against a synthetic + * `this`, so none of them can fail when a page never renders: a route missing + * from the manifest, a component missing from the registry, or a pack that + * never reaches the mapping table all leave every unit test green. + * + * @e2e openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#scenario-a-non-admin-who-opens-the-editor-by-url-can-only-read + * + * WHAT THIS FILE PROVES. + * + * - an administrator sees the list, with the pack this file created in it; + * - clicking that pack opens the editor on it; + * - the editor's field-mapping table shows the pack's rows, in their order; + * - an ordinary user who opens the editor by URL reads the same pack, with a + * note that only administrators change packs and no Save action. + * + * The browser identity is asserted on every page, not assumed: the config + * authenticates page loads as admin, and an override that stopped working + * would turn the non-admin case into an admin case that still passes. + * + * HERMETIC. It creates its own pack through the admin API and deletes it in + * `afterAll`. The ordinary user is `e2e-other`, provisioned by the workflow's + * `playwright-seed-command` (tests/e2e/ci/seed.sh), as in the sharing specs. + */ +import { expect, request as pwRequest, test } from '@playwright/test' +import { resolveBaseUrl } from '../base-url.ts' + +const BASE = resolveBaseUrl() +const ADMIN = process.env.ADMIN_USER || process.env.OR_USER || 'admin' +const ADMIN_PASS = process.env.ADMIN_PASSWORD || process.env.OR_PASS || 'admin' + +/** Seeded non-admin account, see tests/e2e/ci/seed.sh. */ +const OTHER = 'e2e-other' +const OTHER_PASS = 'E2e-Share-Pass-123' + +/** A short unique suffix so a re-run against the same instance never collides. */ +const RUN = Math.random().toString(36).slice(2, 10) + +const PACK_NAME = `e2e migration pack ${RUN}` + +/** The rows the editor must show, in this order. */ +const MAPPINGS = [ + { source: 'Naam', target: 'name', required: true, transform: { type: 'trim' } }, + { source: 'Omschrijving', target: 'description' }, + { + source: 'Status', + target: 'status', + transform: { + type: 'lookup', + map: { Actief: 'Active', Inactief: 'Inactive' }, + }, + }, +] + +const PACK_DEFINITION = { + id: `e2e-migration-pack-${RUN}`, + name: PACK_NAME, + description: 'Created by tests/e2e/ci/migration-pack-pages.spec.ts.', + sourceFormat: 'excel', + version: '1.0.0', + fieldMappings: MAPPINGS, + defaults: { type: 'Application' }, + idStrategy: { type: 'generate' }, +} + +/** Build an API context authenticated as one user. */ +async function contextFor( + user: string, + password: string, +): Promise { + return pwRequest.newContext({ + baseURL: BASE, + extraHTTPHeaders: { + Authorization: `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`, + 'OCS-APIRequest': 'true', + Accept: 'application/json', + }, + }) +} + +/** + * Close any modal covering the page, the way a user does. + * + * On the first app load of a fresh instance nc-vue's support dialog opens + * over everything and its mask swallows clicks. Fails loudly when a mask + * survives three Escapes, instead of clicking into it. + */ +async function dismissBlockingModals(page: Page): Promise { + const mask = page.locator('.modal-mask:visible') + + for (let attempt = 0; attempt < 3; attempt++) { + if ((await mask.count()) === 0) { + return + } + await page.keyboard.press('Escape') + await page.waitForTimeout(300) + } + + await expect( + mask, + 'a modal is still covering the page after three Escapes', + ).toHaveCount(0) +} + +/** Assert who the browser is, so a lost header override cannot go unnoticed. */ +async function expectSignedInAs(page: Page, uid: string): Promise { + const current = await page.evaluate( + () => (window as any).OC?.getCurrentUser?.()?.uid ?? null, + ) + expect(current, `the browser is not signed in as ${uid}`).toBe(uid) +} + +/** The rows of the editor's field-mapping table. */ +function mappingRows(page: Page) { + return page.locator( + 'section[aria-labelledby="migration-pack-mappings"] tbody tr', + ) +} + +let admin: APIRequestContext +let packId: string + +test.beforeAll(async () => { + admin = await contextFor(ADMIN, ADMIN_PASS) + + const other = await contextFor(OTHER, OTHER_PASS) + const probe = await other.get('/index.php/apps/openregister/api/migration-packs') + expect( + probe.status(), + `seeded account '${OTHER}' cannot read the packs (${probe.status()}), did playwright-seed-command run?`, + ).toBeLessThan(400) + await other.dispose() + + const created = await admin.post( + '/index.php/apps/openregister/api/migration-packs', + { data: { definition: PACK_DEFINITION } }, + ) + expect(created.status(), `pack create failed: ${await created.text()}`).toBe(201) + packId = String((await created.json()).id) + expect(packId, 'no id came back from the pack create').toMatch(/^\d+$/) +}) + +test.afterAll(async () => { + if (packId) { + await admin + .delete(`/index.php/apps/openregister/api/migration-packs/${packId}`) + .catch(() => undefined) + } + await admin?.dispose() +}) + +test.describe('migration packs as an administrator', () => { + test('the list renders with the pack in it, and the pack opens in the editor', async ({ + page, + }) => { + await page.goto('/index.php/apps/openregister/migration-packs', { + waitUntil: 'domcontentloaded', + }) + + await expect( + page.getByRole('heading', { + level: 2, + name: 'Migration packs', + exact: true, + }), + 'MigrationPacksIndex did not render its heading', + ).toBeVisible({ timeout: 30_000 }) + await expectSignedInAs(page, ADMIN) + await dismissBlockingModals(page) + + const table = page.locator('table.migration-packs__table') + await expect( + table.getByRole('columnheader', { name: 'Slug', exact: true }), + ).toBeVisible() + const link = table.getByRole('link', { name: PACK_NAME, exact: true }) + await expect(link, 'the created pack is not in the list').toBeVisible() + await expect(table).toContainText(PACK_DEFINITION.id) + await expect( + page.getByRole('button', { name: 'Import JSON', exact: true }), + ).toBeVisible() + + await link.click() + + await expect(page).toHaveURL( + new RegExp(`/apps/openregister/migration-packs/${packId}$`), + ) + await expect( + page.getByRole('heading', { level: 2, name: PACK_NAME, exact: true }), + 'MigrationPackDetail did not open the clicked pack', + ).toBeVisible({ timeout: 30_000 }) + }) + + test('the field-mapping table shows every row of the pack, in order', async ({ + page, + }) => { + await page.goto(`/index.php/apps/openregister/migration-packs/${packId}`, { + waitUntil: 'domcontentloaded', + }) + await expect( + page.getByRole('heading', { + level: 3, + name: 'Field mappings', + exact: true, + }), + ).toBeVisible({ timeout: 30_000 }) + await expectSignedInAs(page, ADMIN) + await dismissBlockingModals(page) + + await expect(mappingRows(page)).toHaveCount(MAPPINGS.length) + for (const [index, mapping] of MAPPINGS.entries()) { + const number = index + 1 + await expect( + page.getByRole('textbox', { + name: `Source column of row ${number}`, + }), + ).toHaveValue(mapping.source) + await expect( + page.getByRole('textbox', { name: `Target field of row ${number}` }), + ).toHaveValue(mapping.target) + } + await expect( + page.getByRole('checkbox', { name: 'Row 1 is required' }), + ).toBeChecked() + await expect( + page.getByRole('textbox', { name: 'Source column of row 1' }), + ).toBeEnabled() + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + ).toBeVisible() + }) +}) + +test.describe('migration packs as an ordinary user', () => { + /* + * Overrides the config's admin header for every request this page makes, + * page loads included, the same way object-shares-tab.spec.ts does. + */ + test.use({ + extraHTTPHeaders: { + Authorization: `Basic ${Buffer.from(`${OTHER}:${OTHER_PASS}`).toString('base64')}`, + 'OCS-APIRequest': 'true', + }, + }) + + test('the editor opened by URL reads the pack and offers no Save', async ({ + page, + }) => { + await page.goto(`/index.php/apps/openregister/migration-packs/${packId}`, { + waitUntil: 'domcontentloaded', + }) + await expect( + page.getByRole('heading', { level: 2, name: PACK_NAME, exact: true }), + 'a non-admin should still read the pack', + ).toBeVisible({ timeout: 30_000 }) + await expectSignedInAs(page, OTHER) + await dismissBlockingModals(page) + + await expect( + page.getByText( + 'Only administrators can change migration packs. You can read this one.', + ), + ).toBeVisible() + await expect(mappingRows(page)).toHaveCount(MAPPINGS.length) + await expect( + page.getByRole('textbox', { name: 'Source column of row 1' }), + ).toBeDisabled() + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + 'a non-admin must not be offered Save', + ).toHaveCount(0) + }) +}) From 185f58d9b131596cc9ecd8b20f5da0046b26fdeb Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 20:17:54 +0200 Subject: [PATCH 116/183] docs(migration-packs): say why both pages stay custom, and point tasks at the moved files The _notes now name what a typed page would need: nextcloud-vue 2.72.0 keeps its named index sources (flows, tasks) in a registry inside the library that an app cannot extend, and CnDetailPage only loads a register object. tasks.md lists the dialogs at their new paths and nl.json among the translated files. Co-Authored-By: Claude Opus 5.5 (1M context) --- openspec/changes/migration-pack-editor-ui/tasks.md | 6 +++--- src/manifest.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openspec/changes/migration-pack-editor-ui/tasks.md b/openspec/changes/migration-pack-editor-ui/tasks.md index 97d48d73a7..0adfdab227 100644 --- a/openspec/changes/migration-pack-editor-ui/tasks.md +++ b/openspec/changes/migration-pack-editor-ui/tasks.md @@ -38,12 +38,12 @@ Playwright scenarios are open. - [x] 3.1 Migration packs list: name, slug, source format, version, built-in, updated; open, export, delete with confirmation (built-in warning), import JSON. - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` - - files: `src/views/migrationPack/MigrationPacksIndex.vue`, `src/services/migrationPacks.js` + - files: `src/views/migrationPack/MigrationPacksIndex.vue`, `src/dialogs/migrationPack/DeleteMigrationPackDialog.vue`, `src/services/migrationPacks.js` - [x] 3.2 Editor: general card (slug read-only when built-in), ordered mapping table with add, remove and move, defaults, rows to skip, Advanced (JSON) view, save with `{definition}`, patch version raised, readable 422, refusal when someone else saved first (D-8, D-11). - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` - files: `src/views/migrationPack/MigrationPackDetail.vue`, `src/components/migrationPack/KeyValueRows.vue` - [x] 3.3 Transform dialog for every validator type, the three-way choice for other values, unknown keys and types named and kept. - - files: `src/components/migrationPack/TransformDialog.vue` + - files: `src/dialogs/migrationPack/TransformDialog.vue` - [x] 3.4 Sample-row test over the preview endpoint, 400 ms after the last change, older answers dropped. - spec_ref: `migration-mapping-packs#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it` - files: `src/components/migrationPack/PackPreviewPanel.vue` @@ -56,7 +56,7 @@ Playwright scenarios are open. ## 4. Translations and docs - [x] 4.1 English and Dutch for every new string. - - files: `l10n/en.js`, `l10n/nl.js` + - files: `l10n/en.js`, `l10n/nl.js`, `l10n/nl.json` (the manifest titles and menu label, which the manifest l10n gate reads there) - [ ] 4.2 The other 35 required locales, so `npm run test:l10n:parity` passes. - [x] 4.3 User documentation of the screens. - files: `docs/features/migration-packs.md` diff --git a/src/manifest.json b/src/manifest.json index af8f7fffe8..1c7cd25c06 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -346,7 +346,7 @@ "title": "Migration packs", "component": "MigrationPacksIndex", "permission": "admin", - "_note": "The column mappings an import runs (migration-pack-editor-ui). A migration pack is an OpenRegister foundation entity served by its own controller (/api/migration-packs), not a register-stored object, so the built-in index renderer (useObjectStore by register+schema slug) cannot resolve it, and no named entity source exists for it. `permission` is documentation only: OpenRegister passes no permissions list to CnAppRoot, so the page itself shows read-only for a non-admin and the menu entry is gated on runtime.user.isAdmin." + "_note": "The column mappings an import runs (migration-pack-editor-ui). A migration pack is an OpenRegister foundation entity served by its own controller (/api/migration-packs), not a register-stored object, so the built-in index renderer (useObjectStore by register+schema slug) cannot resolve it. A named entity source (config.entitySource, as the flows page uses) would close that, but in nextcloud-vue 2.72.0 the sources are a fixed registry inside the library (indexSources.js: flows, tasks) that an app cannot add to; a migrationPacks source there is what would let this page become type:index. `permission` is documentation only: OpenRegister passes no permissions list to CnAppRoot, so the page itself shows read-only for a non-admin and the menu entry is gated on runtime.user.isAdmin." }, { "id": "migration-pack-detail", @@ -355,7 +355,7 @@ "title": "Migration pack", "component": "MigrationPackDetail", "permission": "admin", - "_note": "The editor of one migration pack (migration-pack-editor-ui): an ordered field-mapping table with per-row transform dialogs, an Advanced (JSON) view and a sample-row test over /api/migration-packs/preview. Not a register object, so the built-in detail renderer has nothing to load, and its nested fieldMappings with transforms are not expressible as a schema form." + "_note": "The editor of one migration pack (migration-pack-editor-ui): an ordered field-mapping table with per-row transform dialogs, an Advanced (JSON) view and a sample-row test over /api/migration-packs/preview. Not a register object, so the built-in detail renderer (CnDetailPage, which loads one object by register+schema) has nothing to load and has no named-source mode in nextcloud-vue 2.72.0, and its ordered fieldMappings with per-row transforms are not expressible as a schema form." }, { "id": "connections", From 99c6e4b055616f4550072a306fcbb6392b4d6cd2 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 20:21:05 +0200 Subject: [PATCH 117/183] fix(l10n): translate the migration-pack strings into every locale Co-Authored-By: Claude Opus 5.5 (1M context) --- l10n/be.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/bg.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/bs.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/ca.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/cs.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/da.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/de.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/el.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/es.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/et.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/fi.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/fr.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/ga.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/hr.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/hu.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/is.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/it.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/lb.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/lt.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/lv.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/mk.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/mt.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/nb.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/pl.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/pt.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/rm.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/ro.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/ru.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/sk.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/sl.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/sq.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/sr.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/sv.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/tr.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- l10n/uk.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 35 files changed, 4900 insertions(+), 35 deletions(-) diff --git a/l10n/be.js b/l10n/be.js index 256511b962..97c7ebb5d0 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Адсочвае", "{completed} of {total} completed": "Завершана {completed} з {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "З памылкай: {failed}, спынена: {stopped}" + "{failed} failed, {stopped} stopped": "З памылкай: {failed}, спынена: {stopped}", + "A column to join has no name.": "Слупок для аб'яднання не мае назвы.", + "A default value has no target field.": "Значэнне па змаўчанні не мае мэтавага поля.", + "A fixed value is written even when the column is empty.": "Фіксаванае значэнне запісваецца, нават калі слупок пусты.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Пакет міграцыі вызначае, які слупок файла імпарту становіцца якім полем і як яго значэнне пераўтвараецца па дарозе.", + "A pack definition is a JSON object.": "Вызначэнне пакета — гэта аб'ект JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Фармат даты PHP, напрыклад d-m-Y. Пачніце яго з !, каб часткі, якія фармат не называе, не браліся з сённяшняй даты. Пакіньце пустым, каб чытаць любую звычайную дату.", + "Add a field mapping with a source column to test it.": "Дадайце супастаўленне поля са слупком крыніцы, каб праверыць яго.", + "Add at least one column to join.": "Дадайце хаця б адзін слупок для аб'яднання.", + "Add at least one field mapping.": "Дадайце хаця б адно супастаўленне поля.", + "Add at least one value to the table.": "Дадайце ў табліцу хаця б адно значэнне.", + "Add column": "Дадаць слупок", + "Add default": "Дадаць значэнне па змаўчанні", + "Add field mapping": "Дадаць супастаўленне поля", + "Add value": "Дадаць значэнне", + "Advanced (JSON)": "Пашыраныя (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Праграма, якая імпартуе з гэтым пакетам, можа дадаваць уласныя правілы, напрыклад, якія слупкі змяшчаюць даты або з якога аркуша паходзіць радок. Праверце гэтую праграму, перш чым змяняць пакет, які яна ўсталявала.", + "any format": "любы фармат", + "As JSON": "Як JSON", + "Built-in": "Убудаваны", + "Choose a source format.": "Выберыце фармат крыніцы.", + "Column to join {number}": "Слупок для аб'яднання {number}", + "Column {source} to field {target}": "Слупок {source} у поле {target}", + "Copied as is": "Скапіявана без змен", + "Could not delete the migration pack.": "Не ўдалося выдаліць пакет міграцыі.", + "Could not import the file.": "Не ўдалося імпартаваць файл.", + "Could not load the migration pack.": "Не ўдалося загрузіць пакет міграцыі.", + "Could not load the migration packs.": "Не ўдалося загрузіць пакеты міграцыі.", + "Could not save the migration pack.": "Не ўдалося захаваць пакет міграцыі.", + "Date {from} to {to}": "Дата з фармату {from} у {to}", + "Defaults": "Значэнні па змаўчанні", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Выдаліць \"{name}\"? Імпарты, якія выкарыстоўваюць гэты пакет, больш не знойдуць яго.", + "Delete migration pack": "Выдаліць пакет міграцыі", + "Delete {name}": "Выдаліць {name}", + "Deleted \"{name}\".": "\"{name}\" выдалены.", + "Edit {name}": "Рэдагаваць {name}", + "Export {name}": "Экспартаваць {name}", + "Field mappings": "Супастаўленні палёў", + "Fields the import would write": "Палі, якія запісаў бы імпарт", + "Fix the problems in the pack first; then it can be tested.": "Спачатку выпраўце праблемы ў пакеце; потым яго можна будзе праверыць.", + "Fixed value": "Фіксаванае значэнне", + "Fixed value {value}": "Фіксаванае значэнне {value}", + "Form": "Форма", + "General": "Агульныя", + "Generate a new id": "Стварыць новы ідэнтыфікатар", + "Id column": "Слупок ідэнтыфікатара", + "Import JSON": "Імпартаваць JSON", + "Imported \"{name}\".": "\"{name}\" імпартаваны.", + "Imports find the pack by this slug.": "Імпарты знаходзяць пакет па гэтым слагу.", + "Join columns": "Аб'яднаць слупкі", + "Joined with {fields}, separated by \"{separator}\"": "Аб'яднана з {fields}, раздзяляльнік \"{separator}\"", + "Leave the field empty": "Пакінуць поле пустым", + "Lookup table": "Табліца адпаведнасці", + "Lookup table, values: {count}, {unmapped}": "Табліца адпаведнасці, значэнняў: {count}, {unmapped}", + "Migration pack": "Пакет міграцыі", + "Migration packs": "Пакеты міграцыі", + "Move row {number} down": "Перамясціць радок {number} уніз", + "Move row {number} up": "Перамясціць радок {number} уверх", + "Name the column that holds the id.": "Укажыце слупок, які змяшчае ідэнтыфікатар.", + "No defaults.": "Няма значэнняў па змаўчанні.", + "No fields: every mapping was empty or failed.": "Няма палёў: кожнае супастаўленне было пустым або не ўдалося.", + "No migration packs yet": "Пакетаў міграцыі пакуль няма", + "None: copy the value as is": "Няма: скапіяваць значэнне без змен", + "Object id": "Ідэнтыфікатар аб'екта", + "Only administrators can change migration packs.": "Толькі адміністратары могуць змяняць пакеты міграцыі.", + "Only administrators can change migration packs. You can read this one.": "Толькі адміністратары могуць змяняць пакеты міграцыі. Вы можаце прачытаць гэты пакет.", + "other values are an error": "іншыя значэнні выклікаюць памылку", + "other values get a default": "іншыя значэнні атрымліваюць значэнне па змаўчанні", + "other values stay empty": "іншыя значэнні застаюцца пустымі", + "Problem": "Праблема", + "Problems in this row": "Праблемы ў гэтым радку", + "Reload the pack": "Перазагрузіць пакет", + "Remove column {number}": "Выдаліць слупок {number}", + "Remove row {number}": "Выдаліць радок {number}", + "Report an error for the row": "Паведаміць пра памылку ў радку", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Нумары радкоў у крыніцы праз коску. Радок 1 — першы радок з данымі.", + "Row {number} has no source column.": "Радок {number} не мае слупка крыніцы.", + "Row {number} has no target field.": "Радок {number} не мае мэтавага поля.", + "Row {number} is required": "Радок {number} абавязковы", + "Row {number}: {message}": "Радок {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Радкі выконваюцца зверху ўніз. Калі два радкі запісваюць адно і тое ж поле і абодва слупкі маюць значэнне, перамагае ніжні радок.", + "Rows to skip": "Радкі для пропуску", + "Rows to skip must be whole numbers from 1 up: {values}": "Радкі для пропуску павінны быць цэлымі лікамі ад 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Радкі {rows} запісваюць гэтае поле; перамагае самы ніжні радок са значэннем.", + "Sample value": "Узорнае значэнне", + "Sample value for {column}": "Узорнае значэнне для {column}", + "Saved as version {version}.": "Захавана як версія {version}.", + "Saving raises the version to {version}.": "Захаванне павышае версію да {version}.", + "Separator": "Раздзяляльнік", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Нехта іншы захаваў гэты пакет пасля таго, як вы яго адкрылі. Перазагрузіце яго, каб убачыць іх змены; вашы змены не захаваны.", + "Source column": "Слупок крыніцы", + "Source column of row {number}": "Слупок крыніцы радка {number}", + "Source date format": "Фармат даты ў крыніцы", + "Source format": "Фармат крыніцы", + "Store this value": "Захаваць гэтае значэнне", + "Stored date format": "Фармат захаванай даты", + "Stored value": "Захаванае значэнне", + "Take the id from a column": "Узяць ідэнтыфікатар са слупка", + "Target field": "Мэтавае поле", + "Target field of row {number}": "Мэтавае поле радка {number}", + "Test with a sample row": "Праверыць на ўзорным радку", + "The default for \"{key}\" is not valid JSON.": "Значэнне па змаўчанні для \"{key}\" не з'яўляецца сапраўдным JSON.", + "The default for \"{key}\" is set twice.": "Значэнне па змаўчанні для \"{key}\" зададзена двойчы.", + "The fixed value is not valid JSON.": "Фіксаванае значэнне не з'яўляецца сапраўдным JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Форма мае значэнне, якое не з'яўляецца сапраўдным JSON. Спачатку выпраўце яго.", + "The name is required.": "Назва абавязковая.", + "The pack cannot be saved yet.": "Пакет пакуль нельга захаваць.", + "The pack is not valid.": "Пакет несапраўдны.", + "The result for \"{key}\" is not valid JSON.": "Вынік для \"{key}\" не з'яўляецца сапраўдным JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Слаг павінен пачынацца з малой літары або лічбы і змяшчаць толькі малыя літары, лічбы і злучкі.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Слаг убудаванага пакета нельга змяніць. Пакіньце \"id\" роўным {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Слаг убудаванага пакета нельга змяніць: праграма, якая яго ўсталявала, знаходзіць пакет па гэтым слагу.", + "The test could not run.": "Не ўдалося запусціць праверку.", + "The value \"{key}\" is in the table twice.": "Значэнне \"{key}\" ёсць у табліцы двойчы.", + "The value for other values is not valid JSON.": "Значэнне для іншых значэнняў не з'яўляецца сапраўдным JSON.", + "The value of column {source} comes first, then these columns in order.": "Спачатку ідзе значэнне слупка {source}, потым гэтыя слупкі па парадку.", + "The version must look like 1.0.0 (major.minor.patch).": "Версія павінна выглядаць як 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Поўнае вызначэнне пакета. Змены тут замяняюць форму, калі вы вернецеся да яе або захаваеце.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Гэты рэдактар не ведае тыпу пераўтварэння \"{type}\". Ён застаецца такім, як быў захаваны.", + "This is not valid JSON: {reason}": "Гэта несапраўдны JSON: {reason}", + "This migration pack does not exist.": "Гэтага пакета міграцыі не існуе.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Гэты пакет усталявала праграма. Яна ўсталюе яго зноў, без змен, калі наступны раз будзе ўсталявана або абноўлена.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Гэта пераўтварэнне таксама мае налады, якія гэты рэдактар не паказвае ({keys}). Яны захоўваюцца, калі вы захоўваеце.", + "Transform": "Пераўтварэнне", + "Transform \"{type}\" (kept as stored)": "Пераўтварэнне \"{type}\" (захавана як ёсць)", + "Transform for row {number}": "Пераўтварэнне для радка {number}", + "Transform of row {number}": "Пераўтварэнне радка {number}", + "Trim spaces": "Абрэзаць прабелы па краях", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Увядзіце значэнні так, як яны выглядаюць у крыніцы. Вынік выкарыстоўвае супастаўленне вышэй, уключаючы незахаваныя змены.", + "Undo changes": "Адмяніць змены", + "Value for other values": "Значэнне для іншых значэнняў", + "Value in the source": "Значэнне ў крыніцы", + "Value {number} is JSON": "Значэнне {number} — JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Значэнні, з якіх пачынаецца кожны імпартаваны аб'ект. Супастаўленне, якое дае полю значэнне, замяняе яго значэнне па змаўчанні.", + "Values that are not in the table": "Значэнні, якіх няма ў табліцы", + "View {name}": "Праглядзець {name}", + "Yes/no table": "Табліца так/не", + "Yes/no table, values: {count}, {unmapped}": "Табліца так/не, значэнняў: {count}, {unmapped}", + "Store false": "Захаваць не (false)", + "other values are stored as false": "іншыя значэнні захоўваюцца як не (false)" }, "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/bg.js b/l10n/bg.js index 7ff16ac216..22a21dd531 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Наблюдава", "{completed} of {total} completed": "Завършени {completed} от {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Неуспешни: {failed}, спрени: {stopped}" + "{failed} failed, {stopped} stopped": "Неуспешни: {failed}, спрени: {stopped}", + "A column to join has no name.": "Колона за обединяване няма име.", + "A default value has no target field.": "Стойност по подразбиране няма целево поле.", + "A fixed value is written even when the column is empty.": "Фиксирана стойност се записва дори когато колоната е празна.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Миграционният пакет определя коя колона от файла за импортиране става кое поле и как стойността ѝ се преобразува по пътя.", + "A pack definition is a JSON object.": "Дефиницията на пакет е JSON обект.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP формат за дата, например d-m-Y. Започнете го с !, за да не се вземат от днешната дата частите, които форматът не посочва. Оставете го празно, за да се чете всяка обичайна дата.", + "Add a field mapping with a source column to test it.": "Добавете съпоставяне на поле с колона източник, за да го тествате.", + "Add at least one column to join.": "Добавете поне една колона за обединяване.", + "Add at least one field mapping.": "Добавете поне едно съпоставяне на поле.", + "Add at least one value to the table.": "Добавете поне една стойност в таблицата.", + "Add column": "Добавяне на колона", + "Add default": "Добавяне на стойност по подразбиране", + "Add field mapping": "Добавяне на съпоставяне на поле", + "Add value": "Добавяне на стойност", + "Advanced (JSON)": "Разширени (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Приложение, което импортира с този пакет, може да добавя собствени правила, например кои колони съдържат дати или от кой лист идва даден ред. Проверете това приложение, преди да промените пакет, който то е инсталирало.", + "any format": "всякакъв формат", + "As JSON": "Като JSON", + "Built-in": "Вграден", + "Choose a source format.": "Изберете формат на източника.", + "Column to join {number}": "Колона за обединяване {number}", + "Column {source} to field {target}": "Колона {source} към поле {target}", + "Copied as is": "Копира се без промяна", + "Could not delete the migration pack.": "Миграционният пакет не можа да бъде изтрит.", + "Could not import the file.": "Файлът не можа да бъде импортиран.", + "Could not load the migration pack.": "Миграционният пакет не можа да бъде зареден.", + "Could not load the migration packs.": "Миграционните пакети не можаха да бъдат заредени.", + "Could not save the migration pack.": "Миграционният пакет не можа да бъде запазен.", + "Date {from} to {to}": "Дата от {from} към {to}", + "Defaults": "Стойности по подразбиране", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Да се изтрие ли \"{name}\"? Импортиранията, които използват този пакет, вече няма да го намират.", + "Delete migration pack": "Изтриване на миграционен пакет", + "Delete {name}": "Изтриване на {name}", + "Deleted \"{name}\".": "\"{name}\" е изтрит.", + "Edit {name}": "Редактиране на {name}", + "Export {name}": "Експортиране на {name}", + "Field mappings": "Съпоставяния на полета", + "Fields the import would write": "Полета, които импортирането би записало", + "Fix the problems in the pack first; then it can be tested.": "Първо отстранете проблемите в пакета; след това той може да бъде тестван.", + "Fixed value": "Фиксирана стойност", + "Fixed value {value}": "Фиксирана стойност {value}", + "Form": "Формуляр", + "General": "Общи", + "Generate a new id": "Генериране на нов идентификатор", + "Id column": "Колона с идентификатор", + "Import JSON": "Импортиране на JSON", + "Imported \"{name}\".": "\"{name}\" е импортиран.", + "Imports find the pack by this slug.": "Импортиранията намират пакета по този slug.", + "Join columns": "Обединяване на колони", + "Joined with {fields}, separated by \"{separator}\"": "Обединено с {fields}, разделено с \"{separator}\"", + "Leave the field empty": "Оставяне на полето празно", + "Lookup table": "Таблица за съответствие", + "Lookup table, values: {count}, {unmapped}": "Таблица за съответствие, стойности: {count}, {unmapped}", + "Migration pack": "Миграционен пакет", + "Migration packs": "Миграционни пакети", + "Move row {number} down": "Преместване на ред {number} надолу", + "Move row {number} up": "Преместване на ред {number} нагоре", + "Name the column that holds the id.": "Посочете колоната, която съдържа идентификатора.", + "No defaults.": "Няма стойности по подразбиране.", + "No fields: every mapping was empty or failed.": "Няма полета: всяко съпоставяне беше празно или неуспешно.", + "No migration packs yet": "Все още няма миграционни пакети", + "None: copy the value as is": "Без: копиране на стойността без промяна", + "Object id": "Идентификатор на обекта", + "Only administrators can change migration packs.": "Само администратори могат да променят миграционни пакети.", + "Only administrators can change migration packs. You can read this one.": "Само администратори могат да променят миграционни пакети. Можете да прегледате този.", + "other values are an error": "другите стойности са грешка", + "other values get a default": "другите стойности получават стойност по подразбиране", + "other values stay empty": "другите стойности остават празни", + "Problem": "Проблем", + "Problems in this row": "Проблеми в този ред", + "Reload the pack": "Презареждане на пакета", + "Remove column {number}": "Премахване на колона {number}", + "Remove row {number}": "Премахване на ред {number}", + "Report an error for the row": "Отчитане на грешка за реда", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Номера на редове в източника, разделени със запетаи. Ред 1 е първият ред с данни.", + "Row {number} has no source column.": "Ред {number} няма колона източник.", + "Row {number} has no target field.": "Ред {number} няма целево поле.", + "Row {number} is required": "Ред {number} е задължителен", + "Row {number}: {message}": "Ред {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Редовете се изпълняват отгоре надолу. Когато два реда записват едно и също поле и двете колони имат стойност, печели по-долният ред.", + "Rows to skip": "Редове за пропускане", + "Rows to skip must be whole numbers from 1 up: {values}": "Редовете за пропускане трябва да са цели числа от 1 нагоре: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Редове {rows} записват това поле; печели най-долният ред със стойност.", + "Sample value": "Примерна стойност", + "Sample value for {column}": "Примерна стойност за {column}", + "Saved as version {version}.": "Запазено като версия {version}.", + "Saving raises the version to {version}.": "Запазването повишава версията до {version}.", + "Separator": "Разделител", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Някой друг е запазил този пакет, след като сте го отворили. Презаредете го, за да видите промените му; Вашите промени не са запазени.", + "Source column": "Колона източник", + "Source column of row {number}": "Колона източник на ред {number}", + "Source date format": "Формат на датата в източника", + "Source format": "Формат на източника", + "Store this value": "Запазване на тази стойност", + "Stored date format": "Формат на запазената дата", + "Stored value": "Запазена стойност", + "Take the id from a column": "Вземане на идентификатора от колона", + "Target field": "Целево поле", + "Target field of row {number}": "Целево поле на ред {number}", + "Test with a sample row": "Тестване с примерен ред", + "The default for \"{key}\" is not valid JSON.": "Стойността по подразбиране за \"{key}\" не е валиден JSON.", + "The default for \"{key}\" is set twice.": "Стойността по подразбиране за \"{key}\" е зададена два пъти.", + "The fixed value is not valid JSON.": "Фиксираната стойност не е валиден JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Формулярът съдържа стойност, която не е валиден JSON. Първо я поправете.", + "The name is required.": "Името е задължително.", + "The pack cannot be saved yet.": "Пакетът все още не може да бъде запазен.", + "The pack is not valid.": "Пакетът не е валиден.", + "The result for \"{key}\" is not valid JSON.": "Резултатът за \"{key}\" не е валиден JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug-ът трябва да започва с малка буква или цифра и да съдържа само малки букви, цифри и тирета.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug-ът на вграден пакет не може да се променя. Запазете \"id\" като {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug-ът на вграден пакет не може да се променя: приложението, което го е инсталирало, намира пакета по този slug.", + "The test could not run.": "Тестът не можа да бъде изпълнен.", + "The value \"{key}\" is in the table twice.": "Стойността \"{key}\" е в таблицата два пъти.", + "The value for other values is not valid JSON.": "Стойността за другите стойности не е валиден JSON.", + "The value of column {source} comes first, then these columns in order.": "Стойността на колона {source} е първа, след нея следват тези колони по ред.", + "The version must look like 1.0.0 (major.minor.patch).": "Версията трябва да изглежда като 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Цялата дефиниция на пакета. Промените тук заменят формуляра, когато се върнете към него или запазите.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Този редактор не познава типа преобразуване \"{type}\". Той се запазва така, както е съхранен.", + "This is not valid JSON: {reason}": "Това не е валиден JSON: {reason}", + "This migration pack does not exist.": "Този миграционен пакет не съществува.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Този пакет е инсталиран от приложение. Това приложение ще го инсталира отново, без редакции, при следващото си инсталиране или обновяване.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Това преобразуване има и настройки, които този редактор не показва ({keys}). Те се запазват, когато запазите.", + "Transform": "Преобразуване", + "Transform \"{type}\" (kept as stored)": "Преобразуване \"{type}\" (запазено както е съхранено)", + "Transform for row {number}": "Преобразуване за ред {number}", + "Transform of row {number}": "Преобразуване на ред {number}", + "Trim spaces": "Премахване на интервалите", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Въведете стойностите така, както са в източника. Резултатът използва съпоставянето по-горе, включително промените, които още не сте запазили.", + "Undo changes": "Отмяна на промените", + "Value for other values": "Стойност за другите стойности", + "Value in the source": "Стойност в източника", + "Value {number} is JSON": "Стойност {number} е JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Стойности, с които започва всеки импортиран обект. Съпоставяне, което дава стойност на поле, заменя стойността му по подразбиране.", + "Values that are not in the table": "Стойности, които не са в таблицата", + "View {name}": "Преглед на {name}", + "Yes/no table": "Таблица да/не", + "Yes/no table, values: {count}, {unmapped}": "Таблица да/не, стойности: {count}, {unmapped}", + "Store false": "Запазване на „не“ (false)", + "other values are stored as false": "другите стойности се запазват като „не“ (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.js b/l10n/bs.js index 2f37aaf388..05a938aa38 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Prati", "{completed} of {total} completed": "Završeno {completed} od {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Neuspjelo: {failed}, zaustavljeno: {stopped}" + "{failed} failed, {stopped} stopped": "Neuspjelo: {failed}, zaustavljeno: {stopped}", + "A column to join has no name.": "Kolona za spajanje nema naziv.", + "A default value has no target field.": "Zadana vrijednost nema ciljno polje.", + "A fixed value is written even when the column is empty.": "Fiksna vrijednost se upisuje i kada je kolona prazna.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Migracijski paket određuje koja kolona datoteke za uvoz postaje koje polje i kako se njena vrijednost usput pretvara.", + "A pack definition is a JSON object.": "Definicija paketa je JSON objekat.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP format datuma, npr. d-m-Y. Počnite ga sa ! kako se dijelovi koje format ne navodi ne bi uzimali od današnjeg datuma. Ostavite prazno da bi se čitao bilo koji uobičajeni datum.", + "Add a field mapping with a source column to test it.": "Dodajte mapiranje polja s izvornom kolonom da biste ga testirali.", + "Add at least one column to join.": "Dodajte barem jednu kolonu za spajanje.", + "Add at least one field mapping.": "Dodajte barem jedno mapiranje polja.", + "Add at least one value to the table.": "Dodajte barem jednu vrijednost u tabelu.", + "Add column": "Dodaj kolonu", + "Add default": "Dodaj zadanu vrijednost", + "Add field mapping": "Dodaj mapiranje polja", + "Add value": "Dodaj vrijednost", + "Advanced (JSON)": "Napredno (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Aplikacija koja uvozi pomoću ovog paketa može dodati vlastita pravila, npr. koje kolone sadrže datume ili s kojeg lista dolazi red. Provjerite tu aplikaciju prije nego što izmijenite paket koji je ona instalirala.", + "any format": "bilo koji format", + "As JSON": "Kao JSON", + "Built-in": "Ugrađeno", + "Choose a source format.": "Odaberite izvorni format.", + "Column to join {number}": "Kolona za spajanje {number}", + "Column {source} to field {target}": "Kolona {source} u polje {target}", + "Copied as is": "Kopira se nepromijenjeno", + "Could not delete the migration pack.": "Nije bilo moguće izbrisati migracijski paket.", + "Could not import the file.": "Nije bilo moguće uvesti datoteku.", + "Could not load the migration pack.": "Nije bilo moguće učitati migracijski paket.", + "Could not load the migration packs.": "Nije bilo moguće učitati migracijske pakete.", + "Could not save the migration pack.": "Nije bilo moguće spremiti migracijski paket.", + "Date {from} to {to}": "Datum iz {from} u {to}", + "Defaults": "Zadane vrijednosti", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Izbrisati \"{name}\"? Uvozi koji koriste ovaj paket više ga neće pronaći.", + "Delete migration pack": "Izbriši migracijski paket", + "Delete {name}": "Izbriši {name}", + "Deleted \"{name}\".": "\"{name}\" je izbrisan.", + "Edit {name}": "Uredi {name}", + "Export {name}": "Izvezi {name}", + "Field mappings": "Mapiranja polja", + "Fields the import would write": "Polja koja bi uvoz upisao", + "Fix the problems in the pack first; then it can be tested.": "Najprije ispravite probleme u paketu; nakon toga se može testirati.", + "Fixed value": "Fiksna vrijednost", + "Fixed value {value}": "Fiksna vrijednost {value}", + "Form": "Obrazac", + "General": "Općenito", + "Generate a new id": "Generiši novi id", + "Id column": "Kolona s id-om", + "Import JSON": "Uvezi JSON", + "Imported \"{name}\".": "\"{name}\" je uvezen.", + "Imports find the pack by this slug.": "Uvozi pronalaze paket po ovom slugu.", + "Join columns": "Spoji kolone", + "Joined with {fields}, separated by \"{separator}\"": "Spojeno s {fields}, razdvojeno sa \"{separator}\"", + "Leave the field empty": "Ostavi polje prazno", + "Lookup table": "Tabela preslikavanja", + "Lookup table, values: {count}, {unmapped}": "Tabela preslikavanja, vrijednosti: {count}, {unmapped}", + "Migration pack": "Migracijski paket", + "Migration packs": "Migracijski paketi", + "Move row {number} down": "Pomjeri red {number} dolje", + "Move row {number} up": "Pomjeri red {number} gore", + "Name the column that holds the id.": "Navedite kolonu koja sadrži id.", + "No defaults.": "Nema zadanih vrijednosti.", + "No fields: every mapping was empty or failed.": "Nema polja: svako mapiranje je bilo prazno ili nije uspjelo.", + "No migration packs yet": "Još nema migracijskih paketa", + "None: copy the value as is": "Ništa: kopiraj vrijednost nepromijenjenu", + "Object id": "Id objekta", + "Only administrators can change migration packs.": "Samo administratori mogu mijenjati migracijske pakete.", + "Only administrators can change migration packs. You can read this one.": "Samo administratori mogu mijenjati migracijske pakete. Ovaj paket možete pregledati.", + "other values are an error": "ostale vrijednosti daju grešku", + "other values get a default": "ostale vrijednosti dobijaju zadanu vrijednost", + "other values stay empty": "ostale vrijednosti ostaju prazne", + "Problem": "Greška", + "Problems in this row": "Problemi u ovom redu", + "Reload the pack": "Ponovo učitaj paket", + "Remove column {number}": "Ukloni kolonu {number}", + "Remove row {number}": "Ukloni red {number}", + "Report an error for the row": "Prijavi grešku za red", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Brojevi redova u izvoru, razdvojeni zarezima. Red 1 je prvi red s podacima.", + "Row {number} has no source column.": "Red {number} nema izvornu kolonu.", + "Row {number} has no target field.": "Red {number} nema ciljno polje.", + "Row {number} is required": "Red {number} je obavezan", + "Row {number}: {message}": "Red {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Redovi se izvršavaju odozgo prema dolje. Kada dva reda upisuju isto polje i obje kolone imaju vrijednost, prednost ima donji red.", + "Rows to skip": "Redovi za preskakanje", + "Rows to skip must be whole numbers from 1 up: {values}": "Redovi za preskakanje moraju biti cijeli brojevi od 1 naviše: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Redovi {rows} upisuju ovo polje; prednost ima najniži red s vrijednošću.", + "Sample value": "Primjer vrijednosti", + "Sample value for {column}": "Primjer vrijednosti za {column}", + "Saved as version {version}.": "Spremljeno kao verzija {version}.", + "Saving raises the version to {version}.": "Spremanjem se verzija povećava na {version}.", + "Separator": "Razdjelnik", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Neko drugi je spremio ovaj paket nakon što ste ga otvorili. Ponovo ga učitajte da vidite njihove izmjene; vaše izmjene nisu spremljene.", + "Source column": "Izvorna kolona", + "Source column of row {number}": "Izvorna kolona reda {number}", + "Source date format": "Format datuma u izvoru", + "Source format": "Izvorni format", + "Store this value": "Spremi ovu vrijednost", + "Stored date format": "Format spremljenog datuma", + "Stored value": "Spremljena vrijednost", + "Take the id from a column": "Preuzmi id iz kolone", + "Target field": "Ciljno polje", + "Target field of row {number}": "Ciljno polje reda {number}", + "Test with a sample row": "Testiraj s primjerom reda", + "The default for \"{key}\" is not valid JSON.": "Zadana vrijednost za \"{key}\" nije ispravan JSON.", + "The default for \"{key}\" is set twice.": "Zadana vrijednost za \"{key}\" je postavljena dvaput.", + "The fixed value is not valid JSON.": "Fiksna vrijednost nije ispravan JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Obrazac sadrži vrijednost koja nije ispravan JSON. Najprije je ispravite.", + "The name is required.": "Naziv je obavezan.", + "The pack cannot be saved yet.": "Paket se još ne može spremiti.", + "The pack is not valid.": "Paket nije ispravan.", + "The result for \"{key}\" is not valid JSON.": "Rezultat za \"{key}\" nije ispravan JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug mora počinjati malim slovom ili cifrom i smije sadržavati samo mala slova, cifre i crtice.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug ugrađenog paketa ne može se promijeniti. Zadržite \"id\" kao {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug ugrađenog paketa ne može se promijeniti: aplikacija koja ga je instalirala pronalazi paket po ovom slugu.", + "The test could not run.": "Test nije bilo moguće pokrenuti.", + "The value \"{key}\" is in the table twice.": "Vrijednost \"{key}\" se u tabeli nalazi dvaput.", + "The value for other values is not valid JSON.": "Vrijednost za ostale vrijednosti nije ispravan JSON.", + "The value of column {source} comes first, then these columns in order.": "Vrijednost kolone {source} dolazi prva, zatim ove kolone redom.", + "The version must look like 1.0.0 (major.minor.patch).": "Verzija mora izgledati kao 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Cijela definicija paketa. Izmjene ovdje zamjenjuju obrazac kada mu se vratite ili spremite.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Ovaj uređivač ne poznaje tip transformacije \"{type}\". Zadržava se onako kako je spremljen.", + "This is not valid JSON: {reason}": "Ovo nije ispravan JSON: {reason}", + "This migration pack does not exist.": "Ovaj migracijski paket ne postoji.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Ovaj paket je instalirala aplikacija. Ta aplikacija ga ponovo instalira, bez izmjena, sljedeći put kada se instalira ili ažurira.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Ova transformacija ima i postavke koje ovaj uređivač ne prikazuje ({keys}). Zadržavaju se pri spremanju.", + "Transform": "Transformacija", + "Transform \"{type}\" (kept as stored)": "Transformacija \"{type}\" (zadržana kako je spremljena)", + "Transform for row {number}": "Transformacija za red {number}", + "Transform of row {number}": "Transformacija reda {number}", + "Trim spaces": "Ukloni razmake s krajeva", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Upišite vrijednosti onako kako se pojavljuju u izvoru. Rezultat koristi gornje mapiranje, uključujući izmjene koje niste spremili.", + "Undo changes": "Poništi izmjene", + "Value for other values": "Vrijednost za ostale vrijednosti", + "Value in the source": "Vrijednost u izvoru", + "Value {number} is JSON": "Vrijednost {number} je JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Vrijednosti s kojima počinje svaki uvezeni objekat. Mapiranje koje polju daje vrijednost zamjenjuje njegovu zadanu vrijednost.", + "Values that are not in the table": "Vrijednosti koje nisu u tabeli", + "View {name}": "Prikaži {name}", + "Yes/no table": "Tabela da/ne", + "Yes/no table, values: {count}, {unmapped}": "Tabela da/ne, vrijednosti: {count}, {unmapped}", + "Store false": "Spremi ne (false)", + "other values are stored as false": "ostale vrijednosti se spremaju kao ne (false)" }, "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/ca.js b/l10n/ca.js index 4e59a0b1c7..8e2f1580c0 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Vigila", "{completed} of {total} completed": "{completed} de {total} completades", "{date} by {person}": "{date} per {person}", - "{failed} failed, {stopped} stopped": "{failed} fallides, {stopped} aturades" + "{failed} failed, {stopped} stopped": "{failed} fallides, {stopped} aturades", + "A column to join has no name.": "Una columna per unir no té nom.", + "A default value has no target field.": "Un valor per defecte no té camp de destinació.", + "A fixed value is written even when the column is empty.": "Un valor fix s'escriu encara que la columna sigui buida.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Un paquet de migració indica quina columna d'un fitxer d'importació es converteix en quin camp i com es transforma el seu valor pel camí.", + "A pack definition is a JSON object.": "Una definició de paquet és un objecte JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Un format de data de PHP, com ara d-m-Y. Comenceu-lo amb ! perquè les parts que el format no esmenta no es prenguin de la data d'avui. Deixeu-lo buit per llegir qualsevol data habitual.", + "Add a field mapping with a source column to test it.": "Afegiu un mapatge de camp amb una columna d'origen per provar-lo.", + "Add at least one column to join.": "Afegiu almenys una columna per unir.", + "Add at least one field mapping.": "Afegiu almenys un mapatge de camp.", + "Add at least one value to the table.": "Afegiu almenys un valor a la taula.", + "Add column": "Afegir columna", + "Add default": "Afegir valor per defecte", + "Add field mapping": "Afegir mapatge de camp", + "Add value": "Afegir valor", + "Advanced (JSON)": "Avançat (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Una aplicació que importa amb aquest paquet hi pot afegir regles pròpies, per exemple quines columnes contenen dates o de quin full prové una fila. Comproveu aquesta aplicació abans de canviar un paquet que hagi instal·lat.", + "any format": "qualsevol format", + "As JSON": "Com a JSON", + "Built-in": "Integrat", + "Choose a source format.": "Trieu un format d'origen.", + "Column to join {number}": "Columna per unir {number}", + "Column {source} to field {target}": "Columna {source} al camp {target}", + "Copied as is": "Copiat tal qual", + "Could not delete the migration pack.": "No s'ha pogut eliminar el paquet de migració.", + "Could not import the file.": "No s'ha pogut importar el fitxer.", + "Could not load the migration pack.": "No s'ha pogut carregar el paquet de migració.", + "Could not load the migration packs.": "No s'han pogut carregar els paquets de migració.", + "Could not save the migration pack.": "No s'ha pogut desar el paquet de migració.", + "Date {from} to {to}": "Data de {from} a {to}", + "Defaults": "Valors per defecte", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Voleu eliminar \"{name}\"? Les importacions que fan servir aquest paquet ja no el trobaran.", + "Delete migration pack": "Eliminar el paquet de migració", + "Delete {name}": "Eliminar {name}", + "Deleted \"{name}\".": "S'ha eliminat \"{name}\".", + "Edit {name}": "Editar {name}", + "Export {name}": "Exportar {name}", + "Field mappings": "Mapatges de camps", + "Fields the import would write": "Camps que escriuria la importació", + "Fix the problems in the pack first; then it can be tested.": "Primer corregiu els problemes del paquet; després es podrà provar.", + "Fixed value": "Valor fix", + "Fixed value {value}": "Valor fix {value}", + "Form": "Formulari", + "General": "Dades generals", + "Generate a new id": "Generar un id nou", + "Id column": "Columna d'id", + "Import JSON": "Importar JSON", + "Imported \"{name}\".": "S'ha importat \"{name}\".", + "Imports find the pack by this slug.": "Les importacions troben el paquet per aquest slug.", + "Join columns": "Unir columnes", + "Joined with {fields}, separated by \"{separator}\"": "Unit amb {fields}, separat per \"{separator}\"", + "Leave the field empty": "Deixar el camp buit", + "Lookup table": "Taula de cerca", + "Lookup table, values: {count}, {unmapped}": "Taula de cerca, valors: {count}, {unmapped}", + "Migration pack": "Paquet de migració", + "Migration packs": "Paquets de migració", + "Move row {number} down": "Moure la fila {number} avall", + "Move row {number} up": "Moure la fila {number} amunt", + "Name the column that holds the id.": "Indiqueu la columna que conté l'id.", + "No defaults.": "Sense valors per defecte.", + "No fields: every mapping was empty or failed.": "Sense camps: tots els mapatges eren buits o han fallat.", + "No migration packs yet": "Encara no hi ha paquets de migració", + "None: copy the value as is": "Cap: copiar el valor tal qual", + "Object id": "Id de l'objecte", + "Only administrators can change migration packs.": "Només els administradors poden canviar els paquets de migració.", + "Only administrators can change migration packs. You can read this one.": "Només els administradors poden canviar els paquets de migració. Podeu consultar aquest.", + "other values are an error": "els altres valors donen un error", + "other values get a default": "els altres valors reben un valor per defecte", + "other values stay empty": "els altres valors queden buits", + "Problem": "Problema", + "Problems in this row": "Problemes en aquesta fila", + "Reload the pack": "Tornar a carregar el paquet", + "Remove column {number}": "Eliminar la columna {number}", + "Remove row {number}": "Eliminar la fila {number}", + "Report an error for the row": "Informar d'un error per a la fila", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Números de fila a l'origen, separats per comes. La fila 1 és la primera fila de dades.", + "Row {number} has no source column.": "La fila {number} no té columna d'origen.", + "Row {number} has no target field.": "La fila {number} no té camp de destinació.", + "Row {number} is required": "La fila {number} és obligatòria", + "Row {number}: {message}": "Fila {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Les files s'apliquen de dalt a baix. Quan dues files escriuen el mateix camp i totes dues columnes tenen un valor, guanya la fila de més avall.", + "Rows to skip": "Files que cal ometre", + "Rows to skip must be whole numbers from 1 up: {values}": "Les files que cal ometre han de ser nombres enters a partir d'1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Les files {rows} escriuen aquest camp; guanya la fila més baixa amb un valor.", + "Sample value": "Valor d'exemple", + "Sample value for {column}": "Valor d'exemple per a {column}", + "Saved as version {version}.": "Desat com a versió {version}.", + "Saving raises the version to {version}.": "En desar, la versió puja a {version}.", + "Separator": "Separador", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Una altra persona ha desat aquest paquet després que l'obríssiu. Torneu-lo a carregar per veure'n els canvis; els vostres canvis no s'han desat.", + "Source column": "Columna d'origen", + "Source column of row {number}": "Columna d'origen de la fila {number}", + "Source date format": "Format de data d'origen", + "Source format": "Format d'origen", + "Store this value": "Desar aquest valor", + "Stored date format": "Format de data desat", + "Stored value": "Valor desat", + "Take the id from a column": "Agafar l'id d'una columna", + "Target field": "Camp de destinació", + "Target field of row {number}": "Camp de destinació de la fila {number}", + "Test with a sample row": "Provar amb una fila d'exemple", + "The default for \"{key}\" is not valid JSON.": "El valor per defecte de \"{key}\" no és JSON vàlid.", + "The default for \"{key}\" is set twice.": "El valor per defecte de \"{key}\" està definit dues vegades.", + "The fixed value is not valid JSON.": "El valor fix no és JSON vàlid.", + "The form has a value that is not valid JSON. Fix it first.": "El formulari té un valor que no és JSON vàlid. Corregiu-lo primer.", + "The name is required.": "El nom és obligatori.", + "The pack cannot be saved yet.": "El paquet encara no es pot desar.", + "The pack is not valid.": "El paquet no és vàlid.", + "The result for \"{key}\" is not valid JSON.": "El resultat de \"{key}\" no és JSON vàlid.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "L'slug ha de començar amb una lletra minúscula o un dígit i contenir només lletres minúscules, dígits i guionets.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "L'slug d'un paquet integrat no pot canviar. Mantingueu \"id\" com a {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "L'slug d'un paquet integrat no pot canviar: l'aplicació que l'ha instal·lat troba el paquet per aquest slug.", + "The test could not run.": "No s'ha pogut executar la prova.", + "The value \"{key}\" is in the table twice.": "El valor \"{key}\" apareix dues vegades a la taula.", + "The value for other values is not valid JSON.": "El valor per als altres valors no és JSON vàlid.", + "The value of column {source} comes first, then these columns in order.": "Primer va el valor de la columna {source} i després aquestes columnes, en ordre.", + "The version must look like 1.0.0 (major.minor.patch).": "La versió ha de tenir el format 1.0.0 (major.menor.pedaç).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "La definició completa del paquet. Els canvis fets aquí substitueixen el formulari quan hi torneu o deseu.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Aquest editor no coneix el tipus de transformació \"{type}\". Es conserva tal com està desat.", + "This is not valid JSON: {reason}": "Això no és JSON vàlid: {reason}", + "This migration pack does not exist.": "Aquest paquet de migració no existeix.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Aquest paquet l'ha instal·lat una aplicació. Aquesta aplicació el torna a instal·lar, sense modificacions, la propera vegada que s'instal·li o s'actualitzi.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Aquesta transformació també té paràmetres que aquest editor no mostra ({keys}). Es conserven quan deseu.", + "Transform": "Transformació", + "Transform \"{type}\" (kept as stored)": "Transformació \"{type}\" (es conserva tal com està desada)", + "Transform for row {number}": "Transformació per a la fila {number}", + "Transform of row {number}": "Transformació de la fila {number}", + "Trim spaces": "Retallar espais", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Escriviu els valors tal com apareixen a l'origen. El resultat fa servir el mapatge de dalt, inclosos els canvis que encara no heu desat.", + "Undo changes": "Desfer els canvis", + "Value for other values": "Valor per als altres valors", + "Value in the source": "Valor a l'origen", + "Value {number} is JSON": "El valor {number} és JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Valors amb què comença cada objecte importat. Un mapatge que dona un valor a un camp substitueix el seu valor per defecte.", + "Values that are not in the table": "Valors que no són a la taula", + "View {name}": "Veure {name}", + "Yes/no table": "Taula de sí/no", + "Yes/no table, values: {count}, {unmapped}": "Taula de sí/no, valors: {count}, {unmapped}", + "Store false": "Desar no (false)", + "other values are stored as false": "els altres valors es desen com a no (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.js b/l10n/cs.js index 9acca0aee1..c5977bad34 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Sleduje", "{completed} of {total} completed": "Dokončeno {completed} z {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Selhalo: {failed}, zastaveno: {stopped}" + "{failed} failed, {stopped} stopped": "Selhalo: {failed}, zastaveno: {stopped}", + "A column to join has no name.": "Sloupec ke spojení nemá název.", + "A default value has no target field.": "Výchozí hodnota nemá cílové pole.", + "A fixed value is written even when the column is empty.": "Pevná hodnota se zapíše, i když je sloupec prázdný.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Migrační balíček určuje, ze kterého sloupce importovaného souboru se stane které pole a jak se jeho hodnota cestou převede.", + "A pack definition is a JSON object.": "Definice balíčku je objekt JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Formát data PHP, například d-m-Y. Začněte jej znakem !, aby se části, které formát neuvádí, nepřebíraly z dnešního data. Ponechte prázdné, chcete-li číst jakékoli běžné datum.", + "Add a field mapping with a source column to test it.": "Pro otestování přidejte mapování pole se zdrojovým sloupcem.", + "Add at least one column to join.": "Přidejte alespoň jeden sloupec ke spojení.", + "Add at least one field mapping.": "Přidejte alespoň jedno mapování pole.", + "Add at least one value to the table.": "Přidejte do tabulky alespoň jednu hodnotu.", + "Add column": "Přidat sloupec", + "Add default": "Přidat výchozí hodnotu", + "Add field mapping": "Přidat mapování pole", + "Add value": "Přidat hodnotu", + "Advanced (JSON)": "Pokročilé (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Aplikace, která s tímto balíčkem importuje, může přidat vlastní pravidla, například ve kterých sloupcích jsou datumy nebo ze kterého listu řádek pochází. Než změníte balíček, který nainstalovala, zkontrolujte tuto aplikaci.", + "any format": "libovolný formát", + "As JSON": "Jako JSON", + "Built-in": "Vestavěný", + "Choose a source format.": "Zvolte zdrojový formát.", + "Column to join {number}": "Sloupec ke spojení {number}", + "Column {source} to field {target}": "Sloupec {source} do pole {target}", + "Copied as is": "Převzato beze změny", + "Could not delete the migration pack.": "Migrační balíček se nepodařilo smazat.", + "Could not import the file.": "Soubor se nepodařilo naimportovat.", + "Could not load the migration pack.": "Migrační balíček se nepodařilo načíst.", + "Could not load the migration packs.": "Migrační balíčky se nepodařilo načíst.", + "Could not save the migration pack.": "Migrační balíček se nepodařilo uložit.", + "Date {from} to {to}": "Datum z {from} na {to}", + "Defaults": "Výchozí hodnoty", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Smazat \"{name}\"? Importy, které tento balíček používají, jej už nenajdou.", + "Delete migration pack": "Smazat migrační balíček", + "Delete {name}": "Smazat {name}", + "Deleted \"{name}\".": "Balíček \"{name}\" byl smazán.", + "Edit {name}": "Upravit {name}", + "Export {name}": "Exportovat {name}", + "Field mappings": "Mapování polí", + "Fields the import would write": "Pole, která by import zapsal", + "Fix the problems in the pack first; then it can be tested.": "Nejprve opravte problémy v balíčku; potom jej bude možné otestovat.", + "Fixed value": "Pevná hodnota", + "Fixed value {value}": "Pevná hodnota {value}", + "Form": "Formulář", + "General": "Obecné", + "Generate a new id": "Vygenerovat nové ID", + "Id column": "Sloupec s ID", + "Import JSON": "Importovat JSON", + "Imported \"{name}\".": "Balíček \"{name}\" byl naimportován.", + "Imports find the pack by this slug.": "Importy najdou balíček podle tohoto slugu.", + "Join columns": "Spojit sloupce", + "Joined with {fields}, separated by \"{separator}\"": "Spojeno s {fields}, odděleno \"{separator}\"", + "Leave the field empty": "Ponechat pole prázdné", + "Lookup table": "Převodní tabulka", + "Lookup table, values: {count}, {unmapped}": "Převodní tabulka, hodnoty: {count}, {unmapped}", + "Migration pack": "Migrační balíček", + "Migration packs": "Migrační balíčky", + "Move row {number} down": "Přesunout řádek {number} dolů", + "Move row {number} up": "Přesunout řádek {number} nahoru", + "Name the column that holds the id.": "Zadejte sloupec, který obsahuje ID.", + "No defaults.": "Žádné výchozí hodnoty.", + "No fields: every mapping was empty or failed.": "Žádná pole: každé mapování bylo prázdné nebo selhalo.", + "No migration packs yet": "Zatím žádné migrační balíčky", + "None: copy the value as is": "Žádná: převzít hodnotu beze změny", + "Object id": "ID objektu", + "Only administrators can change migration packs.": "Migrační balíčky mohou měnit pouze správci.", + "Only administrators can change migration packs. You can read this one.": "Migrační balíčky mohou měnit pouze správci. Tento balíček si můžete přečíst.", + "other values are an error": "ostatní hodnoty jsou chyba", + "other values get a default": "ostatní hodnoty dostanou výchozí hodnotu", + "other values stay empty": "ostatní hodnoty zůstanou prázdné", + "Problem": "Problém", + "Problems in this row": "Problémy v tomto řádku", + "Reload the pack": "Znovu načíst balíček", + "Remove column {number}": "Odebrat sloupec {number}", + "Remove row {number}": "Odebrat řádek {number}", + "Report an error for the row": "Nahlásit chybu pro řádek", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Čísla řádků ve zdroji oddělená čárkami. Řádek 1 je první datový řádek.", + "Row {number} has no source column.": "Řádek {number} nemá zdrojový sloupec.", + "Row {number} has no target field.": "Řádek {number} nemá cílové pole.", + "Row {number} is required": "Řádek {number} je povinný", + "Row {number}: {message}": "Řádek {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Řádky se zpracovávají shora dolů. Když dva řádky zapisují stejné pole a oba sloupce mají hodnotu, vyhrává nižší řádek.", + "Rows to skip": "Řádky k přeskočení", + "Rows to skip must be whole numbers from 1 up: {values}": "Řádky k přeskočení musí být celá čísla od 1 výše: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Řádky {rows} zapisují toto pole; vyhrává nejnižší řádek s hodnotou.", + "Sample value": "Ukázková hodnota", + "Sample value for {column}": "Ukázková hodnota pro {column}", + "Saved as version {version}.": "Uloženo jako verze {version}.", + "Saving raises the version to {version}.": "Uložením se verze zvýší na {version}.", + "Separator": "Oddělovač", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Někdo jiný tento balíček uložil poté, co jste jej otevřeli. Načtěte jej znovu, abyste viděli jeho změny; vaše změny nejsou uloženy.", + "Source column": "Zdrojový sloupec", + "Source column of row {number}": "Zdrojový sloupec řádku {number}", + "Source date format": "Formát data ve zdroji", + "Source format": "Zdrojový formát", + "Store this value": "Uložit tuto hodnotu", + "Stored date format": "Formát uloženého data", + "Stored value": "Uložená hodnota", + "Take the id from a column": "Převzít ID ze sloupce", + "Target field": "Cílové pole", + "Target field of row {number}": "Cílové pole řádku {number}", + "Test with a sample row": "Otestovat s ukázkovým řádkem", + "The default for \"{key}\" is not valid JSON.": "Výchozí hodnota pro \"{key}\" není platný JSON.", + "The default for \"{key}\" is set twice.": "Výchozí hodnota pro \"{key}\" je nastavena dvakrát.", + "The fixed value is not valid JSON.": "Pevná hodnota není platný JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Formulář obsahuje hodnotu, která není platný JSON. Nejprve ji opravte.", + "The name is required.": "Název je povinný.", + "The pack cannot be saved yet.": "Balíček zatím nelze uložit.", + "The pack is not valid.": "Balíček není platný.", + "The result for \"{key}\" is not valid JSON.": "Výsledek pro \"{key}\" není platný JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug musí začínat malým písmenem nebo číslicí a smí obsahovat pouze malá písmena, číslice a spojovníky.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug vestavěného balíčku nelze změnit. Ponechte \"id\" jako {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug vestavěného balíčku nelze změnit: aplikace, která jej nainstalovala, balíček podle tohoto slugu hledá.", + "The test could not run.": "Test se nepodařilo spustit.", + "The value \"{key}\" is in the table twice.": "Hodnota \"{key}\" je v tabulce dvakrát.", + "The value for other values is not valid JSON.": "Hodnota pro ostatní hodnoty není platný JSON.", + "The value of column {source} comes first, then these columns in order.": "Nejprve je hodnota sloupce {source}, potom tyto sloupce v uvedeném pořadí.", + "The version must look like 1.0.0 (major.minor.patch).": "Verze musí mít tvar 1.0.0 (hlavní.vedlejší.oprava).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Celá definice balíčku. Změny zde nahradí formulář, když se k němu vrátíte nebo uložíte.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Tento editor nezná typ transformace \"{type}\". Zůstane zachován tak, jak je uložen.", + "This is not valid JSON: {reason}": "Toto není platný JSON: {reason}", + "This migration pack does not exist.": "Tento migrační balíček neexistuje.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Tento balíček nainstalovala aplikace. Při své příští instalaci nebo aktualizaci jej tato aplikace nainstaluje znovu, bez úprav.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Tato transformace má i nastavení, která tento editor nezobrazuje ({keys}). Při uložení zůstanou zachována.", + "Transform": "Transformace", + "Transform \"{type}\" (kept as stored)": "Transformace \"{type}\" (zachována tak, jak je uložena)", + "Transform for row {number}": "Transformace pro řádek {number}", + "Transform of row {number}": "Transformace řádku {number}", + "Trim spaces": "Oříznout mezery", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Zadejte hodnoty tak, jak jsou ve zdroji. Výsledek používá výše uvedené mapování včetně změn, které jste ještě neuložili.", + "Undo changes": "Vrátit změny zpět", + "Value for other values": "Hodnota pro ostatní hodnoty", + "Value in the source": "Hodnota ve zdroji", + "Value {number} is JSON": "Hodnota {number} je JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Hodnoty, se kterými začíná každý importovaný objekt. Mapování, které poli dá hodnotu, nahradí jeho výchozí hodnotu.", + "Values that are not in the table": "Hodnoty, které nejsou v tabulce", + "View {name}": "Zobrazit {name}", + "Yes/no table": "Tabulka ano/ne", + "Yes/no table, values: {count}, {unmapped}": "Tabulka ano/ne, hodnoty: {count}, {unmapped}", + "Store false": "Uložit ne (false)", + "other values are stored as false": "ostatní hodnoty se uloží jako ne (false)" }, "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/da.js b/l10n/da.js index 618662c712..a051e3c41c 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Overvåger", "{completed} of {total} completed": "{completed} af {total} gennemført", "{date} by {person}": "{date} af {person}", - "{failed} failed, {stopped} stopped": "{failed} mislykket, {stopped} stoppet" + "{failed} failed, {stopped} stopped": "{failed} mislykket, {stopped} stoppet", + "A column to join has no name.": "En kolonne, der skal sammenføjes, har intet navn.", + "A default value has no target field.": "En standardværdi har intet målfelt.", + "A fixed value is written even when the column is empty.": "En fast værdi skrives, også når kolonnen er tom.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "En migreringspakke angiver, hvilken kolonne i en importfil der bliver til hvilket felt, og hvordan værdien konverteres undervejs.", + "A pack definition is a JSON object.": "En pakkedefinition er et JSON-objekt.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Et PHP-datoformat som d-m-Y. Start det med !, så dele, som formatet ikke angiver, ikke hentes fra dags dato. Lad det være tomt for at læse enhver almindelig dato.", + "Add a field mapping with a source column to test it.": "Tilføj en feltmapping med en kildekolonne for at teste den.", + "Add at least one column to join.": "Tilføj mindst én kolonne, der skal sammenføjes.", + "Add at least one field mapping.": "Tilføj mindst én feltmapping.", + "Add at least one value to the table.": "Tilføj mindst én værdi til tabellen.", + "Add column": "Tilføj kolonne", + "Add default": "Tilføj standardværdi", + "Add field mapping": "Tilføj feltmapping", + "Add value": "Tilføj værdi", + "Advanced (JSON)": "Avanceret (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "En app, der importerer med denne pakke, kan tilføje sine egne regler, for eksempel hvilke kolonner der indeholder datoer, eller hvilket ark en række kommer fra. Tjek den app, før du ændrer en pakke, som den har installeret.", + "any format": "ethvert format", + "As JSON": "Som JSON", + "Built-in": "Indbygget", + "Choose a source format.": "Vælg et kildeformat.", + "Column to join {number}": "Kolonne til sammenføjning {number}", + "Column {source} to field {target}": "Kolonne {source} til felt {target}", + "Copied as is": "Kopieret uændret", + "Could not delete the migration pack.": "Migreringspakken kunne ikke slettes.", + "Could not import the file.": "Filen kunne ikke importeres.", + "Could not load the migration pack.": "Migreringspakken kunne ikke indlæses.", + "Could not load the migration packs.": "Migreringspakkerne kunne ikke indlæses.", + "Could not save the migration pack.": "Migreringspakken kunne ikke gemmes.", + "Date {from} to {to}": "Dato {from} til {to}", + "Defaults": "Standardværdier", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Slet \"{name}\"? Importer, der bruger denne pakke, vil ikke længere kunne finde den.", + "Delete migration pack": "Slet migreringspakke", + "Delete {name}": "Slet {name}", + "Deleted \"{name}\".": "\"{name}\" er slettet.", + "Edit {name}": "Rediger {name}", + "Export {name}": "Eksporter {name}", + "Field mappings": "Feltmappinger", + "Fields the import would write": "Felter, som importen ville skrive", + "Fix the problems in the pack first; then it can be tested.": "Ret først problemerne i pakken; derefter kan den testes.", + "Fixed value": "Fast værdi", + "Fixed value {value}": "Fast værdi {value}", + "Form": "Formular", + "General": "Generelt", + "Generate a new id": "Generér et nyt id", + "Id column": "Id-kolonne", + "Import JSON": "Importér JSON", + "Imported \"{name}\".": "\"{name}\" er importeret.", + "Imports find the pack by this slug.": "Importer finder pakken via denne slug.", + "Join columns": "Sammenføj kolonner", + "Joined with {fields}, separated by \"{separator}\"": "Sammenføjet med {fields}, adskilt af \"{separator}\"", + "Leave the field empty": "Lad feltet være tomt", + "Lookup table": "Opslagstabel", + "Lookup table, values: {count}, {unmapped}": "Opslagstabel, værdier: {count}, {unmapped}", + "Migration pack": "Migreringspakke", + "Migration packs": "Migreringspakker", + "Move row {number} down": "Flyt række {number} ned", + "Move row {number} up": "Flyt række {number} op", + "Name the column that holds the id.": "Angiv den kolonne, der indeholder id'et.", + "No defaults.": "Ingen standardværdier.", + "No fields: every mapping was empty or failed.": "Ingen felter: alle mappinger var tomme eller mislykkedes.", + "No migration packs yet": "Ingen migreringspakker endnu", + "None: copy the value as is": "Ingen: kopiér værdien uændret", + "Object id": "Objekt-id", + "Only administrators can change migration packs.": "Kun administratorer kan ændre migreringspakker.", + "Only administrators can change migration packs. You can read this one.": "Kun administratorer kan ændre migreringspakker. Du kan læse denne.", + "other values are an error": "andre værdier giver en fejl", + "other values get a default": "andre værdier får en standardværdi", + "other values stay empty": "andre værdier forbliver tomme", + "Problem": "Fejl", + "Problems in this row": "Problemer i denne række", + "Reload the pack": "Genindlæs pakken", + "Remove column {number}": "Fjern kolonne {number}", + "Remove row {number}": "Fjern række {number}", + "Report an error for the row": "Rapportér en fejl for rækken", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Rækkenumre i kilden, adskilt af kommaer. Række 1 er den første datarække.", + "Row {number} has no source column.": "Række {number} har ingen kildekolonne.", + "Row {number} has no target field.": "Række {number} har intet målfelt.", + "Row {number} is required": "Række {number} er påkrævet", + "Row {number}: {message}": "Række {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Rækkerne behandles fra top til bund. Når to rækker skriver det samme felt, og begge kolonner har en værdi, vinder den nederste række.", + "Rows to skip": "Rækker, der skal springes over", + "Rows to skip must be whole numbers from 1 up: {values}": "Rækker, der skal springes over, skal være hele tal fra 1 og op: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Rækkerne {rows} skriver dette felt; den nederste række med en værdi vinder.", + "Sample value": "Eksempelværdi", + "Sample value for {column}": "Eksempelværdi for {column}", + "Saved as version {version}.": "Gemt som version {version}.", + "Saving raises the version to {version}.": "Når du gemmer, hæves versionen til {version}.", + "Separator": "Skilletegn", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "En anden har gemt denne pakke, efter du åbnede den. Genindlæs den for at se vedkommendes ændringer; dine ændringer er ikke gemt.", + "Source column": "Kildekolonne", + "Source column of row {number}": "Kildekolonne for række {number}", + "Source date format": "Datoformat i kilden", + "Source format": "Kildeformat", + "Store this value": "Gem denne værdi", + "Stored date format": "Gemt datoformat", + "Stored value": "Gemt værdi", + "Take the id from a column": "Hent id'et fra en kolonne", + "Target field": "Målfelt", + "Target field of row {number}": "Målfelt for række {number}", + "Test with a sample row": "Test med en eksempelrække", + "The default for \"{key}\" is not valid JSON.": "Standardværdien for \"{key}\" er ikke gyldig JSON.", + "The default for \"{key}\" is set twice.": "Standardværdien for \"{key}\" er angivet to gange.", + "The fixed value is not valid JSON.": "Den faste værdi er ikke gyldig JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Formularen har en værdi, der ikke er gyldig JSON. Ret den først.", + "The name is required.": "Navnet er påkrævet.", + "The pack cannot be saved yet.": "Pakken kan ikke gemmes endnu.", + "The pack is not valid.": "Pakken er ikke gyldig.", + "The result for \"{key}\" is not valid JSON.": "Resultatet for \"{key}\" er ikke gyldig JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Sluggen skal starte med et lille bogstav eller et ciffer og må kun indeholde små bogstaver, cifre og bindestreger.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Sluggen for en indbygget pakke kan ikke ændres. Behold \"id\" som {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Sluggen for en indbygget pakke kan ikke ændres: den app, der installerede den, finder pakken via denne slug.", + "The test could not run.": "Testen kunne ikke køres.", + "The value \"{key}\" is in the table twice.": "Værdien \"{key}\" står to gange i tabellen.", + "The value for other values is not valid JSON.": "Værdien for andre værdier er ikke gyldig JSON.", + "The value of column {source} comes first, then these columns in order.": "Værdien fra kolonne {source} kommer først, derefter disse kolonner i rækkefølge.", + "The version must look like 1.0.0 (major.minor.patch).": "Versionen skal se ud som 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Hele pakkedefinitionen. Ændringer her erstatter formularen, når du går tilbage til den eller gemmer.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Denne editor kender ikke transformationstypen \"{type}\". Den bevares, som den er gemt.", + "This is not valid JSON: {reason}": "Dette er ikke gyldig JSON: {reason}", + "This migration pack does not exist.": "Denne migreringspakke findes ikke.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Denne pakke blev installeret af en app. Den app installerer den igen, uredigeret, næste gang appen installeres eller opdateres.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Denne transformation har også indstillinger, som denne editor ikke viser ({keys}). De bevares, når du gemmer.", + "Transform": "Transformation", + "Transform \"{type}\" (kept as stored)": "Transformation \"{type}\" (bevares som gemt)", + "Transform for row {number}": "Transformation for række {number}", + "Transform of row {number}": "Transformation af række {number}", + "Trim spaces": "Fjern mellemrum i kanterne", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Skriv værdier, som de står i kilden. Resultatet bruger mappingen ovenfor, inklusive ændringer, du ikke har gemt.", + "Undo changes": "Fortryd ændringer", + "Value for other values": "Værdi for andre værdier", + "Value in the source": "Værdi i kilden", + "Value {number} is JSON": "Værdi {number} er JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Værdier, som hvert importeret objekt starter med. En mapping, der giver et felt en værdi, erstatter dets standardværdi.", + "Values that are not in the table": "Værdier, der ikke står i tabellen", + "View {name}": "Vis {name}", + "Yes/no table": "Ja/nej-tabel", + "Yes/no table, values: {count}, {unmapped}": "Ja/nej-tabel, værdier: {count}, {unmapped}", + "Store false": "Gem nej (false)", + "other values are stored as false": "andre værdier gemmes som nej (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.js b/l10n/de.js index 3afc0e9c9a..6ddb854094 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Beobachtet", "{completed} of {total} completed": "{completed} von {total} abgeschlossen", "{date} by {person}": "{date} von {person}", - "{failed} failed, {stopped} stopped": "{failed} fehlgeschlagen, {stopped} gestoppt" + "{failed} failed, {stopped} stopped": "{failed} fehlgeschlagen, {stopped} gestoppt", + "A column to join has no name.": "Eine zu verbindende Spalte hat keinen Namen.", + "A default value has no target field.": "Ein Standardwert hat kein Zielfeld.", + "A fixed value is written even when the column is empty.": "Ein fester Wert wird auch dann geschrieben, wenn die Spalte leer ist.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Ein Migrationspaket legt fest, welche Spalte einer Importdatei zu welchem Feld wird und wie ihr Wert dabei umgewandelt wird.", + "A pack definition is a JSON object.": "Eine Paketdefinition ist ein JSON-Objekt.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Ein PHP-Datumsformat wie d-m-Y. Beginnen Sie es mit !, damit Teile, die das Format nicht nennt, nicht vom heutigen Datum übernommen werden. Lassen Sie es leer, um jedes gängige Datum zu lesen.", + "Add a field mapping with a source column to test it.": "Fügen Sie eine Feldzuordnung mit einer Quellspalte hinzu, um sie zu testen.", + "Add at least one column to join.": "Fügen Sie mindestens eine zu verbindende Spalte hinzu.", + "Add at least one field mapping.": "Fügen Sie mindestens eine Feldzuordnung hinzu.", + "Add at least one value to the table.": "Fügen Sie der Tabelle mindestens einen Wert hinzu.", + "Add column": "Spalte hinzufügen", + "Add default": "Standardwert hinzufügen", + "Add field mapping": "Feldzuordnung hinzufügen", + "Add value": "Wert hinzufügen", + "Advanced (JSON)": "Erweitert (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Eine App, die mit diesem Paket importiert, kann eigene Regeln hinzufügen, zum Beispiel welche Spalten Datumsangaben enthalten oder aus welchem Tabellenblatt eine Zeile stammt. Prüfen Sie diese App, bevor Sie ein Paket ändern, das sie installiert hat.", + "any format": "beliebiges Format", + "As JSON": "Als JSON", + "Built-in": "Integriert", + "Choose a source format.": "Wählen Sie ein Quellformat.", + "Column to join {number}": "Zu verbindende Spalte {number}", + "Column {source} to field {target}": "Spalte {source} zu Feld {target}", + "Copied as is": "Unverändert übernommen", + "Could not delete the migration pack.": "Das Migrationspaket konnte nicht gelöscht werden.", + "Could not import the file.": "Die Datei konnte nicht importiert werden.", + "Could not load the migration pack.": "Das Migrationspaket konnte nicht geladen werden.", + "Could not load the migration packs.": "Die Migrationspakete konnten nicht geladen werden.", + "Could not save the migration pack.": "Das Migrationspaket konnte nicht gespeichert werden.", + "Date {from} to {to}": "Datum {from} nach {to}", + "Defaults": "Standardwerte", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "\"{name}\" löschen? Importe, die dieses Paket verwenden, finden es dann nicht mehr.", + "Delete migration pack": "Migrationspaket löschen", + "Delete {name}": "{name} löschen", + "Deleted \"{name}\".": "\"{name}\" gelöscht.", + "Edit {name}": "{name} bearbeiten", + "Export {name}": "{name} exportieren", + "Field mappings": "Feldzuordnungen", + "Fields the import would write": "Felder, die der Import schreiben würde", + "Fix the problems in the pack first; then it can be tested.": "Beheben Sie zuerst die Probleme im Paket; danach kann es getestet werden.", + "Fixed value": "Fester Wert", + "Fixed value {value}": "Fester Wert {value}", + "Form": "Formular", + "General": "Allgemein", + "Generate a new id": "Neue ID erzeugen", + "Id column": "ID-Spalte", + "Import JSON": "JSON importieren", + "Imported \"{name}\".": "\"{name}\" importiert.", + "Imports find the pack by this slug.": "Importe finden das Paket über diesen Slug.", + "Join columns": "Spalten verbinden", + "Joined with {fields}, separated by \"{separator}\"": "Verbunden mit {fields}, getrennt durch \"{separator}\"", + "Leave the field empty": "Feld leer lassen", + "Lookup table": "Nachschlagetabelle", + "Lookup table, values: {count}, {unmapped}": "Nachschlagetabelle, Werte: {count}, {unmapped}", + "Migration pack": "Migrationspaket", + "Migration packs": "Migrationspakete", + "Move row {number} down": "Zeile {number} nach unten verschieben", + "Move row {number} up": "Zeile {number} nach oben verschieben", + "Name the column that holds the id.": "Geben Sie die Spalte an, die die ID enthält.", + "No defaults.": "Keine Standardwerte.", + "No fields: every mapping was empty or failed.": "Keine Felder: Jede Zuordnung war leer oder ist fehlgeschlagen.", + "No migration packs yet": "Noch keine Migrationspakete", + "None: copy the value as is": "Keine: Wert unverändert übernehmen", + "Object id": "Objekt-ID", + "Only administrators can change migration packs.": "Nur Administratoren können Migrationspakete ändern.", + "Only administrators can change migration packs. You can read this one.": "Nur Administratoren können Migrationspakete ändern. Sie können dieses Paket lesen.", + "other values are an error": "andere Werte sind ein Fehler", + "other values get a default": "andere Werte erhalten einen Standardwert", + "other values stay empty": "andere Werte bleiben leer", + "Problem": "Fehler", + "Problems in this row": "Probleme in dieser Zeile", + "Reload the pack": "Paket neu laden", + "Remove column {number}": "Spalte {number} entfernen", + "Remove row {number}": "Zeile {number} entfernen", + "Report an error for the row": "Fehler für die Zeile melden", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Zeilennummern in der Quelle, durch Kommas getrennt. Zeile 1 ist die erste Datenzeile.", + "Row {number} has no source column.": "Zeile {number} hat keine Quellspalte.", + "Row {number} has no target field.": "Zeile {number} hat kein Zielfeld.", + "Row {number} is required": "Zeile {number} ist erforderlich", + "Row {number}: {message}": "Zeile {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Die Zeilen werden von oben nach unten ausgeführt. Wenn zwei Zeilen dasselbe Feld schreiben und beide Spalten einen Wert haben, gewinnt die untere Zeile.", + "Rows to skip": "Zu überspringende Zeilen", + "Rows to skip must be whole numbers from 1 up: {values}": "Zu überspringende Zeilen müssen ganze Zahlen ab 1 sein: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Die Zeilen {rows} schreiben dieses Feld; die unterste Zeile mit einem Wert gewinnt.", + "Sample value": "Beispielwert", + "Sample value for {column}": "Beispielwert für {column}", + "Saved as version {version}.": "Als Version {version} gespeichert.", + "Saving raises the version to {version}.": "Beim Speichern wird die Version auf {version} erhöht.", + "Separator": "Trennzeichen", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Jemand anderes hat dieses Paket gespeichert, nachdem Sie es geöffnet haben. Laden Sie es neu, um diese Änderungen zu sehen; Ihre Änderungen sind nicht gespeichert.", + "Source column": "Quellspalte", + "Source column of row {number}": "Quellspalte von Zeile {number}", + "Source date format": "Datumsformat der Quelle", + "Source format": "Quellformat", + "Store this value": "Diesen Wert speichern", + "Stored date format": "Gespeichertes Datumsformat", + "Stored value": "Gespeicherter Wert", + "Take the id from a column": "ID aus einer Spalte übernehmen", + "Target field": "Zielfeld", + "Target field of row {number}": "Zielfeld von Zeile {number}", + "Test with a sample row": "Mit einer Beispielzeile testen", + "The default for \"{key}\" is not valid JSON.": "Der Standardwert für \"{key}\" ist kein gültiges JSON.", + "The default for \"{key}\" is set twice.": "Der Standardwert für \"{key}\" ist doppelt gesetzt.", + "The fixed value is not valid JSON.": "Der feste Wert ist kein gültiges JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Das Formular enthält einen Wert, der kein gültiges JSON ist. Beheben Sie ihn zuerst.", + "The name is required.": "Der Name ist erforderlich.", + "The pack cannot be saved yet.": "Das Paket kann noch nicht gespeichert werden.", + "The pack is not valid.": "Das Paket ist ungültig.", + "The result for \"{key}\" is not valid JSON.": "Das Ergebnis für \"{key}\" ist kein gültiges JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Der Slug muss mit einem Kleinbuchstaben oder einer Ziffer beginnen und darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Der Slug eines integrierten Pakets kann nicht geändert werden. Behalten Sie \"id\" als {slug} bei.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Der Slug eines integrierten Pakets kann nicht geändert werden: Die App, die es installiert hat, findet das Paket über diesen Slug.", + "The test could not run.": "Der Test konnte nicht ausgeführt werden.", + "The value \"{key}\" is in the table twice.": "Der Wert \"{key}\" steht zweimal in der Tabelle.", + "The value for other values is not valid JSON.": "Der Wert für andere Werte ist kein gültiges JSON.", + "The value of column {source} comes first, then these columns in order.": "Der Wert der Spalte {source} kommt zuerst, danach diese Spalten in dieser Reihenfolge.", + "The version must look like 1.0.0 (major.minor.patch).": "Die Version muss wie 1.0.0 aussehen (Major.Minor.Patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Die gesamte Paketdefinition. Änderungen hier ersetzen das Formular, wenn Sie zu ihm zurückkehren oder speichern.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Dieser Editor kennt den Umwandlungstyp \"{type}\" nicht. Er bleibt so erhalten, wie er gespeichert ist.", + "This is not valid JSON: {reason}": "Das ist kein gültiges JSON: {reason}", + "This migration pack does not exist.": "Dieses Migrationspaket existiert nicht.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Dieses Paket wurde von einer App installiert. Diese App installiert es bei ihrer nächsten Installation oder Aktualisierung erneut, und zwar unbearbeitet.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Diese Umwandlung hat auch Einstellungen, die dieser Editor nicht anzeigt ({keys}). Sie bleiben beim Speichern erhalten.", + "Transform": "Umwandlung", + "Transform \"{type}\" (kept as stored)": "Umwandlung \"{type}\" (wie gespeichert erhalten)", + "Transform for row {number}": "Umwandlung für Zeile {number}", + "Transform of row {number}": "Umwandlung von Zeile {number}", + "Trim spaces": "Leerzeichen entfernen", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Geben Sie Werte so ein, wie sie in der Quelle stehen. Das Ergebnis verwendet die obige Zuordnung, einschließlich Änderungen, die Sie noch nicht gespeichert haben.", + "Undo changes": "Änderungen verwerfen", + "Value for other values": "Wert für andere Werte", + "Value in the source": "Wert in der Quelle", + "Value {number} is JSON": "Wert {number} ist JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Werte, mit denen jedes importierte Objekt beginnt. Eine Zuordnung, die einem Feld einen Wert gibt, ersetzt dessen Standardwert.", + "Values that are not in the table": "Werte, die nicht in der Tabelle stehen", + "View {name}": "{name} anzeigen", + "Yes/no table": "Ja/Nein-Tabelle", + "Yes/no table, values: {count}, {unmapped}": "Ja/Nein-Tabelle, Werte: {count}, {unmapped}", + "Store false": "Nein (false) speichern", + "other values are stored as false": "andere Werte werden als Nein (false) gespeichert" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.js b/l10n/el.js index 487f23fb6c..d96430d608 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Παρακολουθεί", "{completed} of {total} completed": "Ολοκληρώθηκαν {completed} από {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Απέτυχαν: {failed}, διακόπηκαν: {stopped}" + "{failed} failed, {stopped} stopped": "Απέτυχαν: {failed}, διακόπηκαν: {stopped}", + "A column to join has no name.": "Μια στήλη προς συνένωση δεν έχει όνομα.", + "A default value has no target field.": "Μια προεπιλεγμένη τιμή δεν έχει πεδίο προορισμού.", + "A fixed value is written even when the column is empty.": "Μια σταθερή τιμή εγγράφεται ακόμη και όταν η στήλη είναι κενή.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Ένα πακέτο μετεγκατάστασης ορίζει ποια στήλη ενός αρχείου εισαγωγής γίνεται ποιο πεδίο και πώς μετατρέπεται η τιμή της στην πορεία.", + "A pack definition is a JSON object.": "Ο ορισμός πακέτου είναι ένα αντικείμενο JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Μια μορφή ημερομηνίας PHP, όπως d-m-Y. Ξεκινήστε τη με ! ώστε τα μέρη που δεν ορίζει η μορφή να μη λαμβάνονται από τη σημερινή ημερομηνία. Αφήστε την κενή για ανάγνωση οποιασδήποτε συνήθους ημερομηνίας.", + "Add a field mapping with a source column to test it.": "Προσθέστε μια αντιστοίχιση πεδίου με στήλη προέλευσης για να τη δοκιμάσετε.", + "Add at least one column to join.": "Προσθέστε τουλάχιστον μία στήλη προς συνένωση.", + "Add at least one field mapping.": "Προσθέστε τουλάχιστον μία αντιστοίχιση πεδίου.", + "Add at least one value to the table.": "Προσθέστε τουλάχιστον μία τιμή στον πίνακα.", + "Add column": "Προσθήκη στήλης", + "Add default": "Προσθήκη προεπιλεγμένης τιμής", + "Add field mapping": "Προσθήκη αντιστοίχισης πεδίου", + "Add value": "Προσθήκη τιμής", + "Advanced (JSON)": "Για προχωρημένους (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Μια εφαρμογή που κάνει εισαγωγή με αυτό το πακέτο μπορεί να προσθέσει δικούς της κανόνες, για παράδειγμα ποιες στήλες περιέχουν ημερομηνίες ή από ποιο φύλλο προέρχεται μια γραμμή. Ελέγξτε αυτή την εφαρμογή πριν αλλάξετε ένα πακέτο που εγκατέστησε.", + "any format": "οποιαδήποτε μορφή", + "As JSON": "Ως JSON", + "Built-in": "Ενσωματωμένο", + "Choose a source format.": "Επιλέξτε μορφή προέλευσης.", + "Column to join {number}": "Στήλη προς συνένωση {number}", + "Column {source} to field {target}": "Στήλη {source} στο πεδίο {target}", + "Copied as is": "Αντιγράφεται ως έχει", + "Could not delete the migration pack.": "Δεν ήταν δυνατή η διαγραφή του πακέτου μετεγκατάστασης.", + "Could not import the file.": "Δεν ήταν δυνατή η εισαγωγή του αρχείου.", + "Could not load the migration pack.": "Δεν ήταν δυνατή η φόρτωση του πακέτου μετεγκατάστασης.", + "Could not load the migration packs.": "Δεν ήταν δυνατή η φόρτωση των πακέτων μετεγκατάστασης.", + "Could not save the migration pack.": "Δεν ήταν δυνατή η αποθήκευση του πακέτου μετεγκατάστασης.", + "Date {from} to {to}": "Ημερομηνία από {from} σε {to}", + "Defaults": "Προεπιλεγμένες τιμές", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Διαγραφή του \"{name}\"; Οι εισαγωγές που χρησιμοποιούν αυτό το πακέτο δεν θα το βρίσκουν πλέον.", + "Delete migration pack": "Διαγραφή πακέτου μετεγκατάστασης", + "Delete {name}": "Διαγραφή {name}", + "Deleted \"{name}\".": "Το \"{name}\" διαγράφηκε.", + "Edit {name}": "Επεξεργασία {name}", + "Export {name}": "Εξαγωγή {name}", + "Field mappings": "Αντιστοιχίσεις πεδίων", + "Fields the import would write": "Πεδία που θα έγραφε η εισαγωγή", + "Fix the problems in the pack first; then it can be tested.": "Διορθώστε πρώτα τα προβλήματα στο πακέτο· έπειτα μπορεί να δοκιμαστεί.", + "Fixed value": "Σταθερή τιμή", + "Fixed value {value}": "Σταθερή τιμή {value}", + "Form": "Φόρμα", + "General": "Γενικά", + "Generate a new id": "Δημιουργία νέου id", + "Id column": "Στήλη id", + "Import JSON": "Εισαγωγή JSON", + "Imported \"{name}\".": "Το \"{name}\" εισήχθη.", + "Imports find the pack by this slug.": "Οι εισαγωγές βρίσκουν το πακέτο μέσω αυτού του slug.", + "Join columns": "Συνένωση στηλών", + "Joined with {fields}, separated by \"{separator}\"": "Συνενωμένο με {fields}, με διαχωριστικό \"{separator}\"", + "Leave the field empty": "Να μείνει κενό το πεδίο", + "Lookup table": "Πίνακας αναζήτησης", + "Lookup table, values: {count}, {unmapped}": "Πίνακας αναζήτησης, τιμές: {count}, {unmapped}", + "Migration pack": "Πακέτο μετεγκατάστασης", + "Migration packs": "Πακέτα μετεγκατάστασης", + "Move row {number} down": "Μετακίνηση γραμμής {number} προς τα κάτω", + "Move row {number} up": "Μετακίνηση γραμμής {number} προς τα πάνω", + "Name the column that holds the id.": "Ορίστε τη στήλη που περιέχει το id.", + "No defaults.": "Δεν υπάρχουν προεπιλεγμένες τιμές.", + "No fields: every mapping was empty or failed.": "Κανένα πεδίο: κάθε αντιστοίχιση ήταν κενή ή απέτυχε.", + "No migration packs yet": "Δεν υπάρχουν ακόμη πακέτα μετεγκατάστασης", + "None: copy the value as is": "Κανένας: αντιγραφή της τιμής ως έχει", + "Object id": "Id αντικειμένου", + "Only administrators can change migration packs.": "Μόνο οι διαχειριστές μπορούν να αλλάξουν πακέτα μετεγκατάστασης.", + "Only administrators can change migration packs. You can read this one.": "Μόνο οι διαχειριστές μπορούν να αλλάξουν πακέτα μετεγκατάστασης. Μπορείτε να διαβάσετε αυτό το πακέτο.", + "other values are an error": "οι άλλες τιμές προκαλούν σφάλμα", + "other values get a default": "οι άλλες τιμές λαμβάνουν προεπιλεγμένη τιμή", + "other values stay empty": "οι άλλες τιμές μένουν κενές", + "Problem": "Πρόβλημα", + "Problems in this row": "Προβλήματα σε αυτή τη γραμμή", + "Reload the pack": "Επαναφόρτωση του πακέτου", + "Remove column {number}": "Αφαίρεση στήλης {number}", + "Remove row {number}": "Αφαίρεση γραμμής {number}", + "Report an error for the row": "Αναφορά σφάλματος για τη γραμμή", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Αριθμοί γραμμών στην προέλευση, χωρισμένοι με κόμματα. Η γραμμή 1 είναι η πρώτη γραμμή δεδομένων.", + "Row {number} has no source column.": "Η γραμμή {number} δεν έχει στήλη προέλευσης.", + "Row {number} has no target field.": "Η γραμμή {number} δεν έχει πεδίο προορισμού.", + "Row {number} is required": "Η γραμμή {number} είναι υποχρεωτική", + "Row {number}: {message}": "Γραμμή {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Οι γραμμές εκτελούνται από πάνω προς τα κάτω. Όταν δύο γραμμές γράφουν το ίδιο πεδίο και και οι δύο στήλες έχουν τιμή, υπερισχύει η κατώτερη γραμμή.", + "Rows to skip": "Γραμμές προς παράλειψη", + "Rows to skip must be whole numbers from 1 up: {values}": "Οι γραμμές προς παράλειψη πρέπει να είναι ακέραιοι αριθμοί από 1 και πάνω: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Οι γραμμές {rows} γράφουν αυτό το πεδίο· υπερισχύει η χαμηλότερη γραμμή με τιμή.", + "Sample value": "Δείγμα τιμής", + "Sample value for {column}": "Δείγμα τιμής για {column}", + "Saved as version {version}.": "Αποθηκεύτηκε ως έκδοση {version}.", + "Saving raises the version to {version}.": "Η αποθήκευση αυξάνει την έκδοση σε {version}.", + "Separator": "Διαχωριστικό", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Κάποιος άλλος αποθήκευσε αυτό το πακέτο αφού το ανοίξατε. Επαναφορτώστε το για να δείτε τις αλλαγές του· οι αλλαγές σας δεν έχουν αποθηκευτεί.", + "Source column": "Στήλη προέλευσης", + "Source column of row {number}": "Στήλη προέλευσης της γραμμής {number}", + "Source date format": "Μορφή ημερομηνίας προέλευσης", + "Source format": "Μορφή προέλευσης", + "Store this value": "Αποθήκευση αυτής της τιμής", + "Stored date format": "Μορφή αποθηκευμένης ημερομηνίας", + "Stored value": "Αποθηκευμένη τιμή", + "Take the id from a column": "Λήψη του id από μια στήλη", + "Target field": "Πεδίο προορισμού", + "Target field of row {number}": "Πεδίο προορισμού της γραμμής {number}", + "Test with a sample row": "Δοκιμή με δείγμα γραμμής", + "The default for \"{key}\" is not valid JSON.": "Η προεπιλεγμένη τιμή για το \"{key}\" δεν είναι έγκυρο JSON.", + "The default for \"{key}\" is set twice.": "Η προεπιλεγμένη τιμή για το \"{key}\" έχει οριστεί δύο φορές.", + "The fixed value is not valid JSON.": "Η σταθερή τιμή δεν είναι έγκυρο JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Η φόρμα περιέχει μια τιμή που δεν είναι έγκυρο JSON. Διορθώστε την πρώτα.", + "The name is required.": "Το όνομα είναι υποχρεωτικό.", + "The pack cannot be saved yet.": "Το πακέτο δεν μπορεί να αποθηκευτεί ακόμη.", + "The pack is not valid.": "Το πακέτο δεν είναι έγκυρο.", + "The result for \"{key}\" is not valid JSON.": "Το αποτέλεσμα για το \"{key}\" δεν είναι έγκυρο JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Το slug πρέπει να ξεκινά με πεζό γράμμα ή ψηφίο και να περιέχει μόνο πεζά γράμματα, ψηφία και παύλες.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Το slug ενός ενσωματωμένου πακέτου δεν μπορεί να αλλάξει. Διατηρήστε το \"id\" ως {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Το slug ενός ενσωματωμένου πακέτου δεν μπορεί να αλλάξει: η εφαρμογή που το εγκατέστησε βρίσκει το πακέτο μέσω αυτού του slug.", + "The test could not run.": "Δεν ήταν δυνατή η εκτέλεση της δοκιμής.", + "The value \"{key}\" is in the table twice.": "Η τιμή \"{key}\" υπάρχει δύο φορές στον πίνακα.", + "The value for other values is not valid JSON.": "Η τιμή για τις άλλες τιμές δεν είναι έγκυρο JSON.", + "The value of column {source} comes first, then these columns in order.": "Πρώτα έρχεται η τιμή της στήλης {source} και μετά αυτές οι στήλες με τη σειρά.", + "The version must look like 1.0.0 (major.minor.patch).": "Η έκδοση πρέπει να έχει τη μορφή 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Ολόκληρος ο ορισμός του πακέτου. Οι αλλαγές εδώ αντικαθιστούν τη φόρμα όταν επιστρέψετε σε αυτήν ή αποθηκεύσετε.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Αυτός ο επεξεργαστής δεν γνωρίζει τον τύπο μετασχηματισμού \"{type}\". Διατηρείται όπως είναι αποθηκευμένος.", + "This is not valid JSON: {reason}": "Αυτό δεν είναι έγκυρο JSON: {reason}", + "This migration pack does not exist.": "Αυτό το πακέτο μετεγκατάστασης δεν υπάρχει.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Αυτό το πακέτο εγκαταστάθηκε από μια εφαρμογή. Η εφαρμογή το εγκαθιστά ξανά, χωρίς επεξεργασίες, την επόμενη φορά που θα εγκατασταθεί ή θα ενημερωθεί.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Αυτός ο μετασχηματισμός έχει επίσης ρυθμίσεις που δεν εμφανίζει αυτός ο επεξεργαστής ({keys}). Διατηρούνται κατά την αποθήκευση.", + "Transform": "Μετασχηματισμός", + "Transform \"{type}\" (kept as stored)": "Μετασχηματισμός \"{type}\" (διατηρείται όπως είναι αποθηκευμένος)", + "Transform for row {number}": "Μετασχηματισμός για τη γραμμή {number}", + "Transform of row {number}": "Μετασχηματισμός της γραμμής {number}", + "Trim spaces": "Αφαίρεση κενών στα άκρα", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Πληκτρολογήστε τις τιμές όπως εμφανίζονται στην προέλευση. Το αποτέλεσμα χρησιμοποιεί την παραπάνω αντιστοίχιση, μαζί με τις αλλαγές που δεν έχετε αποθηκεύσει.", + "Undo changes": "Αναίρεση αλλαγών", + "Value for other values": "Τιμή για τις άλλες τιμές", + "Value in the source": "Τιμή στην προέλευση", + "Value {number} is JSON": "Η τιμή {number} είναι JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Τιμές με τις οποίες ξεκινά κάθε εισαγόμενο αντικείμενο. Μια αντιστοίχιση που δίνει τιμή σε ένα πεδίο αντικαθιστά την προεπιλεγμένη του τιμή.", + "Values that are not in the table": "Τιμές που δεν υπάρχουν στον πίνακα", + "View {name}": "Προβολή {name}", + "Yes/no table": "Πίνακας ναι/όχι", + "Yes/no table, values: {count}, {unmapped}": "Πίνακας ναι/όχι, τιμές: {count}, {unmapped}", + "Store false": "Αποθήκευση ως όχι (false)", + "other values are stored as false": "οι άλλες τιμές αποθηκεύονται ως όχι (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.js b/l10n/es.js index 2e7772956b..80a6874411 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Vigila", "{completed} of {total} completed": "{completed} de {total} completadas", "{date} by {person}": "{date} por {person}", - "{failed} failed, {stopped} stopped": "{failed} fallidas, {stopped} detenidas" + "{failed} failed, {stopped} stopped": "{failed} fallidas, {stopped} detenidas", + "A column to join has no name.": "Una columna que se va a unir no tiene nombre.", + "A default value has no target field.": "Un valor predeterminado no tiene campo de destino.", + "A fixed value is written even when the column is empty.": "Un valor fijo se escribe aunque la columna esté vacía.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Un paquete de migración indica qué columna de un archivo de importación se convierte en qué campo y cómo se transforma su valor por el camino.", + "A pack definition is a JSON object.": "Una definición de paquete es un objeto JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Un formato de fecha de PHP, como d-m-Y. Empiécelo con ! para que las partes que el formato no nombra no se tomen de la fecha de hoy. Déjelo vacío para leer cualquier fecha habitual.", + "Add a field mapping with a source column to test it.": "Añada un mapeo de campo con una columna de origen para probarlo.", + "Add at least one column to join.": "Añada al menos una columna para unir.", + "Add at least one field mapping.": "Añada al menos un mapeo de campo.", + "Add at least one value to the table.": "Añada al menos un valor a la tabla.", + "Add column": "Añadir columna", + "Add default": "Añadir valor predeterminado", + "Add field mapping": "Añadir mapeo de campo", + "Add value": "Añadir valor", + "Advanced (JSON)": "Avanzado (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Una aplicación que importa con este paquete puede añadir sus propias reglas, por ejemplo, qué columnas contienen fechas o de qué hoja procede una fila. Revise esa aplicación antes de cambiar un paquete que ella instaló.", + "any format": "cualquier formato", + "As JSON": "Como JSON", + "Built-in": "Integrado", + "Choose a source format.": "Elija un formato de origen.", + "Column to join {number}": "Columna para unir {number}", + "Column {source} to field {target}": "Columna {source} al campo {target}", + "Copied as is": "Copiado tal cual", + "Could not delete the migration pack.": "No se pudo eliminar el paquete de migración.", + "Could not import the file.": "No se pudo importar el archivo.", + "Could not load the migration pack.": "No se pudo cargar el paquete de migración.", + "Could not load the migration packs.": "No se pudieron cargar los paquetes de migración.", + "Could not save the migration pack.": "No se pudo guardar el paquete de migración.", + "Date {from} to {to}": "Fecha de {from} a {to}", + "Defaults": "Valores predeterminados", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "¿Eliminar \"{name}\"? Las importaciones que usan este paquete ya no lo encontrarán.", + "Delete migration pack": "Eliminar paquete de migración", + "Delete {name}": "Eliminar {name}", + "Deleted \"{name}\".": "Se ha eliminado \"{name}\".", + "Edit {name}": "Editar {name}", + "Export {name}": "Exportar {name}", + "Field mappings": "Mapeos de campos", + "Fields the import would write": "Campos que escribiría la importación", + "Fix the problems in the pack first; then it can be tested.": "Primero corrija los problemas del paquete; después se podrá probar.", + "Fixed value": "Valor fijo", + "Fixed value {value}": "Valor fijo {value}", + "Form": "Formulario", + "General": "Datos generales", + "Generate a new id": "Generar un id nuevo", + "Id column": "Columna de id", + "Import JSON": "Importar JSON", + "Imported \"{name}\".": "Se ha importado \"{name}\".", + "Imports find the pack by this slug.": "Las importaciones encuentran el paquete por este slug.", + "Join columns": "Unir columnas", + "Joined with {fields}, separated by \"{separator}\"": "Unido con {fields}, separado por \"{separator}\"", + "Leave the field empty": "Dejar el campo vacío", + "Lookup table": "Tabla de búsqueda", + "Lookup table, values: {count}, {unmapped}": "Tabla de búsqueda, valores: {count}, {unmapped}", + "Migration pack": "Paquete de migración", + "Migration packs": "Paquetes de migración", + "Move row {number} down": "Bajar la fila {number}", + "Move row {number} up": "Subir la fila {number}", + "Name the column that holds the id.": "Indique la columna que contiene el id.", + "No defaults.": "Sin valores predeterminados.", + "No fields: every mapping was empty or failed.": "Sin campos: todos los mapeos estaban vacíos o fallaron.", + "No migration packs yet": "Aún no hay paquetes de migración", + "None: copy the value as is": "Ninguna: copiar el valor tal cual", + "Object id": "Id del objeto", + "Only administrators can change migration packs.": "Solo los administradores pueden cambiar los paquetes de migración.", + "Only administrators can change migration packs. You can read this one.": "Solo los administradores pueden cambiar los paquetes de migración. Puede consultar este.", + "other values are an error": "los demás valores producen un error", + "other values get a default": "los demás valores reciben un valor predeterminado", + "other values stay empty": "los demás valores quedan vacíos", + "Problem": "Problema", + "Problems in this row": "Problemas en esta fila", + "Reload the pack": "Recargar el paquete", + "Remove column {number}": "Eliminar la columna {number}", + "Remove row {number}": "Eliminar la fila {number}", + "Report an error for the row": "Notificar un error para la fila", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Números de fila en el origen, separados por comas. La fila 1 es la primera fila de datos.", + "Row {number} has no source column.": "La fila {number} no tiene columna de origen.", + "Row {number} has no target field.": "La fila {number} no tiene campo de destino.", + "Row {number} is required": "La fila {number} es obligatoria", + "Row {number}: {message}": "Fila {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Las filas se aplican de arriba abajo. Cuando dos filas escriben el mismo campo y ambas columnas tienen un valor, gana la fila inferior.", + "Rows to skip": "Filas que omitir", + "Rows to skip must be whole numbers from 1 up: {values}": "Las filas que omitir deben ser números enteros a partir de 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Las filas {rows} escriben este campo; gana la fila más baja con un valor.", + "Sample value": "Valor de ejemplo", + "Sample value for {column}": "Valor de ejemplo para {column}", + "Saved as version {version}.": "Guardado como versión {version}.", + "Saving raises the version to {version}.": "Al guardar, la versión sube a {version}.", + "Separator": "Separador", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Otra persona guardó este paquete después de que usted lo abriera. Recárguelo para ver esos cambios; sus cambios no se han guardado.", + "Source column": "Columna de origen", + "Source column of row {number}": "Columna de origen de la fila {number}", + "Source date format": "Formato de fecha de origen", + "Source format": "Formato de origen", + "Store this value": "Guardar este valor", + "Stored date format": "Formato de fecha guardado", + "Stored value": "Valor guardado", + "Take the id from a column": "Tomar el id de una columna", + "Target field": "Campo de destino", + "Target field of row {number}": "Campo de destino de la fila {number}", + "Test with a sample row": "Probar con una fila de ejemplo", + "The default for \"{key}\" is not valid JSON.": "El valor predeterminado de \"{key}\" no es JSON válido.", + "The default for \"{key}\" is set twice.": "El valor predeterminado de \"{key}\" está definido dos veces.", + "The fixed value is not valid JSON.": "El valor fijo no es JSON válido.", + "The form has a value that is not valid JSON. Fix it first.": "El formulario tiene un valor que no es JSON válido. Corríjalo primero.", + "The name is required.": "El nombre es obligatorio.", + "The pack cannot be saved yet.": "El paquete aún no se puede guardar.", + "The pack is not valid.": "El paquete no es válido.", + "The result for \"{key}\" is not valid JSON.": "El resultado de \"{key}\" no es JSON válido.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "El slug debe empezar por una letra minúscula o un dígito y contener solo letras minúsculas, dígitos y guiones.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "El slug de un paquete integrado no puede cambiar. Mantenga \"id\" como {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "El slug de un paquete integrado no puede cambiar: la aplicación que lo instaló encuentra el paquete por este slug.", + "The test could not run.": "No se pudo ejecutar la prueba.", + "The value \"{key}\" is in the table twice.": "El valor \"{key}\" aparece dos veces en la tabla.", + "The value for other values is not valid JSON.": "El valor para los demás valores no es JSON válido.", + "The value of column {source} comes first, then these columns in order.": "Primero va el valor de la columna {source} y después estas columnas, en orden.", + "The version must look like 1.0.0 (major.minor.patch).": "La versión debe tener el formato 1.0.0 (mayor.menor.parche).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "La definición completa del paquete. Los cambios hechos aquí sustituyen al formulario cuando vuelva a él o guarde.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Este editor no conoce el tipo de transformación \"{type}\". Se conserva tal como está guardado.", + "This is not valid JSON: {reason}": "Esto no es JSON válido: {reason}", + "This migration pack does not exist.": "Este paquete de migración no existe.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Este paquete lo instaló una aplicación. Esa aplicación lo vuelve a instalar, sin modificaciones, la próxima vez que se instale o se actualice.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Esta transformación también tiene ajustes que este editor no muestra ({keys}). Se conservan al guardar.", + "Transform": "Transformación", + "Transform \"{type}\" (kept as stored)": "Transformación \"{type}\" (se conserva tal como está guardada)", + "Transform for row {number}": "Transformación para la fila {number}", + "Transform of row {number}": "Transformación de la fila {number}", + "Trim spaces": "Recortar espacios", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Escriba los valores tal como aparecen en el origen. El resultado usa el mapeo de arriba, incluidos los cambios que aún no ha guardado.", + "Undo changes": "Deshacer cambios", + "Value for other values": "Valor para los demás valores", + "Value in the source": "Valor en el origen", + "Value {number} is JSON": "El valor {number} es JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Valores con los que empieza cada objeto importado. Un mapeo que da un valor a un campo sustituye su valor predeterminado.", + "Values that are not in the table": "Valores que no están en la tabla", + "View {name}": "Ver {name}", + "Yes/no table": "Tabla de sí/no", + "Yes/no table, values: {count}, {unmapped}": "Tabla de sí/no, valores: {count}, {unmapped}", + "Store false": "Guardar no (false)", + "other values are stored as false": "los demás valores se guardan como no (false)" }, "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" ) diff --git a/l10n/et.js b/l10n/et.js index 6a4f5da2c9..584983e714 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Jälgib", "{completed} of {total} completed": "{completed}/{total} lõpetatud", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "{failed} ebaõnnestus, {stopped} peatatud" + "{failed} failed, {stopped} stopped": "{failed} ebaõnnestus, {stopped} peatatud", + "A column to join has no name.": "Ühendataval veerul pole nime.", + "A default value has no target field.": "Vaikeväärtusel pole sihtvälja.", + "A fixed value is written even when the column is empty.": "Fikseeritud väärtus kirjutatakse ka siis, kui veerg on tühi.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Migratsioonipakett määrab, millisest impordifaili veerust saab milline väli ja kuidas selle väärtus teel teisendatakse.", + "A pack definition is a JSON object.": "Paketi definitsioon on JSON-objekt.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP kuupäevavorming, näiteks d-m-Y. Alusta seda märgiga !, et osi, mida vorming ei nimeta, ei võetaks tänasest kuupäevast. Jäta tühjaks, et lugeda mis tahes levinud kuupäeva.", + "Add a field mapping with a source column to test it.": "Testimiseks lisa lähteveeruga väljavastendus.", + "Add at least one column to join.": "Lisa vähemalt üks ühendatav veerg.", + "Add at least one field mapping.": "Lisa vähemalt üks väljavastendus.", + "Add at least one value to the table.": "Lisa tabelisse vähemalt üks väärtus.", + "Add column": "Lisa veerg", + "Add default": "Lisa vaikeväärtus", + "Add field mapping": "Lisa väljavastendus", + "Add value": "Lisa väärtus", + "Advanced (JSON)": "Täpsem (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Rakendus, mis selle paketiga impordib, võib lisada oma reegleid, näiteks millistes veergudes on kuupäevad või milliselt lehelt rida pärineb. Kontrolli seda rakendust enne, kui muudad paketti, mille see paigaldas.", + "any format": "mis tahes vorming", + "As JSON": "JSON-ina", + "Built-in": "Sisseehitatud", + "Choose a source format.": "Vali lähtevorming.", + "Column to join {number}": "Ühendatav veerg {number}", + "Column {source} to field {target}": "Veerg {source} väljale {target}", + "Copied as is": "Kopeeritud muutmata kujul", + "Could not delete the migration pack.": "Migratsioonipaketi kustutamine ebaõnnestus.", + "Could not import the file.": "Faili importimine ebaõnnestus.", + "Could not load the migration pack.": "Migratsioonipaketi laadimine ebaõnnestus.", + "Could not load the migration packs.": "Migratsioonipakettide laadimine ebaõnnestus.", + "Could not save the migration pack.": "Migratsioonipaketi salvestamine ebaõnnestus.", + "Date {from} to {to}": "Kuupäev vormingust {from} vormingusse {to}", + "Defaults": "Vaikeväärtused", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Kas kustutada „{name}“? Seda paketti kasutavad impordid ei leia seda enam.", + "Delete migration pack": "Kustuta migratsioonipakett", + "Delete {name}": "Kustuta {name}", + "Deleted \"{name}\".": "„{name}“ on kustutatud.", + "Edit {name}": "Muuda {name}", + "Export {name}": "Ekspordi {name}", + "Field mappings": "Väljavastendused", + "Fields the import would write": "Väljad, mida import kirjutaks", + "Fix the problems in the pack first; then it can be tested.": "Paranda esmalt paketi probleemid; siis saab seda testida.", + "Fixed value": "Fikseeritud väärtus", + "Fixed value {value}": "Fikseeritud väärtus {value}", + "Form": "Vorm", + "General": "Üldine", + "Generate a new id": "Loo uus ID", + "Id column": "ID-veerg", + "Import JSON": "Impordi JSON", + "Imported \"{name}\".": "„{name}“ on imporditud.", + "Imports find the pack by this slug.": "Impordid leiavad paketi selle identifikaatori järgi.", + "Join columns": "Ühenda veerud", + "Joined with {fields}, separated by \"{separator}\"": "Ühendatud väljadega {fields}, eraldajaks „{separator}“", + "Leave the field empty": "Jäta väli tühjaks", + "Lookup table": "Vastavustabel", + "Lookup table, values: {count}, {unmapped}": "Vastavustabel, väärtused: {count}, {unmapped}", + "Migration pack": "Migratsioonipakett", + "Migration packs": "Migratsioonipaketid", + "Move row {number} down": "Liiguta rida {number} alla", + "Move row {number} up": "Liiguta rida {number} üles", + "Name the column that holds the id.": "Nimeta veerg, milles on ID.", + "No defaults.": "Vaikeväärtusi pole.", + "No fields: every mapping was empty or failed.": "Välju pole: iga vastendus oli tühi või ebaõnnestus.", + "No migration packs yet": "Migratsioonipakette veel pole", + "None: copy the value as is": "Puudub: kopeeri väärtus muutmata kujul", + "Object id": "Objekti ID", + "Only administrators can change migration packs.": "Migratsioonipakette saavad muuta ainult administraatorid.", + "Only administrators can change migration packs. You can read this one.": "Migratsioonipakette saavad muuta ainult administraatorid. Seda paketti saad lugeda.", + "other values are an error": "muud väärtused on viga", + "other values get a default": "muud väärtused saavad vaikeväärtuse", + "other values stay empty": "muud väärtused jäävad tühjaks", + "Problem": "Probleem", + "Problems in this row": "Probleemid selles reas", + "Reload the pack": "Laadi pakett uuesti", + "Remove column {number}": "Eemalda veerg {number}", + "Remove row {number}": "Eemalda rida {number}", + "Report an error for the row": "Teata rea kohta viga", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Reanumbrid allikas, komadega eraldatud. Rida 1 on esimene andmerida.", + "Row {number} has no source column.": "Real {number} pole lähteveergu.", + "Row {number} has no target field.": "Real {number} pole sihtvälja.", + "Row {number} is required": "Rida {number} on kohustuslik", + "Row {number}: {message}": "Rida {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Read täidetakse ülalt alla. Kui kaks rida kirjutavad sama välja ja mõlemas veerus on väärtus, jääb peale alumine rida.", + "Rows to skip": "Vahelejäetavad read", + "Rows to skip must be whole numbers from 1 up: {values}": "Vahelejäetavad read peavad olema täisarvud alates 1-st: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Read {rows} kirjutavad seda välja; peale jääb kõige alumine väärtusega rida.", + "Sample value": "Näidisväärtus", + "Sample value for {column}": "Näidisväärtus veerule {column}", + "Saved as version {version}.": "Salvestatud versioonina {version}.", + "Saving raises the version to {version}.": "Salvestamisel tõuseb versioon väärtusele {version}.", + "Separator": "Eraldaja", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Keegi teine salvestas selle paketi pärast seda, kui sa selle avasid. Laadi see uuesti, et näha tema muudatusi; sinu muudatusi ei salvestatud.", + "Source column": "Lähteveerg", + "Source column of row {number}": "Rea {number} lähteveerg", + "Source date format": "Kuupäevavorming allikas", + "Source format": "Lähtevorming", + "Store this value": "Salvesta see väärtus", + "Stored date format": "Salvestatav kuupäevavorming", + "Stored value": "Salvestatud väärtus", + "Take the id from a column": "Võta ID veerust", + "Target field": "Sihtväli", + "Target field of row {number}": "Rea {number} sihtväli", + "Test with a sample row": "Testi näidisreaga", + "The default for \"{key}\" is not valid JSON.": "„{key}“ vaikeväärtus ei ole kehtiv JSON.", + "The default for \"{key}\" is set twice.": "„{key}“ vaikeväärtus on määratud kaks korda.", + "The fixed value is not valid JSON.": "Fikseeritud väärtus ei ole kehtiv JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Vormis on väärtus, mis ei ole kehtiv JSON. Paranda see esmalt.", + "The name is required.": "Nimi on kohustuslik.", + "The pack cannot be saved yet.": "Paketti ei saa veel salvestada.", + "The pack is not valid.": "Pakett ei ole kehtiv.", + "The result for \"{key}\" is not valid JSON.": "„{key}“ tulemus ei ole kehtiv JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Identifikaator peab algama väiketähe või numbriga ja tohib sisaldada ainult väiketähti, numbreid ja sidekriipse.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Sisseehitatud paketi identifikaatorit ei saa muuta. Jäta \"id\" väärtuseks {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Sisseehitatud paketi identifikaatorit ei saa muuta: rakendus, mis selle paigaldas, leiab paketi selle identifikaatori järgi.", + "The test could not run.": "Testi ei õnnestunud käivitada.", + "The value \"{key}\" is in the table twice.": "Väärtus „{key}“ on tabelis kaks korda.", + "The value for other values is not valid JSON.": "Muude väärtuste väärtus ei ole kehtiv JSON.", + "The value of column {source} comes first, then these columns in order.": "Esimesena tuleb veeru {source} väärtus, seejärel need veerud järjekorras.", + "The version must look like 1.0.0 (major.minor.patch).": "Versioon peab olema kujul 1.0.0 (peaversioon.alamversioon.parandus).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Kogu paketi definitsioon. Siinsed muudatused asendavad vormi, kui lähed selle juurde tagasi või salvestad.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "See redaktor ei tunne teisendustüüpi „{type}“. See säilitatakse salvestatud kujul.", + "This is not valid JSON: {reason}": "See ei ole kehtiv JSON: {reason}", + "This migration pack does not exist.": "Seda migratsioonipaketti pole olemas.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Selle paketi paigaldas rakendus. See rakendus paigaldab paketi muutmata kujul uuesti, kui rakendus järgmine kord paigaldatakse või uuendatakse.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Sellel teisendusel on ka sätteid, mida see redaktor ei näita ({keys}). Salvestamisel need säilitatakse.", + "Transform": "Teisendus", + "Transform \"{type}\" (kept as stored)": "Teisendus „{type}“ (säilitatud salvestatud kujul)", + "Transform for row {number}": "Teisendus reale {number}", + "Transform of row {number}": "Rea {number} teisendus", + "Trim spaces": "Kärbi tühikud", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Sisesta väärtused nii, nagu need allikas on. Tulemus kasutab ülalolevat vastendust, sealhulgas muudatusi, mida sa pole veel salvestanud.", + "Undo changes": "Võta muudatused tagasi", + "Value for other values": "Muude väärtuste väärtus", + "Value in the source": "Väärtus allikas", + "Value {number} is JSON": "Väärtus {number} on JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Väärtused, millega iga imporditud objekt algab. Vastendus, mis annab väljale väärtuse, asendab selle vaikeväärtuse.", + "Values that are not in the table": "Väärtused, mida tabelis pole", + "View {name}": "Vaata {name}", + "Yes/no table": "Jah/ei-tabel", + "Yes/no table, values: {count}, {unmapped}": "Jah/ei-tabel, väärtused: {count}, {unmapped}", + "Store false": "Salvesta ei (false)", + "other values are stored as false": "muud väärtused salvestatakse kui ei (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.js b/l10n/fi.js index 7f46bbcdd8..00b160c258 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Seuraa", "{completed} of {total} completed": "{completed}/{total} valmiina", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "{failed} epäonnistui, {stopped} pysäytetty" + "{failed} failed, {stopped} stopped": "{failed} epäonnistui, {stopped} pysäytetty", + "A column to join has no name.": "Yhdistettävällä sarakkeella ei ole nimeä.", + "A default value has no target field.": "Oletusarvolla ei ole kohdekenttää.", + "A fixed value is written even when the column is empty.": "Kiinteä arvo kirjoitetaan, vaikka sarake olisi tyhjä.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Migraatiopaketti määrittää, mistä tuontitiedoston sarakkeesta tulee mikin kenttä ja miten sen arvo muunnetaan matkalla.", + "A pack definition is a JSON object.": "Paketin määritys on JSON-objekti.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP-päivämäärämuoto, kuten d-m-Y. Aloita se merkillä !, jotta osia, joita muoto ei nimeä, ei oteta tästä päivästä. Jätä tyhjäksi, jos haluat lukea minkä tahansa tavallisen päivämäärän.", + "Add a field mapping with a source column to test it.": "Lisää kenttäkartoitus, jolla on lähdesarake, jotta voit testata sitä.", + "Add at least one column to join.": "Lisää vähintään yksi yhdistettävä sarake.", + "Add at least one field mapping.": "Lisää vähintään yksi kenttäkartoitus.", + "Add at least one value to the table.": "Lisää taulukkoon vähintään yksi arvo.", + "Add column": "Lisää sarake", + "Add default": "Lisää oletusarvo", + "Add field mapping": "Lisää kenttäkartoitus", + "Add value": "Lisää arvo", + "Advanced (JSON)": "Lisäasetukset (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Sovellus, joka tuo tietoja tällä paketilla, voi lisätä omia sääntöjään, esimerkiksi mitkä sarakkeet sisältävät päivämääriä tai miltä taulukkosivulta rivi tulee. Tarkista kyseinen sovellus ennen kuin muutat sen asentamaa pakettia.", + "any format": "mikä tahansa muoto", + "As JSON": "JSON-muodossa", + "Built-in": "Sisäänrakennettu", + "Choose a source format.": "Valitse lähdemuoto.", + "Column to join {number}": "Yhdistettävä sarake {number}", + "Column {source} to field {target}": "Sarake {source} kenttään {target}", + "Copied as is": "Kopioitu sellaisenaan", + "Could not delete the migration pack.": "Migraatiopakettia ei voitu poistaa.", + "Could not import the file.": "Tiedostoa ei voitu tuoda.", + "Could not load the migration pack.": "Migraatiopakettia ei voitu ladata.", + "Could not load the migration packs.": "Migraatiopaketteja ei voitu ladata.", + "Could not save the migration pack.": "Migraatiopakettia ei voitu tallentaa.", + "Date {from} to {to}": "Päivämäärä muodosta {from} muotoon {to}", + "Defaults": "Oletusarvot", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Poistetaanko \"{name}\"? Tätä pakettia käyttävät tuonnit eivät enää löydä sitä.", + "Delete migration pack": "Poista migraatiopaketti", + "Delete {name}": "Poista kohde {name}", + "Deleted \"{name}\".": "\"{name}\" poistettiin.", + "Edit {name}": "Muokkaa kohdetta {name}", + "Export {name}": "Vie kohde {name}", + "Field mappings": "Kenttäkartoitukset", + "Fields the import would write": "Kentät, jotka tuonti kirjoittaisi", + "Fix the problems in the pack first; then it can be tested.": "Korjaa ensin paketin ongelmat; sen jälkeen sitä voi testata.", + "Fixed value": "Kiinteä arvo", + "Fixed value {value}": "Kiinteä arvo {value}", + "Form": "Lomake", + "General": "Yleiset", + "Generate a new id": "Luo uusi tunnus", + "Id column": "Tunnussarake", + "Import JSON": "Tuo JSON", + "Imported \"{name}\".": "\"{name}\" tuotiin.", + "Imports find the pack by this slug.": "Tuonnit löytävät paketin tämän slugin avulla.", + "Join columns": "Yhdistä sarakkeita", + "Joined with {fields}, separated by \"{separator}\"": "Yhdistetty sarakkeisiin {fields}, erottimena \"{separator}\"", + "Leave the field empty": "Jätä kenttä tyhjäksi", + "Lookup table": "Hakutaulukko", + "Lookup table, values: {count}, {unmapped}": "Hakutaulukko, arvoja: {count}, {unmapped}", + "Migration pack": "Migraatiopaketti", + "Migration packs": "Migraatiopaketit", + "Move row {number} down": "Siirrä riviä {number} alas", + "Move row {number} up": "Siirrä riviä {number} ylös", + "Name the column that holds the id.": "Nimeä sarake, joka sisältää tunnuksen.", + "No defaults.": "Ei oletusarvoja.", + "No fields: every mapping was empty or failed.": "Ei kenttiä: jokainen kartoitus oli tyhjä tai epäonnistui.", + "No migration packs yet": "Ei vielä migraatiopaketteja", + "None: copy the value as is": "Ei mitään: kopioi arvo sellaisenaan", + "Object id": "Objektin tunnus", + "Only administrators can change migration packs.": "Vain ylläpitäjät voivat muuttaa migraatiopaketteja.", + "Only administrators can change migration packs. You can read this one.": "Vain ylläpitäjät voivat muuttaa migraatiopaketteja. Voit lukea tämän paketin.", + "other values are an error": "muut arvot aiheuttavat virheen", + "other values get a default": "muut arvot saavat oletusarvon", + "other values stay empty": "muut arvot jäävät tyhjiksi", + "Problem": "Ongelma", + "Problems in this row": "Tämän rivin ongelmat", + "Reload the pack": "Lataa paketti uudelleen", + "Remove column {number}": "Poista sarake {number}", + "Remove row {number}": "Poista rivi {number}", + "Report an error for the row": "Ilmoita virhe rivistä", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Lähteen rivinumerot pilkuilla erotettuina. Rivi 1 on ensimmäinen tietorivi.", + "Row {number} has no source column.": "Rivillä {number} ei ole lähdesaraketta.", + "Row {number} has no target field.": "Rivillä {number} ei ole kohdekenttää.", + "Row {number} is required": "Rivi {number} on pakollinen", + "Row {number}: {message}": "Rivi {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Rivit käsitellään ylhäältä alas. Kun kaksi riviä kirjoittaa saman kentän ja kummassakin sarakkeessa on arvo, alempi rivi voittaa.", + "Rows to skip": "Ohitettavat rivit", + "Rows to skip must be whole numbers from 1 up: {values}": "Ohitettavien rivien on oltava kokonaislukuja 1:stä alkaen: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Rivit {rows} kirjoittavat tämän kentän; alin rivi, jolla on arvo, voittaa.", + "Sample value": "Esimerkkiarvo", + "Sample value for {column}": "Esimerkkiarvo sarakkeelle {column}", + "Saved as version {version}.": "Tallennettu versiona {version}.", + "Saving raises the version to {version}.": "Tallentaminen nostaa version arvoon {version}.", + "Separator": "Erotin", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Joku muu tallensi tämän paketin sen jälkeen, kun avasit sen. Lataa se uudelleen nähdäksesi hänen muutoksensa; muutoksiasi ei ole tallennettu.", + "Source column": "Lähdesarake", + "Source column of row {number}": "Rivin {number} lähdesarake", + "Source date format": "Lähteen päivämäärämuoto", + "Source format": "Lähdemuoto", + "Store this value": "Tallenna tämä arvo", + "Stored date format": "Tallennettu päivämäärämuoto", + "Stored value": "Tallennettu arvo", + "Take the id from a column": "Ota tunnus sarakkeesta", + "Target field": "Kohdekenttä", + "Target field of row {number}": "Rivin {number} kohdekenttä", + "Test with a sample row": "Testaa esimerkkirivillä", + "The default for \"{key}\" is not valid JSON.": "Kohteen \"{key}\" oletusarvo ei ole kelvollista JSONia.", + "The default for \"{key}\" is set twice.": "Kohteen \"{key}\" oletusarvo on asetettu kahdesti.", + "The fixed value is not valid JSON.": "Kiinteä arvo ei ole kelvollista JSONia.", + "The form has a value that is not valid JSON. Fix it first.": "Lomakkeessa on arvo, joka ei ole kelvollista JSONia. Korjaa se ensin.", + "The name is required.": "Nimi on pakollinen.", + "The pack cannot be saved yet.": "Pakettia ei voi vielä tallentaa.", + "The pack is not valid.": "Paketti ei ole kelvollinen.", + "The result for \"{key}\" is not valid JSON.": "Kohteen \"{key}\" tulos ei ole kelvollista JSONia.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slugin on alettava pienellä kirjaimella tai numerolla, ja se saa sisältää vain pieniä kirjaimia, numeroita ja yhdysmerkkejä.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Sisäänrakennetun paketin slugia ei voi muuttaa. Pidä \"id\" arvossa {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Sisäänrakennetun paketin slugia ei voi muuttaa: sen asentanut sovellus löytää paketin tämän slugin avulla.", + "The test could not run.": "Testiä ei voitu suorittaa.", + "The value \"{key}\" is in the table twice.": "Arvo \"{key}\" on taulukossa kahdesti.", + "The value for other values is not valid JSON.": "Muiden arvojen arvo ei ole kelvollista JSONia.", + "The value of column {source} comes first, then these columns in order.": "Sarakkeen {source} arvo tulee ensin, sitten nämä sarakkeet järjestyksessä.", + "The version must look like 1.0.0 (major.minor.patch).": "Version on oltava muotoa 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Koko paketin määritys. Täällä tehdyt muutokset korvaavat lomakkeen, kun palaat siihen tai tallennat.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Tämä editori ei tunne muunnostyyppiä \"{type}\". Se säilytetään tallennetussa muodossa.", + "This is not valid JSON: {reason}": "Tämä ei ole kelvollista JSONia: {reason}", + "This migration pack does not exist.": "Tätä migraatiopakettia ei ole olemassa.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Tämän paketin asensi sovellus. Kyseinen sovellus asentaa sen uudelleen muokkaamattomana, kun se seuraavan kerran asennetaan tai päivitetään.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Tällä muunnoksella on myös asetuksia, joita tämä editori ei näytä ({keys}). Ne säilytetään, kun tallennat.", + "Transform": "Muunnos", + "Transform \"{type}\" (kept as stored)": "Muunnos \"{type}\" (säilytetty tallennetussa muodossa)", + "Transform for row {number}": "Muunnos riville {number}", + "Transform of row {number}": "Rivin {number} muunnos", + "Trim spaces": "Poista reunojen välilyönnit", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Kirjoita arvot sellaisina kuin ne ovat lähteessä. Tulos käyttää yllä olevaa kartoitusta, myös muutoksia, joita et ole tallentanut.", + "Undo changes": "Kumoa muutokset", + "Value for other values": "Muiden arvojen arvo", + "Value in the source": "Arvo lähteessä", + "Value {number} is JSON": "Arvo {number} on JSONia", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Arvot, joilla jokainen tuotu objekti alkaa. Kartoitus, joka antaa kentälle arvon, korvaa sen oletusarvon.", + "Values that are not in the table": "Arvot, jotka eivät ole taulukossa", + "View {name}": "Näytä kohde {name}", + "Yes/no table": "Kyllä/ei-taulukko", + "Yes/no table, values: {count}, {unmapped}": "Kyllä/ei-taulukko, arvoja: {count}, {unmapped}", + "Store false": "Tallenna ei (false)", + "other values are stored as false": "muut arvot tallennetaan arvona ei (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.js b/l10n/fr.js index b862e2090a..226b8e81f1 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Surveille", "{completed} of {total} completed": "{completed} sur {total} terminées", "{date} by {person}": "{date} par {person}", - "{failed} failed, {stopped} stopped": "{failed} en échec, {stopped} arrêtées" + "{failed} failed, {stopped} stopped": "{failed} en échec, {stopped} arrêtées", + "A column to join has no name.": "Une colonne à joindre n'a pas de nom.", + "A default value has no target field.": "Une valeur par défaut n'a pas de champ cible.", + "A fixed value is written even when the column is empty.": "Une valeur fixe est écrite même lorsque la colonne est vide.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Un pack de migration indique quelle colonne d'un fichier d'import devient quel champ, et comment sa valeur est convertie au passage.", + "A pack definition is a JSON object.": "Une définition de pack est un objet JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Un format de date PHP tel que d-m-Y. Commencez-le par ! pour que les parties que le format ne mentionne pas ne soient pas reprises de la date du jour. Laissez-le vide pour lire n'importe quelle date courante.", + "Add a field mapping with a source column to test it.": "Ajoutez un mappage de champ avec une colonne source pour le tester.", + "Add at least one column to join.": "Ajoutez au moins une colonne à joindre.", + "Add at least one field mapping.": "Ajoutez au moins un mappage de champ.", + "Add at least one value to the table.": "Ajoutez au moins une valeur à la table.", + "Add column": "Ajouter une colonne", + "Add default": "Ajouter une valeur par défaut", + "Add field mapping": "Ajouter un mappage de champ", + "Add value": "Ajouter une valeur", + "Advanced (JSON)": "Avancé (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Une application qui importe avec ce pack peut ajouter ses propres règles, par exemple quelles colonnes contiennent des dates ou de quelle feuille provient une ligne. Vérifiez cette application avant de modifier un pack qu'elle a installé.", + "any format": "tout format", + "As JSON": "En JSON", + "Built-in": "Intégré", + "Choose a source format.": "Choisissez un format source.", + "Column to join {number}": "Colonne à joindre {number}", + "Column {source} to field {target}": "Colonne {source} vers le champ {target}", + "Copied as is": "Copié tel quel", + "Could not delete the migration pack.": "Impossible de supprimer le pack de migration.", + "Could not import the file.": "Impossible d'importer le fichier.", + "Could not load the migration pack.": "Impossible de charger le pack de migration.", + "Could not load the migration packs.": "Impossible de charger les packs de migration.", + "Could not save the migration pack.": "Impossible d'enregistrer le pack de migration.", + "Date {from} to {to}": "Date de {from} vers {to}", + "Defaults": "Valeurs par défaut", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Supprimer « {name} » ? Les imports qui utilisent ce pack ne le trouveront plus.", + "Delete migration pack": "Supprimer le pack de migration", + "Delete {name}": "Supprimer {name}", + "Deleted \"{name}\".": "« {name} » supprimé.", + "Edit {name}": "Modifier {name}", + "Export {name}": "Exporter {name}", + "Field mappings": "Mappages de champs", + "Fields the import would write": "Champs que l'import écrirait", + "Fix the problems in the pack first; then it can be tested.": "Corrigez d'abord les problèmes du pack ; il pourra ensuite être testé.", + "Fixed value": "Valeur fixe", + "Fixed value {value}": "Valeur fixe {value}", + "Form": "Formulaire", + "General": "Général", + "Generate a new id": "Générer un nouvel identifiant", + "Id column": "Colonne d'identifiant", + "Import JSON": "Importer du JSON", + "Imported \"{name}\".": "« {name} » importé.", + "Imports find the pack by this slug.": "Les imports trouvent le pack grâce à ce slug.", + "Join columns": "Joindre des colonnes", + "Joined with {fields}, separated by \"{separator}\"": "Joint avec {fields}, séparé par « {separator} »", + "Leave the field empty": "Laisser le champ vide", + "Lookup table": "Table de correspondance", + "Lookup table, values: {count}, {unmapped}": "Table de correspondance, valeurs : {count}, {unmapped}", + "Migration pack": "Pack de migration", + "Migration packs": "Packs de migration", + "Move row {number} down": "Descendre la ligne {number}", + "Move row {number} up": "Monter la ligne {number}", + "Name the column that holds the id.": "Indiquez la colonne qui contient l'identifiant.", + "No defaults.": "Aucune valeur par défaut.", + "No fields: every mapping was empty or failed.": "Aucun champ : chaque mappage était vide ou a échoué.", + "No migration packs yet": "Aucun pack de migration pour l'instant", + "None: copy the value as is": "Aucune : copier la valeur telle quelle", + "Object id": "Identifiant de l'objet", + "Only administrators can change migration packs.": "Seuls les administrateurs peuvent modifier les packs de migration.", + "Only administrators can change migration packs. You can read this one.": "Seuls les administrateurs peuvent modifier les packs de migration. Vous pouvez consulter celui-ci.", + "other values are an error": "les autres valeurs sont une erreur", + "other values get a default": "les autres valeurs reçoivent une valeur par défaut", + "other values stay empty": "les autres valeurs restent vides", + "Problem": "Problème", + "Problems in this row": "Problèmes dans cette ligne", + "Reload the pack": "Recharger le pack", + "Remove column {number}": "Supprimer la colonne {number}", + "Remove row {number}": "Supprimer la ligne {number}", + "Report an error for the row": "Signaler une erreur pour la ligne", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Numéros de ligne dans la source, séparés par des virgules. La ligne 1 est la première ligne de données.", + "Row {number} has no source column.": "La ligne {number} n'a pas de colonne source.", + "Row {number} has no target field.": "La ligne {number} n'a pas de champ cible.", + "Row {number} is required": "La ligne {number} est obligatoire", + "Row {number}: {message}": "Ligne {number} : {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Les lignes s'exécutent de haut en bas. Lorsque deux lignes écrivent le même champ et que les deux colonnes ont une valeur, la ligne la plus basse l'emporte.", + "Rows to skip": "Lignes à ignorer", + "Rows to skip must be whole numbers from 1 up: {values}": "Les lignes à ignorer doivent être des nombres entiers à partir de 1 : {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Les lignes {rows} écrivent ce champ ; la ligne la plus basse ayant une valeur l'emporte.", + "Sample value": "Valeur d'exemple", + "Sample value for {column}": "Valeur d'exemple pour {column}", + "Saved as version {version}.": "Enregistré en version {version}.", + "Saving raises the version to {version}.": "L'enregistrement fait passer la version à {version}.", + "Separator": "Séparateur", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Quelqu'un d'autre a enregistré ce pack après que vous l'avez ouvert. Rechargez-le pour voir ses modifications ; vos modifications ne sont pas enregistrées.", + "Source column": "Colonne source", + "Source column of row {number}": "Colonne source de la ligne {number}", + "Source date format": "Format de date source", + "Source format": "Format source", + "Store this value": "Enregistrer cette valeur", + "Stored date format": "Format de date enregistré", + "Stored value": "Valeur enregistrée", + "Take the id from a column": "Prendre l'identifiant dans une colonne", + "Target field": "Champ cible", + "Target field of row {number}": "Champ cible de la ligne {number}", + "Test with a sample row": "Tester avec une ligne d'exemple", + "The default for \"{key}\" is not valid JSON.": "La valeur par défaut de « {key} » n'est pas un JSON valide.", + "The default for \"{key}\" is set twice.": "La valeur par défaut de « {key} » est définie deux fois.", + "The fixed value is not valid JSON.": "La valeur fixe n'est pas un JSON valide.", + "The form has a value that is not valid JSON. Fix it first.": "Le formulaire contient une valeur qui n'est pas un JSON valide. Corrigez-la d'abord.", + "The name is required.": "Le nom est obligatoire.", + "The pack cannot be saved yet.": "Le pack ne peut pas encore être enregistré.", + "The pack is not valid.": "Le pack n'est pas valide.", + "The result for \"{key}\" is not valid JSON.": "Le résultat pour « {key} » n'est pas un JSON valide.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Le slug doit commencer par une lettre minuscule ou un chiffre et ne contenir que des lettres minuscules, des chiffres et des tirets.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Le slug d'un pack intégré ne peut pas changer. Conservez « id » à {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Le slug d'un pack intégré ne peut pas changer : l'application qui l'a installé trouve le pack grâce à ce slug.", + "The test could not run.": "Le test n'a pas pu être exécuté.", + "The value \"{key}\" is in the table twice.": "La valeur « {key} » figure deux fois dans la table.", + "The value for other values is not valid JSON.": "La valeur pour les autres valeurs n'est pas un JSON valide.", + "The value of column {source} comes first, then these columns in order.": "La valeur de la colonne {source} vient en premier, puis ces colonnes dans l'ordre.", + "The version must look like 1.0.0 (major.minor.patch).": "La version doit avoir la forme 1.0.0 (majeure.mineure.correctif).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "La définition complète du pack. Les modifications faites ici remplacent le formulaire lorsque vous y revenez ou enregistrez.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Cet éditeur ne connaît pas le type de transformation « {type} ». Il est conservé tel qu'il est enregistré.", + "This is not valid JSON: {reason}": "Ce n'est pas un JSON valide : {reason}", + "This migration pack does not exist.": "Ce pack de migration n'existe pas.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Ce pack a été installé par une application. Cette application le réinstalle, sans modification, la prochaine fois qu'elle est installée ou mise à jour.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Cette transformation a aussi des paramètres que cet éditeur n'affiche pas ({keys}). Ils sont conservés lors de l'enregistrement.", + "Transform": "Transformation", + "Transform \"{type}\" (kept as stored)": "Transformation « {type} » (conservée telle qu'enregistrée)", + "Transform for row {number}": "Transformation pour la ligne {number}", + "Transform of row {number}": "Transformation de la ligne {number}", + "Trim spaces": "Supprimer les espaces", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Saisissez les valeurs telles qu'elles figurent dans la source. Le résultat utilise le mappage ci-dessus, y compris les modifications que vous n'avez pas encore enregistrées.", + "Undo changes": "Annuler les modifications", + "Value for other values": "Valeur pour les autres valeurs", + "Value in the source": "Valeur dans la source", + "Value {number} is JSON": "La valeur {number} est du JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Valeurs avec lesquelles chaque objet importé commence. Un mappage qui donne une valeur à un champ remplace sa valeur par défaut.", + "Values that are not in the table": "Valeurs absentes de la table", + "View {name}": "Afficher {name}", + "Yes/no table": "Table oui/non", + "Yes/no table, values: {count}, {unmapped}": "Table oui/non, valeurs : {count}, {unmapped}", + "Store false": "Enregistrer non (false)", + "other values are stored as false": "les autres valeurs sont enregistrées comme non (false)" }, "nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" ) diff --git a/l10n/ga.js b/l10n/ga.js index ac87b6e5c1..6a381683aa 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Ag faire ar", "{completed} of {total} completed": "{completed} as {total} críochnaithe", "{date} by {person}": "{date} ag {person}", - "{failed} failed, {stopped} stopped": "{failed} teipthe, {stopped} stoptha" + "{failed} failed, {stopped} stopped": "{failed} teipthe, {stopped} stoptha", + "A column to join has no name.": "Níl ainm ar cholún le cumasc.", + "A default value has no target field.": "Níl spriocréimse ag luach réamhshocraithe.", + "A fixed value is written even when the column is empty.": "Scríobhtar luach seasta fiú nuair atá an colún folamh.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Sonraíonn pacáiste aistrithe cén colún de chomhad iompórtála a bheidh ina réimse, agus conas a thiontaítear a luach ar an mbealach.", + "A pack definition is a JSON object.": "Is réad JSON é sainmhíniú pacáiste.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Formáid dáta PHP mar d-m-Y. Cuir ! ag a tús ionas nach dtógfar ón dáta inniu na codanna nach n-ainmníonn an fhormáid. Fág folamh é chun aon dáta coitianta a léamh.", + "Add a field mapping with a source column to test it.": "Cuir mapáil réimse le colún foinse leis chun í a thástáil.", + "Add at least one column to join.": "Cuir colún amháin ar a laghad leis le cumasc.", + "Add at least one field mapping.": "Cuir mapáil réimse amháin ar a laghad leis.", + "Add at least one value to the table.": "Cuir luach amháin ar a laghad sa tábla.", + "Add column": "Cuir colún leis", + "Add default": "Cuir luach réamhshocraithe leis", + "Add field mapping": "Cuir mapáil réimse leis", + "Add value": "Cuir luach leis", + "Advanced (JSON)": "Casta (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Is féidir le feidhmchlár a iompórtálann leis an bpacáiste seo a rialacha féin a chur leis, mar shampla cé na colúin ina bhfuil dátaí nó cén bhileog óna dtagann ró. Seiceáil an feidhmchlár sin sula n-athraíonn tú pacáiste a shuiteáil sé.", + "any format": "aon fhormáid", + "As JSON": "Mar JSON", + "Built-in": "Ionsuite", + "Choose a source format.": "Roghnaigh formáid foinse.", + "Column to join {number}": "Colún le cumasc {number}", + "Column {source} to field {target}": "Colún {source} go réimse {target}", + "Copied as is": "Cóipeáilte mar atá", + "Could not delete the migration pack.": "Níorbh fhéidir an pacáiste aistrithe a scriosadh.", + "Could not import the file.": "Níorbh fhéidir an comhad a iompórtáil.", + "Could not load the migration pack.": "Níorbh fhéidir an pacáiste aistrithe a lódáil.", + "Could not load the migration packs.": "Níorbh fhéidir na pacáistí aistrithe a lódáil.", + "Could not save the migration pack.": "Níorbh fhéidir an pacáiste aistrithe a shábháil.", + "Date {from} to {to}": "Dáta ó {from} go {to}", + "Defaults": "Luachanna réamhshocraithe", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Scrios \"{name}\"? Ní aimseoidh iompórtálacha a úsáideann an pacáiste seo é a thuilleadh.", + "Delete migration pack": "Scrios pacáiste aistrithe", + "Delete {name}": "Scrios {name}", + "Deleted \"{name}\".": "Scriosadh \"{name}\".", + "Edit {name}": "Cuir {name} in eagar", + "Export {name}": "Easpórtáil {name}", + "Field mappings": "Mapálacha réimsí", + "Fields the import would write": "Réimsí a scríobhfadh an iompórtáil", + "Fix the problems in the pack first; then it can be tested.": "Réitigh na fadhbanna sa phacáiste ar dtús; ansin is féidir é a thástáil.", + "Fixed value": "Luach seasta", + "Fixed value {value}": "Luach seasta {value}", + "Form": "Foirm", + "General": "Ginearálta", + "Generate a new id": "Gin id nua", + "Id column": "Colún id", + "Import JSON": "Iompórtáil JSON", + "Imported \"{name}\".": "Iompórtáladh \"{name}\".", + "Imports find the pack by this slug.": "Aimsíonn iompórtálacha an pacáiste tríd an slug seo.", + "Join columns": "Cumaisc colúin", + "Joined with {fields}, separated by \"{separator}\"": "Cumascthe le {fields}, scartha le \"{separator}\"", + "Leave the field empty": "Fág an réimse folamh", + "Lookup table": "Tábla cuardaigh", + "Lookup table, values: {count}, {unmapped}": "Tábla cuardaigh, luachanna: {count}, {unmapped}", + "Migration pack": "Pacáiste aistrithe", + "Migration packs": "Pacáistí aistrithe", + "Move row {number} down": "Bog ró {number} síos", + "Move row {number} up": "Bog ró {number} suas", + "Name the column that holds the id.": "Ainmnigh an colún ina bhfuil an id.", + "No defaults.": "Gan luachanna réamhshocraithe.", + "No fields: every mapping was empty or failed.": "Gan réimsí: bhí gach mapáil folamh nó theip uirthi.", + "No migration packs yet": "Níl aon phacáistí aistrithe fós", + "None: copy the value as is": "Dada: cóipeáil an luach mar atá", + "Object id": "Id an réada", + "Only administrators can change migration packs.": "Ní féidir ach le riarthóirí pacáistí aistrithe a athrú.", + "Only administrators can change migration packs. You can read this one.": "Ní féidir ach le riarthóirí pacáistí aistrithe a athrú. Is féidir leat an ceann seo a léamh.", + "other values are an error": "tugann luachanna eile earráid", + "other values get a default": "faigheann luachanna eile luach réamhshocraithe", + "other values stay empty": "fanann luachanna eile folamh", + "Problem": "Fadhb", + "Problems in this row": "Fadhbanna sa ró seo", + "Reload the pack": "Athlódáil an pacáiste", + "Remove column {number}": "Bain colún {number}", + "Remove row {number}": "Bain ró {number}", + "Report an error for the row": "Tuairiscigh earráid don ró", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Uimhreacha rónna san fhoinse, scartha le camóga. Is é ró 1 an chéad ró sonraí.", + "Row {number} has no source column.": "Níl colún foinse ag ró {number}.", + "Row {number} has no target field.": "Níl spriocréimse ag ró {number}.", + "Row {number} is required": "Tá ró {number} riachtanach", + "Row {number}: {message}": "Ró {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Ritheann rónna ó bharr go bun. Nuair a scríobhann dhá ró an réimse céanna agus luach ag an dá cholún, buann an ró is ísle.", + "Rows to skip": "Rónna le scipeáil", + "Rows to skip must be whole numbers from 1 up: {values}": "Ní mór do rónna le scipeáil a bheith ina slánuimhreacha ó 1 suas: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Scríobhann rónna {rows} an réimse seo; buann an ró is ísle a bhfuil luach aige.", + "Sample value": "Luach samplach", + "Sample value for {column}": "Luach samplach do {column}", + "Saved as version {version}.": "Sábháladh mar leagan {version}.", + "Saving raises the version to {version}.": "Ardaíonn sábháil an leagan go {version}.", + "Separator": "Deighilteoir", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Shábháil duine eile an pacáiste seo tar éis duit é a oscailt. Athlódáil é chun a n-athruithe a fheiceáil; níor sábháladh d'athruithe.", + "Source column": "Colún foinse", + "Source column of row {number}": "Colún foinse ró {number}", + "Source date format": "Formáid dáta na foinse", + "Source format": "Formáid foinse", + "Store this value": "Stóráil an luach seo", + "Stored date format": "Formáid dáta stóráilte", + "Stored value": "Luach stóráilte", + "Take the id from a column": "Tóg an id ó cholún", + "Target field": "Spriocréimse", + "Target field of row {number}": "Spriocréimse ró {number}", + "Test with a sample row": "Tástáil le ró samplach", + "The default for \"{key}\" is not valid JSON.": "Ní JSON bailí é an luach réamhshocraithe do \"{key}\".", + "The default for \"{key}\" is set twice.": "Socraíodh an luach réamhshocraithe do \"{key}\" faoi dhó.", + "The fixed value is not valid JSON.": "Ní JSON bailí é an luach seasta.", + "The form has a value that is not valid JSON. Fix it first.": "Tá luach san fhoirm nach JSON bailí é. Réitigh é ar dtús.", + "The name is required.": "Tá an t-ainm riachtanach.", + "The pack cannot be saved yet.": "Ní féidir an pacáiste a shábháil fós.", + "The pack is not valid.": "Níl an pacáiste bailí.", + "The result for \"{key}\" is not valid JSON.": "Ní JSON bailí é an toradh do \"{key}\".", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Ní mór don slug tosú le litir bheag nó digit agus gan ach litreacha beaga, digití agus fleiscíní a bheith ann.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Ní féidir slug pacáiste ionsuite a athrú. Coinnigh \"id\" mar {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Ní féidir slug pacáiste ionsuite a athrú: aimsíonn an feidhmchlár a shuiteáil é an pacáiste tríd an slug seo.", + "The test could not run.": "Níorbh fhéidir an tástáil a rith.", + "The value \"{key}\" is in the table twice.": "Tá an luach \"{key}\" sa tábla faoi dhó.", + "The value for other values is not valid JSON.": "Ní JSON bailí é an luach do luachanna eile.", + "The value of column {source} comes first, then these columns in order.": "Tagann luach cholún {source} ar dtús, ansin na colúin seo in ord.", + "The version must look like 1.0.0 (major.minor.patch).": "Ní mór don leagan cuma 1.0.0 a bheith air (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Sainmhíniú iomlán an phacáiste. Tagann athruithe anseo in áit na foirme nuair a fhilleann tú uirthi nó nuair a shábhálann tú.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Níl an cineál claochlaithe \"{type}\" ar eolas ag an eagarthóir seo. Coinnítear é mar atá sé stóráilte.", + "This is not valid JSON: {reason}": "Ní JSON bailí é seo: {reason}", + "This migration pack does not exist.": "Níl an pacáiste aistrithe seo ann.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Shuiteáil feidhmchlár an pacáiste seo. Suiteálann an feidhmchlár sin arís é, gan eagarthóireacht, an chéad uair eile a shuiteáiltear nó a nuashonraítear é.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Tá socruithe eile ag an gclaochlú seo nach dtaispeánann an t-eagarthóir seo ({keys}). Coinnítear iad nuair a shábhálann tú.", + "Transform": "Claochlú", + "Transform \"{type}\" (kept as stored)": "Claochlú \"{type}\" (coinnithe mar atá stóráilte)", + "Transform for row {number}": "Claochlú do ró {number}", + "Transform of row {number}": "Claochlú ró {number}", + "Trim spaces": "Bearr spásanna", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Clóscríobh luachanna mar a thaispeántar iad san fhoinse. Úsáideann an toradh an mhapáil thuas, lena n-áirítear athruithe nár shábháil tú.", + "Undo changes": "Cealaigh athruithe", + "Value for other values": "Luach do luachanna eile", + "Value in the source": "Luach san fhoinse", + "Value {number} is JSON": "Is JSON é luach {number}", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Luachanna lena dtosaíonn gach réad iompórtáilte. Tagann mapáil a thugann luach do réimse in áit a luacha réamhshocraithe.", + "Values that are not in the table": "Luachanna nach bhfuil sa tábla", + "View {name}": "Féach ar {name}", + "Yes/no table": "Tábla tá/níl", + "Yes/no table, values: {count}, {unmapped}": "Tábla tá/níl, luachanna: {count}, {unmapped}", + "Store false": "Stóráil níl (false)", + "other values are stored as false": "stóráiltear luachanna eile mar níl (false)" }, "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4);" ) diff --git a/l10n/hr.js b/l10n/hr.js index 00440b6779..50a429dd88 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Prati", "{completed} of {total} completed": "Dovršeno {completed} od {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Neuspjelo: {failed}, zaustavljeno: {stopped}" + "{failed} failed, {stopped} stopped": "Neuspjelo: {failed}, zaustavljeno: {stopped}", + "A column to join has no name.": "Stupac za spajanje nema naziv.", + "A default value has no target field.": "Zadana vrijednost nema ciljno polje.", + "A fixed value is written even when the column is empty.": "Fiksna vrijednost upisuje se i kada je stupac prazan.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Migracijski paket određuje koji stupac datoteke za uvoz postaje koje polje i kako se njegova vrijednost usput pretvara.", + "A pack definition is a JSON object.": "Definicija paketa je JSON objekt.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP format datuma, na primjer d-m-Y. Započnite ga znakom ! kako se dijelovi koje format ne navodi ne bi uzimali iz današnjeg datuma. Ostavite prazno za čitanje bilo kojeg uobičajenog datuma.", + "Add a field mapping with a source column to test it.": "Dodajte mapiranje polja s izvornim stupcem da biste ga testirali.", + "Add at least one column to join.": "Dodajte barem jedan stupac za spajanje.", + "Add at least one field mapping.": "Dodajte barem jedno mapiranje polja.", + "Add at least one value to the table.": "Dodajte barem jednu vrijednost u tablicu.", + "Add column": "Dodaj stupac", + "Add default": "Dodaj zadanu vrijednost", + "Add field mapping": "Dodaj mapiranje polja", + "Add value": "Dodaj vrijednost", + "Advanced (JSON)": "Napredno (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Aplikacija koja uvozi s ovim paketom može dodati vlastita pravila, na primjer koji stupci sadrže datume ili s kojeg lista dolazi redak. Provjerite tu aplikaciju prije nego što promijenite paket koji je ona instalirala.", + "any format": "bilo koji format", + "As JSON": "Kao JSON", + "Built-in": "Ugrađeno", + "Choose a source format.": "Odaberite izvorni format.", + "Column to join {number}": "Stupac za spajanje {number}", + "Column {source} to field {target}": "Stupac {source} u polje {target}", + "Copied as is": "Kopirano nepromijenjeno", + "Could not delete the migration pack.": "Migracijski paket nije bilo moguće izbrisati.", + "Could not import the file.": "Datoteku nije bilo moguće uvesti.", + "Could not load the migration pack.": "Migracijski paket nije bilo moguće učitati.", + "Could not load the migration packs.": "Migracijske pakete nije bilo moguće učitati.", + "Could not save the migration pack.": "Migracijski paket nije bilo moguće spremiti.", + "Date {from} to {to}": "Datum iz {from} u {to}", + "Defaults": "Zadane vrijednosti", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Izbrisati \"{name}\"? Uvozi koji koriste ovaj paket više ga neće pronaći.", + "Delete migration pack": "Izbriši migracijski paket", + "Delete {name}": "Izbriši {name}", + "Deleted \"{name}\".": "\"{name}\" je izbrisan.", + "Edit {name}": "Uredi {name}", + "Export {name}": "Izvezi {name}", + "Field mappings": "Mapiranja polja", + "Fields the import would write": "Polja koja bi uvoz upisao", + "Fix the problems in the pack first; then it can be tested.": "Najprije ispravite probleme u paketu; zatim ga je moguće testirati.", + "Fixed value": "Fiksna vrijednost", + "Fixed value {value}": "Fiksna vrijednost {value}", + "Form": "Obrazac", + "General": "Općenito", + "Generate a new id": "Generiraj novi id", + "Id column": "Stupac s id-om", + "Import JSON": "Uvezi JSON", + "Imported \"{name}\".": "\"{name}\" je uvezen.", + "Imports find the pack by this slug.": "Uvozi pronalaze paket po ovom slugu.", + "Join columns": "Spoji stupce", + "Joined with {fields}, separated by \"{separator}\"": "Spojeno s {fields}, odvojeno s \"{separator}\"", + "Leave the field empty": "Ostavi polje prazno", + "Lookup table": "Tablica za pretraživanje", + "Lookup table, values: {count}, {unmapped}": "Tablica za pretraživanje, vrijednosti: {count}, {unmapped}", + "Migration pack": "Migracijski paket", + "Migration packs": "Migracijski paketi", + "Move row {number} down": "Pomakni redak {number} dolje", + "Move row {number} up": "Pomakni redak {number} gore", + "Name the column that holds the id.": "Navedite stupac koji sadrži id.", + "No defaults.": "Nema zadanih vrijednosti.", + "No fields: every mapping was empty or failed.": "Nema polja: svako je mapiranje bilo prazno ili nije uspjelo.", + "No migration packs yet": "Još nema migracijskih paketa", + "None: copy the value as is": "Ništa: kopiraj vrijednost nepromijenjenu", + "Object id": "Id objekta", + "Only administrators can change migration packs.": "Samo administratori mogu mijenjati migracijske pakete.", + "Only administrators can change migration packs. You can read this one.": "Samo administratori mogu mijenjati migracijske pakete. Ovaj paket možete pregledati.", + "other values are an error": "ostale vrijednosti daju pogrešku", + "other values get a default": "ostale vrijednosti dobivaju zadanu vrijednost", + "other values stay empty": "ostale vrijednosti ostaju prazne", + "Problem": "Poteškoća", + "Problems in this row": "Problemi u ovom retku", + "Reload the pack": "Ponovno učitaj paket", + "Remove column {number}": "Ukloni stupac {number}", + "Remove row {number}": "Ukloni redak {number}", + "Report an error for the row": "Prijavi pogrešku za redak", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Brojevi redaka u izvoru, odvojeni zarezima. Redak 1 je prvi podatkovni redak.", + "Row {number} has no source column.": "Redak {number} nema izvorni stupac.", + "Row {number} has no target field.": "Redak {number} nema ciljno polje.", + "Row {number} is required": "Redak {number} je obavezan", + "Row {number}: {message}": "Redak {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Retci se izvode odozgo prema dolje. Kada dva retka upisuju isto polje i oba stupca imaju vrijednost, prednost ima niži redak.", + "Rows to skip": "Retci za preskakanje", + "Rows to skip must be whole numbers from 1 up: {values}": "Retci za preskakanje moraju biti cijeli brojevi od 1 naviše: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Retci {rows} upisuju ovo polje; prednost ima najniži redak s vrijednošću.", + "Sample value": "Primjer vrijednosti", + "Sample value for {column}": "Primjer vrijednosti za {column}", + "Saved as version {version}.": "Spremljeno kao verzija {version}.", + "Saving raises the version to {version}.": "Spremanjem se verzija povećava na {version}.", + "Separator": "Razdjelnik", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Netko drugi spremio je ovaj paket nakon što ste ga otvorili. Ponovno ga učitajte da biste vidjeli te promjene; vaše promjene nisu spremljene.", + "Source column": "Izvorni stupac", + "Source column of row {number}": "Izvorni stupac retka {number}", + "Source date format": "Format datuma u izvoru", + "Source format": "Izvorni format", + "Store this value": "Spremi ovu vrijednost", + "Stored date format": "Spremljeni format datuma", + "Stored value": "Spremljena vrijednost", + "Take the id from a column": "Preuzmi id iz stupca", + "Target field": "Ciljno polje", + "Target field of row {number}": "Ciljno polje retka {number}", + "Test with a sample row": "Testiraj s primjerom retka", + "The default for \"{key}\" is not valid JSON.": "Zadana vrijednost za \"{key}\" nije valjani JSON.", + "The default for \"{key}\" is set twice.": "Zadana vrijednost za \"{key}\" postavljena je dvaput.", + "The fixed value is not valid JSON.": "Fiksna vrijednost nije valjani JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Obrazac sadrži vrijednost koja nije valjani JSON. Najprije je ispravite.", + "The name is required.": "Naziv je obavezan.", + "The pack cannot be saved yet.": "Paket se još ne može spremiti.", + "The pack is not valid.": "Paket nije valjan.", + "The result for \"{key}\" is not valid JSON.": "Rezultat za \"{key}\" nije valjani JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug mora počinjati malim slovom ili znamenkom i sadržavati samo mala slova, znamenke i crtice.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug ugrađenog paketa ne može se promijeniti. Zadržite \"id\" kao {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug ugrađenog paketa ne može se promijeniti: aplikacija koja ga je instalirala pronalazi paket po ovom slugu.", + "The test could not run.": "Test se nije mogao pokrenuti.", + "The value \"{key}\" is in the table twice.": "Vrijednost \"{key}\" nalazi se u tablici dvaput.", + "The value for other values is not valid JSON.": "Vrijednost za ostale vrijednosti nije valjani JSON.", + "The value of column {source} comes first, then these columns in order.": "Najprije dolazi vrijednost stupca {source}, zatim ovi stupci redom.", + "The version must look like 1.0.0 (major.minor.patch).": "Verzija mora izgledati kao 1.0.0 (glavna.sporedna.zakrpa).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Cijela definicija paketa. Promjene ovdje zamjenjuju obrazac kada se vratite na njega ili spremite.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Ovaj uređivač ne poznaje vrstu pretvorbe \"{type}\". Zadržava se onako kako je spremljena.", + "This is not valid JSON: {reason}": "Ovo nije valjani JSON: {reason}", + "This migration pack does not exist.": "Ovaj migracijski paket ne postoji.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Ovaj je paket instalirala aplikacija. Ta ga aplikacija ponovno instalira, bez izmjena, sljedeći put kada se instalira ili ažurira.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Ova pretvorba ima i postavke koje ovaj uređivač ne prikazuje ({keys}). Zadržavaju se kada spremite.", + "Transform": "Pretvorba", + "Transform \"{type}\" (kept as stored)": "Pretvorba \"{type}\" (zadržana kako je spremljena)", + "Transform for row {number}": "Pretvorba za redak {number}", + "Transform of row {number}": "Pretvorba retka {number}", + "Trim spaces": "Ukloni razmake", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Upišite vrijednosti kako se pojavljuju u izvoru. Rezultat koristi gornje mapiranje, uključujući promjene koje još niste spremili.", + "Undo changes": "Poništi promjene", + "Value for other values": "Vrijednost za ostale vrijednosti", + "Value in the source": "Vrijednost u izvoru", + "Value {number} is JSON": "Vrijednost {number} je JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Vrijednosti s kojima počinje svaki uvezeni objekt. Mapiranje koje polju daje vrijednost zamjenjuje njegovu zadanu vrijednost.", + "Values that are not in the table": "Vrijednosti koje nisu u tablici", + "View {name}": "Prikaži {name}", + "Yes/no table": "Tablica da/ne", + "Yes/no table, values: {count}, {unmapped}": "Tablica da/ne, vrijednosti: {count}, {unmapped}", + "Store false": "Spremi ne (false)", + "other values are stored as false": "ostale vrijednosti spremaju se kao ne (false)" }, "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/hu.js b/l10n/hu.js index d92cab4862..1926d8b55d 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Figyeli", "{completed} of {total} completed": "{completed} / {total} befejezve", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Sikertelen: {failed}, leállítva: {stopped}" + "{failed} failed, {stopped} stopped": "Sikertelen: {failed}, leállítva: {stopped}", + "A column to join has no name.": "Egy egyesítendő oszlopnak nincs neve.", + "A default value has no target field.": "Egy alapértelmezett értéknek nincs célmezője.", + "A fixed value is written even when the column is empty.": "A rögzített érték akkor is beíródik, ha az oszlop üres.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "A migrációs csomag meghatározza, hogy az importfájl melyik oszlopából melyik mező lesz, és hogyan alakul át közben az értéke.", + "A pack definition is a JSON object.": "A csomagdefiníció egy JSON-objektum.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP-dátumformátum, például d-m-Y. Kezdje !-lel, hogy a formátumban meg nem nevezett részek ne a mai dátumból kerüljenek átvételre. Hagyja üresen, ha bármely szokásos dátumot be szeretne olvasni.", + "Add a field mapping with a source column to test it.": "A teszteléshez adjon hozzá egy forrásoszloppal rendelkező mezőleképezést.", + "Add at least one column to join.": "Adjon hozzá legalább egy egyesítendő oszlopot.", + "Add at least one field mapping.": "Adjon hozzá legalább egy mezőleképezést.", + "Add at least one value to the table.": "Adjon hozzá legalább egy értéket a táblához.", + "Add column": "Oszlop hozzáadása", + "Add default": "Alapértelmezett érték hozzáadása", + "Add field mapping": "Mezőleképezés hozzáadása", + "Add value": "Érték hozzáadása", + "Advanced (JSON)": "Speciális (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Az ezzel a csomaggal importáló alkalmazás saját szabályokat is hozzáadhat, például hogy mely oszlopok tartalmaznak dátumot, vagy melyik munkalapról származik egy sor. Mielőtt módosítaná az általa telepített csomagot, ellenőrizze az adott alkalmazást.", + "any format": "bármely formátum", + "As JSON": "JSON-ként", + "Built-in": "Beépített", + "Choose a source format.": "Válasszon forrásformátumot.", + "Column to join {number}": "{number}. egyesítendő oszlop", + "Column {source} to field {target}": "{source} oszlop a(z) {target} mezőbe", + "Copied as is": "Változatlanul átvéve", + "Could not delete the migration pack.": "A migrációs csomag nem törölhető.", + "Could not import the file.": "A fájl nem importálható.", + "Could not load the migration pack.": "A migrációs csomag nem tölthető be.", + "Could not load the migration packs.": "A migrációs csomagok nem tölthetők be.", + "Could not save the migration pack.": "A migrációs csomag nem menthető.", + "Date {from} to {to}": "Dátum {from} formátumból {to} formátumba", + "Defaults": "Alapértelmezett értékek", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Törli a(z) \"{name}\" csomagot? Az ezt a csomagot használó importálások többé nem fogják megtalálni.", + "Delete migration pack": "Migrációs csomag törlése", + "Delete {name}": "{name} törlése", + "Deleted \"{name}\".": "\"{name}\" törölve.", + "Edit {name}": "{name} szerkesztése", + "Export {name}": "{name} exportálása", + "Field mappings": "Mezőleképezések", + "Fields the import would write": "Mezők, amelyeket az importálás írna", + "Fix the problems in the pack first; then it can be tested.": "Előbb javítsa ki a csomag hibáit; utána tesztelhető.", + "Fixed value": "Rögzített érték", + "Fixed value {value}": "Rögzített érték: {value}", + "Form": "Űrlap", + "General": "Általános", + "Generate a new id": "Új azonosító generálása", + "Id column": "Azonosító oszlop", + "Import JSON": "JSON importálása", + "Imported \"{name}\".": "\"{name}\" importálva.", + "Imports find the pack by this slug.": "Az importálások ezen a slugon keresztül találják meg a csomagot.", + "Join columns": "Oszlopok egyesítése", + "Joined with {fields}, separated by \"{separator}\"": "Egyesítve ezekkel: {fields}, elválasztó: \"{separator}\"", + "Leave the field empty": "A mező üresen hagyása", + "Lookup table": "Megfeleltetési tábla", + "Lookup table, values: {count}, {unmapped}": "Megfeleltetési tábla, értékek: {count}, {unmapped}", + "Migration pack": "Migrációs csomag", + "Migration packs": "Migrációs csomagok", + "Move row {number} down": "{number}. sor mozgatása lefelé", + "Move row {number} up": "{number}. sor mozgatása felfelé", + "Name the column that holds the id.": "Adja meg az azonosítót tartalmazó oszlopot.", + "No defaults.": "Nincsenek alapértelmezett értékek.", + "No fields: every mapping was empty or failed.": "Nincsenek mezők: minden leképezés üres volt vagy sikertelen.", + "No migration packs yet": "Még nincsenek migrációs csomagok", + "None: copy the value as is": "Nincs: az érték változatlan átvétele", + "Object id": "Objektumazonosító", + "Only administrators can change migration packs.": "Csak az adminisztrátorok módosíthatják a migrációs csomagokat.", + "Only administrators can change migration packs. You can read this one.": "Csak az adminisztrátorok módosíthatják a migrációs csomagokat. Ezt a csomagot megtekintheti.", + "other values are an error": "más értékek hibát okoznak", + "other values get a default": "más értékek alapértelmezett értéket kapnak", + "other values stay empty": "más értékek üresen maradnak", + "Problem": "Probléma", + "Problems in this row": "Problémák ebben a sorban", + "Reload the pack": "Csomag újratöltése", + "Remove column {number}": "{number}. oszlop eltávolítása", + "Remove row {number}": "{number}. sor eltávolítása", + "Report an error for the row": "Hiba jelentése a sorhoz", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "A forrás sorszámai vesszővel elválasztva. Az 1. sor az első adatsor.", + "Row {number} has no source column.": "A(z) {number}. sornak nincs forrásoszlopa.", + "Row {number} has no target field.": "A(z) {number}. sornak nincs célmezője.", + "Row {number} is required": "A(z) {number}. sor kötelező", + "Row {number}: {message}": "{number}. sor: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "A sorok fentről lefelé futnak le. Ha két sor ugyanazt a mezőt írja, és mindkét oszlopnak van értéke, az alsó sor érvényesül.", + "Rows to skip": "Kihagyandó sorok", + "Rows to skip must be whole numbers from 1 up: {values}": "A kihagyandó soroknak 1-től kezdődő egész számoknak kell lenniük: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Ezt a mezőt a következő sorok írják: {rows}; a legalsó, értékkel rendelkező sor érvényesül.", + "Sample value": "Mintaérték", + "Sample value for {column}": "Mintaérték ehhez: {column}", + "Saved as version {version}.": "Mentve {version} verzióként.", + "Saving raises the version to {version}.": "A mentés a verziót {version} értékre emeli.", + "Separator": "Elválasztó", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Valaki más elmentette ezt a csomagot, miután Ön megnyitotta. Töltse be újra a módosítások megtekintéséhez; az Ön módosításai nincsenek mentve.", + "Source column": "Forrásoszlop", + "Source column of row {number}": "A(z) {number}. sor forrásoszlopa", + "Source date format": "Dátumformátum a forrásban", + "Source format": "Forrásformátum", + "Store this value": "Ezen érték tárolása", + "Stored date format": "Tárolt dátumformátum", + "Stored value": "Tárolt érték", + "Take the id from a column": "Azonosító átvétele egy oszlopból", + "Target field": "Célmező", + "Target field of row {number}": "A(z) {number}. sor célmezője", + "Test with a sample row": "Tesztelés egy mintasorral", + "The default for \"{key}\" is not valid JSON.": "A(z) \"{key}\" alapértelmezett értéke nem érvényes JSON.", + "The default for \"{key}\" is set twice.": "A(z) \"{key}\" alapértelmezett értéke kétszer van megadva.", + "The fixed value is not valid JSON.": "A rögzített érték nem érvényes JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Az űrlap olyan értéket tartalmaz, amely nem érvényes JSON. Előbb javítsa ki.", + "The name is required.": "A név megadása kötelező.", + "The pack cannot be saved yet.": "A csomag még nem menthető.", + "The pack is not valid.": "A csomag nem érvényes.", + "The result for \"{key}\" is not valid JSON.": "A(z) \"{key}\" eredménye nem érvényes JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "A slugnak kisbetűvel vagy számjeggyel kell kezdődnie, és csak kisbetűket, számjegyeket és kötőjeleket tartalmazhat.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Beépített csomag slugja nem módosítható. Az \"id\" értéke maradjon {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Beépített csomag slugja nem módosítható: az azt telepítő alkalmazás ezen a slugon keresztül találja meg a csomagot.", + "The test could not run.": "A teszt nem futtatható.", + "The value \"{key}\" is in the table twice.": "A(z) \"{key}\" érték kétszer szerepel a táblában.", + "The value for other values is not valid JSON.": "A más értékekhez tartozó érték nem érvényes JSON.", + "The value of column {source} comes first, then these columns in order.": "Először a(z) {source} oszlop értéke következik, majd ezek az oszlopok sorrendben.", + "The version must look like 1.0.0 (major.minor.patch).": "A verziónak 1.0.0 formájúnak kell lennie (fő.al.javítás).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "A teljes csomagdefiníció. Az itteni módosítások felülírják az űrlapot, amikor visszatér hozzá vagy ment.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Ez a szerkesztő nem ismeri a(z) \"{type}\" átalakítástípust. A tárolt formájában megmarad.", + "This is not valid JSON: {reason}": "Ez nem érvényes JSON: {reason}", + "This migration pack does not exist.": "Ez a migrációs csomag nem létezik.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Ezt a csomagot egy alkalmazás telepítette. Az alkalmazás a következő telepítésekor vagy frissítésekor szerkesztetlenül újratelepíti.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Ennek az átalakításnak olyan beállításai is vannak, amelyeket ez a szerkesztő nem jelenít meg ({keys}). Mentéskor ezek megmaradnak.", + "Transform": "Átalakítás", + "Transform \"{type}\" (kept as stored)": "\"{type}\" átalakítás (a tárolt formájában megtartva)", + "Transform for row {number}": "Átalakítás a(z) {number}. sorhoz", + "Transform of row {number}": "A(z) {number}. sor átalakítása", + "Trim spaces": "Szóközök levágása", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Írja be az értékeket úgy, ahogy a forrásban szerepelnek. Az eredmény a fenti leképezést használja, beleértve a még nem mentett módosításokat is.", + "Undo changes": "Módosítások visszavonása", + "Value for other values": "Érték más értékekhez", + "Value in the source": "Érték a forrásban", + "Value {number} is JSON": "A(z) {number}. érték JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Értékek, amelyekkel minden importált objektum indul. Az a leképezés, amely értéket ad egy mezőnek, felülírja annak alapértelmezett értékét.", + "Values that are not in the table": "Értékek, amelyek nincsenek a táblában", + "View {name}": "{name} megtekintése", + "Yes/no table": "Igen/nem tábla", + "Yes/no table, values: {count}, {unmapped}": "Igen/nem tábla, értékek: {count}, {unmapped}", + "Store false": "Nem (false) tárolása", + "other values are stored as false": "más értékek nem (false) értékként tárolódnak" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.js b/l10n/is.js index 0b78a9c19a..a3899dcaae 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Fylgist með", "{completed} of {total} completed": "{completed} af {total} lokið", "{date} by {person}": "{date} af {person}", - "{failed} failed, {stopped} stopped": "{failed} mistókust, {stopped} stöðvaðar" + "{failed} failed, {stopped} stopped": "{failed} mistókust, {stopped} stöðvaðar", + "A column to join has no name.": "Dálkur sem á að sameina hefur ekkert nafn.", + "A default value has no target field.": "Sjálfgefið gildi hefur ekkert marksvæði.", + "A fixed value is written even when the column is empty.": "Fast gildi er skrifað jafnvel þótt dálkurinn sé tómur.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Flutningspakki segir til um hvaða dálkur innflutningsskrár verður að hvaða svæði og hvernig gildi hans er umbreytt á leiðinni.", + "A pack definition is a JSON object.": "Pakkaskilgreining er JSON-hlutur.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP-dagsetningarsnið eins og d-m-Y. Byrjaðu það á ! svo hlutar sem sniðið nefnir ekki séu ekki teknir úr deginum í dag. Hafðu það autt til að lesa hvaða algenga dagsetningu sem er.", + "Add a field mapping with a source column to test it.": "Bættu við svæðavörpun með upprunadálki til að prófa hana.", + "Add at least one column to join.": "Bættu við að minnsta kosti einum dálki til að sameina.", + "Add at least one field mapping.": "Bættu við að minnsta kosti einni svæðavörpun.", + "Add at least one value to the table.": "Bættu að minnsta kosti einu gildi við töfluna.", + "Add column": "Bæta við dálki", + "Add default": "Bæta við sjálfgefnu gildi", + "Add field mapping": "Bæta við svæðavörpun", + "Add value": "Bæta við gildi", + "Advanced (JSON)": "Ítarlegt (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Forrit sem flytur inn með þessum pakka getur bætt við eigin reglum, til dæmis hvaða dálkar innihalda dagsetningar eða úr hvaða vinnublaði röð kemur. Athugaðu það forrit áður en þú breytir pakka sem það setti upp.", + "any format": "hvaða snið sem er", + "As JSON": "Sem JSON", + "Built-in": "Innbyggður", + "Choose a source format.": "Veldu upprunasnið.", + "Column to join {number}": "Dálkur til að sameina {number}", + "Column {source} to field {target}": "Dálkur {source} í svæði {target}", + "Copied as is": "Afritað óbreytt", + "Could not delete the migration pack.": "Ekki tókst að eyða flutningspakkanum.", + "Could not import the file.": "Ekki tókst að flytja skrána inn.", + "Could not load the migration pack.": "Ekki tókst að hlaða flutningspakkanum.", + "Could not load the migration packs.": "Ekki tókst að hlaða flutningspökkunum.", + "Could not save the migration pack.": "Ekki tókst að vista flutningspakkann.", + "Date {from} to {to}": "Dagsetning úr {from} í {to}", + "Defaults": "Sjálfgefin gildi", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Eyða \"{name}\"? Innflutningar sem nota þennan pakka finna hann ekki lengur.", + "Delete migration pack": "Eyða flutningspakka", + "Delete {name}": "Eyða {name}", + "Deleted \"{name}\".": "\"{name}\" var eytt.", + "Edit {name}": "Breyta {name}", + "Export {name}": "Flytja út {name}", + "Field mappings": "Svæðavarpanir", + "Fields the import would write": "Svæði sem innflutningurinn myndi skrifa", + "Fix the problems in the pack first; then it can be tested.": "Lagaðu fyrst vandamálin í pakkanum; þá er hægt að prófa hann.", + "Fixed value": "Fast gildi", + "Fixed value {value}": "Fast gildi {value}", + "Form": "Eyðublað", + "General": "Almennt", + "Generate a new id": "Búa til nýtt auðkenni", + "Id column": "Auðkennisdálkur", + "Import JSON": "Flytja inn JSON", + "Imported \"{name}\".": "\"{name}\" var flutt inn.", + "Imports find the pack by this slug.": "Innflutningar finna pakkann eftir þessu auðkenni.", + "Join columns": "Sameina dálka", + "Joined with {fields}, separated by \"{separator}\"": "Sameinað með {fields}, aðskilið með \"{separator}\"", + "Leave the field empty": "Hafa svæðið autt", + "Lookup table": "Uppflettitafla", + "Lookup table, values: {count}, {unmapped}": "Uppflettitafla, gildi: {count}, {unmapped}", + "Migration pack": "Flutningspakki", + "Migration packs": "Flutningspakkar", + "Move row {number} down": "Færa röð {number} niður", + "Move row {number} up": "Færa röð {number} upp", + "Name the column that holds the id.": "Tilgreindu dálkinn sem inniheldur auðkennið.", + "No defaults.": "Engin sjálfgefin gildi.", + "No fields: every mapping was empty or failed.": "Engin svæði: hver vörpun var tóm eða mistókst.", + "No migration packs yet": "Engir flutningspakkar enn", + "None: copy the value as is": "Ekkert: afrita gildið óbreytt", + "Object id": "Hlutauðkenni", + "Only administrators can change migration packs.": "Aðeins stjórnendur geta breytt flutningspökkum.", + "Only administrators can change migration packs. You can read this one.": "Aðeins stjórnendur geta breytt flutningspökkum. Þú getur lesið þennan.", + "other values are an error": "önnur gildi valda villu", + "other values get a default": "önnur gildi fá sjálfgefið gildi", + "other values stay empty": "önnur gildi haldast auð", + "Problem": "Vandamál", + "Problems in this row": "Vandamál í þessari röð", + "Reload the pack": "Endurhlaða pakkann", + "Remove column {number}": "Fjarlægja dálk {number}", + "Remove row {number}": "Fjarlægja röð {number}", + "Report an error for the row": "Tilkynna villu fyrir röðina", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Raðnúmer í upprunanum, aðskilin með kommum. Röð 1 er fyrsta gagnaröðin.", + "Row {number} has no source column.": "Röð {number} hefur engan upprunadálk.", + "Row {number} has no target field.": "Röð {number} hefur ekkert marksvæði.", + "Row {number} is required": "Röð {number} er nauðsynleg", + "Row {number}: {message}": "Röð {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Raðir keyra ofan frá og niður. Þegar tvær raðir skrifa sama svæði og báðir dálkar hafa gildi, vinnur neðri röðin.", + "Rows to skip": "Raðir til að sleppa", + "Rows to skip must be whole numbers from 1 up: {values}": "Raðir til að sleppa verða að vera heilar tölur frá 1 og upp: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Raðir {rows} skrifa þetta svæði; neðsta röðin með gildi vinnur.", + "Sample value": "Dæmigildi", + "Sample value for {column}": "Dæmigildi fyrir {column}", + "Saved as version {version}.": "Vistað sem útgáfa {version}.", + "Saving raises the version to {version}.": "Vistun hækkar útgáfuna í {version}.", + "Separator": "Skiltákn", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Einhver annar vistaði þennan pakka eftir að þú opnaðir hann. Endurhladdu hann til að sjá breytingarnar; breytingarnar þínar eru ekki vistaðar.", + "Source column": "Upprunadálkur", + "Source column of row {number}": "Upprunadálkur raðar {number}", + "Source date format": "Dagsetningarsnið í uppruna", + "Source format": "Upprunasnið", + "Store this value": "Vista þetta gildi", + "Stored date format": "Vistað dagsetningarsnið", + "Stored value": "Vistað gildi", + "Take the id from a column": "Taka auðkennið úr dálki", + "Target field": "Marksvæði", + "Target field of row {number}": "Marksvæði raðar {number}", + "Test with a sample row": "Prófa með dæmiröð", + "The default for \"{key}\" is not valid JSON.": "Sjálfgefna gildið fyrir \"{key}\" er ekki gilt JSON.", + "The default for \"{key}\" is set twice.": "Sjálfgefna gildið fyrir \"{key}\" er stillt tvisvar.", + "The fixed value is not valid JSON.": "Fasta gildið er ekki gilt JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Eyðublaðið hefur gildi sem er ekki gilt JSON. Lagaðu það fyrst.", + "The name is required.": "Nafnið er nauðsynlegt.", + "The pack cannot be saved yet.": "Ekki er hægt að vista pakkann ennþá.", + "The pack is not valid.": "Pakkinn er ekki gildur.", + "The result for \"{key}\" is not valid JSON.": "Niðurstaðan fyrir \"{key}\" er ekki gilt JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Auðkennið verður að byrja á lágstaf eða tölustaf og má aðeins innihalda lágstafi, tölustafi og bandstrik.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Ekki er hægt að breyta auðkenni innbyggðs pakka. Haltu \"id\" sem {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Ekki er hægt að breyta auðkenni innbyggðs pakka: forritið sem setti hann upp finnur pakkann eftir þessu auðkenni.", + "The test could not run.": "Ekki tókst að keyra prófunina.", + "The value \"{key}\" is in the table twice.": "Gildið \"{key}\" er tvisvar í töflunni.", + "The value for other values is not valid JSON.": "Gildið fyrir önnur gildi er ekki gilt JSON.", + "The value of column {source} comes first, then these columns in order.": "Gildi dálks {source} kemur fyrst, síðan þessir dálkar í röð.", + "The version must look like 1.0.0 (major.minor.patch).": "Útgáfan verður að líta út eins og 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Öll pakkaskilgreiningin. Breytingar hér koma í stað eyðublaðsins þegar þú ferð aftur í það eða vistar.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Þessi ritill þekkir ekki umbreytingartegundina \"{type}\". Hún er geymd eins og hún var vistuð.", + "This is not valid JSON: {reason}": "Þetta er ekki gilt JSON: {reason}", + "This migration pack does not exist.": "Þessi flutningspakki er ekki til.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Þessi pakki var settur upp af forriti. Það forrit setur hann upp aftur, óbreyttan, næst þegar það er sett upp eða uppfært.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Þessi umbreyting hefur líka stillingar sem þessi ritill sýnir ekki ({keys}). Þær haldast þegar þú vistar.", + "Transform": "Umbreyting", + "Transform \"{type}\" (kept as stored)": "Umbreyting \"{type}\" (geymd eins og hún var vistuð)", + "Transform for row {number}": "Umbreyting fyrir röð {number}", + "Transform of row {number}": "Umbreyting raðar {number}", + "Trim spaces": "Snyrta bil", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Sláðu inn gildi eins og þau birtast í upprunanum. Niðurstaðan notar vörpunina hér að ofan, þar með taldar breytingar sem þú hefur ekki vistað.", + "Undo changes": "Afturkalla breytingar", + "Value for other values": "Gildi fyrir önnur gildi", + "Value in the source": "Gildi í upprunanum", + "Value {number} is JSON": "Gildi {number} er JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Gildi sem hver innfluttur hlutur byrjar með. Vörpun sem gefur svæði gildi kemur í stað sjálfgefna gildisins.", + "Values that are not in the table": "Gildi sem eru ekki í töflunni", + "View {name}": "Skoða {name}", + "Yes/no table": "Já/nei-tafla", + "Yes/no table, values: {count}, {unmapped}": "Já/nei-tafla, gildi: {count}, {unmapped}", + "Store false": "Vista nei (false)", + "other values are stored as false": "önnur gildi eru vistuð sem nei (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.js b/l10n/it.js index afa551d345..60b3658971 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Osserva", "{completed} of {total} completed": "{completed} di {total} completate", "{date} by {person}": "{date} da {person}", - "{failed} failed, {stopped} stopped": "{failed} non riuscite, {stopped} fermate" + "{failed} failed, {stopped} stopped": "{failed} non riuscite, {stopped} fermate", + "A column to join has no name.": "Una colonna da unire non ha un nome.", + "A default value has no target field.": "Un valore predefinito non ha un campo di destinazione.", + "A fixed value is written even when the column is empty.": "Un valore fisso viene scritto anche quando la colonna è vuota.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Un pacchetto di migrazione stabilisce quale colonna di un file di importazione diventa quale campo e come il suo valore viene convertito lungo il percorso.", + "A pack definition is a JSON object.": "La definizione di un pacchetto è un oggetto JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Un formato data PHP come d-m-Y. Inizialo con ! perché le parti che il formato non indica non vengano prese dalla data di oggi. Lascialo vuoto per leggere qualsiasi data comune.", + "Add a field mapping with a source column to test it.": "Aggiungi una mappatura di campo con una colonna di origine per provarla.", + "Add at least one column to join.": "Aggiungi almeno una colonna da unire.", + "Add at least one field mapping.": "Aggiungi almeno una mappatura di campo.", + "Add at least one value to the table.": "Aggiungi almeno un valore alla tabella.", + "Add column": "Aggiungi colonna", + "Add default": "Aggiungi valore predefinito", + "Add field mapping": "Aggiungi mappatura di campo", + "Add value": "Aggiungi valore", + "Advanced (JSON)": "Avanzate (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Un'app che importa con questo pacchetto può aggiungere regole proprie, per esempio quali colonne contengono date o da quale foglio proviene una riga. Controlla quell'app prima di modificare un pacchetto che ha installato.", + "any format": "qualsiasi formato", + "As JSON": "Come JSON", + "Built-in": "Integrato", + "Choose a source format.": "Scegli un formato di origine.", + "Column to join {number}": "Colonna da unire {number}", + "Column {source} to field {target}": "Colonna {source} nel campo {target}", + "Copied as is": "Copiato così com'è", + "Could not delete the migration pack.": "Impossibile eliminare il pacchetto di migrazione.", + "Could not import the file.": "Impossibile importare il file.", + "Could not load the migration pack.": "Impossibile caricare il pacchetto di migrazione.", + "Could not load the migration packs.": "Impossibile caricare i pacchetti di migrazione.", + "Could not save the migration pack.": "Impossibile salvare il pacchetto di migrazione.", + "Date {from} to {to}": "Data da {from} a {to}", + "Defaults": "Valori predefiniti", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Eliminare \"{name}\"? Le importazioni che usano questo pacchetto non lo troveranno più.", + "Delete migration pack": "Elimina pacchetto di migrazione", + "Delete {name}": "Elimina {name}", + "Deleted \"{name}\".": "\"{name}\" eliminato.", + "Edit {name}": "Modifica {name}", + "Export {name}": "Esporta {name}", + "Field mappings": "Mappature dei campi", + "Fields the import would write": "Campi che l'importazione scriverebbe", + "Fix the problems in the pack first; then it can be tested.": "Correggi prima i problemi nel pacchetto; poi potrà essere provato.", + "Fixed value": "Valore fisso", + "Fixed value {value}": "Valore fisso {value}", + "Form": "Modulo", + "General": "Generale", + "Generate a new id": "Genera un nuovo ID", + "Id column": "Colonna ID", + "Import JSON": "Importa JSON", + "Imported \"{name}\".": "\"{name}\" importato.", + "Imports find the pack by this slug.": "Le importazioni trovano il pacchetto tramite questo slug.", + "Join columns": "Unisci colonne", + "Joined with {fields}, separated by \"{separator}\"": "Unito con {fields}, separato da \"{separator}\"", + "Leave the field empty": "Lascia il campo vuoto", + "Lookup table": "Tabella di corrispondenza", + "Lookup table, values: {count}, {unmapped}": "Tabella di corrispondenza, valori: {count}, {unmapped}", + "Migration pack": "Pacchetto di migrazione", + "Migration packs": "Pacchetti di migrazione", + "Move row {number} down": "Sposta giù la riga {number}", + "Move row {number} up": "Sposta su la riga {number}", + "Name the column that holds the id.": "Indica la colonna che contiene l'ID.", + "No defaults.": "Nessun valore predefinito.", + "No fields: every mapping was empty or failed.": "Nessun campo: ogni mappatura era vuota o non è riuscita.", + "No migration packs yet": "Ancora nessun pacchetto di migrazione", + "None: copy the value as is": "Nessuna: copia il valore così com'è", + "Object id": "ID oggetto", + "Only administrators can change migration packs.": "Solo gli amministratori possono modificare i pacchetti di migrazione.", + "Only administrators can change migration packs. You can read this one.": "Solo gli amministratori possono modificare i pacchetti di migrazione. Puoi leggere questo.", + "other values are an error": "gli altri valori sono un errore", + "other values get a default": "gli altri valori ricevono un valore predefinito", + "other values stay empty": "gli altri valori restano vuoti", + "Problem": "Problema", + "Problems in this row": "Problemi in questa riga", + "Reload the pack": "Ricarica il pacchetto", + "Remove column {number}": "Rimuovi colonna {number}", + "Remove row {number}": "Rimuovi riga {number}", + "Report an error for the row": "Segnala un errore per la riga", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Numeri di riga nell'origine, separati da virgole. La riga 1 è la prima riga di dati.", + "Row {number} has no source column.": "La riga {number} non ha una colonna di origine.", + "Row {number} has no target field.": "La riga {number} non ha un campo di destinazione.", + "Row {number} is required": "La riga {number} è obbligatoria", + "Row {number}: {message}": "Riga {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Le righe vengono eseguite dall'alto verso il basso. Quando due righe scrivono lo stesso campo ed entrambe le colonne hanno un valore, prevale la riga più in basso.", + "Rows to skip": "Righe da saltare", + "Rows to skip must be whole numbers from 1 up: {values}": "Le righe da saltare devono essere numeri interi a partire da 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Le righe {rows} scrivono questo campo; prevale la riga più in basso con un valore.", + "Sample value": "Valore di esempio", + "Sample value for {column}": "Valore di esempio per {column}", + "Saved as version {version}.": "Salvato come versione {version}.", + "Saving raises the version to {version}.": "Il salvataggio porta la versione a {version}.", + "Separator": "Separatore", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Qualcun altro ha salvato questo pacchetto dopo che lo hai aperto. Ricaricalo per vedere le sue modifiche; le tue modifiche non sono salvate.", + "Source column": "Colonna di origine", + "Source column of row {number}": "Colonna di origine della riga {number}", + "Source date format": "Formato data di origine", + "Source format": "Formato di origine", + "Store this value": "Salva questo valore", + "Stored date format": "Formato data salvato", + "Stored value": "Valore salvato", + "Take the id from a column": "Prendi l'ID da una colonna", + "Target field": "Campo di destinazione", + "Target field of row {number}": "Campo di destinazione della riga {number}", + "Test with a sample row": "Prova con una riga di esempio", + "The default for \"{key}\" is not valid JSON.": "Il valore predefinito per \"{key}\" non è un JSON valido.", + "The default for \"{key}\" is set twice.": "Il valore predefinito per \"{key}\" è impostato due volte.", + "The fixed value is not valid JSON.": "Il valore fisso non è un JSON valido.", + "The form has a value that is not valid JSON. Fix it first.": "Il modulo contiene un valore che non è un JSON valido. Correggilo prima.", + "The name is required.": "Il nome è obbligatorio.", + "The pack cannot be saved yet.": "Il pacchetto non può ancora essere salvato.", + "The pack is not valid.": "Il pacchetto non è valido.", + "The result for \"{key}\" is not valid JSON.": "Il risultato per \"{key}\" non è un JSON valido.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Lo slug deve iniziare con una lettera minuscola o una cifra e contenere solo lettere minuscole, cifre e trattini.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Lo slug di un pacchetto integrato non può cambiare. Mantieni \"id\" su {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Lo slug di un pacchetto integrato non può cambiare: l'app che lo ha installato trova il pacchetto tramite questo slug.", + "The test could not run.": "Impossibile eseguire il test.", + "The value \"{key}\" is in the table twice.": "Il valore \"{key}\" compare due volte nella tabella.", + "The value for other values is not valid JSON.": "Il valore per gli altri valori non è un JSON valido.", + "The value of column {source} comes first, then these columns in order.": "Il valore della colonna {source} viene per primo, poi queste colonne nell'ordine.", + "The version must look like 1.0.0 (major.minor.patch).": "La versione deve avere la forma 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "L'intera definizione del pacchetto. Le modifiche fatte qui sostituiscono il modulo quando ci torni o salvi.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Questo editor non conosce il tipo di trasformazione \"{type}\". Viene mantenuto così come è salvato.", + "This is not valid JSON: {reason}": "Questo non è un JSON valido: {reason}", + "This migration pack does not exist.": "Questo pacchetto di migrazione non esiste.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Questo pacchetto è stato installato da un'app. Quell'app lo reinstalla, senza modifiche, la prossima volta che viene installata o aggiornata.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Questa trasformazione ha anche impostazioni che questo editor non mostra ({keys}). Vengono mantenute quando salvi.", + "Transform": "Trasformazione", + "Transform \"{type}\" (kept as stored)": "Trasformazione \"{type}\" (mantenuta come salvata)", + "Transform for row {number}": "Trasformazione per la riga {number}", + "Transform of row {number}": "Trasformazione della riga {number}", + "Trim spaces": "Rimuovi spazi", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Digita i valori come appaiono nell'origine. Il risultato usa la mappatura qui sopra, comprese le modifiche che non hai ancora salvato.", + "Undo changes": "Annulla modifiche", + "Value for other values": "Valore per gli altri valori", + "Value in the source": "Valore nell'origine", + "Value {number} is JSON": "Il valore {number} è JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Valori con cui inizia ogni oggetto importato. Una mappatura che dà un valore a un campo sostituisce il suo valore predefinito.", + "Values that are not in the table": "Valori non presenti nella tabella", + "View {name}": "Visualizza {name}", + "Yes/no table": "Tabella sì/no", + "Yes/no table, values: {count}, {unmapped}": "Tabella sì/no, valori: {count}, {unmapped}", + "Store false": "Salva no (false)", + "other values are stored as false": "gli altri valori vengono salvati come no (false)" }, "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" ) diff --git a/l10n/lb.js b/l10n/lb.js index 608d6df9c0..958c4f67a9 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Iwwerwaacht", "{completed} of {total} completed": "{completed} vu {total} ofgeschloss", "{date} by {person}": "{date} vum {person}", - "{failed} failed, {stopped} stopped": "{failed} feelgeschloen, {stopped} gestoppt" + "{failed} failed, {stopped} stopped": "{failed} feelgeschloen, {stopped} gestoppt", + "A column to join has no name.": "Eng Kolonn fir ze verbannen huet keen Numm.", + "A default value has no target field.": "E Standardwäert huet kee Zilfeld.", + "A fixed value is written even when the column is empty.": "E fixe Wäert gëtt och geschriwwen, wann d'Kolonn eidel ass.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "E Migratiounspak leet fest, wéi eng Kolonn vun enger Importdatei zu wéi engem Feld gëtt, a wéi hire Wäert ënnerwee ëmgewandelt gëtt.", + "A pack definition is a JSON object.": "Eng Pak-Definitioun ass en JSON-Objet.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "En PHP-Datumsformat wéi d-m-Y. Fänkt et mat ! un, sou datt Deeler, déi d'Format net nennt, net vun haut iwwerholl ginn. Loosst et eidel, fir all üblechen Datum ze liesen.", + "Add a field mapping with a source column to test it.": "Setzt e Feld-Mapping mat enger Quellkolonn derbäi, fir et ze testen.", + "Add at least one column to join.": "Setzt mindestens eng Kolonn derbäi, fir ze verbannen.", + "Add at least one field mapping.": "Setzt mindestens e Feld-Mapping derbäi.", + "Add at least one value to the table.": "Setzt mindestens ee Wäert an d'Tabell.", + "Add column": "Kolonn derbäisetzen", + "Add default": "Standardwäert derbäisetzen", + "Add field mapping": "Feld-Mapping derbäisetzen", + "Add value": "Wäert derbäisetzen", + "Advanced (JSON)": "Erweidert (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Eng App, déi mat dësem Pak importéiert, ka seng eege Reegele derbäisetzen, zum Beispill wéi eng Kolonnen Datumer enthalen oder vu wéi engem Blat eng Rei kënnt. Kontrolléiert déi App, ier Dir e Pak ännert, deen si installéiert huet.", + "any format": "all Format", + "As JSON": "Als JSON", + "Built-in": "Agebaut", + "Choose a source format.": "Wielt e Quellformat.", + "Column to join {number}": "Kolonn fir ze verbannen {number}", + "Column {source} to field {target}": "Kolonn {source} an d'Feld {target}", + "Copied as is": "Onverännert iwwerholl", + "Could not delete the migration pack.": "De Migratiounspak konnt net geläscht ginn.", + "Could not import the file.": "D'Datei konnt net importéiert ginn.", + "Could not load the migration pack.": "De Migratiounspak konnt net gelueden ginn.", + "Could not load the migration packs.": "D'Migratiounspäck konnten net gelueden ginn.", + "Could not save the migration pack.": "De Migratiounspak konnt net gespäichert ginn.", + "Date {from} to {to}": "Datum vun {from} op {to}", + "Defaults": "Standardwäerter", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "\"{name}\" läschen? Importer, déi dëse Pak benotzen, fannen en net méi.", + "Delete migration pack": "Migratiounspak läschen", + "Delete {name}": "{name} läschen", + "Deleted \"{name}\".": "\"{name}\" gouf geläscht.", + "Edit {name}": "{name} beaarbechten", + "Export {name}": "{name} exportéieren", + "Field mappings": "Feld-Mappingen", + "Fields the import would write": "Felder, déi den Import schreiwe géif", + "Fix the problems in the pack first; then it can be tested.": "Behieft fir d'éischt d'Problemer am Pak; duerno kann en getest ginn.", + "Fixed value": "Fixe Wäert", + "Fixed value {value}": "Fixe Wäert {value}", + "Form": "Formulaire", + "General": "Allgemeng", + "Generate a new id": "Eng nei ID generéieren", + "Id column": "ID-Kolonn", + "Import JSON": "JSON importéieren", + "Imported \"{name}\".": "\"{name}\" gouf importéiert.", + "Imports find the pack by this slug.": "Importer fannen de Pak iwwer dëse Kuerznumm.", + "Join columns": "Kolonne verbannen", + "Joined with {fields}, separated by \"{separator}\"": "Verbonne mat {fields}, getrennt duerch \"{separator}\"", + "Leave the field empty": "D'Feld eidel loossen", + "Lookup table": "Referenztabell", + "Lookup table, values: {count}, {unmapped}": "Referenztabell, Wäerter: {count}, {unmapped}", + "Migration pack": "Migratiounspak", + "Migration packs": "Migratiounspäck", + "Move row {number} down": "Rei {number} erof réckelen", + "Move row {number} up": "Rei {number} erop réckelen", + "Name the column that holds the id.": "Nennt d'Kolonn, déi d'ID enthält.", + "No defaults.": "Keng Standardwäerter.", + "No fields: every mapping was empty or failed.": "Keng Felder: all Mapping war eidel oder ass feelgeschloen.", + "No migration packs yet": "Nach keng Migratiounspäck", + "None: copy the value as is": "Keng: de Wäert onverännert iwwerhuelen", + "Object id": "Objet-ID", + "Only administrators can change migration packs.": "Nëmmen Administrateure kënnen Migratiounspäck änneren.", + "Only administrators can change migration packs. You can read this one.": "Nëmmen Administrateure kënnen Migratiounspäck änneren. Dir kënnt dëse Pak liesen.", + "other values are an error": "aner Wäerter ginn e Feeler", + "other values get a default": "aner Wäerter kréien e Standardwäert", + "other values stay empty": "aner Wäerter bleiwen eidel", + "Problem": "Feeler", + "Problems in this row": "Problemer an dëser Rei", + "Reload the pack": "Pak nei lueden", + "Remove column {number}": "Kolonn {number} ewechhuelen", + "Remove row {number}": "Rei {number} ewechhuelen", + "Report an error for the row": "E Feeler fir d'Rei mellen", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Reienummeren an der Quell, getrennt duerch Kommaen. Rei 1 ass déi éischt Datenrei.", + "Row {number} has no source column.": "Rei {number} huet keng Quellkolonn.", + "Row {number} has no target field.": "Rei {number} huet kee Zilfeld.", + "Row {number} is required": "Rei {number} ass obligatoresch", + "Row {number}: {message}": "Rei {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "D'Reie lafe vun uewen no ënnen. Wann zwou Reien datselwecht Feld schreiwen an déi zwou Kolonnen e Wäert hunn, gewënnt déi ënnescht Rei.", + "Rows to skip": "Reien, déi iwwersprong ginn", + "Rows to skip must be whole numbers from 1 up: {values}": "Reien, déi iwwersprong ginn, musse ganz Zuele vun 1 un sinn: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "D'Reien {rows} schreiwen dëst Feld; déi ënnescht Rei mat engem Wäert gewënnt.", + "Sample value": "Beispillwäert", + "Sample value for {column}": "Beispillwäert fir {column}", + "Saved as version {version}.": "Als Versioun {version} gespäichert.", + "Saving raises the version to {version}.": "Beim Späicheren klëmmt d'Versioun op {version}.", + "Separator": "Trennzeechen", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Een aneren huet dëse Pak gespäichert, nodeems Dir en opgemaach hutt. Lued en nei, fir déi Ännerungen ze gesinn; Är Ännerunge sinn net gespäichert.", + "Source column": "Quellkolonn", + "Source column of row {number}": "Quellkolonn vun der Rei {number}", + "Source date format": "Datumsformat an der Quell", + "Source format": "Quellformat", + "Store this value": "Dëse Wäert späicheren", + "Stored date format": "Gespäichert Datumsformat", + "Stored value": "Gespäicherte Wäert", + "Take the id from a column": "D'ID aus enger Kolonn huelen", + "Target field": "Zilfeld", + "Target field of row {number}": "Zilfeld vun der Rei {number}", + "Test with a sample row": "Mat enger Beispillrei testen", + "The default for \"{key}\" is not valid JSON.": "De Standardwäert fir \"{key}\" ass kee gültegen JSON.", + "The default for \"{key}\" is set twice.": "De Standardwäert fir \"{key}\" ass zweemol gesat.", + "The fixed value is not valid JSON.": "De fixe Wäert ass kee gültegen JSON.", + "The form has a value that is not valid JSON. Fix it first.": "De Formulaire huet e Wäert, deen kee gültegen JSON ass. Behieft deen fir d'éischt.", + "The name is required.": "Den Numm ass obligatoresch.", + "The pack cannot be saved yet.": "De Pak kann nach net gespäichert ginn.", + "The pack is not valid.": "De Pak ass net gülteg.", + "The result for \"{key}\" is not valid JSON.": "D'Resultat fir \"{key}\" ass kee gültegen JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "De Kuerznumm muss mat engem klenge Buschtaf oder enger Ziffer ufänken an däerf nëmme kleng Buschtawen, Ziffere a Bindestricher enthalen.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "De Kuerznumm vun engem agebaute Pak kann net geännert ginn. Loosst \"id\" op {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "De Kuerznumm vun engem agebaute Pak kann net geännert ginn: d'App, déi en installéiert huet, fënnt de Pak iwwer dëse Kuerznumm.", + "The test could not run.": "Den Test konnt net lafen.", + "The value \"{key}\" is in the table twice.": "De Wäert \"{key}\" steet zweemol an der Tabell.", + "The value for other values is not valid JSON.": "De Wäert fir aner Wäerter ass kee gültegen JSON.", + "The value of column {source} comes first, then these columns in order.": "De Wäert vun der Kolonn {source} kënnt fir d'éischt, duerno dës Kolonnen an der Reiefolleg.", + "The version must look like 1.0.0 (major.minor.patch).": "D'Versioun muss ausgesi wéi 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Déi ganz Pak-Definitioun. Ännerungen hei ersetzen de Formulaire, wann Dir dohinner zréckgitt oder späichert.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Dësen Editor kennt den Transformatiounstyp \"{type}\" net. E bleift esou, wéi e gespäichert ass.", + "This is not valid JSON: {reason}": "Dat ass kee gültegen JSON: {reason}", + "This migration pack does not exist.": "Dëse Migratiounspak gëtt et net.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Dëse Pak gouf vun enger App installéiert. Déi App installéiert en nees, onbeaarbecht, déi nächste Kéier, wou se installéiert oder aktualiséiert gëtt.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Dës Transformatioun huet och Astellungen, déi dësen Editor net weist ({keys}). Si bleiwen erhalen, wann Dir späichert.", + "Transform": "Transformatioun", + "Transform \"{type}\" (kept as stored)": "Transformatioun \"{type}\" (bleift wéi gespäichert)", + "Transform for row {number}": "Transformatioun fir d'Rei {number}", + "Transform of row {number}": "Transformatioun vun der Rei {number}", + "Trim spaces": "Espacen um Rand ewechhuelen", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Gitt d'Wäerter esou an, wéi se an der Quell stinn. D'Resultat benotzt d'Mapping uewendriwwer, och Ännerungen, déi Dir nach net gespäichert hutt.", + "Undo changes": "Ännerungen réckgängeg maachen", + "Value for other values": "Wäert fir aner Wäerter", + "Value in the source": "Wäert an der Quell", + "Value {number} is JSON": "Wäert {number} ass JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Wäerter, mat deenen all importéiert Objet ufänkt. E Mapping, dat engem Feld e Wäert gëtt, ersetzt säi Standardwäert.", + "Values that are not in the table": "Wäerter, déi net an der Tabell sinn", + "View {name}": "{name} weisen", + "Yes/no table": "Jo/Nee-Tabell", + "Yes/no table, values: {count}, {unmapped}": "Jo/Nee-Tabell, Wäerter: {count}, {unmapped}", + "Store false": "Nee (false) späicheren", + "other values are stored as false": "aner Wäerter ginn als nee (false) gespäichert" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.js b/l10n/lt.js index eac6de8dd8..0b7ad559b8 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Stebi", "{completed} of {total} completed": "{completed} iš {total} baigta", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "{failed} nepavyko, {stopped} sustabdyta" + "{failed} failed, {stopped} stopped": "{failed} nepavyko, {stopped} sustabdyta", + "A column to join has no name.": "Sujungiamas stulpelis neturi pavadinimo.", + "A default value has no target field.": "Numatytoji reikšmė neturi tikslinio lauko.", + "A fixed value is written even when the column is empty.": "Fiksuota reikšmė įrašoma net tada, kai stulpelis tuščias.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Migracijos paketas nurodo, kuris importo failo stulpelis tampa kuriuo lauku ir kaip jo reikšmė pakeliui konvertuojama.", + "A pack definition is a JSON object.": "Paketo apibrėžtis yra JSON objektas.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP datos formatas, pavyzdžiui, d-m-Y. Pradėkite jį simboliu !, kad dalys, kurių formatas nenurodo, nebūtų imamos iš šiandienos datos. Palikite tuščią, kad būtų nuskaityta bet kuri įprasta data.", + "Add a field mapping with a source column to test it.": "Pridėkite lauko susiejimą su šaltinio stulpeliu, kad galėtumėte jį išbandyti.", + "Add at least one column to join.": "Pridėkite bent vieną sujungiamą stulpelį.", + "Add at least one field mapping.": "Pridėkite bent vieną lauko susiejimą.", + "Add at least one value to the table.": "Pridėkite bent vieną reikšmę į lentelę.", + "Add column": "Pridėti stulpelį", + "Add default": "Pridėti numatytąją reikšmę", + "Add field mapping": "Pridėti lauko susiejimą", + "Add value": "Pridėti reikšmę", + "Advanced (JSON)": "Išplėstiniai (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Programėlė, kuri importuoja su šiuo paketu, gali pridėti savo taisykles, pavyzdžiui, kuriuose stulpeliuose yra datos arba iš kurio lapo yra eilutė. Prieš keisdami tos programėlės įdiegtą paketą, patikrinkite ją.", + "any format": "bet koks formatas", + "As JSON": "Kaip JSON", + "Built-in": "Integruotas", + "Choose a source format.": "Pasirinkite šaltinio formatą.", + "Column to join {number}": "Sujungiamas stulpelis {number}", + "Column {source} to field {target}": "Stulpelis {source} į lauką {target}", + "Copied as is": "Nukopijuota be pakeitimų", + "Could not delete the migration pack.": "Nepavyko ištrinti migracijos paketo.", + "Could not import the file.": "Nepavyko importuoti failo.", + "Could not load the migration pack.": "Nepavyko įkelti migracijos paketo.", + "Could not load the migration packs.": "Nepavyko įkelti migracijos paketų.", + "Could not save the migration pack.": "Nepavyko įrašyti migracijos paketo.", + "Date {from} to {to}": "Data iš {from} į {to}", + "Defaults": "Numatytosios reikšmės", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Ištrinti \"{name}\"? Importai, kurie naudoja šį paketą, jo nebesuras.", + "Delete migration pack": "Ištrinti migracijos paketą", + "Delete {name}": "Ištrinti {name}", + "Deleted \"{name}\".": "\"{name}\" ištrintas.", + "Edit {name}": "Redaguoti {name}", + "Export {name}": "Eksportuoti {name}", + "Field mappings": "Laukų susiejimai", + "Fields the import would write": "Laukai, kuriuos įrašytų importas", + "Fix the problems in the pack first; then it can be tested.": "Pirmiausia ištaisykite paketo problemas; tada jį bus galima išbandyti.", + "Fixed value": "Fiksuota reikšmė", + "Fixed value {value}": "Fiksuota reikšmė {value}", + "Form": "Forma", + "General": "Bendra", + "Generate a new id": "Sugeneruoti naują id", + "Id column": "Id stulpelis", + "Import JSON": "Importuoti JSON", + "Imported \"{name}\".": "\"{name}\" importuotas.", + "Imports find the pack by this slug.": "Importai randa paketą pagal šį trumpąjį pavadinimą.", + "Join columns": "Sujungti stulpelius", + "Joined with {fields}, separated by \"{separator}\"": "Sujungta su {fields}, atskirta \"{separator}\"", + "Leave the field empty": "Palikti lauką tuščią", + "Lookup table": "Paieškos lentelė", + "Lookup table, values: {count}, {unmapped}": "Paieškos lentelė, reikšmės: {count}, {unmapped}", + "Migration pack": "Migracijos paketas", + "Migration packs": "Migracijos paketai", + "Move row {number} down": "Perkelti eilutę {number} žemyn", + "Move row {number} up": "Perkelti eilutę {number} aukštyn", + "Name the column that holds the id.": "Nurodykite stulpelį, kuriame yra id.", + "No defaults.": "Numatytųjų reikšmių nėra.", + "No fields: every mapping was empty or failed.": "Laukų nėra: kiekvienas susiejimas buvo tuščias arba nepavyko.", + "No migration packs yet": "Migracijos paketų dar nėra", + "None: copy the value as is": "Jokio: kopijuoti reikšmę be pakeitimų", + "Object id": "Objekto id", + "Only administrators can change migration packs.": "Tik administratoriai gali keisti migracijos paketus.", + "Only administrators can change migration packs. You can read this one.": "Tik administratoriai gali keisti migracijos paketus. Šį paketą galite peržiūrėti.", + "other values are an error": "kitos reikšmės sukelia klaidą", + "other values get a default": "kitoms reikšmėms suteikiama numatytoji reikšmė", + "other values stay empty": "kitos reikšmės lieka tuščios", + "Problem": "Problema", + "Problems in this row": "Problemos šioje eilutėje", + "Reload the pack": "Iš naujo įkelti paketą", + "Remove column {number}": "Pašalinti stulpelį {number}", + "Remove row {number}": "Pašalinti eilutę {number}", + "Report an error for the row": "Pranešti apie eilutės klaidą", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Šaltinio eilučių numeriai, atskirti kableliais. 1 eilutė yra pirmoji duomenų eilutė.", + "Row {number} has no source column.": "Eilutė {number} neturi šaltinio stulpelio.", + "Row {number} has no target field.": "Eilutė {number} neturi tikslinio lauko.", + "Row {number} is required": "Eilutė {number} yra privaloma", + "Row {number}: {message}": "Eilutė {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Eilutės vykdomos iš viršaus į apačią. Kai dvi eilutės rašo į tą patį lauką ir abiejuose stulpeliuose yra reikšmė, laimi žemesnė eilutė.", + "Rows to skip": "Praleidžiamos eilutės", + "Rows to skip must be whole numbers from 1 up: {values}": "Praleidžiamos eilutės turi būti sveikieji skaičiai nuo 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Eilutės {rows} rašo į šį lauką; laimi žemiausia eilutė su reikšme.", + "Sample value": "Pavyzdinė reikšmė", + "Sample value for {column}": "Pavyzdinė reikšmė stulpeliui {column}", + "Saved as version {version}.": "Įrašyta kaip versija {version}.", + "Saving raises the version to {version}.": "Įrašius versija padidės iki {version}.", + "Separator": "Skirtukas", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Kažkas kitas įrašė šį paketą po to, kai jį atidarėte. Įkelkite jį iš naujo, kad pamatytumėte tuos pakeitimus; jūsų pakeitimai neįrašyti.", + "Source column": "Šaltinio stulpelis", + "Source column of row {number}": "Eilutės {number} šaltinio stulpelis", + "Source date format": "Šaltinio datos formatas", + "Source format": "Šaltinio formatas", + "Store this value": "Įrašyti šią reikšmę", + "Stored date format": "Įrašomos datos formatas", + "Stored value": "Įrašoma reikšmė", + "Take the id from a column": "Paimti id iš stulpelio", + "Target field": "Tikslinis laukas", + "Target field of row {number}": "Eilutės {number} tikslinis laukas", + "Test with a sample row": "Išbandyti su pavyzdine eilute", + "The default for \"{key}\" is not valid JSON.": "\"{key}\" numatytoji reikšmė nėra tinkamas JSON.", + "The default for \"{key}\" is set twice.": "\"{key}\" numatytoji reikšmė nustatyta du kartus.", + "The fixed value is not valid JSON.": "Fiksuota reikšmė nėra tinkamas JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Formoje yra reikšmė, kuri nėra tinkamas JSON. Pirmiausia ją ištaisykite.", + "The name is required.": "Pavadinimas yra privalomas.", + "The pack cannot be saved yet.": "Paketo dar negalima įrašyti.", + "The pack is not valid.": "Paketas netinkamas.", + "The result for \"{key}\" is not valid JSON.": "\"{key}\" rezultatas nėra tinkamas JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Trumpasis pavadinimas turi prasidėti mažąja raide arba skaitmeniu ir gali turėti tik mažąsias raides, skaitmenis ir brūkšnelius.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Integruoto paketo trumpojo pavadinimo pakeisti negalima. Palikite \"id\" reikšmę {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Integruoto paketo trumpojo pavadinimo pakeisti negalima: jį įdiegusi programėlė randa paketą pagal šį trumpąjį pavadinimą.", + "The test could not run.": "Nepavyko paleisti bandymo.", + "The value \"{key}\" is in the table twice.": "Reikšmė \"{key}\" lentelėje yra du kartus.", + "The value for other values is not valid JSON.": "Reikšmė kitoms reikšmėms nėra tinkamas JSON.", + "The value of column {source} comes first, then these columns in order.": "Pirmiausia eina stulpelio {source} reikšmė, po to šie stulpeliai iš eilės.", + "The version must look like 1.0.0 (major.minor.patch).": "Versija turi atrodyti kaip 1.0.0 (pagrindinė.šalutinė.pataisa).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Visa paketo apibrėžtis. Čia atlikti pakeitimai pakeičia formą, kai prie jos grįžtate arba įrašote.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Šis redaktorius nežino konvertavimo tipo \"{type}\". Jis paliekamas toks, koks įrašytas.", + "This is not valid JSON: {reason}": "Tai nėra tinkamas JSON: {reason}", + "This migration pack does not exist.": "Šio migracijos paketo nėra.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Šį paketą įdiegė programėlė. Ta programėlė jį vėl įdiegia, be pakeitimų, kitą kartą, kai ji įdiegiama arba atnaujinama.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Šis konvertavimas taip pat turi nustatymų, kurių šis redaktorius nerodo ({keys}). Įrašant jie išsaugomi.", + "Transform": "Konvertavimas", + "Transform \"{type}\" (kept as stored)": "Konvertavimas \"{type}\" (paliekamas toks, koks įrašytas)", + "Transform for row {number}": "Konvertavimas eilutei {number}", + "Transform of row {number}": "Eilutės {number} konvertavimas", + "Trim spaces": "Pašalinti tarpus", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Įveskite reikšmes taip, kaip jos yra šaltinyje. Rezultatui naudojamas aukščiau esantis susiejimas, įskaitant dar neįrašytus pakeitimus.", + "Undo changes": "Atšaukti pakeitimus", + "Value for other values": "Reikšmė kitoms reikšmėms", + "Value in the source": "Reikšmė šaltinyje", + "Value {number} is JSON": "Reikšmė {number} yra JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Reikšmės, nuo kurių prasideda kiekvienas importuotas objektas. Susiejimas, suteikiantis laukui reikšmę, pakeičia jo numatytąją reikšmę.", + "Values that are not in the table": "Reikšmės, kurių nėra lentelėje", + "View {name}": "Peržiūrėti {name}", + "Yes/no table": "Taip/ne lentelė", + "Yes/no table, values: {count}, {unmapped}": "Taip/ne lentelė, reikšmės: {count}, {unmapped}", + "Store false": "Įrašyti ne (false)", + "other values are stored as false": "kitos reikšmės įrašomos kaip ne (false)" }, "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/lv.js b/l10n/lv.js index ebf6e8b533..ebead8a6ad 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Uzrauga", "{completed} of {total} completed": "{completed} no {total} pabeigtas", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "{failed} neizdevās, {stopped} apturētas" + "{failed} failed, {stopped} stopped": "{failed} neizdevās, {stopped} apturētas", + "A column to join has no name.": "Apvienojamai kolonnai nav nosaukuma.", + "A default value has no target field.": "Noklusējuma vērtībai nav mērķa lauka.", + "A fixed value is written even when the column is empty.": "Fiksētā vērtība tiek ierakstīta arī tad, ja kolonna ir tukša.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Migrācijas pakotne nosaka, kura importa faila kolonna kļūst par kuru lauku un kā tās vērtība pa ceļam tiek pārveidota.", + "A pack definition is a JSON object.": "Pakotnes definīcija ir JSON objekts.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP datuma formāts, piemēram, d-m-Y. Sāc to ar !, lai daļas, kuras formāts nenosauc, netiktu ņemtas no šodienas datuma. Atstāj tukšu, lai nolasītu jebkuru ierastu datumu.", + "Add a field mapping with a source column to test it.": "Lai testētu, pievieno lauka kartējumu ar avota kolonnu.", + "Add at least one column to join.": "Pievieno vismaz vienu apvienojamo kolonnu.", + "Add at least one field mapping.": "Pievieno vismaz vienu lauka kartējumu.", + "Add at least one value to the table.": "Pievieno tabulai vismaz vienu vērtību.", + "Add column": "Pievienot kolonnu", + "Add default": "Pievienot noklusējuma vērtību", + "Add field mapping": "Pievienot lauka kartējumu", + "Add value": "Pievienot vērtību", + "Advanced (JSON)": "Paplašināti (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Lietotne, kas importē ar šo pakotni, var pievienot savus noteikumus, piemēram, kurās kolonnās ir datumi vai no kuras lapas nāk rinda. Pirms maini pakotni, ko tā instalēja, pārbaudi šo lietotni.", + "any format": "jebkurš formāts", + "As JSON": "Kā JSON", + "Built-in": "Iebūvēta", + "Choose a source format.": "Izvēlies avota formātu.", + "Column to join {number}": "Apvienojamā kolonna {number}", + "Column {source} to field {target}": "Kolonna {source} uz lauku {target}", + "Copied as is": "Pārņemts nemainīts", + "Could not delete the migration pack.": "Neizdevās dzēst migrācijas pakotni.", + "Could not import the file.": "Neizdevās importēt failu.", + "Could not load the migration pack.": "Neizdevās ielādēt migrācijas pakotni.", + "Could not load the migration packs.": "Neizdevās ielādēt migrācijas pakotnes.", + "Could not save the migration pack.": "Neizdevās saglabāt migrācijas pakotni.", + "Date {from} to {to}": "Datums no {from} uz {to}", + "Defaults": "Noklusējuma vērtības", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Dzēst \"{name}\"? Importi, kas izmanto šo pakotni, to vairs neatradīs.", + "Delete migration pack": "Dzēst migrācijas pakotni", + "Delete {name}": "Dzēst {name}", + "Deleted \"{name}\".": "Pakotne \"{name}\" ir izdzēsta.", + "Edit {name}": "Rediģēt {name}", + "Export {name}": "Eksportēt {name}", + "Field mappings": "Lauku kartējumi", + "Fields the import would write": "Lauki, kurus imports ierakstītu", + "Fix the problems in the pack first; then it can be tested.": "Vispirms novērs problēmas pakotnē; tad to varēs testēt.", + "Fixed value": "Fiksēta vērtība", + "Fixed value {value}": "Fiksēta vērtība {value}", + "Form": "Veidlapa", + "General": "Vispārīgi", + "Generate a new id": "Ģenerēt jaunu ID", + "Id column": "ID kolonna", + "Import JSON": "Importēt JSON", + "Imported \"{name}\".": "Pakotne \"{name}\" ir importēta.", + "Imports find the pack by this slug.": "Importi atrod pakotni pēc šī identifikatora.", + "Join columns": "Apvienot kolonnas", + "Joined with {fields}, separated by \"{separator}\"": "Apvienots ar {fields}, atdalīts ar \"{separator}\"", + "Leave the field empty": "Atstāt lauku tukšu", + "Lookup table": "Uzmeklēšanas tabula", + "Lookup table, values: {count}, {unmapped}": "Uzmeklēšanas tabula, vērtības: {count}, {unmapped}", + "Migration pack": "Migrācijas pakotne", + "Migration packs": "Migrācijas pakotnes", + "Move row {number} down": "Pārvietot rindu {number} uz leju", + "Move row {number} up": "Pārvietot rindu {number} uz augšu", + "Name the column that holds the id.": "Norādi kolonnu, kurā ir ID.", + "No defaults.": "Nav noklusējuma vērtību.", + "No fields: every mapping was empty or failed.": "Nav lauku: katrs kartējums bija tukšs vai neizdevās.", + "No migration packs yet": "Vēl nav migrācijas pakotņu", + "None: copy the value as is": "Nav: pārņemt vērtību nemainītu", + "Object id": "Objekta ID", + "Only administrators can change migration packs.": "Migrācijas pakotnes var mainīt tikai administratori.", + "Only administrators can change migration packs. You can read this one.": "Migrācijas pakotnes var mainīt tikai administratori. Šo pakotni vari lasīt.", + "other values are an error": "citas vērtības ir kļūda", + "other values get a default": "citas vērtības saņem noklusējuma vērtību", + "other values stay empty": "citas vērtības paliek tukšas", + "Problem": "Problēma", + "Problems in this row": "Problēmas šajā rindā", + "Reload the pack": "Pārlādēt pakotni", + "Remove column {number}": "Noņemt kolonnu {number}", + "Remove row {number}": "Noņemt rindu {number}", + "Report an error for the row": "Ziņot par kļūdu rindā", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Rindu numuri avotā, atdalīti ar komatiem. 1. rinda ir pirmā datu rinda.", + "Row {number} has no source column.": "Rindai {number} nav avota kolonnas.", + "Row {number} has no target field.": "Rindai {number} nav mērķa lauka.", + "Row {number} is required": "Rinda {number} ir obligāta", + "Row {number}: {message}": "Rinda {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Rindas tiek izpildītas no augšas uz leju. Ja divas rindas raksta vienu un to pašu lauku un abās kolonnās ir vērtība, uzvar zemākā rinda.", + "Rows to skip": "Izlaižamās rindas", + "Rows to skip must be whole numbers from 1 up: {values}": "Izlaižamajām rindām jābūt veseliem skaitļiem, sākot no 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Rindas {rows} raksta šo lauku; uzvar zemākā rinda ar vērtību.", + "Sample value": "Parauga vērtība", + "Sample value for {column}": "Parauga vērtība kolonnai {column}", + "Saved as version {version}.": "Saglabāts kā versija {version}.", + "Saving raises the version to {version}.": "Saglabājot versija tiek paaugstināta līdz {version}.", + "Separator": "Atdalītājs", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Kāds cits saglabāja šo pakotni pēc tam, kad tu to atvēri. Pārlādē to, lai redzētu viņa izmaiņas; tavas izmaiņas nav saglabātas.", + "Source column": "Avota kolonna", + "Source column of row {number}": "Rindas {number} avota kolonna", + "Source date format": "Avota datuma formāts", + "Source format": "Avota formāts", + "Store this value": "Saglabāt šo vērtību", + "Stored date format": "Saglabātais datuma formāts", + "Stored value": "Saglabātā vērtība", + "Take the id from a column": "Ņemt ID no kolonnas", + "Target field": "Mērķa lauks", + "Target field of row {number}": "Rindas {number} mērķa lauks", + "Test with a sample row": "Testēt ar parauga rindu", + "The default for \"{key}\" is not valid JSON.": "Noklusējuma vērtība laukam \"{key}\" nav derīgs JSON.", + "The default for \"{key}\" is set twice.": "Noklusējuma vērtība laukam \"{key}\" ir iestatīta divreiz.", + "The fixed value is not valid JSON.": "Fiksētā vērtība nav derīgs JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Veidlapā ir vērtība, kas nav derīgs JSON. Vispirms to izlabo.", + "The name is required.": "Nosaukums ir obligāts.", + "The pack cannot be saved yet.": "Pakotni vēl nevar saglabāt.", + "The pack is not valid.": "Pakotne nav derīga.", + "The result for \"{key}\" is not valid JSON.": "Rezultāts laukam \"{key}\" nav derīgs JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Identifikatoram jāsākas ar mazo burtu vai ciparu, un tajā drīkst būt tikai mazie burti, cipari un defises.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Iebūvētas pakotnes identifikatoru nevar mainīt. Atstāj \"id\" kā {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Iebūvētas pakotnes identifikatoru nevar mainīt: lietotne, kas to instalēja, atrod pakotni pēc šī identifikatora.", + "The test could not run.": "Testu neizdevās palaist.", + "The value \"{key}\" is in the table twice.": "Vērtība \"{key}\" tabulā ir divreiz.", + "The value for other values is not valid JSON.": "Vērtība citām vērtībām nav derīgs JSON.", + "The value of column {source} comes first, then these columns in order.": "Vispirms ir kolonnas {source} vērtība, tad šīs kolonnas norādītajā secībā.", + "The version must look like 1.0.0 (major.minor.patch).": "Versijai jābūt formā 1.0.0 (galvenā.papildu.labojums).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Visa pakotnes definīcija. Šeit veiktās izmaiņas aizstāj veidlapu, kad tu atgriezies pie tās vai saglabā.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Šis redaktors nepazīst pārveidojuma veidu \"{type}\". Tas tiek paturēts tāds, kāds saglabāts.", + "This is not valid JSON: {reason}": "Šis nav derīgs JSON: {reason}", + "This migration pack does not exist.": "Šī migrācijas pakotne neeksistē.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Šo pakotni instalēja lietotne. Nākamreiz, kad šī lietotne tiks instalēta vai atjaunināta, tā pakotni instalēs atkārtoti, bez labojumiem.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Šim pārveidojumam ir arī iestatījumi, kurus šis redaktors nerāda ({keys}). Saglabājot tie tiek paturēti.", + "Transform": "Pārveidojums", + "Transform \"{type}\" (kept as stored)": "Pārveidojums \"{type}\" (paturēts, kā saglabāts)", + "Transform for row {number}": "Pārveidojums rindai {number}", + "Transform of row {number}": "Rindas {number} pārveidojums", + "Trim spaces": "Apgriezt atstarpes", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Ievadi vērtības tā, kā tās ir avotā. Rezultāts izmanto augstāk esošo kartējumu, ieskaitot izmaiņas, kuras vēl neesi saglabājis.", + "Undo changes": "Atsaukt izmaiņas", + "Value for other values": "Vērtība citām vērtībām", + "Value in the source": "Vērtība avotā", + "Value {number} is JSON": "Vērtība {number} ir JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Vērtības, ar kurām sākas katrs importētais objekts. Kartējums, kas laukam piešķir vērtību, aizstāj tā noklusējuma vērtību.", + "Values that are not in the table": "Vērtības, kuru nav tabulā", + "View {name}": "Skatīt {name}", + "Yes/no table": "Jā/nē tabula", + "Yes/no table, values: {count}, {unmapped}": "Jā/nē tabula, vērtības: {count}, {unmapped}", + "Store false": "Saglabāt nē (false)", + "other values are stored as false": "citas vērtības tiek saglabātas kā nē (false)" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);" ) diff --git a/l10n/mk.js b/l10n/mk.js index 4f4ccbffa6..90e7e7e4b2 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Следи", "{completed} of {total} completed": "Завршени {completed} од {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Неуспешни: {failed}, запрени: {stopped}" + "{failed} failed, {stopped} stopped": "Неуспешни: {failed}, запрени: {stopped}", + "A column to join has no name.": "Колона за спојување нема име.", + "A default value has no target field.": "Стандардна вредност нема целно поле.", + "A fixed value is written even when the column is empty.": "Фиксна вредност се запишува дури и кога колоната е празна.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Миграцискиот пакет одредува која колона од датотеката за увоз станува кое поле и како нејзината вредност се претвора на патот.", + "A pack definition is a JSON object.": "Дефиницијата на пакетот е JSON-објект.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP-формат на датум, како d-m-Y. Започнете го со ! за деловите што форматот не ги именува да не се земаат од денешниот датум. Оставете го празно за да се чита кој било вообичаен датум.", + "Add a field mapping with a source column to test it.": "Додајте мапирање на поле со изворна колона за да го тестирате.", + "Add at least one column to join.": "Додајте барем една колона за спојување.", + "Add at least one field mapping.": "Додајте барем едно мапирање на поле.", + "Add at least one value to the table.": "Додајте барем една вредност во табелата.", + "Add column": "Додај колона", + "Add default": "Додај стандардна вредност", + "Add field mapping": "Додај мапирање на поле", + "Add value": "Додај вредност", + "Advanced (JSON)": "Напредно (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Апликација што увезува со овој пакет може да додаде свои правила, на пример кои колони содржат датуми или од кој лист доаѓа редот. Проверете ја таа апликација пред да промените пакет што таа го инсталирала.", + "any format": "кој било формат", + "As JSON": "Како JSON", + "Built-in": "Вграден", + "Choose a source format.": "Изберете изворен формат.", + "Column to join {number}": "Колона за спојување {number}", + "Column {source} to field {target}": "Колона {source} во поле {target}", + "Copied as is": "Копирано непроменето", + "Could not delete the migration pack.": "Миграцискиот пакет не можеше да се избрише.", + "Could not import the file.": "Датотеката не можеше да се увезе.", + "Could not load the migration pack.": "Миграцискиот пакет не можеше да се вчита.", + "Could not load the migration packs.": "Миграциските пакети не можеа да се вчитаат.", + "Could not save the migration pack.": "Миграцискиот пакет не можеше да се зачува.", + "Date {from} to {to}": "Датум од {from} во {to}", + "Defaults": "Стандардни вредности", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Да се избрише \"{name}\"? Увозите што го користат овој пакет повеќе нема да го најдат.", + "Delete migration pack": "Избриши миграциски пакет", + "Delete {name}": "Избриши {name}", + "Deleted \"{name}\".": "\"{name}\" е избришан.", + "Edit {name}": "Уреди {name}", + "Export {name}": "Извези {name}", + "Field mappings": "Мапирања на полиња", + "Fields the import would write": "Полиња што увозот би ги запишал", + "Fix the problems in the pack first; then it can be tested.": "Прво поправете ги проблемите во пакетот; потоа може да се тестира.", + "Fixed value": "Фиксна вредност", + "Fixed value {value}": "Фиксна вредност {value}", + "Form": "Формулар", + "General": "Општо", + "Generate a new id": "Генерирај нов идентификатор", + "Id column": "Колона со идентификатор", + "Import JSON": "Увези JSON", + "Imported \"{name}\".": "\"{name}\" е увезен.", + "Imports find the pack by this slug.": "Увозите го наоѓаат пакетот според овој slug.", + "Join columns": "Спој колони", + "Joined with {fields}, separated by \"{separator}\"": "Споено со {fields}, одделено со \"{separator}\"", + "Leave the field empty": "Остави го полето празно", + "Lookup table": "Табела за пребарување", + "Lookup table, values: {count}, {unmapped}": "Табела за пребарување, вредности: {count}, {unmapped}", + "Migration pack": "Миграциски пакет", + "Migration packs": "Миграциски пакети", + "Move row {number} down": "Премести го редот {number} надолу", + "Move row {number} up": "Премести го редот {number} нагоре", + "Name the column that holds the id.": "Наведете ја колоната што го содржи идентификаторот.", + "No defaults.": "Нема стандардни вредности.", + "No fields: every mapping was empty or failed.": "Нема полиња: секое мапирање беше празно или не успеа.", + "No migration packs yet": "Сè уште нема миграциски пакети", + "None: copy the value as is": "Ништо: копирај ја вредноста непроменета", + "Object id": "ID на објект", + "Only administrators can change migration packs.": "Само администраторите можат да ги менуваат миграциските пакети.", + "Only administrators can change migration packs. You can read this one.": "Само администраторите можат да ги менуваат миграциските пакети. Овој можете да го читате.", + "other values are an error": "другите вредности се грешка", + "other values get a default": "другите вредности добиваат стандардна вредност", + "other values stay empty": "другите вредности остануваат празни", + "Problem": "Проблем", + "Problems in this row": "Проблеми во овој ред", + "Reload the pack": "Повторно вчитај го пакетот", + "Remove column {number}": "Отстрани ја колоната {number}", + "Remove row {number}": "Отстрани го редот {number}", + "Report an error for the row": "Пријави грешка за редот", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Броеви на редови во изворот, одделени со запирки. Редот 1 е првиот ред со податоци.", + "Row {number} has no source column.": "Редот {number} нема изворна колона.", + "Row {number} has no target field.": "Редот {number} нема целно поле.", + "Row {number} is required": "Редот {number} е задолжителен", + "Row {number}: {message}": "Ред {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Редовите се извршуваат од горе надолу. Кога два реда запишуваат во истото поле и двете колони имаат вредност, победува подолниот ред.", + "Rows to skip": "Редови за прескокнување", + "Rows to skip must be whole numbers from 1 up: {values}": "Редовите за прескокнување мора да бидат цели броеви од 1 нагоре: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Редовите {rows} го запишуваат ова поле; победува најдолниот ред со вредност.", + "Sample value": "Примерна вредност", + "Sample value for {column}": "Примерна вредност за {column}", + "Saved as version {version}.": "Зачувано како верзија {version}.", + "Saving raises the version to {version}.": "Зачувувањето ја зголемува верзијата на {version}.", + "Separator": "Разделувач", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Некој друг го зачувал овој пакет откако го отворивте. Вчитајте го повторно за да ги видите неговите промени; вашите промени не се зачувани.", + "Source column": "Изворна колона", + "Source column of row {number}": "Изворна колона на редот {number}", + "Source date format": "Формат на датум во изворот", + "Source format": "Изворен формат", + "Store this value": "Зачувај ја оваа вредност", + "Stored date format": "Зачуван формат на датум", + "Stored value": "Зачувана вредност", + "Take the id from a column": "Земи го идентификаторот од колона", + "Target field": "Целно поле", + "Target field of row {number}": "Целно поле на редот {number}", + "Test with a sample row": "Тестирај со примерен ред", + "The default for \"{key}\" is not valid JSON.": "Стандардната вредност за \"{key}\" не е валиден JSON.", + "The default for \"{key}\" is set twice.": "Стандардната вредност за \"{key}\" е поставена двапати.", + "The fixed value is not valid JSON.": "Фиксната вредност не е валиден JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Формуларот има вредност што не е валиден JSON. Прво поправете ја.", + "The name is required.": "Името е задолжително.", + "The pack cannot be saved yet.": "Пакетот сè уште не може да се зачува.", + "The pack is not valid.": "Пакетот не е валиден.", + "The result for \"{key}\" is not valid JSON.": "Резултатот за \"{key}\" не е валиден JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug-от мора да започнува со мала буква или цифра и да содржи само мали букви, цифри и цртички.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug-от на вграден пакет не може да се промени. Задржете го \"id\" како {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug-от на вграден пакет не може да се промени: апликацијата што го инсталирала го наоѓа пакетот според овој slug.", + "The test could not run.": "Тестот не можеше да се изврши.", + "The value \"{key}\" is in the table twice.": "Вредноста \"{key}\" е двапати во табелата.", + "The value for other values is not valid JSON.": "Вредноста за другите вредности не е валиден JSON.", + "The value of column {source} comes first, then these columns in order.": "Прво доаѓа вредноста од колоната {source}, а потоа овие колони по ред.", + "The version must look like 1.0.0 (major.minor.patch).": "Верзијата мора да изгледа како 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Целата дефиниција на пакетот. Промените тука го заменуваат формуларот кога ќе се вратите на него или ќе зачувате.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Овој уредувач не го познава типот на трансформација \"{type}\". Се задржува како што е зачуван.", + "This is not valid JSON: {reason}": "Ова не е валиден JSON: {reason}", + "This migration pack does not exist.": "Овој миграциски пакет не постои.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Овој пакет е инсталиран од апликација. Таа апликација повторно го инсталира, неуреден, следниот пат кога ќе биде инсталирана или ажурирана.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Оваа трансформација има и поставки што овој уредувач не ги прикажува ({keys}). Тие се задржуваат кога зачувувате.", + "Transform": "Трансформација", + "Transform \"{type}\" (kept as stored)": "Трансформација \"{type}\" (задржана како што е зачувана)", + "Transform for row {number}": "Трансформација за редот {number}", + "Transform of row {number}": "Трансформација на редот {number}", + "Trim spaces": "Отстрани празни места на краевите", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Внесете ги вредностите како што се појавуваат во изворот. Резултатот го користи мапирањето погоре, вклучувајќи ги промените што не сте ги зачувале.", + "Undo changes": "Врати ги промените", + "Value for other values": "Вредност за другите вредности", + "Value in the source": "Вредност во изворот", + "Value {number} is JSON": "Вредноста {number} е JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Вредности со кои започнува секој увезен објект. Мапирање што му дава вредност на полето ја заменува неговата стандардна вредност.", + "Values that are not in the table": "Вредности што не се во табелата", + "View {name}": "Прегледај {name}", + "Yes/no table": "Табела да/не", + "Yes/no table, values: {count}, {unmapped}": "Табела да/не, вредности: {count}, {unmapped}", + "Store false": "Зачувај не (false)", + "other values are stored as false": "другите вредности се зачувуваат како не (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.js b/l10n/mt.js index b8cb1b0bd9..a93a6986d0 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Qed josserva", "{completed} of {total} completed": "{completed} minn {total} lesti", "{date} by {person}": "{date} minn {person}", - "{failed} failed, {stopped} stopped": "{failed} fallew, {stopped} waqfu" + "{failed} failed, {stopped} stopped": "{failed} fallew, {stopped} waqfu", + "A column to join has no name.": "Kolonna li trid tingħaqad m'għandhiex isem.", + "A default value has no target field.": "Valur predefinit m'għandux kamp fil-mira.", + "A fixed value is written even when the column is empty.": "Valur fiss jinkiteb anke meta l-kolonna tkun vojta.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Pakkett tal-migrazzjoni jgħid liema kolonna ta' Fajl tal-importazzjoni ssir liema kamp, u kif il-valur tagħha jiġi kkonvertit fit-triq.", + "A pack definition is a JSON object.": "Definizzjoni ta' pakkett hija oġġett JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Format tad-data PHP bħal d-m-Y. Ibdih b'! biex il-partijiet li l-format ma jsemmix ma jittieħdux mid-data tal-lum. Ħallih vojt biex tinqara kull data komuni.", + "Add a field mapping with a source column to test it.": "Żid immappjar ta' kamp b'kolonna tas-sors biex tittestjah.", + "Add at least one column to join.": "Żid mill-inqas kolonna waħda biex tingħaqad.", + "Add at least one field mapping.": "Żid mill-inqas immappjar ta' kamp wieħed.", + "Add at least one value to the table.": "Żid mill-inqas valur wieħed mat-tabella.", + "Add column": "Żid kolonna", + "Add default": "Żid valur predefinit", + "Add field mapping": "Żid immappjar ta' kamp", + "Add value": "Żid valur", + "Advanced (JSON)": "Avvanzat (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "App li timporta b'dan il-pakkett tista' żżid regoli tagħha stess, pereżempju liema kolonni fihom dati jew minn liema folja ġejja ringiela. Iċċekkja dik l-app qabel ma tibdel pakkett li installat hi.", + "any format": "kwalunkwe format", + "As JSON": "Bħala JSON", + "Built-in": "Integrat", + "Choose a source format.": "Agħżel format tas-sors.", + "Column to join {number}": "Kolonna li tingħaqad {number}", + "Column {source} to field {target}": "Kolonna {source} għall-kamp {target}", + "Copied as is": "Ikkupjat kif inhu", + "Could not delete the migration pack.": "Ma setax jitħassar il-pakkett tal-migrazzjoni.", + "Could not import the file.": "Ma setax jiġi importat il-Fajl.", + "Could not load the migration pack.": "Ma setax jitgħabba l-pakkett tal-migrazzjoni.", + "Could not load the migration packs.": "Ma setgħux jitgħabbew il-pakketti tal-migrazzjoni.", + "Could not save the migration pack.": "Ma setax jiġi ssejvjat il-pakkett tal-migrazzjoni.", + "Date {from} to {to}": "Data minn {from} għal {to}", + "Defaults": "Valuri predefiniti", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Tħassar \"{name}\"? L-importazzjonijiet li jużaw dan il-pakkett ma jibqgħux isibuh.", + "Delete migration pack": "Ħassar il-pakkett tal-migrazzjoni", + "Delete {name}": "Ħassar {name}", + "Deleted \"{name}\".": "\"{name}\" tħassar.", + "Edit {name}": "Editja {name}", + "Export {name}": "Esporta {name}", + "Field mappings": "Immappjar tal-kampijiet", + "Fields the import would write": "Kampijiet li l-importazzjoni kienet tikteb", + "Fix the problems in the pack first; then it can be tested.": "L-ewwel irranġa l-problemi fil-pakkett; imbagħad jista' jiġi ttestjat.", + "Fixed value": "Valur fiss", + "Fixed value {value}": "Valur fiss {value}", + "Form": "Formola", + "General": "Ġenerali", + "Generate a new id": "Iġġenera ID ġdid", + "Id column": "Kolonna tal-ID", + "Import JSON": "Importa JSON", + "Imported \"{name}\".": "\"{name}\" ġie importat.", + "Imports find the pack by this slug.": "L-importazzjonijiet isibu l-pakkett b'dan is-slug.", + "Join columns": "Għaqqad il-kolonni", + "Joined with {fields}, separated by \"{separator}\"": "Magħqud ma' {fields}, separat b'\"{separator}\"", + "Leave the field empty": "Ħalli l-kamp vojt", + "Lookup table": "Tabella ta' tqabbil", + "Lookup table, values: {count}, {unmapped}": "Tabella ta' tqabbil, valuri: {count}, {unmapped}", + "Migration pack": "Pakkett tal-migrazzjoni", + "Migration packs": "Pakketti tal-migrazzjoni", + "Move row {number} down": "Ċaqlaq ir-ringiela {number} 'l isfel", + "Move row {number} up": "Ċaqlaq ir-ringiela {number} 'il fuq", + "Name the column that holds the id.": "Semmi l-kolonna li fiha l-ID.", + "No defaults.": "L-ebda valur predefinit.", + "No fields: every mapping was empty or failed.": "L-ebda kamp: kull immappjar kien vojt jew falla.", + "No migration packs yet": "Għad m'hemmx pakketti tal-migrazzjoni", + "None: copy the value as is": "Xejn: ikkupja l-valur kif inhu", + "Object id": "ID tal-Oġġett", + "Only administrators can change migration packs.": "L-amministraturi biss jistgħu jibdlu l-pakketti tal-migrazzjoni.", + "Only administrators can change migration packs. You can read this one.": "L-amministraturi biss jistgħu jibdlu l-pakketti tal-migrazzjoni. Tista' taqra dan.", + "other values are an error": "valuri oħra huma żball", + "other values get a default": "valuri oħra jieħdu valur predefinit", + "other values stay empty": "valuri oħra jibqgħu vojta", + "Problem": "Problema", + "Problems in this row": "Problemi f'din ir-ringiela", + "Reload the pack": "Erġa' tella' l-pakkett", + "Remove column {number}": "Neħħi l-kolonna {number}", + "Remove row {number}": "Neħħi r-ringiela {number}", + "Report an error for the row": "Irrapporta żball għar-ringiela", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Numri tar-ringieli fis-sors, separati b'virgoli. Ringiela 1 hija l-ewwel ringiela tad-data.", + "Row {number} has no source column.": "Ir-ringiela {number} m'għandhiex kolonna tas-sors.", + "Row {number} has no target field.": "Ir-ringiela {number} m'għandhiex kamp fil-mira.", + "Row {number} is required": "Ir-ringiela {number} hija meħtieġa", + "Row {number}: {message}": "Ringiela {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Ir-ringieli jitħaddmu minn fuq għal isfel. Meta żewġ ringieli jiktbu l-istess kamp u ż-żewġ kolonni għandhom valur, tirbaħ ir-ringiela t'isfel.", + "Rows to skip": "Ringieli li għandhom jinqabżu", + "Rows to skip must be whole numbers from 1 up: {values}": "Ir-ringieli li għandhom jinqabżu jridu jkunu numri sħaħ minn 1 'il fuq: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Ir-ringieli {rows} jiktbu dan il-kamp; tirbaħ l-aktar ringiela t'isfel li għandha valur.", + "Sample value": "Valur ta' eżempju", + "Sample value for {column}": "Valur ta' eżempju għal {column}", + "Saved as version {version}.": "Issejvjat bħala l-verżjoni {version}.", + "Saving raises the version to {version}.": "Meta tissejvja, il-verżjoni titla' għal {version}.", + "Separator": "Separatur", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Xi ħadd ieħor issejvja dan il-pakkett wara li fetaħtu int. Erġa' tellgħu biex tara l-bidliet tiegħu; il-bidliet tiegħek mhumiex issejvjati.", + "Source column": "Kolonna tas-sors", + "Source column of row {number}": "Kolonna tas-sors tar-ringiela {number}", + "Source date format": "Format tad-data fis-sors", + "Source format": "Format tas-sors", + "Store this value": "Aħżen dan il-valur", + "Stored date format": "Format tad-data maħżun", + "Stored value": "Valur maħżun", + "Take the id from a column": "Ħu l-ID minn kolonna", + "Target field": "Kamp fil-mira", + "Target field of row {number}": "Kamp fil-mira tar-ringiela {number}", + "Test with a sample row": "Ittestja b'ringiela ta' eżempju", + "The default for \"{key}\" is not valid JSON.": "Il-valur predefinit għal \"{key}\" mhuwiex JSON validu.", + "The default for \"{key}\" is set twice.": "Il-valur predefinit għal \"{key}\" huwa ssettjat darbtejn.", + "The fixed value is not valid JSON.": "Il-valur fiss mhuwiex JSON validu.", + "The form has a value that is not valid JSON. Fix it first.": "Il-formola fiha valur li mhuwiex JSON validu. L-ewwel irranġah.", + "The name is required.": "L-isem huwa meħtieġ.", + "The pack cannot be saved yet.": "Il-pakkett għadu ma jistax jiġi ssejvjat.", + "The pack is not valid.": "Il-pakkett mhuwiex validu.", + "The result for \"{key}\" is not valid JSON.": "Ir-riżultat għal \"{key}\" mhuwiex JSON validu.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Is-slug irid jibda b'ittra żgħira jew b'ċifra u jkun fih biss ittri żgħar, ċifri u siżingi.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Is-slug ta' pakkett integrat ma jistax jinbidel. Żomm \"id\" bħala {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Is-slug ta' pakkett integrat ma jistax jinbidel: l-app li installatu ssib il-pakkett b'dan is-slug.", + "The test could not run.": "It-test ma setax jitħaddem.", + "The value \"{key}\" is in the table twice.": "Il-valur \"{key}\" jinsab darbtejn fit-tabella.", + "The value for other values is not valid JSON.": "Il-valur għal valuri oħra mhuwiex JSON validu.", + "The value of column {source} comes first, then these columns in order.": "Il-valur tal-kolonna {source} jiġi l-ewwel, imbagħad dawn il-kolonni f'ordni.", + "The version must look like 1.0.0 (major.minor.patch).": "Il-verżjoni trid tkun bħal 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Id-definizzjoni sħiħa tal-pakkett. Il-bidliet hawn jissostitwixxu l-formola meta terġa' lura għaliha jew tissejvja.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Dan l-editur ma jafx it-tip ta' trasformazzjoni \"{type}\". Jinżamm kif inhu maħżun.", + "This is not valid JSON: {reason}": "Dan mhuwiex JSON validu: {reason}", + "This migration pack does not exist.": "Dan il-pakkett tal-migrazzjoni ma jeżistix.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Dan il-pakkett ġie installat minn app. Dik l-app terġa' tinstallah, mhux editjat, il-darba li jmiss li tiġi installata jew aġġornata.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Din it-trasformazzjoni għandha wkoll settings li dan l-editur ma jurix ({keys}). Jinżammu meta tissejvja.", + "Transform": "Trasformazzjoni", + "Transform \"{type}\" (kept as stored)": "Trasformazzjoni \"{type}\" (miżmuma kif maħżuna)", + "Transform for row {number}": "Trasformazzjoni għar-ringiela {number}", + "Transform of row {number}": "Trasformazzjoni tar-ringiela {number}", + "Trim spaces": "Neħħi l-ispazji", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Ikteb il-valuri kif jidhru fis-sors. Ir-riżultat juża l-immappjar ta' hawn fuq, inklużi bidliet li għadek ma ssejvjajtx.", + "Undo changes": "Annulla l-bidliet", + "Value for other values": "Valur għal valuri oħra", + "Value in the source": "Valur fis-sors", + "Value {number} is JSON": "Il-valur {number} huwa JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Valuri li bihom jibda kull Oġġett importat. Immappjar li jagħti valur lil kamp jissostitwixxi l-valur predefinit tiegħu.", + "Values that are not in the table": "Valuri li mhumiex fit-tabella", + "View {name}": "Ara {name}", + "Yes/no table": "Tabella iva/le", + "Yes/no table, values: {count}, {unmapped}": "Tabella iva/le, valuri: {count}, {unmapped}", + "Store false": "Aħżen le (false)", + "other values are stored as false": "valuri oħra jinħażnu bħala le (false)" }, "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/nb.js b/l10n/nb.js index 61b01fa469..bf987a95e8 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Overvåker", "{completed} of {total} completed": "{completed} av {total} fullført", "{date} by {person}": "{date} av {person}", - "{failed} failed, {stopped} stopped": "{failed} mislyktes, {stopped} stoppet" + "{failed} failed, {stopped} stopped": "{failed} mislyktes, {stopped} stoppet", + "A column to join has no name.": "En kolonne som skal slås sammen, har ikke noe navn.", + "A default value has no target field.": "En standardverdi har ikke noe målfelt.", + "A fixed value is written even when the column is empty.": "En fast verdi skrives selv når kolonnen er tom.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "En migreringspakke angir hvilken kolonne i en importfil som blir hvilket felt, og hvordan verdien konverteres underveis.", + "A pack definition is a JSON object.": "En pakkedefinisjon er et JSON-objekt.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Et PHP-datoformat som d-m-Y. Start med ! slik at deler formatet ikke nevner, ikke hentes fra dagens dato. La stå tomt for å lese alle vanlige datoer.", + "Add a field mapping with a source column to test it.": "Legg til en felttilordning med en kildekolonne for å teste den.", + "Add at least one column to join.": "Legg til minst én kolonne som skal slås sammen.", + "Add at least one field mapping.": "Legg til minst én felttilordning.", + "Add at least one value to the table.": "Legg til minst én verdi i tabellen.", + "Add column": "Legg til kolonne", + "Add default": "Legg til standardverdi", + "Add field mapping": "Legg til felttilordning", + "Add value": "Legg til verdi", + "Advanced (JSON)": "Avansert (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "En app som importerer med denne pakken, kan legge til egne regler, for eksempel hvilke kolonner som inneholder datoer eller hvilket ark en rad kommer fra. Sjekk den appen før du endrer en pakke den har installert.", + "any format": "alle formater", + "As JSON": "Som JSON", + "Built-in": "Innebygd", + "Choose a source format.": "Velg et kildeformat.", + "Column to join {number}": "Kolonne som skal slås sammen {number}", + "Column {source} to field {target}": "Kolonne {source} til felt {target}", + "Copied as is": "Kopieres uendret", + "Could not delete the migration pack.": "Kunne ikke slette migreringspakken.", + "Could not import the file.": "Kunne ikke importere filen.", + "Could not load the migration pack.": "Kunne ikke laste inn migreringspakken.", + "Could not load the migration packs.": "Kunne ikke laste inn migreringspakkene.", + "Could not save the migration pack.": "Kunne ikke lagre migreringspakken.", + "Date {from} to {to}": "Dato {from} til {to}", + "Defaults": "Standardverdier", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Slette \"{name}\"? Importer som bruker denne pakken, finner den ikke lenger.", + "Delete migration pack": "Slett migreringspakke", + "Delete {name}": "Slett {name}", + "Deleted \"{name}\".": "\"{name}\" er slettet.", + "Edit {name}": "Rediger {name}", + "Export {name}": "Eksporter {name}", + "Field mappings": "Felttilordninger", + "Fields the import would write": "Felt importen ville skrive", + "Fix the problems in the pack first; then it can be tested.": "Rett opp problemene i pakken først; deretter kan den testes.", + "Fixed value": "Fast verdi", + "Fixed value {value}": "Fast verdi {value}", + "Form": "Skjemavisning", + "General": "Generelt", + "Generate a new id": "Generer en ny id", + "Id column": "Id-kolonne", + "Import JSON": "Importer JSON", + "Imported \"{name}\".": "\"{name}\" er importert.", + "Imports find the pack by this slug.": "Importer finner pakken via denne slugen.", + "Join columns": "Slå sammen kolonner", + "Joined with {fields}, separated by \"{separator}\"": "Slått sammen med {fields}, skilt med \"{separator}\"", + "Leave the field empty": "La feltet stå tomt", + "Lookup table": "Oppslagstabell", + "Lookup table, values: {count}, {unmapped}": "Oppslagstabell, verdier: {count}, {unmapped}", + "Migration pack": "Migreringspakke", + "Migration packs": "Migreringspakker", + "Move row {number} down": "Flytt rad {number} ned", + "Move row {number} up": "Flytt rad {number} opp", + "Name the column that holds the id.": "Angi kolonnen som inneholder id-en.", + "No defaults.": "Ingen standardverdier.", + "No fields: every mapping was empty or failed.": "Ingen felt: hver tilordning var tom eller mislyktes.", + "No migration packs yet": "Ingen migreringspakker ennå", + "None: copy the value as is": "Ingen: kopier verdien uendret", + "Object id": "Objekt-id", + "Only administrators can change migration packs.": "Bare administratorer kan endre migreringspakker.", + "Only administrators can change migration packs. You can read this one.": "Bare administratorer kan endre migreringspakker. Du kan lese denne.", + "other values are an error": "andre verdier gir en feil", + "other values get a default": "andre verdier får en standardverdi", + "other values stay empty": "andre verdier forblir tomme", + "Problem": "Feil", + "Problems in this row": "Problemer i denne raden", + "Reload the pack": "Last inn pakken på nytt", + "Remove column {number}": "Fjern kolonne {number}", + "Remove row {number}": "Fjern rad {number}", + "Report an error for the row": "Rapporter en feil for raden", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Radnumre i kilden, skilt med komma. Rad 1 er den første dataraden.", + "Row {number} has no source column.": "Rad {number} har ingen kildekolonne.", + "Row {number} has no target field.": "Rad {number} har ikke noe målfelt.", + "Row {number} is required": "Rad {number} er påkrevd", + "Row {number}: {message}": "Rad {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Radene kjøres ovenfra og ned. Når to rader skriver til samme felt og begge kolonnene har en verdi, vinner den nederste raden.", + "Rows to skip": "Rader som skal hoppes over", + "Rows to skip must be whole numbers from 1 up: {values}": "Rader som skal hoppes over, må være hele tall fra og med 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Radene {rows} skriver dette feltet; den nederste raden med en verdi vinner.", + "Sample value": "Eksempelverdi", + "Sample value for {column}": "Eksempelverdi for {column}", + "Saved as version {version}.": "Lagret som versjon {version}.", + "Saving raises the version to {version}.": "Lagring øker versjonen til {version}.", + "Separator": "Skilletegn", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Noen andre lagret denne pakken etter at du åpnet den. Last den inn på nytt for å se endringene deres; endringene dine er ikke lagret.", + "Source column": "Kildekolonne", + "Source column of row {number}": "Kildekolonne for rad {number}", + "Source date format": "Datoformat i kilden", + "Source format": "Kildeformat", + "Store this value": "Lagre denne verdien", + "Stored date format": "Lagret datoformat", + "Stored value": "Lagret verdi", + "Take the id from a column": "Hent id-en fra en kolonne", + "Target field": "Målfelt", + "Target field of row {number}": "Målfelt for rad {number}", + "Test with a sample row": "Test med en eksempelrad", + "The default for \"{key}\" is not valid JSON.": "Standardverdien for \"{key}\" er ikke gyldig JSON.", + "The default for \"{key}\" is set twice.": "Standardverdien for \"{key}\" er angitt to ganger.", + "The fixed value is not valid JSON.": "Den faste verdien er ikke gyldig JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Skjemavisningen inneholder en verdi som ikke er gyldig JSON. Rett den opp først.", + "The name is required.": "Navnet er påkrevd.", + "The pack cannot be saved yet.": "Pakken kan ikke lagres ennå.", + "The pack is not valid.": "Pakken er ikke gyldig.", + "The result for \"{key}\" is not valid JSON.": "Resultatet for \"{key}\" er ikke gyldig JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slugen må begynne med en liten bokstav eller et siffer og kan bare inneholde små bokstaver, sifre og bindestreker.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slugen til en innebygd pakke kan ikke endres. Behold \"id\" som {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slugen til en innebygd pakke kan ikke endres: appen som installerte den, finner pakken via denne slugen.", + "The test could not run.": "Testen kunne ikke kjøres.", + "The value \"{key}\" is in the table twice.": "Verdien \"{key}\" står to ganger i tabellen.", + "The value for other values is not valid JSON.": "Verdien for andre verdier er ikke gyldig JSON.", + "The value of column {source} comes first, then these columns in order.": "Verdien i kolonne {source} kommer først, deretter disse kolonnene i rekkefølge.", + "The version must look like 1.0.0 (major.minor.patch).": "Versjonen må se ut som 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Hele pakkedefinisjonen. Endringer her erstatter skjemavisningen når du går tilbake til den eller lagrer.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Denne redigereren kjenner ikke transformasjonstypen \"{type}\". Den beholdes slik den er lagret.", + "This is not valid JSON: {reason}": "Dette er ikke gyldig JSON: {reason}", + "This migration pack does not exist.": "Denne migreringspakken finnes ikke.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Denne pakken ble installert av en app. Den appen installerer den på nytt, uredigert, neste gang appen installeres eller oppdateres.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Denne transformasjonen har også innstillinger som redigereren ikke viser ({keys}). De beholdes når du lagrer.", + "Transform": "Transformasjon", + "Transform \"{type}\" (kept as stored)": "Transformasjon \"{type}\" (beholdt slik den er lagret)", + "Transform for row {number}": "Transformasjon for rad {number}", + "Transform of row {number}": "Transformasjon av rad {number}", + "Trim spaces": "Fjern mellomrom i endene", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Skriv verdiene slik de står i kilden. Resultatet bruker tilordningen ovenfor, inkludert endringer du ikke har lagret.", + "Undo changes": "Angre endringer", + "Value for other values": "Verdi for andre verdier", + "Value in the source": "Verdi i kilden", + "Value {number} is JSON": "Verdi {number} er JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Verdier hvert importerte objekt starter med. En tilordning som gir et felt en verdi, erstatter standardverdien.", + "Values that are not in the table": "Verdier som ikke står i tabellen", + "View {name}": "Vis {name}", + "Yes/no table": "Ja/nei-tabell", + "Yes/no table, values: {count}, {unmapped}": "Ja/nei-tabell, verdier: {count}, {unmapped}", + "Store false": "Lagre nei (false)", + "other values are stored as false": "andre verdier lagres som nei (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.js b/l10n/pl.js index d8104a8020..b92276d990 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Obserwuje", "{completed} of {total} completed": "Ukończono {completed} z {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Nieudane: {failed}, zatrzymane: {stopped}" + "{failed} failed, {stopped} stopped": "Nieudane: {failed}, zatrzymane: {stopped}", + "A column to join has no name.": "Kolumna do połączenia nie ma nazwy.", + "A default value has no target field.": "Wartość domyślna nie ma pola docelowego.", + "A fixed value is written even when the column is empty.": "Stała wartość jest zapisywana nawet wtedy, gdy kolumna jest pusta.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Pakiet migracji określa, która kolumna pliku importu staje się którym polem i jak po drodze przekształcana jest jej wartość.", + "A pack definition is a JSON object.": "Definicja pakietu jest obiektem JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Format daty PHP, na przykład d-m-Y. Zacznij go od !, aby części, których format nie określa, nie były brane z dzisiejszej daty. Pozostaw puste, aby odczytać dowolną typową datę.", + "Add a field mapping with a source column to test it.": "Dodaj mapowanie pola z kolumną źródłową, aby je przetestować.", + "Add at least one column to join.": "Dodaj co najmniej jedną kolumnę do połączenia.", + "Add at least one field mapping.": "Dodaj co najmniej jedno mapowanie pola.", + "Add at least one value to the table.": "Dodaj co najmniej jedną wartość do tabeli.", + "Add column": "Dodaj kolumnę", + "Add default": "Dodaj wartość domyślną", + "Add field mapping": "Dodaj mapowanie pola", + "Add value": "Dodaj wartość", + "Advanced (JSON)": "Zaawansowane (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Aplikacja, która importuje z użyciem tego pakietu, może dodać własne reguły, na przykład które kolumny zawierają daty albo z którego arkusza pochodzi wiersz. Sprawdź tę aplikację, zanim zmienisz zainstalowany przez nią pakiet.", + "any format": "dowolny format", + "As JSON": "Jako JSON", + "Built-in": "Wbudowany", + "Choose a source format.": "Wybierz format źródłowy.", + "Column to join {number}": "Kolumna do połączenia {number}", + "Column {source} to field {target}": "Kolumna {source} do pola {target}", + "Copied as is": "Skopiowano bez zmian", + "Could not delete the migration pack.": "Nie udało się usunąć pakietu migracji.", + "Could not import the file.": "Nie udało się zaimportować pliku.", + "Could not load the migration pack.": "Nie udało się wczytać pakietu migracji.", + "Could not load the migration packs.": "Nie udało się wczytać pakietów migracji.", + "Could not save the migration pack.": "Nie udało się zapisać pakietu migracji.", + "Date {from} to {to}": "Data z {from} na {to}", + "Defaults": "Wartości domyślne", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Usunąć \"{name}\"? Importy, które używają tego pakietu, nie będą go już mogły znaleźć.", + "Delete migration pack": "Usuń pakiet migracji", + "Delete {name}": "Usuń {name}", + "Deleted \"{name}\".": "Usunięto \"{name}\".", + "Edit {name}": "Edytuj {name}", + "Export {name}": "Eksportuj {name}", + "Field mappings": "Mapowania pól", + "Fields the import would write": "Pola, które zapisałby import", + "Fix the problems in the pack first; then it can be tested.": "Najpierw popraw problemy w pakiecie; potem będzie można go przetestować.", + "Fixed value": "Stała wartość", + "Fixed value {value}": "Stała wartość {value}", + "Form": "Formularz", + "General": "Ogólne", + "Generate a new id": "Wygeneruj nowy identyfikator", + "Id column": "Kolumna identyfikatora", + "Import JSON": "Importuj JSON", + "Imported \"{name}\".": "Zaimportowano \"{name}\".", + "Imports find the pack by this slug.": "Importy znajdują pakiet po tym slugu.", + "Join columns": "Połącz kolumny", + "Joined with {fields}, separated by \"{separator}\"": "Połączone z {fields}, rozdzielone \"{separator}\"", + "Leave the field empty": "Pozostaw pole puste", + "Lookup table": "Tabela wyszukiwania", + "Lookup table, values: {count}, {unmapped}": "Tabela wyszukiwania, wartości: {count}, {unmapped}", + "Migration pack": "Pakiet migracji", + "Migration packs": "Pakiety migracji", + "Move row {number} down": "Przesuń wiersz {number} w dół", + "Move row {number} up": "Przesuń wiersz {number} w górę", + "Name the column that holds the id.": "Podaj kolumnę zawierającą identyfikator.", + "No defaults.": "Brak wartości domyślnych.", + "No fields: every mapping was empty or failed.": "Brak pól: każde mapowanie było puste lub się nie powiodło.", + "No migration packs yet": "Nie ma jeszcze pakietów migracji", + "None: copy the value as is": "Brak: skopiuj wartość bez zmian", + "Object id": "Identyfikator obiektu", + "Only administrators can change migration packs.": "Tylko administratorzy mogą zmieniać pakiety migracji.", + "Only administrators can change migration packs. You can read this one.": "Tylko administratorzy mogą zmieniać pakiety migracji. Możesz przeglądać ten pakiet.", + "other values are an error": "inne wartości powodują błąd", + "other values get a default": "inne wartości otrzymują wartość domyślną", + "other values stay empty": "inne wartości pozostają puste", + "Problem": "Problem", + "Problems in this row": "Problemy w tym wierszu", + "Reload the pack": "Wczytaj pakiet ponownie", + "Remove column {number}": "Usuń kolumnę {number}", + "Remove row {number}": "Usuń wiersz {number}", + "Report an error for the row": "Zgłoś błąd dla wiersza", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Numery wierszy w źródle, rozdzielone przecinkami. Wiersz 1 to pierwszy wiersz danych.", + "Row {number} has no source column.": "Wiersz {number} nie ma kolumny źródłowej.", + "Row {number} has no target field.": "Wiersz {number} nie ma pola docelowego.", + "Row {number} is required": "Wiersz {number} jest wymagany", + "Row {number}: {message}": "Wiersz {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Wiersze są wykonywane od góry do dołu. Gdy dwa wiersze zapisują to samo pole i obie kolumny mają wartość, wygrywa niższy wiersz.", + "Rows to skip": "Wiersze do pominięcia", + "Rows to skip must be whole numbers from 1 up: {values}": "Wiersze do pominięcia muszą być liczbami całkowitymi od 1 wzwyż: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Wiersze {rows} zapisują to pole; wygrywa najniższy wiersz z wartością.", + "Sample value": "Przykładowa wartość", + "Sample value for {column}": "Przykładowa wartość dla {column}", + "Saved as version {version}.": "Zapisano jako wersję {version}.", + "Saving raises the version to {version}.": "Zapisanie podniesie wersję do {version}.", + "Separator": "Znak rozdzielający", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Od chwili otwarcia ten pakiet został zapisany przez kogoś innego. Wczytaj go ponownie, aby zobaczyć te zmiany; Twoje zmiany nie zostały zapisane.", + "Source column": "Kolumna źródłowa", + "Source column of row {number}": "Kolumna źródłowa wiersza {number}", + "Source date format": "Format daty w źródle", + "Source format": "Format źródłowy", + "Store this value": "Zapisz tę wartość", + "Stored date format": "Zapisywany format daty", + "Stored value": "Zapisywana wartość", + "Take the id from a column": "Pobierz identyfikator z kolumny", + "Target field": "Pole docelowe", + "Target field of row {number}": "Pole docelowe wiersza {number}", + "Test with a sample row": "Testuj na przykładowym wierszu", + "The default for \"{key}\" is not valid JSON.": "Wartość domyślna dla \"{key}\" nie jest prawidłowym JSON-em.", + "The default for \"{key}\" is set twice.": "Wartość domyślna dla \"{key}\" jest ustawiona dwukrotnie.", + "The fixed value is not valid JSON.": "Stała wartość nie jest prawidłowym JSON-em.", + "The form has a value that is not valid JSON. Fix it first.": "Formularz zawiera wartość, która nie jest prawidłowym JSON-em. Najpierw ją popraw.", + "The name is required.": "Nazwa jest wymagana.", + "The pack cannot be saved yet.": "Pakietu nie można jeszcze zapisać.", + "The pack is not valid.": "Pakiet jest nieprawidłowy.", + "The result for \"{key}\" is not valid JSON.": "Wynik dla \"{key}\" nie jest prawidłowym JSON-em.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug musi zaczynać się od małej litery lub cyfry i zawierać tylko małe litery, cyfry i łączniki.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Sluga wbudowanego pakietu nie można zmienić. Pozostaw \"id\" jako {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Sluga wbudowanego pakietu nie można zmienić: aplikacja, która go zainstalowała, znajduje pakiet po tym slugu.", + "The test could not run.": "Nie udało się uruchomić testu.", + "The value \"{key}\" is in the table twice.": "Wartość \"{key}\" występuje w tabeli dwukrotnie.", + "The value for other values is not valid JSON.": "Wartość dla innych wartości nie jest prawidłowym JSON-em.", + "The value of column {source} comes first, then these columns in order.": "Najpierw jest wartość kolumny {source}, potem kolejno te kolumny.", + "The version must look like 1.0.0 (major.minor.patch).": "Wersja musi mieć postać 1.0.0 (główna.poboczna.poprawka).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Cała definicja pakietu. Zmiany tutaj zastępują formularz, gdy do niego wrócisz lub zapiszesz.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Ten edytor nie zna typu przekształcenia \"{type}\". Zostaje zachowany w zapisanej postaci.", + "This is not valid JSON: {reason}": "To nie jest prawidłowy JSON: {reason}", + "This migration pack does not exist.": "Ten pakiet migracji nie istnieje.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Ten pakiet został zainstalowany przez aplikację. Ta aplikacja instaluje go ponownie, bez zmian, przy swojej następnej instalacji lub aktualizacji.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "To przekształcenie ma też ustawienia, których ten edytor nie pokazuje ({keys}). Zostają zachowane przy zapisie.", + "Transform": "Przekształcenie", + "Transform \"{type}\" (kept as stored)": "Przekształcenie \"{type}\" (zachowane w zapisanej postaci)", + "Transform for row {number}": "Przekształcenie dla wiersza {number}", + "Transform of row {number}": "Przekształcenie wiersza {number}", + "Trim spaces": "Usuń spacje z brzegów", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Wpisz wartości tak, jak występują w źródle. Wynik używa powyższego mapowania, łącznie ze zmianami, których jeszcze nie zapisano.", + "Undo changes": "Cofnij zmiany", + "Value for other values": "Wartość dla innych wartości", + "Value in the source": "Wartość w źródle", + "Value {number} is JSON": "Wartość {number} jest JSON-em", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Wartości, od których zaczyna się każdy importowany obiekt. Mapowanie, które nadaje polu wartość, zastępuje jego wartość domyślną.", + "Values that are not in the table": "Wartości, których nie ma w tabeli", + "View {name}": "Wyświetl {name}", + "Yes/no table": "Tabela tak/nie", + "Yes/no table, values: {count}, {unmapped}": "Tabela tak/nie, wartości: {count}, {unmapped}", + "Store false": "Zapisz nie (false)", + "other values are stored as false": "inne wartości są zapisywane jako nie (false)" }, "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/pt.js b/l10n/pt.js index e64f92d48f..c4a1270672 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Observa", "{completed} of {total} completed": "{completed} de {total} concluídas", "{date} by {person}": "{date} por {person}", - "{failed} failed, {stopped} stopped": "{failed} falhadas, {stopped} paradas" + "{failed} failed, {stopped} stopped": "{failed} falhadas, {stopped} paradas", + "A column to join has no name.": "Uma coluna a juntar não tem nome.", + "A default value has no target field.": "Um valor predefinido não tem campo de destino.", + "A fixed value is written even when the column is empty.": "Um valor fixo é escrito mesmo quando a coluna está vazia.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Um pacote de migração indica que coluna de um ficheiro de importação passa a ser que campo e como o respetivo valor é convertido pelo caminho.", + "A pack definition is a JSON object.": "Uma definição de pacote é um objeto JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Um formato de data PHP, como d-m-Y. Comece-o com ! para que as partes que o formato não indica não sejam retiradas da data de hoje. Deixe-o vazio para ler qualquer data comum.", + "Add a field mapping with a source column to test it.": "Adicione um mapeamento de campo com uma coluna de origem para o testar.", + "Add at least one column to join.": "Adicione pelo menos uma coluna a juntar.", + "Add at least one field mapping.": "Adicione pelo menos um mapeamento de campo.", + "Add at least one value to the table.": "Adicione pelo menos um valor à tabela.", + "Add column": "Adicionar coluna", + "Add default": "Adicionar valor predefinido", + "Add field mapping": "Adicionar mapeamento de campo", + "Add value": "Adicionar valor", + "Advanced (JSON)": "Avançado (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Uma aplicação que importa com este pacote pode acrescentar regras próprias, por exemplo, que colunas contêm datas ou de que folha vem uma linha. Verifique essa aplicação antes de alterar um pacote que ela instalou.", + "any format": "qualquer formato", + "As JSON": "Como JSON", + "Built-in": "Integrado", + "Choose a source format.": "Escolha um formato de origem.", + "Column to join {number}": "Coluna a juntar {number}", + "Column {source} to field {target}": "Coluna {source} para o campo {target}", + "Copied as is": "Copiado sem alterações", + "Could not delete the migration pack.": "Não foi possível eliminar o pacote de migração.", + "Could not import the file.": "Não foi possível importar o ficheiro.", + "Could not load the migration pack.": "Não foi possível carregar o pacote de migração.", + "Could not load the migration packs.": "Não foi possível carregar os pacotes de migração.", + "Could not save the migration pack.": "Não foi possível guardar o pacote de migração.", + "Date {from} to {to}": "Data de {from} para {to}", + "Defaults": "Valores predefinidos", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Eliminar \"{name}\"? As importações que usam este pacote deixarão de o encontrar.", + "Delete migration pack": "Eliminar pacote de migração", + "Delete {name}": "Eliminar {name}", + "Deleted \"{name}\".": "\"{name}\" eliminado.", + "Edit {name}": "Editar {name}", + "Export {name}": "Exportar {name}", + "Field mappings": "Mapeamentos de campos", + "Fields the import would write": "Campos que a importação escreveria", + "Fix the problems in the pack first; then it can be tested.": "Corrija primeiro os problemas do pacote; depois poderá ser testado.", + "Fixed value": "Valor fixo", + "Fixed value {value}": "Valor fixo {value}", + "Form": "Formulário", + "General": "Geral", + "Generate a new id": "Gerar um novo ID", + "Id column": "Coluna de ID", + "Import JSON": "Importar JSON", + "Imported \"{name}\".": "\"{name}\" importado.", + "Imports find the pack by this slug.": "As importações encontram o pacote por este slug.", + "Join columns": "Juntar colunas", + "Joined with {fields}, separated by \"{separator}\"": "Juntado com {fields}, separado por \"{separator}\"", + "Leave the field empty": "Deixar o campo vazio", + "Lookup table": "Tabela de correspondência", + "Lookup table, values: {count}, {unmapped}": "Tabela de correspondência, valores: {count}, {unmapped}", + "Migration pack": "Pacote de migração", + "Migration packs": "Pacotes de migração", + "Move row {number} down": "Mover a linha {number} para baixo", + "Move row {number} up": "Mover a linha {number} para cima", + "Name the column that holds the id.": "Indique a coluna que contém o ID.", + "No defaults.": "Sem valores predefinidos.", + "No fields: every mapping was empty or failed.": "Sem campos: todos os mapeamentos estavam vazios ou falharam.", + "No migration packs yet": "Ainda não há pacotes de migração", + "None: copy the value as is": "Nenhuma: copiar o valor sem alterações", + "Object id": "ID do objeto", + "Only administrators can change migration packs.": "Só os administradores podem alterar pacotes de migração.", + "Only administrators can change migration packs. You can read this one.": "Só os administradores podem alterar pacotes de migração. Pode consultar este.", + "other values are an error": "os outros valores são um erro", + "other values get a default": "os outros valores recebem um valor predefinido", + "other values stay empty": "os outros valores ficam vazios", + "Problem": "Problema", + "Problems in this row": "Problemas nesta linha", + "Reload the pack": "Recarregar o pacote", + "Remove column {number}": "Remover a coluna {number}", + "Remove row {number}": "Remover a linha {number}", + "Report an error for the row": "Comunicar um erro para a linha", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Números de linha na origem, separados por vírgulas. A linha 1 é a primeira linha de dados.", + "Row {number} has no source column.": "A linha {number} não tem coluna de origem.", + "Row {number} has no target field.": "A linha {number} não tem campo de destino.", + "Row {number} is required": "A linha {number} é obrigatória", + "Row {number}: {message}": "Linha {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "As linhas são executadas de cima para baixo. Quando duas linhas escrevem o mesmo campo e ambas as colunas têm um valor, prevalece a linha de baixo.", + "Rows to skip": "Linhas a ignorar", + "Rows to skip must be whole numbers from 1 up: {values}": "As linhas a ignorar têm de ser números inteiros a partir de 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "As linhas {rows} escrevem este campo; prevalece a linha mais abaixo com um valor.", + "Sample value": "Valor de exemplo", + "Sample value for {column}": "Valor de exemplo para {column}", + "Saved as version {version}.": "Guardado como versão {version}.", + "Saving raises the version to {version}.": "Ao guardar, a versão sobe para {version}.", + "Separator": "Separador", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Outra pessoa guardou este pacote depois de o ter aberto. Recarregue-o para ver as alterações dessa pessoa; as suas alterações não foram guardadas.", + "Source column": "Coluna de origem", + "Source column of row {number}": "Coluna de origem da linha {number}", + "Source date format": "Formato de data na origem", + "Source format": "Formato de origem", + "Store this value": "Guardar este valor", + "Stored date format": "Formato de data guardado", + "Stored value": "Valor guardado", + "Take the id from a column": "Obter o ID de uma coluna", + "Target field": "Campo de destino", + "Target field of row {number}": "Campo de destino da linha {number}", + "Test with a sample row": "Testar com uma linha de exemplo", + "The default for \"{key}\" is not valid JSON.": "O valor predefinido de \"{key}\" não é JSON válido.", + "The default for \"{key}\" is set twice.": "O valor predefinido de \"{key}\" está definido duas vezes.", + "The fixed value is not valid JSON.": "O valor fixo não é JSON válido.", + "The form has a value that is not valid JSON. Fix it first.": "O formulário tem um valor que não é JSON válido. Corrija-o primeiro.", + "The name is required.": "O nome é obrigatório.", + "The pack cannot be saved yet.": "O pacote ainda não pode ser guardado.", + "The pack is not valid.": "O pacote não é válido.", + "The result for \"{key}\" is not valid JSON.": "O resultado de \"{key}\" não é JSON válido.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "O slug tem de começar por uma letra minúscula ou um dígito e conter apenas letras minúsculas, dígitos e hífenes.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "O slug de um pacote integrado não pode ser alterado. Mantenha \"id\" como {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "O slug de um pacote integrado não pode ser alterado: a aplicação que o instalou encontra o pacote por este slug.", + "The test could not run.": "Não foi possível executar o teste.", + "The value \"{key}\" is in the table twice.": "O valor \"{key}\" está duas vezes na tabela.", + "The value for other values is not valid JSON.": "O valor para os outros valores não é JSON válido.", + "The value of column {source} comes first, then these columns in order.": "Primeiro vem o valor da coluna {source}, seguido destas colunas por ordem.", + "The version must look like 1.0.0 (major.minor.patch).": "A versão tem de ter o formato 1.0.0 (principal.secundária.correção).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "A definição completa do pacote. As alterações aqui substituem o formulário quando voltar a ele ou guardar.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Este editor não conhece o tipo de transformação \"{type}\". É mantido tal como está guardado.", + "This is not valid JSON: {reason}": "Isto não é JSON válido: {reason}", + "This migration pack does not exist.": "Este pacote de migração não existe.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Este pacote foi instalado por uma aplicação. Essa aplicação volta a instalá-lo, sem edições, da próxima vez que for instalada ou atualizada.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Esta transformação também tem definições que este editor não mostra ({keys}). São mantidas quando guardar.", + "Transform": "Transformação", + "Transform \"{type}\" (kept as stored)": "Transformação \"{type}\" (mantida tal como está guardada)", + "Transform for row {number}": "Transformação para a linha {number}", + "Transform of row {number}": "Transformação da linha {number}", + "Trim spaces": "Remover espaços", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Escreva os valores tal como aparecem na origem. O resultado usa o mapeamento acima, incluindo alterações que ainda não guardou.", + "Undo changes": "Anular alterações", + "Value for other values": "Valor para os outros valores", + "Value in the source": "Valor na origem", + "Value {number} is JSON": "O valor {number} é JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Valores com que cada objeto importado começa. Um mapeamento que dá um valor a um campo substitui o respetivo valor predefinido.", + "Values that are not in the table": "Valores que não estão na tabela", + "View {name}": "Ver {name}", + "Yes/no table": "Tabela sim/não", + "Yes/no table, values: {count}, {unmapped}": "Tabela sim/não, valores: {count}, {unmapped}", + "Store false": "Guardar não (false)", + "other values are stored as false": "os outros valores são guardados como não (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.js b/l10n/rm.js index f3970fc5ce..185cb625b7 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Observa", "{completed} of {total} completed": "{completed} da {total} terminadas", "{date} by {person}": "{date} da {person}", - "{failed} failed, {stopped} stopped": "{failed} betg reussidas, {stopped} fermadas" + "{failed} failed, {stopped} stopped": "{failed} betg reussidas, {stopped} fermadas", + "A column to join has no name.": "Ina colonna da cumbinar n'ha nagin num.", + "A default value has no target field.": "Ina valur predefinida n'ha nagin champ da destinaziun.", + "A fixed value is written even when the column is empty.": "Ina valur fixa vegn scritta er sche la colonna è vida.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "In pachet da migraziun definescha tge colonna d'ina datoteca d'import daventa tge champ e co sia valur vegn convertida sin via.", + "A pack definition is a JSON object.": "Ina definiziun da pachet è in object JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "In format da data PHP sco d-m-Y. Cumenzai cun !, uschia che parts che il format na numna betg na vegnan betg prendidas dad oz. Laschai vid per leger mintga data usitada.", + "Add a field mapping with a source column to test it.": "Agiuntai in mapping da champ cun ina colonna da funtauna per al testar.", + "Add at least one column to join.": "Agiuntai almain ina colonna da cumbinar.", + "Add at least one field mapping.": "Agiuntai almain in mapping da champ.", + "Add at least one value to the table.": "Agiuntai almain ina valur a la tabella.", + "Add column": "Agiuntar ina colonna", + "Add default": "Agiuntar ina valur predefinida", + "Add field mapping": "Agiuntar in mapping da champ", + "Add value": "Agiuntar ina valur", + "Advanced (JSON)": "Avanzà (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Ina app che importa cun quest pachet po agiuntar atgnas reglas, per exempel tge colonnas che cuntegnan datas u da tge fegl che ina lingia deriva. Controllai quella app avant che Vus midais in pachet ch'ella ha installà.", + "any format": "mintga format", + "As JSON": "Sco JSON", + "Built-in": "Integrà", + "Choose a source format.": "Tschernai in format da funtauna.", + "Column to join {number}": "Colonna da cumbinar {number}", + "Column {source} to field {target}": "Colonna {source} en il champ {target}", + "Copied as is": "Copià senza midadas", + "Could not delete the migration pack.": "Betg reussì da stizzar il pachet da migraziun.", + "Could not import the file.": "Betg reussì d'importar la datoteca.", + "Could not load the migration pack.": "Betg reussì da chargiar il pachet da migraziun.", + "Could not load the migration packs.": "Betg reussì da chargiar ils pachets da migraziun.", + "Could not save the migration pack.": "Betg reussì da memorisar il pachet da migraziun.", + "Date {from} to {to}": "Data da {from} en {to}", + "Defaults": "Valurs predefinidas", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Stizzar \"{name}\"? Imports che utiliseschan quest pachet na chattan betg pli el.", + "Delete migration pack": "Stizzar il pachet da migraziun", + "Delete {name}": "Stizzar {name}", + "Deleted \"{name}\".": "\"{name}\" è vegnì stizzà.", + "Edit {name}": "Modifitgar {name}", + "Export {name}": "Exportar {name}", + "Field mappings": "Mappings da champs", + "Fields the import would write": "Champs che l'import scrivess", + "Fix the problems in the pack first; then it can be tested.": "Curregi l'emprim ils problems en il pachet; alura po el vegnir testà.", + "Fixed value": "Valur fixa", + "Fixed value {value}": "Valur fixa {value}", + "Form": "Formular", + "General": "Infurmaziuns generalas", + "Generate a new id": "Generar in nov id", + "Id column": "Colonna da l'id", + "Import JSON": "Importar JSON", + "Imported \"{name}\".": "\"{name}\" è vegnì importà.", + "Imports find the pack by this slug.": "Imports chattan il pachet tenor quest slug.", + "Join columns": "Cumbinar colonnas", + "Joined with {fields}, separated by \"{separator}\"": "Cumbinà cun {fields}, separà cun \"{separator}\"", + "Leave the field empty": "Laschar vid il champ", + "Lookup table": "Tabella da consultaziun", + "Lookup table, values: {count}, {unmapped}": "Tabella da consultaziun, valurs: {count}, {unmapped}", + "Migration pack": "Pachet da migraziun", + "Migration packs": "Pachets da migraziun", + "Move row {number} down": "Spustar la lingia {number} engiu", + "Move row {number} up": "Spustar la lingia {number} ensi", + "Name the column that holds the id.": "Numnai la colonna che cuntegna l'id.", + "No defaults.": "Naginas valurs predefinidas.", + "No fields: every mapping was empty or failed.": "Nagins champs: mintga mapping era vid u n'ha betg funcziunà.", + "No migration packs yet": "Anc nagins pachets da migraziun", + "None: copy the value as is": "Nagin: copiar la valur senza midadas", + "Object id": "ID da l'object", + "Only administrators can change migration packs.": "Mo administraturs pon midar pachets da migraziun.", + "Only administrators can change migration packs. You can read this one.": "Mo administraturs pon midar pachets da migraziun. Vus pudais leger quest pachet.", + "other values are an error": "autras valurs èn ina errur", + "other values get a default": "autras valurs survegnan ina valur predefinida", + "other values stay empty": "autras valurs restan vidas", + "Problem": "Errur", + "Problems in this row": "Problems en questa lingia", + "Reload the pack": "Rechargiar il pachet", + "Remove column {number}": "Allontanar la colonna {number}", + "Remove row {number}": "Allontanar la lingia {number}", + "Report an error for the row": "Annunziar ina errur per la lingia", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Numers da lingias en la funtauna, separads cun commas. La lingia 1 è l'emprima lingia da datas.", + "Row {number} has no source column.": "La lingia {number} n'ha nagina colonna da funtauna.", + "Row {number} has no target field.": "La lingia {number} n'ha nagin champ da destinaziun.", + "Row {number} is required": "La lingia {number} è obligatorica", + "Row {number}: {message}": "Lingia {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Las lingias vegnan elavuradas da sura engiu. Sche duas lingias scrivan il medem champ ed omaduas colonnas han ina valur, gudogna la lingia sutvart.", + "Rows to skip": "Lingias da sursiglir", + "Rows to skip must be whole numbers from 1 up: {values}": "Lingias da sursiglir ston esser dumbers entirs a partir dad 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Las lingias {rows} scrivan quest champ; la lingia la pli bassa cun ina valur gudogna.", + "Sample value": "Valur d'exempel", + "Sample value for {column}": "Valur d'exempel per {column}", + "Saved as version {version}.": "Memorisà sco versiun {version}.", + "Saving raises the version to {version}.": "Memorisar auza la versiun sin {version}.", + "Separator": "Separatur", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Insatgi auter ha memorisà quest pachet suenter che Vus l'avais avert. Rechargiai el per vesair sias midadas; Vossas midadas n'èn betg memorisadas.", + "Source column": "Colonna da funtauna", + "Source column of row {number}": "Colonna da funtauna da la lingia {number}", + "Source date format": "Format da data en la funtauna", + "Source format": "Format da funtauna", + "Store this value": "Memorisar questa valur", + "Stored date format": "Format da data memorisà", + "Stored value": "Valur memorisada", + "Take the id from a column": "Prender l'id d'ina colonna", + "Target field": "Champ da destinaziun", + "Target field of row {number}": "Champ da destinaziun da la lingia {number}", + "Test with a sample row": "Testar cun ina lingia d'exempel", + "The default for \"{key}\" is not valid JSON.": "La valur predefinida per \"{key}\" n'è betg JSON valid.", + "The default for \"{key}\" is set twice.": "La valur predefinida per \"{key}\" è definida duas giadas.", + "The fixed value is not valid JSON.": "La valur fixa n'è betg JSON valid.", + "The form has a value that is not valid JSON. Fix it first.": "Il formular cuntegna ina valur che n'è betg JSON valid. Curregi l'emprim quella.", + "The name is required.": "Il num è obligatoric.", + "The pack cannot be saved yet.": "Il pachet na po anc betg vegnir memorisà.", + "The pack is not valid.": "Il pachet n'è betg valid.", + "The result for \"{key}\" is not valid JSON.": "Il resultat per \"{key}\" n'è betg JSON valid.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Il slug sto cumenzar cun ina minuscla u cun ina cifra e dastga cuntegnair mo minusclas, cifras e stritgs d'uniun.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Il slug d'in pachet integrà na po betg vegnir midà. Mantegnai \"id\" sco {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Il slug d'in pachet integrà na po betg vegnir midà: l'app che l'ha installà chatta il pachet tenor quest slug.", + "The test could not run.": "Il test n'ha betg pudì vegnir exequì.", + "The value \"{key}\" is in the table twice.": "La valur \"{key}\" è duas giadas en la tabella.", + "The value for other values is not valid JSON.": "La valur per autras valurs n'è betg JSON valid.", + "The value of column {source} comes first, then these columns in order.": "La valur da la colonna {source} vegn l'emprim, alura questas colonnas en urden.", + "The version must look like 1.0.0 (major.minor.patch).": "La versiun sto vesair ora sco 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "L'entira definiziun dal pachet. Midadas qua remplazzan il formular, cura che Vus turnais tar el u memorisais.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Quest editur n'enconuscha betg il tip da transfurmaziun \"{type}\". El vegn mantegnì sco memorisà.", + "This is not valid JSON: {reason}": "Quai n'è betg JSON valid: {reason}", + "This migration pack does not exist.": "Quest pachet da migraziun n'exista betg.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Quest pachet è vegnì installà d'ina app. Quella app al installescha danovamain, betg modifitgà, la proxima giada ch'ella vegn installada u actualisada.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Questa transfurmaziun ha er parameters che quest editur na mussa betg ({keys}). Els vegnan mantegnids, cura che Vus memorisais.", + "Transform": "Transfurmaziun", + "Transform \"{type}\" (kept as stored)": "Transfurmaziun \"{type}\" (mantegnida sco memorisada)", + "Transform for row {number}": "Transfurmaziun per la lingia {number}", + "Transform of row {number}": "Transfurmaziun da la lingia {number}", + "Trim spaces": "Allontanar spazis al cumenzament e la fin", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Endatai valurs sco ch'ellas cumparan en la funtauna. Il resultat utilisescha il mapping survart, inclusiv midadas che Vus n'avais anc betg memorisà.", + "Undo changes": "Revocar las midadas", + "Value for other values": "Valur per autras valurs", + "Value in the source": "Valur en la funtauna", + "Value {number} is JSON": "La valur {number} è JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Valurs cun las qualas mintga object importà cumenza. In mapping che dat ina valur ad in champ remplazza sia valur predefinida.", + "Values that are not in the table": "Valurs che n'èn betg en la tabella", + "View {name}": "Vesair {name}", + "Yes/no table": "Tabella gea/na", + "Yes/no table, values: {count}, {unmapped}": "Tabella gea/na, valurs: {count}, {unmapped}", + "Store false": "Memorisar na (false)", + "other values are stored as false": "autras valurs vegnan memorisadas sco na (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.js b/l10n/ro.js index 81262cf00a..88320bb23b 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Urmărește", "{completed} of {total} completed": "{completed} din {total} finalizate", "{date} by {person}": "{date} de {person}", - "{failed} failed, {stopped} stopped": "{failed} eșuate, {stopped} oprite" + "{failed} failed, {stopped} stopped": "{failed} eșuate, {stopped} oprite", + "A column to join has no name.": "O coloană de concatenat nu are nume.", + "A default value has no target field.": "O valoare implicită nu are câmp țintă.", + "A fixed value is written even when the column is empty.": "O valoare fixă este scrisă chiar și atunci când coloana este goală.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Un pachet de migrare stabilește ce coloană dintr-un fișier de import devine ce câmp și cum este convertită valoarea sa pe parcurs.", + "A pack definition is a JSON object.": "O definiție de pachet este un obiect JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Un format de dată PHP, de exemplu d-m-Y. Începeți-l cu ! pentru ca părțile pe care formatul nu le numește să nu fie preluate din data de azi. Lăsați-l gol pentru a citi orice dată uzuală.", + "Add a field mapping with a source column to test it.": "Adăugați o mapare de câmp cu o coloană sursă pentru a o testa.", + "Add at least one column to join.": "Adăugați cel puțin o coloană de concatenat.", + "Add at least one field mapping.": "Adăugați cel puțin o mapare de câmp.", + "Add at least one value to the table.": "Adăugați cel puțin o valoare în tabel.", + "Add column": "Adăugare coloană", + "Add default": "Adăugare valoare implicită", + "Add field mapping": "Adăugare mapare de câmp", + "Add value": "Adăugare valoare", + "Advanced (JSON)": "Avansat (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "O aplicație care importă cu acest pachet poate adăuga reguli proprii, de exemplu ce coloane conțin date calendaristice sau din ce foaie provine un rând. Verificați aplicația respectivă înainte de a modifica un pachet pe care l-a instalat.", + "any format": "orice format", + "As JSON": "Ca JSON", + "Built-in": "Încorporat", + "Choose a source format.": "Alegeți un format sursă.", + "Column to join {number}": "Coloana de concatenat {number}", + "Column {source} to field {target}": "Coloana {source} în câmpul {target}", + "Copied as is": "Copiat ca atare", + "Could not delete the migration pack.": "Pachetul de migrare nu a putut fi șters.", + "Could not import the file.": "Fișierul nu a putut fi importat.", + "Could not load the migration pack.": "Pachetul de migrare nu a putut fi încărcat.", + "Could not load the migration packs.": "Pachetele de migrare nu au putut fi încărcate.", + "Could not save the migration pack.": "Pachetul de migrare nu a putut fi salvat.", + "Date {from} to {to}": "Dată din {from} în {to}", + "Defaults": "Valori implicite", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Ștergeți \"{name}\"? Importurile care folosesc acest pachet nu îl vor mai găsi.", + "Delete migration pack": "Ștergere pachet de migrare", + "Delete {name}": "Ștergere {name}", + "Deleted \"{name}\".": "\"{name}\" a fost șters.", + "Edit {name}": "Editare {name}", + "Export {name}": "Exportare {name}", + "Field mappings": "Mapări de câmpuri", + "Fields the import would write": "Câmpuri pe care importul le-ar scrie", + "Fix the problems in the pack first; then it can be tested.": "Remediați mai întâi problemele din pachet; apoi acesta poate fi testat.", + "Fixed value": "Valoare fixă", + "Fixed value {value}": "Valoare fixă {value}", + "Form": "Formular", + "General": "Informații generale", + "Generate a new id": "Generare ID nou", + "Id column": "Coloană ID", + "Import JSON": "Importare JSON", + "Imported \"{name}\".": "\"{name}\" a fost importat.", + "Imports find the pack by this slug.": "Importurile găsesc pachetul după acest slug.", + "Join columns": "Concatenare coloane", + "Joined with {fields}, separated by \"{separator}\"": "Concatenat cu {fields}, separat prin \"{separator}\"", + "Leave the field empty": "Lăsare câmp gol", + "Lookup table": "Tabel de corespondență", + "Lookup table, values: {count}, {unmapped}": "Tabel de corespondență, valori: {count}, {unmapped}", + "Migration pack": "Pachet de migrare", + "Migration packs": "Pachete de migrare", + "Move row {number} down": "Mutare rând {number} în jos", + "Move row {number} up": "Mutare rând {number} în sus", + "Name the column that holds the id.": "Indicați coloana care conține ID-ul.", + "No defaults.": "Nicio valoare implicită.", + "No fields: every mapping was empty or failed.": "Niciun câmp: fiecare mapare a fost goală sau a eșuat.", + "No migration packs yet": "Încă nu există pachete de migrare", + "None: copy the value as is": "Niciuna: copiere valoare ca atare", + "Object id": "ID obiect", + "Only administrators can change migration packs.": "Doar administratorii pot modifica pachetele de migrare.", + "Only administrators can change migration packs. You can read this one.": "Doar administratorii pot modifica pachetele de migrare. Puteți citi acest pachet.", + "other values are an error": "celelalte valori sunt o eroare", + "other values get a default": "celelalte valori primesc o valoare implicită", + "other values stay empty": "celelalte valori rămân goale", + "Problem": "Problemă", + "Problems in this row": "Probleme în acest rând", + "Reload the pack": "Reîncărcare pachet", + "Remove column {number}": "Eliminare coloană {number}", + "Remove row {number}": "Eliminare rând {number}", + "Report an error for the row": "Raportare eroare pentru rând", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Numerele rândurilor din sursă, separate prin virgule. Rândul 1 este primul rând de date.", + "Row {number} has no source column.": "Rândul {number} nu are coloană sursă.", + "Row {number} has no target field.": "Rândul {number} nu are câmp țintă.", + "Row {number} is required": "Rândul {number} este obligatoriu", + "Row {number}: {message}": "Rândul {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Rândurile se execută de sus în jos. Când două rânduri scriu același câmp și ambele coloane au o valoare, câștigă rândul de mai jos.", + "Rows to skip": "Rânduri de omis", + "Rows to skip must be whole numbers from 1 up: {values}": "Rândurile de omis trebuie să fie numere întregi de la 1 în sus: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Rândurile {rows} scriu acest câmp; câștigă cel mai de jos rând cu o valoare.", + "Sample value": "Valoare exemplu", + "Sample value for {column}": "Valoare exemplu pentru {column}", + "Saved as version {version}.": "Salvat ca versiunea {version}.", + "Saving raises the version to {version}.": "Salvarea ridică versiunea la {version}.", + "Separator": "Caracter separator", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Altcineva a salvat acest pachet după ce l-ați deschis. Reîncărcați-l pentru a vedea modificările respective; modificările dumneavoastră nu sunt salvate.", + "Source column": "Coloană sursă", + "Source column of row {number}": "Coloana sursă a rândului {number}", + "Source date format": "Formatul datei în sursă", + "Source format": "Format sursă", + "Store this value": "Salvarea acestei valori", + "Stored date format": "Format de dată stocat", + "Stored value": "Valoare stocată", + "Take the id from a column": "Preluare ID dintr-o coloană", + "Target field": "Câmp țintă", + "Target field of row {number}": "Câmpul țintă al rândului {number}", + "Test with a sample row": "Testare cu un rând exemplu", + "The default for \"{key}\" is not valid JSON.": "Valoarea implicită pentru \"{key}\" nu este JSON valid.", + "The default for \"{key}\" is set twice.": "Valoarea implicită pentru \"{key}\" este setată de două ori.", + "The fixed value is not valid JSON.": "Valoarea fixă nu este JSON valid.", + "The form has a value that is not valid JSON. Fix it first.": "Formularul conține o valoare care nu este JSON valid. Remediați-o mai întâi.", + "The name is required.": "Numele este obligatoriu.", + "The pack cannot be saved yet.": "Pachetul nu poate fi salvat încă.", + "The pack is not valid.": "Pachetul nu este valid.", + "The result for \"{key}\" is not valid JSON.": "Rezultatul pentru \"{key}\" nu este JSON valid.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug-ul trebuie să înceapă cu o literă mică sau o cifră și să conțină doar litere mici, cifre și cratime.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug-ul unui pachet încorporat nu se poate modifica. Păstrați \"id\" ca {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug-ul unui pachet încorporat nu se poate modifica: aplicația care l-a instalat găsește pachetul după acest slug.", + "The test could not run.": "Testul nu a putut fi rulat.", + "The value \"{key}\" is in the table twice.": "Valoarea \"{key}\" apare de două ori în tabel.", + "The value for other values is not valid JSON.": "Valoarea pentru celelalte valori nu este JSON valid.", + "The value of column {source} comes first, then these columns in order.": "Valoarea coloanei {source} vine prima, apoi aceste coloane în ordine.", + "The version must look like 1.0.0 (major.minor.patch).": "Versiunea trebuie să arate ca 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Întreaga definiție a pachetului. Modificările de aici înlocuiesc formularul când reveniți la el sau salvați.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Acest editor nu cunoaște tipul de transformare \"{type}\". Este păstrat așa cum a fost stocat.", + "This is not valid JSON: {reason}": "Acesta nu este JSON valid: {reason}", + "This migration pack does not exist.": "Acest pachet de migrare nu există.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Acest pachet a fost instalat de o aplicație. Aplicația respectivă îl instalează din nou, needitat, data viitoare când este instalată sau actualizată.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Această transformare are și setări pe care acest editor nu le afișează ({keys}). Ele sunt păstrate când salvați.", + "Transform": "Transformare", + "Transform \"{type}\" (kept as stored)": "Transformare \"{type}\" (păstrată așa cum a fost stocată)", + "Transform for row {number}": "Transformare pentru rândul {number}", + "Transform of row {number}": "Transformarea rândului {number}", + "Trim spaces": "Eliminare spații", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Introduceți valorile așa cum apar în sursă. Rezultatul folosește maparea de mai sus, inclusiv modificările pe care nu le-ați salvat încă.", + "Undo changes": "Anulare modificări", + "Value for other values": "Valoare pentru celelalte valori", + "Value in the source": "Valoare în sursă", + "Value {number} is JSON": "Valoarea {number} este JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Valori cu care începe fiecare obiect importat. O mapare care dă o valoare unui câmp înlocuiește valoarea implicită a acestuia.", + "Values that are not in the table": "Valori care nu sunt în tabel", + "View {name}": "Vizualizare {name}", + "Yes/no table": "Tabel da/nu", + "Yes/no table, values: {count}, {unmapped}": "Tabel da/nu, valori: {count}, {unmapped}", + "Store false": "Salvare ca nu (false)", + "other values are stored as false": "celelalte valori sunt salvate ca nu (false)" }, "nplurals=3; plural=(n==1?0:(((n%100>19)||((n%100==0)&&(n!=0)))?2:1));" ) diff --git a/l10n/ru.js b/l10n/ru.js index 3252d6440e..700d3dcb5c 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Отслеживает", "{completed} of {total} completed": "Завершено {completed} из {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "С ошибкой: {failed}, остановлено: {stopped}" + "{failed} failed, {stopped} stopped": "С ошибкой: {failed}, остановлено: {stopped}", + "A column to join has no name.": "У столбца для объединения нет имени.", + "A default value has no target field.": "У значения по умолчанию нет целевого поля.", + "A fixed value is written even when the column is empty.": "Фиксированное значение записывается, даже если столбец пуст.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Пакет миграции определяет, какой столбец файла импорта становится каким полем и как его значение преобразуется по пути.", + "A pack definition is a JSON object.": "Определение пакета — это объект JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Формат даты PHP, например d-m-Y. Начните его с !, чтобы части, не указанные в формате, не брались из сегодняшней даты. Оставьте пустым, чтобы распознавать любую распространённую дату.", + "Add a field mapping with a source column to test it.": "Добавьте сопоставление поля со столбцом источника, чтобы протестировать его.", + "Add at least one column to join.": "Добавьте хотя бы один столбец для объединения.", + "Add at least one field mapping.": "Добавьте хотя бы одно сопоставление поля.", + "Add at least one value to the table.": "Добавьте в таблицу хотя бы одно значение.", + "Add column": "Добавить столбец", + "Add default": "Добавить значение по умолчанию", + "Add field mapping": "Добавить сопоставление поля", + "Add value": "Добавить значение", + "Advanced (JSON)": "Дополнительно (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Приложение, которое импортирует данные с этим пакетом, может добавлять собственные правила, например какие столбцы содержат даты или с какого листа взята строка. Проверьте это приложение, прежде чем изменять установленный им пакет.", + "any format": "любой формат", + "As JSON": "Как JSON", + "Built-in": "Встроенный", + "Choose a source format.": "Выберите формат источника.", + "Column to join {number}": "Столбец для объединения {number}", + "Column {source} to field {target}": "Столбец {source} в поле {target}", + "Copied as is": "Копируется без изменений", + "Could not delete the migration pack.": "Не удалось удалить пакет миграции.", + "Could not import the file.": "Не удалось импортировать файл.", + "Could not load the migration pack.": "Не удалось загрузить пакет миграции.", + "Could not load the migration packs.": "Не удалось загрузить пакеты миграции.", + "Could not save the migration pack.": "Не удалось сохранить пакет миграции.", + "Date {from} to {to}": "Дата из {from} в {to}", + "Defaults": "Значения по умолчанию", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Удалить \"{name}\"? Импорты, использующие этот пакет, больше не смогут его найти.", + "Delete migration pack": "Удалить пакет миграции", + "Delete {name}": "Удалить {name}", + "Deleted \"{name}\".": "\"{name}\" удалён.", + "Edit {name}": "Редактировать {name}", + "Export {name}": "Экспортировать {name}", + "Field mappings": "Сопоставления полей", + "Fields the import would write": "Поля, которые запишет импорт", + "Fix the problems in the pack first; then it can be tested.": "Сначала исправьте проблемы в пакете; после этого его можно будет протестировать.", + "Fixed value": "Фиксированное значение", + "Fixed value {value}": "Фиксированное значение {value}", + "Form": "Форма", + "General": "Общие", + "Generate a new id": "Сгенерировать новый id", + "Id column": "Столбец id", + "Import JSON": "Импортировать JSON", + "Imported \"{name}\".": "\"{name}\" импортирован.", + "Imports find the pack by this slug.": "Импорты находят пакет по этому slug.", + "Join columns": "Объединить столбцы", + "Joined with {fields}, separated by \"{separator}\"": "Объединено с {fields}, разделитель \"{separator}\"", + "Leave the field empty": "Оставить поле пустым", + "Lookup table": "Таблица соответствий", + "Lookup table, values: {count}, {unmapped}": "Таблица соответствий, значения: {count}, {unmapped}", + "Migration pack": "Пакет миграции", + "Migration packs": "Пакеты миграции", + "Move row {number} down": "Переместить строку {number} вниз", + "Move row {number} up": "Переместить строку {number} вверх", + "Name the column that holds the id.": "Укажите столбец, содержащий id.", + "No defaults.": "Нет значений по умолчанию.", + "No fields: every mapping was empty or failed.": "Нет полей: каждое сопоставление было пустым или завершилось ошибкой.", + "No migration packs yet": "Пакетов миграции пока нет", + "None: copy the value as is": "Нет: копировать значение без изменений", + "Object id": "ID объекта", + "Only administrators can change migration packs.": "Только администраторы могут изменять пакеты миграции.", + "Only administrators can change migration packs. You can read this one.": "Только администраторы могут изменять пакеты миграции. Вы можете просматривать этот пакет.", + "other values are an error": "остальные значения вызывают ошибку", + "other values get a default": "остальные значения получают значение по умолчанию", + "other values stay empty": "остальные значения остаются пустыми", + "Problem": "Проблема", + "Problems in this row": "Проблемы в этой строке", + "Reload the pack": "Перезагрузить пакет", + "Remove column {number}": "Удалить столбец {number}", + "Remove row {number}": "Удалить строку {number}", + "Report an error for the row": "Сообщить об ошибке для строки", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Номера строк в источнике через запятую. Строка 1 — первая строка данных.", + "Row {number} has no source column.": "В строке {number} нет столбца источника.", + "Row {number} has no target field.": "В строке {number} нет целевого поля.", + "Row {number} is required": "Строка {number} обязательна", + "Row {number}: {message}": "Строка {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Строки выполняются сверху вниз. Если две строки записывают одно и то же поле и в обоих столбцах есть значение, побеждает нижняя строка.", + "Rows to skip": "Пропускаемые строки", + "Rows to skip must be whole numbers from 1 up: {values}": "Пропускаемые строки должны быть целыми числами начиная с 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Строки {rows} записывают это поле; побеждает самая нижняя строка со значением.", + "Sample value": "Пример значения", + "Sample value for {column}": "Пример значения для {column}", + "Saved as version {version}.": "Сохранено как версия {version}.", + "Saving raises the version to {version}.": "При сохранении версия повысится до {version}.", + "Separator": "Разделитель", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Кто-то другой сохранил этот пакет после того, как вы его открыли. Перезагрузите его, чтобы увидеть чужие изменения; ваши изменения не сохранены.", + "Source column": "Столбец источника", + "Source column of row {number}": "Столбец источника строки {number}", + "Source date format": "Формат даты в источнике", + "Source format": "Формат источника", + "Store this value": "Сохранить это значение", + "Stored date format": "Формат сохраняемой даты", + "Stored value": "Сохраняемое значение", + "Take the id from a column": "Взять id из столбца", + "Target field": "Целевое поле", + "Target field of row {number}": "Целевое поле строки {number}", + "Test with a sample row": "Протестировать на примере строки", + "The default for \"{key}\" is not valid JSON.": "Значение по умолчанию для \"{key}\" не является корректным JSON.", + "The default for \"{key}\" is set twice.": "Значение по умолчанию для \"{key}\" задано дважды.", + "The fixed value is not valid JSON.": "Фиксированное значение не является корректным JSON.", + "The form has a value that is not valid JSON. Fix it first.": "В форме есть значение, которое не является корректным JSON. Сначала исправьте его.", + "The name is required.": "Название обязательно.", + "The pack cannot be saved yet.": "Пакет пока нельзя сохранить.", + "The pack is not valid.": "Пакет недействителен.", + "The result for \"{key}\" is not valid JSON.": "Результат для \"{key}\" не является корректным JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug должен начинаться со строчной буквы или цифры и содержать только строчные буквы, цифры и дефисы.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug встроенного пакета нельзя изменить. Оставьте \"id\" равным {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug встроенного пакета нельзя изменить: приложение, которое его установило, находит пакет по этому slug.", + "The test could not run.": "Не удалось выполнить тест.", + "The value \"{key}\" is in the table twice.": "Значение \"{key}\" встречается в таблице дважды.", + "The value for other values is not valid JSON.": "Значение для остальных значений не является корректным JSON.", + "The value of column {source} comes first, then these columns in order.": "Сначала идёт значение столбца {source}, затем эти столбцы по порядку.", + "The version must look like 1.0.0 (major.minor.patch).": "Версия должна выглядеть как 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Полное определение пакета. Изменения здесь заменяют форму, когда вы возвращаетесь к ней или сохраняете.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Этот редактор не знает тип преобразования \"{type}\". Он сохраняется в исходном виде.", + "This is not valid JSON: {reason}": "Это некорректный JSON: {reason}", + "This migration pack does not exist.": "Этот пакет миграции не существует.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Этот пакет установлен приложением. Это приложение установит его заново, без правок, при своей следующей установке или обновлении.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "У этого преобразования есть также настройки, которые этот редактор не показывает ({keys}). При сохранении они не теряются.", + "Transform": "Преобразование", + "Transform \"{type}\" (kept as stored)": "Преобразование \"{type}\" (сохраняется в исходном виде)", + "Transform for row {number}": "Преобразование для строки {number}", + "Transform of row {number}": "Преобразование строки {number}", + "Trim spaces": "Обрезать пробелы", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Вводите значения так, как они записаны в источнике. Результат использует сопоставление выше, включая несохранённые изменения.", + "Undo changes": "Отменить изменения", + "Value for other values": "Значение для остальных значений", + "Value in the source": "Значение в источнике", + "Value {number} is JSON": "Значение {number} — JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Значения, с которых начинается каждый импортируемый объект. Сопоставление, задающее полю значение, заменяет его значение по умолчанию.", + "Values that are not in the table": "Значения, которых нет в таблице", + "View {name}": "Просмотреть {name}", + "Yes/no table": "Таблица да/нет", + "Yes/no table, values: {count}, {unmapped}": "Таблица да/нет, значения: {count}, {unmapped}", + "Store false": "Сохранить как нет (false)", + "other values are stored as false": "остальные значения сохраняются как нет (false)" }, "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/sk.js b/l10n/sk.js index c182dd95e1..29b51a438d 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Sleduje", "{completed} of {total} completed": "Dokončené {completed} z {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Zlyhalo: {failed}, zastavené: {stopped}" + "{failed} failed, {stopped} stopped": "Zlyhalo: {failed}, zastavené: {stopped}", + "A column to join has no name.": "Stĺpec na spojenie nemá názov.", + "A default value has no target field.": "Predvolená hodnota nemá cieľové pole.", + "A fixed value is written even when the column is empty.": "Pevná hodnota sa zapíše, aj keď je stĺpec prázdny.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Migračný balík určuje, ktorý stĺpec importovaného súboru sa stane ktorým poľom a ako sa jeho hodnota cestou prevedie.", + "A pack definition is a JSON object.": "Definícia balíka je objekt JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Formát dátumu PHP, napríklad d-m-Y. Začnite ho znakom !, aby sa časti, ktoré formát neuvádza, nepreberali z dnešného dátumu. Nechajte prázdne, ak chcete načítať akýkoľvek bežný dátum.", + "Add a field mapping with a source column to test it.": "Pridajte mapovanie poľa so zdrojovým stĺpcom, aby ste ho mohli otestovať.", + "Add at least one column to join.": "Pridajte aspoň jeden stĺpec na spojenie.", + "Add at least one field mapping.": "Pridajte aspoň jedno mapovanie poľa.", + "Add at least one value to the table.": "Pridajte do tabuľky aspoň jednu hodnotu.", + "Add column": "Pridať stĺpec", + "Add default": "Pridať predvolenú hodnotu", + "Add field mapping": "Pridať mapovanie poľa", + "Add value": "Pridať hodnotu", + "Advanced (JSON)": "Pokročilé (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Aplikácia, ktorá importuje s týmto balíkom, môže pridať vlastné pravidlá, napríklad ktoré stĺpce obsahujú dátumy alebo z ktorého hárka pochádza riadok. Skôr než zmeníte balík, ktorý táto aplikácia nainštalovala, skontrolujte ju.", + "any format": "akýkoľvek formát", + "As JSON": "Ako JSON", + "Built-in": "Vstavaný", + "Choose a source format.": "Vyberte zdrojový formát.", + "Column to join {number}": "Stĺpec na spojenie {number}", + "Column {source} to field {target}": "Stĺpec {source} do poľa {target}", + "Copied as is": "Skopírované bez zmeny", + "Could not delete the migration pack.": "Migračný balík sa nepodarilo odstrániť.", + "Could not import the file.": "Súbor sa nepodarilo importovať.", + "Could not load the migration pack.": "Migračný balík sa nepodarilo načítať.", + "Could not load the migration packs.": "Migračné balíky sa nepodarilo načítať.", + "Could not save the migration pack.": "Migračný balík sa nepodarilo uložiť.", + "Date {from} to {to}": "Dátum z {from} na {to}", + "Defaults": "Predvolené hodnoty", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Odstrániť \"{name}\"? Importy, ktoré tento balík používajú, ho už nenájdu.", + "Delete migration pack": "Odstrániť migračný balík", + "Delete {name}": "Odstrániť {name}", + "Deleted \"{name}\".": "\"{name}\" bol odstránený.", + "Edit {name}": "Upraviť {name}", + "Export {name}": "Exportovať {name}", + "Field mappings": "Mapovania polí", + "Fields the import would write": "Polia, ktoré by import zapísal", + "Fix the problems in the pack first; then it can be tested.": "Najprv opravte problémy v balíku; potom ho bude možné otestovať.", + "Fixed value": "Pevná hodnota", + "Fixed value {value}": "Pevná hodnota {value}", + "Form": "Formulár", + "General": "Všeobecné", + "Generate a new id": "Vygenerovať nové id", + "Id column": "Stĺpec s id", + "Import JSON": "Importovať JSON", + "Imported \"{name}\".": "\"{name}\" bol importovaný.", + "Imports find the pack by this slug.": "Importy nájdu balík podľa tohto slugu.", + "Join columns": "Spojiť stĺpce", + "Joined with {fields}, separated by \"{separator}\"": "Spojené s {fields}, oddelené \"{separator}\"", + "Leave the field empty": "Nechať pole prázdne", + "Lookup table": "Vyhľadávacia tabuľka", + "Lookup table, values: {count}, {unmapped}": "Vyhľadávacia tabuľka, hodnoty: {count}, {unmapped}", + "Migration pack": "Migračný balík", + "Migration packs": "Migračné balíky", + "Move row {number} down": "Posunúť riadok {number} nadol", + "Move row {number} up": "Posunúť riadok {number} nahor", + "Name the column that holds the id.": "Uveďte stĺpec, ktorý obsahuje id.", + "No defaults.": "Žiadne predvolené hodnoty.", + "No fields: every mapping was empty or failed.": "Žiadne polia: každé mapovanie bolo prázdne alebo zlyhalo.", + "No migration packs yet": "Zatiaľ žiadne migračné balíky", + "None: copy the value as is": "Žiadna: skopírovať hodnotu bez zmeny", + "Object id": "Id objektu", + "Only administrators can change migration packs.": "Migračné balíky môžu meniť iba správcovia.", + "Only administrators can change migration packs. You can read this one.": "Migračné balíky môžu meniť iba správcovia. Tento si môžete prezrieť.", + "other values are an error": "ostatné hodnoty spôsobia chybu", + "other values get a default": "ostatné hodnoty dostanú predvolenú hodnotu", + "other values stay empty": "ostatné hodnoty zostanú prázdne", + "Problem": "Problém", + "Problems in this row": "Problémy v tomto riadku", + "Reload the pack": "Znovu načítať balík", + "Remove column {number}": "Odstrániť stĺpec {number}", + "Remove row {number}": "Odstrániť riadok {number}", + "Report an error for the row": "Nahlásiť chybu pre riadok", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Čísla riadkov v zdroji oddelené čiarkami. Riadok 1 je prvý dátový riadok.", + "Row {number} has no source column.": "Riadok {number} nemá zdrojový stĺpec.", + "Row {number} has no target field.": "Riadok {number} nemá cieľové pole.", + "Row {number} is required": "Riadok {number} je povinný", + "Row {number}: {message}": "Riadok {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Riadky sa vykonávajú zhora nadol. Keď dva riadky zapisujú to isté pole a oba stĺpce majú hodnotu, vyhrá nižší riadok.", + "Rows to skip": "Riadky na preskočenie", + "Rows to skip must be whole numbers from 1 up: {values}": "Riadky na preskočenie musia byť celé čísla od 1 vyššie: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Riadky {rows} zapisujú toto pole; vyhrá najnižší riadok s hodnotou.", + "Sample value": "Vzorová hodnota", + "Sample value for {column}": "Vzorová hodnota pre {column}", + "Saved as version {version}.": "Uložené ako verzia {version}.", + "Saving raises the version to {version}.": "Uložením sa verzia zvýši na {version}.", + "Separator": "Oddeľovač", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Niekto iný uložil tento balík po tom, čo ste ho otvorili. Znovu ho načítajte, aby ste videli tieto zmeny; vaše zmeny nie sú uložené.", + "Source column": "Zdrojový stĺpec", + "Source column of row {number}": "Zdrojový stĺpec riadka {number}", + "Source date format": "Formát dátumu v zdroji", + "Source format": "Zdrojový formát", + "Store this value": "Uložiť túto hodnotu", + "Stored date format": "Ukladaný formát dátumu", + "Stored value": "Ukladaná hodnota", + "Take the id from a column": "Prevziať id zo stĺpca", + "Target field": "Cieľové pole", + "Target field of row {number}": "Cieľové pole riadka {number}", + "Test with a sample row": "Otestovať so vzorovým riadkom", + "The default for \"{key}\" is not valid JSON.": "Predvolená hodnota pre \"{key}\" nie je platný JSON.", + "The default for \"{key}\" is set twice.": "Predvolená hodnota pre \"{key}\" je nastavená dvakrát.", + "The fixed value is not valid JSON.": "Pevná hodnota nie je platný JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Formulár obsahuje hodnotu, ktorá nie je platný JSON. Najprv ju opravte.", + "The name is required.": "Názov je povinný.", + "The pack cannot be saved yet.": "Balík sa zatiaľ nedá uložiť.", + "The pack is not valid.": "Balík nie je platný.", + "The result for \"{key}\" is not valid JSON.": "Výsledok pre \"{key}\" nie je platný JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug musí začínať malým písmenom alebo číslicou a obsahovať iba malé písmená, číslice a spojovníky.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug vstavaného balíka sa nedá zmeniť. Ponechajte \"id\" ako {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug vstavaného balíka sa nedá zmeniť: aplikácia, ktorá ho nainštalovala, nájde balík podľa tohto slugu.", + "The test could not run.": "Test sa nepodarilo spustiť.", + "The value \"{key}\" is in the table twice.": "Hodnota \"{key}\" je v tabuľke dvakrát.", + "The value for other values is not valid JSON.": "Hodnota pre ostatné hodnoty nie je platný JSON.", + "The value of column {source} comes first, then these columns in order.": "Najprv je hodnota stĺpca {source}, potom tieto stĺpce v poradí.", + "The version must look like 1.0.0 (major.minor.patch).": "Verzia musí vyzerať ako 1.0.0 (hlavná.vedľajšia.oprava).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Celá definícia balíka. Zmeny tu nahradia formulár, keď sa k nemu vrátite alebo uložíte.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Tento editor nepozná typ prevodu \"{type}\". Ponechá sa tak, ako je uložený.", + "This is not valid JSON: {reason}": "Toto nie je platný JSON: {reason}", + "This migration pack does not exist.": "Tento migračný balík neexistuje.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Tento balík nainštalovala aplikácia. Pri jej ďalšej inštalácii alebo aktualizácii ho táto aplikácia nainštaluje znova, bez úprav.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Tento prevod má aj nastavenia, ktoré tento editor nezobrazuje ({keys}). Pri uložení sa zachovajú.", + "Transform": "Prevod", + "Transform \"{type}\" (kept as stored)": "Prevod \"{type}\" (ponechaný tak, ako je uložený)", + "Transform for row {number}": "Prevod pre riadok {number}", + "Transform of row {number}": "Prevod riadka {number}", + "Trim spaces": "Orezať medzery", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Zadajte hodnoty tak, ako sú v zdroji. Výsledok používa mapovanie vyššie vrátane zmien, ktoré ste ešte neuložili.", + "Undo changes": "Vrátiť zmeny späť", + "Value for other values": "Hodnota pre ostatné hodnoty", + "Value in the source": "Hodnota v zdroji", + "Value {number} is JSON": "Hodnota {number} je JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Hodnoty, s ktorými začína každý importovaný objekt. Mapovanie, ktoré poľu priradí hodnotu, nahradí jeho predvolenú hodnotu.", + "Values that are not in the table": "Hodnoty, ktoré nie sú v tabuľke", + "View {name}": "Zobraziť {name}", + "Yes/no table": "Tabuľka áno/nie", + "Yes/no table, values: {count}, {unmapped}": "Tabuľka áno/nie, hodnoty: {count}, {unmapped}", + "Store false": "Uložiť nie (false)", + "other values are stored as false": "ostatné hodnoty sa uložia ako nie (false)" }, "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/sl.js b/l10n/sl.js index 1ee4c56f95..108dfc2a49 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Spremlja", "{completed} of {total} completed": "Dokončano {completed} od {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Neuspešni: {failed}, ustavljeni: {stopped}" + "{failed} failed, {stopped} stopped": "Neuspešni: {failed}, ustavljeni: {stopped}", + "A column to join has no name.": "Stolpec za združitev nima imena.", + "A default value has no target field.": "Privzeta vrednost nima ciljnega polja.", + "A fixed value is written even when the column is empty.": "Fiksna vrednost se zapiše, tudi če je stolpec prazen.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Selitveni paket določa, kateri stolpec uvozne datoteke postane katero polje in kako se njegova vrednost med potjo pretvori.", + "A pack definition is a JSON object.": "Definicija paketa je objekt JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Oblika datuma PHP, na primer d-m-Y. Začnite jo z !, da se deli, ki jih oblika ne navaja, ne prevzamejo iz današnjega datuma. Pustite jo prazno, če želite prebrati kateri koli običajen datum.", + "Add a field mapping with a source column to test it.": "Za preizkus dodajte mapiranje polja z izvornim stolpcem.", + "Add at least one column to join.": "Dodajte vsaj en stolpec za združitev.", + "Add at least one field mapping.": "Dodajte vsaj eno mapiranje polja.", + "Add at least one value to the table.": "V tabelo dodajte vsaj eno vrednost.", + "Add column": "Dodaj stolpec", + "Add default": "Dodaj privzeto vrednost", + "Add field mapping": "Dodaj mapiranje polja", + "Add value": "Dodaj vrednost", + "Advanced (JSON)": "Napredno (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Aplikacija, ki uvaža s tem paketom, lahko doda lastna pravila, na primer kateri stolpci vsebujejo datume ali s katerega delovnega lista prihaja vrstica. Preden spremenite paket, ki ga je namestila, preverite to aplikacijo.", + "any format": "katera koli oblika", + "As JSON": "Kot JSON", + "Built-in": "Vgrajeno", + "Choose a source format.": "Izberite izvorno obliko.", + "Column to join {number}": "Stolpec za združitev {number}", + "Column {source} to field {target}": "Stolpec {source} v polje {target}", + "Copied as is": "Prevzeto nespremenjeno", + "Could not delete the migration pack.": "Selitvenega paketa ni bilo mogoče izbrisati.", + "Could not import the file.": "Datoteke ni bilo mogoče uvoziti.", + "Could not load the migration pack.": "Selitvenega paketa ni bilo mogoče naložiti.", + "Could not load the migration packs.": "Selitvenih paketov ni bilo mogoče naložiti.", + "Could not save the migration pack.": "Selitvenega paketa ni bilo mogoče shraniti.", + "Date {from} to {to}": "Datum iz {from} v {to}", + "Defaults": "Privzete vrednosti", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Želite izbrisati \"{name}\"? Uvozi, ki uporabljajo ta paket, ga ne bodo več našli.", + "Delete migration pack": "Izbriši selitveni paket", + "Delete {name}": "Izbriši {name}", + "Deleted \"{name}\".": "Paket \"{name}\" je izbrisan.", + "Edit {name}": "Uredi {name}", + "Export {name}": "Izvozi {name}", + "Field mappings": "Mapiranja polj", + "Fields the import would write": "Polja, ki bi jih uvoz zapisal", + "Fix the problems in the pack first; then it can be tested.": "Najprej odpravite težave v paketu; nato ga bo mogoče preizkusiti.", + "Fixed value": "Fiksna vrednost", + "Fixed value {value}": "Fiksna vrednost {value}", + "Form": "Obrazec", + "General": "Splošno", + "Generate a new id": "Ustvari nov ID", + "Id column": "Stolpec z ID", + "Import JSON": "Uvozi JSON", + "Imported \"{name}\".": "Paket \"{name}\" je uvožen.", + "Imports find the pack by this slug.": "Uvozi najdejo paket po tej oznaki.", + "Join columns": "Združi stolpce", + "Joined with {fields}, separated by \"{separator}\"": "Združeno s {fields}, ločeno z \"{separator}\"", + "Leave the field empty": "Pusti polje prazno", + "Lookup table": "Preslikovalna tabela", + "Lookup table, values: {count}, {unmapped}": "Preslikovalna tabela, vrednosti: {count}, {unmapped}", + "Migration pack": "Selitveni paket", + "Migration packs": "Selitveni paketi", + "Move row {number} down": "Premakni vrstico {number} navzdol", + "Move row {number} up": "Premakni vrstico {number} navzgor", + "Name the column that holds the id.": "Navedite stolpec, ki vsebuje ID.", + "No defaults.": "Ni privzetih vrednosti.", + "No fields: every mapping was empty or failed.": "Ni polj: vsako mapiranje je bilo prazno ali ni uspelo.", + "No migration packs yet": "Selitvenih paketov še ni", + "None: copy the value as is": "Brez: prevzemi vrednost nespremenjeno", + "Object id": "ID objekta", + "Only administrators can change migration packs.": "Selitvene pakete lahko spreminjajo samo skrbniki.", + "Only administrators can change migration packs. You can read this one.": "Selitvene pakete lahko spreminjajo samo skrbniki. Ta paket lahko berete.", + "other values are an error": "druge vrednosti so napaka", + "other values get a default": "druge vrednosti dobijo privzeto vrednost", + "other values stay empty": "druge vrednosti ostanejo prazne", + "Problem": "Težava", + "Problems in this row": "Težave v tej vrstici", + "Reload the pack": "Znova naloži paket", + "Remove column {number}": "Odstrani stolpec {number}", + "Remove row {number}": "Odstrani vrstico {number}", + "Report an error for the row": "Sporoči napako za vrstico", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Številke vrstic v izvoru, ločene z vejicami. Vrstica 1 je prva podatkovna vrstica.", + "Row {number} has no source column.": "Vrstica {number} nima izvornega stolpca.", + "Row {number} has no target field.": "Vrstica {number} nima ciljnega polja.", + "Row {number} is required": "Vrstica {number} je obvezna", + "Row {number}: {message}": "Vrstica {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Vrstice se izvajajo od zgoraj navzdol. Če dve vrstici zapisujeta isto polje in imata oba stolpca vrednost, prevlada spodnja vrstica.", + "Rows to skip": "Vrstice za preskok", + "Rows to skip must be whole numbers from 1 up: {values}": "Vrstice za preskok morajo biti cela števila od 1 naprej: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Vrstice {rows} zapisujejo to polje; prevlada najnižja vrstica z vrednostjo.", + "Sample value": "Vzorčna vrednost", + "Sample value for {column}": "Vzorčna vrednost za {column}", + "Saved as version {version}.": "Shranjeno kot različica {version}.", + "Saving raises the version to {version}.": "Shranjevanje dvigne različico na {version}.", + "Separator": "Ločilo", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Nekdo drug je shranil ta paket, potem ko ste ga odprli. Znova ga naložite, da vidite njegove spremembe; vaše spremembe niso shranjene.", + "Source column": "Izvorni stolpec", + "Source column of row {number}": "Izvorni stolpec vrstice {number}", + "Source date format": "Oblika datuma v izvoru", + "Source format": "Izvorna oblika", + "Store this value": "Shrani to vrednost", + "Stored date format": "Shranjena oblika datuma", + "Stored value": "Shranjena vrednost", + "Take the id from a column": "Prevzemi ID iz stolpca", + "Target field": "Ciljno polje", + "Target field of row {number}": "Ciljno polje vrstice {number}", + "Test with a sample row": "Preizkusi z vzorčno vrstico", + "The default for \"{key}\" is not valid JSON.": "Privzeta vrednost za \"{key}\" ni veljaven JSON.", + "The default for \"{key}\" is set twice.": "Privzeta vrednost za \"{key}\" je nastavljena dvakrat.", + "The fixed value is not valid JSON.": "Fiksna vrednost ni veljaven JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Obrazec vsebuje vrednost, ki ni veljaven JSON. Najprej jo popravite.", + "The name is required.": "Ime je obvezno.", + "The pack cannot be saved yet.": "Paketa še ni mogoče shraniti.", + "The pack is not valid.": "Paket ni veljaven.", + "The result for \"{key}\" is not valid JSON.": "Rezultat za \"{key}\" ni veljaven JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Oznaka se mora začeti z malo črko ali števko in sme vsebovati samo male črke, števke in vezaje.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Oznake vgrajenega paketa ni mogoče spremeniti. Pustite \"id\" na {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Oznake vgrajenega paketa ni mogoče spremeniti: aplikacija, ki ga je namestila, najde paket po tej oznaki.", + "The test could not run.": "Preizkusa ni bilo mogoče zagnati.", + "The value \"{key}\" is in the table twice.": "Vrednost \"{key}\" je v tabeli dvakrat.", + "The value for other values is not valid JSON.": "Vrednost za druge vrednosti ni veljaven JSON.", + "The value of column {source} comes first, then these columns in order.": "Najprej pride vrednost stolpca {source}, nato ti stolpci po vrsti.", + "The version must look like 1.0.0 (major.minor.patch).": "Različica mora biti oblike 1.0.0 (glavna.manjša.popravek).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Celotna definicija paketa. Spremembe tukaj nadomestijo obrazec, ko se vrnete nanj ali shranite.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Ta urejevalnik ne pozna vrste pretvorbe \"{type}\". Ohrani se, kot je shranjena.", + "This is not valid JSON: {reason}": "To ni veljaven JSON: {reason}", + "This migration pack does not exist.": "Ta selitveni paket ne obstaja.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Ta paket je namestila aplikacija. Ob svoji naslednji namestitvi ali posodobitvi ga bo ta aplikacija znova namestila, neurejenega.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Ta pretvorba ima tudi nastavitve, ki jih ta urejevalnik ne prikaže ({keys}). Ob shranjevanju se ohranijo.", + "Transform": "Pretvorba", + "Transform \"{type}\" (kept as stored)": "Pretvorba \"{type}\" (ohranjena, kot je shranjena)", + "Transform for row {number}": "Pretvorba za vrstico {number}", + "Transform of row {number}": "Pretvorba vrstice {number}", + "Trim spaces": "Obreži presledke", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Vnesite vrednosti, kot so zapisane v izvoru. Rezultat uporablja zgornje mapiranje, vključno s spremembami, ki jih še niste shranili.", + "Undo changes": "Razveljavi spremembe", + "Value for other values": "Vrednost za druge vrednosti", + "Value in the source": "Vrednost v izvoru", + "Value {number} is JSON": "Vrednost {number} je JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Vrednosti, s katerimi se začne vsak uvoženi objekt. Mapiranje, ki polju da vrednost, nadomesti njegovo privzeto vrednost.", + "Values that are not in the table": "Vrednosti, ki niso v tabeli", + "View {name}": "Prikaži {name}", + "Yes/no table": "Tabela da/ne", + "Yes/no table, values: {count}, {unmapped}": "Tabela da/ne, vrednosti: {count}, {unmapped}", + "Store false": "Shrani ne (false)", + "other values are stored as false": "druge vrednosti se shranijo kot ne (false)" }, "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);" ) diff --git a/l10n/sq.js b/l10n/sq.js index 06e2976b34..911654133d 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Vëzhgon", "{completed} of {total} completed": "{completed} nga {total} të përfunduara", "{date} by {person}": "{date} nga {person}", - "{failed} failed, {stopped} stopped": "{failed} të dështuara, {stopped} të ndalura" + "{failed} failed, {stopped} stopped": "{failed} të dështuara, {stopped} të ndalura", + "A column to join has no name.": "Një kolonë për bashkim nuk ka emër.", + "A default value has no target field.": "Një vlerë e parazgjedhur nuk ka fushë të synuar.", + "A fixed value is written even when the column is empty.": "Një vlerë fikse shkruhet edhe kur kolona është bosh.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Një paketë migrimi përcakton se cila kolonë e një skedari importi bëhet cila fushë dhe si konvertohet vlera e saj gjatë rrugës.", + "A pack definition is a JSON object.": "Përkufizimi i paketës është një objekt JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Një format date PHP si d-m-Y. Filloni me ! që pjesët që formati nuk i përmend të mos merren nga data e sotme. Lëreni bosh për të lexuar çdo datë të zakonshme.", + "Add a field mapping with a source column to test it.": "Shtoni një hartëzim fushe me një kolonë burimi për ta testuar.", + "Add at least one column to join.": "Shtoni të paktën një kolonë për bashkim.", + "Add at least one field mapping.": "Shtoni të paktën një hartëzim fushe.", + "Add at least one value to the table.": "Shtoni të paktën një vlerë në tabelë.", + "Add column": "Shto kolonë", + "Add default": "Shto vlerë të parazgjedhur", + "Add field mapping": "Shto hartëzim fushe", + "Add value": "Shto vlerë", + "Advanced (JSON)": "Të avancuara (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Një aplikacion që importon me këtë paketë mund të shtojë rregullat e veta, për shembull cilat kolona përmbajnë data ose nga cila fletë vjen një rresht. Kontrolloni atë aplikacion përpara se të ndryshoni një paketë që ai ka instaluar.", + "any format": "çdo format", + "As JSON": "Si JSON", + "Built-in": "E integruar", + "Choose a source format.": "Zgjidhni një format burimi.", + "Column to join {number}": "Kolona për bashkim {number}", + "Column {source} to field {target}": "Kolona {source} te fusha {target}", + "Copied as is": "Kopjuar siç është", + "Could not delete the migration pack.": "Paketa e migrimit nuk u fshi dot.", + "Could not import the file.": "Skedari nuk u importua dot.", + "Could not load the migration pack.": "Paketa e migrimit nuk u ngarkua dot.", + "Could not load the migration packs.": "Paketat e migrimit nuk u ngarkuan dot.", + "Could not save the migration pack.": "Paketa e migrimit nuk u ruajt dot.", + "Date {from} to {to}": "Data nga {from} në {to}", + "Defaults": "Vlerat e parazgjedhura", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Të fshihet \"{name}\"? Importet që përdorin këtë paketë nuk do ta gjejnë më.", + "Delete migration pack": "Fshi paketën e migrimit", + "Delete {name}": "Fshi {name}", + "Deleted \"{name}\".": "\"{name}\" u fshi.", + "Edit {name}": "Ndrysho {name}", + "Export {name}": "Eksporto {name}", + "Field mappings": "Hartëzimet e fushave", + "Fields the import would write": "Fushat që importi do të shkruante", + "Fix the problems in the pack first; then it can be tested.": "Rregulloni fillimisht problemet në paketë; pastaj ajo mund të testohet.", + "Fixed value": "Vlerë fikse", + "Fixed value {value}": "Vlerë fikse {value}", + "Form": "Formular", + "General": "Të përgjithshme", + "Generate a new id": "Gjenero një identifikues të ri", + "Id column": "Kolona e identifikuesit", + "Import JSON": "Importo JSON", + "Imported \"{name}\".": "\"{name}\" u importua.", + "Imports find the pack by this slug.": "Importet e gjejnë paketën sipas këtij emri të shkurtër.", + "Join columns": "Bashko kolonat", + "Joined with {fields}, separated by \"{separator}\"": "Bashkuar me {fields}, të ndara me \"{separator}\"", + "Leave the field empty": "Lëre fushën bosh", + "Lookup table": "Tabelë kërkimi", + "Lookup table, values: {count}, {unmapped}": "Tabelë kërkimi, vlera: {count}, {unmapped}", + "Migration pack": "Paketë migrimi", + "Migration packs": "Paketa migrimi", + "Move row {number} down": "Zhvendos rreshtin {number} poshtë", + "Move row {number} up": "Zhvendos rreshtin {number} lart", + "Name the column that holds the id.": "Emërtoni kolonën që përmban identifikuesin.", + "No defaults.": "Nuk ka vlera të parazgjedhura.", + "No fields: every mapping was empty or failed.": "Nuk ka fusha: çdo hartëzim ishte bosh ose dështoi.", + "No migration packs yet": "Ende nuk ka paketa migrimi", + "None: copy the value as is": "Asnjë: kopjo vlerën siç është", + "Object id": "ID e objektit", + "Only administrators can change migration packs.": "Vetëm administratorët mund të ndryshojnë paketat e migrimit.", + "Only administrators can change migration packs. You can read this one.": "Vetëm administratorët mund të ndryshojnë paketat e migrimit. Ju mund ta lexoni këtë.", + "other values are an error": "vlerat e tjera janë gabim", + "other values get a default": "vlerat e tjera marrin një vlerë të parazgjedhur", + "other values stay empty": "vlerat e tjera mbeten bosh", + "Problem": "Problemi", + "Problems in this row": "Probleme në këtë rresht", + "Reload the pack": "Ringarko paketën", + "Remove column {number}": "Hiq kolonën {number}", + "Remove row {number}": "Hiq rreshtin {number}", + "Report an error for the row": "Raporto një gabim për rreshtin", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Numrat e rreshtave në burim, të ndarë me presje. Rreshti 1 është rreshti i parë i të dhënave.", + "Row {number} has no source column.": "Rreshti {number} nuk ka kolonë burimi.", + "Row {number} has no target field.": "Rreshti {number} nuk ka fushë të synuar.", + "Row {number} is required": "Rreshti {number} është i detyrueshëm", + "Row {number}: {message}": "Rreshti {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Rreshtat ekzekutohen nga lart poshtë. Kur dy rreshta shkruajnë të njëjtën fushë dhe të dyja kolonat kanë vlerë, fiton rreshti më poshtë.", + "Rows to skip": "Rreshtat për t'u kapërcyer", + "Rows to skip must be whole numbers from 1 up: {values}": "Rreshtat për t'u kapërcyer duhet të jenë numra të plotë nga 1 e lart: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Rreshtat {rows} shkruajnë këtë fushë; fiton rreshti më i poshtëm me vlerë.", + "Sample value": "Vlerë shembull", + "Sample value for {column}": "Vlerë shembull për {column}", + "Saved as version {version}.": "U ruajt si versioni {version}.", + "Saving raises the version to {version}.": "Ruajtja e ngre versionin në {version}.", + "Separator": "Ndarës", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Dikush tjetër e ruajti këtë paketë pasi ju e hapët. Ringarkojeni për të parë ndryshimet e tyre; ndryshimet tuaja nuk janë ruajtur.", + "Source column": "Kolona e burimit", + "Source column of row {number}": "Kolona e burimit e rreshtit {number}", + "Source date format": "Formati i datës në burim", + "Source format": "Formati i burimit", + "Store this value": "Ruaj këtë vlerë", + "Stored date format": "Formati i datës së ruajtur", + "Stored value": "Vlera e ruajtur", + "Take the id from a column": "Merr identifikuesin nga një kolonë", + "Target field": "Fusha e synuar", + "Target field of row {number}": "Fusha e synuar e rreshtit {number}", + "Test with a sample row": "Testo me një rresht shembull", + "The default for \"{key}\" is not valid JSON.": "Vlera e parazgjedhur për \"{key}\" nuk është JSON i vlefshëm.", + "The default for \"{key}\" is set twice.": "Vlera e parazgjedhur për \"{key}\" është caktuar dy herë.", + "The fixed value is not valid JSON.": "Vlera fikse nuk është JSON i vlefshëm.", + "The form has a value that is not valid JSON. Fix it first.": "Formulari ka një vlerë që nuk është JSON i vlefshëm. Rregullojeni fillimisht.", + "The name is required.": "Emri është i detyrueshëm.", + "The pack cannot be saved yet.": "Paketa nuk mund të ruhet ende.", + "The pack is not valid.": "Paketa nuk është e vlefshme.", + "The result for \"{key}\" is not valid JSON.": "Rezultati për \"{key}\" nuk është JSON i vlefshëm.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Emri i shkurtër duhet të fillojë me një shkronjë të vogël ose shifër dhe të përmbajë vetëm shkronja të vogla, shifra dhe viza ndarëse.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Emri i shkurtër i një pakete të integruar nuk mund të ndryshojë. Mbajeni \"id\" si {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Emri i shkurtër i një pakete të integruar nuk mund të ndryshojë: aplikacioni që e instaloi e gjen paketën sipas këtij emri të shkurtër.", + "The test could not run.": "Testi nuk mund të ekzekutohej.", + "The value \"{key}\" is in the table twice.": "Vlera \"{key}\" është dy herë në tabelë.", + "The value for other values is not valid JSON.": "Vlera për vlerat e tjera nuk është JSON i vlefshëm.", + "The value of column {source} comes first, then these columns in order.": "Vlera e kolonës {source} vjen e para, pastaj këto kolona me radhë.", + "The version must look like 1.0.0 (major.minor.patch).": "Versioni duhet të duket si 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "I gjithë përkufizimi i paketës. Ndryshimet këtu zëvendësojnë formularin kur ktheheni te ai ose ruani.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Ky redaktues nuk e njeh llojin e transformimit \"{type}\". Ai mbahet siç është ruajtur.", + "This is not valid JSON: {reason}": "Ky nuk është JSON i vlefshëm: {reason}", + "This migration pack does not exist.": "Kjo paketë migrimi nuk ekziston.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Kjo paketë u instalua nga një aplikacion. Ai aplikacion e instalon sërish, të pandryshuar, herën tjetër kur instalohet ose përditësohet.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Ky transformim ka gjithashtu cilësime që ky redaktues nuk i shfaq ({keys}). Ato ruhen kur ruani.", + "Transform": "Transformim", + "Transform \"{type}\" (kept as stored)": "Transformim \"{type}\" (mbajtur siç është ruajtur)", + "Transform for row {number}": "Transformimi për rreshtin {number}", + "Transform of row {number}": "Transformimi i rreshtit {number}", + "Trim spaces": "Hiq hapësirat anësore", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Shkruani vlerat siç shfaqen në burim. Rezultati përdor hartëzimin më sipër, përfshirë ndryshimet që nuk i keni ruajtur.", + "Undo changes": "Zhbëj ndryshimet", + "Value for other values": "Vlera për vlerat e tjera", + "Value in the source": "Vlera në burim", + "Value {number} is JSON": "Vlera {number} është JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Vlerat me të cilat fillon çdo objekt i importuar. Një hartëzim që i jep një fushe vlerë zëvendëson vlerën e saj të parazgjedhur.", + "Values that are not in the table": "Vlerat që nuk janë në tabelë", + "View {name}": "Shiko {name}", + "Yes/no table": "Tabelë po/jo", + "Yes/no table, values: {count}, {unmapped}": "Tabelë po/jo, vlera: {count}, {unmapped}", + "Store false": "Ruaj jo (false)", + "other values are stored as false": "vlerat e tjera ruhen si jo (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.js b/l10n/sr.js index 63ce0cf32f..1413035066 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Прати", "{completed} of {total} completed": "Завршено {completed} од {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Неуспело: {failed}, заустављено: {stopped}" + "{failed} failed, {stopped} stopped": "Неуспело: {failed}, заустављено: {stopped}", + "A column to join has no name.": "Колона за спајање нема назив.", + "A default value has no target field.": "Подразумевана вредност нема циљно поље.", + "A fixed value is written even when the column is empty.": "Фиксна вредност се уписује чак и када је колона празна.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Миграциони пакет одређује која колона датотеке за увоз постаје које поље и како се њена вредност успут претвара.", + "A pack definition is a JSON object.": "Дефиниција пакета је JSON објекат.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "PHP формат датума, на пример d-m-Y. Почните га знаком ! да се делови које формат не наводи не би узимали од данашњег датума. Оставите га празним да би се читао сваки уобичајени датум.", + "Add a field mapping with a source column to test it.": "Додајте мапирање поља са изворном колоном да бисте га тестирали.", + "Add at least one column to join.": "Додајте бар једну колону за спајање.", + "Add at least one field mapping.": "Додајте бар једно мапирање поља.", + "Add at least one value to the table.": "Додајте бар једну вредност у табелу.", + "Add column": "Додај колону", + "Add default": "Додај подразумевану вредност", + "Add field mapping": "Додај мапирање поља", + "Add value": "Додај вредност", + "Advanced (JSON)": "Напредно (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Апликација која увози помоћу овог пакета може да дода сопствена правила, на пример које колоне садрже датуме или из ког листа потиче неки ред. Проверите ту апликацију пре него што измените пакет који је она инсталирала.", + "any format": "било који формат", + "As JSON": "Као JSON", + "Built-in": "Уграђени", + "Choose a source format.": "Изаберите изворни формат.", + "Column to join {number}": "Колона за спајање {number}", + "Column {source} to field {target}": "Колона {source} у поље {target}", + "Copied as is": "Преузето без измена", + "Could not delete the migration pack.": "Миграциони пакет није могуће обрисати.", + "Could not import the file.": "Датотеку није могуће увести.", + "Could not load the migration pack.": "Миграциони пакет није могуће учитати.", + "Could not load the migration packs.": "Миграционе пакете није могуће учитати.", + "Could not save the migration pack.": "Миграциони пакет није могуће сачувати.", + "Date {from} to {to}": "Датум из {from} у {to}", + "Defaults": "Подразумеване вредности", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Обрисати \"{name}\"? Увози који користе овај пакет више га неће пронаћи.", + "Delete migration pack": "Обриши миграциони пакет", + "Delete {name}": "Обриши {name}", + "Deleted \"{name}\".": "\"{name}\" је обрисан.", + "Edit {name}": "Уреди {name}", + "Export {name}": "Извези {name}", + "Field mappings": "Мапирања поља", + "Fields the import would write": "Поља која би увоз уписао", + "Fix the problems in the pack first; then it can be tested.": "Прво отклоните проблеме у пакету; након тога може да се тестира.", + "Fixed value": "Фиксна вредност", + "Fixed value {value}": "Фиксна вредност {value}", + "Form": "Образац", + "General": "Опште", + "Generate a new id": "Генериши нови ИД", + "Id column": "Колона са ИД-ом", + "Import JSON": "Увези JSON", + "Imported \"{name}\".": "\"{name}\" је увезен.", + "Imports find the pack by this slug.": "Увози проналазе пакет по овом slug-у.", + "Join columns": "Спој колоне", + "Joined with {fields}, separated by \"{separator}\"": "Спојено са {fields}, раздвојено знаком \"{separator}\"", + "Leave the field empty": "Остави поље празно", + "Lookup table": "Табела пресликавања", + "Lookup table, values: {count}, {unmapped}": "Табела пресликавања, вредности: {count}, {unmapped}", + "Migration pack": "Миграциони пакет", + "Migration packs": "Миграциони пакети", + "Move row {number} down": "Помери ред {number} надоле", + "Move row {number} up": "Помери ред {number} нагоре", + "Name the column that holds the id.": "Наведите колону која садржи ИД.", + "No defaults.": "Нема подразумеваних вредности.", + "No fields: every mapping was empty or failed.": "Нема поља: свако мапирање је било празно или није успело.", + "No migration packs yet": "Још нема миграционих пакета", + "None: copy the value as is": "Ништа: преузми вредност без измена", + "Object id": "ИД објекта", + "Only administrators can change migration packs.": "Само администратори могу да мењају миграционе пакете.", + "Only administrators can change migration packs. You can read this one.": "Само администратори могу да мењају миграционе пакете. Овај пакет можете да прегледате.", + "other values are an error": "остале вредности су грешка", + "other values get a default": "остале вредности добијају подразумевану вредност", + "other values stay empty": "остале вредности остају празне", + "Problem": "Проблем", + "Problems in this row": "Проблеми у овом реду", + "Reload the pack": "Поново учитај пакет", + "Remove column {number}": "Уклони колону {number}", + "Remove row {number}": "Уклони ред {number}", + "Report an error for the row": "Пријави грешку за ред", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Бројеви редова у извору, раздвојени зарезима. Ред 1 је први ред са подацима.", + "Row {number} has no source column.": "Ред {number} нема изворну колону.", + "Row {number} has no target field.": "Ред {number} нема циљно поље.", + "Row {number} is required": "Ред {number} је обавезан", + "Row {number}: {message}": "Ред {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Редови се извршавају одозго надоле. Када два реда уписују исто поље и обе колоне имају вредност, побеђује нижи ред.", + "Rows to skip": "Редови за прескакање", + "Rows to skip must be whole numbers from 1 up: {values}": "Редови за прескакање морају бити цели бројеви од 1 навише: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Редови {rows} уписују ово поље; побеђује најнижи ред са вредношћу.", + "Sample value": "Пример вредности", + "Sample value for {column}": "Пример вредности за {column}", + "Saved as version {version}.": "Сачувано као верзија {version}.", + "Saving raises the version to {version}.": "Чување подиже верзију на {version}.", + "Separator": "Сепаратор", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Неко други је сачувао овај пакет након што сте га отворили. Поново га учитајте да бисте видели те измене; ваше измене нису сачуване.", + "Source column": "Изворна колона", + "Source column of row {number}": "Изворна колона реда {number}", + "Source date format": "Формат датума у извору", + "Source format": "Изворни формат", + "Store this value": "Сачувај ову вредност", + "Stored date format": "Сачувани формат датума", + "Stored value": "Сачувана вредност", + "Take the id from a column": "Узми ИД из колоне", + "Target field": "Циљно поље", + "Target field of row {number}": "Циљно поље реда {number}", + "Test with a sample row": "Тестирај са примером реда", + "The default for \"{key}\" is not valid JSON.": "Подразумевана вредност за \"{key}\" није исправан JSON.", + "The default for \"{key}\" is set twice.": "Подразумевана вредност за \"{key}\" је постављена двапут.", + "The fixed value is not valid JSON.": "Фиксна вредност није исправан JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Образац садржи вредност која није исправан JSON. Прво је исправите.", + "The name is required.": "Назив је обавезан.", + "The pack cannot be saved yet.": "Пакет још не може да се сачува.", + "The pack is not valid.": "Пакет није исправан.", + "The result for \"{key}\" is not valid JSON.": "Резултат за \"{key}\" није исправан JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug мора да почиње малим словом или цифром и сме да садржи само мала слова, цифре и цртице.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug уграђеног пакета не може да се промени. Задржите \"id\" као {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug уграђеног пакета не може да се промени: апликација која га је инсталирала проналази пакет по овом slug-у.", + "The test could not run.": "Тест није могао да се покрене.", + "The value \"{key}\" is in the table twice.": "Вредност \"{key}\" се у табели налази двапут.", + "The value for other values is not valid JSON.": "Вредност за остале вредности није исправан JSON.", + "The value of column {source} comes first, then these columns in order.": "Вредност колоне {source} иде прва, а затим ове колоне по реду.", + "The version must look like 1.0.0 (major.minor.patch).": "Верзија мора да изгледа као 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Цела дефиниција пакета. Измене овде замењују образац када му се вратите или сачувате.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Овај уређивач не познаје тип трансформације \"{type}\". Задржава се онакав какав је сачуван.", + "This is not valid JSON: {reason}": "Ово није исправан JSON: {reason}", + "This migration pack does not exist.": "Овај миграциони пакет не постоји.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Овај пакет је инсталирала апликација. Та апликација га поново инсталира, неизмењеног, следећи пут када се она инсталира или ажурира.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Ова трансформација има и подешавања која овај уређивач не приказује ({keys}). Задржавају се када сачувате.", + "Transform": "Трансформација", + "Transform \"{type}\" (kept as stored)": "Трансформација \"{type}\" (задржана како је сачувана)", + "Transform for row {number}": "Трансформација за ред {number}", + "Transform of row {number}": "Трансформација реда {number}", + "Trim spaces": "Уклони размаке", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Унесите вредности онако како се појављују у извору. Резултат користи мапирање изнад, укључујући измене које још нисте сачували.", + "Undo changes": "Поништи измене", + "Value for other values": "Вредност за остале вредности", + "Value in the source": "Вредност у извору", + "Value {number} is JSON": "Вредност {number} је JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Вредности са којима почиње сваки увезени објекат. Мапирање које пољу даје вредност замењује његову подразумевану вредност.", + "Values that are not in the table": "Вредности које нису у табели", + "View {name}": "Прикажи {name}", + "Yes/no table": "Табела да/не", + "Yes/no table, values: {count}, {unmapped}": "Табела да/не, вредности: {count}, {unmapped}", + "Store false": "Сачувај „не“ (false)", + "other values are stored as false": "остале вредности се чувају као „не“ (false)" }, "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/sv.js b/l10n/sv.js index bb438df30d..fbc208816e 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Bevakar", "{completed} of {total} completed": "{completed} av {total} slutförda", "{date} by {person}": "{date} av {person}", - "{failed} failed, {stopped} stopped": "{failed} misslyckade, {stopped} stoppade" + "{failed} failed, {stopped} stopped": "{failed} misslyckade, {stopped} stoppade", + "A column to join has no name.": "En kolumn att sammanfoga har inget namn.", + "A default value has no target field.": "Ett standardvärde saknar målfält.", + "A fixed value is written even when the column is empty.": "Ett fast värde skrivs även när kolumnen är tom.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Ett migreringspaket anger vilken kolumn i en importfil som blir vilket fält, och hur dess värde konverteras på vägen.", + "A pack definition is a JSON object.": "En paketdefinition är ett JSON-objekt.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Ett PHP-datumformat som d-m-Y. Börja med ! så att delar som formatet inte anger inte hämtas från dagens datum. Lämna tomt för att läsa alla vanliga datum.", + "Add a field mapping with a source column to test it.": "Lägg till en fältmappning med en källkolumn för att testa den.", + "Add at least one column to join.": "Lägg till minst en kolumn att sammanfoga.", + "Add at least one field mapping.": "Lägg till minst en fältmappning.", + "Add at least one value to the table.": "Lägg till minst ett värde i tabellen.", + "Add column": "Lägg till kolumn", + "Add default": "Lägg till standardvärde", + "Add field mapping": "Lägg till fältmappning", + "Add value": "Lägg till värde", + "Advanced (JSON)": "Avancerat (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "En app som importerar med detta paket kan lägga till egna regler, till exempel vilka kolumner som innehåller datum eller vilket blad en rad kommer från. Kontrollera den appen innan du ändrar ett paket som den har installerat.", + "any format": "valfritt format", + "As JSON": "Som JSON", + "Built-in": "Inbyggt", + "Choose a source format.": "Välj ett källformat.", + "Column to join {number}": "Kolumn att sammanfoga {number}", + "Column {source} to field {target}": "Kolumn {source} till fält {target}", + "Copied as is": "Kopieras oförändrat", + "Could not delete the migration pack.": "Det gick inte att ta bort migreringspaketet.", + "Could not import the file.": "Det gick inte att importera filen.", + "Could not load the migration pack.": "Det gick inte att läsa in migreringspaketet.", + "Could not load the migration packs.": "Det gick inte att läsa in migreringspaketen.", + "Could not save the migration pack.": "Det gick inte att spara migreringspaketet.", + "Date {from} to {to}": "Datum {from} till {to}", + "Defaults": "Standardvärden", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Ta bort \"{name}\"? Importer som använder detta paket hittar det inte längre.", + "Delete migration pack": "Ta bort migreringspaket", + "Delete {name}": "Ta bort {name}", + "Deleted \"{name}\".": "\"{name}\" har tagits bort.", + "Edit {name}": "Redigera {name}", + "Export {name}": "Exportera {name}", + "Field mappings": "Fältmappningar", + "Fields the import would write": "Fält som importen skulle skriva", + "Fix the problems in the pack first; then it can be tested.": "Åtgärda problemen i paketet först; sedan kan det testas.", + "Fixed value": "Fast värde", + "Fixed value {value}": "Fast värde {value}", + "Form": "Formulär", + "General": "Allmänt", + "Generate a new id": "Generera ett nytt id", + "Id column": "Id-kolumn", + "Import JSON": "Importera JSON", + "Imported \"{name}\".": "\"{name}\" har importerats.", + "Imports find the pack by this slug.": "Importer hittar paketet via denna slug.", + "Join columns": "Sammanfoga kolumner", + "Joined with {fields}, separated by \"{separator}\"": "Sammanfogad med {fields}, avgränsad med \"{separator}\"", + "Leave the field empty": "Lämna fältet tomt", + "Lookup table": "Uppslagstabell", + "Lookup table, values: {count}, {unmapped}": "Uppslagstabell, värden: {count}, {unmapped}", + "Migration pack": "Migreringspaket", + "Migration packs": "Migreringspaketen", + "Move row {number} down": "Flytta rad {number} nedåt", + "Move row {number} up": "Flytta rad {number} uppåt", + "Name the column that holds the id.": "Ange kolumnen som innehåller id:t.", + "No defaults.": "Inga standardvärden.", + "No fields: every mapping was empty or failed.": "Inga fält: varje mappning var tom eller misslyckades.", + "No migration packs yet": "Inga migreringspaket ännu", + "None: copy the value as is": "Ingen: kopiera värdet oförändrat", + "Object id": "Objekt-id", + "Only administrators can change migration packs.": "Endast administratörer kan ändra migreringspaket.", + "Only administrators can change migration packs. You can read this one.": "Endast administratörer kan ändra migreringspaket. Du kan läsa det här paketet.", + "other values are an error": "andra värden ger ett fel", + "other values get a default": "andra värden får ett standardvärde", + "other values stay empty": "andra värden förblir tomma", + "Problem": "Fel", + "Problems in this row": "Problem på den här raden", + "Reload the pack": "Läs in paketet igen", + "Remove column {number}": "Ta bort kolumn {number}", + "Remove row {number}": "Ta bort rad {number}", + "Report an error for the row": "Rapportera ett fel för raden", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Radnummer i källan, separerade med kommatecken. Rad 1 är den första dataraden.", + "Row {number} has no source column.": "Rad {number} saknar källkolumn.", + "Row {number} has no target field.": "Rad {number} saknar målfält.", + "Row {number} is required": "Rad {number} är obligatorisk", + "Row {number}: {message}": "Rad {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Raderna körs uppifrån och ned. När två rader skriver samma fält och båda kolumnerna har ett värde vinner den nedre raden.", + "Rows to skip": "Rader att hoppa över", + "Rows to skip must be whole numbers from 1 up: {values}": "Rader att hoppa över måste vara heltal från 1 och uppåt: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Raderna {rows} skriver detta fält; den nedersta raden med ett värde vinner.", + "Sample value": "Exempelvärde", + "Sample value for {column}": "Exempelvärde för {column}", + "Saved as version {version}.": "Sparat som version {version}.", + "Saving raises the version to {version}.": "När du sparar höjs versionen till {version}.", + "Separator": "Avgränsare", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Någon annan sparade detta paket efter att du öppnade det. Läs in det igen för att se deras ändringar; dina ändringar har inte sparats.", + "Source column": "Källkolumn", + "Source column of row {number}": "Källkolumn för rad {number}", + "Source date format": "Datumformat i källan", + "Source format": "Källformat", + "Store this value": "Lagra detta värde", + "Stored date format": "Lagrat datumformat", + "Stored value": "Lagrat värde", + "Take the id from a column": "Hämta id:t från en kolumn", + "Target field": "Målfält", + "Target field of row {number}": "Målfält för rad {number}", + "Test with a sample row": "Testa med en exempelrad", + "The default for \"{key}\" is not valid JSON.": "Standardvärdet för \"{key}\" är inte giltig JSON.", + "The default for \"{key}\" is set twice.": "Standardvärdet för \"{key}\" har angetts två gånger.", + "The fixed value is not valid JSON.": "Det fasta värdet är inte giltig JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Formuläret har ett värde som inte är giltig JSON. Åtgärda det först.", + "The name is required.": "Namnet är obligatoriskt.", + "The pack cannot be saved yet.": "Paketet kan inte sparas ännu.", + "The pack is not valid.": "Paketet är inte giltigt.", + "The result for \"{key}\" is not valid JSON.": "Resultatet för \"{key}\" är inte giltig JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug måste börja med en gemen bokstav eller siffra och får bara innehålla gemena bokstäver, siffror och bindestreck.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug för ett inbyggt paket kan inte ändras. Behåll \"id\" som {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug för ett inbyggt paket kan inte ändras: appen som installerade det hittar paketet via denna slug.", + "The test could not run.": "Testet kunde inte köras.", + "The value \"{key}\" is in the table twice.": "Värdet \"{key}\" finns två gånger i tabellen.", + "The value for other values is not valid JSON.": "Värdet för andra värden är inte giltig JSON.", + "The value of column {source} comes first, then these columns in order.": "Värdet i kolumn {source} kommer först, sedan dessa kolumner i ordning.", + "The version must look like 1.0.0 (major.minor.patch).": "Versionen måste se ut som 1.0.0 (major.minor.patch).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Hela paketdefinitionen. Ändringar här ersätter formuläret när du går tillbaka till det eller sparar.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Den här redigeraren känner inte till transformeringstypen \"{type}\". Den behålls som den är lagrad.", + "This is not valid JSON: {reason}": "Detta är inte giltig JSON: {reason}", + "This migration pack does not exist.": "Detta migreringspaket finns inte.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Detta paket installerades av en app. Den appen installerar det igen, oredigerat, nästa gång appen installeras eller uppdateras.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Den här transformeringen har även inställningar som redigeraren inte visar ({keys}). De behålls när du sparar.", + "Transform": "Transformering", + "Transform \"{type}\" (kept as stored)": "Transformering \"{type}\" (behålls som lagrad)", + "Transform for row {number}": "Transformering för rad {number}", + "Transform of row {number}": "Transformering av rad {number}", + "Trim spaces": "Trimma blanksteg", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Skriv värden som de förekommer i källan. Resultatet använder mappningen ovan, inklusive ändringar som du inte har sparat.", + "Undo changes": "Ångra ändringar", + "Value for other values": "Värde för andra värden", + "Value in the source": "Värde i källan", + "Value {number} is JSON": "Värde {number} är JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Värden som varje importerat objekt börjar med. En mappning som ger ett fält ett värde ersätter dess standardvärde.", + "Values that are not in the table": "Värden som inte finns i tabellen", + "View {name}": "Visa {name}", + "Yes/no table": "Ja/nej-tabell", + "Yes/no table, values: {count}, {unmapped}": "Ja/nej-tabell, värden: {count}, {unmapped}", + "Store false": "Lagra nej (false)", + "other values are stored as false": "andra värden lagras som nej (false)" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.js b/l10n/tr.js index e64ed2634c..be0c0cfca8 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "İzliyor", "{completed} of {total} completed": "{total} içinden {completed} tamamlandı", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "Başarısız: {failed}, durdurulan: {stopped}" + "{failed} failed, {stopped} stopped": "Başarısız: {failed}, durdurulan: {stopped}", + "A column to join has no name.": "Birleştirilecek bir sütunun adı yok.", + "A default value has no target field.": "Bir varsayılan değerin hedef alanı yok.", + "A fixed value is written even when the column is empty.": "Sütun boş olsa bile sabit bir değer yazılır.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Taşıma paketi, içe aktarma dosyasının hangi sütununun hangi alana dönüşeceğini ve değerinin bu sırada nasıl dönüştürüleceğini belirler.", + "A pack definition is a JSON object.": "Paket tanımı bir JSON nesnesidir.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "d-m-Y gibi bir PHP tarih biçimi. Biçimin belirtmediği kısımların bugünün tarihinden alınmaması için ! ile başlatın. Herhangi bir yaygın tarihi okumak için boş bırakın.", + "Add a field mapping with a source column to test it.": "Test etmek için kaynak sütunu olan bir alan eşlemesi ekleyin.", + "Add at least one column to join.": "Birleştirmek için en az bir sütun ekleyin.", + "Add at least one field mapping.": "En az bir alan eşlemesi ekleyin.", + "Add at least one value to the table.": "Tabloya en az bir değer ekleyin.", + "Add column": "Sütun ekle", + "Add default": "Varsayılan değer ekle", + "Add field mapping": "Alan eşlemesi ekle", + "Add value": "Değer ekle", + "Advanced (JSON)": "Gelişmiş (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Bu paketle içe aktarım yapan bir uygulama kendi kurallarını ekleyebilir; örneğin hangi sütunların tarih içerdiğini veya bir satırın hangi sayfadan geldiğini. Bir uygulamanın kurduğu paketi değiştirmeden önce o uygulamayı kontrol edin.", + "any format": "herhangi bir biçim", + "As JSON": "JSON olarak", + "Built-in": "Yerleşik", + "Choose a source format.": "Bir kaynak biçimi seçin.", + "Column to join {number}": "Birleştirilecek sütun {number}", + "Column {source} to field {target}": "{source} sütunundan {target} alanına", + "Copied as is": "Olduğu gibi kopyalandı", + "Could not delete the migration pack.": "Taşıma paketi silinemedi.", + "Could not import the file.": "Dosya içe aktarılamadı.", + "Could not load the migration pack.": "Taşıma paketi yüklenemedi.", + "Could not load the migration packs.": "Taşıma paketleri yüklenemedi.", + "Could not save the migration pack.": "Taşıma paketi kaydedilemedi.", + "Date {from} to {to}": "Tarih {from} biçiminden {to} biçimine", + "Defaults": "Varsayılan değerler", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "\"{name}\" silinsin mi? Bu paketi kullanan içe aktarımlar onu artık bulamaz.", + "Delete migration pack": "Taşıma paketini sil", + "Delete {name}": "{name} öğesini sil", + "Deleted \"{name}\".": "\"{name}\" silindi.", + "Edit {name}": "{name} öğesini düzenle", + "Export {name}": "{name} öğesini dışa aktar", + "Field mappings": "Alan eşlemeleri", + "Fields the import would write": "İçe aktarımın yazacağı alanlar", + "Fix the problems in the pack first; then it can be tested.": "Önce paketteki sorunları düzeltin; ardından test edilebilir.", + "Fixed value": "Sabit değer", + "Fixed value {value}": "Sabit değer {value}", + "Form": "Form görünümü", + "General": "Genel", + "Generate a new id": "Yeni bir kimlik oluştur", + "Id column": "Kimlik sütunu", + "Import JSON": "JSON içe aktar", + "Imported \"{name}\".": "\"{name}\" içe aktarıldı.", + "Imports find the pack by this slug.": "İçe aktarımlar paketi bu slug ile bulur.", + "Join columns": "Sütunları birleştir", + "Joined with {fields}, separated by \"{separator}\"": "{fields} ile birleştirildi, \"{separator}\" ile ayrıldı", + "Leave the field empty": "Alanı boş bırak", + "Lookup table": "Arama tablosu", + "Lookup table, values: {count}, {unmapped}": "Arama tablosu, değerler: {count}, {unmapped}", + "Migration pack": "Taşıma paketi", + "Migration packs": "Taşıma paketleri", + "Move row {number} down": "{number}. satırı aşağı taşı", + "Move row {number} up": "{number}. satırı yukarı taşı", + "Name the column that holds the id.": "Kimliği içeren sütunu belirtin.", + "No defaults.": "Varsayılan değer yok.", + "No fields: every mapping was empty or failed.": "Alan yok: tüm eşlemeler boştu veya başarısız oldu.", + "No migration packs yet": "Henüz taşıma paketi yok", + "None: copy the value as is": "Hiçbiri: değeri olduğu gibi kopyala", + "Object id": "Nesne kimliği", + "Only administrators can change migration packs.": "Taşıma paketlerini yalnızca yöneticiler değiştirebilir.", + "Only administrators can change migration packs. You can read this one.": "Taşıma paketlerini yalnızca yöneticiler değiştirebilir. Bu paketi okuyabilirsiniz.", + "other values are an error": "diğer değerler hata verir", + "other values get a default": "diğer değerler varsayılan değer alır", + "other values stay empty": "diğer değerler boş kalır", + "Problem": "Sorun", + "Problems in this row": "Bu satırdaki sorunlar", + "Reload the pack": "Paketi yeniden yükle", + "Remove column {number}": "{number}. sütunu kaldır", + "Remove row {number}": "{number}. satırı kaldır", + "Report an error for the row": "Satır için hata bildir", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Kaynaktaki satır numaraları, virgülle ayrılmış. 1. satır ilk veri satırıdır.", + "Row {number} has no source column.": "{number}. satırın kaynak sütunu yok.", + "Row {number} has no target field.": "{number}. satırın hedef alanı yok.", + "Row {number} is required": "{number}. satır gereklidir", + "Row {number}: {message}": "Satır {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Satırlar yukarıdan aşağıya işlenir. İki satır aynı alana yazdığında ve her iki sütunda da değer olduğunda alttaki satır kazanır.", + "Rows to skip": "Atlanacak satırlar", + "Rows to skip must be whole numbers from 1 up: {values}": "Atlanacak satırlar 1'den başlayan tam sayılar olmalıdır: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "{rows} satırları bu alana yazar; değeri olan en alttaki satır kazanır.", + "Sample value": "Örnek değer", + "Sample value for {column}": "{column} için örnek değer", + "Saved as version {version}.": "{version} sürümü olarak kaydedildi.", + "Saving raises the version to {version}.": "Kaydetmek sürümü {version} değerine yükseltir.", + "Separator": "Ayırıcı", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Siz açtıktan sonra bu paketi başka biri kaydetti. Bu değişiklikleri görmek için paketi yeniden yükleyin; sizin değişiklikleriniz kaydedilmedi.", + "Source column": "Kaynak sütun", + "Source column of row {number}": "{number}. satırın kaynak sütunu", + "Source date format": "Kaynak tarih biçimi", + "Source format": "Kaynak biçimi", + "Store this value": "Bu değeri kaydet", + "Stored date format": "Kaydedilen tarih biçimi", + "Stored value": "Kaydedilen değer", + "Take the id from a column": "Kimliği bir sütundan al", + "Target field": "Hedef alan", + "Target field of row {number}": "{number}. satırın hedef alanı", + "Test with a sample row": "Örnek bir satırla test et", + "The default for \"{key}\" is not valid JSON.": "\"{key}\" için varsayılan değer geçerli bir JSON değil.", + "The default for \"{key}\" is set twice.": "\"{key}\" için varsayılan değer iki kez ayarlanmış.", + "The fixed value is not valid JSON.": "Sabit değer geçerli bir JSON değil.", + "The form has a value that is not valid JSON. Fix it first.": "Formda geçerli JSON olmayan bir değer var. Önce onu düzeltin.", + "The name is required.": "Ad gereklidir.", + "The pack cannot be saved yet.": "Paket henüz kaydedilemez.", + "The pack is not valid.": "Paket geçerli değil.", + "The result for \"{key}\" is not valid JSON.": "\"{key}\" için sonuç geçerli bir JSON değil.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug küçük bir harf veya rakamla başlamalı ve yalnızca küçük harf, rakam ve kısa çizgi içermelidir.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Yerleşik bir paketin slug değeri değiştirilemez. \"id\" değerini {slug} olarak bırakın.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Yerleşik bir paketin slug değeri değiştirilemez: onu kuran uygulama paketi bu slug ile bulur.", + "The test could not run.": "Test çalıştırılamadı.", + "The value \"{key}\" is in the table twice.": "\"{key}\" değeri tabloda iki kez var.", + "The value for other values is not valid JSON.": "Diğer değerler için değer geçerli bir JSON değil.", + "The value of column {source} comes first, then these columns in order.": "Önce {source} sütununun değeri, ardından sırayla bu sütunlar gelir.", + "The version must look like 1.0.0 (major.minor.patch).": "Sürüm 1.0.0 (ana.ikincil.yama) biçiminde olmalıdır.", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Paket tanımının tamamı. Burada yapılan değişiklikler, forma geri döndüğünüzde veya kaydettiğinizde formun yerini alır.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Bu düzenleyici \"{type}\" dönüşüm türünü tanımıyor. Kaydedildiği gibi korunur.", + "This is not valid JSON: {reason}": "Bu geçerli bir JSON değil: {reason}", + "This migration pack does not exist.": "Bu taşıma paketi yok.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Bu paket bir uygulama tarafından kuruldu. O uygulama bir sonraki kurulumunda veya güncellemesinde paketi düzenlenmemiş hâliyle yeniden kurar.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Bu dönüşümün, bu düzenleyicinin göstermediği ayarları da var ({keys}). Kaydettiğinizde korunurlar.", + "Transform": "Dönüşüm", + "Transform \"{type}\" (kept as stored)": "\"{type}\" dönüşümü (kaydedildiği gibi korunur)", + "Transform for row {number}": "{number}. satır için dönüşüm", + "Transform of row {number}": "{number}. satırın dönüşümü", + "Trim spaces": "Boşlukları kırp", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Değerleri kaynakta göründükleri gibi yazın. Sonuç, henüz kaydetmediğiniz değişiklikler dahil yukarıdaki eşlemeyi kullanır.", + "Undo changes": "Değişiklikleri geri al", + "Value for other values": "Diğer değerler için değer", + "Value in the source": "Kaynaktaki değer", + "Value {number} is JSON": "{number}. değer JSON'dur", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Her içe aktarılan nesnenin başladığı değerler. Bir alana değer veren eşleme, o alanın varsayılan değerinin yerini alır.", + "Values that are not in the table": "Tabloda olmayan değerler", + "View {name}": "{name} öğesini görüntüle", + "Yes/no table": "Evet/hayır tablosu", + "Yes/no table, values: {count}, {unmapped}": "Evet/hayır tablosu, değerler: {count}, {unmapped}", + "Store false": "Hayır (false) olarak kaydet", + "other values are stored as false": "diğer değerler hayır (false) olarak kaydedilir" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.js b/l10n/uk.js index 6bfcbf2e21..172ac8979c 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -3359,7 +3359,146 @@ OC.L10N.register( "Watching": "Відстежує", "{completed} of {total} completed": "Завершено {completed} з {total}", "{date} by {person}": "{date}, {person}", - "{failed} failed, {stopped} stopped": "З помилкою: {failed}, зупинено: {stopped}" + "{failed} failed, {stopped} stopped": "З помилкою: {failed}, зупинено: {stopped}", + "A column to join has no name.": "Стовпець для об'єднання не має назви.", + "A default value has no target field.": "Значення за замовчуванням не має цільового поля.", + "A fixed value is written even when the column is empty.": "Фіксоване значення записується, навіть якщо стовпець порожній.", + "A migration pack says which column of an import file becomes which field, and how its value is converted on the way.": "Пакет міграції визначає, який стовпець файлу імпорту стає яким полем і як його значення перетворюється дорогою.", + "A pack definition is a JSON object.": "Визначення пакета — це об'єкт JSON.", + "A PHP date format such as d-m-Y. Start it with ! so parts the format does not name are not taken from today. Leave it empty to read any common date.": "Формат дати PHP, наприклад d-m-Y. Почніть його з !, щоб частини, які формат не називає, не бралися із сьогоднішньої дати. Залиште порожнім, щоб читати будь-яку поширену дату.", + "Add a field mapping with a source column to test it.": "Щоб перевірити, додайте зіставлення поля зі стовпцем джерела.", + "Add at least one column to join.": "Додайте принаймні один стовпець для об'єднання.", + "Add at least one field mapping.": "Додайте принаймні одне зіставлення поля.", + "Add at least one value to the table.": "Додайте до таблиці принаймні одне значення.", + "Add column": "Додати стовпець", + "Add default": "Додати значення за замовчуванням", + "Add field mapping": "Додати зіставлення поля", + "Add value": "Додати значення", + "Advanced (JSON)": "Додатково (JSON)", + "An app that imports with this pack can add rules of its own, for example which columns hold dates or which sheet a row comes from. Check that app before you change a pack it installed.": "Застосунок, який імпортує за допомогою цього пакета, може додавати власні правила, наприклад, які стовпці містять дати або з якого аркуша походить рядок. Перш ніж змінювати встановлений ним пакет, перевірте цей застосунок.", + "any format": "будь-який формат", + "As JSON": "Як JSON", + "Built-in": "Вбудований", + "Choose a source format.": "Виберіть формат джерела.", + "Column to join {number}": "Стовпець для об'єднання {number}", + "Column {source} to field {target}": "Стовпець {source} у поле {target}", + "Copied as is": "Скопійовано без змін", + "Could not delete the migration pack.": "Не вдалося видалити пакет міграції.", + "Could not import the file.": "Не вдалося імпортувати файл.", + "Could not load the migration pack.": "Не вдалося завантажити пакет міграції.", + "Could not load the migration packs.": "Не вдалося завантажити пакети міграції.", + "Could not save the migration pack.": "Не вдалося зберегти пакет міграції.", + "Date {from} to {to}": "Дата з {from} у {to}", + "Defaults": "Значення за замовчуванням", + "Delete \"{name}\"? Imports that use this pack will no longer find it.": "Видалити \"{name}\"? Імпорти, які використовують цей пакет, більше не знайдуть його.", + "Delete migration pack": "Видалити пакет міграції", + "Delete {name}": "Видалити {name}", + "Deleted \"{name}\".": "\"{name}\" видалено.", + "Edit {name}": "Редагувати {name}", + "Export {name}": "Експортувати {name}", + "Field mappings": "Зіставлення полів", + "Fields the import would write": "Поля, які записав би імпорт", + "Fix the problems in the pack first; then it can be tested.": "Спершу виправте проблеми в пакеті; тоді його можна буде перевірити.", + "Fixed value": "Фіксоване значення", + "Fixed value {value}": "Фіксоване значення {value}", + "Form": "Форма", + "General": "Загальне", + "Generate a new id": "Згенерувати новий ID", + "Id column": "Стовпець ID", + "Import JSON": "Імпортувати JSON", + "Imported \"{name}\".": "\"{name}\" імпортовано.", + "Imports find the pack by this slug.": "Імпорти знаходять пакет за цим slug.", + "Join columns": "Об'єднати стовпці", + "Joined with {fields}, separated by \"{separator}\"": "Об'єднано з {fields}, розділено \"{separator}\"", + "Leave the field empty": "Залишити поле порожнім", + "Lookup table": "Таблиця відповідностей", + "Lookup table, values: {count}, {unmapped}": "Таблиця відповідностей, значення: {count}, {unmapped}", + "Migration pack": "Пакет міграції", + "Migration packs": "Пакети міграції", + "Move row {number} down": "Перемістити рядок {number} вниз", + "Move row {number} up": "Перемістити рядок {number} вгору", + "Name the column that holds the id.": "Вкажіть стовпець, що містить ID.", + "No defaults.": "Немає значень за замовчуванням.", + "No fields: every mapping was empty or failed.": "Немає полів: кожне зіставлення було порожнім або не вдалося.", + "No migration packs yet": "Пакетів міграції ще немає", + "None: copy the value as is": "Немає: скопіювати значення без змін", + "Object id": "ID об'єкта", + "Only administrators can change migration packs.": "Лише адміністратори можуть змінювати пакети міграції.", + "Only administrators can change migration packs. You can read this one.": "Лише адміністратори можуть змінювати пакети міграції. Цей пакет ви можете переглядати.", + "other values are an error": "інші значення є помилкою", + "other values get a default": "інші значення отримують значення за замовчуванням", + "other values stay empty": "інші значення залишаються порожніми", + "Problem": "Проблема", + "Problems in this row": "Проблеми в цьому рядку", + "Reload the pack": "Перезавантажити пакет", + "Remove column {number}": "Вилучити стовпець {number}", + "Remove row {number}": "Вилучити рядок {number}", + "Report an error for the row": "Повідомити про помилку для рядка", + "Row numbers in the source, separated by commas. Row 1 is the first data row.": "Номери рядків у джерелі через кому. Рядок 1 — перший рядок даних.", + "Row {number} has no source column.": "Рядок {number} не має стовпця джерела.", + "Row {number} has no target field.": "Рядок {number} не має цільового поля.", + "Row {number} is required": "Рядок {number} обов'язковий", + "Row {number}: {message}": "Рядок {number}: {message}", + "Rows run from top to bottom. When two rows write the same field and both columns have a value, the lower row wins.": "Рядки виконуються згори донизу. Якщо два рядки записують те саме поле й обидва стовпці мають значення, перемагає нижній рядок.", + "Rows to skip": "Рядки для пропуску", + "Rows to skip must be whole numbers from 1 up: {values}": "Рядки для пропуску мають бути цілими числами від 1: {values}", + "Rows {rows} write this field; the lowest row with a value wins.": "Рядки {rows} записують це поле; перемагає найнижчий рядок зі значенням.", + "Sample value": "Зразкове значення", + "Sample value for {column}": "Зразкове значення для {column}", + "Saved as version {version}.": "Збережено як версію {version}.", + "Saving raises the version to {version}.": "Збереження підвищує версію до {version}.", + "Separator": "Роздільник", + "Someone else saved this pack after you opened it. Reload it to see their changes; your changes are not saved.": "Хтось інший зберіг цей пакет після того, як ви його відкрили. Перезавантажте його, щоб побачити ці зміни; ваші зміни не збережено.", + "Source column": "Стовпець джерела", + "Source column of row {number}": "Стовпець джерела рядка {number}", + "Source date format": "Формат дати в джерелі", + "Source format": "Формат джерела", + "Store this value": "Зберегти це значення", + "Stored date format": "Формат збереженої дати", + "Stored value": "Збережене значення", + "Take the id from a column": "Брати ID зі стовпця", + "Target field": "Цільове поле", + "Target field of row {number}": "Цільове поле рядка {number}", + "Test with a sample row": "Перевірити на зразковому рядку", + "The default for \"{key}\" is not valid JSON.": "Значення за замовчуванням для \"{key}\" не є дійсним JSON.", + "The default for \"{key}\" is set twice.": "Значення за замовчуванням для \"{key}\" задано двічі.", + "The fixed value is not valid JSON.": "Фіксоване значення не є дійсним JSON.", + "The form has a value that is not valid JSON. Fix it first.": "Форма містить значення, яке не є дійсним JSON. Спершу виправте його.", + "The name is required.": "Назва обов'язкова.", + "The pack cannot be saved yet.": "Пакет поки що не можна зберегти.", + "The pack is not valid.": "Пакет недійсний.", + "The result for \"{key}\" is not valid JSON.": "Результат для \"{key}\" не є дійсним JSON.", + "The slug must start with a lowercase letter or digit and contain only lowercase letters, digits and hyphens.": "Slug має починатися з малої літери або цифри й містити лише малі літери, цифри та дефіси.", + "The slug of a built-in pack cannot change. Keep \"id\" as {slug}.": "Slug вбудованого пакета змінити не можна. Залиште \"id\" як {slug}.", + "The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.": "Slug вбудованого пакета змінити не можна: застосунок, який його встановив, знаходить пакет за цим slug.", + "The test could not run.": "Не вдалося запустити перевірку.", + "The value \"{key}\" is in the table twice.": "Значення \"{key}\" є в таблиці двічі.", + "The value for other values is not valid JSON.": "Значення для інших значень не є дійсним JSON.", + "The value of column {source} comes first, then these columns in order.": "Спочатку йде значення стовпця {source}, потім ці стовпці по порядку.", + "The version must look like 1.0.0 (major.minor.patch).": "Версія має виглядати як 1.0.0 (основна.додаткова.виправлення).", + "The whole pack definition. Changes here replace the form when you go back to it or save.": "Повне визначення пакета. Зміни тут замінюють форму, коли ви повертаєтеся до неї або зберігаєте.", + "This editor does not know the transform type \"{type}\". It is kept as stored.": "Цей редактор не знає типу перетворення \"{type}\". Воно залишається таким, як збережено.", + "This is not valid JSON: {reason}": "Це не дійсний JSON: {reason}", + "This migration pack does not exist.": "Цей пакет міграції не існує.", + "This pack was installed by an app. That app installs it again, unedited, the next time it is installed or updated.": "Цей пакет встановив застосунок. Під час наступного встановлення або оновлення цей застосунок знову встановить його без змін.", + "This transform also has settings this editor does not show ({keys}). They are kept when you save.": "Це перетворення має також налаштування, яких цей редактор не показує ({keys}). Під час збереження вони залишаються.", + "Transform": "Перетворення", + "Transform \"{type}\" (kept as stored)": "Перетворення \"{type}\" (залишено як збережено)", + "Transform for row {number}": "Перетворення для рядка {number}", + "Transform of row {number}": "Перетворення рядка {number}", + "Trim spaces": "Обрізати пробіли", + "Type values as they appear in the source. The result uses the mapping above, including changes you have not saved.": "Введіть значення так, як вони виглядають у джерелі. Результат використовує зіставлення вище, включно зі змінами, які ви ще не зберегли.", + "Undo changes": "Скасувати зміни", + "Value for other values": "Значення для інших значень", + "Value in the source": "Значення в джерелі", + "Value {number} is JSON": "Значення {number} є JSON", + "Values every imported object starts with. A mapping that gives a field a value replaces its default.": "Значення, з якими починається кожен імпортований об'єкт. Зіставлення, що надає полю значення, замінює його значення за замовчуванням.", + "Values that are not in the table": "Значення, яких немає в таблиці", + "View {name}": "Переглянути {name}", + "Yes/no table": "Таблиця так/ні", + "Yes/no table, values: {count}, {unmapped}": "Таблиця так/ні, значення: {count}, {unmapped}", + "Store false": "Зберегти ні (false)", + "other values are stored as false": "інші значення зберігаються як ні (false)" }, "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);" ) From d5721890fb21043c3dc675140d0eedb76fa1c85b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 20:32:32 +0200 Subject: [PATCH 118/183] test(objects): the draft activation collection runs each step as the account its name claims The first CI run of this collection was red: the cookie jar carried the administrator's session into the owner's requests, and Nextcloud answers a request that has a session as that session's user, whatever its Authorization header says. The draft was the administrator's, so "somebody else" was its owner and got 200. Every request now names its own account and sends no cookies. Four who-am-I steps assert that the server sees the account each step names and that the three accounts differ. A colleague who is no administrator joins: 404 on activate, refused on PATCH, PUT and a POST that names the draft's id. A plain save with status active is asserted refused for the owner, the administrator and a colleague, on PUT and PATCH. --- ...r-draft-activation.postman_collection.json | 1596 +++++++++++++++-- 1 file changed, 1484 insertions(+), 112 deletions(-) diff --git a/tests/integration/openregister-draft-activation.postman_collection.json b/tests/integration/openregister-draft-activation.postman_collection.json index 45e6ca0cdf..2a3e90ca45 100644 --- a/tests/integration/openregister-draft-activation.postman_collection.json +++ b/tests/integration/openregister-draft-activation.postman_collection.json @@ -2,24 +2,9 @@ "info": { "_postman_id": "or-draft-activation-2026-10-12", "name": "OpenRegister Draft Activation API", - "description": "POST /api/objects/{register}/{schema}/{id}/activate, asserted over HTTP (change draft-owner-activation-and-lifetime, decision 216). A caller's side view, so the DELIVERED status of a refusal is what is checked.\n\nWHAT IT ASSERTS, each paired with its control:\n * somebody else, an administrator included, is refused with a delivered 403, and the draft is still a draft afterwards\n * a plain save that sets @self.status to active stays refused\n * an incomplete draft is refused with 422 and one finding per property\n * the owner's activation answers 200 with the same id and status active\n * a second activation answers 409, an unknown id 404\n * a schema keeps its draftLifetimeDays on save and on read\n * the owner sees their own draft in a list by default, and _drafts=none leaves it out while the rest stays\n\nThe cleanup job is not asserted here: it runs from cron, and its path from appinfo/info.xml to the delete is covered by DraftExpiryJobTest.", + "description": "POST /api/objects/{register}/{schema}/{id}/activate, asserted over HTTP (change draft-owner-activation-and-lifetime, decision 216). A caller's side view, so the DELIVERED status of a refusal is what is checked.\n\nTHREE ACCOUNTS: the administrator, the draft's owner and a colleague who is no administrator. Every request carries its own basic auth and sends no cookies (protocolProfileBehavior.disableCookies). With a cookie jar the administrator's session rides along on the owner's requests and Nextcloud answers as the administrator, whatever the Authorization header says: that made this collection's first CI run red, with a draft owned by the administrator. The 'who am I' steps assert that the server sees the account each step names and that the three differ.\n\nWHAT IT ASSERTS, each paired with its control:\n * the administrator, who may read the draft, is refused with a delivered 403; a colleague, who may not, with 404; the draft is still a draft afterwards\n * a colleague cannot change the draft through PATCH, PUT or a POST that names its id, and the answer never holds its content\n * a plain save that sets @self.status to active stays refused, for the owner, the administrator and a colleague, on PUT and on PATCH\n * an incomplete draft is refused with 422 and a `required` finding per property\n * the owner's activation answers 200 with the same id and status active\n * a second activation answers 409, an unknown id 404\n * a schema keeps its draftLifetimeDays on save and on read\n * the owner sees their own draft in a list by default, and _drafts=none leaves it out while the rest stays\n\nThe cleanup job is not asserted here: it runs from cron, and its path from appinfo/info.xml to the delete is covered by DraftExpiryJobTest.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, - "auth": { - "type": "basic", - "basic": [ - { - "key": "username", - "value": "{{admin_user}}", - "type": "string" - }, - { - "key": "password", - "value": "{{admin_password}}", - "type": "string" - } - ] - }, "variable": [ { "key": "base_url", @@ -65,6 +50,16 @@ "key": "secondDraftUuid", "value": "", "type": "string" + }, + { + "key": "colleagueId", + "value": "", + "type": "string" + }, + { + "key": "colleaguePassword", + "value": "", + "type": "string" } ], "item": [ @@ -107,6 +102,21 @@ "body": { "mode": "raw", "raw": "{\"userid\": \"{{ownerId}}\", \"password\": \"{{ownerPassword}}\"}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] } }, "event": [ @@ -115,10 +125,13 @@ "script": { "type": "text/javascript", "exec": [ - "// A draft has one owner, so the refusal needs a second account. It is removed in tearDown.", + "// A draft has one owner, so the refusals need more accounts: the owner, and a colleague who is no administrator. Both are removed in tearDown.", "const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);", + "const password = () => 'Dr4ft!' + suffix + '-' + Math.random().toString(36).slice(2) + 'Zq9';", "pm.collectionVariables.set('ownerId', 'or-draft-owner-' + suffix);", - "pm.collectionVariables.set('ownerPassword', 'Dr4ft!' + suffix + '-' + Math.random().toString(36).slice(2) + 'Zq9');" + "pm.collectionVariables.set('ownerPassword', password());", + "pm.collectionVariables.set('colleagueId', 'or-draft-colleague-' + suffix);", + "pm.collectionVariables.set('colleaguePassword', password());" ] } }, @@ -131,10 +144,13 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { - "name": "setUp: a register", + "name": "setUp: a third account, a colleague who is no administrator", "request": { "method": "POST", "header": [ @@ -152,21 +168,41 @@ } ], "url": { - "raw": "{{base_url}}/index.php/apps/openregister/api/registers", + "raw": "{{base_url}}/ocs/v2.php/cloud/users?format=json", "host": [ "{{base_url}}" ], "path": [ - "index.php", - "apps", - "openregister", - "api", - "registers" + "ocs", + "v2.php", + "cloud", + "users" + ], + "query": [ + { + "key": "format", + "value": "json" + } ] }, "body": { "mode": "raw", - "raw": "{\"title\": \"draft activation newman register\", \"description\": \"draft activation collection fixture\"}" + "raw": "{\"userid\": \"{{colleagueId}}\", \"password\": \"{{colleaguePassword}}\"}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] } }, "event": [ @@ -175,17 +211,19 @@ "script": { "type": "text/javascript", "exec": [ - "pm.test('the register is created', () => pm.expect(pm.response.code, pm.response.text()).to.be.below(300));", - "pm.collectionVariables.set('registerId', String(pm.response.json().id));" + "pm.test('the colleague account is created', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));" ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { - "name": "setUp: a schema with two required properties and a draft lifetime", + "name": "GET who am I as the administrator -> each step runs as the account its name claims", "request": { - "method": "POST", + "method": "GET", "header": [ { "key": "OCS-APIRequest", @@ -201,21 +239,37 @@ } ], "url": { - "raw": "{{base_url}}/index.php/apps/openregister/api/schemas", + "raw": "{{base_url}}/ocs/v2.php/cloud/user?format=json", "host": [ "{{base_url}}" ], "path": [ - "index.php", - "apps", - "openregister", - "api", - "schemas" + "ocs", + "v2.php", + "cloud", + "user" + ], + "query": [ + { + "key": "format", + "value": "json" + } ] }, - "body": { - "mode": "raw", - "raw": "{\"title\": \"draft activation newman schema\", \"description\": \"draft activation collection fixture\", \"required\": [\"subject\", \"channel\"], \"properties\": {\"subject\": {\"type\": \"string\", \"title\": \"Subject\", \"maxLength\": 200}, \"channel\": {\"type\": \"string\", \"title\": \"Channel\", \"enum\": [\"phone\", \"email\", \"counter\"]}, \"note\": {\"type\": \"string\", \"title\": \"Note\"}}, \"configuration\": {\"draftLifetimeDays\": 7}, \"authorization\": {\"read\": [\"authenticated\"], \"create\": [\"authenticated\"], \"update\": [\"authenticated\"], \"delete\": [\"authenticated\"]}}" + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] } }, "event": [ @@ -224,15 +278,1087 @@ "script": { "type": "text/javascript", "exec": [ - "pm.test('the schema is created', () => pm.expect(pm.response.code, pm.response.text()).to.be.below(300));", - "pm.collectionVariables.set('schemaId', String(pm.response.json().id));" + "// The first run of this collection in CI was red for this reason alone: the cookie jar carried the", + "// administrator's session into the owner's requests, and Nextcloud answers a request that has a", + "// session as that session's user, whatever its Authorization header says. So the draft was the", + "// administrator's, and \"somebody else\" was its owner. Every request here sends no cookies", + "// (protocolProfileBehavior.disableCookies), and these steps fail when that stops being true.", + "pm.test('the request is answered', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));", + "const id = String(pm.response.json().ocs.data.id);", + "pm.test('the server sees the administrator', () => pm.expect(id).to.eql(String(pm.variables.get('admin_user'))));", + "const accounts = [String(pm.variables.get('admin_user')), String(pm.collectionVariables.get('ownerId')), String(pm.collectionVariables.get('colleagueId'))];", + "pm.test('the three accounts are three different accounts', () => pm.expect(new Set(accounts).size, accounts.join(', ')).to.eql(3));", + "pm.test('no account name is empty', () => accounts.forEach((account) => pm.expect(account).to.not.be.oneOf(['', 'undefined', 'null'])));" ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { - "name": "setUp: the register carries the schema", + "name": "GET who am I as the owner -> each step runs as the account its name claims", + "request": { + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/ocs/v2.php/cloud/user?format=json", + "host": [ + "{{base_url}}" + ], + "path": [ + "ocs", + "v2.php", + "cloud", + "user" + ], + "query": [ + { + "key": "format", + "value": "json" + } + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{ownerId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{ownerPassword}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// The first run of this collection in CI was red for this reason alone: the cookie jar carried the", + "// administrator's session into the owner's requests, and Nextcloud answers a request that has a", + "// session as that session's user, whatever its Authorization header says. So the draft was the", + "// administrator's, and \"somebody else\" was its owner. Every request here sends no cookies", + "// (protocolProfileBehavior.disableCookies), and these steps fail when that stops being true.", + "pm.test('the request is answered', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));", + "const id = String(pm.response.json().ocs.data.id);", + "pm.test('the server sees the owner', () => pm.expect(id).to.eql(String(pm.collectionVariables.get('ownerId'))));", + "const accounts = [String(pm.variables.get('admin_user')), String(pm.collectionVariables.get('ownerId')), String(pm.collectionVariables.get('colleagueId'))];", + "pm.test('the three accounts are three different accounts', () => pm.expect(new Set(accounts).size, accounts.join(', ')).to.eql(3));", + "pm.test('no account name is empty', () => accounts.forEach((account) => pm.expect(account).to.not.be.oneOf(['', 'undefined', 'null'])));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "GET who am I as the colleague -> each step runs as the account its name claims", + "request": { + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/ocs/v2.php/cloud/user?format=json", + "host": [ + "{{base_url}}" + ], + "path": [ + "ocs", + "v2.php", + "cloud", + "user" + ], + "query": [ + { + "key": "format", + "value": "json" + } + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{colleagueId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{colleaguePassword}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// The first run of this collection in CI was red for this reason alone: the cookie jar carried the", + "// administrator's session into the owner's requests, and Nextcloud answers a request that has a", + "// session as that session's user, whatever its Authorization header says. So the draft was the", + "// administrator's, and \"somebody else\" was its owner. Every request here sends no cookies", + "// (protocolProfileBehavior.disableCookies), and these steps fail when that stops being true.", + "pm.test('the request is answered', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));", + "const id = String(pm.response.json().ocs.data.id);", + "pm.test('the server sees the colleague', () => pm.expect(id).to.eql(String(pm.collectionVariables.get('colleagueId'))));", + "const accounts = [String(pm.variables.get('admin_user')), String(pm.collectionVariables.get('ownerId')), String(pm.collectionVariables.get('colleagueId'))];", + "pm.test('the three accounts are three different accounts', () => pm.expect(new Set(accounts).size, accounts.join(', ')).to.eql(3));", + "pm.test('no account name is empty', () => accounts.forEach((account) => pm.expect(account).to.not.be.oneOf(['', 'undefined', 'null'])));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "GET who am I as the administrator, after the others -> each step runs as the account its name claims", + "request": { + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/ocs/v2.php/cloud/user?format=json", + "host": [ + "{{base_url}}" + ], + "path": [ + "ocs", + "v2.php", + "cloud", + "user" + ], + "query": [ + { + "key": "format", + "value": "json" + } + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// The first run of this collection in CI was red for this reason alone: the cookie jar carried the", + "// administrator's session into the owner's requests, and Nextcloud answers a request that has a", + "// session as that session's user, whatever its Authorization header says. So the draft was the", + "// administrator's, and \"somebody else\" was its owner. Every request here sends no cookies", + "// (protocolProfileBehavior.disableCookies), and these steps fail when that stops being true.", + "pm.test('the request is answered', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));", + "const id = String(pm.response.json().ocs.data.id);", + "pm.test('the server sees the administrator, after the others', () => pm.expect(id).to.eql(String(pm.variables.get('admin_user'))));", + "const accounts = [String(pm.variables.get('admin_user')), String(pm.collectionVariables.get('ownerId')), String(pm.collectionVariables.get('colleagueId'))];", + "pm.test('the three accounts are three different accounts', () => pm.expect(new Set(accounts).size, accounts.join(', ')).to.eql(3));", + "pm.test('no account name is empty', () => accounts.forEach((account) => pm.expect(account).to.not.be.oneOf(['', 'undefined', 'null'])));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "setUp: a register", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/registers", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "registers" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"title\": \"draft activation newman register\", \"description\": \"draft activation collection fixture\"}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('the register is created', () => pm.expect(pm.response.code, pm.response.text()).to.be.below(300));", + "pm.collectionVariables.set('registerId', String(pm.response.json().id));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "setUp: a schema with two required properties and a draft lifetime", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/schemas", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "schemas" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"title\": \"draft activation newman schema\", \"description\": \"draft activation collection fixture\", \"required\": [\"subject\", \"channel\"], \"properties\": {\"subject\": {\"type\": \"string\", \"title\": \"Subject\", \"maxLength\": 200}, \"channel\": {\"type\": \"string\", \"title\": \"Channel\", \"enum\": [\"phone\", \"email\", \"counter\"]}, \"note\": {\"type\": \"string\", \"title\": \"Note\"}}, \"configuration\": {\"draftLifetimeDays\": 7}, \"authorization\": {\"read\": [\"authenticated\"], \"create\": [\"authenticated\"], \"update\": [\"authenticated\"], \"delete\": [\"authenticated\"]}}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('the schema is created', () => pm.expect(pm.response.code, pm.response.text()).to.be.below(300));", + "pm.collectionVariables.set('schemaId', String(pm.response.json().id));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "setUp: the register carries the schema", + "request": { + "method": "PUT", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/registers/{{registerId}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "registers", + "{{registerId}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"schemas\": [\"{{schemaId}}\"]}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('the schema is attached', () => pm.expect(pm.response.code, pm.response.text()).to.be.below(300));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "GET the schema -> the draft lifetime survived the save", + "request": { + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/schemas/{{schemaId}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "schemas", + "{{schemaId}}" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('the schema is readable', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));", + "// An unknown configuration key vanishes on save without a word; this one is declared.", + "pm.test('draftLifetimeDays reads back as 7', () => pm.expect((pm.response.json().configuration || {}).draftLifetimeDays).to.eql(7));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "setUp: the owner starts a draft that misses a required property", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "objects", + "{{registerId}}", + "{{schemaId}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"subject\": \"Bin not collected\", \"@self\": {\"status\": \"draft\"}}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{ownerId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{ownerPassword}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('the draft is created', () => pm.expect(pm.response.code, pm.response.text()).to.be.below(300));", + "const b = pm.response.json();", + "pm.collectionVariables.set('draftUuid', String((b['@self'] && b['@self'].id) || b.id || b.uuid));", + "pm.test('it is a draft', () => pm.expect((b['@self'] || {}).status).to.eql('draft'));", + "// The stored owner is what the activation compares with, so it is asserted where the draft is made.", + "pm.test('the draft is the owner account\\'s', () => pm.expect((b['@self'] || {}).owner).to.eql(pm.collectionVariables.get('ownerId')));", + "pm.test('the draft is not the administrator\\'s', () => pm.expect((b['@self'] || {}).owner).to.not.eql(pm.variables.get('admin_user')));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "POST .../activate as the administrator -> 403, delivered as 403", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}/activate", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "objects", + "{{registerId}}", + "{{schemaId}}", + "{{draftUuid}}", + "activate" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"object\": {\"channel\": \"phone\"}}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// The administrator may read the draft and is still not its owner. The status is the point:", + "// OCSMiddleware would deliver a 403 from an OCSController as HTTP 200 with the refusal in the body.", + "pm.test('the refusal is not delivered as a success', () => pm.expect(pm.response.code, pm.response.text()).to.be.at.least(400));", + "pm.test('a non-owner is refused with 403', () => pm.expect(pm.response.code, pm.response.text()).to.eql(403));", + "pm.test('the reason is not-the-owner', () => pm.expect(pm.response.json().reason).to.eql('not-the-owner'));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "POST .../activate as a colleague -> 404, the draft does not exist for them", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}/activate", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "objects", + "{{registerId}}", + "{{schemaId}}", + "{{draftUuid}}", + "activate" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"object\": {\"channel\": \"phone\"}}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{colleagueId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{colleaguePassword}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// A colleague may not read somebody else's draft, so it answers as an unknown id does.", + "pm.test('a colleague is refused with 404', () => pm.expect(pm.response.code, pm.response.text()).to.eql(404));", + "pm.test('the reason is not-found', () => pm.expect(pm.response.json().reason).to.eql('not-found'));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "GET the draft as a colleague -> 404", + "request": { + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "objects", + "{{registerId}}", + "{{schemaId}}", + "{{draftUuid}}" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{colleagueId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{colleaguePassword}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('a colleague cannot read the draft', () => pm.expect(pm.response.code, pm.response.text()).to.eql(404));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "PATCH the draft as a colleague -> refused, somebody who cannot read a draft cannot change it", + "request": { + "method": "PATCH", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "objects", + "{{registerId}}", + "{{schemaId}}", + "{{draftUuid}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"subject\": \"Changed by a colleague\"}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{colleagueId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{colleaguePassword}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Found on a live instance, 12 October 2026: this answered 200 with the draft's content.", + "pm.test('the change is refused', () => pm.expect(pm.response.code, pm.response.text()).to.be.within(400, 499));", + "pm.test('the answer does not hold the draft', () => pm.expect(pm.response.text()).to.not.include('Bin not collected'));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "POST naming the draft's id as a colleague -> refused, a create cannot land on a draft", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "objects", + "{{registerId}}", + "{{schemaId}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"id\": \"{{draftUuid}}\", \"subject\": \"Created over it by a colleague\", \"channel\": \"phone\"}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{colleagueId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{colleaguePassword}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('the write is refused', () => pm.expect(pm.response.code, pm.response.text()).to.be.within(400, 499));", + "pm.test('the answer does not hold the draft', () => pm.expect(pm.response.text()).to.not.include('Bin not collected'));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "PUT the draft as a colleague -> 404", + "request": { + "method": "PUT", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "objects", + "{{registerId}}", + "{{schemaId}}", + "{{draftUuid}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"subject\": \"Replaced by a colleague\", \"channel\": \"phone\"}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{colleagueId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{colleaguePassword}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('a colleague cannot replace the draft', () => pm.expect(pm.response.code, pm.response.text()).to.be.within(400, 499));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "GET the draft as its owner -> still a draft after the refusals", + "request": { + "method": "GET", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "index.php", + "apps", + "openregister", + "api", + "objects", + "{{registerId}}", + "{{schemaId}}", + "{{draftUuid}}" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{ownerId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{ownerPassword}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('the draft is readable by its owner', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));", + "pm.test('it is still a draft', () => pm.expect(pm.response.json()['@self'].status).to.eql('draft'));", + "pm.test('it is still the owner\\'s', () => pm.expect(pm.response.json()['@self'].owner).to.eql(pm.collectionVariables.get('ownerId')));", + "pm.test('it holds what its owner typed', () => pm.expect(pm.response.json().subject).to.eql('Bin not collected'));", + "pm.test('nobody filled in the channel', () => pm.expect(pm.response.json().channel == null).to.eql(true));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "PUT with @self.status active as the owner -> a plain save cannot leave draft", "request": { "method": "PUT", "header": [ @@ -250,7 +1376,7 @@ } ], "url": { - "raw": "{{base_url}}/index.php/apps/openregister/api/registers/{{registerId}}", + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}", "host": [ "{{base_url}}" ], @@ -259,13 +1385,30 @@ "apps", "openregister", "api", - "registers", - "{{registerId}}" + "objects", + "{{registerId}}", + "{{schemaId}}", + "{{draftUuid}}" ] }, "body": { "mode": "raw", - "raw": "{\"schemas\": [\"{{schemaId}}\"]}" + "raw": "{\"subject\": \"Bin not collected\", \"channel\": \"phone\", \"@self\": {\"status\": \"active\"}}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{ownerId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{ownerPassword}}", + "type": "string" + } + ] } }, "event": [ @@ -274,16 +1417,19 @@ "script": { "type": "text/javascript", "exec": [ - "pm.test('the schema is attached', () => pm.expect(pm.response.code, pm.response.text()).to.be.below(300));" + "pm.test('the save is refused', () => pm.expect(pm.response.code, pm.response.text()).to.be.within(400, 499));" ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { - "name": "GET the schema -> the draft lifetime survived the save", + "name": "PATCH with @self.status active as the owner -> refused", "request": { - "method": "GET", + "method": "PATCH", "header": [ { "key": "OCS-APIRequest", @@ -299,7 +1445,7 @@ } ], "url": { - "raw": "{{base_url}}/index.php/apps/openregister/api/schemas/{{schemaId}}", + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}", "host": [ "{{base_url}}" ], @@ -308,8 +1454,29 @@ "apps", "openregister", "api", - "schemas", - "{{schemaId}}" + "objects", + "{{registerId}}", + "{{schemaId}}", + "{{draftUuid}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"channel\": \"phone\", \"@self\": {\"status\": \"active\"}}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{ownerId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{ownerPassword}}", + "type": "string" + } ] } }, @@ -319,18 +1486,19 @@ "script": { "type": "text/javascript", "exec": [ - "pm.test('the schema is readable', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));", - "// An unknown configuration key vanishes on save without a word; this one is declared.", - "pm.test('draftLifetimeDays reads back as 7', () => pm.expect((pm.response.json().configuration || {}).draftLifetimeDays).to.eql(7));" + "pm.test('the save is refused for the owner', () => pm.expect(pm.response.code, pm.response.text()).to.be.within(400, 499));" ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { - "name": "setUp: the owner starts a draft that misses a required property", + "name": "PUT with @self.status active as the administrator -> refused", "request": { - "method": "POST", + "method": "PUT", "header": [ { "key": "OCS-APIRequest", @@ -346,7 +1514,7 @@ } ], "url": { - "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}", + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}", "host": [ "{{base_url}}" ], @@ -357,24 +1525,25 @@ "api", "objects", "{{registerId}}", - "{{schemaId}}" + "{{schemaId}}", + "{{draftUuid}}" ] }, "body": { "mode": "raw", - "raw": "{\"subject\": \"Bin not collected\", \"@self\": {\"status\": \"draft\"}}" + "raw": "{\"subject\": \"Bin not collected\", \"channel\": \"phone\", \"@self\": {\"status\": \"active\"}}" }, "auth": { "type": "basic", "basic": [ { "key": "username", - "value": "{{ownerId}}", + "value": "{{admin_user}}", "type": "string" }, { "key": "password", - "value": "{{ownerPassword}}", + "value": "{{admin_password}}", "type": "string" } ] @@ -386,19 +1555,19 @@ "script": { "type": "text/javascript", "exec": [ - "pm.test('the draft is created', () => pm.expect(pm.response.code, pm.response.text()).to.be.below(300));", - "const b = pm.response.json();", - "pm.collectionVariables.set('draftUuid', String((b['@self'] && b['@self'].id) || b.id || b.uuid));", - "pm.test('it is a draft', () => pm.expect((b['@self'] || {}).status).to.eql('draft'));" + "pm.test('the save is refused for the administrator', () => pm.expect(pm.response.code, pm.response.text()).to.be.within(400, 499));" ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { - "name": "POST .../activate as somebody else -> 403, delivered as 403", + "name": "PATCH with @self.status active as the administrator -> refused", "request": { - "method": "POST", + "method": "PATCH", "header": [ { "key": "OCS-APIRequest", @@ -414,7 +1583,7 @@ } ], "url": { - "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}/activate", + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/{{registerId}}/{{schemaId}}/{{draftUuid}}", "host": [ "{{base_url}}" ], @@ -426,13 +1595,27 @@ "objects", "{{registerId}}", "{{schemaId}}", - "{{draftUuid}}", - "activate" + "{{draftUuid}}" ] }, "body": { "mode": "raw", - "raw": "{\"object\": {\"channel\": \"phone\"}}" + "raw": "{\"channel\": \"phone\", \"@self\": {\"status\": \"active\"}}" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] } }, "event": [ @@ -441,20 +1624,19 @@ "script": { "type": "text/javascript", "exec": [ - "// The administrator may read the draft and is still not its owner. The status is the point:", - "// OCSMiddleware would deliver a 403 from an OCSController as HTTP 200 with the refusal in the body.", - "pm.test('the refusal is not delivered as a success', () => pm.expect(pm.response.code, pm.response.text()).to.be.at.least(400));", - "pm.test('a non-owner is refused with 403', () => pm.expect(pm.response.code, pm.response.text()).to.eql(403));", - "pm.test('the reason is not-the-owner', () => pm.expect(pm.response.json().reason).to.eql('not-the-owner'));" + "pm.test('the save is refused for the administrator', () => pm.expect(pm.response.code, pm.response.text()).to.be.within(400, 499));" ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { - "name": "GET the draft as its owner -> still a draft after the refusal", + "name": "PATCH with @self.status active as a colleague -> refused", "request": { - "method": "GET", + "method": "PATCH", "header": [ { "key": "OCS-APIRequest", @@ -485,17 +1667,21 @@ "{{draftUuid}}" ] }, + "body": { + "mode": "raw", + "raw": "{\"channel\": \"phone\", \"@self\": {\"status\": \"active\"}}" + }, "auth": { "type": "basic", "basic": [ { "key": "username", - "value": "{{ownerId}}", + "value": "{{colleagueId}}", "type": "string" }, { "key": "password", - "value": "{{ownerPassword}}", + "value": "{{colleaguePassword}}", "type": "string" } ] @@ -507,17 +1693,19 @@ "script": { "type": "text/javascript", "exec": [ - "pm.test('the draft is readable by its owner', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));", - "pm.test('it is still a draft', () => pm.expect(pm.response.json()['@self'].status).to.eql('draft'));" + "pm.test('the save is refused for a colleague', () => pm.expect(pm.response.code, pm.response.text()).to.be.within(400, 499));" ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { - "name": "PUT with @self.status active -> a plain save cannot leave draft", + "name": "GET the draft as its owner -> still a draft after every plain save", "request": { - "method": "PUT", + "method": "GET", "header": [ { "key": "OCS-APIRequest", @@ -548,10 +1736,6 @@ "{{draftUuid}}" ] }, - "body": { - "mode": "raw", - "raw": "{\"subject\": \"Bin not collected\", \"channel\": \"phone\", \"@self\": {\"status\": \"active\"}}" - }, "auth": { "type": "basic", "basic": [ @@ -574,11 +1758,17 @@ "script": { "type": "text/javascript", "exec": [ - "pm.test('the save is refused', () => pm.expect(pm.response.code, pm.response.text()).to.be.within(400, 499));" + "pm.test('the draft is readable by its owner', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));", + "pm.test('no plain save left draft', () => pm.expect(pm.response.json()['@self'].status).to.eql('draft'));", + "pm.test('no refused save stored its channel', () => pm.expect(pm.response.json().channel == null).to.eql(true));", + "pm.test('it holds what its owner typed', () => pm.expect(pm.response.json().subject).to.eql('Bin not collected'));" ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "POST .../activate incomplete -> 422 with a finding per property", @@ -645,7 +1835,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "POST .../activate with the last value -> 200, active, same id", @@ -716,7 +1909,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "GET the object as the administrator -> active", @@ -751,6 +1947,21 @@ "{{schemaId}}", "{{draftUuid}}" ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] } }, "event": [ @@ -764,7 +1975,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "POST .../activate again -> 409, there is no draft left", @@ -828,7 +2042,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "POST .../activate on an unknown object -> 404", @@ -892,7 +2109,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "setUp: the owner starts a second draft", @@ -959,7 +2179,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "GET the list as the owner -> their own draft is in it by default", @@ -1029,7 +2252,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "GET the list with _drafts=none -> no drafts, the owner's own included", @@ -1104,7 +2330,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "tearDown: the second draft", @@ -1166,7 +2395,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "tearDown: the object", @@ -1201,6 +2433,21 @@ "{{schemaId}}", "{{draftUuid}}" ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] } }, "event": [ @@ -1213,7 +2460,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "tearDown: the schema", @@ -1246,6 +2496,21 @@ "schemas", "{{schemaId}}" ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] } }, "event": [ @@ -1258,7 +2523,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "tearDown: the register", @@ -1291,6 +2559,21 @@ "registers", "{{registerId}}" ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] } }, "event": [ @@ -1303,7 +2586,10 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } }, { "name": "tearDown: the second account", @@ -1341,6 +2627,21 @@ "value": "json" } ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] } }, "event": [ @@ -1353,7 +2654,78 @@ ] } } - ] + ], + "protocolProfileBehavior": { + "disableCookies": true + } + }, + { + "name": "tearDown: the third account", + "request": { + "method": "DELETE", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base_url}}/ocs/v2.php/cloud/users/{{colleagueId}}?format=json", + "host": [ + "{{base_url}}" + ], + "path": [ + "ocs", + "v2.php", + "cloud", + "users", + "{{colleagueId}}" + ], + "query": [ + { + "key": "format", + "value": "json" + } + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}", + "type": "string" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('the colleague account is removed', () => pm.expect(pm.response.code, pm.response.text()).to.eql(200));" + ] + } + } + ], + "protocolProfileBehavior": { + "disableCookies": true + } } ] } From 498da7797793546c7039a9a009b7ab9d608b9ccd Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 20:32:32 +0200 Subject: [PATCH 119/183] fix(objects): a stored null is a property nobody filled in when a draft is made definitive A draft read back from its table holds null for every empty column. Judged as null, a missing required property answered code type with "The data (null) must match the type: string", and a property the server fills was refused too. Found on a live instance; the unit test built its draft without the key. --- lib/Service/Object/DraftActivation.php | 7 ++- .../Service/Object/DraftActivationTest.php | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/Service/Object/DraftActivation.php b/lib/Service/Object/DraftActivation.php index c0c5123ec9..a905736cd9 100644 --- a/lib/Service/Object/DraftActivation.php +++ b/lib/Service/Object/DraftActivation.php @@ -109,7 +109,12 @@ public function activate(Register $register, Schema $schema, string $draftId, Dr $uuid = (string)$draft->getUuid(); unset($changes['@self'], $changes['id']); - $object = array_merge(DraftFinder::dataOf(object: $draft), $changes); + // A stored draft holds null for every property nobody filled in (a + // table has a column for each), so a stored null is "not supplied": + // judged as null it reads as a wrong type, also for a property the + // server fills. What the caller sends now is judged as sent. + $stored = array_filter(DraftFinder::dataOf(object: $draft), static fn (mixed $value): bool => $value !== null); + $object = array_merge($stored, $changes); $this->validateInFull(schema: $schema, object: $object); $object['@self'] = ['status' => ObjectEntity::STATUS_ACTIVE]; diff --git a/tests/Unit/Service/Object/DraftActivationTest.php b/tests/Unit/Service/Object/DraftActivationTest.php index 573ff91d87..736155fb9b 100644 --- a/tests/Unit/Service/Object/DraftActivationTest.php +++ b/tests/Unit/Service/Object/DraftActivationTest.php @@ -393,6 +393,51 @@ public function testAnIncompleteDraftIsRefusedPerField(): void { $this->assertSame('draft', $draft->getStatus()); }//end testAnIncompleteDraftIsRefusedPerField() + /** + * A stored draft holds null for every property nobody filled in: a table has a column for each. + * Found live (12 October): the answer named `channel` with code `type` and the sentence + * "The data (null) must match the type: string", where the caller needs `required`. + */ + public function testAStoredNullIsAPropertyNobodyFilledIn(): void { + $this->store( + uuid: 'd1', + owner: 'agent', + status: 'draft', + data: ['subject' => null, 'channel' => null, 'priority' => null, 'number' => null, 'contactedAt' => '2026-10-12T15:00:00+02:00'] + ); + + $refused = $this->refusalOf(draftId: 'd1', owner: DraftOwner::ofUser(user: $this->user(uid: 'agent'))); + + $byProperty = []; + foreach ($refused->getFindings() as $finding) { + $byProperty[$finding['property']] = $finding['code']; + } + + $this->assertSame(['channel' => 'required', 'subject' => 'required'], $this->sorted(map: $byProperty)); + + // Control: with the two values sent, the same stored nulls stand in nobody's way. + $saved = $this->activation->activate( + register: $this->register, + schema: $this->schema, + draftId: 'd1', + owner: DraftOwner::ofUser(user: $this->user(uid: 'agent')), + changes: ['subject' => 'Bin not collected', 'channel' => 'phone'] + ); + $this->assertSame('active', $saved->getStatus()); + $this->assertArrayNotHasKey('priority', $this->saves[0]['object']); + }//end testAStoredNullIsAPropertyNobodyFilledIn() + + /** + * A map sorted by key, so an assertion reads the same whatever order the validator found things in. + * @param array $map The map. + * @return array The sorted map. + */ + private function sorted(array $map): array { + ksort($map); + + return $map; + }//end sorted() + /** * The last changes are laid over the stored draft before it is judged; `@self` and `id` in them are ignored. */ From 853d562997176658eaf0aeddd52eeb792460ff69 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 20:32:32 +0200 Subject: [PATCH 120/183] fix(objects): a save cannot write over a draft its caller cannot read A draft answers to its owner only, and that rule lives in the read query. The save's own lookup therefore did not find somebody else's draft, went on as a create under a chosen uuid, and was stored over the draft. On a live instance a second account changed the first one's draft through PATCH, and through a POST naming its id, and read its content in the answer. GET, PUT and DELETE answered 404 to the same account. The save now looks the uuid up once more past the read rule and refuses when it is a draft kept from the caller. RBAC off, a stored object that is no draft, a uuid nobody stored and a schema with draftsVisible are judged as before. --- CHANGELOG.md | 1 + docs/features/form-destination.md | 3 + lib/Service/Object/DraftStatusPolicy.php | 21 + lib/Service/ObjectService.php | 55 +++ .../specs/form-destination/spec.md | 27 ++ .../tasks.md | 6 + .../Service/Object/DraftStatusPolicyTest.php | 24 ++ .../Service/ObjectServiceDraftWriteTest.php | 407 ++++++++++++++++++ 8 files changed, 544 insertions(+) create mode 100644 tests/Unit/Service/ObjectServiceDraftWriteTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6402253ec1..bb2120538e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - **Word-family text extraction now captures all content and supports legacy `.doc` and `.odt`.** `TextExtractionService::extractWord()` was rewritten around a recursive PhpWord element walker (`walkWordElements`, depth-capped at `MAX_WORD_DEPTH = 50`) that descends tables (rows → cells, including nested tables and in-cell text-runs / list items), section headers and footers, and document-level footnotes/endnotes — content the previous 2-level traversal silently dropped (notably entire tables, because a PhpWord `Table` exposes `getRows()`, not `getElements()`, so the old loop skipped it). The PhpWord reader is now selected from the file MIME / extension (`resolveWordReader`): `.docx` → `Word2007`, legacy `.doc` → `MsDoc`, `.odt` → `ODText` — previously the default `Word2007` reader was always used, so `.doc` and `.odt` failed to load even when routed. `application/vnd.oasis.opendocument.text` is added to the Word routing in `isWordDocument()`. (`text-extraction-word-completeness`) ### Behaviour changes +- **A draft can no longer be changed by somebody who cannot read it (`draft-owner-activation-and-lifetime`).** A draft is visible to its owner only. Until now a second account could still change it: a `PATCH` on the draft, or a `POST` that named its id, was judged by the schema's create rule and stored over the draft. The answer held the draft's content. Both now answer 403. `GET`, `PUT` and `DELETE` already answered 404. An administrator may still correct a draft, and a schema with `draftsVisible: true` keeps its own rules. - **Upgrade note: drafts now expire after 30 days (`draft-owner-activation-and-lifetime`, decisions 216, 226 and 228).** A draft is an object in status `draft`. A draft that nobody changed for 30 days is removed by the daily job `DraftExpiryJob`, for good and with its files. This applies to drafts that exist today: **the first run of the job after the upgrade removes every draft older than 30 days**, at most 1,000 per run. Look before it runs: `occ openregister:drafts:expired` lists how many drafts the next run would remove per schema, and removes nothing. To keep the drafts of a schema, set `"draftLifetimeDays": false` in its configuration. A whole number of days, 1 or more, sets another lifetime. The lifetime counts from the draft's last change. Each removed draft leaves one audit-trail entry (`draft.removed`) that says who or which job removed it, when, in which register and schema, and which object. The entry never holds what the draft contained. The job logs a count per schema. - **Dashboard right sidebar is now collapsed by default on initial load.** The dashboard view (route `/`) renders with its right-hand app sidebar closed so the dashboard's primary content is unobstructed on arrival. The sidebar remains fully functional — the standard Nextcloud sidebar toggle opens it, and its filters, system totals, and orphaned-items sections behave identically once open. No other view's sidebar default open/closed state changes. (`dashboard-sidebar-default-state`) - **File access is permission-based and ownership-agnostic; objects can link files owned by other users.** `FileValidationHandler::checkOwnership()` now grants access on `Node::isReadable()` alone instead of denying when the file's owner UID differs from the session user — fixing `File is not owned by the current session` errors when uploading, viewing, updating, or deleting a file reached via a Nextcloud share or owned by a user other than the `openregister` system user. The update and delete paths now assert `Node::isUpdateable()` / `Node::isDeletable()` before mutating (failing fast with `NotPermittedException` in addition to Nextcloud's native enforcement), and `FileOwnershipHandler::transferFileOwnershipIfNeeded()` no longer re-owns a file to the system user when the current user already has write rights — ownership follows the folder's mount owner (the system user inside the OpenRegister folder; the original owner for a folder linked from outside it), with re-owning retained only as a fallback. (`file-permission-share-access`) diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md index 5a46d6d254..bdddb9b29d 100644 --- a/docs/features/form-destination.md +++ b/docs/features/form-destination.md @@ -108,6 +108,9 @@ A draft is the destination object itself, saved with `@self.status: draft` still refused, whatever the schema's hard-validation flag. - A draft is visible to its owner only. Set `draftsVisible: true` in the schema configuration to let everyone who may read the schema see drafts. +- Who cannot read a draft cannot change it. A PATCH, or a create that names + the draft's id, answers 403 for anybody but the owner. An administrator + may still correct a draft. - Submit with `_draft` to leave draft. The submit runs full validation, sets `@self.status` to `active`, answers with the moment it left draft as `receivedAt`, and dispatches `ObjectActivatedEvent`. diff --git a/lib/Service/Object/DraftStatusPolicy.php b/lib/Service/Object/DraftStatusPolicy.php index 505399f42c..c750782408 100644 --- a/lib/Service/Object/DraftStatusPolicy.php +++ b/lib/Service/Object/DraftStatusPolicy.php @@ -277,6 +277,27 @@ public function draftsVisibleToOthers(Schema $schema): bool { return (($schema->getConfiguration() ?? [])['draftsVisible'] ?? false) === true; }//end draftsVisibleToOthers() + /** + * Whether a stored draft is kept from a caller: it is a draft, its schema keeps drafts to their owner, and the caller is not that owner. + * + * The read rule (MagicRbacHandler) already hides such a draft. A write + * that looks its target up past that rule asks here, so somebody who + * cannot read a draft cannot change it either. + * + * @param ObjectEntity $object The stored object. + * @param Schema $schema Its schema. + * @param string|null $userId The caller, null when nobody is signed in. + * + * @return bool True when the caller may not touch it. + * + * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-draft-must-not-be-changed-by-somebody-who-cannot-read-it + */ + public function keptFrom(ObjectEntity $object, Schema $schema, ?string $userId): bool { + return $object->isDraft() === true + && $this->draftsVisibleToOthers(schema: $schema) === false + && $object->getOwner() !== $userId; + }//end keptFrom() + /** * Whether the schema's lists show the caller their own drafts by default. * diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index c06e0dded6..6596c4d92c 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -2278,6 +2278,9 @@ private function checkSavePermissions(?string $uuid, bool $_rbac, array $object= object: $existingObject ); } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + // Not found FOR THIS CALLER: new, or stored and hidden from them. + $this->refuseSomebodyElsesDraft(uuid: $uuid, _rbac: $_rbac); + // Object not found, this is a CREATE operation with specific UUID. $this->checkPermission( schema: $this->currentSchema, @@ -2290,6 +2293,58 @@ private function checkSavePermissions(?string $uuid, bool $_rbac, array $object= }//end try }//end checkSavePermissions() + /** + * Refuse a write to a stored draft the caller cannot read. + * + * A draft answers to its owner only (decision 180), and that rule lives in + * the read query. The lookup above therefore does not find somebody else's + * draft, and the write went on as a create under a chosen uuid, judged by + * the schema's create rule, and was then stored over the draft. Anybody + * who may create in the schema could change somebody else's draft through + * PATCH and got its content back in the answer. Found on a live instance, + * 12 October 2026. + * + * So the uuid is looked up once more, past the read rule, and only for + * this question. Skipped with RBAC off: an administrator, a job and a + * scope-value owner (decision 195) are judged by their caller. + * + * @param string $uuid The uuid the caller's own lookup did not find. + * @param bool $_rbac Whether the write is judged for the signed-in caller. + * + * @return void + * + * @throws \OCA\OpenRegister\Exception\NotAuthorizedException When the uuid is a draft kept from the caller. + * + * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-draft-must-not-be-changed-by-somebody-who-cannot-read-it + */ + private function refuseSomebodyElsesDraft(string $uuid, bool $_rbac): void + { + if ($_rbac === false || $this->currentSchema === null) { + return; + } + + try { + $stored = $this->objectMapper->find( + identifier: $uuid, + register: $this->currentRegister, + schema: $this->currentSchema, + _rbac: false, + _multitenancy: false + ); + } catch (\OCP\AppFramework\Db\DoesNotExistException) { + return; + } + + $kept = (new DraftStatusPolicy())->keptFrom( + object: $stored, + schema: $this->currentSchema, + userId: $this->userSession->getUser()?->getUID() + ); + if ($kept === true) { + throw new \OCA\OpenRegister\Exception\NotAuthorizedException(message: 'Only the person who started this draft can change it.'); + } + }//end refuseSomebodyElsesDraft() + /** * Build the transient object a create rule's `match` is evaluated against. * diff --git a/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md b/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md index 7480f76643..947ccfe7fd 100644 --- a/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md +++ b/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md @@ -53,6 +53,14 @@ A refusal SHALL change nothing and SHALL answer `{ message, reason, findings }` - **THEN** the answer is 422 with `reason` `invalid` and a `required` finding for `subject` and one for `channel` - **AND** the object is still a draft and no event is dispatched +#### Scenario: A stored empty value is a property nobody filled in +@e2e exclude unit; DraftActivationTest covers the stored nulls, the Newman collection openregister-draft-activation asserts the `required` finding on a draft read back from its table + +- **GIVEN** a stored draft whose table holds null for `channel`, for a property with a default and for a server-set property +- **WHEN** its owner activates it +- **THEN** the finding for `channel` has code `required`, not a wrong-type finding about null +- **AND** the property with a default and the server-set property get no finding + #### Scenario: Somebody else is refused with a real error status @e2e exclude unit; DraftActivationTest covers the owner check and ObjectDraftControllerTest the plain Controller, the Newman collection openregister-draft-activation asserts the delivered 403 from a caller's side @@ -76,6 +84,25 @@ A refusal SHALL change nothing and SHALL answer `{ message, reason, findings }` - **THEN** it becomes `active` - **AND** the same call asserting `portal:subject-2` is refused with `not-the-owner` +### Requirement: A draft MUST NOT be changed by somebody who cannot read it + +A draft answers to its owner only, unless its schema shows drafts to others (`draftsVisible`). A save under RBAC whose own lookup does not find the object SHALL look the uuid up once more past the read rule, and SHALL refuse the write when it is a draft kept from the caller. So a PATCH, and a create that names the draft's id, cannot write over somebody else's draft or return its content. The refusal is `NotAuthorizedException`, delivered as 403. A save with RBAC off (an administrator, a job, a scope-value owner, decision 195) is judged by its caller, as before. A stored object that is not a draft, and a uuid nobody stored, are judged as before. + +#### Scenario: A colleague cannot change a draft through PATCH +@e2e exclude unit; ObjectServiceDraftWriteTest covers the save with the mapper answering as the read rule does, the Newman collection openregister-draft-activation asserts the delivered refusal and the unchanged draft from a third account + +- **GIVEN** a draft owned by one user, on a schema every signed-in user may create and update in +- **WHEN** another user, not an administrator, sends a PATCH to it, or a POST that names its id +- **THEN** the write is refused with 403 +- **AND** the draft holds what its owner typed + +#### Scenario: The rule is about drafts only +@e2e exclude unit; ObjectServiceDraftWriteTest covers each control + +- **GIVEN** the same save on a uuid nobody stored, on a stored object that is not a draft, with RBAC off, and on a schema with `draftsVisible` +- **WHEN** it runs +- **THEN** each is judged as it was before this requirement + ### Requirement: A draft lifetime MUST default to thirty days, and a schema MUST be able to change it or opt out A draft SHALL have a lifetime of 30 days unless its schema says otherwise (decisions 201 and 228). A schema SHALL be able to declare `draftLifetimeDays` in its configuration: a whole number of days, 1 or more, sets another lifetime; `false` opts out and the schema's drafts SHALL be kept. An absent key or `null` SHALL mean the default. The key SHALL survive a schema save, an import and an export, the value `false` included. Any other value (zero, a negative number, a fraction, `true`, text) SHALL be refused: it SHALL be stored as `false` and reported as a dropped key, so a typing error keeps drafts and never reads as "remove every draft now" or "remove after 30 days". A stored value nobody can read SHALL keep the drafts too. diff --git a/openspec/changes/draft-owner-activation-and-lifetime/tasks.md b/openspec/changes/draft-owner-activation-and-lifetime/tasks.md index fd6e21602e..fcbf68fdae 100644 --- a/openspec/changes/draft-owner-activation-and-lifetime/tasks.md +++ b/openspec/changes/draft-owner-activation-and-lifetime/tasks.md @@ -48,6 +48,12 @@ - [x] 3.7 `DraftRemovalRecord`: the permanent delete of a draft writes one audit-trail entry `draft.removed` through `AuditTrailMapper::createAuditTrailEntry()`, naming the user or the job, never the content (decision 226) - Spec ref: specs/form-destination/spec.md, "Removing a draft MUST leave one audit-trail entry without its content" - Test: DraftRemovalRecordTest (the real delete handler, with a record that is not a draft as control), DraftRemovalAuditRowTest (the row read back from the migrated audit table), DraftExpiryJobTest (the job as actor) +- [x] 3.8 `DraftActivation`: a stored null is a property nobody filled in, so a missing required property answers `required` and a server-filled one is excused (found live: the table holds null for every empty column) + - Spec ref: specs/form-destination/spec.md, scenario "A stored empty value is a property nobody filled in" + - Test: DraftActivationTest::testAStoredNullIsAPropertyNobodyFilledIn +- [x] 3.9 `ObjectService::saveObject()`: a save under RBAC cannot write over a draft its caller cannot read (found live: PATCH and a POST naming the id changed another user's draft) + - Spec ref: specs/form-destination/spec.md, "A draft MUST NOT be changed by somebody who cannot read it" + - Test: ObjectServiceDraftWriteTest (each refusal with its controls), DraftStatusPolicyTest::testADraftIsKeptFromEverybodyButItsOwner, the Newman collection's colleague steps ## 4. Lists diff --git a/tests/Unit/Service/Object/DraftStatusPolicyTest.php b/tests/Unit/Service/Object/DraftStatusPolicyTest.php index 24570adff6..4dfa6dde70 100644 --- a/tests/Unit/Service/Object/DraftStatusPolicyTest.php +++ b/tests/Unit/Service/Object/DraftStatusPolicyTest.php @@ -181,6 +181,30 @@ public function testDraftsAreOwnerOnlyUnlessTheSchemaSaysOtherwise(): void { $this->assertTrue($this->policy->draftsVisibleToOthers(schema: $schema)); }//end testDraftsAreOwnerOnlyUnlessTheSchemaSaysOtherwise() + /** + * A draft is kept from everybody but its owner, on a schema that keeps drafts to their owner; nothing else is. + */ + public function testADraftIsKeptFromEverybodyButItsOwner(): void { + $schema = new Schema(); + $draft = new ObjectEntity(); + $draft->setOwner('agent'); + $draft->setStatus('draft'); + + $this->assertTrue($this->policy->keptFrom(object: $draft, schema: $schema, userId: 'colleague')); + $this->assertTrue($this->policy->keptFrom(object: $draft, schema: $schema, userId: null)); + $this->assertFalse($this->policy->keptFrom(object: $draft, schema: $schema, userId: 'agent')); + + // Control: an object that is no draft is nobody's to keep. + $record = new ObjectEntity(); + $record->setOwner('agent'); + $record->setStatus('active'); + $this->assertFalse($this->policy->keptFrom(object: $record, schema: $schema, userId: 'colleague')); + + // Control: a schema that shows its drafts to others keeps none. + $schema->setConfiguration(['draftsVisible' => true]); + $this->assertFalse($this->policy->keptFrom(object: $draft, schema: $schema, userId: 'colleague')); + }//end testADraftIsKeptFromEverybodyButItsOwner() + /** * A schema's lists show the caller their own drafts unless it says `draftsListed: false`. */ diff --git a/tests/Unit/Service/ObjectServiceDraftWriteTest.php b/tests/Unit/Service/ObjectServiceDraftWriteTest.php new file mode 100644 index 0000000000..eb8c014e4e --- /dev/null +++ b/tests/Unit/Service/ObjectServiceDraftWriteTest.php @@ -0,0 +1,407 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-draft-must-not-be-changed-by-somebody-who-cannot-read-it + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ + +namespace OCA\OpenRegister\Tests\Unit\Service; + +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\Db\ViewMapper; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Exception\NotAuthorizedException; +use OCA\OpenRegister\Service\DateTimeNormalizer; +use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Object\AuditHandler; +use OCA\OpenRegister\Service\Object\CacheHandler; +use OCA\OpenRegister\Service\Object\CascadingHandler; +use OCA\OpenRegister\Service\Object\DataManipulationHandler; +use OCA\OpenRegister\Service\Object\DeleteObject; +use OCA\OpenRegister\Service\Object\FacetHandler; +use OCA\OpenRegister\Service\Object\GetObject; +use OCA\OpenRegister\Service\Object\LockHandler; +use OCA\OpenRegister\Service\Object\MergeHandler; +use OCA\OpenRegister\Service\Object\MetadataHandler; +use OCA\OpenRegister\Service\Object\MigrationHandler; +use OCA\OpenRegister\Service\Object\PerformanceOptimizationHandler; +use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCA\OpenRegister\Service\Object\QueryHandler; +use OCA\OpenRegister\Service\Object\RelationHandler; +use OCA\OpenRegister\Service\Object\RenderObject; +use OCA\OpenRegister\Service\Object\RevertHandler; +use OCA\OpenRegister\Service\Object\SaveObject; +use OCA\OpenRegister\Service\Object\SaveObjects; +use OCA\OpenRegister\Service\Object\SearchQueryHandler; +use OCA\OpenRegister\Service\Object\UtilityHandler; +use OCA\OpenRegister\Service\Object\ValidateObject; +use OCA\OpenRegister\Service\Object\ValidationHandler; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\ObjectSource\ObjectSourceRegistry; +use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\SearchTrailService; +use OCA\OpenRegister\Service\SettingsService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\IAppContainer; +use OCP\IGroupManager; +use OCP\IUserManager; +use OCP\IUser; +use OCP\IUserSession; +use Opis\JsonSchema\ValidationResult; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Tests that a save cannot reach a draft its caller cannot read. + * + * @covers \OCA\OpenRegister\Service\ObjectService + * @uses \OCA\OpenRegister\Service\Object\DraftStatusPolicy + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Db\Register + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Exception\NotAuthorizedException + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class ObjectServiceDraftWriteTest extends TestCase { + + /** @var ObjectService */ + private ObjectService $service; + + /** @var MockObject&SaveObject */ + private MockObject $saveHandler; + + /** @var MockObject&IUserSession */ + private MockObject $session; + + /** @var MockObject&RenderObject */ + private MockObject $renderHandler; + + /** @var MockObject&DeleteObject */ + private MockObject $deleteHandler; + + /** @var MockObject&MagicMapper */ + private MockObject $objectMapper; + + /** @var MockObject&CascadingHandler */ + private MockObject $cascadingHandler; + + /** @var MockObject&DateTimeNormalizer */ + private MockObject $dateTimeNormalizer; + + /** @var array> What reached the save handler. */ + private array $saves = []; + + /** + * Set up fresh service + mocks before each test. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->saveHandler = $this->createMock(SaveObject::class); + $this->session = $this->createMock(IUserSession::class); + $this->renderHandler = $this->createMock(RenderObject::class); + $this->deleteHandler = $this->createMock(DeleteObject::class); + $this->objectMapper = $this->createMock(MagicMapper::class); + $this->cascadingHandler = $this->createMock(CascadingHandler::class); + $this->dateTimeNormalizer = $this->createMock(DateTimeNormalizer::class); + + // saveObject() returns renderHandler->renderEntity($savedObject, …); + // echo the saved entity back so the tests can assertSame() on it. + $this->renderHandler->method('renderEntity')->willReturnArgument(0); + + // normalize() echoes input unchanged (no date coercion side-effects needed here). + $this->dateTimeNormalizer->method('normalize')->willReturnCallback( + static function (?string $input): ?\DateTimeImmutable { + if ($input === null || trim($input) === '') { + return null; + } + + try { + return new \DateTimeImmutable($input); + } catch (\Throwable $e) { + return null; + } + } + ); + + // CascadingHandler: return object unchanged, UUID unchanged. + $this->cascadingHandler->method('handlePreValidationCascading')->willReturnCallback( + static function (array $obj, mixed $schema, ?string $uuid, ?int $register): array { + return [$obj, $uuid]; + } + ); + + // The controls reach validation; what the object holds is not this test's subject. + $validator = $this->createMock(ValidateObject::class); + $validator->method('validateObject')->willReturn(new ValidationResult(null)); + + $this->service = new ObjectService( + $this->createMock(DataManipulationHandler::class), + $this->deleteHandler, + $this->createMock(GetObject::class), + $this->createMock(PermissionHandler::class), + $this->renderHandler, + $this->saveHandler, + $this->createMock(SaveObjects::class), + $this->createMock(SearchQueryHandler::class), + $validator, + $this->createMock(LockHandler::class), + $this->createMock(AuditHandler::class), + $this->createMock(RelationHandler::class), + $this->createMock(MergeHandler::class), + $this->createMock(FacetHandler::class), + $this->createMock(MetadataHandler::class), + $this->createMock(PerformanceOptimizationHandler::class), + $this->createMock(QueryHandler::class), + $this->createMock(RevertHandler::class), + $this->createMock(UtilityHandler::class), + $this->createMock(ValidationHandler::class), + $this->cascadingHandler, + $this->createMock(MigrationHandler::class), + $this->createMock(RegisterMapper::class), + $this->createMock(SchemaMapper::class), + $this->createMock(ViewMapper::class), + $this->objectMapper, + $this->createMock(FileService::class), + $this->session, + $this->createMock(SearchTrailService::class), + $this->createMock(IGroupManager::class), + $this->createMock(IUserManager::class), + $this->createMock(OrganisationService::class), + $this->createMock(LoggerInterface::class), + $this->createMock(CacheHandler::class), + $this->createMock(SettingsService::class), + $this->dateTimeNormalizer, + $this->createMock(IAppContainer::class), + $this->createMock(ObjectSourceRegistry::class) + ); + }//end setUp() + + /** + * A register and a schema, whose drafts are the owner's alone unless told otherwise. + * + * @param bool $draftsVisible Whether the schema lets others see its drafts. + * + * @return array{0: Register, 1: Schema} + */ + private function scope(bool $draftsVisible = false): array { + $register = new Register(); + $register->setId(7); + $schema = new Schema(); + $schema->setId(99); + $schema->setSlug('ticket'); + if ($draftsVisible === true) { + $schema->setConfiguration(['draftsVisible' => true]); + } + + return [$register, $schema]; + }//end scope() + + /** + * Sign a user in, or nobody. + * + * @param string|null $uid The user id, null for nobody. + * + * @return void + */ + private function signedIn(?string $uid): void { + $user = null; + if ($uid !== null) { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + } + + $this->session->method('getUser')->willReturn($user); + }//end signedIn() + + /** + * Store one object, and make the mapper answer as the real one does for a caller it is hidden from. + * + * With the read rule on the lookup finds nothing; with it off it finds the object. + * + * @param string|null $status The stored status, or null for no stored object at all. + * @param bool $exists False when the uuid is not stored. + * + * @return void + */ + private function hiddenFromTheCaller(?string $status, bool $exists = true): void { + $this->objectMapper->method('find')->willReturnCallback( + static function ( + string|int $identifier, + ?Register $register = null, + ?Schema $schema = null, + bool $includeDeleted = false, + bool $_rbac = true, + ) use ( + $status, + $exists + ): ObjectEntity { + if ($exists === false || $_rbac === true) { + throw new DoesNotExistException('Object not found in magic table'); + } + + $entity = new ObjectEntity(); + $entity->setUuid((string) $identifier); + $entity->setOwner('agent'); + $entity->setStatus($status); + + return $entity; + } + ); + + $this->saveHandler->method('saveObject')->willReturnCallback( + function (mixed ...$arguments): ObjectEntity { + $this->saves[] = $arguments; + $entity = new ObjectEntity(); + $entity->setUuid('draft-1'); + + return $entity; + } + ); + }//end hiddenFromTheCaller() + + /** + * Save over the uuid, the way PATCH and a POST that names an id do: RBAC on, the uuid given. + * + * @param bool $rbac Whether the save is judged for the signed-in caller. + * @param bool $draftsVisible Whether the schema lets others see its drafts. + * + * @return void + */ + private function saveOver(bool $rbac = true, bool $draftsVisible = false): void { + [$register, $schema] = $this->scope(draftsVisible: $draftsVisible); + $this->service->saveObject( + object: ['subject' => 'changed by somebody else'], + register: $register, + schema: $schema, + uuid: 'draft-1', + _rbac: $rbac, + _multitenancy: $rbac + ); + }//end saveOver() + + /** + * Somebody else's draft is not written, and nothing reaches the save handler. + * + * @dataProvider notTheOwner + * + * @param string|null $uid The caller. + * + * @return void + */ + public function testSomebodyElsesDraftIsNotWritten(?string $uid): void { + $this->signedIn(uid: $uid); + $this->hiddenFromTheCaller(status: 'draft'); + + try { + $this->saveOver(); + $this->fail('A save over somebody else\'s draft was let through.'); + } catch (NotAuthorizedException $refused) { + $this->assertSame('Only the person who started this draft can change it.', $refused->getMessage()); + } + + $this->assertSame([], $this->saves); + }//end testSomebodyElsesDraftIsNotWritten() + + /** + * Callers who are not the draft's owner. + * + * @return array + */ + public static function notTheOwner(): array { + return [ + 'a colleague' => ['colleague'], + 'nobody signed in' => [null], + ]; + }//end notTheOwner() + + /** + * Control: a uuid nobody stored is a create under a chosen uuid, as before. + * + * @return void + */ + public function testAChosenUuidNobodyStoredIsStillACreate(): void { + $this->signedIn(uid: 'colleague'); + $this->hiddenFromTheCaller(status: null, exists: false); + + $this->saveOver(); + + $this->assertCount(1, $this->saves); + }//end testAChosenUuidNobodyStoredIsStillACreate() + + /** + * Control: the rule is about drafts. A stored object that is not a draft is judged as it was. + * + * @return void + */ + public function testAHiddenObjectThatIsNoDraftIsJudgedAsBefore(): void { + $this->signedIn(uid: 'colleague'); + $this->hiddenFromTheCaller(status: 'active'); + + $this->saveOver(); + + $this->assertCount(1, $this->saves); + }//end testAHiddenObjectThatIsNoDraftIsJudgedAsBefore() + + /** + * Control: with RBAC off (an administrator, a job, a scope-value owner) the caller's own check stands. + * + * @return void + */ + public function testWithRbacOffTheCallerDecides(): void { + $this->signedIn(uid: 'admin'); + $this->hiddenFromTheCaller(status: 'draft'); + + $this->saveOver(rbac: false); + + $this->assertCount(1, $this->saves); + }//end testWithRbacOffTheCallerDecides() + + /** + * Control: a schema that shows its drafts to others lets them be judged by its own rules. + * + * @return void + */ + public function testASchemaThatShowsItsDraftsKeepsItsOwnRules(): void { + $this->signedIn(uid: 'colleague'); + $this->hiddenFromTheCaller(status: 'draft'); + + $this->saveOver(draftsVisible: true); + + $this->assertCount(1, $this->saves); + }//end testASchemaThatShowsItsDraftsKeepsItsOwnRules() +}//end class From cabae918860913e3428e11c6c4169d3f8b1bdfdc Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 20:34:37 +0200 Subject: [PATCH 121/183] test(objects): the draft tests name every class they execute CI listed DraftExpiryJobTest, DraftExpiryTest and DraftRemovalAuditRowTest as risky for classes they run and did not declare. --- tests/Unit/BackgroundJob/DraftExpiryJobTest.php | 1 + tests/Unit/Db/DraftRemovalAuditRowTest.php | 3 +++ tests/Unit/Service/Object/DraftExpiryTest.php | 1 + tests/Unit/Service/ObjectServiceDraftWriteTest.php | 8 ++++++++ 4 files changed, 13 insertions(+) diff --git a/tests/Unit/BackgroundJob/DraftExpiryJobTest.php b/tests/Unit/BackgroundJob/DraftExpiryJobTest.php index 9037639904..4bcfb43a3a 100644 --- a/tests/Unit/BackgroundJob/DraftExpiryJobTest.php +++ b/tests/Unit/BackgroundJob/DraftExpiryJobTest.php @@ -45,6 +45,7 @@ * @uses \OCA\OpenRegister\Db\ObjectEntity * @uses \OCA\OpenRegister\Db\Register * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Db\AuditTrail */ class DraftExpiryJobTest extends TestCase { diff --git a/tests/Unit/Db/DraftRemovalAuditRowTest.php b/tests/Unit/Db/DraftRemovalAuditRowTest.php index 31b410d1a5..0d05e23336 100644 --- a/tests/Unit/Db/DraftRemovalAuditRowTest.php +++ b/tests/Unit/Db/DraftRemovalAuditRowTest.php @@ -43,6 +43,9 @@ * @uses \OCA\OpenRegister\Db\Register * @uses \OCA\OpenRegister\Db\Schema * @uses \OCA\OpenRegister\Service\AuditHashService + * @uses \OCA\OpenRegister\Db\AuditFlowAttribution + * @uses \OCA\OpenRegister\Service\Audit\PurposeAttribution + * @uses \OCA\OpenRegister\Service\Audit\TokenAttribution */ class DraftRemovalAuditRowTest extends TestCase { diff --git a/tests/Unit/Service/Object/DraftExpiryTest.php b/tests/Unit/Service/Object/DraftExpiryTest.php index 65b457b1cc..d9b2dd45c2 100644 --- a/tests/Unit/Service/Object/DraftExpiryTest.php +++ b/tests/Unit/Service/Object/DraftExpiryTest.php @@ -39,6 +39,7 @@ * @uses \OCA\OpenRegister\Db\ObjectEntity * @uses \OCA\OpenRegister\Db\Register * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Db\AuditTrail */ class DraftExpiryTest extends TestCase { diff --git a/tests/Unit/Service/ObjectServiceDraftWriteTest.php b/tests/Unit/Service/ObjectServiceDraftWriteTest.php index eb8c014e4e..205e091a54 100644 --- a/tests/Unit/Service/ObjectServiceDraftWriteTest.php +++ b/tests/Unit/Service/ObjectServiceDraftWriteTest.php @@ -91,6 +91,14 @@ * @uses \OCA\OpenRegister\Db\Register * @uses \OCA\OpenRegister\Db\Schema * @uses \OCA\OpenRegister\Exception\NotAuthorizedException + * @uses \OCA\OpenRegister\Db\View + * @uses \OCA\OpenRegister\Service\RegisterScopedSchemaResolver + * @uses \OCA\OpenRegister\Service\Rbac\HierarchyGrantExpander + * @uses \OCA\OpenRegister\Service\WritePhaseProbe + * @uses \OCA\OpenRegister\Service\Object\NotSuppliedHandler + * @uses \OCA\OpenRegister\Service\Object\RepeatingGroupValidator + * @uses \OCA\OpenRegister\Service\Geo\GeoJsonGeometryValidator + * @uses \OCA\OpenRegister\Service\Hinge\LensResolver * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ From b55b1df0aead841f8cbc938a8305609f2873fdfc Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 20:44:51 +0200 Subject: [PATCH 122/183] feat(objects): a generated identifier waits until the object leaves draft Decision 201: a draft gets its number only on leaving draft. The listener runs before the write, so the gate's events do not reach it. It now asks the gate what a creation is: a draft's first save takes no number, and the save that leaves draft issues it, as a create does. Seen on an instance: a draft and its autosave carried no number, the activation got the next one without a gap, and the number was frozen afterwards. --- docs/features/form-destination.md | 4 + lib/Listener/GeneratedIdentifierListener.php | 27 +++- lib/Service/Object/ObjectEventGate.php | 24 ++++ .../specs/object-events/spec.md | 21 +++- .../draft-creation-side-effects/tasks.md | 3 + .../GeneratedIdentifierListenerTest.php | 118 +++++++++++++++++- .../Service/Object/ObjectEventGateTest.php | 16 +++ 7 files changed, 207 insertions(+), 6 deletions(-) diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md index 3141c59287..c4869fa631 100644 --- a/docs/features/form-destination.md +++ b/docs/features/form-destination.md @@ -229,6 +229,10 @@ translation sidecar and cleanup. Do not listen to them from an app. `ObjectActivatedEvent` carries the moment of receipt. Do your creation work on `ObjectCreatedEvent` only, or it runs twice. +A generated identifier follows the same rule. A draft takes no number. The +save that leaves draft issues it, so a half-typed case leaves no gap in the +sequence. + ### Keep your own drafts out of a work list A list leaves out other people's drafts and keeps your own (decision 201). diff --git a/lib/Listener/GeneratedIdentifierListener.php b/lib/Listener/GeneratedIdentifierListener.php index 5409161352..6f900418db 100644 --- a/lib/Listener/GeneratedIdentifierListener.php +++ b/lib/Listener/GeneratedIdentifierListener.php @@ -40,6 +40,7 @@ use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Event\ObjectCreatingEvent; use OCA\OpenRegister\Event\ObjectUpdatingEvent; +use OCA\OpenRegister\Service\Object\ObjectEventGate; use OCA\OpenRegister\Service\Schemas\GeneratedIdentifierDeclaration; use OCA\OpenRegister\Service\SequenceService; use OCP\EventDispatcher\Event; @@ -67,30 +68,48 @@ class GeneratedIdentifierListener implements IEventListener { * @param SchemaMapper $schemaMapper Resolves the object's schema. * @param SequenceService $sequences Reserves a number, atomically. * @param LoggerInterface $logger PSR logger. + * @param ObjectEventGate $gate Decides what a draft is held from. */ public function __construct( private readonly SchemaMapper $schemaMapper, private readonly SequenceService $sequences, private readonly LoggerInterface $logger, + private readonly ObjectEventGate $gate, ) { }//end __construct() /** * Fill on create, refuse a change on update. * + * A draft is not created yet (decisions 201 and 227): it takes no number + * on its first save, and the save that leaves draft is its creation, so + * the number is issued there. The gate that holds a draft's creation side + * effects answers both questions, so "is this a creation" is decided in + * one place before a write as after it. + * * @param Event $event Dispatched event. * * @return void * * @spec openspec/changes/generated-identifier/specs/computed-fields/spec.md#requirement-a-property-declares-a-generated-identifier-from-a-sequence-and-a-format + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-a-generated-identifier-must-be-issued-when-the-object-leaves-draft */ public function handle(Event $event): void { if ($event instanceof ObjectCreatingEvent) { - $this->fill(event: $event); + if ($this->gate->holdsCreation(object: $event->getObject()) === false) { + $this->fill(object: $event->getObject(), event: $event); + } + return; } if ($event instanceof ObjectUpdatingEvent) { + if ($this->gate->leavesDraft(new: $event->getNewObject(), old: $event->getOldObject()) === true) { + $this->fill(object: $event->getNewObject(), event: $event); + + return; + } + $this->freeze(event: $event); } @@ -107,14 +126,14 @@ public function handle(Event $event): void { * without the number it was declared to carry is a record somebody will * quote in a letter, so the create is refused and says why. * - * @param ObjectCreatingEvent $event The create being prepared. + * @param ObjectEntity $object The object being created, or leaving draft. + * @param ObjectCreatingEvent|ObjectUpdatingEvent $event The write being prepared; it carries a refusal. * * @return void * * @spec openspec/changes/generated-identifier/specs/computed-fields/spec.md#requirement-a-property-declares-a-generated-identifier-from-a-sequence-and-a-format */ - private function fill(ObjectCreatingEvent $event): void { - $object = $event->getObject(); + private function fill(ObjectEntity $object, ObjectCreatingEvent|ObjectUpdatingEvent $event): void { $schema = $this->loadSchema(object: $object); if ($schema === null) { return; diff --git a/lib/Service/Object/ObjectEventGate.php b/lib/Service/Object/ObjectEventGate.php index 6ef4a2c3f8..e7f741ab02 100644 --- a/lib/Service/Object/ObjectEventGate.php +++ b/lib/Service/Object/ObjectEventGate.php @@ -92,6 +92,30 @@ public function created(ObjectEntity $object): void { $this->events->dispatchTyped(new ObjectCreatedEvent(object: $object)); }//end created() + /** + * Whether a write that stores this object for the first time is held from creation work: it is a draft. + * For a listener that runs BEFORE the write and does creation work there + * (issuing a number). It asks here, so before a write and after it the + * same class decides what a creation is. + * @param ObjectEntity $object The object about to be stored. + * @return bool True for a draft. + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-a-generated-identifier-must-be-issued-when-the-object-leaves-draft + */ + public function holdsCreation(ObjectEntity $object): bool { + return $object->isDraft() === true; + }//end holdsCreation() + + /** + * Whether a save takes a stored draft out of draft: the moment its held creation work runs. + * @param ObjectEntity $new The object as it is about to be stored. + * @param ObjectEntity|null $old The object as stored, when the caller knows it. + * @return bool True when the stored object is a draft and the new one is not. + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-a-generated-identifier-must-be-issued-when-the-object-leaves-draft + */ + public function leavesDraft(ObjectEntity $new, ?ObjectEntity $old): bool { + return $old !== null && $old->isDraft() === true && $new->isDraft() === false; + }//end leavesDraft() + /** * A stored object was saved again. * diff --git a/openspec/changes/draft-creation-side-effects/specs/object-events/spec.md b/openspec/changes/draft-creation-side-effects/specs/object-events/spec.md index 8663886acd..75a9fff074 100644 --- a/openspec/changes/draft-creation-side-effects/specs/object-events/spec.md +++ b/openspec/changes/draft-creation-side-effects/specs/object-events/spec.md @@ -59,7 +59,7 @@ The listeners that run before a write (`ObjectCreatingEvent`, `ObjectUpdatingEve ### Requirement: A draft's updates and delete MUST stay on the draft path -A save of an object that is still a draft SHALL dispatch `DraftUpdatedEvent` and no `ObjectUpdatedEvent`: an autosave is not a change anybody was told about. A delete of a draft SHALL dispatch `DraftDeletedEvent` and no `ObjectDeletedEvent`: a listener that never saw the object created is not told it is gone. That holds for the owner's discard and for the draft cleanup. The audit-trail entry of a removed draft (`draft.removed`) is written by the delete itself and does not depend on an event. +A save of an object that is still a draft SHALL dispatch `DraftUpdatedEvent` and no `ObjectUpdatedEvent`: an autosave is not a change anybody was told about. A delete of a draft SHALL dispatch `DraftDeletedEvent` and no `ObjectDeletedEvent`: a listener that never saw the object created is not told it is gone. That holds for the owner's discard and for the draft cleanup. This is deliberate and it is the whole rule for a draft's delete: no side-effect listener is told, on any path, and no "deleted" webhook, flow, activity entry or push follows a draft that was never announced. The audit-trail entry of a removed draft (`draft.removed`) is written by the delete itself and does not depend on an event. #### Scenario: An autosave and a discard reach no side effect @e2e exclude unit; ObjectEventGateTest covers both, with an ordinary object's update and delete as control @@ -68,6 +68,25 @@ A save of an object that is still a draft SHALL dispatch `DraftUpdatedEvent` and - **WHEN** a draft ticket is autosaved and then discarded - **THEN** neither webhook runs +### Requirement: A generated identifier MUST be issued when the object leaves draft + +A draft SHALL NOT take a number from a sequence (decision 201): `GeneratedIdentifierListener` fills nothing and moves no counter on a draft's first save or on a later save that keeps it a draft. The save that takes a stored draft out of draft SHALL issue the number, the way a create does: an empty declared property is filled, a value the object already carries is kept and the counter moves past it, and a counter that cannot answer refuses the save. From then on the number is frozen, as for every object. The listener runs before the write, so it does not receive the gate's events. It SHALL ask `ObjectEventGate` (`holdsCreation()`, `leavesDraft()`) what a creation is, so one class decides that before a write and after it. + +#### Scenario: A half-typed case burns no number +@e2e exclude unit; GeneratedIdentifierListenerTest counts the calls to the sequence service on a draft's first save and on its autosave + +- **GIVEN** a schema whose `identifier` is generated from the sequence `case` +- **WHEN** a draft is saved, and saved again +- **THEN** it has no identifier and the sequence was not asked + +#### Scenario: The number is issued on leaving draft +@e2e exclude unit; GeneratedIdentifierListenerTest covers the save that leaves draft, a number the draft already carried, a counter that is down, and the freeze afterwards as control; ObjectEventGateTest covers the two answers + +- **GIVEN** that draft, and the next free number 7 +- **WHEN** its owner makes it definitive +- **THEN** the object is stored with the identifier that number 7 renders to +- **AND** a later save that tries to change the identifier is refused + ### Requirement: The rule MUST be kept by a test, not by attention A test SHALL fail when a class other than `ObjectEventGate` creates `ObjectCreatedEvent`, `ObjectUpdatedEvent`, `ObjectDeletedEvent`, `ObjectActivatedEvent` or a draft event; when a listener that is not on the list above is registered on a draft event; when a class other than the gate and the registration block names a draft event; and when one listener is registered on both `ObjectCreatedEvent` and `ObjectActivatedEvent`. The test SHALL read the real registration code, not a copy of it. diff --git a/openspec/changes/draft-creation-side-effects/tasks.md b/openspec/changes/draft-creation-side-effects/tasks.md index c6c796e1d2..eaa83efd4d 100644 --- a/openspec/changes/draft-creation-side-effects/tasks.md +++ b/openspec/changes/draft-creation-side-effects/tasks.md @@ -19,6 +19,9 @@ - Test: ObjectEventRuleTest (the registered listeners equal the list) - [x] 2.2 `AnnotationNotificationListener` loses its own draft check and its registration on `ObjectActivatedEvent`; it is an instance of the rule now - Test: AnnotationNotificationListenerTest (as on development), ObjectEventRuleTest (no listener on both events) +- [x] 2.3 `GeneratedIdentifierListener` asks the gate: a draft takes no number, the save that leaves draft issues it (decision 201) + - Spec ref: specs/object-events/spec.md, "A generated identifier MUST be issued when the object leaves draft" + - Test: GeneratedIdentifierListenerTest (four new cases), ObjectEventGateTest::testTheGateSaysWhatACreationIsBeforeAWrite ## 3. Kept by a test diff --git a/tests/Unit/Listener/GeneratedIdentifierListenerTest.php b/tests/Unit/Listener/GeneratedIdentifierListenerTest.php index 603e85ab9d..bf6c219f76 100644 --- a/tests/Unit/Listener/GeneratedIdentifierListenerTest.php +++ b/tests/Unit/Listener/GeneratedIdentifierListenerTest.php @@ -25,9 +25,11 @@ use OCA\OpenRegister\Event\ObjectCreatingEvent; use OCA\OpenRegister\Event\ObjectUpdatingEvent; use OCA\OpenRegister\Listener\GeneratedIdentifierListener; +use OCA\OpenRegister\Service\Object\ObjectEventGate; use OCA\OpenRegister\Service\Schemas\GeneratedIdentifierDeclaration; use OCA\OpenRegister\Service\SequenceService; use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -78,7 +80,9 @@ protected function setUp(): void { $this->listener = new GeneratedIdentifierListener( $this->schemaMapper, $this->sequences, - $this->createMock(originalClassName: LoggerInterface::class) + $this->createMock(originalClassName: LoggerInterface::class), + // The real gate: it decides what a draft is held from, here as after a write. + new ObjectEventGate($this->createMock(originalClassName: IEventDispatcher::class)) ); }//end setUp() @@ -411,6 +415,118 @@ public function testAnUpdateNeverTakesANumber(): void { }//end testAnUpdateNeverTakesANumber() + /** + * An object in a given status, carrying a body and a schema. + * + * @param string|null $status The stored status. + * @param array $body The object's data. + * + * @return ObjectEntity The object. + */ + private function objectIn(?string $status, array $body): ObjectEntity { + $object = $this->objectWith(body: $body); + $object->setStatus($status); + + return $object; + + }//end objectIn() + + /** + * A draft takes no number: not on its first save, and not on a later one (decision 201). + * + * @return void + */ + public function testADraftTakesNoNumber(): void { + $this->schemaWith(properties: ['identifier' => $this->declaredProperty()]); + $this->sequences->expects($this->never())->method('reserveNext'); + $this->sequences->expects($this->never())->method('advanceTo'); + + $draft = $this->objectIn(status: 'draft', body: ['title' => 'Half typed']); + $this->listener->handle(new ObjectCreatingEvent($draft)); + + $this->assertArrayNotHasKey(key: 'identifier', array: $draft->getObject()); + + $later = $this->objectIn(status: 'draft', body: ['title' => 'Typed a little more']); + $this->listener->handle(new ObjectUpdatingEvent($later, $draft)); + + $this->assertArrayNotHasKey(key: 'identifier', array: $later->getObject()); + + }//end testADraftTakesNoNumber() + + /** + * The save that leaves draft issues the number, once, and keeps the rest of the body. + * + * @return void + */ + public function testLeavingDraftIssuesTheNumber(): void { + $this->schemaWith(properties: ['identifier' => $this->declaredProperty()]); + $this->sequences->expects($this->once())->method('reserveNext')->willReturn(7); + + $draft = $this->objectIn(status: 'draft', body: ['title' => 'A case', 'identifier' => null]); + $active = $this->objectIn(status: 'active', body: ['title' => 'A case', 'identifier' => null]); + $event = new ObjectUpdatingEvent($active, $draft); + + $this->listener->handle($event); + + $this->assertFalse(condition: $event->isPropagationStopped()); + $this->assertMatchesRegularExpression( + pattern: '/^Z-\d{4}-00007$/', + string: (string)($active->getObject()['identifier'] ?? '') + ); + $this->assertSame(expected: 'A case', actual: ($active->getObject()['title'] ?? null)); + + // Control: from here on it is an ordinary object. Its next save takes + // no number, and its number is frozen. + $issued = $active->getObject()['identifier']; + $next = $this->objectIn(status: 'active', body: ['title' => 'A case', 'identifier' => 'Z-1999-00001']); + $refused = new ObjectUpdatingEvent($next, $active); + $this->listener->handle($refused); + + $this->assertTrue(condition: $refused->isPropagationStopped()); + $this->assertSame(expected: $issued, actual: ($refused->getErrors()['issued'] ?? null)); + + }//end testLeavingDraftIssuesTheNumber() + + /** + * A number the draft already carried is kept on leaving draft, and the counter moves past it, as on a create. + * + * @return void + */ + public function testLeavingDraftKeepsASuppliedNumber(): void { + $this->schemaWith(properties: ['identifier' => $this->declaredProperty()]); + $this->sequences->expects($this->never())->method('reserveNext'); + $this->sequences->expects($this->once())->method('advanceTo'); + $year = date(format: 'Y'); + + $draft = $this->objectIn(status: 'draft', body: ['identifier' => 'Z-' . $year . '-00042']); + $active = $this->objectIn(status: 'active', body: ['identifier' => 'Z-' . $year . '-00042']); + + $this->listener->handle(new ObjectUpdatingEvent($active, $draft)); + + $this->assertSame(expected: 'Z-' . $year . '-00042', actual: $active->getObject()['identifier']); + + }//end testLeavingDraftKeepsASuppliedNumber() + + /** + * When the counter is unavailable, the save that leaves draft is refused, as a create is. + * + * @return void + */ + public function testAnUnavailableCounterRefusesLeavingDraft(): void { + $this->schemaWith(properties: ['identifier' => $this->declaredProperty()]); + $this->sequences->method('reserveNext')->willThrowException(new \RuntimeException('counter down')); + + $event = new ObjectUpdatingEvent( + $this->objectIn(status: 'active', body: ['title' => 'A case']), + $this->objectIn(status: 'draft', body: ['title' => 'A case']) + ); + $this->listener->handle($event); + + $this->assertTrue(condition: $event->isPropagationStopped()); + $this->assertSame(expected: 'generated-identifier-unavailable', actual: ($event->getErrors()['code'] ?? null)); + + }//end testAnUnavailableCounterRefusesLeavingDraft() + /** * Some other event is not this listener's business. * diff --git a/tests/Unit/Service/Object/ObjectEventGateTest.php b/tests/Unit/Service/Object/ObjectEventGateTest.php index 158a116ad3..76afeed018 100644 --- a/tests/Unit/Service/Object/ObjectEventGateTest.php +++ b/tests/Unit/Service/Object/ObjectEventGateTest.php @@ -176,4 +176,20 @@ public function testADraftEventIsReadLikeItsCounterpart(): void { $this->assertSame($old, $updated->getOldObject()); $this->assertInstanceOf(ObjectUpdatedEvent::class, $updated); }//end testADraftEventIsReadLikeItsCounterpart() + + /** + * Before a write the gate answers the same question it answers after one: is this a creation. + */ + public function testTheGateSaysWhatACreationIsBeforeAWrite(): void { + $this->assertTrue($this->gate->holdsCreation(object: $this->object(status: 'draft'))); + $this->assertFalse($this->gate->holdsCreation(object: $this->object(status: 'active'))); + $this->assertFalse($this->gate->holdsCreation(object: $this->object(status: null))); + + $this->assertTrue($this->gate->leavesDraft(new: $this->object(status: 'active'), old: $this->object(status: 'draft'))); + // Controls: an autosave, an ordinary save, and a save whose stored object is not known. + $this->assertFalse($this->gate->leavesDraft(new: $this->object(status: 'draft'), old: $this->object(status: 'draft'))); + $this->assertFalse($this->gate->leavesDraft(new: $this->object(status: 'active'), old: $this->object(status: 'active'))); + $this->assertFalse($this->gate->leavesDraft(new: $this->object(status: 'active'), old: null)); + $this->assertSame([], $this->received); + }//end testTheGateSaysWhatACreationIsBeforeAWrite() }//end class From e5d87993ad5f3597d8a502c91f1e7763c98a7544 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 23:39:37 +0200 Subject: [PATCH 123/183] fix(migration-packs): one error envelope without exception text, split the request handling Every error from the migration-pack endpoints now answers the shared ADR-050 shape {message, error, details?} with static, translated text; no response carries an exception message. Request parsing and preview validation move to MigrationPackRequestReader, error responses and logging to MigrationPackErrorResponder, so the controller passes phpmd without new thresholds, baseline entries or suppressions. - write endpoints are admin-only through Nextcloud's default middleware (no AuthorizedAdminSetting), with the in-body check kept - import reports created or replaced from the write itself - preview caps the request at 1 MiB besides the row count - a raw-body update whose id equals the route id is refused with a pointer to the definition envelope - a deleted built-in slug is remembered before the row goes, and seeding a built-in pack takes its slug off the deleted list - validator row numbers count from 1, matching the editor table Closes review #4602 be-f1, be-f2, be-f3, be-f4, be-f5, be-f6, be-f7, ci-f1. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/Controller/MigrationPacksController.php | 434 +++++------------- .../BuiltinPackSlugLockedException.php | 20 +- .../InvalidPackDefinitionException.php | 60 +++ .../MigrationPackRefusedException.php | 52 +++ .../MigrationPackRequestException.php | 75 +++ .../MigrationPackErrorResponder.php | 211 +++++++++ .../MigrationPackImportResult.php | 49 ++ .../MigrationPackRequestReader.php | 286 ++++++++++++ .../MigrationPack/PackDefinitionValidator.php | 26 +- lib/Service/MigrationPackService.php | 78 +++- .../MigrationPacksControllerTest.php | 317 ++++++++++++- .../PackDefinitionValidatorTest.php | 42 +- .../Unit/Service/MigrationPackServiceTest.php | 77 +++- 13 files changed, 1342 insertions(+), 385 deletions(-) create mode 100644 lib/Exception/InvalidPackDefinitionException.php create mode 100644 lib/Exception/MigrationPackRefusedException.php create mode 100644 lib/Exception/MigrationPackRequestException.php create mode 100644 lib/Service/MigrationPack/MigrationPackErrorResponder.php create mode 100644 lib/Service/MigrationPack/MigrationPackImportResult.php create mode 100644 lib/Service/MigrationPack/MigrationPackRequestReader.php diff --git a/lib/Controller/MigrationPacksController.php b/lib/Controller/MigrationPacksController.php index 14a9f7e4f0..25ebebc655 100644 --- a/lib/Controller/MigrationPacksController.php +++ b/lib/Controller/MigrationPacksController.php @@ -9,15 +9,19 @@ * to resolve a pack for any user with manage-permission on the target * register, not just admins. Mutations (`create`/`update`/`destroy`/`import`) * and the `preview` dry run are restricted to Nextcloud administrators and - * require a CSRF token. `#[AuthorizedAdminSetting]` keeps anyone outside the - * OpenRegister admin section out at the middleware; the body check then - * narrows that to Nextcloud administrators, because packs drive imports that - * write without RBAC (for example stackiq's CMDB import). + * require a CSRF token. They carry no auth attribute, so Nextcloud's own + * middleware already admits administrators only; a user an administrator + * delegated the OpenRegister settings section to is not admitted, because + * packs drive imports that write without RBAC (for example stackiq's CMDB + * import). The body check repeats the admin test as defence in depth. * * Writes that address a pack by id take that id from the route only. Nextcloud * merges a JSON body over the URL parameters, so a body `id` (which in a pack * document is the slug) would otherwise replace the route id. * + * Every error answers `{"message", "error", "details"?}` (ADR-050), built by + * MigrationPackErrorResponder. + * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. * @@ -39,22 +43,21 @@ namespace OCA\OpenRegister\Controller; -use InvalidArgumentException; -use OCA\OpenRegister\Exception\BuiltinPackSlugLockedException; +use OCA\OpenRegister\Exception\MigrationPackRefusedException; +use OCA\OpenRegister\Service\MigrationPack\MigrationPackErrorResponder; +use OCA\OpenRegister\Service\MigrationPack\MigrationPackRequestReader; use OCA\OpenRegister\Service\MigrationPackService; -use OCA\OpenRegister\Settings\OpenRegisterAdmin; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; -use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\JSONResponse; use OCP\IGroupManager; -use OCP\IL10N; use OCP\IRequest; use OCP\IUserSession; use Psr\Log\LoggerInterface; +use Throwable; /** * MigrationPacksController handles CRUD + JSON import/export for migration packs. @@ -67,7 +70,7 @@ class MigrationPacksController extends Controller { * * @var int */ - public const MAX_PREVIEW_ROWS = 20; + public const MAX_PREVIEW_ROWS = MigrationPackRequestReader::MAX_PREVIEW_ROWS; /** * Constructor. @@ -77,8 +80,11 @@ class MigrationPacksController extends Controller { * @param MigrationPackService $service Business logic. * @param IUserSession $userSession Current-user session. * @param IGroupManager $groupManager Group manager (admin check). - * @param LoggerInterface $logger Logger. - * @param IL10N $l10n Translator for error messages. + * @param LoggerInterface $logger Logger for the audit lines. + * @param MigrationPackRequestReader $requestReader Reads definitions, uploads and preview input. + * @param MigrationPackErrorResponder $errors Builds every error response. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged */ public function __construct( string $appName, @@ -87,7 +93,8 @@ public function __construct( private readonly IUserSession $userSession, private readonly IGroupManager $groupManager, private readonly LoggerInterface $logger, - private readonly IL10N $l10n, + private readonly MigrationPackRequestReader $requestReader, + private readonly MigrationPackErrorResponder $errors, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() @@ -107,141 +114,24 @@ private function resolveUserId(): ?string { }//end resolveUserId() /** - * Whether the current user is a Nextcloud administrator. + * The refusal for a caller who may not change packs, or null for an administrator. * - * @return bool - */ - private function isCurrentUserAdmin(): bool { - $user = $this->userSession->getUser(); - if ($user === null) { - return false; - } - - return $this->groupManager->isAdmin($user->getUID()); - }//end isCurrentUserAdmin() - - /** - * Build a 401 response for anonymous callers. - * - * @return JSONResponse - */ - private function authRequiredResponse(): JSONResponse { - return new JSONResponse(data: ['error' => 'Authentication required'], statusCode: 401); - }//end authRequiredResponse() - - /** - * Build a 403 response for non-admin callers on an admin-only action. - * - * @return JSONResponse - */ - private function adminRequiredResponse(): JSONResponse { - return new JSONResponse(data: ['error' => 'Administrator privileges required'], statusCode: 403); - }//end adminRequiredResponse() - - /** - * The parameters the router extracted from the URL, without the body. - * - * IRequest declares `urlParams` with `@property-read`; the isset() lets a - * request double that does not carry it fall back to no route values. - * - * @return array - * - * @psalm-suppress NoInterfaceProperties `urlParams` is a documented read-only property of IRequest. - */ - private function routeParams(): array { - if (isset($this->request->urlParams) === false) { - return []; - } - - return $this->request->urlParams; - }//end routeParams() - - /** - * The pack row id from the route, falling back to the dispatched argument. - * - * The dispatched `$id` is read from the merged parameters, where a body - * `id` wins; a pack slug there is cast to 0. The route value does not - * have that problem. - * - * @param int $id The id the dispatcher passed in. - * - * @return int - */ - private function resolveRouteId(int $id): int { - $routeId = (string)($this->routeParams()['id'] ?? ''); - if (ctype_digit($routeId) === true) { - return (int)$routeId; - } - - return $id; - }//end resolveRouteId() - - /** - * Read the pack definition from the request body. - * - * Accepts the envelope `{"definition": {...}}`, taken as is, or the pack - * document as the whole body. In the second form, router keys (`_route` - * and other `_`-prefixed keys) are dropped, and so is an `id` that is only - * the route id merged in by Nextcloud rather than a slug from the body. - * - * @return array - */ - private function definitionFromRequest(): array { - $params = $this->request->getParams(); - if (is_array($params['definition'] ?? null) === true) { - return $params['definition']; - } - - foreach (array_keys($params) as $key) { - if (str_starts_with((string)$key, '_') === true) { - unset($params[$key]); - } - } - - $routeParams = $this->routeParams(); - if (array_key_exists('id', $routeParams) === true - && array_key_exists('id', $params) === true - && (string)$params['id'] === (string)$routeParams['id'] - ) { - unset($params['id']); - } - - return $params; - }//end definitionFromRequest() - - /** - * Whether a pack with the slug of the given definition is stored. - * - * @param array $definition The pack definition document. - * - * @return bool + * @return JSONResponse|null 401 when nobody is signed in, 403 for a non-admin. * * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged */ - private function packSlugIsStored(array $definition): bool { - if (is_string($definition['id'] ?? null) === false) { - return false; + private function refuseNonAdmin(): ?JSONResponse { + $userId = $this->resolveUserId(); + if ($userId === null) { + return $this->errors->notAuthenticated(); } - try { - $this->service->findByPackSlug(packSlug: $definition['id']); - return true; - } catch (DoesNotExistException $e) { - return false; + if ($this->groupManager->isAdmin($userId) === false) { + return $this->errors->adminRequired(); } - }//end packSlugIsStored() - /** - * Build a 400 response for a malformed preview request. - * - * @param string $message Translated, human-readable reason. - * @param string $error Machine-readable error code. - * - * @return JSONResponse - */ - private function badRequestResponse(string $message, string $error): JSONResponse { - return new JSONResponse(data: ['message' => $message, 'error' => $error], statusCode: 400); - }//end badRequestResponse() + return null; + }//end refuseNonAdmin() /** * List every migration pack. Any authenticated user may browse packs to @@ -267,9 +157,8 @@ private function badRequestResponse(string $message, string $error): JSONRespons #[NoAdminRequired] #[NoCSRFRequired] public function index(): JSONResponse { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->authRequiredResponse(); + if ($this->resolveUserId() === null) { + return $this->errors->notAuthenticated(); } $packs = $this->service->findAll(); @@ -302,15 +191,14 @@ public function index(): JSONResponse { #[NoAdminRequired] #[NoCSRFRequired] public function show(int $id): JSONResponse { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->authRequiredResponse(); + if ($this->resolveUserId() === null) { + return $this->errors->notAuthenticated(); } try { $pack = $this->service->find(id: $id); } catch (DoesNotExistException $e) { - return new JSONResponse(data: ['error' => 'Migration pack not found'], statusCode: 404); + return $this->errors->notFound(); } return new JSONResponse(data: $pack->jsonSerialize()); @@ -339,15 +227,14 @@ public function show(int $id): JSONResponse { #[NoAdminRequired] #[NoCSRFRequired] public function export(int $id): JSONResponse|DataDownloadResponse { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->authRequiredResponse(); + if ($this->resolveUserId() === null) { + return $this->errors->notAuthenticated(); } try { $pack = $this->service->find(id: $id); } catch (DoesNotExistException $e) { - return new JSONResponse(data: ['error' => 'Migration pack not found'], statusCode: 404); + return $this->errors->notFound(); } $json = json_encode($pack->getDefinitionArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @@ -359,58 +246,39 @@ public function export(int $id): JSONResponse|DataDownloadResponse { /** * Import a migration pack definition from an uploaded JSON file. Admin only. * Upserts by the document's own `id` — re-importing an updated pack - * updates the existing row. The log line says whether a pack was created - * or replaced. + * updates the existing row. The log line says whether the import created + * or replaced a pack, as reported by the write itself. * * @return JSONResponse * + * @auth admin-only Imports write instance-wide pack definitions that drive RBAC-free imports, so only + * Nextcloud administrators may call it; the body repeats the check. + * * @spec openspec/specs/migration-mapping-packs/spec.md#migration-packs-must-be-manageable-via-admin-gated-rest-crud-with-json-file-importexport */ - #[AuthorizedAdminSetting(settings: OpenRegisterAdmin::class)] public function import(): JSONResponse { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->authRequiredResponse(); + $refusal = $this->refuseNonAdmin(); + if ($refusal !== null) { + return $refusal; } - if ($this->isCurrentUserAdmin() === false) { - return $this->adminRequiredResponse(); - } - - $uploadedFile = $this->request->getUploadedFile('file'); - if (empty($uploadedFile['tmp_name']) === true) { - return new JSONResponse(data: ['error' => 'No file uploaded'], statusCode: 400); - } - - $raw = file_get_contents($uploadedFile['tmp_name']); - $decoded = null; - if ($raw !== false) { - $decoded = json_decode($raw, true); - } - - if (is_array($decoded) === false) { - return new JSONResponse(data: ['error' => 'Uploaded file is not a valid JSON object'], statusCode: 400); - } - - $replaced = $this->packSlugIsStored(definition: $decoded); + $userId = (string)$this->resolveUserId(); try { - $pack = $this->service->importDefinition(definition: $decoded, ownerUid: $userId); - } catch (InvalidArgumentException $e) { - return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 422); - } catch (\Throwable $e) { - $this->logger->error( - message: '[MigrationPacksController] Error importing migration pack: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__, 'trace' => $e->getTraceAsString()] - ); - return new JSONResponse(data: ['error' => 'Failed to import migration pack'], statusCode: 500); + $definition = $this->requestReader->uploadedDefinition(request: $this->request); + $result = $this->service->importDefinition(definition: $definition, ownerUid: $userId); + } catch (MigrationPackRefusedException $e) { + return $this->errors->refused(exception: $e); + } catch (Throwable $e) { + return $this->errors->unexpected(action: 'import', exception: $e); } - $outcome = 'created'; - if ($replaced === true) { - $outcome = 'replaced'; + $outcome = 'replaced'; + if ($result->created === true) { + $outcome = 'created'; } + $pack = $result->pack; $this->logger->info( message: '[MigrationPacksController] Migration pack "{packSlug}" (id {packId}) imported by {userId}: {outcome}', context: [ @@ -428,35 +296,31 @@ public function import(): JSONResponse { /** * Create a migration pack. Admin only. * - * The body is either `{"definition": {...}}` or the pack document itself. + * The body is either `{"definition": {...}}` or the pack document itself; + * see MigrationPackRequestReader::definition() for how the two differ. * * @return JSONResponse * + * @auth admin-only Creates an instance-wide pack definition that drives RBAC-free imports, so only + * Nextcloud administrators may call it; the body repeats the check. + * * @spec openspec/specs/migration-mapping-packs/spec.md#migration-packs-must-be-manageable-via-admin-gated-rest-crud-with-json-file-importexport */ - #[AuthorizedAdminSetting(settings: OpenRegisterAdmin::class)] public function create(): JSONResponse { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->authRequiredResponse(); + $refusal = $this->refuseNonAdmin(); + if ($refusal !== null) { + return $refusal; } - if ($this->isCurrentUserAdmin() === false) { - return $this->adminRequiredResponse(); - } - - $definition = $this->definitionFromRequest(); + $userId = (string)$this->resolveUserId(); try { - $pack = $this->service->create(definition: $definition, ownerUid: $userId); - } catch (InvalidArgumentException $e) { - return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 422); - } catch (\Throwable $e) { - $this->logger->error( - message: '[MigrationPacksController] Error creating migration pack: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__, 'trace' => $e->getTraceAsString()] - ); - return new JSONResponse(data: ['error' => 'Failed to create migration pack'], statusCode: 500); + $definition = $this->requestReader->definition(request: $this->request); + $pack = $this->service->create(definition: $definition, ownerUid: $userId); + } catch (MigrationPackRefusedException $e) { + return $this->errors->refused(exception: $e); + } catch (Throwable $e) { + return $this->errors->unexpected(action: 'create', exception: $e); } $this->logger->info( @@ -483,47 +347,33 @@ public function create(): JSONResponse { * * @return JSONResponse * + * @auth admin-only Changes an instance-wide pack definition that drives RBAC-free imports, so only + * Nextcloud administrators may call it; the body repeats the check. + * * @spec openspec/specs/migration-mapping-packs/spec.md#migration-packs-must-be-manageable-via-admin-gated-rest-crud-with-json-file-importexport */ - #[AuthorizedAdminSetting(settings: OpenRegisterAdmin::class)] public function update(int $id): JSONResponse { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->authRequiredResponse(); + $refusal = $this->refuseNonAdmin(); + if ($refusal !== null) { + return $refusal; } - if ($this->isCurrentUserAdmin() === false) { - return $this->adminRequiredResponse(); - } - - $id = $this->resolveRouteId(id: $id); - $definition = $this->definitionFromRequest(); + $id = $this->requestReader->routeId(request: $this->request, dispatchedId: $id); try { - $pack = $this->service->update(id: $id, definition: $definition); + $definition = $this->requestReader->definition(request: $this->request); + $pack = $this->service->update(id: $id, definition: $definition); } catch (DoesNotExistException $e) { - return new JSONResponse(data: ['error' => 'Migration pack not found'], statusCode: 404); - } catch (BuiltinPackSlugLockedException $e) { - return new JSONResponse( - data: [ - 'message' => $this->l10n->t('The id of a built-in migration pack cannot be changed'), - 'error' => 'builtin-slug-locked', - ], - statusCode: 422 - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 422); - } catch (\Throwable $e) { - $this->logger->error( - message: '[MigrationPacksController] Error updating migration pack: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__, 'id' => $id, 'trace' => $e->getTraceAsString()] - ); - return new JSONResponse(data: ['error' => 'Failed to update migration pack'], statusCode: 500); + return $this->errors->notFound(); + } catch (MigrationPackRefusedException $e) { + return $this->errors->refused(exception: $e); + } catch (Throwable $e) { + return $this->errors->unexpected(action: 'update', exception: $e, context: ['id' => $id]); } $this->logger->info( message: '[MigrationPacksController] Migration pack "{packSlug}" (id {packId}) updated by {userId}', - context: ['packSlug' => $pack->getPackSlug(), 'packId' => $id, 'userId' => $userId] + context: ['packSlug' => $pack->getPackSlug(), 'packId' => $id, 'userId' => $this->resolveUserId()] ); return new JSONResponse(data: $pack->jsonSerialize()); @@ -537,36 +387,30 @@ public function update(int $id): JSONResponse { * * @return JSONResponse * + * @auth admin-only Deletes an instance-wide pack definition that drives RBAC-free imports, so only + * Nextcloud administrators may call it; the body repeats the check. + * * @spec openspec/specs/migration-mapping-packs/spec.md#migration-packs-must-be-manageable-via-admin-gated-rest-crud-with-json-file-importexport */ - #[AuthorizedAdminSetting(settings: OpenRegisterAdmin::class)] public function destroy(int $id): JSONResponse { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->authRequiredResponse(); - } - - if ($this->isCurrentUserAdmin() === false) { - return $this->adminRequiredResponse(); + $refusal = $this->refuseNonAdmin(); + if ($refusal !== null) { + return $refusal; } - $id = $this->resolveRouteId(id: $id); + $id = $this->requestReader->routeId(request: $this->request, dispatchedId: $id); try { $pack = $this->service->delete(id: $id); } catch (DoesNotExistException $e) { - return new JSONResponse(data: ['error' => 'Migration pack not found'], statusCode: 404); - } catch (\Throwable $e) { - $this->logger->error( - message: '[MigrationPacksController] Error deleting migration pack: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__, 'id' => $id, 'trace' => $e->getTraceAsString()] - ); - return new JSONResponse(data: ['error' => 'Failed to delete migration pack'], statusCode: 500); + return $this->errors->notFound(); + } catch (Throwable $e) { + return $this->errors->unexpected(action: 'delete', exception: $e, context: ['id' => $id]); } $this->logger->info( message: '[MigrationPacksController] Migration pack "{packSlug}" (id {packId}) deleted by {userId}', - context: ['packSlug' => $pack->getPackSlug(), 'packId' => $id, 'userId' => $userId] + context: ['packSlug' => $pack->getPackSlug(), 'packId' => $id, 'userId' => $this->resolveUserId()] ); return new JSONResponse(data: null, statusCode: 204); @@ -576,83 +420,31 @@ public function destroy(int $id): JSONResponse { * Map sample rows through an unsaved pack definition. Admin only. * * Body: `{"definition": {...}, "rows": [{"": ""}, ...]}` - * with at most MAX_PREVIEW_ROWS rows. Returns + * with at most MAX_PREVIEW_ROWS rows and at most + * MigrationPackRequestReader::MAX_PREVIEW_BYTES in all. Returns * `{"rows": [{"rowNumber", "data", "errors"}]}` from the same mapping * engine an import uses. Nothing is stored. * * @return JSONResponse * - * @spec openspec/specs/migration-mapping-packs/spec.md#the-mapping-engine-must-apply-pack-transforms-per-row-and-never-leak-unresolved-references + * @auth admin-only Runs the mapping engine over caller-supplied input for the pack editor, which only + * Nextcloud administrators use; the body repeats the check. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it */ - #[AuthorizedAdminSetting(settings: OpenRegisterAdmin::class)] public function preview(): JSONResponse { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->authRequiredResponse(); - } - - if ($this->isCurrentUserAdmin() === false) { - return $this->adminRequiredResponse(); - } - - $definition = $this->request->getParam('definition'); - if (is_array($definition) === false) { - return $this->badRequestResponse( - message: $this->l10n->t('The request must contain a pack definition object'), - error: 'invalid-request' - ); - } - - $rows = $this->request->getParam('rows', []); - if (is_array($rows) === false || array_is_list($rows) === false) { - return $this->badRequestResponse( - message: $this->l10n->t('Sample rows must be a list of objects'), - error: 'invalid-request' - ); - } - - if (count($rows) > self::MAX_PREVIEW_ROWS) { - return $this->badRequestResponse( - message: $this->l10n->t('A preview takes at most %s sample rows', [self::MAX_PREVIEW_ROWS]), - error: 'too-many-rows' - ); - } - - foreach ($rows as $row) { - if (is_array($row) === false) { - return $this->badRequestResponse( - message: $this->l10n->t('Sample rows must be a list of objects'), - error: 'invalid-request' - ); - } - } - - $errors = $this->service->validateDefinition(definition: $definition); - if ($errors !== []) { - return new JSONResponse( - data: [ - 'message' => $this->l10n->t('The pack definition is invalid'), - 'error' => 'invalid-pack', - 'details' => ['errors' => $errors], - ], - statusCode: 422 - ); + $refusal = $this->refuseNonAdmin(); + if ($refusal !== null) { + return $refusal; } try { - $results = $this->service->previewRows(definition: $definition, rows: $rows); - } catch (\Throwable $e) { - $this->logger->error( - message: '[MigrationPacksController] Error previewing migration pack: ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__, 'trace' => $e->getTraceAsString()] - ); - return new JSONResponse( - data: [ - 'message' => $this->l10n->t('Failed to preview migration pack'), - 'error' => 'preview-failed', - ], - statusCode: 500 - ); + $input = $this->requestReader->previewInput(request: $this->request); + $results = $this->service->previewRows(definition: $input['definition'], rows: $input['rows']); + } catch (MigrationPackRefusedException $e) { + return $this->errors->refused(exception: $e); + } catch (Throwable $e) { + return $this->errors->unexpected(action: 'preview', exception: $e); } return new JSONResponse(data: ['rows' => $results]); diff --git a/lib/Exception/BuiltinPackSlugLockedException.php b/lib/Exception/BuiltinPackSlugLockedException.php index fd41f1062c..ec1b280b9f 100644 --- a/lib/Exception/BuiltinPackSlugLockedException.php +++ b/lib/Exception/BuiltinPackSlugLockedException.php @@ -25,8 +25,6 @@ namespace OCA\OpenRegister\Exception; -use InvalidArgumentException; - /** * An update refused because it renames a built-in migration pack. * @@ -34,9 +32,9 @@ * make that lookup miss, and the next seed run would create a second copy * under the original slug, so the slug of a built-in pack is fixed. * - * Extends InvalidArgumentException so callers that already translate an - * invalid definition into a 422 keep doing so; the controller catches this - * class first to return its own error code. + * Extends InvalidPackDefinitionException (and so InvalidArgumentException), + * so callers that already translate an invalid definition into a 422 keep + * doing so; the error responder tells it apart to return its own error code. * * @category Exception * @package OCA\OpenRegister\Exception @@ -51,5 +49,15 @@ * * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them */ -class BuiltinPackSlugLockedException extends InvalidArgumentException { +class BuiltinPackSlugLockedException extends InvalidPackDefinitionException { + /** + * Constructor. + * + * @param string $message Which built-in pack the update tried to rename. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-the-slug-of-a-built-in-pack-must-not-change + */ + public function __construct(string $message) { + parent::__construct(errors: [$message]); + }//end __construct() }//end class diff --git a/lib/Exception/InvalidPackDefinitionException.php b/lib/Exception/InvalidPackDefinitionException.php new file mode 100644 index 0000000000..0a2df2e8ef --- /dev/null +++ b/lib/Exception/InvalidPackDefinitionException.php @@ -0,0 +1,60 @@ + + * @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\Exception; + +/** + * A pack definition refused, with every reason as a separate entry. + * + * The controller answers it with 422 `invalid-pack` and hands the reasons to + * the client as `details.errors`, so the client never has to split the + * exception message. The message still joins the reasons, for logs and for + * callers that only know InvalidArgumentException (an ancestor). + * + * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + */ +class InvalidPackDefinitionException extends MigrationPackRefusedException { + /** + * Constructor. + * + * @param string[] $errors Every reason the definition was refused. + * + * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + */ + public function __construct(private readonly array $errors) { + parent::__construct(message: 'Invalid migration pack definition: ' . implode('; ', $errors)); + }//end __construct() + + /** + * Every reason the definition was refused. + * + * @return list + * + * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + */ + public function getErrors(): array { + return array_values($this->errors); + }//end getErrors() +}//end class diff --git a/lib/Exception/MigrationPackRefusedException.php b/lib/Exception/MigrationPackRefusedException.php new file mode 100644 index 0000000000..2857c915ce --- /dev/null +++ b/lib/Exception/MigrationPackRefusedException.php @@ -0,0 +1,52 @@ + + * @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\Exception; + +use InvalidArgumentException; + +/** + * A migration pack request refused because of what the caller sent. + * + * MigrationPacksController catches this one type and lets + * MigrationPackErrorResponder pick the status and error code from the + * concrete subclass: MigrationPackRequestException answers 400, any other + * refusal 422. Anything that is not a refusal is an unexpected failure and + * answered with 500. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ +abstract class MigrationPackRefusedException extends InvalidArgumentException { + /** + * The reasons for the refusal, one per entry, for `details.errors`. + * + * @return list Empty when the refusal has no itemised reasons. + * + * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + */ + public function getErrors(): array { + return []; + }//end getErrors() +}//end class diff --git a/lib/Exception/MigrationPackRequestException.php b/lib/Exception/MigrationPackRequestException.php new file mode 100644 index 0000000000..88a490fd24 --- /dev/null +++ b/lib/Exception/MigrationPackRequestException.php @@ -0,0 +1,75 @@ + + * @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\Exception; + +/** + * A malformed migration pack request, answered with HTTP 400. + * + * Carries the machine-readable error code and the translated sentence the + * caller is shown. Both are chosen where the request is read, from static + * text, so the response never carries anything a downstream library wrote. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it + */ +class MigrationPackRequestException extends MigrationPackRefusedException { + /** + * Constructor. + * + * @param string $errorCode Kebab-case code: `invalid-request` or `too-many-rows`. + * @param string $userMessage The translated reason, shown to the caller. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it + */ + public function __construct( + private readonly string $errorCode, + private readonly string $userMessage, + ) { + parent::__construct(message: $errorCode); + }//end __construct() + + /** + * The machine-readable error code. + * + * @return string + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it + */ + public function getErrorCode(): string { + return $this->errorCode; + }//end getErrorCode() + + /** + * The translated reason the caller is shown. + * + * @return string + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it + */ + public function getUserMessage(): string { + return $this->userMessage; + }//end getUserMessage() +}//end class diff --git a/lib/Service/MigrationPack/MigrationPackErrorResponder.php b/lib/Service/MigrationPack/MigrationPackErrorResponder.php new file mode 100644 index 0000000000..b448b1ac14 --- /dev/null +++ b/lib/Service/MigrationPack/MigrationPackErrorResponder.php @@ -0,0 +1,211 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\MigrationPack; + +use OCA\OpenRegister\Exception\BuiltinPackSlugLockedException; +use OCA\OpenRegister\Exception\MigrationPackRefusedException; +use OCA\OpenRegister\Exception\MigrationPackRequestException; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IL10N; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * One place for the shape of a migration pack error (ADR-050). + * + * Every error is `{"message", "error", "details"?}`: `message` is static text + * translated through IL10N, `error` a kebab-case code the client dispatches + * on, and `details.errors` the validator's reasons for an invalid pack. No + * exception message reaches a response; an unexpected failure is logged with + * its exception and answered with a static message. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ +class MigrationPackErrorResponder { + /** + * Constructor. + * + * @param IL10N $l10n Translates the messages. + * @param LoggerInterface $logger Records unexpected failures. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ + public function __construct( + private readonly IL10N $l10n, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * 401: nobody is signed in. + * + * @return JSONResponse + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ + public function notAuthenticated(): JSONResponse { + return $this->error( + status: 401, + error: 'not-authenticated', + message: $this->l10n->t('Authentication required') + ); + }//end notAuthenticated() + + /** + * 403: the caller is signed in but not a Nextcloud administrator. + * + * @return JSONResponse + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ + public function adminRequired(): JSONResponse { + return $this->error( + status: 403, + error: 'admin-required', + message: $this->l10n->t('Administrator privileges required') + ); + }//end adminRequired() + + /** + * 404: no pack has the requested row id. + * + * @return JSONResponse + * + * @spec openspec/specs/migration-mapping-packs/spec.md#migration-packs-must-be-manageable-via-admin-gated-rest-crud-with-json-file-importexport + */ + public function notFound(): JSONResponse { + return $this->error( + status: 404, + error: 'pack-not-found', + message: $this->l10n->t('Migration pack not found') + ); + }//end notFound() + + /** + * Answer a refusal the caller caused: 400 for a malformed request, 422 for a refused definition. + * + * @param MigrationPackRefusedException $exception The refusal. + * + * @return JSONResponse + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ + public function refused(MigrationPackRefusedException $exception): JSONResponse { + if ($exception instanceof MigrationPackRequestException) { + return $this->badRequest(exception: $exception); + } + + return $this->refusedDefinition(exception: $exception); + }//end refused() + + /** + * 400: the request is malformed before any pack is read. + * + * @param MigrationPackRequestException $exception The refusal, with its code and translated reason. + * + * @return JSONResponse + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it + */ + private function badRequest(MigrationPackRequestException $exception): JSONResponse { + return $this->error( + status: 400, + error: $exception->getErrorCode(), + message: $exception->getUserMessage() + ); + }//end badRequest() + + /** + * 422: the pack definition is refused. + * + * A renamed built-in pack answers `builtin-slug-locked`. Any other refusal + * answers `invalid-pack` with every reason in `details.errors`. + * + * @param MigrationPackRefusedException $exception The refusal, with its reasons. + * + * @return JSONResponse + * + * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + */ + private function refusedDefinition(MigrationPackRefusedException $exception): JSONResponse { + if ($exception instanceof BuiltinPackSlugLockedException) { + return $this->error( + status: 422, + error: 'builtin-slug-locked', + message: $this->l10n->t('The id of a built-in migration pack cannot be changed') + ); + } + + return new JSONResponse( + data: [ + 'message' => $this->l10n->t('The pack definition is invalid'), + 'error' => 'invalid-pack', + 'details' => ['errors' => $exception->getErrors()], + ], + statusCode: 422 + ); + }//end refusedDefinition() + + /** + * 500: log an unexpected failure and answer with a static message. + * + * @param 'import'|'create'|'update'|'delete'|'preview' $action What the caller tried to do. + * @param Throwable $exception The failure, logged in full. + * @param array $context Extra log context, such as the row id. + * + * @return JSONResponse + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ + public function unexpected(string $action, Throwable $exception, array $context = []): JSONResponse { + $this->logger->error( + message: '[MigrationPacksController] Error during migration pack ' . $action . ': ' . $exception->getMessage(), + context: array_merge($context, ['exception' => $exception]) + ); + + $message = match ($action) { + 'import' => $this->l10n->t('Failed to import migration pack'), + 'create' => $this->l10n->t('Failed to create migration pack'), + 'update' => $this->l10n->t('Failed to update migration pack'), + 'delete' => $this->l10n->t('Failed to delete migration pack'), + 'preview' => $this->l10n->t('Failed to preview migration pack'), + }; + + return $this->error(status: 500, error: 'migration-pack-error', message: $message); + }//end unexpected() + + /** + * Build one `{message, error}` response. + * + * @param int $status The HTTP status. + * @param string $error The kebab-case error code. + * @param string $message The translated message. + * + * @return JSONResponse + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ + private function error(int $status, string $error, string $message): JSONResponse { + return new JSONResponse(data: ['message' => $message, 'error' => $error], statusCode: $status); + }//end error() +}//end class diff --git a/lib/Service/MigrationPack/MigrationPackImportResult.php b/lib/Service/MigrationPack/MigrationPackImportResult.php new file mode 100644 index 0000000000..4e4af1d1fa --- /dev/null +++ b/lib/Service/MigrationPack/MigrationPackImportResult.php @@ -0,0 +1,49 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\MigrationPack; + +use OCA\OpenRegister\Db\MigrationPack; + +/** + * The stored pack and whether the import created it or replaced an existing one. + * + * Reported by the write itself, so the audit log line says what the service + * did rather than what a lookup before the write expected it to do. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ +final class MigrationPackImportResult { + /** + * Constructor. + * + * @param MigrationPack $pack The stored pack. + * @param bool $created True when the import inserted a new row, false when it replaced one. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged + */ + public function __construct( + public readonly MigrationPack $pack, + public readonly bool $created, + ) { + }//end __construct() +}//end class diff --git a/lib/Service/MigrationPack/MigrationPackRequestReader.php b/lib/Service/MigrationPack/MigrationPackRequestReader.php new file mode 100644 index 0000000000..67f09e4ced --- /dev/null +++ b/lib/Service/MigrationPack/MigrationPackRequestReader.php @@ -0,0 +1,286 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-create-and-update-must-accept-a-definition-envelope-and-keep-the-route-id + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\MigrationPack; + +use OCA\OpenRegister\Exception\InvalidPackDefinitionException; +use OCA\OpenRegister\Exception\MigrationPackRequestException; +use OCP\IL10N; +use OCP\IRequest; + +/** + * Turns a MigrationPacksController request into the input its service call needs. + * + * Every refusal here happens before a pack is read or written, and is thrown + * as an exception the controller answers in the shared error shape. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-create-and-update-must-accept-a-definition-envelope-and-keep-the-route-id + */ +class MigrationPackRequestReader { + /** + * The most sample rows one preview request may map. + * + * @var int + */ + public const MAX_PREVIEW_ROWS = 20; + + /** + * The largest preview input, definition and rows together, as JSON bytes. + * + * The row count alone does not bound the work: one row can carry any + * number of columns, and every row runs through the mapping engine in the + * request. 1 MiB holds a large pack with long lookup tables and twenty + * wide rows. + * + * @var int + */ + public const MAX_PREVIEW_BYTES = 1048576; + + /** + * Constructor. + * + * @param PackDefinitionValidator $validator Reports what is wrong with a raw body that lost its id. + * @param IL10N $l10n Translates the refusals the caller is shown. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-create-and-update-must-accept-a-definition-envelope-and-keep-the-route-id + */ + public function __construct( + private readonly PackDefinitionValidator $validator, + private readonly IL10N $l10n, + ) { + }//end __construct() + + /** + * The pack row id from the route, falling back to the dispatched argument. + * + * The dispatched `$id` is read from the merged parameters, where a body + * `id` wins; a pack slug there is cast to 0. The route value does not + * have that problem. + * + * @param IRequest $request The request. + * @param int $dispatchedId The id the dispatcher passed to the controller. + * + * @return int + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-create-and-update-must-accept-a-definition-envelope-and-keep-the-route-id + */ + public function routeId(IRequest $request, int $dispatchedId): int { + $routeId = (string)($this->routeParams(request: $request)['id'] ?? ''); + if (ctype_digit($routeId) === true) { + return (int)$routeId; + } + + return $dispatchedId; + }//end routeId() + + /** + * Read the pack definition from a create or update request body. + * + * Accepts the envelope `{"definition": {...}}`, taken as is, or the pack + * document as the whole body (the raw form). A body with a top-level + * `definition` object is always read as the envelope. In the raw form, + * every `_`-prefixed key is dropped, because Nextcloud merges router keys + * such as `_route` into the same parameters; a pack that needs such keys + * must use the envelope. + * + * Nextcloud also merges the route `id` into those parameters, so in the + * raw form a body `id` equal to the row id in the URL cannot be told apart + * from no body `id` at all. That request is refused, with every validation + * error of the pack without an id plus a pointer to the envelope, rather + * than guessed at. + * + * @param IRequest $request The request. + * + * @return array + * + * @throws InvalidPackDefinitionException When the raw form's id is the route id. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-create-and-update-must-accept-a-definition-envelope-and-keep-the-route-id + */ + public function definition(IRequest $request): array { + $params = $request->getParams(); + if (is_array($params['definition'] ?? null) === true) { + return $params['definition']; + } + + $definition = array_filter( + $params, + static fn ($key): bool => str_starts_with((string)$key, '_') === false, + ARRAY_FILTER_USE_KEY + ); + + $routeParams = $this->routeParams(request: $request); + if (array_key_exists('id', $routeParams) === false + || array_key_exists('id', $definition) === false + || (string)$definition['id'] !== (string)$routeParams['id'] + ) { + return $definition; + } + + unset($definition['id']); + $errors = $this->validator->validate(definition: $definition); + $errors[] = 'An "id" in the request body that equals the row id in the URL cannot be told apart from it.' + . ' Send the pack as {"definition": {...}} to keep that id.'; + + throw new InvalidPackDefinitionException(errors: $errors); + }//end definition() + + /** + * Read the pack document from the uploaded `file` of an import request. + * + * @param IRequest $request The request. + * + * @return array + * + * @throws MigrationPackRequestException When no file was uploaded, or it is not a JSON object. + * + * @spec openspec/specs/migration-mapping-packs/spec.md#migration-packs-must-be-manageable-via-admin-gated-rest-crud-with-json-file-importexport + */ + public function uploadedDefinition(IRequest $request): array { + $uploadedFile = $request->getUploadedFile('file'); + if (is_array($uploadedFile) === false || empty($uploadedFile['tmp_name']) === true) { + throw new MigrationPackRequestException( + errorCode: 'invalid-request', + userMessage: $this->l10n->t('No file uploaded') + ); + } + + $raw = file_get_contents($uploadedFile['tmp_name']); + $decoded = null; + if ($raw !== false) { + $decoded = json_decode($raw, true); + } + + if (is_array($decoded) === false) { + throw new MigrationPackRequestException( + errorCode: 'invalid-request', + userMessage: $this->l10n->t('Uploaded file is not a valid JSON object') + ); + } + + return $decoded; + }//end uploadedDefinition() + + /** + * Read the definition and sample rows of a preview request. + * + * Bounded twice: at most MAX_PREVIEW_ROWS rows, and at most + * MAX_PREVIEW_BYTES for the definition and rows together. + * + * @param IRequest $request The request. + * + * @return array{definition: array, rows: list>} + * + * @throws MigrationPackRequestException When the body is malformed, or has too many or too large rows. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it + */ + public function previewInput(IRequest $request): array { + $definition = $request->getParam('definition'); + if (is_array($definition) === false) { + throw new MigrationPackRequestException( + errorCode: 'invalid-request', + userMessage: $this->l10n->t('The request must contain a pack definition object') + ); + } + + $rows = $this->sampleRows(rows: $request->getParam('rows', [])); + + $size = strlen((string)json_encode([$definition, $rows], JSON_PARTIAL_OUTPUT_ON_ERROR)); + if ($size > self::MAX_PREVIEW_BYTES) { + throw new MigrationPackRequestException( + errorCode: 'invalid-request', + userMessage: $this->l10n->t('The preview request is too large') + ); + } + + return ['definition' => $definition, 'rows' => $rows]; + }//end previewInput() + + /** + * Check that the sample rows are a list of at most MAX_PREVIEW_ROWS objects. + * + * @param mixed $rows The `rows` parameter as sent. + * + * @return list> + * + * @throws MigrationPackRequestException When the rows are not a list of objects, or too many. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it + */ + private function sampleRows(mixed $rows): array { + if (is_array($rows) === false || array_is_list($rows) === false) { + throw $this->rowsAreNotAList(); + } + + if (count($rows) > self::MAX_PREVIEW_ROWS) { + throw new MigrationPackRequestException( + errorCode: 'too-many-rows', + userMessage: $this->l10n->t('A preview takes at most %s sample rows', [self::MAX_PREVIEW_ROWS]) + ); + } + + foreach ($rows as $row) { + if (is_array($row) === false) { + throw $this->rowsAreNotAList(); + } + } + + return $rows; + }//end sampleRows() + + /** + * The refusal for sample rows that are not a list of objects. + * + * @return MigrationPackRequestException + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it + */ + private function rowsAreNotAList(): MigrationPackRequestException { + return new MigrationPackRequestException( + errorCode: 'invalid-request', + userMessage: $this->l10n->t('Sample rows must be a list of objects') + ); + }//end rowsAreNotAList() + + /** + * The parameters the router extracted from the URL, without the body. + * + * IRequest declares `urlParams` with `@property-read`; the isset() lets a + * request double that does not carry it fall back to no route values. + * + * @param IRequest $request The request. + * + * @return array + * + * @psalm-suppress NoInterfaceProperties `urlParams` is a documented read-only property of IRequest. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-create-and-update-must-accept-a-definition-envelope-and-keep-the-route-id + */ + private function routeParams(IRequest $request): array { + if (isset($request->urlParams) === false) { + return []; + } + + return $request->urlParams; + }//end routeParams() +}//end class diff --git a/lib/Service/MigrationPack/PackDefinitionValidator.php b/lib/Service/MigrationPack/PackDefinitionValidator.php index 11fe80dbbb..c143e4040f 100644 --- a/lib/Service/MigrationPack/PackDefinitionValidator.php +++ b/lib/Service/MigrationPack/PackDefinitionValidator.php @@ -33,7 +33,7 @@ namespace OCA\OpenRegister\Service\MigrationPack; -use InvalidArgumentException; +use OCA\OpenRegister\Exception\InvalidPackDefinitionException; /** * Structural + business-rule validator for a migration-pack JSON document. @@ -96,14 +96,14 @@ public function validate(array $definition): array { * * @return void * - * @throws InvalidArgumentException When the definition is invalid. The message joins every error found. + * @throws InvalidPackDefinitionException When the definition is invalid. It carries every error found. * * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them */ public function assertValid(array $definition): void { $errors = $this->validate(definition: $definition); if (empty($errors) === false) { - throw new InvalidArgumentException('Invalid migration pack definition: ' . implode('; ', $errors)); + throw new InvalidPackDefinitionException(errors: $errors); } }//end assertValid() @@ -181,10 +181,16 @@ private function validateVersion(array $definition): array { /** * Validate the `fieldMappings` array and every entry within it. * + * Errors name a mapping by its position counted from 1 ("Mapping row 4"), + * the number the pack editor shows in its table, not the 0-based array + * index. + * * @param array $definition The pack definition. * * @return string[] * + * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + * * @SuppressWarnings(PHPMD.CyclomaticComplexity) Per-transform-type validation requires many branches. * @SuppressWarnings(PHPMD.NPathComplexity) Per-transform-type validation requires many branches. */ @@ -195,29 +201,29 @@ private function validateFieldMappings(array $definition): array { } $errors = []; - foreach ($mappings as $index => $mapping) { - $path = 'fieldMappings[' . $index . ']'; + foreach (array_values($mappings) as $position => $mapping) { + $row = 'Mapping row ' . ($position + 1); if (is_array($mapping) === false) { - $errors[] = $path . ' must be an object'; + $errors[] = $row . ' must be an object'; continue; } $source = $mapping['source'] ?? null; if (is_string($source) === false || $source === '') { - $errors[] = $path . '.source is required and must be a non-empty string'; + $errors[] = $row . ': source is required and must be a non-empty string'; } $target = $mapping['target'] ?? null; if (is_string($target) === false || $target === '') { - $errors[] = $path . '.target is required and must be a non-empty string'; + $errors[] = $row . ': target is required and must be a non-empty string'; } if (isset($mapping['required']) === true && is_bool($mapping['required']) === false) { - $errors[] = $path . '.required must be a boolean when present'; + $errors[] = $row . ': required must be a boolean when present'; } if (isset($mapping['transform']) === true) { - $errors = array_merge($errors, $this->validateTransform(transform: $mapping['transform'], path: $path . '.transform')); + $errors = array_merge($errors, $this->validateTransform(transform: $mapping['transform'], path: $row . ': transform')); } }//end foreach diff --git a/lib/Service/MigrationPackService.php b/lib/Service/MigrationPackService.php index 8afcac23eb..0566d3c57e 100644 --- a/lib/Service/MigrationPackService.php +++ b/lib/Service/MigrationPackService.php @@ -25,11 +25,12 @@ namespace OCA\OpenRegister\Service; use DateTime; -use InvalidArgumentException; use OCA\OpenRegister\Db\MigrationPack; use OCA\OpenRegister\Db\MigrationPackMapper; use OCA\OpenRegister\Exception\BuiltinPackSlugLockedException; +use OCA\OpenRegister\Exception\InvalidPackDefinitionException; use OCA\OpenRegister\Service\MigrationPack\MappingEngine; +use OCA\OpenRegister\Service\MigrationPack\MigrationPackImportResult; use OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IAppConfig; @@ -43,6 +44,11 @@ class MigrationPackService { /** * App config key listing the slugs of deleted built-in packs. * + * A slug is added when a built-in pack is deleted and removed when a + * built-in pack is seeded under it again, so the list holds the slugs + * whose built-in pack is currently gone. Nothing else clears it: after the + * owning app is uninstalled its slugs stay locked. + * * @var string */ public const DELETED_BUILTIN_SLUGS_KEY = 'migration_packs_deleted_builtin_slugs'; @@ -108,7 +114,9 @@ public function findByPackSlug(string $packSlug): MigrationPack { * Create a migration pack from a definition document. * * A pack created under the slug of a deleted built-in pack is built-in - * again, so its slug stays locked until the app that owns it seeds it. + * again, so its slug stays locked. Seeding a built-in pack (`$builtin`) + * takes its slug off the deleted list once the row is stored: the pack is + * built-in again by its own flag. * * @param array $definition The pack definition document (the JSON body). * @param string|null $ownerUid The creating admin's uid, or null for a system/built-in seed. @@ -118,7 +126,7 @@ public function findByPackSlug(string $packSlug): MigrationPack { * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $builtin only distinguishes admin-authored packs from the seeded reference pack. * - * @throws InvalidArgumentException When the definition is structurally invalid or its slug already exists. + * @throws InvalidPackDefinitionException When the definition is structurally invalid or its slug already exists. * * @spec openspec/specs/migration-mapping-packs/spec.md */ @@ -127,7 +135,7 @@ public function create(array $definition, ?string $ownerUid = null, bool $builti $slug = (string)$definition['id']; if ($this->slugExists(packSlug: $slug) === true) { - throw new InvalidArgumentException('A migration pack with id "' . $slug . '" already exists'); + throw new InvalidPackDefinitionException(errors: ['A migration pack with id "' . $slug . '" already exists']); } $pack = new MigrationPack(); @@ -141,7 +149,14 @@ public function create(array $definition, ?string $ownerUid = null, bool $builti $pack->setCreatedAt(new DateTime()); $pack->setUpdatedAt(new DateTime()); - return $this->mapper->insert(entity: $pack); + $stored = $this->mapper->insert(entity: $pack); + + // After the insert, so a failed insert leaves the slug locked. + if ($builtin === true) { + $this->forgetDeletedBuiltinSlug(packSlug: $slug); + } + + return $stored; }//end create() /** @@ -158,7 +173,7 @@ public function create(array $definition, ?string $ownerUid = null, bool $builti * * @throws DoesNotExistException When no row matches. * @throws BuiltinPackSlugLockedException When the definition renames a built-in pack. - * @throws InvalidArgumentException When the definition is structurally invalid, or its slug collides with a different pack. + * @throws InvalidPackDefinitionException When the definition is structurally invalid, or its slug collides with a different pack. * * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them */ @@ -175,7 +190,7 @@ public function update(int $id, array $definition): MigrationPack { } if ($slug !== $pack->getPackSlug() && $this->slugExists(packSlug: $slug) === true) { - throw new InvalidArgumentException('A migration pack with id "' . $slug . '" already exists'); + throw new InvalidPackDefinitionException(errors: ['A migration pack with id "' . $slug . '" already exists']); } if ($slug !== $pack->getPackSlug() && $this->isDeletedBuiltinSlug(packSlug: $slug) === true) { @@ -196,7 +211,9 @@ public function update(int $id, array $definition): MigrationPack { * Delete a migration pack. * * The slug of a deleted built-in pack is remembered, so a pack created - * or imported under it later keeps the slug lock. + * or imported under it later keeps the slug lock. It is remembered before + * the row goes: if remembering fails, the pack is still there and still + * built-in, so the lock never opens. * * @param int $id The migration pack id. * @@ -208,12 +225,12 @@ public function update(int $id, array $definition): MigrationPack { */ public function delete(int $id): MigrationPack { $pack = $this->mapper->find(id: $id); - $this->mapper->delete(entity: $pack); - if ($pack->getBuiltin() === true) { $this->rememberDeletedBuiltinSlug(packSlug: $pack->getPackSlug()); } + $this->mapper->delete(entity: $pack); + return $pack; }//end delete() @@ -243,7 +260,7 @@ public function validateDefinition(array $definition): array { * * @return list, errors: list>}> * - * @throws InvalidArgumentException When the definition is structurally invalid. + * @throws InvalidPackDefinitionException When the definition is structurally invalid. * * @spec openspec/specs/migration-mapping-packs/spec.md#the-mapping-engine-must-apply-pack-transforms-per-row-and-never-leak-unresolved-references */ @@ -269,16 +286,18 @@ public function previewRows(array $definition, array $rows): array { * document's own `id`, so re-importing an updated pack (e.g. shared from * another instance) updates the existing row rather than colliding. * + * The result says which of the two happened, decided by the write itself. + * * @param array $definition The decoded pack definition document. * @param string|null $ownerUid The importing admin's uid. * - * @return MigrationPack + * @return MigrationPackImportResult The stored pack, and whether it was created or replaced. * - * @throws InvalidArgumentException When the definition is structurally invalid. + * @throws InvalidPackDefinitionException When the definition is structurally invalid. * - * @spec openspec/specs/migration-mapping-packs/spec.md + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged */ - public function importDefinition(array $definition, ?string $ownerUid = null): MigrationPack { + public function importDefinition(array $definition, ?string $ownerUid = null): MigrationPackImportResult { $this->validator->assertValid(definition: $definition); $slug = (string)$definition['id']; @@ -286,10 +305,16 @@ public function importDefinition(array $definition, ?string $ownerUid = null): M try { $existing = $this->mapper->findByPackSlug(packSlug: $slug); } catch (DoesNotExistException $e) { - return $this->create(definition: $definition, ownerUid: $ownerUid); + return new MigrationPackImportResult( + pack: $this->create(definition: $definition, ownerUid: $ownerUid), + created: true + ); } - return $this->update(id: $existing->getId(), definition: $definition); + return new MigrationPackImportResult( + pack: $this->update(id: $existing->getId(), definition: $definition), + created: false + ); }//end importDefinition() /** @@ -326,6 +351,25 @@ private function rememberDeletedBuiltinSlug(string $packSlug): void { $this->appConfig->setValueArray('openregister', self::DELETED_BUILTIN_SLUGS_KEY, array_values($slugs), true); }//end rememberDeletedBuiltinSlug() + /** + * Take a slug off the deleted built-in list, once its pack is seeded again. + * + * @param string $packSlug The pack document's own `id`. + * + * @return void + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-the-slug-of-a-built-in-pack-must-not-change + */ + private function forgetDeletedBuiltinSlug(string $packSlug): void { + if ($this->isDeletedBuiltinSlug(packSlug: $packSlug) === false) { + return; + } + + $slugs = $this->appConfig->getValueArray('openregister', self::DELETED_BUILTIN_SLUGS_KEY, [], true); + $remaining = array_values(array_diff($slugs, [$packSlug])); + $this->appConfig->setValueArray('openregister', self::DELETED_BUILTIN_SLUGS_KEY, $remaining, true); + }//end forgetDeletedBuiltinSlug() + /** * Whether a pack with the given slug already exists. * diff --git a/tests/Unit/Controller/MigrationPacksControllerTest.php b/tests/Unit/Controller/MigrationPacksControllerTest.php index 17dee2d32f..4c5f7f12e0 100644 --- a/tests/Unit/Controller/MigrationPacksControllerTest.php +++ b/tests/Unit/Controller/MigrationPacksControllerTest.php @@ -5,10 +5,11 @@ * * Unit tests for `MigrationPacksController`: anonymous callers are rejected * everywhere; reads (`index`/`show`) succeed for any authenticated user; - * mutations (`create`/`update`/`destroy`) are admin-gated and CSRF-protected; + * mutations (`create`/`update`/`destroy`) are admin-only and CSRF-protected; * update/destroy always act on the route id; every successful create, update, * import and delete leaves an audit log line; the - * `preview` dry run maps sample rows through an unsaved definition. + * `preview` dry run maps sample rows through an unsaved definition; every + * error answers `{message, error, details?}` without exception text. * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -33,7 +34,11 @@ use OCA\OpenRegister\Db\MigrationPack; use OCA\OpenRegister\Db\MigrationPackMapper; use OCA\OpenRegister\Exception\BuiltinPackSlugLockedException; +use OCA\OpenRegister\Exception\InvalidPackDefinitionException; use OCA\OpenRegister\Service\MigrationPack\MappingEngine; +use OCA\OpenRegister\Service\MigrationPack\MigrationPackErrorResponder; +use OCA\OpenRegister\Service\MigrationPack\MigrationPackImportResult; +use OCA\OpenRegister\Service\MigrationPack\MigrationPackRequestReader; use OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator; use OCA\OpenRegister\Service\MigrationPackService; use OCA\OpenRegister\Tests\Unit\Support\MergingRequestFake; @@ -52,6 +57,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use ReflectionMethod; +use RuntimeException; class MigrationPacksControllerTest extends TestCase { private MigrationPacksController $controller; @@ -124,7 +130,8 @@ private function controllerFor(IRequest $request, ?MigrationPackService $service $this->userSession, $this->groupManager, $this->logger, - $this->l10n + new MigrationPackRequestReader(new PackDefinitionValidator(), $this->l10n), + new MigrationPackErrorResponder($this->l10n, $this->logger) ); } @@ -248,12 +255,173 @@ public function testCreateSucceedsForAdmin(): void { $this->assertSame(201, $response->getStatus()); } - public function testCreateReturns422OnInvalidArgument(): void { + public function testCreateAnswersARefusedDefinitionWithItsReasons(): void { $this->setupUser('admin', true); - $this->service->method('create')->willThrowException(new InvalidArgumentException('bad pack')); + $this->service->method('create')->willThrowException( + new InvalidPackDefinitionException(['"name" is required', 'Mapping row 2: target is required']) + ); $response = $this->controller->create(); + + $this->assertSame(422, $response->getStatus()); + $this->assertSame( + [ + 'message' => 'The pack definition is invalid', + 'error' => 'invalid-pack', + 'details' => ['errors' => ['"name" is required', 'Mapping row 2: target is required']], + ], + $response->getData() + ); + } + + /** + * @return array + */ + public static function refusingWrites(): array { + return [ + 'create' => ['create'], + 'update' => ['update'], + 'import' => ['import'], + ]; + } + + #[DataProvider('refusingWrites')] + public function testEveryWriteAnswersAnInvalidPackInTheSharedShape(string $method): void { + $this->setupUser('admin', true); + $refusal = new InvalidPackDefinitionException(['"version" must be a semver string']); + $this->service->method('create')->willThrowException($refusal); + $this->service->method('update')->willThrowException($refusal); + $this->service->method('importDefinition')->willThrowException($refusal); + + $controller = $this->controller; + if ($method === 'import') { + $controller = $this->importController($this->validDefinition()); + } + + $response = match ($method) { + 'update' => $controller->update(1), + default => $controller->$method(), + }; + $this->assertSame(422, $response->getStatus()); + $data = $response->getData(); + $this->assertSame('invalid-pack', $data['error']); + $this->assertSame('The pack definition is invalid', $data['message']); + $this->assertSame(['"version" must be a semver string'], $data['details']['errors']); + } + + /** + * @return array + */ + public static function failingWrites(): array { + return [ + 'create' => ['create'], + 'update' => ['update'], + 'destroy' => ['destroy'], + 'import' => ['import'], + ]; + } + + #[DataProvider('failingWrites')] + public function testAnUnexpectedFailureIsLoggedAndAnsweredWithoutItsText(string $method): void { + $this->setupUser('admin', true); + $failure = new RuntimeException('SQLSTATE[HY000] secret-table-name'); + $this->service->method('create')->willThrowException($failure); + $this->service->method('update')->willThrowException($failure); + $this->service->method('delete')->willThrowException($failure); + $this->service->method('importDefinition')->willThrowException($failure); + $this->logger->expects($this->once()) + ->method('error') + ->with($this->anything(), $this->callback(static fn (array $context): bool => $context['exception'] === $failure)); + + $controller = $this->controller; + if ($method === 'import') { + $controller = $this->importController($this->validDefinition()); + } + + $response = match ($method) { + 'update', 'destroy' => $controller->$method(1), + default => $controller->$method(), + }; + + $this->assertSame(500, $response->getStatus()); + $data = $response->getData(); + $this->assertSame('migration-pack-error', $data['error']); + $this->assertStringNotContainsString('secret-table-name', json_encode($data)); + $this->assertNotEmpty($data['message']); + } + + public function testAPlainInvalidArgumentIsNotEchoedBack(): void { + // Only a pack refusal carries reasons meant for the caller; any other + // InvalidArgumentException is an internal fault. + $this->setupUser('admin', true); + $this->service->method('create')->willThrowException(new InvalidArgumentException('internal detail')); + + $response = $this->controller->create(); + + $this->assertSame(500, $response->getStatus()); + $this->assertStringNotContainsString('internal detail', json_encode($response->getData())); + } + + /** + * @return array + */ + public static function refusedCallers(): array { + return [ + 'anonymous index' => ['index', false, 401, 'not-authenticated'], + 'anonymous show' => ['show', false, 401, 'not-authenticated'], + 'anonymous export' => ['export', false, 401, 'not-authenticated'], + 'anonymous create' => ['create', false, 401, 'not-authenticated'], + 'anonymous preview' => ['preview', false, 401, 'not-authenticated'], + 'non-admin create' => ['create', true, 403, 'admin-required'], + 'non-admin update' => ['update', true, 403, 'admin-required'], + 'non-admin destroy' => ['destroy', true, 403, 'admin-required'], + 'non-admin import' => ['import', true, 403, 'admin-required'], + 'non-admin preview' => ['preview', true, 403, 'admin-required'], + ]; + } + + #[DataProvider('refusedCallers')] + public function testARefusedCallerGetsAMessageAndACode(string $method, bool $signedIn, int $status, string $error): void { + $this->setupUser($signedIn === true ? 'alice' : null, false); + + $response = match ($method) { + 'show', 'export', 'update', 'destroy' => $this->controller->$method(1), + default => $this->controller->$method(), + }; + + $this->assertSame($status, $response->getStatus()); + $this->assertSame($error, $response->getData()['error']); + $this->assertNotEmpty($response->getData()['message']); + } + + /** + * @return array + */ + public static function lookupsByRowId(): array { + return [ + 'show' => ['show'], + 'export' => ['export'], + 'update' => ['update'], + 'destroy' => ['destroy'], + ]; + } + + #[DataProvider('lookupsByRowId')] + public function testAnUnknownRowIdAnswersPackNotFound(string $method): void { + $this->setupUser('admin', true); + $missing = new DoesNotExistException('not found'); + $this->service->method('find')->willThrowException($missing); + $this->service->method('update')->willThrowException($missing); + $this->service->method('delete')->willThrowException($missing); + + $response = $this->controller->$method(999); + + $this->assertSame(404, $response->getStatus()); + $this->assertSame( + ['message' => 'Migration pack not found', 'error' => 'pack-not-found'], + $response->getData() + ); } public function testUpdateRejectsNonAdmin(): void { @@ -404,16 +572,47 @@ public function testUpdateRawBodyWithoutIdDoesNotTurnTheRouteIdIntoASlug(): void unset($body['id']); $request = new MergingRequestFake(['_route' => 'openregister.migrationPacks.update', 'id' => '5'], $body); - $this->service->expects($this->once()) - ->method('update') - ->with( - 5, - $this->callback(static fn (array $definition): bool => array_key_exists('id', $definition) === false) - ) - ->willThrowException(new InvalidArgumentException('Invalid migration pack definition: id is required')); + $this->service->expects($this->never())->method('update'); + + $response = $this->controllerFor($request)->update($request->dispatchedInt('id')); + + $this->assertSame(422, $response->getStatus()); + $data = $response->getData(); + $this->assertSame('invalid-pack', $data['error']); + $this->assertStringContainsString('"id" is required', $data['details']['errors'][0]); + } + + public function testUpdateRawBodyWhoseSlugIsTheRowIdIsRefusedWithAPointerToTheEnvelope(): void { + // Slug "5" on row 5: after Nextcloud's merge the body id and the route + // id are the same string, so the raw form cannot keep it. + $this->setupUser('admin', true); + $request = new MergingRequestFake( + ['_route' => 'openregister.migrationPacks.update', 'id' => '5'], + $this->validDefinition(['id' => '5']) + ); + + $this->service->expects($this->never())->method('update'); $response = $this->controllerFor($request)->update($request->dispatchedInt('id')); + $this->assertSame(422, $response->getStatus()); + $errors = $response->getData()['details']['errors']; + $this->assertStringContainsString('{"definition": {...}}', end($errors)); + } + + public function testUpdateEnvelopeKeepsASlugThatIsTheRowId(): void { + $this->setupUser('admin', true); + $request = new MergingRequestFake( + ['_route' => 'openregister.migrationPacks.update', 'id' => '5'], + ['definition' => $this->validDefinition(['id' => '5'])] + ); + + $this->service->expects($this->once()) + ->method('update') + ->with(5, $this->validDefinition(['id' => '5'])) + ->willReturn($this->packEntity('5')); + + $this->assertSame(200, $this->controllerFor($request)->update($request->dispatchedInt('id'))->getStatus()); } public function testCreateAcceptsTheEnvelope(): void { @@ -526,11 +725,12 @@ private function storedPack(string $slug, int $id, bool $builtin = false): Migra public function testImportReplacingAStoredPackLogsTheActorSlugAndOutcome(): void { $this->setupUser('ronald', true); $stored = $this->storedPack('stackiq-topdesk-module', 5, true); - $this->service->method('findByPackSlug')->with('stackiq-topdesk-module')->willReturn($stored); + // The outcome comes from the write, not from a lookup before it. + $this->service->expects($this->never())->method('findByPackSlug'); $this->service->expects($this->once()) ->method('importDefinition') ->with($this->validDefinition(), 'ronald') - ->willReturn($stored); + ->willReturn(new MigrationPackImportResult($stored, false)); $this->logger->expects($this->once()) ->method('info') @@ -551,8 +751,9 @@ public function testImportReplacingAStoredPackLogsTheActorSlugAndOutcome(): void public function testImportOfANewSlugLogsThatItCreatedAPack(): void { $this->setupUser('ronald', true); - $this->service->method('findByPackSlug')->willThrowException(new DoesNotExistException('not found')); - $this->service->method('importDefinition')->willReturn($this->storedPack('stackiq-topdesk-module', 8)); + $this->service->method('importDefinition')->willReturn( + new MigrationPackImportResult($this->storedPack('stackiq-topdesk-module', 8), true) + ); $this->logger->expects($this->once()) ->method('info') @@ -570,13 +771,36 @@ public function testImportOfANewSlugLogsThatItCreatedAPack(): void { public function testImportFailureWritesNoAuditLine(): void { $this->setupUser('ronald', true); - $this->service->method('findByPackSlug')->willThrowException(new DoesNotExistException('not found')); - $this->service->method('importDefinition')->willThrowException(new InvalidArgumentException('bad pack')); + $this->service->method('importDefinition')->willThrowException(new InvalidPackDefinitionException(['bad pack'])); $this->logger->expects($this->never())->method('info'); $this->assertSame(422, $this->importController($this->validDefinition())->import()->getStatus()); } + public function testImportWithoutAFileIsABadRequest(): void { + $this->setupUser('ronald', true); + $this->service->expects($this->never())->method('importDefinition'); + + $response = $this->controller->import(); + + $this->assertSame(400, $response->getStatus()); + $this->assertSame(['message' => 'No file uploaded', 'error' => 'invalid-request'], $response->getData()); + } + + public function testImportOfAFileThatIsNotAJsonObjectIsABadRequest(): void { + $this->setupUser('ronald', true); + $file = tempnam(sys_get_temp_dir(), 'pack'); + file_put_contents($file, '"just a string"'); + $this->tempFiles[] = $file; + $request = $this->createMock(IRequest::class); + $request->method('getUploadedFile')->with('file')->willReturn(['tmp_name' => $file]); + + $response = $this->controllerFor($request)->import(); + + $this->assertSame(400, $response->getStatus()); + $this->assertSame('invalid-request', $response->getData()['error']); + } + public function testCreateLogsTheActorAndSlug(): void { $this->setupUser('ronald', true); $request = new MergingRequestFake( @@ -603,7 +827,7 @@ public function testCreateLogsTheActorAndSlug(): void { public function testCreateFailureWritesNoAuditLine(): void { $this->setupUser('ronald', true); - $this->service->method('create')->willThrowException(new InvalidArgumentException('bad pack')); + $this->service->method('create')->willThrowException(new InvalidPackDefinitionException(['bad pack'])); $this->logger->expects($this->never())->method('info'); $this->assertSame(422, $this->controller->create()->getStatus()); @@ -638,8 +862,17 @@ public function testDestroyActsOnTheRouteIdAndLogsTheActorAndSlug(): void { public function testEveryTranslatedMessageHasADutchTranslation(): void { // PHP IL10N reads l10n/.json; a key missing there reaches a Dutch - // administrator in English. - $source = file_get_contents(__DIR__ . '/../../../lib/Controller/MigrationPacksController.php'); + // administrator in English. The controller's messages live in its two + // collaborators. + $source = ''; + foreach ([ + 'lib/Controller/MigrationPacksController.php', + 'lib/Service/MigrationPack/MigrationPackErrorResponder.php', + 'lib/Service/MigrationPack/MigrationPackRequestReader.php', + ] as $file) { + $source .= file_get_contents(__DIR__ . '/../../../' . $file); + } + preg_match_all("/->t\\(\\s*'((?:[^'\\\\]|\\\\.)*)'/", $source, $matches); $this->assertNotEmpty($matches[1]); @@ -673,12 +906,20 @@ public function testWriteMethodsRequireCsrfAndAdmin(string $method): void { $this->assertSame([], $reflection->getAttributes(NoCSRFRequired::class), $method . ' must require a CSRF token'); $this->assertSame([], $reflection->getAttributes(NoAdminRequired::class), $method . ' must not open to non-admins'); - $this->assertCount(1, $reflection->getAttributes(AuthorizedAdminSetting::class), $method . ' must be admin-gated'); + // AuthorizedAdminSetting would also admit users an admin delegated the + // OpenRegister section to. With no attribute, Nextcloud's middleware + // admits administrators only. + $this->assertSame( + [], + $reflection->getAttributes(AuthorizedAdminSetting::class), + $method . ' must not open to delegated admins' + ); // Nextcloud still honours the docblock forms, so they must be gone too. $docComment = (string)$reflection->getDocComment(); $this->assertStringNotContainsString('@NoCSRFRequired', $docComment); $this->assertStringNotContainsString('@NoAdminRequired', $docComment); + $this->assertMatchesRegularExpression('/^\s*\* @auth admin-only \S/m', $docComment, $method . ' must declare its posture'); } /** @@ -795,6 +1036,38 @@ public function testPreviewRejectsAMalformedRequest(array $body): void { $this->assertSame('invalid-request', $response->getData()['error']); } + public function testPreviewRejectsAnOversizedRequest(): void { + $this->setupUser('admin', true); + $wide = ['Naam' => str_repeat('x', (int)(MigrationPackRequestReader::MAX_PREVIEW_BYTES / 2))]; + $controller = $this->previewController(['definition' => $this->validDefinition(), 'rows' => [$wide, $wide]]); + + $response = $controller->preview(); + + $this->assertSame(400, $response->getStatus()); + $this->assertSame( + ['message' => 'The preview request is too large', 'error' => 'invalid-request'], + $response->getData() + ); + } + + public function testPreviewFailureAnswersTheSharedErrorCode(): void { + $this->setupUser('admin', true); + $this->service->method('previewRows')->willThrowException(new RuntimeException('engine exploded')); + $request = new MergingRequestFake( + ['_route' => 'openregister.migrationPacks.preview'], + ['definition' => $this->validDefinition(), 'rows' => []], + 'POST' + ); + + $response = $this->controllerFor($request)->preview(); + + $this->assertSame(500, $response->getStatus()); + $this->assertSame( + ['message' => 'Failed to preview migration pack', 'error' => 'migration-pack-error'], + $response->getData() + ); + } + public function testPreviewRejectsNonAdmin(): void { $this->setupUser('alice', false); $response = $this->previewController(['definition' => $this->validDefinition(), 'rows' => []])->preview(); diff --git a/tests/Unit/Service/MigrationPack/PackDefinitionValidatorTest.php b/tests/Unit/Service/MigrationPack/PackDefinitionValidatorTest.php index 0a4ccd9d31..2458c46fdb 100644 --- a/tests/Unit/Service/MigrationPack/PackDefinitionValidatorTest.php +++ b/tests/Unit/Service/MigrationPack/PackDefinitionValidatorTest.php @@ -25,6 +25,7 @@ namespace OCA\OpenRegister\Tests\Unit\Service\MigrationPack; use InvalidArgumentException; +use OCA\OpenRegister\Exception\InvalidPackDefinitionException; use OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator; use PHPUnit\Framework\TestCase; @@ -131,13 +132,13 @@ public function testEmptyFieldMappingsIsRejected(): void { public function testFieldMappingWithoutSourceIsRejected(): void { $errors = $this->validator->validate($this->validPack(['fieldMappings' => [['target' => 'title']]])); $this->assertNotEmpty($errors); - $this->assertStringContainsString('.source', implode(' ', $errors)); + $this->assertContains('Mapping row 1: source is required and must be a non-empty string', $errors); } public function testFieldMappingWithoutTargetIsRejected(): void { $errors = $this->validator->validate($this->validPack(['fieldMappings' => [['source' => 'Name']]])); $this->assertNotEmpty($errors); - $this->assertStringContainsString('.target', implode(' ', $errors)); + $this->assertContains('Mapping row 1: target is required and must be a non-empty string', $errors); } public function testUnknownTransformTypeIsRejected(): void { @@ -151,7 +152,30 @@ public function testUnknownTransformTypeIsRejected(): void { ) ); $this->assertNotEmpty($errors); - $this->assertStringContainsString('.transform.type', implode(' ', $errors)); + $this->assertStringContainsString('Mapping row 1: transform.type', implode(' ', $errors)); + } + + public function testMappingRowsAreCountedFromOneAsTheEditorTableShowsThem(): void { + $errors = $this->validator->validate( + $this->validPack( + [ + 'fieldMappings' => [ + ['source' => 'Name', 'target' => 'title'], + ['source' => 'Code', 'target' => 'code'], + 'not an object', + ['source' => 'Status', 'target' => 'status', 'transform' => ['type' => 'lookup']], + ], + ] + ) + ); + + $this->assertSame( + [ + 'Mapping row 3 must be an object', + 'Mapping row 4: transform.map is required and must be a non-empty object for a lookup transform', + ], + $errors + ); } /** @@ -242,6 +266,18 @@ public function testAssertValidThrowsWithJoinedMessages(): void { $this->validator->assertValid(['id' => '']); } + public function testAssertValidCarriesEveryErrorSeparately(): void { + $definition = $this->validPack(['name' => '', 'version' => '1.0']); + + try { + $this->validator->assertValid($definition); + $this->fail('An invalid pack must be refused'); + } catch (InvalidPackDefinitionException $e) { + $this->assertSame($this->validator->validate($definition), $e->getErrors()); + $this->assertCount(2, $e->getErrors()); + } + } + public function testAssertValidDoesNotThrowForAValidPack(): void { $this->validator->assertValid($this->validPack()); $this->addToAssertionCount(1); diff --git a/tests/Unit/Service/MigrationPackServiceTest.php b/tests/Unit/Service/MigrationPackServiceTest.php index dcc1eba3e3..183c28efdb 100644 --- a/tests/Unit/Service/MigrationPackServiceTest.php +++ b/tests/Unit/Service/MigrationPackServiceTest.php @@ -33,6 +33,7 @@ use OCA\OpenRegister\Db\MigrationPack; use OCA\OpenRegister\Db\MigrationPackMapper; use OCA\OpenRegister\Exception\BuiltinPackSlugLockedException; +use OCA\OpenRegister\Exception\InvalidPackDefinitionException; use OCA\OpenRegister\Service\MigrationPack\MappingEngine; use OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator; use OCA\OpenRegister\Service\MigrationPackService; @@ -40,6 +41,7 @@ use OCP\IAppConfig; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use RuntimeException; class MigrationPackServiceTest extends TestCase { private MigrationPackService $service; @@ -120,6 +122,13 @@ public function testCreateRejectsDuplicateSlug(): void { $existing->setPackSlug('test-pack'); $this->mapper->method('findByPackSlug')->willReturn($existing); + try { + $this->service->create($this->validDefinition()); + $this->fail('A duplicate slug must be refused'); + } catch (InvalidPackDefinitionException $e) { + $this->assertSame(['A migration pack with id "test-pack" already exists'], $e->getErrors()); + } + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageMatches('/already exists/'); $this->service->create($this->validDefinition()); @@ -174,6 +183,60 @@ public function testDeleteRemovesTheRowAndReturnsIt(): void { $this->assertSame([], $this->configArrays); } + public function testDeletingABuiltinPackKeepsTheRowWhenItsSlugCannotBeRemembered(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueArray')->willReturn([]); + $appConfig->method('setValueArray')->willThrowException(new RuntimeException('app config unavailable')); + $service = new MigrationPackService($this->mapper, new PackDefinitionValidator(), new MappingEngine(), $appConfig); + $this->mapper->method('find')->willReturn($this->builtinPack()); + $this->mapper->expects($this->never())->method('delete'); + + $this->expectException(RuntimeException::class); + $service->delete(3); + } + + public function testSeedingABuiltinPackTakesItsSlugOffTheDeletedList(): void { + $this->configArrays['openregister/' . MigrationPackService::DELETED_BUILTIN_SLUGS_KEY] = ['other-pack', 'test-pack']; + $this->mapper->method('findByPackSlug')->willThrowException(new DoesNotExistException('not found')); + $this->mapper->method('insert')->willReturnCallback(static fn (MigrationPack $pack) => $pack); + + $this->assertTrue($this->service->create($this->validDefinition(), null, true)->getBuiltin()); + $this->assertSame( + ['other-pack'], + $this->configArrays['openregister/' . MigrationPackService::DELETED_BUILTIN_SLUGS_KEY] + ); + } + + public function testAFailedSeedLeavesTheSlugLocked(): void { + $this->configArrays['openregister/' . MigrationPackService::DELETED_BUILTIN_SLUGS_KEY] = ['test-pack']; + $this->mapper->method('findByPackSlug')->willThrowException(new DoesNotExistException('not found')); + $this->mapper->method('insert')->willThrowException(new RuntimeException('insert failed')); + + try { + $this->service->create($this->validDefinition(), null, true); + $this->fail('The insert failure must surface'); + } catch (RuntimeException $e) { + $this->assertSame('insert failed', $e->getMessage()); + } + + $this->assertSame( + ['test-pack'], + $this->configArrays['openregister/' . MigrationPackService::DELETED_BUILTIN_SLUGS_KEY] + ); + } + + public function testAdminCreateUnderADeletedBuiltinSlugKeepsItOnTheList(): void { + $this->configArrays['openregister/' . MigrationPackService::DELETED_BUILTIN_SLUGS_KEY] = ['test-pack']; + $this->mapper->method('findByPackSlug')->willThrowException(new DoesNotExistException('not found')); + $this->mapper->method('insert')->willReturnCallback(static fn (MigrationPack $pack) => $pack); + + $this->assertTrue($this->service->create($this->validDefinition(), 'admin-uid')->getBuiltin()); + $this->assertSame( + ['test-pack'], + $this->configArrays['openregister/' . MigrationPackService::DELETED_BUILTIN_SLUGS_KEY] + ); + } + public function testDeletingABuiltinPackRemembersItsSlugOnce(): void { $this->mapper->method('find')->willReturn($this->builtinPack()); @@ -209,7 +272,7 @@ static function (MigrationPack $pack): MigrationPack { $this->mapper->expects($this->never())->method('update'); $this->service->delete(3); - $imported = $this->service->importDefinition($this->validDefinition(), 'admin-uid'); + $imported = $this->service->importDefinition($this->validDefinition(), 'admin-uid')->pack; $this->assertTrue($imported->getBuiltin()); $this->assertSame('admin-uid', $imported->getOwner()); @@ -302,7 +365,7 @@ public function testImportDefinitionUpdatesABuiltinPackUnderItsOwnSlug(): void { $this->mapper->method('find')->willReturn($builtin); $this->mapper->expects($this->once())->method('update')->willReturnArgument(0); - $pack = $this->service->importDefinition($this->validDefinition(['name' => 'Reimported'])); + $pack = $this->service->importDefinition($this->validDefinition(['name' => 'Reimported']))->pack; $this->assertSame('Reimported', $pack->getName()); } @@ -409,8 +472,9 @@ public function testImportDefinitionCreatesWhenSlugAbsent(): void { $this->mapper->expects($this->once())->method('insert')->willReturnCallback(static fn (MigrationPack $p) => $p); $this->mapper->expects($this->never())->method('update'); - $pack = $this->service->importDefinition($this->validDefinition(), 'admin-uid'); - $this->assertSame('test-pack', $pack->getPackSlug()); + $result = $this->service->importDefinition($this->validDefinition(), 'admin-uid'); + $this->assertSame('test-pack', $result->pack->getPackSlug()); + $this->assertTrue($result->created); } public function testImportDefinitionUpdatesWhenSlugAlreadyExists(): void { @@ -427,7 +491,8 @@ public function testImportDefinitionUpdatesWhenSlugAlreadyExists(): void { $this->mapper->expects($this->once())->method('update')->willReturnArgument(0); $this->mapper->expects($this->never())->method('insert'); - $pack = $this->service->importDefinition($this->validDefinition(['name' => 'Updated Name'])); - $this->assertSame('Updated Name', $pack->getName()); + $result = $this->service->importDefinition($this->validDefinition(['name' => 'Updated Name'])); + $this->assertSame('Updated Name', $result->pack->getName()); + $this->assertFalse($result->created); } } From c76eec754c27f1fd167169d67740512873cb3f0e Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 23:39:48 +0200 Subject: [PATCH 124/183] fix(migration-packs): guard unsaved edits, read the error codes, make the editor accessible - the editor asks before leaving with unsaved changes (route leave, route update and beforeunload), through a new UnsavedChangesDialog - a local t() turns off escaping, so quotes and ampersands in messages show as typed - a yes/no table stores real booleans and refuses other results - the save conflict check also compares the definition and maps a 409/412 to the reload notice; a pack deleted while editing can be saved as a new pack - explainError() reads the error code and details.errors instead of parsing English exception text - the transform button's name includes its visible summary; row moves and removals keep focus and are announced; status notices and hints are exposed to assistive technology - the preview clears when the definition becomes invalid - sampleRowFrom() cannot pollute Object.prototype - a layout-only JSON edit no longer counts as a change - row labels are whole translated strings; dates use the Nextcloud locale Closes review #4602 fe-f1, fe-f2, fe-f3, fe-f4 (client side), fe-f5, fe-f6, fe-f7, fe-f8, fe-f9, fe-f10, fe-f11, fe-f12, fe-f13. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/migrationPack/KeyValueRows.vue | 79 ++- .../migrationPack/PackPreviewPanel.vue | 31 +- .../DeleteMigrationPackDialog.vue | 2 +- src/dialogs/migrationPack/TransformDialog.vue | 87 +++- .../migrationPack/UnsavedChangesDialog.vue | 61 +++ src/services/migrationPacks.js | 96 +++- .../migrationPack/MigrationPackDetail.vue | 345 +++++++++++- .../migrationPack/MigrationPacks.spec.js | 492 +++++++++++++++++- .../migrationPack/MigrationPacksIndex.vue | 43 +- src/views/migrationPack/l10n.js | 36 ++ src/views/migrationPack/packDraft.js | 73 ++- src/views/migrationPack/packDraft.spec.js | 73 +++ 12 files changed, 1316 insertions(+), 102 deletions(-) create mode 100644 src/dialogs/migrationPack/UnsavedChangesDialog.vue create mode 100644 src/views/migrationPack/l10n.js diff --git a/src/components/migrationPack/KeyValueRows.vue b/src/components/migrationPack/KeyValueRows.vue index c458132c83..5235758665 100644 --- a/src/components/migrationPack/KeyValueRows.vue +++ b/src/components/migrationPack/KeyValueRows.vue @@ -3,7 +3,8 @@ A value is text, or JSON when the stored value was not a string (a list, a number, true/false, null). The "JSON" switch per row keeps that type on - save, so `"Saas": ["SaaS"]` stays a list. + save, so `"Saas": ["SaaS"]` stays a list. With `booleanValues` (a yes/no + table) every value is a yes/no choice instead, stored as true or false. @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui SPDX-License-Identifier: EUPL-1.2 @@ -20,7 +21,7 @@ {{ valueLabel }} - + {{ t('openregister', 'As JSON') }} @@ -37,19 +38,44 @@ :value="row.key" type="text" :disabled="disabled" - :aria-label="`${keyLabel} ${index + 1}`" + :aria-label="rowLabel(keyLabel, index)" @input="update(index, { key: $event.target.value })" /> - + + + + - + diff --git a/src/services/migrationPacks.js b/src/services/migrationPacks.js index b55a42cc76..f672280e6b 100644 --- a/src/services/migrationPacks.js +++ b/src/services/migrationPacks.js @@ -13,8 +13,8 @@ */ import { getCurrentUser } from '@nextcloud/auth' import axios from '@nextcloud/axios' -import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' +import { t } from '../views/migrationPack/l10n.js' /** The largest number of sample rows the preview endpoint takes. */ export const PREVIEW_ROW_LIMIT = 20 @@ -90,6 +90,19 @@ export async function updatePack(id, definition) { return response.data } +/** + * Create a pack from a definition. + * + * @param {object} definition The full definition. + * @return {Promise} The created pack. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-create-and-update-must-accept-a-definition-envelope-and-keep-the-route-id + */ +export async function createPack(definition) { + const response = await axios.post(packUrl(), { definition }) + return response.data +} + /** * Delete a pack. * @@ -146,40 +159,81 @@ export async function previewPack(definition, rows) { return response.data?.rows || [] } +/** + * The text this page shows for each error slug of the migration-pack API. + * + * The API answers every error as `{message, error, details?}` (ADR-050), with + * `error` a kebab-case slug. The screens show their own translation of the + * slug; they never read meaning out of the English wording of `message`. + * `migration-pack-error` (an unexpected failure) is left out on purpose: the + * caller's own fallback says what could not be done. + * + * @return {object} Slug to text. + * + * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui + */ +function slugTexts() { + return { + 'invalid-pack': t('openregister', 'The pack is not valid.'), + 'builtin-slug-locked': t( + 'openregister', + 'The slug of a built-in pack cannot change: the app that installed it finds the pack by this slug.', + ), + 'pack-not-found': t('openregister', 'This migration pack does not exist.'), + 'not-authenticated': t( + 'openregister', + 'You are not signed in. Sign in and try again.', + ), + 'admin-required': t( + 'openregister', + 'Only administrators can change migration packs.', + ), + 'too-many-rows': t( + 'openregister', + 'There are too many sample rows to test at once.', + ), + } +} + +/** The shape of an API error slug: lowercase words joined by hyphens. */ +const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/ + /** * A readable explanation of a failed request. * - * The API answers `{message, error, details}`; older responses carry only - * `error`. A validator refusal joins its findings with "; ", which reads badly - * as one sentence, so it is split into a list. + * Reads the slug and `details.errors` of the API's error body. A known slug + * gets this page's own text; an unknown one gets the server's message, which + * the API localises; anything else gets the caller's fallback. A 403 always + * means the user is not an administrator. * * @param {unknown} error The rejected axios error. - * @param {string} fallback The message when the server gave none. + * @param {string} fallback The message when the slug is not one this page knows. + * @param {object} [texts] Slug to text, for the caller's own wording of a slug. * @return {{message: string, details: Array, code: string, status: number}} The explanation. * * @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui */ -export function explainError(error, fallback) { +export function explainError(error, fallback, texts = {}) { const status = error?.response?.status || 0 const body = error?.response?.data || {} - const code = typeof body.error === 'string' ? body.error : '' - let message = typeof body.message === 'string' ? body.message : code - let details = Array.isArray(body.details?.errors) + const code = + typeof body.error === 'string' && SLUG_PATTERN.test(body.error) + ? body.error + : '' + const details = Array.isArray(body.details?.errors) ? body.details.errors.map(String) : [] - const prefix = 'Invalid migration pack definition: ' - if (details.length === 0 && message.startsWith(prefix)) { - details = message.slice(prefix.length).split('; ') - message = t('openregister', 'The pack is not valid.') - } + const known = { ...slugTexts(), ...texts } + let message = fallback if (status === 403) { - message = t( - 'openregister', - 'Only administrators can change migration packs.', - ) - } - if (!message) { - message = fallback + message = known['admin-required'] + } else if (Object.hasOwn(known, code)) { + message = known[code] + } else if (code !== '' && code !== 'migration-pack-error') { + message = + typeof body.message === 'string' && body.message !== '' + ? body.message + : fallback } return { message, details, code, status } } diff --git a/src/views/migrationPack/MigrationPackDetail.vue b/src/views/migrationPack/MigrationPackDetail.vue index 0dba16aafb..2bf1b44421 100644 --- a/src/views/migrationPack/MigrationPackDetail.vue +++ b/src/views/migrationPack/MigrationPackDetail.vue @@ -8,6 +8,9 @@ wins for the same target. Saving an unedited pack sends the stored definition back unchanged, including keys this editor does not show. + Leaving with unsaved changes asks first: a route change through the router + guards, closing or reloading the tab through the browser's own prompt. + @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-pack-changes-must-be-limited-to-administrators-and-logged @spec openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md#requirement-the-slug-of-a-built-in-pack-must-not-change @@ -225,9 +228,15 @@ 'Target field of row {number}', { number: index + 1 }, ) + " + :aria-describedby=" + targetHint(row) + ? hintId(row) + : undefined " />

{{ targetHint(row) }}

@@ -248,14 +257,14 @@ + {{ t( 'openregister', - 'Transform of row {number}', + 'Transform of row {number}:', { number: index + 1 }, ) - " - @click="editingRow = index"> + }} {{ summary(row) }} @@ -264,6 +273,7 @@ class="migration-pack__row-actions"> +
+ {{ announcement }} +
+ @@ -380,12 +398,30 @@ :lang="jsonLang" :linter="jsonLinter" /> - - {{ jsonError }} -
+ +

+ {{ + t( + 'openregister', + 'Someone deleted this pack while you were editing it. Your changes are not saved: save them as a new pack, or copy them from the JSON.', + ) + }} +

+
+ + {{ t('openregister', 'Save as a new pack') }} + + + {{ t('openregister', 'Show the JSON') }} + +
+
+ + {{ jsonError }} +

{{ saveProblem.message }}

    @@ -421,7 +457,7 @@
@@ -448,12 +492,16 @@ :readOnly="!isAdmin" @apply="applyTransform" @close="editingRow = null" /> + +