Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ public function register(IRegistrationContext $context): void {
// it wires everything else by hand (WOO-559). Without it the SPA
// catch-all answered the store page's JSON call with HTML 200.
(new StorePlaneRegistrar())->register($context);

// OpenRegister asks which published forms write into a schema that
// is about to change (decision 179). Portaliq answers for its form
// bindings.
(new FormDependentsRegistrar())->register($context);
}//end register()

/**
Expand Down
55 changes: 55 additions & 0 deletions lib/AppInfo/FormDependentsRegistrar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/**
* Portaliq Form Dependents Registrar
*
* Puts portaliq's answer on OpenRegister's question "which published forms
* write into this schema?" (decision 179). Its own small class, so a test can
* hand it a registration context and see the listener land on the event.
*
* @category AppInfo
* @package OCA\Portaliq\AppInfo
*
* @author Conduction Development Team <info@conduction.nl>
* @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. <info@conduction.nl>
* SPDX-License-Identifier: EUPL-1.2
*
* @link https://conduction.nl
*
* @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007
*/

declare(strict_types=1);

namespace OCA\Portaliq\AppInfo;

use OCA\OpenRegister\Event\FormDestinationDependentsEvent;
use OCA\Portaliq\Listener\FormDestinationDependentsListener;
use OCP\AppFramework\Bootstrap\IRegistrationContext;

/**
* Registers the listener that announces portaliq's published form bindings.
*
* @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007
*/
class FormDependentsRegistrar {
/**
* Register the listener on OpenRegister's event.
*
* The event is named by its class constant, which needs no autoload: on
* an instance without OpenRegister nothing dispatches it and the listener
* is never built.
*
* @param IRegistrationContext $context The registration context.
*
* @return void
*
* @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007
*/
public function register(IRegistrationContext $context): void {
$context->registerEventListener(FormDestinationDependentsEvent::class, FormDestinationDependentsListener::class);
}//end register()
}//end class
250 changes: 250 additions & 0 deletions lib/Listener/FormDestinationDependentsListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
<?php

/**
* Portaliq Form Destination Dependents Listener
*
* Before OpenRegister saves a schema it asks every app which published forms
* write into it (decision 179). Portaliq answers with one form per published
* form binding whose destination is that schema, with the mapping a submit
* of that form sends. OpenRegister judges each against the schema as it is
* about to become, and either refuses the save or has the form unpublished
* here (decision 181).
*
* @category Listener
* @package OCA\Portaliq\Listener
*
* @author Conduction Development Team <info@conduction.nl>
* @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. <info@conduction.nl>
* SPDX-License-Identifier: EUPL-1.2
*
* @link https://conduction.nl
*
* @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007
*/

declare(strict_types=1);

namespace OCA\Portaliq\Listener;

use OCA\OpenRegister\Event\FormDestinationDependentsEvent;
use OCA\OpenRegister\Service\Form\FormDependent;
use OCA\Portaliq\AppInfo\Application;
use OCA\Portaliq\Service\Intake\DestinationMapping;
use OCA\Portaliq\Service\Intake\PortalFormBindingResolver;
use OCA\Portaliq\Service\PortalObjectWriter;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Throwable;

/**
* Announces each published form binding that writes into the schema about to change.
*
* @template-implements IEventListener<Event>
*
* @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007
*/
class FormDestinationDependentsListener implements IEventListener {
private const REGISTER = 'portaliq';

private const BINDINGS = 'portalFormBinding';

/**
* Constructor.
*
* @param PortalFormBindingResolver $bindings Reads the published bindings and what each renders.
* @param DestinationMapping $mapping Builds the mapping a submit of a binding sends.
* @param PortalObjectWriter $writer Sets a binding back to draft.
* @param LoggerInterface $logger The logger.
*/
public function __construct(
private readonly PortalFormBindingResolver $bindings,
private readonly DestinationMapping $mapping,
private readonly PortalObjectWriter $writer,
private readonly LoggerInterface $logger,
) {
}//end __construct()

/**
* Add one form per published binding whose destination is the event's schema.
*
* @param Event $event The dispatched event.
*
* @return void
*
* @listener-placement inline answer — the asker reads the event right after dispatch; a deferred answer comes after the save is judged.
* @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007
*/
public function handle(Event $event): void {
if (($event instanceof FormDestinationDependentsEvent) === false) {
return;
}

try {
$schema = $event->getSchema();
$names = $this->namesOf(schema: $schema);
$properties = ['properties' => $schema->getProperties()];
foreach ($this->bindings->allPublishedBindings() as $binding) {
$form = $this->formFor(binding: $binding, names: $names, schema: $properties);
if ($form !== null) {
$event->addForm($form);
}
}
} catch (Throwable $e) {
// A schema save must not fail on portaliq's answer. The price is
// that this save is judged without portaliq's forms, so say so loudly.
$this->logger->error(
'Portaliq: could not list the forms that write into a schema about to change',
['reason' => $e->getMessage()]
);
}
}//end handle()

/**
* The form a binding stands for, or null when it does not write into this schema.
*
* A binding whose form lives on another host, and one that renders no
* form, write nothing and are left out.
*
* @param array<string, mixed> $binding The published binding.
* @param array<int, string> $names The names the schema answers to.
* @param array<string, mixed> $schema The proposed schema, with `properties`.
*
* @return FormDependent|null
*/
private function formFor(array $binding, array $names, array $schema): ?FormDependent {
$destination = $this->mapping->destinationOf(binding: $binding);
$bindingId = $this->idOf(row: $binding);
if ($destination === null || $bindingId === '' || in_array($destination['schema'], $names, true) === false) {
return null;
}

$render = $this->bindings->render(binding: $binding);
if ((string)($render['kind'] ?? '') === PortalFormBindingResolver::KIND_EXTERNAL || ($render['resolvesToNoForm'] ?? false) === true) {
return null;
}

$portal = (string)($binding['portal'] ?? '');

return new FormDependent(
id: $bindingId,
app: Application::APP_ID,
title: $this->titleOf(binding: $binding, render: $render),
author: $this->authorOf(binding: $binding),
mapping: $this->mapping->mappingFor(
binding: $binding,
fields: (array)($render['fields'] ?? []),
schema: $schema,
subjectRef: DestinationMapping::SUBJECT_PLACEHOLDER
),
unpublish: function () use ($bindingId, $portal): void {
$this->unpublish(bindingId: $bindingId, portal: $portal);
},
// Every portal submit reaches OpenRegister as an anonymous write
// (DestinationSubmit), so the form is judged as a public one.
audience: 'public',
);
}//end formFor()

/**
* The name an author knows the form by: the form's own, else the binding's, else its portal and route.
*
* @param array<string, mixed> $binding The binding.
* @param array<string, mixed> $render What it renders to.
*
* @return string
*/
private function titleOf(array $binding, array $render): string {
$candidates = [
(string)($render['formName'] ?? ''),
(string)($binding['formName'] ?? ''),
(string)($binding['portal'] ?? '') . ' ' . (string)($binding['route'] ?? ''),
];
foreach ($candidates as $candidate) {
if (trim($candidate) !== '') {
return trim($candidate);
}
}

return '';
}//end titleOf()

/**
* The user to tell when the form is unpublished: the binding's owner, when it has one.
*
* @param array<string, mixed> $binding The binding.
*
* @return string|null
*/
private function authorOf(array $binding): ?string {
$owner = ($binding['@self']['owner'] ?? null);
if (is_string($owner) === true && $owner !== '') {
return $owner;
}

return null;
}//end authorOf()

/**
* Set a binding back to draft: it stops rendering, and keeps everything else.
*
* @param string $bindingId The binding.
* @param string $portal The portal it belongs to.
*
* @return void
*
* @throws RuntimeException When the binding could not be written, so OpenRegister reports the failure.
*/
private function unpublish(string $bindingId, string $portal): void {
$written = $this->writer->updateObject(
register: self::REGISTER,
schema: self::BINDINGS,
scopeField: 'portal',
subjectRef: $portal,
organisation: '',
id: $bindingId,
data: ['status' => 'draft']
);
if ($written === null) {
throw new RuntimeException('The form binding ' . $bindingId . ' could not be set to draft.');
}
}//end unpublish()

/**
* The names a binding may use for this schema: its slug, uuid and id.
*
* @param object $schema The proposed schema.
*
* @return array<int, string>
*/
private function namesOf(object $schema): array {
$names = [];
foreach ([$schema->getSlug(), $schema->getUuid(), $schema->getId()] as $name) {
if ($name !== null && (string)$name !== '') {
$names[] = (string)$name;
}
}

return $names;
}//end namesOf()

/**
* @param array<string, mixed> $row A stored row.
*
* @return string Its identifier, or ''.
*/
private function idOf(array $row): string {
$self = (array)($row['@self'] ?? []);
foreach ([($row['id'] ?? null), ($row['uuid'] ?? null), ($self['id'] ?? null), ($self['uuid'] ?? null)] as $candidate) {
if ((is_string($candidate) === true || is_int($candidate) === true) && (string)$candidate !== '') {
return (string)$candidate;
}
}

return '';
}//end idOf()
}//end class
29 changes: 29 additions & 0 deletions lib/Service/Intake/PortalFormBindingResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,35 @@ public function publishedBindings(string $portal): array {
return $published;
}//end publishedBindings()

/**
* Every published binding, of every portal.
*
* For the one caller that asks about the whole instance: a destination
* schema is about to change and its forms must be found, whichever
* portal shows them. Never reached from a visitor's request.
*
* @return array<int, array<string, mixed>>
*
* @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007
*/
public function allPublishedBindings(): array {
$rows = $this->reader->readCollection(
register: self::REGISTER,
schema: self::SCHEMA,
scopeField: 'status',
subjectRef: 'published',
organisation: '',
limit: 500
);

return array_values(
array_filter(
$rows,
static fn ($row): bool => is_array($row) === true && (string)($row['status'] ?? 'draft') === 'published'
)
);
}//end allPublishedBindings()

/**
* Whether a case type is inside the scope a portal declared.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

### Requirement: A form binding names its destination and is valid against it (REQ-PIFO-007)

A `portalFormBinding` SHALL store `destination { register, schema }`, and MAY name `subjectField` (the property that holds the signed-in resident's subject reference), `paymentField` (the property the payment state is read from), `fields` (a form field written into a differently named property) and `fixed` (values every submit writes). Saving or publishing a binding SHALL run OpenRegister's form destination validator over the bound form. A binding with findings SHALL NOT be published, from the first release (decision 181); a binding in `draft` MAY be saved with its findings, which the preview shows (decision 189).
A `portalFormBinding` SHALL store `destination { register, schema }`, and MAY name `subjectField` (the property that holds the signed-in resident's subject reference), `paymentField` (the property the payment state is read from), `fields` (a form field written into a differently named property) and `fixed` (values every submit writes). Saving or publishing a binding SHALL run OpenRegister's form destination validator over the bound form. A binding with findings SHALL NOT be published, from the first release (decision 181); a binding in `draft` MAY be saved with its findings, which the preview shows (decision 189). When OpenRegister asks which published forms write into a schema that is about to change (`FormDestinationDependentsEvent`), portaliq SHALL announce every published binding, of every portal, whose destination is that schema and that renders a form here, and SHALL set a binding back to `draft` when OpenRegister has it unpublished (decision 181).

#### Scenario: A draft binding saves with its findings
- **GIVEN** a binding in status `draft` whose form misses a required property of its destination
Expand All @@ -19,6 +19,12 @@ A `portalFormBinding` SHALL store `destination { register, schema }`, and MAY na
- **WHEN** an administrator publishes the binding
- **THEN** the publish is refused and the preview names `communicationChannel` as `required-unmapped`

#### Scenario: The destination schema changes under a published binding
- **GIVEN** a published binding whose form writes `toelichting` into its destination schema
- **WHEN** OpenRegister is about to save that schema without `toelichting` and asks which published forms write into it
- **THEN** portaliq announces the binding as a form of app `portaliq`, with the mapping a submit of it sends and audience `public`
- **AND** when OpenRegister has the form unpublished, the binding goes back to `draft` and keeps everything else

### Requirement: A saved form is a draft of its destination object (REQ-PIFO-008)

"Save and carry on later" SHALL save the destination object with OpenRegister metadata status `draft` (decision 180). The draft MAY miss required answers. It SHALL NOT hold a type-invalid answer; such an answer SHALL be refused on its field. Sending SHALL move the object out of `draft` through OpenRegister's submit service, with full validation; only then SHALL it get its reference and received moment. portaliq SHALL keep no draft store of its own. The draft's owner SHALL be `portal:<subjectRef>` (decision 195): only that signed-in resident reads, saves, sends or discards it. A draft SHALL read as gone, and be discarded, once the action's retention has passed since its last change (`updatedAt`): `draft.retentionDays`, 30 days by default (decision 201). Picked files SHALL be uploaded into the draft object. The site SHALL label a saved draft "Voorlopig" (decision 200).
Expand Down
6 changes: 6 additions & 0 deletions openspec/changes/submit-creates-the-case-directly/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@
- [x] 6.3 `intake-conditional-questions-and-drafts`: its draft requirements move to draft destination objects (decision 180); open PR #1152 follows the same rule
- [x] 6.4 `embedded-intake-form` REQ-EIF-003/004, `intake-pay-on-submit` REQ-IPS-003/005, `form-statements-intro-and-confirmation-mail` REQ-FCI-003, `resident-identity-in-forms` REQ-RIF-002/005, `portal-shared-runtime` task 69

## 6b. Answer OpenRegister when a destination schema changes

- [x] 6b.1 `lib/Listener/FormDestinationDependentsListener.php` adds one `FormDependent` per published `portalFormBinding` whose destination is the event's schema (slug, uuid or id), with the mapping `DestinationMapping::mappingFor()` builds against the proposed schema, audience `public`, and an unpublish that sets the binding to `draft`. An external form and a binding that renders no form are left out. Registered by `lib/AppInfo/FormDependentsRegistrar.php`, called from `Application::register()`. `PortalFormBindingResolver::allPublishedBindings()` reads every portal's published bindings.
- PHPUnit `FormDestinationDependentsListenerTest` (OpenRegister's real event, schema, validator and `FormDependentsCheck`), `FormDependentsRegistrarTest::testTheAppRegistersTheListener`, `PortalFormBindingResolverTest::testEveryPortalsPublishedBindingsAreListedAndNoDraft`
- [ ] 6b.2 (live pass, decision 139) On an instance with dossiq: publish a binding into a case schema, remove a property its form writes, save the schema. The save answers with the binding under `affected`, the binding reads `draft`, and its owner gets the notification

## 7. Verification

- [ ] 7.1 `composer check:strict`, `npm run lint`, `openspec validate submit-creates-the-case-directly --strict`
2 changes: 2 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@
<referencedClass name="OCA\OpenRegister\Event\ObjectUpdatedEvent"/>
<referencedClass name="OCA\OpenRegister\Event\ObjectDeletingEvent"/>
<referencedClass name="OCA\OpenRegister\Event\ObjectDeletedEvent"/>
<referencedClass name="OCA\OpenRegister\Event\FormDestinationDependentsEvent"/>
<referencedClass name="OCA\OpenRegister\Service\Form\FormDependent"/>
<referencedClass name="OCA\OpenRegister\Service\ObjectService"/>
<referencedClass name="OCA\OpenRegister\Service\ConfigurationService"/>
<referencedClass name="OCA\OpenRegister\Service\RegisterService"/>
Expand Down
Loading
Loading